diff --git a/.gitignore b/.gitignore index a84107a..3d5ce3c 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,5 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +/design \ No newline at end of file diff --git a/bun.lockb b/bun.lockb index 36571e2..843e90c 100644 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/next.config.mjs b/next.config.mjs deleted file mode 100644 index 4678774..0000000 --- a/next.config.mjs +++ /dev/null @@ -1,4 +0,0 @@ -/** @type {import('next').NextConfig} */ -const nextConfig = {}; - -export default nextConfig; diff --git a/next.config.ts b/next.config.ts new file mode 100644 index 0000000..cd503ba --- /dev/null +++ b/next.config.ts @@ -0,0 +1,32 @@ +import type { NextConfig } from 'next' + +const config: NextConfig = { + reactStrictMode: true, + poweredByHeader: false, + output: 'standalone', + trailingSlash: false, + skipTrailingSlashRedirect: true, + images: { + remotePatterns: [ + { + protocol: 'https', + hostname: '**' + } + ] + }, + experimental: { + optimizePackageImports: ['tailwindcss'] + }, + env: { + APP_PORT: process.env['APP_PORT'], + APP_URL: process.env['APP_URL'], + COOKIE_DOMAIN: process.env['COOKIE_DOMAIN'], + API_URL: process.env['API_URL'], + STORAGE_URL: process.env['STORAGE_URL'], + TURNSTILE_SITE_KEY: process.env['TURNSTILE_SITE_KEY'], + YANDEX_METRIKA_ID: process.env['YANDEX_METRIKA_ID'], + GOOGLE_ANALYTICS_ID: process.env['GOOGLE_ANALYTICS_ID'] + } +} + +export default config diff --git a/orval.config.ts b/orval.config.ts new file mode 100644 index 0000000..a1a9074 --- /dev/null +++ b/orval.config.ts @@ -0,0 +1,13 @@ +import { config } from 'dotenv' +import { defineConfig } from 'orval' + +config({ path: '.env' }) + +export default defineConfig({ + client: { + input: `${process.env['API_DOCS']}/json`, + output: { + schemas: './src/generated' + } + } +}) diff --git a/package.json b/package.json index e40e83a..f2093a1 100644 --- a/package.json +++ b/package.json @@ -6,25 +6,57 @@ "dev": "tsx -r dotenv/config ./src/server/server.ts", "build": "next build", "start": "next start -p 14701", - "lint": "next lint" + "lint": "next lint", + "generate": "orval --config ./orval.config.ts" }, "dependencies": { - "@radix-ui/react-slot": "^1.1.1", + "@hookform/resolvers": "^3.10.0", + "@kinescope/react-kinescope-player": "^0.5.3", + "@next/third-parties": "^15.1.7", + "@radix-ui/react-accordion": "^1.2.3", + "@radix-ui/react-alert-dialog": "^1.1.6", + "@radix-ui/react-avatar": "^1.1.3", + "@radix-ui/react-collapsible": "^1.1.3", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-dropdown-menu": "^2.1.6", + "@radix-ui/react-label": "^2.1.1", + "@radix-ui/react-progress": "^1.1.2", + "@radix-ui/react-scroll-area": "^1.2.3", + "@radix-ui/react-separator": "^1.1.2", + "@radix-ui/react-slot": "^1.1.2", + "@radix-ui/react-tabs": "^1.1.3", + "@radix-ui/react-tooltip": "^1.1.8", + "@tanstack/react-query": "^5.66.0", + "axios": "^1.8.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dotenv": "^16.4.7", - "lucide-react": "^0.474.0", + "framer-motion": "^12.4.2", + "geist": "^1.3.1", + "input-otp": "^1.4.2", + "js-cookie": "^3.0.5", + "lucide-react": "^0.483.0", "next": "15.1.6", + "next-themes": "^0.4.4", "react": "19.0.0", + "react-circular-progressbar": "^2.2.0", "react-dom": "19.0.0", - "tailwind-merge": "^3.0.1", - "tailwindcss-animate": "^1.0.7" + "react-hook-form": "^7.54.2", + "react-icons": "^5.4.0", + "react-turnstile": "^1.1.4", + "recharts": "^2.15.1", + "sonner": "^1.7.4", + "tailwind-merge": "^3.0.2", + "tailwindcss-animate": "^1.0.7", + "zod": "^3.24.1" }, "devDependencies": { "@trivago/prettier-plugin-sort-imports": "^5.2.2", + "@types/js-cookie": "^3.0.6", "@types/node": "^20", "@types/react": "19", "@types/react-dom": "19", + "orval": "7.4.1", "postcss": "^8", "prettier": "^3.4.2", "prettier-plugin-tailwindcss": "^0.6.11", diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..a0451aa Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/next.svg b/public/next.svg deleted file mode 100644 index 5174b28..0000000 --- a/public/next.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/touch-icons/192x192.png b/public/touch-icons/192x192.png new file mode 100644 index 0000000..d6808b7 Binary files /dev/null and b/public/touch-icons/192x192.png differ diff --git a/public/touch-icons/512x512.png b/public/touch-icons/512x512.png new file mode 100644 index 0000000..0dce493 Binary files /dev/null and b/public/touch-icons/512x512.png differ diff --git a/public/vercel.svg b/public/vercel.svg deleted file mode 100644 index d2f8422..0000000 --- a/public/vercel.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/api/account.ts b/src/api/account.ts new file mode 100644 index 0000000..7509e93 --- /dev/null +++ b/src/api/account.ts @@ -0,0 +1,44 @@ +import type { + AccountResponse, + ChangeEmailRequest, + ChangePasswordRequest, + CreateUserRequest, + CreateUserResponse, + PasswordResetRequest, + SendPasswordResetRequest +} from '../generated' +import { setSessionToken } from '../lib/cookies/session' + +import { api, instance } from './instance' + +export const getMe = async () => + await instance + .get('/auth/account') + .then(response => response.data) + +export const createAccount = async (data: CreateUserRequest) => { + const response = await api.post( + '/auth/account/create', + data + ) + + if (response.data.token) { + setSessionToken(response.data.token) + + instance.defaults.headers['X-Session-Token'] = response.data.token + } + + return response.data +} + +export const sendPasswordReset = (data: SendPasswordResetRequest) => + api.post('/auth/account/reset_password', data) + +export const passwordReset = (data: PasswordResetRequest) => + api.patch('/auth/account/reset_password', data) + +export const changeEmail = (data: ChangeEmailRequest) => + instance.patch('/auth/account/change/email', data) + +export const changePassword = (data: ChangePasswordRequest) => + instance.patch('/auth/account/change/password', data) diff --git a/src/api/course.ts b/src/api/course.ts new file mode 100644 index 0000000..5cf5d7b --- /dev/null +++ b/src/api/course.ts @@ -0,0 +1,24 @@ +import type { CourseResponse, LessonResponse } from '../generated' + +import { api, instance } from './instance' + +export const getCourses = async () => + await api.get('/courses').then(response => response.data) + +export const getPopularCourses = async () => + await api + .get('/courses/popular') + .then(response => response.data) + +export const getCourse = async (slug: string) => + await api + .get(`/courses/${slug}`) + .then(response => response.data) + +export const getCourseLessons = async (id: string) => + await instance + .get(`/courses/${id}/lessons`) + .then(response => response.data) + +export const incrementCourseViews = (id: string) => + api.patch(`/courses/${id}/views`) diff --git a/src/api/index.ts b/src/api/index.ts new file mode 100644 index 0000000..d698c63 --- /dev/null +++ b/src/api/index.ts @@ -0,0 +1,8 @@ +export * from './account' +export * from './course' +export * from './instance' +export * from './lesson' +export * from './mfa' +export * from './progress' +export * from './session' +export * from './users' diff --git a/src/api/instance.ts b/src/api/instance.ts new file mode 100644 index 0000000..6e10b8f --- /dev/null +++ b/src/api/instance.ts @@ -0,0 +1,15 @@ +import axios from 'axios' + +import { APP_CONFIG } from '../constants/app' +import { getSessionToken } from '../lib/cookies/session' + +export const api = axios.create({ + baseURL: APP_CONFIG.apiUrl +}) + +export const instance = axios.create({ + baseURL: APP_CONFIG.apiUrl, + headers: { + 'X-Session-Token': getSessionToken() ?? '' + } +}) diff --git a/src/api/lesson.ts b/src/api/lesson.ts new file mode 100644 index 0000000..61e9768 --- /dev/null +++ b/src/api/lesson.ts @@ -0,0 +1,16 @@ +import type { CreateLessonRequest, LessonResponse } from '../generated' + +import { api, instance } from './instance' + +export const getLesson = async (slug: string) => + await api + .get(`/lessons/${slug}`) + .then(response => response.data) + +export const getCompletedLessons = async (courseId: string) => + await instance + .get(`/lessons/${courseId}/progress`) + .then(response => response.data) + +export const createLesson = (data: CreateLessonRequest) => + instance.post('/lessons', data) diff --git a/src/api/mfa.ts b/src/api/mfa.ts new file mode 100644 index 0000000..bf796e2 --- /dev/null +++ b/src/api/mfa.ts @@ -0,0 +1,37 @@ +import type { + LoginSessionResponse, + MfaStatusResponse, + MfaVerifyRequest, + TotpDisableRequest, + TotpEnableRequest, + TotpGenerateSecretResponse +} from '../generated' + +import { api, instance } from './instance' + +export const fetchMfaStatus = async () => + await instance + .get('/auth/mfa') + .then(response => response.data) + +export const fetchRecovery = async () => + await instance + .get('/auth/mfa/recovery') + .then(response => response.data) + +export const regenerateRecovery = () => + instance.patch('/auth/mfa/recovery') + +export const totpEnable = (data: TotpEnableRequest) => + instance.put('/auth/mfa/totp', data) + +export const totpGenerateSecret = async () => + await instance + .post('/auth/mfa/totp') + .then(response => response.data) + +export const totpDisable = (data: TotpDisableRequest) => + instance.delete('/auth/mfa/totp', { data }) + +export const verifyMfa = (data: MfaVerifyRequest) => + api.post('/auth/mfa/verify', data) diff --git a/src/api/progress.ts b/src/api/progress.ts new file mode 100644 index 0000000..7615b5b --- /dev/null +++ b/src/api/progress.ts @@ -0,0 +1,16 @@ +import type { + CreateProgressRequest, + CreateProgressResponse +} from '../generated' + +import { instance } from './instance' + +export const createProgress = async (data: CreateProgressRequest) => + await instance + .put('/progress', data) + .then(response => response.data) + +export const getCourseProgress = async (courseId: string) => + await instance + .get(`/progress/${courseId}`) + .then(response => response.data) diff --git a/src/api/session.ts b/src/api/session.ts new file mode 100644 index 0000000..c789fe6 --- /dev/null +++ b/src/api/session.ts @@ -0,0 +1,31 @@ +import type { + LoginMfaResponse, + LoginRequest, + LoginSessionResponse, + SessionResponse +} from '../generated' +import { removeSessionToken } from '../lib/cookies/session' + +import { api, instance } from './instance' + +export const login = async (data: LoginRequest) => + await api + .post< + LoginSessionResponse | LoginMfaResponse + >('/auth/session/login', data) + .then(response => response.data) + +export const logout = async () => + await instance + .post('/auth/session/logout') + .then(() => removeSessionToken()) + +export const getSessions = async () => + await instance + .get('/auth/session/all') + .then(response => response.data) + +export const revokeSession = (id: string) => + instance.delete(`/auth/session/${id}`) + +export const removeAllSessions = () => instance.delete('/auth/session/all') diff --git a/src/api/users.ts b/src/api/users.ts new file mode 100644 index 0000000..f2a0668 --- /dev/null +++ b/src/api/users.ts @@ -0,0 +1,33 @@ +import type { + LeaderResponse, + MeProgressResponse, + MeStatisticsResponse, + PatchUserRequest +} from '../generated' + +import { api, instance } from './instance' + +export const getLeaders = async () => + await api + .get('/users/leaders') + .then(response => response.data) + +export const getMeStatistics = async () => + await instance + .get('/users/@me/statistics') + .then(response => response.data) + +export const getMeProgress = async () => + await instance + .get('/users/@me/progress') + .then(response => response.data) + +export const changeAvatar = (formData: FormData) => + instance.patch('/users/@me/avatar', formData, { + headers: { + 'Content-Type': 'multipart/form-data' + } + }) + +export const patchUser = (data: PatchUserRequest) => + instance.patch('/users/@me', data) diff --git a/src/app/(public)/about/page.tsx b/src/app/(public)/about/page.tsx new file mode 100644 index 0000000..f83fdf4 --- /dev/null +++ b/src/app/(public)/about/page.tsx @@ -0,0 +1,70 @@ +import type { Metadata } from 'next' + +export const metadata: Metadata = { + title: 'Об основателе' +} + +export default function AboutPage() { + return ( +
+
+
+

+ Об основателе +

+
+

+ Меня зовут Вадим, и я — веб-разработчик. В свои 14 + лет я уже успел создать свою образовательную + платформу{' '} + TeaCoder и + запустить YouTube-канал, где публикую видеоуроки по + веб-разработке. +

+

+ Моя история началась в 11 лет, когда мой папа + записал меня на офлайн-курсы по созданию сайтов. + Честно говоря, с первого урока мне не очень + понравилось, и я не был уверен, что это то, чем я + хочу заниматься. Однако я решил продолжить, и + продолжил ходить на эти курсы. Мы начали с простого + конструктора сайтов Tilda, а затем я освоил основы + HTML и CSS. Этот путь длился целый год. +

+

+ В 12 лет я продолжил обучаться на курсах по Python. + Несмотря на то, что это был мой первый язык + программирования и было довольно тяжело, я понял, + насколько увлекательным может быть процесс создания + программ. После этого я начал изучать JavaScript и + PHP. В то время я был еще новичком, но это не + остановило меня — я продолжил изучать и углублять + свои знания самостоятельно, создавая проекты и решая + задачи. +

+

+ Вскоре мне стало интересно работать с фреймворками, + и я освоил React JS. Это позволило мне расширить + горизонты и начать создавать более сложные и + динамичные приложения. +

+

+ Сегодня я рад поделиться своими знаниями и опытом с + другими. Моя платформа{' '} + TeaCoder{' '} + помогает начинающим веб-разработчикам погрузиться в + мир программирования и создать свои первые проекты. +

+

+ Каждый день я стремлюсь учиться и открывать для себя + что-то новое в мире технологий, и мне очень нравится + то, чем я занимаюсь. Надеюсь, мой опыт вдохновит вас + на создание собственных проектов и карьеру в + веб-разработке! +

+
+
+
+
+ ) +} diff --git a/src/app/(public)/courses/[slug]/page.tsx b/src/app/(public)/courses/[slug]/page.tsx new file mode 100644 index 0000000..aeeef03 --- /dev/null +++ b/src/app/(public)/courses/[slug]/page.tsx @@ -0,0 +1,75 @@ +import type { Metadata } from 'next' +import { cookies } from 'next/headers' +import { notFound } from 'next/navigation' + +import { api, getCourse, getCourseLessons, getCourses } from '@/src/api' +import { CourseDetails } from '@/src/components/course/course-details' +import { CourseProvider } from '@/src/components/providers/course-provider' +import { getMediaSource } from '@/src/lib/utils' + +export async function generateStaticParams() { + const courses = await getCourses() + + const paths = courses.map(course => { + return { + params: { slug: course.slug } + } + }) + + return paths +} + +export async function generateMetadata({ + params +}: { + params: Promise<{ slug: string }> +}): Promise { + const { slug } = await params + + const course = await getCourse(slug).catch(error => { + notFound() + }) + + return { + title: course.title, + description: course.description, + openGraph: { + images: [ + { + url: getMediaSource(course.thumbnail ?? '', 'courses'), + alt: course.title + } + ] + }, + twitter: { + title: course.title, + description: course.description ?? '', + images: [ + { + url: getMediaSource(course.thumbnail ?? '', 'courses'), + alt: course.title + } + ] + } + } +} + +export default async function CoursePage({ + params +}: { + params: Promise<{ slug: string }> +}) { + const { slug } = await params + + const course = await getCourse(slug).catch(error => { + notFound() + }) + + const lessons = await getCourseLessons(course.id) + + return ( + + + + ) +} diff --git a/src/app/(public)/courses/page.tsx b/src/app/(public)/courses/page.tsx new file mode 100644 index 0000000..7bbf624 --- /dev/null +++ b/src/app/(public)/courses/page.tsx @@ -0,0 +1,39 @@ +import { BookOpen } from 'lucide-react' +import type { Metadata } from 'next' +import Image from 'next/image' +import Link from 'next/link' +import { FaYoutube } from 'react-icons/fa6' + +import { getCourses } from '@/src/api' +import { CourseCard } from '@/src/components/course/course-card' +import { Badge } from '@/src/components/ui/badge' +import { getMediaSource } from '@/src/lib/utils' + +export const metadata: Metadata = { + title: 'Курсы', + description: + 'Здесь собраны курсы по веб-разработке, которые помогут вам освоить самые востребованные технологии и инструменты.' +} + +export default async function CoursesPage() { + const courses = await getCourses() + + return ( +
+
+

+ Курсы +

+

+ Здесь собраны курсы по веб-разработке, которые помогут вам + освоить самые востребованные технологии и инструменты. +

+
+
+ {courses.map((course, index) => ( + + ))} +
+
+ ) +} diff --git a/src/app/(public)/document/privacy-policy/page.tsx b/src/app/(public)/document/privacy-policy/page.tsx new file mode 100644 index 0000000..a44fe9e --- /dev/null +++ b/src/app/(public)/document/privacy-policy/page.tsx @@ -0,0 +1,302 @@ +import type { Metadata } from 'next' +import Link from 'next/link' + +export const metadata: Metadata = { + title: 'Политика конфиденциальности' +} + +export default function PrivacyPage() { + return ( +
+
+
+

+ Политика конфиденциальности +

+
+

+ Политика обработки персональных данных (далее – + Политика или Политика конфиденциальности) + разработана в соответствии с Федеральным законом от + 27.07.2006. №152-ФЗ «О персональных данных» (далее – + ФЗ-152). +

+

+ 1. Общие положения +

+

+ 1.1. Настоящая Политика является неотъемлемой частью + Пользовательского соглашения (далее — «Соглашение»), + размещённого и/или доступного в сети Интернет по + адресу:{' '} + + https://teacoder.ru/document/terms-of-use + + , а также иных заключаемых с Пользователем + договоров, когда это прямо предусмотрено их + условиями. +

+

+ 1.2. Заключая Соглашение вы свободно, своей волей и + в своих интересах даёте бессрочное письменное + согласие на любые способы обработки своих + персональных данных, включая любое действие + (операцию) или совокупность действий (операций), + совершаемых с использованием средств автоматизации + или без использования таких средств с персональными + данными, в том числе сбор, запись, систематизацию, + накопление, хранение, уточнение (обновление, + изменение), извлечение, использование, передачу + (распространение, предоставление, доступ), + обезличивание, блокирование, удаление, уничтожение + персональных данных согласно Федеральному закону от + 27 июля 2006 г. № 152-ФЗ «О персональных данных» в + установленных настоящей Политикой целях. +

+

+ 1.3. К настоящей Политике, включая толкование её + положений и порядок принятия, исполнения, изменения + и прекращения, подлежит применению законодательство + Российской Федерации. +

+

+ 1.4. Здесь и далее в Политике используются термины и + определения, предусмотренные Соглашением, а также + иными заключаемыми с Пользователем договорами, если + иное не предусмотрено настоящей Политикой или не + вытекает из её существа. В иных случаях толкование + применяемого в Политике термина производится в + соответствии с действующим законодательством + Российской Федерации, обычаями делового оборота, или + научной доктриной. +

+

+ 2. Персональная информация +

+

+ 2.1. Под персональной информацией в настоящей + Политике понимается: +

+
    +
  • + Информация, которую Пользователь предоставляет о + себе самостоятельно при регистрации или + авторизации, а также в процессе дальнейшего + использования Сайта, включая персональные данные + Пользователя. +
  • +
  • + Данные, которые передаются в автоматическом + режиме в зависимости от настроек программного + обеспечения Пользователя, включая, но не + ограничиваясь: IP-адрес, cookie, данные об + используемом Пользователем программном + обеспечении и оборудовании для работы в сети + связи, включая Интернет, параметрах и настройках + интернет-браузеров, передаваемой и получаемой с + использованием Сервиса информации и материалах. +
  • +
+

+ 2.2. Администрация не несёт ответственности за + порядок использования Персональной информации + Пользователя третьими лицами, с которыми + Пользователь взаимодействует в рамках использования + Сайта и предоставляемого на его основе Сервиса. +

+

+ 2.3. Пользователь осознаёт и принимает возможность + размещения на страницах Сайта программного + обеспечения третьих лиц, в результате чего такие + лица могут получать указанные в п.2.1 обезличенные + данные. К указанному программному обеспечению + третьих лиц среди прочего могут относиться: +

+
    +
  • + Системы по сбору статистики посещений (например, + счётчики Яндекс Метрика, Google Analytics и т. + д.); +
  • +
  • + Социальные плагины (блоки) социальных сетей + (например, Facebook и т. п.); +
  • +
  • + Системы баннеропоказов (например, AdRiver и т. + п.); +
  • +
  • + Другие системы сбора обезличенной информации. +
  • +
+

+ Пользователь вправе самостоятельно ограничить сбор + такой информации третьими лицами, используя + стандартные настройки конфиденциальности + применяемого им для работы с Сайтом + интернет-браузера. +

+

+ 2.4. Администрация вправе устанавливать требования к + составу Персональной информации Пользователя, + которая должна обязательно предоставляться для + использования Сайта и основанного на нём Сервиса. + Если определённая информация не помечена + Администрацией как обязательная, её предоставление + или раскрытие осуществляется Пользователем по своему + усмотрению. +

+

+ 2.5. Администрация не осуществляет проверку + достоверности предоставляемой Персональной + информации, полагая, что Пользователь действует + добросовестно, осмотрительно и прилагает все + необходимые усилия к поддержанию такой информации в + актуальном состоянии. +

+

+ 3. Цели обработки Персональной информации +

+

+ 3.1. Администрация осуществляет обработку, в том + числе сбор и хранение только той Персональной + информации, которая необходима для заключения и + исполнения договоров с Пользователем. +

+

+ 3.2. Администрация вправе использовать Персональную + информацию в частности в следующих целях: +

+
    +
  • + Заключение договоров на использование Сайта и + Сервиса; +
  • +
  • + Исполнение обязательств по заключённым + договорам, включая предоставление Пользователю + доступа к Сайту и Сервису; +
  • +
  • + Идентификация Пользователя в рамках исполнения + обязательств по заключённым с ним договорам; +
  • +
  • + Оказание технической поддержки в связи с + использованием Сайта и Сервиса; +
  • +
  • + Обеспечение связи с Пользователем в целях + информационного обслуживания и улучшения + качества Сервиса, в том числе в порядке + нотификации с привлечением третьих лиц; +
  • +
  • + Использование обезличенных данных для таргетинга + рекламных и/или информационных материалов по + возрасту, полу, другим признакам; +
  • +
  • + Проведение маркетинговых, статистических и иных + исследований на основе обезличенных данных. +
  • +
+

+ 4. Требования к защите Персональной информации +

+

+ 4.1. Администрация осуществляет хранение + Персональной информации и обеспечивает её охрану от + несанкционированного доступа и распространения в + соответствии с внутренними правилами и регламентами. +

+

+ 4.2. В отношении Персональной информации + Пользователя сохраняется её конфиденциальность, за + исключением случаев, когда технология Сайта или + предоставляемого на его основе Сервиса либо + настройки используемого Пользователем программного + обеспечения предусматривают открытый обмен + информацией с иными Пользователями Сайта или с + любыми пользователями сети Интернет. +

+

+ 4.3. Администрация вправе передавать Персональную + информацию третьим лицам в следующих случаях: +

+
    +
  • + Пользователь выразил своё согласие на такие + действия, включая случаи применения + Пользователем настроек используемого + программного обеспечения, не ограничивающих + предоставление определённой информации; +
  • +
  • + Передача необходима в рамках использования + Пользователем функциональных возможностей Сайта + и/или специфики основанного на нём Сервиса; +
  • +
  • + В связи с передачей Сайта во владение, + пользование или собственность такого третьего + лица, включая уступку прав по заключённым с + Пользователем договорам в пользу такого третьего + лица; +
  • +
  • + По запросу суда или иного уполномоченного + государственного органа в рамках установленной + законодательством процедуры; +
  • +
  • + Для защиты прав и законных интересов + Администрации в связи с нарушением заключённых с + Пользователем договоров. +
  • +
+

+ 5. Изменение и удаление Персональной информации +

+

+ 5.1. Пользователь вправе в любой момент + самостоятельно отредактировать в своём Личном + кабинете предоставленную им при регистрации или + авторизации Персональную информацию. +

+

+ 5.2. В целях повышения качества Сервиса и + обеспечения возможности правовой защиты, + Администрация вправе хранить лог-файлы о действиях, + совершённых Пользователем в рамках использования + Сайта и/или Сервиса, а также в связи с заключением и + исполнением Пользователем Соглашения и иных + договоров со своей стороны, в течение 1 (Одного) + года с момента их совершения. +

+

+ 6. Изменение Политики конфиденциальности +

+

+ 6.1. Настоящая Политика может быть изменена или + прекращена Администрацией в одностороннем порядке + без предварительного уведомления Пользователя. Новая + редакция Политики вступает в силу с момента её + размещения на Сайте, если иное не предусмотрено + новой редакцией Политики. +

+

+ 6.2. Действующая редакция Политики находится на + Сайте в сети Интернет по адресу + + https://teacoder.ru/document/privacy-policy + + . +

+
+
+
+
+ ) +} diff --git a/src/app/(public)/document/terms-of-use/page.tsx b/src/app/(public)/document/terms-of-use/page.tsx new file mode 100644 index 0000000..40f1ebc --- /dev/null +++ b/src/app/(public)/document/terms-of-use/page.tsx @@ -0,0 +1,474 @@ +import type { Metadata } from 'next' +import Link from 'next/link' + +export const metadata: Metadata = { + title: 'Пользовательское соглашение' +} + +export default function AgreementPage() { + return ( +
+
+
+

+ Пользовательское соглашение +

+
+

+ Настоящее Пользовательское соглашение (далее — + «Соглашение») регулирует отношения между владельцем + teaocder.ru (далее Сайт или Администрация) и любым + лицом, являющимся пользователем Сайта (далее — + «Пользователь»). +

+

+ 1. Общие положения +

+

+ 1.1. В настоящем Соглашении применяются следующие + термины и определения: +

+
    +
  • + Соглашение — настоящий документ со всеми + дополнениями, изменениями и указанными в нём + обязательными документами. +
  • +
  • + Пользователь — дееспособное физическое + лицо, присоединившееся к настоящему Соглашению в + собственном интересе либо выступающее от имени и + в интересах представляемого им юридического + лица. +
  • +
  • + Сайт — любая из автоматизированных + информационных систем, доступных в сети Интернет + по сетевым адресам в следующих доменах (включая + поддомены): teaocder.ru. +
  • +
  • + Сервис — комплекс услуг, предоставляемых + Пользователю с использованием Сайта. +
  • +
  • + Личный кабинет — персональный раздел + Сайта и дополнительные функциональные + возможности Сайта, к которым Пользователь + получает доступ после прохождения регистрации + и/или авторизации на Сайте. +
  • +
+

+ 1.2. В настоящем Соглашении могут быть использованы + иные термины и определения, не указанные в п.1.1. + Соглашения. В этом случае толкование такого термина + производится в соответствии с текстом Соглашения. +

+

+ 1.3. Использование Пользователем Сайта в пределах + его функциональных возможностей, включая: +

+
    +
  • Просмотр размещённых на Сайте материалов;
  • +
  • Регистрацию и/или авторизацию на Сайте;
  • +
  • + Размещение или отображение на Сайте любых + материалов, включая, но, не ограничиваясь + такими, как: тексты, гипертекстовые ссылки, + изображения, аудио и видео файлы, сведения и/или + иная информация; +
  • +
+

+ создаёт договор на условиях настоящего Соглашения в + соответствии с положениями ст.437 и 438 Гражданского + кодекса Российской Федерации (ГК РФ). +

+

+ 1.4. Обязательным условием использования Сайта + является полное и безоговорочное принятие + Пользователем условий следующих документов (по + тексту Соглашения — «обязательные документы»): +

+
    +
  • + 1.4.1. Политики конфиденциальности, размещённой + и/или доступной на Сайте в сети Интернет по + адресу{' '} + + https://teacoder.ru/document/privacy-policy + + . +
  • +
+

+ 1.5. Воспользовавшись любой из указанных в п.1.3. + возможностей по использованию Сайта, вы + подтверждаете, что: +

+
    +
  • + Ознакомились с условиями настоящего Соглашения в + полном объёме до начала использования Сайта. +
  • +
  • + Принимаете все условия настоящего Соглашения в + полном объёме без каких-либо изъятий и + ограничений с вашей стороны в соответствии со + ст.438 ГК РФ и обязуетесь их соблюдать или + прекратить использование Сайта. +
  • +
  • + Соглашение, включая относящиеся к нему + обязательные документы, может быть изменено + Администрацией без какого-либо специального + уведомления Пользователя. +
  • +
+

+ 1.6. Действующая редакция Соглашения находится на + Сайте в сети Интернет по адресу + + https://teaocder.ru/document/terms-of-use + + . +

+

+ 2. Порядок регистрации Пользователя и использование + Сайта +

+

+ 2.1. Использование полных функциональных + возможностей Сайта, включая использование Сервисов, + допускается только после прохождения Пользователем + регистрации и авторизации на Сайте в соответствии с + установленной Администрацией процедурой. +

+

+ 2.2. Перечень функциональных возможностей Сайта, + использование которых требует предварительной + регистрации и/или авторизации, а также принятия в + необходимых случаях дополнительных документов на + использование Сервисов, определяется по единоличному + усмотрению Администрации и может время от времени + изменяться. +

+

+ 2.3. По завершении процедуры регистрации создаётся + уникальная учётная запись, связанная с Личным + кабинетом Пользователя на Сайте, которая необходима + для использования большинства функциональных + возможностей Сайта и основанных на них Сервисов. +

+

+ 2.4. Для регистрации Пользователь обязуется + предоставить достоверную и полную информацию о себе + по вопросам, предлагаемым в форме регистрации, и + поддерживать эту информацию в актуальном состоянии. + Если Пользователь предоставляет неверную информацию + или у Администрации есть основания полагать, что + предоставленная Пользователем информация неполна или + недостоверна, Администрация имеет право по своему + усмотрению заблокировать либо удалить учётную запись + Пользователя, а также отказать Пользователю в + использовании Сайта и связанных с ними Сервисов + полностью или в определённой части. +

+

+ 2.5. Любые действия, совершённые из под учётной + записи Пользователя, считаются совершёнными + соответствующим Пользователем. В случае + несанкционированного доступа к учётной записи + Пользователя, или распространения доступа, + Пользователь обязан незамедлительно сообщить об этом + Администрации. +

+

+ 2.6. Администрация оставляет за собой право в любой + момент потребовать от Пользователя подтверждения + данных, указанных при регистрации, и запросить в + связи с этим подтверждающие документы (в частности — + документы, удостоверяющие личность), + непредоставление которых, по усмотрению + Администрации, может быть приравнено к + предоставлению недостоверной информации и повлечь + последствия, предусмотренные п.2.4. Соглашения. В + случае если данные Пользователя, указанные в + предоставленных им документах, не соответствуют + данным, указанным при регистрации, а также в случае, + когда данные, указанные при регистрации, не + позволяют идентифицировать пользователя, + Администрация вправе применить меры, указанные в + п.2.4. Соглашения. +

+

+ 2.7. Информация о Пользователе, содержащаяся в + учётной записи и Личном кабинете Пользователя, + хранится и обрабатывается Администрацией в + соответствии с Политикой конфиденциальности. +

+ +

+ 3. Условия размещения материалов Пользователем на + Сайте +

+

+ 3.1. Пользователь обязуется использовать сайт только + в законных целях. Пользователь обязуется не + размещать на сайте и не направлять куда-либо + через/посредством сайта любые материалы следующего + характера: +

+
    +
  • + Нарушающие законодательство, содержащие угрозы и + оскорбления, дискредитирующие других лиц, + нарушающие права граждан на частную жизнь или + публичный порядок, носящие характер + непристойности +
  • +
  • + Нарушающие в той или иной степени честь и + достоинство, права и охраняемые законом интересы + других лиц +
  • +
  • + Способствующие или содержащие призывы к + разжиганию религиозной, расовой или + межнациональной розни, содержащие попытки + разжигания вражды или призывы к насилию +
  • +
  • + Спам, вирусные материалы, несанкционированную + рекламу +
  • +
+

+ А также иные материалы, которые побуждают других лиц + на противоправное поведение, влекущее уголовную, + гражданско-правовую и иную ответственность или + каким-либо образом нарушающее положения + законодательства Российской Федерации. +

+

+ 3.2. Пользователь соглашается с тем, что он несёт + единоличную полную ответственность в отношение + размещаемых на сайте материалов, в том числе, за + содержание таких материалов, соответствие их + требованиям законодательства, за нарушения прав + третьих лиц на размещаемые Пользователем материалы и + возмещает любой ущерб, возникающий вследствие таких + нарушений, а также любой ущерб, возникающий + вследствие загрузки на Сайт таких материалов. +

+

+ 3.3. Пользователь обязуется не загружать, не + размещать или иным образом использовать на Сайте + какие-либо материалы, охраняемые законодательством + об интеллектуальной собственности (в том числе, + авторским правом, законодательством о товарных + знаках), и иные охраняемые законодательством + материалы без получения письменного разрешения + обладателя прав на охраняемый материал. При этом + бремя доказывания того, что размещение на Сайте + Пользователем материалов не нарушает авторские, + смежные и иные права третьих лиц на размещаемые + материалы, лежит на Пользователе. +

+

+ 3.4. При размещении каких-либо материалов на Сайте + для всеобщего пользования, Пользователь тем самым + автоматически предоставляет Администрации (или + подтверждает, что владелец таких материалов + предоставил Администрации) безвозмездное, + постоянное, безотзывное, неисключительное право на + использование, воспроизведение, изменение, + редактирование, копирование, опубликование, перевод + и распространение таких материалов (частично или + полностью) на территории всех стран мира и/или + включение таких материалов в другие произведения в + любой форме посредством использования любых средств + массовой информации или технологий, которые известны + в настоящее время или могут быть изобретены в + будущем, на весь срок охраны авторского права, + предусмотренный законодательством РФ, в отношении + таких материалов. Пользователь также разрешает + доступ, просмотр, хранение или воспроизведение таких + материалов другим Пользователям для личного + использования. +

+ +

+ 4. Ограничения по использованию Сайта +

+

+ 4.1. Администрация вправе устанавливать лимиты по + объёму и составу размещаемых Пользователем + информационных материалов, а также вводить иные + технические ограничения использования Сайта, которые + время от времени будут доводиться до сведения + Пользователей в форме и способом по выбору + Администрации. +

+

+ 4.2. Администрация оставляет за собой право + блокировать, исключать, удалять материалы с Сайта + без согласия Пользователя, либо временно ограничить + доступ к ним, если они не отвечают требованиям + действующего законодательства и/или настоящего + Соглашения. +

+

+ 4.3. В случае многократного или грубого нарушения + условий настоящего Соглашения и/или требований + законодательства, Администрация оставляет за собой + право заблокировать учётную запись Пользователя + целиком, удалить его либо иным образом ограничить + (прекратить) предоставление Сервиса. +

+

+ 4.4. В случае привлечения Администрации к + ответственности или наложения на неё взыскания в + связи с допущенными Пользователем нарушениями прав + и/или интересов третьих лиц, а равно установленных + законодательством запретов или ограничений, + Пользователь обязан в полном объёме возместить + убытки Администрации. +

+ +

+ 5. Ответственность и гарантии +

+

+ 5.1. Администрация не несёт ответственности за + нарушение условий настоящего Соглашения, если такое + нарушение вызвано действием обстоятельств + непреодолимой силы (форс-мажор), включая: действия + органов государственной власти, пожар, наводнение, + землетрясение, другие стихийные действия, отсутствие + электроэнергии и/или сбои работы компьютерной сети + (нарушение работы линий связи, неисправность + оборудования и т. п.), забастовки, гражданские + волнения, беспорядки, любые иные обстоятельства, не + ограничиваясь перечисленным, которые могут повлиять + на выполнение Администрацией условий настоящего + Соглашения и неподконтрольные Администрации. +

+

+ 5.2. Администрация также не несёт ответственности за + неисполнение или ненадлежащее исполнение + обязательств по Соглашению, а также возможные + убытки, возникшие в том числе, но не ограничиваясь, + в результате: Неправомерных действий третьих лиц, + направленных на нарушения информационной + безопасности или нормального функционирования Сайта; + Сбоев в работе Сайта, вызванных ошибками в коде, + компьютерными вирусами и иными посторонними + фрагментами кода в программном обеспечении Сайта; + Отсутствия (невозможности установления, прекращения + и пр.) интернет-соединений между сервером + Пользователя и сервером Сайта; Проведения + государственными и муниципальными органами, а также + организациями мероприятий в рамках + оперативно-розыскных мероприятий; Установления + государственного регулирования (или регулирования + иными организациями) хозяйственной деятельности + коммерческих организаций в сети Интернет и/или + установления указанными субъектами разовых + ограничений, затрудняющих или делающих невозможным + исполнение Соглашения или его части; Других случаев, + связанных с действиями/бездействием третьих лиц, + направленными на ухудшение общей ситуации с + использованием сети Интернет и/или компьютерного + оборудования, существовавшей на момент заключения + Соглашения; Выполнения Администрацией + профилактических работ на Сайте. +

+

+ 5.3. Все споры, вытекающие из правоотношений по + настоящему Соглашению, разрешаются путём + переговоров. В случае, если Стороны не придут к + решению возникших между ними споров в ходе + переговоров, такие споры должны быть переданы на + рассмотрение в соответствующий суд Российской + Федерации по месту нахождения Администрации с + обязательным соблюдением претензионного порядка. + Срок ответа на претензию составляет 1 (Один) месяц. +

+ +

+ 6. Интеллектуальная собственность +

+

+ 6.1. Администрация является правообладателем + объектов интеллектуальной собственности с помощью + которых функционирует Сайт, включая, но, не + ограничиваясь: программы для ЭВМ, тексты, + фотографии, видеоматериалы, графические изображения, + музыкальные и звуковые произведения, товарные знаки, + фирменное наименование, которые охраняются в + соответствии с положениями четвёртой части ГК РФ. +

+

+ 6.2. Администрация предоставляет Пользователю право + на использование объектов интеллектуальной + собственности, указанных в п.6.1. исключительно в + личных некоммерческих целях с обязательной активной + ссылкой на Сайт. +

+

+ 6.3. Любое использование объектов интеллектуальной + собственности Администрации, указанных в п.6.1. в + коммерческих целях недопустимо, и влечёт за собой + гражданско-правовую, административную, а также + уголовную ответственность, предусмотренную + законодательством Российской Федерации. +

+

+ 6.4. Пользователь, размещая свои материалы на сайте, + предоставляет Администрации и другим Пользователям + право на их использование в соответствии с п.3.4. + настоящего Соглашения. +

+ +

+ 7. Заключительные положения +

+

+ 7.1. Настоящее Соглашение регулируется и толкуется в + соответствии с законодательством Российской + Федерации. Вопросы, не урегулированные настоящим + Соглашением, подлежат разрешению в соответствии с + законодательством Российской Федерации. Все + возможные споры, вытекающие из отношений, + регулируемых настоящим Соглашением, разрешаются в + порядке, установленном действующим законодательством + Российской Федерации, по нормам российского права. + Везде по тексту настоящего Соглашения, если явно не + указано иное, под термином «законодательство» + понимается законодательство Российской Федерации. +

+

+ 7.2. Если по тем или иным причинам одно или + несколько положений настоящего Соглашения будут + признаны недействительными или не имеющими + юридической силы, это не оказывает влияния на + действительность или применимость остальных + положений Соглашения. +

+

+ 7.3. Бездействие со стороны Администрации в случае + нарушения Пользователем либо иными Пользователями + положений Соглашений не лишает Администрации права + предпринять соответствующие действия в защиту своих + интересов позднее, а также не означает отказ + Администрации от своих прав в случае совершения в + последующем подобных либо сходных нарушений. +

+
+
+
+
+ ) +} diff --git a/src/app/(public)/layout.tsx b/src/app/(public)/layout.tsx new file mode 100644 index 0000000..8ec3d87 --- /dev/null +++ b/src/app/(public)/layout.tsx @@ -0,0 +1,22 @@ +import { type ReactNode } from 'react' + +import { Footer } from '@/src/components/layout/footer' +import { Header } from '@/src/components/layout/header' + +export default function PublicLayout({ children }: { children: ReactNode }) { + return ( +
+
+
+
+
+
+ +
+
+ {children} +
+
+
+ ) +} diff --git a/src/app/(public)/page.tsx b/src/app/(public)/page.tsx new file mode 100644 index 0000000..15ac011 --- /dev/null +++ b/src/app/(public)/page.tsx @@ -0,0 +1,25 @@ +import type { Metadata } from 'next' +import { Fragment } from 'react' + +import { getPopularCourses } from '@/src/api' +import { Features } from '@/src/components/home/features' +import { Hero } from '@/src/components/home/hero' +import { Popular } from '@/src/components/home/popular' +import { TelegramCTA } from '@/src/components/home/telegram-cta' + +export const metadata: Metadata = { + title: 'Образовательная платформа по веб разработке' +} + +export default async function HomePage() { + const courses = await getPopularCourses() + + return ( + + + + + + + ) +} diff --git a/src/app/account/layout.tsx b/src/app/account/layout.tsx new file mode 100644 index 0000000..29811d3 --- /dev/null +++ b/src/app/account/layout.tsx @@ -0,0 +1,23 @@ +import type { ReactNode } from 'react' + +import { Header } from '@/src/components/layout/header' +import { UserNavigation } from '@/src/components/layout/user-navigation' +import { AccountProvider } from '@/src/components/providers/account-provider' + +export default function AccountLayout({ children }: { children: ReactNode }) { + return ( + +
+
+
+
+
+ +
+ {children} +
+
+
+ + ) +} diff --git a/src/app/account/page.tsx b/src/app/account/page.tsx new file mode 100644 index 0000000..d5d12ee --- /dev/null +++ b/src/app/account/page.tsx @@ -0,0 +1,11 @@ +import type { Metadata } from 'next' + +import { Progress } from '@/src/components/account/progress/progress' + +export const metadata: Metadata = { + title: 'Мой прогресс' +} + +export default function ProgressPage() { + return +} diff --git a/src/app/account/sessions/page.tsx b/src/app/account/sessions/page.tsx new file mode 100644 index 0000000..9044114 --- /dev/null +++ b/src/app/account/sessions/page.tsx @@ -0,0 +1,11 @@ +import type { Metadata } from 'next' + +import { Sessions } from '@/src/components/account/sessions/sessions' + +export const metadata: Metadata = { + title: 'Уcтройства' +} + +export default function SessionsPage() { + return +} diff --git a/src/app/account/settings/page.tsx b/src/app/account/settings/page.tsx new file mode 100644 index 0000000..c6e91c0 --- /dev/null +++ b/src/app/account/settings/page.tsx @@ -0,0 +1,11 @@ +import type { Metadata } from 'next' + +import { Settings } from '@/src/components/account/settings/settings' + +export const metadata: Metadata = { + title: 'Настройки аккаунта' +} + +export default function SettingsPage() { + return +} diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx new file mode 100644 index 0000000..d52a4e7 --- /dev/null +++ b/src/app/auth/login/page.tsx @@ -0,0 +1,11 @@ +import type { Metadata } from 'next' + +import { LoginForm } from '@/src/components/auth/login-form' + +export const metadata: Metadata = { + title: 'Войти в аккаунт' +} + +export default function LoginPage() { + return +} diff --git a/src/app/auth/recovery/[token]/page.tsx b/src/app/auth/recovery/[token]/page.tsx new file mode 100644 index 0000000..0c0757d --- /dev/null +++ b/src/app/auth/recovery/[token]/page.tsx @@ -0,0 +1,11 @@ +import type { Metadata } from 'next' + +import { NewPasswordForm } from '@/src/components/auth/new-password-form' + +export const metadata: Metadata = { + title: 'Новый пароль' +} + +export default async function NewPasswordPage() { + return +} diff --git a/src/app/auth/recovery/page.tsx b/src/app/auth/recovery/page.tsx new file mode 100644 index 0000000..327f083 --- /dev/null +++ b/src/app/auth/recovery/page.tsx @@ -0,0 +1,11 @@ +import type { Metadata } from 'next' + +import { ResetPasswordForm } from '@/src/components/auth/reset-password-form' + +export const metadata: Metadata = { + title: 'Сброс пароля' +} + +export default function ResetPasswordPage() { + return +} diff --git a/src/app/auth/register/page.tsx b/src/app/auth/register/page.tsx new file mode 100644 index 0000000..90a6d55 --- /dev/null +++ b/src/app/auth/register/page.tsx @@ -0,0 +1,11 @@ +import type { Metadata } from 'next' + +import { RegisterForm } from '@/src/components/auth/register-form' + +export const metadata: Metadata = { + title: 'Создать аккаунт' +} + +export default function RegisterPage() { + return +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 559370a..43351d7 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,25 +1,98 @@ -import '@/src/styles/globals.css' +import { GoogleAnalytics } from '@next/third-parties/google' +import { GeistSans } from 'geist/font/sans' import type { Metadata } from 'next' -import { Geist } from 'next/font/google' +import type { ReactNode } from 'react' + +import { YandexMetrika } from '../components/analitycs/yandex-metrika' +import { TanstackQueryProvider } from '../components/providers/tanstack-query-provider' +import { Toaster } from '../components/shared/sonner' +import { APP_CONFIG, SEO } from '../constants' -const geistSans = Geist({ - variable: '--font-geist-sans', - subsets: ['latin'], -}) +import '@/src/styles/globals.css' export const metadata: Metadata = { - title: 'Create Next App', - description: 'Generated by create next app', + title: { + absolute: SEO.name, + template: `%s - ${SEO.name}` + }, + description: SEO.description, + metadataBase: new URL(APP_CONFIG.baseUrl), + applicationName: SEO.name, + keywords: SEO.keywords, + icons: { + icon: '/favicon.ico', + shortcut: '/favicon.ico', + apple: '/touch-icons/192x192.png', + other: { + rel: 'touch-icons', + url: '/touch-icons/192x192.png', + sizes: '192x192', + type: 'image/png' + } + }, + manifest: '/manifest.webmanifest', + openGraph: { + title: SEO.name, + description: SEO.description, + type: 'website', + emails: ['support@teacoder.ru'], + siteName: SEO.name, + locale: 'ru_RU', + images: [ + { + url: new URL(APP_CONFIG.baseUrl + '/touch-icons/512x512.png'), + width: 512, + height: 512, + alt: SEO.name + } + ], + url: APP_CONFIG.baseUrl + }, + twitter: { + card: 'summary_large_image', + title: SEO.name, + description: SEO.description, + images: [ + { + url: new URL(APP_CONFIG.baseUrl + '/touch-icons/512x512.png'), + width: 512, + height: 512, + alt: SEO.name + } + ] + }, + formatDetection: SEO.formatDetection } -export default function RootLayout({ - children, -}: Readonly<{ - children: React.ReactNode -}>) { +export default function RootLayout({ children }: { children: ReactNode }) { return ( - - {children} + + + + {children} + + + {process.env['NODE_ENV'] === 'production' && ( + <> + + + + )} + + ) } diff --git a/src/app/lesson/[slug]/page.tsx b/src/app/lesson/[slug]/page.tsx new file mode 100644 index 0000000..26d21bb --- /dev/null +++ b/src/app/lesson/[slug]/page.tsx @@ -0,0 +1,103 @@ +import type { Metadata } from 'next' +import { cookies } from 'next/headers' +import { notFound } from 'next/navigation' + +import { api, getCourseLessons, getLesson } from '@/src/api' +import { LessonCompleteButton } from '@/src/components/lesson/lesson-complete-button' +import { LessonContainer } from '@/src/components/lesson/lesson-container' +import { LessonPlayer } from '@/src/components/lesson/lesson-player' +import { LessonSidebar } from '@/src/components/lesson/lesson-sidebar' + +export const revalidate = 60 + +async function getUserProgress(courseId: string) { + const cookie = await cookies() + + const token = cookie.get('token')?.value + + const { data: progressCount } = await api.get( + `/progress/${courseId}`, + { + headers: { + 'X-Session-Token': token ?? '' + } + } + ) + + const { data: completedLessons } = await api.get( + `/lessons/${courseId}/progress`, + { + headers: { + 'X-Session-Token': token ?? '' + } + } + ) + + return { progressCount, completedLessons } +} + +export async function generateMetadata({ + params +}: { + params: Promise<{ slug: string }> +}): Promise { + const { slug } = await params + + const lesson = await getLesson(slug).catch(error => { + notFound() + }) + + return { + title: lesson.title, + description: lesson.description + } +} + +export default async function LessonPage({ + params +}: { + params: Promise<{ slug: string }> +}) { + const { slug } = await params + + const lesson = await getLesson(slug).catch(error => { + notFound() + }) + + const lessons = await getCourseLessons(lesson.course.id) + + const { progressCount, completedLessons } = await getUserProgress( + lesson.course.id + ) + + return ( +
+ + +

{lesson.title}

+ + {lesson.description && ( +

+ {lesson.description} +

+ )} + +
+ + +
+ +
+
+
+
+ ) +} diff --git a/src/app/manifest.ts b/src/app/manifest.ts new file mode 100644 index 0000000..91c369a --- /dev/null +++ b/src/app/manifest.ts @@ -0,0 +1,30 @@ +import { MetadataRoute } from 'next' + +import { SEO } from '../constants' + +export default function manifest(): MetadataRoute.Manifest { + return { + name: SEO.name, + short_name: SEO.name, + categories: SEO.keywords, + lang: 'ru_RU', + description: SEO.description, + start_url: '/', + display: 'standalone', + background_color: '#FFFFFF', + theme_color: '#2563EB', + orientation: 'portrait', + icons: [ + { + src: '/touch-icons/192x192.png', + sizes: '192x192', + type: 'image/png' + }, + { + src: '/touch-icons/512x512.png', + sizes: '512x512', + type: 'image/png' + } + ] + } +} diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx new file mode 100644 index 0000000..211f231 --- /dev/null +++ b/src/app/not-found.tsx @@ -0,0 +1,30 @@ +import { ChevronLeft } from 'lucide-react' +import type { Metadata } from 'next' +import Link from 'next/link' + +import { Button } from '../components/ui/button' + +export const metadata: Metadata = { + title: 'Страница не найдена' +} + +export default function NotFoundPage() { + return ( +
+
+

404

+

+ Кажется, мы потеряли эту страницу. +

+
+ +
+
+
+ ) +} diff --git a/src/app/page.tsx b/src/app/page.tsx deleted file mode 100644 index 2cbe4ef..0000000 --- a/src/app/page.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function Home() { - return
Home Page
-} diff --git a/src/app/robots.ts b/src/app/robots.ts new file mode 100644 index 0000000..65c391b --- /dev/null +++ b/src/app/robots.ts @@ -0,0 +1,24 @@ +import type { MetadataRoute } from 'next' + +import { APP_CONFIG } from '../constants' + +export default function robots(): MetadataRoute.Robots { + return { + rules: { + userAgent: '*', + allow: '/', + disallow: [ + '/*?', + '/*.html', + '/auth/recovery/*', + '/account/*', + '/lesson/*', + '*?*=*', + '*?*=*&*=*', + '*?*=*=*' + ] + }, + host: APP_CONFIG.baseUrl, + sitemap: `${APP_CONFIG.baseUrl}/sitemap.xml` + } +} diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts new file mode 100644 index 0000000..3ed0d4e --- /dev/null +++ b/src/app/sitemap.ts @@ -0,0 +1,23 @@ +import type { MetadataRoute } from 'next' + +import { getCourses } from '../api' +import { APP_CONFIG } from '../constants' + +export default async function sitemap(): Promise { + const courses: MetadataRoute.Sitemap = (await getCourses()).map(course => ({ + url: `${APP_CONFIG.baseUrl}/${course.slug}`, + lastModified: course.updatedAt, + changeFrequency: 'monthly', + priority: 0.9 + })) + + return [ + { + url: APP_CONFIG.baseUrl, + lastModified: new Date(), + changeFrequency: 'yearly', + priority: 1 + }, + ...courses + ] +} diff --git a/src/components/account/progress/courses-list.tsx b/src/components/account/progress/courses-list.tsx new file mode 100644 index 0000000..071a543 --- /dev/null +++ b/src/components/account/progress/courses-list.tsx @@ -0,0 +1,74 @@ +import { useQuery } from '@tanstack/react-query' +import { BookOpen, ChevronRight } from 'lucide-react' + +import { CourseProgress } from '../../shared/course-progress' + +import { getMeProgress } from '@/src/api' +import { Button } from '@/src/components/ui/button' +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle +} from '@/src/components/ui/card' + +interface CoursesListProps { + onViewAll: () => void +} + +export function CoursesList({ onViewAll }: CoursesListProps) { + const { data, isLoading } = useQuery({ + queryKey: ['get me progress'], + queryFn: () => getMeProgress() + }) + + return ( + + + + Все курсы + + Ваш прогресс по курсам + + +
+ {data?.map(course => ( +
+
+
+ {course.title} +
+
+ {course.completedLessons}/ + {course.totalLessons} уроков +
+
+ +
+ ))} +
+
+ + + + +
+ ) +} diff --git a/src/components/account/progress/courses-tab.tsx b/src/components/account/progress/courses-tab.tsx new file mode 100644 index 0000000..b623b46 --- /dev/null +++ b/src/components/account/progress/courses-tab.tsx @@ -0,0 +1,87 @@ +import { useQuery } from '@tanstack/react-query' +import Link from 'next/link' + +import { CourseProgress } from '../../shared/course-progress' + +import { getMeProgress } from '@/src/api' +import { Button } from '@/src/components/ui/button' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle +} from '@/src/components/ui/card' +import { Separator } from '@/src/components/ui/separator' + +export function CoursesTab() { + const { data, isLoading } = useQuery({ + queryKey: ['get me progress'], + queryFn: () => getMeProgress() + }) + + return ( + + + Курсы + Ваш прогресс по всем курсам + + +
+ {data?.map(course => ( +
+
+
+ {course.title} +
+
+ {course.completedLessons}/ + {course.totalLessons} уроков +
+
+
+ + + {course.progress}% + +
+
+ + Последний доступ:{' '} + {new Date( + course.lastAccessed + ).toLocaleDateString()} + + {course.lastLesson && ( + + )} +
+ {course.id !== data[data.length - 1].id && ( + + )} +
+ ))} +
+
+
+ ) +} diff --git a/src/components/account/progress/leaderboard.tsx b/src/components/account/progress/leaderboard.tsx new file mode 100644 index 0000000..fcecf0d --- /dev/null +++ b/src/components/account/progress/leaderboard.tsx @@ -0,0 +1,109 @@ +import { useQuery } from '@tanstack/react-query' +import { ChevronRight, Trophy, Users } from 'lucide-react' + +import { Avatar, AvatarFallback, AvatarImage } from '../../ui/avatar' + +import { getLeaders } from '@/src/api' +import { Button } from '@/src/components/ui/button' +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle +} from '@/src/components/ui/card' +import { getMediaSource } from '@/src/lib/utils' + +interface LeaderboardProps { + limit?: number + showButton?: boolean + onViewAll?: () => void +} + +export function Leaderboard({ + limit, + showButton, + onViewAll +}: LeaderboardProps) { + const { data, isLoading } = useQuery({ + queryKey: ['get leaders'], + queryFn: () => getLeaders() + }) + + const users = limit ? data?.slice(0, limit) : data + + return ( + + + + Рейтинг пользователей + + + Пользователи с наибольшим количеством очков + + + +
+ {users?.map((user, index) => { + const position = index + 1 + + return ( +
+
+
+ {position === 1 && ( + + )} + {position === 2 && ( + + )} + {position === 3 && ( + + )} + {position > 3 && position} +
+
+ + + + {user?.displayName.slice(0, 1)} + + +

+ {user.displayName} +

+
+
+
+ {user.points} очков +
+
+ ) + })} +
+
+ {showButton && ( + + + + )} +
+ ) +} diff --git a/src/components/account/progress/progress.tsx b/src/components/account/progress/progress.tsx new file mode 100644 index 0000000..80b3587 --- /dev/null +++ b/src/components/account/progress/progress.tsx @@ -0,0 +1,64 @@ +'use client' + +import { useState } from 'react' + +import { Heading } from '../../shared/heading' + +import { CoursesList } from './courses-list' +import { CoursesTab } from './courses-tab' +import { Leaderboard } from './leaderboard' +import { UserStats } from './user-stats' +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger +} from '@/src/components/ui/tabs' + +export function Progress() { + const [activeTab, setActiveTab] = useState('overview') + + return ( +
+
+ + + + + Обзор + Курсы + Рейтинг + + + + + setActiveTab('courses')} + /> + setActiveTab('leaderboard')} + /> + + + + + + + + + + +
+
+ ) +} diff --git a/src/components/account/progress/user-stats.tsx b/src/components/account/progress/user-stats.tsx new file mode 100644 index 0000000..e67c50d --- /dev/null +++ b/src/components/account/progress/user-stats.tsx @@ -0,0 +1,90 @@ +import { useQuery } from '@tanstack/react-query' +import { BookOpen, Crown, Medal, Trophy } from 'lucide-react' +import React from 'react' +import { CircularProgressbar } from 'react-circular-progressbar' +import 'react-circular-progressbar/dist/styles.css' + +import { getMeStatistics } from '@/src/api' +import { + Card, + CardContent, + CardHeader, + CardTitle +} from '@/src/components/ui/card' + +export function UserStats() { + const { data, isLoading } = useQuery({ + queryKey: ['get me statistics'], + queryFn: () => getMeStatistics() + }) + + return isLoading ? ( +
+ {/* */} + {/* */} +
+ ) : ( +
+ + + + + Очки и рейтинг + + + +
+
+
+ {data?.totalPoints} +
+
+ Всего очков +
+
+
+
+
+ + + + + + Прогресс обучения + + + +
+
+
+ {data?.lessonsCompleted} +
+
+ Пройдено уроков +
+
+
+ +
+
+
+
+
+ ) +} diff --git a/src/components/account/sessions/remove-all-sessions.tsx b/src/components/account/sessions/remove-all-sessions.tsx new file mode 100644 index 0000000..ce00950 --- /dev/null +++ b/src/components/account/sessions/remove-all-sessions.tsx @@ -0,0 +1,47 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useState } from 'react' +import { toast } from 'sonner' + +import { ConfirmDialog } from '../../shared/confirm-dialog' +import { Button } from '../../ui/button' + +import { removeAllSessions } from '@/src/api' + +export function RemoveAllSessions() { + const [isOpen, setIsOpen] = useState(false) + + const queryClient = useQueryClient() + + const { mutate, isPending } = useMutation({ + mutationKey: ['remove all sessions'], + mutationFn: () => removeAllSessions(), + onSuccess() { + queryClient.invalidateQueries({ queryKey: ['get sessions'] }) + setIsOpen(false) + }, + onError(error) { + if (error.message) { + toast.error(error.message) + } else { + toast.error('Ошибка при отключении') + } + } + }) + + return ( + mutate()} + isLoading={isPending} + open={isOpen} + onOpenChange={setIsOpen} + > + + + ) +} diff --git a/src/components/account/sessions/remove-session.tsx b/src/components/account/sessions/remove-session.tsx new file mode 100644 index 0000000..504fb8c --- /dev/null +++ b/src/components/account/sessions/remove-session.tsx @@ -0,0 +1,47 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useState } from 'react' +import { toast } from 'sonner' + +import { ConfirmDialog } from '../../shared/confirm-dialog' +import { Button } from '../../ui/button' + +import { revokeSession } from '@/src/api' + +interface RevokeSessionProps { + id: string +} + +export function RevokeSession({ id }: RevokeSessionProps) { + const [isOpen, setIsOpen] = useState(false) + + const queryClient = useQueryClient() + + const { mutate, isPending } = useMutation({ + mutationKey: ['revoke session', id], + mutationFn: () => revokeSession(id), + onSuccess() { + queryClient.invalidateQueries({ queryKey: ['get sessions'] }) + setIsOpen(false) + }, + onError(error) { + toast.error(error.message ?? 'Ошибка при удалении сессии') + } + }) + + return ( + mutate()} + isLoading={isPending} + open={isOpen} + onOpenChange={setIsOpen} + > + + + ) +} diff --git a/src/components/account/sessions/session-item.tsx b/src/components/account/sessions/session-item.tsx new file mode 100644 index 0000000..721ff20 --- /dev/null +++ b/src/components/account/sessions/session-item.tsx @@ -0,0 +1,72 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { Button } from '../../ui/button' +import { Card, CardContent } from '../../ui/card' + +import { RevokeSession } from './remove-session' +import { revokeSession } from '@/src/api' +import { SessionResponse } from '@/src/generated' +import { formatDate, getBrowserIcon } from '@/src/lib/utils' + +interface SessionItemProps { + session: SessionResponse + isCurrentSession?: boolean +} + +export function SessionItem({ session, isCurrentSession }: SessionItemProps) { + const Icon = getBrowserIcon(session.browser) + + const queryClient = useQueryClient() + + const { mutate, isPending } = useMutation({ + mutationKey: ['revoke session'], + mutationFn: () => revokeSession(session.id), + onSuccess() { + queryClient.invalidateQueries({ queryKey: ['get sessions'] }) + }, + onError(error) { + if (error.message) { + toast.error(error.message) + } else { + toast.error('Ошибка при удалении сессии') + } + } + }) + + return ( + + +
+
+ +
+
+

+ {session.browser}, {session.os} +

+

+ {isCurrentSession && ( + + + + + + + Текущее устройство + + • + + )} + {session.city}, {session.country} + {!isCurrentSession && ( + <> • {formatDate(session.createdAt)} + )} +

+
+
+ {!isCurrentSession && } +
+
+ ) +} diff --git a/src/components/account/sessions/sessions.tsx b/src/components/account/sessions/sessions.tsx new file mode 100644 index 0000000..94e0894 --- /dev/null +++ b/src/components/account/sessions/sessions.tsx @@ -0,0 +1,49 @@ +'use client' + +import { useQuery } from '@tanstack/react-query' +import { Loader2 } from 'lucide-react' +import { Fragment } from 'react' + +import { Heading } from '../../shared/heading' + +import { RemoveAllSessions } from './remove-all-sessions' +import { SessionItem } from './session-item' +import { getSessions } from '@/src/api' + +export function Sessions() { + const { data, isLoading } = useQuery({ + queryKey: ['get sessions'], + queryFn: () => getSessions() + }) + + return ( +
+
+ {isLoading ? ( +
+ +
+ ) : ( + +
+ + +
+
+ {data?.map((session, index) => ( + + ))} +
+
+ )} +
+
+ ) +} diff --git a/src/components/account/settings/account-actions.tsx b/src/components/account/settings/account-actions.tsx new file mode 100644 index 0000000..ddc0461 --- /dev/null +++ b/src/components/account/settings/account-actions.tsx @@ -0,0 +1,58 @@ +import { useMutation } from '@tanstack/react-query' +import { useRouter } from 'next/navigation' +import { useState } from 'react' +import { toast } from 'sonner' + +import { ConfirmDialog } from '../../shared/confirm-dialog' +import { Button } from '../../ui/button' +import { Card, CardContent } from '../../ui/card' + +import { logout } from '@/src/api' + +export function AccountActions() { + const [isOpen, setIsOpen] = useState(false) + + const { push } = useRouter() + + const { mutate } = useMutation({ + mutationKey: ['logout'], + mutationFn: () => logout(), + onSuccess() { + setIsOpen(false) + push('/auth/login') + }, + onError(error) { + toast.error(error.message ?? 'Ошибка при выходе') + } + }) + + return ( +
+

Действия

+ + +
+
+
+

Выход

+

+ Завершите сеанс, чтобы выйти из аккаунта на + этом устройстве. +

+
+ mutate()} + > + + +
+
+
+
+
+ ) +} diff --git a/src/components/account/settings/account-form.tsx b/src/components/account/settings/account-form.tsx new file mode 100644 index 0000000..5bdaa95 --- /dev/null +++ b/src/components/account/settings/account-form.tsx @@ -0,0 +1,25 @@ +import { Card, CardContent } from '../../ui/card' + +import { EmailForm } from './email-form' +import { PasswordForm } from './password-form' +import type { AccountResponse } from '@/src/generated' + +interface AccountFormProps { + user: AccountResponse | undefined +} + +export function AccountForm({ user }: AccountFormProps) { + return ( +
+

Аккаунт

+ + +
+ + +
+
+
+
+ ) +} diff --git a/src/components/account/settings/avatar-form.tsx b/src/components/account/settings/avatar-form.tsx new file mode 100644 index 0000000..a1fa3b2 --- /dev/null +++ b/src/components/account/settings/avatar-form.tsx @@ -0,0 +1,71 @@ +import { useMutation } from '@tanstack/react-query' +import { ChangeEvent, useState } from 'react' +import { toast } from 'sonner' + +import { Avatar, AvatarFallback, AvatarImage } from '../../ui/avatar' +import { Input } from '../../ui/input' + +import { changeAvatar } from '@/src/api' +import type { AccountResponse } from '@/src/generated' +import { getMediaSource } from '@/src/lib/utils' + +interface AvatarFormProps { + user: AccountResponse | undefined +} + +export function AvatarForm({ user }: AvatarFormProps) { + const [preview, setPreview] = useState( + user?.avatar ? getMediaSource(user.avatar, 'users') : null + ) + + const { mutate } = useMutation({ + mutationKey: ['change user avatar'], + mutationFn: (data: FormData) => changeAvatar(data), + onSuccess: () => { + toast.success('Аватар успешно обновлён') + }, + onError(error) { + toast.error(error.message ?? 'Ошибка при обновлении аватара') + } + }) + + async function handleFileChange(event: ChangeEvent) { + const file = event.target.files?.[0] + + if (file) { + const formData = new FormData() + formData.append('file', file) + + console.log('File added to FormData:', formData.get('file')) + + mutate(formData) + } else { + toast.error('Пожалуйста, выберите файл') + } + } + + return ( +
+ +
+

Аватарка

+

+ Форматы: JPEG, PNG, WEBP, GIF. Макс. размер: 10 МБ. +

+
+
+ ) +} diff --git a/src/components/account/settings/disable-totp-form.tsx b/src/components/account/settings/disable-totp-form.tsx new file mode 100644 index 0000000..b234b78 --- /dev/null +++ b/src/components/account/settings/disable-totp-form.tsx @@ -0,0 +1,113 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useEffect, useState } from 'react' +import { useForm } from 'react-hook-form' +import { toast } from 'sonner' +import { z } from 'zod' + +import { ConfirmDialog } from '../../shared/confirm-dialog' +import { Button } from '../../ui/button' +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from '../../ui/form' +import { Input } from '../../ui/input' + +import { totpDisable } from '@/src/api' + +const disableTotpSchema = z.object({ + password: z + .string() + .min(6, { message: 'Пароль должен содержать хотя бы 6 символов' }) +}) + +export type DisableTotp = z.infer + +export function DisableTotpForm() { + const [isOpen, setIsOpen] = useState(false) + + const queryClient = useQueryClient() + + const { mutateAsync, isPending } = useMutation({ + mutationKey: ['totp disable'], + mutationFn: (data: DisableTotp) => totpDisable(data), + onSuccess() { + queryClient.invalidateQueries({ queryKey: ['mfa status'] }) + setIsOpen(false) + }, + onError(error) { + if (error.message) { + toast.error(error.message) + } else { + toast.error('Ошибка при отключении') + } + } + }) + + const form = useForm({ + resolver: zodResolver(disableTotpSchema), + defaultValues: { + password: '' + } + }) + + useEffect(() => { + form.reset() + }, [form, form.reset, form.formState.isSubmitSuccessful]) + + async function onSubmit(data: DisableTotp) { + await mutateAsync(data) + } + + return ( + +

+ Вы уверены, что хотите отключить этот метод + двухфакторной аутентификации?{' '} +

+ +
+ + ( + + Пароль + + + + + + )} + /> + + +
+ } + confirmText='Отключить' + destructive + handleConfirm={form.handleSubmit(onSubmit)} + isLoading={isPending} + open={isOpen} + onOpenChange={setIsOpen} + > + + + ) +} diff --git a/src/components/account/settings/display-name-form.tsx b/src/components/account/settings/display-name-form.tsx new file mode 100644 index 0000000..c92afe1 --- /dev/null +++ b/src/components/account/settings/display-name-form.tsx @@ -0,0 +1,101 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { useMutation } from '@tanstack/react-query' +import { useForm } from 'react-hook-form' +import { toast } from 'sonner' +import { z } from 'zod' + +import { Button } from '../../ui/button' +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from '../../ui/form' +import { Input } from '../../ui/input' + +import { patchUser } from '@/src/api' +import type { AccountResponse } from '@/src/generated' + +const displayNameSchema = z.object({ + displayName: z.string({ message: 'Имя обязательно' }) +}) + +export type DisplayName = z.infer + +interface DisplayNameFormProps { + user: AccountResponse | undefined +} + +export function DisplayNameForm({ user }: DisplayNameFormProps) { + const { mutateAsync, isPending } = useMutation({ + mutationKey: ['patch user'], + mutationFn: (data: DisplayName) => patchUser(data), + onSuccess() { + toast.success('Профиль обновлён') + }, + onError(error) { + toast.error(error.message ?? 'Ошибка при обновлении профиля') + } + }) + + const form = useForm({ + resolver: zodResolver(displayNameSchema), + values: { + displayName: user?.displayName ?? '' + } + }) + + const { isDirty } = form.formState + + async function onSubmit(data: DisplayName) { + await mutateAsync(data) + } + + return ( +
+
+ + ( + + Ваше имя + +
+ + {isDirty && ( +
+ +
+ )} +
+
+ + Измените ваше имя на любое, какое захотите. + + +
+ )} + /> + + +
+ ) +} diff --git a/src/components/account/settings/email-form.tsx b/src/components/account/settings/email-form.tsx new file mode 100644 index 0000000..fe9227e --- /dev/null +++ b/src/components/account/settings/email-form.tsx @@ -0,0 +1,182 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { Mail } from 'lucide-react' +import { useState } from 'react' +import { useForm } from 'react-hook-form' +import { toast } from 'sonner' +import { z } from 'zod' + +import { Button } from '../../ui/button' +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger +} from '../../ui/dialog' +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from '../../ui/form' +import { Input } from '../../ui/input' + +import { changeEmail } from '@/src/api' +import type { AccountResponse } from '@/src/generated' + +const emailSchema = z.object({ + email: z + .string() + .min(1, { message: 'Email обязателен' }) + .email({ message: 'Введите корректный адрес электронной почты' }) +}) + +export type Email = z.infer + +interface EmailFormProps { + user: AccountResponse | undefined +} + +export function EmailForm({ user }: EmailFormProps) { + const [isOpen, setIsOpen] = useState(false) + + const queryClient = useQueryClient() + + const { mutateAsync, isPending } = useMutation({ + mutationKey: ['change email'], + mutationFn: (data: Email) => changeEmail(data), + onSuccess() { + queryClient.invalidateQueries({ queryKey: ['get current'] }) + setIsOpen(false) + }, + onError(error) { + toast.error(error.message ?? 'Ошибка при смене почты') + } + }) + + const form = useForm({ + resolver: zodResolver(emailSchema), + defaultValues: { + email: '' + } + }) + + async function onSubmit(data: Email) { + await mutateAsync(data) + } + + return ( +
+
+
+ +
+
+
+

Почта

+ {/* {user?.isEmailVerified ? ( + Подтверждёна + ) : ( + Не подтверждёна + )} */} +
+

+ Ваша учетная запись привязана к адресу{' '} + + {user?.email} + + . На него мы отправляем важные уведомления и информацию + о безопасности. +

+
+
+
+ {/* + + + + + + {!user?.isEmailVerified && ( + + + Подтвердить + + )} + + + Изменить + + + + */} + + { + form.reset() + setIsOpen(state) + }} + > + + + + + + Обновление почты + + Введите новый почтовый адрес. + + +
+ + ( + + Почта + + + + + + )} + /> + + + + + + + + +
+
+
+
+ ) +} diff --git a/src/components/account/settings/enable-totp-form.tsx b/src/components/account/settings/enable-totp-form.tsx new file mode 100644 index 0000000..13ab56b --- /dev/null +++ b/src/components/account/settings/enable-totp-form.tsx @@ -0,0 +1,356 @@ +'use client' + +import { zodResolver } from '@hookform/resolvers/zod' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { Check, Copy, Loader2, TriangleAlert } from 'lucide-react' +import { useEffect, useState } from 'react' +import { useForm } from 'react-hook-form' +import { toast } from 'sonner' +import { z } from 'zod' + +import { Alert, AlertDescription, AlertTitle } from '../../ui/alert' +import { Badge } from '../../ui/badge' +import { Button } from '../../ui/button' +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger +} from '../../ui/dialog' +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from '../../ui/form' +import { Input } from '../../ui/input' + +import { fetchRecovery, totpEnable, totpGenerateSecret } from '@/src/api' + +const enableTotpSchema = z.object({ + pin: z + .string() + .min(6, { + message: 'PIN-код должен содержать минимум 6 символов' + }) + .max(6, { message: 'PIN-код должен содержать не более 6 символов' }), + secret: z.string() +}) + +export type EnableTotp = z.infer + +export function EnableTotpForm() { + const [isOpen, setIsOpen] = useState(false) + const [step, setStep] = useState(1) + + const [isCopied, setIsCopied] = useState(false) + + const queryClient = useQueryClient() + + const { + mutate, + data: totp, + isPending: isLoadingGenerate + } = useMutation({ + mutationKey: ['totp generate secret'], + mutationFn: () => totpGenerateSecret() + }) + + const { + data, + isLoading: isLoadingRecovery, + refetch + } = useQuery({ + queryKey: ['fetch recovery codes'], + queryFn: () => fetchRecovery(), + enabled: step === 2 + }) + + const { mutateAsync, isPending } = useMutation({ + mutationKey: ['totp enable'], + mutationFn: (data: EnableTotp) => totpEnable(data), + onSuccess() { + refetch() + queryClient.invalidateQueries({ queryKey: ['mfa status'] }) + setStep(2) + }, + onError(error) { + toast.error(error.message ?? 'Ошибка при верификации кода') + } + }) + + const form = useForm({ + resolver: zodResolver(enableTotpSchema), + defaultValues: { + pin: '', + secret: '' + } + }) + + function onCopy() { + if (!totp?.secret) return + + setIsCopied(true) + navigator.clipboard.writeText(totp?.secret) + setTimeout(() => { + setIsCopied(false) + }, 2000) + } + + const Icon = isCopied ? Check : Copy + + useEffect(() => { + if (isOpen) { + mutate() + } + }, [mutate, isOpen]) + + function splitArrayIntoColumns(arr: string[]) { + if (!arr || arr.length === 0) return [[], [], []] + + const mid = Math.ceil(arr.length / 3) + const first = arr.slice(0, mid) + const second = arr.slice(mid, mid * 2) + const third = arr.slice(mid * 2) + + return [first, second, third] + } + + function handleDownload() { + const recoveryCodesText = data?.join('\n') + + const blob = new Blob([recoveryCodesText!], { type: 'text/plain' }) + const fileURL = window.URL.createObjectURL(blob) + const link = document.createElement('a') + + link.href = fileURL + link.setAttribute('download', 'teacoder_recovery_codes.txt') + + document.body.appendChild(link) + link.click() + + setTimeout(() => window.URL.revokeObjectURL(fileURL), 0) + } + + async function onSubmit(data: EnableTotp) { + mutateAsync({ + pin: data.pin, + secret: totp?.secret ?? '' + }) + } + + const recoveryCodes = data ? splitArrayIntoColumns(data) : [[], [], []] + + return ( + { + form.reset() + setIsOpen(state) + }} + > + + + + + + + {step === 1 + ? 'Приложения для аутентификации' + : 'Коды восстановления'} + + + {step === 1 + ? 'При каждом входе в аккаунт, кроме пароля, потребуется одноразовый код из приложения-аутентификатора.' + : 'Эти коды помогут вам получить доступ к учетной записи, если вы потеряете доступ к устройству и не сможете получать коды двухфакторной аутентификации.'} + + + + {isLoadingGenerate ? ( +
+ +
+ ) : ( + step === 1 && ( +
+
+
+ Шаг 1 + Отсканируйте QR-код +
+

+ Отсканируйте QR-код ниже или введите + секретный ключ вручную в + приложение-аутентификатор. +

+
+ QR-код +
+

+ Проблемы с QR-кодом? +

+

+ Введите секретный ключ вручную: +

+
+ {totp?.secret} +
+ +
+
+
+
+
+ Шаг 2 + Верификация кода +
+

+ Введите шестизначный код из приложения. +

+
+ + ( + + + Введите код + + + + + + + )} + /> + + + + + + + + +
+
+ ) + )} + + {isLoadingRecovery ? ( +
+ +
+ ) : ( + step === 2 && ( +
+ + + + Пожалуйста, храните их в безопасном месте. + + + Они — последний способ восстановления + доступа к учетной записи. + + +
+
+ {recoveryCodes[0].map((code, index) => ( +

+ {code} +

+ ))} +
+
+ {recoveryCodes[1].map((code, index) => ( +

+ {code} +

+ ))} +
+
+ {recoveryCodes[2].map((code, index) => ( +

+ {code} +

+ ))} +
+
+ + + + +
+ + +
+
+
+ ) + )} +
+
+ ) +} diff --git a/src/components/account/settings/password-form.tsx b/src/components/account/settings/password-form.tsx new file mode 100644 index 0000000..e25e969 --- /dev/null +++ b/src/components/account/settings/password-form.tsx @@ -0,0 +1,178 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { useMutation } from '@tanstack/react-query' +import { KeyRound } from 'lucide-react' +import { useEffect, useState } from 'react' +import { useForm } from 'react-hook-form' +import { toast } from 'sonner' +import { z } from 'zod' + +import { Button } from '../../ui/button' +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger +} from '../../ui/dialog' +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from '../../ui/form' +import { Input } from '../../ui/input' + +import { changePassword } from '@/src/api' + +const passwordSchema = z.object({ + currentPassword: z + .string() + .min(6, { + message: 'Текущий пароль должен содержать хотя бы 6 символов' + }) + .max(128, { + message: 'Текущий пароль должен содержать не более 128 символов' + }), + newPassword: z + .string() + .min(6, { message: 'Новый пароль должен содержать хотя бы 6 символов' }) + .max(128, { + message: 'Новый пароль должен содержать не более 128 символов' + }) +}) + +export type Password = z.infer + +export function PasswordForm() { + const [isOpen, setIsOpen] = useState(false) + + const { mutateAsync, isPending } = useMutation({ + mutationKey: ['change password'], + mutationFn: (data: Password) => changePassword(data), + onSuccess() { + setIsOpen(false) + }, + onError(error) { + toast.error(error.message ?? 'Ошибка при смене пароля') + } + }) + + const form = useForm({ + resolver: zodResolver(passwordSchema), + defaultValues: { + currentPassword: '', + newPassword: '' + } + }) + + useEffect(() => { + form.reset() + }, [form, form.reset, form.formState.isSubmitSuccessful]) + + async function onSubmit(data: Password) { + await mutateAsync(data) + } + + return ( +
+
+
+ +
+
+

Пароль

+

+ Пароль — ключ к вашей учетной записи. Никому его не + сообщайте. При необходимости вы можете изменить его + здесь для повышения безопасности. +

+
+
+
+ { + form.reset() + setIsOpen(state) + }} + > + + + + + + Обновление пароля + + Введите текущий и новый пароль для обновления. + + +
+ + ( + + + Текущий пароль + + + + + + + )} + /> + ( + + Новый пароль + + + + + + )} + /> + + + + + + + + +
+
+
+
+ ) +} diff --git a/src/components/account/settings/profile-form.tsx b/src/components/account/settings/profile-form.tsx new file mode 100644 index 0000000..3aca4af --- /dev/null +++ b/src/components/account/settings/profile-form.tsx @@ -0,0 +1,25 @@ +import { Card, CardContent } from '../../ui/card' + +import { AvatarForm } from './avatar-form' +import { DisplayNameForm } from './display-name-form' +import type { AccountResponse } from '@/src/generated' + +interface ProfileForm { + user: AccountResponse | undefined +} + +export function ProfileForm({ user }: ProfileForm) { + return ( +
+

Профиль

+ + +
+ + +
+
+
+
+ ) +} diff --git a/src/components/account/settings/recovery-codes-modal.tsx b/src/components/account/settings/recovery-codes-modal.tsx new file mode 100644 index 0000000..93625c9 --- /dev/null +++ b/src/components/account/settings/recovery-codes-modal.tsx @@ -0,0 +1,143 @@ +'use client' + +import { useMutation, useQuery } from '@tanstack/react-query' +import { Download, RotateCcw, TriangleAlert } from 'lucide-react' +import { useState } from 'react' +import { toast } from 'sonner' + +import { Alert, AlertDescription, AlertTitle } from '../../ui/alert' +import { Button } from '../../ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogTitle, + DialogTrigger +} from '../../ui/dialog' +import { Separator } from '../../ui/separator' + +import { fetchRecovery, regenerateRecovery } from '@/src/api' + +export function RecoveryCodesModal() { + const [isOpen, setIsOpen] = useState(false) + + const { data, isLoading, refetch } = useQuery({ + queryKey: ['fetch recovery codes'], + queryFn: () => fetchRecovery(), + enabled: isOpen + }) + + const { mutate: regenerate, isPending } = useMutation({ + mutationKey: ['regenerate recovery codes'], + mutationFn: () => regenerateRecovery(), + onSuccess() { + refetch() + }, + onError(error) { + if (error.message) { + toast.error(error.message) + } else { + toast.error('Ошибка при генерации новых кодов') + } + } + }) + + function splitArrayIntoColumns(arr: string[]) { + if (!arr || arr.length === 0) return [[], [], []] + + const mid = Math.ceil(arr.length / 3) + const first = arr.slice(0, mid) + const second = arr.slice(mid, mid * 2) + const third = arr.slice(mid * 2) + + return [first, second, third] + } + + function handleDownload() { + const recoveryCodesText = data?.join('\n') + + const blob = new Blob([recoveryCodesText!], { type: 'text/plain' }) + const fileURL = window.URL.createObjectURL(blob) + const link = document.createElement('a') + + link.href = fileURL + link.setAttribute('download', 'teacoder_recovery_codes.txt') + + document.body.appendChild(link) + link.click() + + setTimeout(() => window.URL.revokeObjectURL(fileURL), 0) + } + + const recoveryCodes = data ? splitArrayIntoColumns(data) : [[], [], []] + + return ( + + + + + + Коды восстановления + + Эти коды помогут вам получить доступ к учетной записи, если + вы потеряете доступ к устройству и не сможете получать коды + двухфакторной аутентификации. + + + + + Пожалуйста, храните их в безопасном месте. + + + Они — последний способ восстановления доступа к учетной + записи. + + +
+
+ {recoveryCodes[0].map((code, index) => ( +

+ {code} +

+ ))} +
+
+ {recoveryCodes[1].map((code, index) => ( +

+ {code} +

+ ))} +
+
+ {recoveryCodes[2].map((code, index) => ( +

+ {code} +

+ ))} +
+
+ + + + + +
+
+ ) +} diff --git a/src/components/account/settings/settings.tsx b/src/components/account/settings/settings.tsx new file mode 100644 index 0000000..0940d1e --- /dev/null +++ b/src/components/account/settings/settings.tsx @@ -0,0 +1,42 @@ +'use client' + +import { useQuery } from '@tanstack/react-query' +import { Loader2 } from 'lucide-react' +import { Fragment } from 'react' + +import { Heading } from '../../shared/heading' + +import { AccountActions } from './account-actions' +import { AccountForm } from './account-form' +import { ProfileForm } from './profile-form' +import { TwoStepAuthForm } from './two-step-auth-form' +import { fetchMfaStatus } from '@/src/api' +import { useCurrent } from '@/src/hooks' + +export function Settings() { + const { user, isLoading } = useCurrent() + + const { data: status } = useQuery({ + queryKey: ['mfa status'], + queryFn: () => fetchMfaStatus() + }) + + return ( +
+
+ + +
+ + + + +
+
+
+
+ ) +} diff --git a/src/components/account/settings/two-step-auth-form.tsx b/src/components/account/settings/two-step-auth-form.tsx new file mode 100644 index 0000000..c79d1df --- /dev/null +++ b/src/components/account/settings/two-step-auth-form.tsx @@ -0,0 +1,88 @@ +import { ListOrdered, Smartphone } from 'lucide-react' + +import { Badge } from '../../ui/badge' +import { Card, CardContent } from '../../ui/card' + +import { DisableTotpForm } from './disable-totp-form' +import { EnableTotpForm } from './enable-totp-form' +import { RecoveryCodesModal } from './recovery-codes-modal' +import type { MfaStatusResponse } from '@/src/generated' + +interface TwoFactorAuthFormProps { + status: MfaStatusResponse | undefined +} + +export function TwoStepAuthForm({ status }: TwoFactorAuthFormProps) { + return ( +
+

+ Многофакторная аутентификация +

+ + +
+
+
+
+ +
+
+
+

+ Приложение для аутентификации +

+ {status?.totpMfa ? ( + + Включено + + ) : ( + + Отключено + + )} +
+

+ {status?.totpMfa + ? 'Двухфакторная аутентификация через TOTP включена. Для входа в аккаунт используйте приложение-аутентификатор, чтобы получить код.' + : 'Обеспечьте безопасность своего аккаунта с помощью двухфакторной аутентификации через TOTP.'} +

+
+
+
+ {status?.totpMfa ? ( + + ) : ( + + )} +
+
+ + {status?.recoveryActive && ( +
+
+
+ +
+
+

+ Коды восстановления +

+

+ Вы можете использовать коды + восстановления для доступа к + аккаунту, если потеряете доступ к + своему устройству. +

+
+
+
+ +
+
+ )} +
+
+
+
+ ) +} diff --git a/src/components/analitycs/yandex-metrika.tsx b/src/components/analitycs/yandex-metrika.tsx new file mode 100644 index 0000000..72db81a --- /dev/null +++ b/src/components/analitycs/yandex-metrika.tsx @@ -0,0 +1,37 @@ +import Script from 'next/script' + +interface YandexMetrikaProps { + id: string +} + +export function YandexMetrika({ id }: YandexMetrikaProps) { + return ( + <> + + + + + ) +} diff --git a/src/components/auth/auth-wrapper.tsx b/src/components/auth/auth-wrapper.tsx new file mode 100644 index 0000000..26aa110 --- /dev/null +++ b/src/components/auth/auth-wrapper.tsx @@ -0,0 +1,70 @@ +import Link from 'next/link' +import type { ReactNode } from 'react' + +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle +} from '../ui/card' + +interface Props { + children: ReactNode + heading: string + description?: string + backButtonLabel?: string + backButtonHref?: string +} + +export function AuthWrapper({ + children, + heading, + description, + backButtonLabel, + backButtonHref +}: Props) { + return ( +
+
+ + + {heading} + {description} + + + {/* {isShowSocial && } */} + {children} + + + {/* {backButtonLabel && backButtonHref && ( + + )} */} +

+ Нажимая продолжить, вы соглашаетесь с нашим{' '} + + Пользовательским соглашением + {' '} + и{' '} + + Политикой Конфиденциальности + + . +

+
+
+
+
+ ) +} diff --git a/src/components/auth/login-form.tsx b/src/components/auth/login-form.tsx new file mode 100644 index 0000000..579e838 --- /dev/null +++ b/src/components/auth/login-form.tsx @@ -0,0 +1,184 @@ +'use client' + +import { zodResolver } from '@hookform/resolvers/zod' +import { useMutation } from '@tanstack/react-query' +import Link from 'next/link' +import { useRouter } from 'next/navigation' +import { useEffect, useState } from 'react' +import { useForm } from 'react-hook-form' +import Turnstile from 'react-turnstile' +import { toast } from 'sonner' +import { z } from 'zod' + +import { Button } from '../ui/button' +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from '../ui/form' +import { Input } from '../ui/input' + +import { AuthWrapper } from './auth-wrapper' +import { MfaForm } from './mfa-form' +import { instance, login } from '@/src/api' +import { setSessionToken } from '@/src/lib/cookies/session' + +const loginSchema = z.object({ + email: z + .string() + .email({ message: 'Введите корректный адрес электронной почты' }), + password: z + .string() + .min(6, { message: 'Пароль должен содержать хотя бы 6 символов' }) + .max(128, { message: 'Пароль должен содержать не более 128 символов' }), + captcha: z.string() +}) + +export type Login = z.infer + +export function LoginForm() { + const { push } = useRouter() + + const [methods, setMethods] = useState([]) + const [ticket, setTicket] = useState(null) + + const { mutateAsync, isPending } = useMutation({ + mutationKey: ['login'], + mutationFn: (data: Login) => login(data), + onSuccess(data) { + if ('ticket' in data && typeof data.ticket === 'string') { + setTicket(data.ticket) + setMethods(data.allowedMethods) + } + + if ('token' in data && typeof data.token === 'string') { + setSessionToken(data.token) + + instance.defaults.headers['X-Session-Token'] = data.token + + push('/account/settings') + } + }, + onError(error) { + toast.error(error.message ?? 'Ошибка при входе') + } + }) + + const form = useForm({ + resolver: zodResolver(loginSchema), + defaultValues: { + email: '', + password: '', + captcha: '' + } + }) + + useEffect(() => { + if (form.formState.isSubmitSuccessful && form.getValues('captcha')) { + form.reset() + } + }, [form, form.reset, form.formState.isSubmitSuccessful]) + + async function onSubmit(data: Login) { + if (!data.captcha) { + toast.warning('Пройдите капчу!') + return + } + + await mutateAsync(data) + } + + return methods.length ? ( + + ) : ( + +
+ +
+ ( + + Почта + + + + + + )} + /> + ( + +
+ Пароль + + Забыли пароль? + +
+ + + + +
+ )} + /> + ( + + + + form.setValue('captcha', token) + } + theme='light' + {...field} + /> + + + )} + /> + +
+
+ +
+ ) +} diff --git a/src/components/auth/mfa-form.tsx b/src/components/auth/mfa-form.tsx new file mode 100644 index 0000000..31581ed --- /dev/null +++ b/src/components/auth/mfa-form.tsx @@ -0,0 +1,183 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { useMutation } from '@tanstack/react-query' +import { Key, KeyRound, Smartphone } from 'lucide-react' +import { useRouter } from 'next/navigation' +import { useState } from 'react' +import { useForm } from 'react-hook-form' +import { toast } from 'sonner' +import { z } from 'zod' + +import { Button } from '../ui/button' +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from '../ui/form' +import { Input } from '../ui/input' +import { Tabs, TabsList, TabsTrigger } from '../ui/tabs' + +import { AuthWrapper } from './auth-wrapper' +import { api, instance } from '@/src/api' +import { LoginSessionResponse } from '@/src/generated' +import { setSessionToken } from '@/src/lib/cookies/session' + +const totpSchema = z.object({ + totpCode: z + .string() + .min(6, { message: 'Введите корректный код' }) + .max(6, { message: 'Код должен быть 6 символов' }) +}) + +const recoverySchema = z.object({ + recoveryCode: z + .string() + .min(11, { message: 'Введите корректный код восстановления' }) + .max(11, { message: 'Код должен быть 11 символов' }) +}) + +type Mfa = z.infer + +interface MfaFormProps { + ticket: string + methods: string[] +} + +export function MfaForm({ ticket, methods }: MfaFormProps) { + const { push } = useRouter() + + const [method, setMethod] = useState<'totp' | 'recovery'>('totp') + + const schema = method === 'totp' ? totpSchema : recoverySchema + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { + totpCode: '', + recoveryCode: '' + } + }) + + const { mutateAsync, isPending } = useMutation({ + mutationKey: ['verify mfa'], + mutationFn: async (data: Mfa) => { + const payload = + method === 'totp' + ? // @ts-ignore + { ticket, totpCode: data.totpCode } + : // @ts-ignore + { ticket, recoveryCode: data.recoveryCode } + + const response = await api.post( + '/auth/mfa/verify', + payload + ) + + return response.data + }, + onSuccess(data) { + setSessionToken(data.token) + + instance.defaults.headers['X-Session-Token'] = data.token + + push('/account/settings') + }, + onError(error) { + toast.error(error.message || 'Ошибка при верификации') + } + }) + + async function onSubmit(data: Mfa) { + mutateAsync(data) + } + + return ( + + { + form.reset() + setMethod(value as 'totp' | 'recovery') + }} + > + + + + TOTP-код + + + + Код восстановления + + + + +
+ +
+ {method === 'totp' && ( + ( + + TOTP-код + + + + + + )} + /> + )} + {method === 'recovery' && ( + ( + + + Код восстановления + + + + + + + )} + /> + )} + +
+
+ +
+ ) +} diff --git a/src/components/auth/new-password-form.tsx b/src/components/auth/new-password-form.tsx new file mode 100644 index 0000000..a2d36c9 --- /dev/null +++ b/src/components/auth/new-password-form.tsx @@ -0,0 +1,115 @@ +'use client' + +import { zodResolver } from '@hookform/resolvers/zod' +import { useMutation } from '@tanstack/react-query' +import { useParams, useRouter } from 'next/navigation' +import { useEffect } from 'react' +import { useForm } from 'react-hook-form' +import { toast } from 'sonner' +import { z } from 'zod' + +import { Button } from '../ui/button' +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from '../ui/form' +import { Input } from '../ui/input' + +import { AuthWrapper } from './auth-wrapper' +import { passwordReset } from '@/src/api' + +const newPasswordSchema = z.object({ + token: z.string().max(128, { message: 'Некорректный токен' }), + password: z + .string() + .min(6, { message: 'Пароль должен содержать хотя бы 6 символов' }) + .max(128, { message: 'Пароль должен содержать не более 128 символов' }) +}) + +export type NewPassword = z.infer + +export function NewPasswordForm() { + const { push } = useRouter() + const { token } = useParams<{ token: string }>() + + const { mutateAsync, isPending } = useMutation({ + mutationKey: ['password reset'], + mutationFn: (data: NewPassword) => passwordReset(data), + onSuccess() { + push('/auth/login') + }, + onError(error) { + if (error.message) { + toast.error(error.message) + } else { + toast.error('Ошибка при регистрации') + } + } + }) + + const form = useForm({ + resolver: zodResolver(newPasswordSchema), + defaultValues: { + token: '', + password: '' + } + }) + + useEffect(() => { + form.reset() + }, [form, form.reset, form.formState.isSubmitSuccessful]) + + async function onSubmit(data: NewPassword) { + await mutateAsync({ + token, + password: data.password + }) + } + + return ( + +
+ + ( + + Пароль + + + + + + )} + /> + + + +
+ ) +} diff --git a/src/components/auth/register-form.tsx b/src/components/auth/register-form.tsx new file mode 100644 index 0000000..2ed86e5 --- /dev/null +++ b/src/components/auth/register-form.tsx @@ -0,0 +1,177 @@ +'use client' + +import { zodResolver } from '@hookform/resolvers/zod' +import { useMutation } from '@tanstack/react-query' +import { useRouter } from 'next/navigation' +import { useEffect } from 'react' +import { useForm } from 'react-hook-form' +import Turnstile from 'react-turnstile' +import { toast } from 'sonner' +import { z } from 'zod' + +import { Button } from '../ui/button' +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from '../ui/form' +import { Input } from '../ui/input' + +import { AuthWrapper } from './auth-wrapper' +import { createAccount } from '@/src/api' + +const registerSchema = z.object({ + name: z.string().min(1, { message: 'Имя обязательно' }), + email: z + .string() + .min(1, { message: 'Email обязателен' }) + .email({ message: 'Введите корректный адрес электронной почты' }), + password: z + .string() + .min(6, { message: 'Пароль должен содержать хотя бы 6 символов' }) + .max(128, { message: 'Пароль должен содержать не более 128 символов' }), + captcha: z.string() +}) + +export type Register = z.infer + +export function RegisterForm() { + const { push } = useRouter() + + const { mutateAsync, isPending } = useMutation({ + mutationKey: ['register'], + mutationFn: (data: Register) => createAccount(data), + onSuccess() { + push('/account') + }, + onError(error) { + toast.error(error.message ?? 'Ошибка при регистрации') + } + }) + + const form = useForm({ + resolver: zodResolver(registerSchema), + defaultValues: { + name: '', + email: '', + password: '', + captcha: '' + } + }) + + useEffect(() => { + if (form.formState.isSubmitSuccessful && form.getValues('captcha')) { + form.reset() + } + }, [form, form.reset, form.formState.isSubmitSuccessful]) + + async function onSubmit(data: Register) { + if (!data.captcha) { + toast.warning('Пройдите капчу!') + return + } + + await mutateAsync(data) + } + + return ( + +
+ +
+ ( + + Имя + + + + + + )} + /> + ( + + Почта + + + + + + )} + /> + ( + + Пароль + + + + + + )} + /> + ( + + + + form.setValue('captcha', token) + } + theme='light' + {...field} + /> + + + )} + /> + +
+
+ +
+ ) +} diff --git a/src/components/auth/reset-password-form.tsx b/src/components/auth/reset-password-form.tsx new file mode 100644 index 0000000..6cf8580 --- /dev/null +++ b/src/components/auth/reset-password-form.tsx @@ -0,0 +1,139 @@ +'use client' + +import { zodResolver } from '@hookform/resolvers/zod' +import { useMutation } from '@tanstack/react-query' +import { useEffect } from 'react' +import { useForm } from 'react-hook-form' +import Turnstile from 'react-turnstile' +import { toast } from 'sonner' +import { z } from 'zod' + +import { Button } from '../ui/button' +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from '../ui/form' +import { Input } from '../ui/input' + +import { AuthWrapper } from './auth-wrapper' +import { sendPasswordReset } from '@/src/api' + +const resetPasswordSchema = z.object({ + email: z + .string() + .email({ message: 'Введите корректный адрес электронной почты' }), + captcha: z.string() +}) + +export type ResetPassword = z.infer + +export function ResetPasswordForm() { + const { mutateAsync, isPending } = useMutation({ + mutationKey: ['send password reset'], + mutationFn: (data: ResetPassword) => sendPasswordReset(data), + onSuccess() { + form.reset() + toast.success('Письмо с инструкциями отправлено на вашу почту') + }, + onError(error) { + if (error.message) { + toast.error(error.message) + } else { + toast.error('Ошибка при сбросе пароля') + } + } + }) + + const form = useForm({ + resolver: zodResolver(resetPasswordSchema), + defaultValues: { + email: '', + captcha: '' + } + }) + + useEffect(() => { + if (form.formState.isSubmitSuccessful && form.getValues('captcha')) { + form.reset() + } + }, [form, form.reset, form.formState.isSubmitSuccessful]) + + async function onSubmit(data: ResetPassword) { + if (!data.captcha) { + toast.warning('Пройдите капчу!') + return + } + + await mutateAsync(data) + } + + return ( + +
+ +
+ ( + + Почта + + + + + + )} + /> + ( + + + + form.setValue('captcha', token) + } + theme='light' + {...field} + /> + + + )} + /> + +
+
+ +
+ ) +} diff --git a/src/components/course/course-card.tsx b/src/components/course/course-card.tsx new file mode 100644 index 0000000..da64751 --- /dev/null +++ b/src/components/course/course-card.tsx @@ -0,0 +1,62 @@ +import { BookOpen } from 'lucide-react' +import Image from 'next/image' +import Link from 'next/link' +import { FaYoutube } from 'react-icons/fa' + +import { Badge } from '../ui/badge' + +import type { CourseResponse } from '@/src/generated' +import { getMediaSource } from '@/src/lib/utils' + +interface CourseCardProps { + course: CourseResponse +} + +export function CourseCard({ course }: CourseCardProps) { + return ( + +
+ {course.title} +
+
+

+ {course.title} +

+

+ {course.description} +

+ 0 ? 'default' : 'error'} + className='mt-3' + > + {course.lessons > 0 ? ( + <> + + {course.lessons} уроков` + + ) : ( + <> + + Youtube + + )} + +
+ + ) +} diff --git a/src/components/course/course-details.tsx b/src/components/course/course-details.tsx new file mode 100644 index 0000000..e8f5da6 --- /dev/null +++ b/src/components/course/course-details.tsx @@ -0,0 +1,92 @@ +'use client' + +import { useQuery } from '@tanstack/react-query' +import { CheckCircle, ChevronRight } from 'lucide-react' +import Link from 'next/link' +import { useRef, useState } from 'react' + +import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs' + +import { CourseInfo } from './course-info' +import { CourseLessons } from './course-lessons' +import { CourseOverview } from './course-overview' +import { CourseProgress } from './course-progress' +import { CourseSummary } from './course-summary' +import { getCompletedLessons } from '@/src/api' +import type { CourseResponse, LessonResponse } from '@/src/generated' +import { useAuth } from '@/src/hooks' + +interface CourseDetailsProps { + course: CourseResponse + lessons: LessonResponse[] +} + +export function CourseDetails({ course, lessons }: CourseDetailsProps) { + const [activeTab, setActiveTab] = useState('overview') + const lessonsRef = useRef(null) + + const { isAuthorized } = useAuth() + + const { data: completedLessons, isLoading } = useQuery({ + queryKey: ['get completed lessons'], + queryFn: () => getCompletedLessons(course.id), + enabled: isAuthorized + }) + + if (isLoading) return
Загрузка...
+ + return ( +
+ + {isAuthorized && lessons.length > 0 && ( + + )} + +
+
+ {lessons.length ? ( + + + + Описание + + Уроки + + + + + + + + + + + ) : ( + + )} +
+
+ +
+
+
+ ) +} diff --git a/src/components/course/course-info.tsx b/src/components/course/course-info.tsx new file mode 100644 index 0000000..6c6983c --- /dev/null +++ b/src/components/course/course-info.tsx @@ -0,0 +1,90 @@ +import { BookOpen } from 'lucide-react' + +import { Button } from '../ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '../ui/card' + +import type { CourseResponse, LessonResponse } from '@/src/generated' +import { useAuth } from '@/src/hooks' + +interface CourseInfoProps { + course: CourseResponse + lessons: LessonResponse[] + completedLessons: string[] +} + +export function CourseInfo({ + course, + lessons, + completedLessons +}: CourseInfoProps) { + const { isAuthorized } = useAuth() + + const formattedDate = new Date(course.createdAt).toLocaleDateString( + 'ru-RU', + { + day: 'numeric', + month: 'long', + year: 'numeric' + } + ) + + const progressPercentage = + (lessons?.length ?? 0) > 0 && (completedLessons?.length ?? 0) >= 0 + ? Math.round( + ((completedLessons?.length ?? 0) / (lessons?.length ?? 0)) * + 100 + ) + : 0 + + return ( + + + Информация + + +
+
+

+ Добавлен +

+

{formattedDate}

+
+ +
+

+ Количество уроков +

+ {course.youtubeUrl && + (lessons?.length > 0 ? ( +

+ {lessons.length} уроков +

+ ) : ( +

+ Доступно на YouTube +

+ ))} +
+ + {isAuthorized && lessons.length > 0 && ( +
+

+ Ваш прогресс +

+

+ {progressPercentage}% завершено +

+
+ )} +
+ + {/*
+ +
*/} +
+
+ ) +} diff --git a/src/components/course/course-lessons.tsx b/src/components/course/course-lessons.tsx new file mode 100644 index 0000000..82d10d6 --- /dev/null +++ b/src/components/course/course-lessons.tsx @@ -0,0 +1,74 @@ +import { CheckCircle, ChevronRight } from 'lucide-react' +import Link from 'next/link' + +import type { LessonResponse } from '@/src/generated' +import { useAuth } from '@/src/hooks' + +interface CourseLessonsProps { + lessons: LessonResponse[] + completedLessons: string[] +} + +export function CourseLessons({ + lessons = [], + completedLessons = [] +}: CourseLessonsProps) { + const { isAuthorized } = useAuth() + + const totalLessons = lessons?.length ?? 0 + const completedCount = completedLessons?.length ?? 0 + + return ( +
+
+

Уроки

+ {isAuthorized && ( +

+ {totalLessons} уроков • {completedCount} выполнено +

+ )} +
+
    + {lessons.map(lesson => { + const isCompleted = completedLessons.includes(lesson.id) + + return ( +
  • + +
    +
    + {isCompleted ? ( + + ) : ( + + {lesson.position} + + )} +
    + +
    +

    + {lesson.title} +

    + {lesson.description && ( +

    + {lesson.description} +

    + )} +
    +
    + +
  • + ) + })} +
+
+ ) +} diff --git a/src/components/course/course-overview.tsx b/src/components/course/course-overview.tsx new file mode 100644 index 0000000..89f0990 --- /dev/null +++ b/src/components/course/course-overview.tsx @@ -0,0 +1,41 @@ +import { Fragment } from 'react' + +import type { CourseResponse } from '@/src/generated' + +interface CourseOverviewProps { + course: CourseResponse +} + +export function CourseOverview({ course }: CourseOverviewProps) { + return ( + +
+

О курсе

+

{course.description}

+
+ {/*
+

What you'll learn

+
    +
  • + + + Build modern web applications with Next.js 14 + +
  • +
  • + + Master React Server Components +
  • +
  • + + Implement authentication and authorization +
  • +
  • + + Deploy your application to production +
  • +
+
*/} +
+ ) +} diff --git a/src/components/course/course-progress.tsx b/src/components/course/course-progress.tsx new file mode 100644 index 0000000..ac55428 --- /dev/null +++ b/src/components/course/course-progress.tsx @@ -0,0 +1,28 @@ +import { CourseProgress as Progress } from '../shared/course-progress' + +interface CourseProgressProps { + totalLessons: number + completedLessons: number +} + +export function CourseProgress({ + totalLessons, + completedLessons +}: CourseProgressProps) { + const progressPercentage = + totalLessons > 0 + ? Math.round((completedLessons / totalLessons) * 100) + : 0 + + return ( +
+
+ Ваш прогресс + + {completedLessons}/{totalLessons} уроков + +
+ +
+ ) +} diff --git a/src/components/course/course-summary.tsx b/src/components/course/course-summary.tsx new file mode 100644 index 0000000..d460e8d --- /dev/null +++ b/src/components/course/course-summary.tsx @@ -0,0 +1,108 @@ +import { BookOpen, Code, Eye } from 'lucide-react' +import Image from 'next/image' +import Link from 'next/link' +import type { Dispatch, SetStateAction } from 'react' +import { FaYoutube } from 'react-icons/fa' + +import { Badge } from '../ui/badge' +import { Button } from '../ui/button' + +import type { CourseResponse, LessonResponse } from '@/src/generated' +import { useAuth } from '@/src/hooks' +import { getMediaSource } from '@/src/lib/utils' + +interface CourseSummaryProps { + course: CourseResponse + lessons: LessonResponse[] + setActiveTab: Dispatch> +} + +export function CourseSummary({ + course, + lessons, + setActiveTab +}: CourseSummaryProps) { + const { isAuthorized } = useAuth() + + return ( +
+
+
+ +
+
+
+ + {course.views}{' '} + просмотров + +
+ +

+ {course.title} +

+ +

+ {course.description?.split('.').slice(0, 2).join('.') + + '.'} +

+ +
+ {lessons.length > 0 && ( + + )} + + {course.youtubeUrl && ( + + )} + + {course.attachment && ( + + )} +
+
+ +
+ {course.title} +
+
+
+ ) +} diff --git a/src/components/home/features.tsx b/src/components/home/features.tsx new file mode 100644 index 0000000..a757ac9 --- /dev/null +++ b/src/components/home/features.tsx @@ -0,0 +1,59 @@ +import { AreaChart, BookOpen, Code } from 'lucide-react' + +export const Features = () => { + const features = [ + { + icon: , + title: 'Разнообразие курсов', + description: + 'На платформе есть курсы по программированию и другим темам. Все уроки доступны в любое время, так что ты можешь учиться в удобном ритме.' + }, + { + icon: , + title: 'Отслеживание прогресса', + description: + 'Следи за своими достижениями, выполняй задания и зарабатывай очки. Вся статистика сохраняется в профиле, чтобы ты видел свой рост.' + }, + { + icon: , + title: 'Практика с кодом', + description: + 'Все курсы включают видеоуроки и реальные примеры кода. Ты сможешь не только изучать теорию, но и сразу применять её на практике.' + } + ] + + return ( +
+
+

+ Что тебя ждёт на платформе? +

+

+ Получи доступ к удобным курсам по программированию, + отслеживай свой прогресс и практикуйся с реальными примерами + кода +

+
+
+ {features.map((reason, index) => ( +
+
+
+ {reason.icon} +
+

+ {reason.title} +

+

+ {reason.description} +

+
+
+ ))} +
+
+ ) +} diff --git a/src/components/home/hero.tsx b/src/components/home/hero.tsx new file mode 100644 index 0000000..1ee6819 --- /dev/null +++ b/src/components/home/hero.tsx @@ -0,0 +1,35 @@ +import { Terminal } from 'lucide-react' +import Link from 'next/link' + +import { Button } from '../ui/button' + +export function Hero() { + return ( +
+
+

+ Изучай веб-разработку +
с TeaCoder +

+

+ Платформа для изучения веб-разработки с гибким подходом. + Получи доступ к курсам, которые помогут развить навыки и + разобраться в реальных примерах +

+
+
+ +
+
+ ) +} diff --git a/src/components/home/popular.tsx b/src/components/home/popular.tsx new file mode 100644 index 0000000..70393ed --- /dev/null +++ b/src/components/home/popular.tsx @@ -0,0 +1,27 @@ +import { CourseCard } from '../course/course-card' + +import { CourseResponse } from '@/src/generated' + +interface PopularProps { + courses: CourseResponse[] +} + +export function Popular({ courses }: PopularProps) { + return ( +
+
+

+ Популярные курсы +

+

+ Cамые популярные курсы среди пользователей платформы +

+
+
+ {courses.map((course, index) => ( + + ))} +
+
+ ) +} diff --git a/src/components/home/telegram-cta.tsx b/src/components/home/telegram-cta.tsx new file mode 100644 index 0000000..d16705e --- /dev/null +++ b/src/components/home/telegram-cta.tsx @@ -0,0 +1,35 @@ +import Link from 'next/link' +import { FaTelegram } from 'react-icons/fa6' + +import { Button } from '../ui/button' + +export function TelegramCTA() { + return ( +
+

+ Присоединяйся в Telegram +

+ +

+ Подписывайся на наш канал! Получай последние новости, общайся с + единомышленниками и будь в курсе самых актуальных событий. +

+ + +
+ ) +} diff --git a/src/components/layout/footer.tsx b/src/components/layout/footer.tsx new file mode 100644 index 0000000..4f4fbf2 --- /dev/null +++ b/src/components/layout/footer.tsx @@ -0,0 +1,130 @@ +import { ExternalLink } from 'lucide-react' +import Link from 'next/link' +import { JSX } from 'react' +import { FaGithub } from 'react-icons/fa' +import { FaTelegram, FaYoutube } from 'react-icons/fa6' +import { SiBoosty } from 'react-icons/si' + +interface NavLink { + title: string + href: string + isExternal?: boolean +} + +interface SocialLink { + href: string + icon: JSX.Element + label: string +} + +const generalLinks: NavLink[] = [ + { title: 'Курсы', href: '/courses' }, + { title: 'Об основателе', href: '/about' }, + { + title: 'API документация', + href: 'https://api.teacoder.ru/docs', + isExternal: true + } +] + +const documentsLinks: NavLink[] = [ + { title: 'Пользовательское соглашение', href: '/document/terms-of-use' }, + { title: 'Политика конфиденциальности', href: '/document/privacy-policy' } +] + +const socialLinks: SocialLink[] = [ + { + href: 'https://youtube.com/@TeaCoder52', + icon: , + label: 'YouTube' + }, + { + href: 'https://t.me/TeaCoder_official', + icon: , + label: 'Telegram' + }, + { + href: 'https://github.com/teacoder-team', + icon: , + label: 'GitHub' + }, + { + href: 'https://boosty.to/teacoder', + icon: , + label: 'Boosty' + } +] + +export function Footer() { + return ( +
+
+
+
+

TeaCoder

+

+ Образовательная платформа по веб-разработке +

+
+
+

Общие ссылки

+
    + {generalLinks.map((link, index) => ( + + ))} +
+
+ +
+

Документы

+
    + {documentsLinks.map((link, index) => ( + + ))} +
+
+ +
+

Соц. сети

+
+ {socialLinks.map((link, index) => ( + + ))} +
+
+
+
+
+

+ TeaCoder © {new Date().getFullYear()} Все права защищены. +

+
+
+ ) +} + +const FooterLink = ({ title, href, isExternal }: NavLink) => ( +
  • + + {isExternal && } + {title} + +
  • +) + +const SocialLink = ({ href, icon, label }: SocialLink) => ( + + {icon} + {label} + +) diff --git a/src/components/layout/header.tsx b/src/components/layout/header.tsx new file mode 100644 index 0000000..63b341c --- /dev/null +++ b/src/components/layout/header.tsx @@ -0,0 +1,49 @@ +'use client' + +import Link from 'next/link' + +import { Logo } from '../shared/logo' +import { Button } from '../ui/button' + +import { MobileNav } from './mobile-nav' +import { NavLinks } from './nav-links' +import { UserMenu } from './user-menu' +import { useAuth } from '@/src/hooks' + +export function Header() { + const { isAuthorized } = useAuth() + + return ( +
    +
    +
    + + + TeaCoder + + +
    + {isAuthorized ? ( + + ) : ( +
    + + +
    + )} +
    + +
    +
    +
    + ) +} diff --git a/src/components/layout/mobile-nav.tsx b/src/components/layout/mobile-nav.tsx new file mode 100644 index 0000000..881463b --- /dev/null +++ b/src/components/layout/mobile-nav.tsx @@ -0,0 +1,135 @@ +import { useMutation } from '@tanstack/react-query' +import { ChartArea, LogOut, Menu, Settings } from 'lucide-react' +import Link from 'next/link' +import { useRouter } from 'next/navigation' +import { useState } from 'react' +import { toast } from 'sonner' + +import { Button } from '../ui/button' +import { Separator } from '../ui/separator' +import { + Sheet, + SheetContent, + SheetDescription, + SheetTitle, + SheetTrigger +} from '../ui/sheet' + +import { navLinks } from './nav-links' +import { logout } from '@/src/api' +import { useAuth } from '@/src/hooks' + +export function MobileNav() { + const [isOpen, setIsOpen] = useState(false) + + const router = useRouter() + + const { isAuthorized } = useAuth() + + const { mutate } = useMutation({ + mutationKey: ['logout'], + mutationFn: () => logout(), + onSuccess() { + setIsOpen(false) + router.push('/auth/login') + }, + onError(error) { + toast.error(error.message ?? 'Ошибка при выходе') + } + }) + + return ( + + + + + +
    + TeaCoder + + Образовательная платформа по веб разработке + +
    +
    + {navLinks.map((link, index) => ( + + ))} + + {isAuthorized ? ( + <> + + + + + ) : ( + <> + + + + )} +
    +
    +
    + ) +} diff --git a/src/components/layout/nav-links.tsx b/src/components/layout/nav-links.tsx new file mode 100644 index 0000000..6cc9a89 --- /dev/null +++ b/src/components/layout/nav-links.tsx @@ -0,0 +1,25 @@ +import Link from 'next/link' + +import { Button } from '../ui/button' + +interface NavLink { + title: string + href: string +} + +export const navLinks: NavLink[] = [ + { title: 'Курсы', href: '/courses' }, + { title: 'Об основателе', href: '/about' } +] + +export function NavLinks() { + return ( + + ) +} diff --git a/src/components/layout/user-menu.tsx b/src/components/layout/user-menu.tsx new file mode 100644 index 0000000..e273a64 --- /dev/null +++ b/src/components/layout/user-menu.tsx @@ -0,0 +1,95 @@ +'use client' + +import { useMutation } from '@tanstack/react-query' +import { ChartArea, LogOut, Settings } from 'lucide-react' +import Link from 'next/link' +import { useRouter } from 'next/navigation' +import { toast } from 'sonner' + +import { Avatar, AvatarFallback, AvatarImage } from '../ui/avatar' +import { Button } from '../ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger +} from '../ui/dropdown-menu' + +import { logout } from '@/src/api' +import { useCurrent } from '@/src/hooks/use-current' +import { getMediaSource } from '@/src/lib/utils' + +export function UserMenu() { + const router = useRouter() + + const { user } = useCurrent() + + const { mutate } = useMutation({ + mutationKey: ['logout'], + mutationFn: () => logout(), + onSuccess() { + router.push('/auth/login') + }, + onError(error) { + toast.error(error.message ?? 'Ошибка при выходе') + } + }) + + return ( + + + + + + +
    +

    + {user?.displayName} +

    +

    + {user?.email} +

    +
    +
    + + + + + + Мой прогресс + + + + + + Настройки + + + mutate()} + className='!text-rose-600' + > + + Выйти + + +
    +
    + ) +} diff --git a/src/components/layout/user-navigation.tsx b/src/components/layout/user-navigation.tsx new file mode 100644 index 0000000..274ad91 --- /dev/null +++ b/src/components/layout/user-navigation.tsx @@ -0,0 +1,60 @@ +'use client' + +import { ChartArea, MonitorSmartphone, Settings } from 'lucide-react' +import Link from 'next/link' +import { usePathname } from 'next/navigation' + +import { buttonVariants } from '../ui/button' + +import { cn } from '@/src/lib/utils' + +export const links = [ + { + title: 'Мой прогресс', + href: '/account', + icon: ChartArea + }, + { + title: 'Настройки аккаунта', + href: '/account/settings', + icon: Settings + }, + { + title: 'Устройства', + href: '/account/sessions', + icon: MonitorSmartphone + } +] + +export function UserNavigation() { + const pathname = usePathname() + + return ( +
    + +
    + ) +} diff --git a/src/components/lesson/lesson-complete-button.tsx b/src/components/lesson/lesson-complete-button.tsx new file mode 100644 index 0000000..ee3bca2 --- /dev/null +++ b/src/components/lesson/lesson-complete-button.tsx @@ -0,0 +1,92 @@ +'use client' + +import { useMutation } from '@tanstack/react-query' +import { CircleCheckBig, CircleX } from 'lucide-react' +import { useRouter } from 'next/navigation' +import { toast } from 'sonner' + +import { Button } from '../ui/button' + +import { createProgress } from '@/src/api' +import type { LessonResponse } from '@/src/generated' +import { cn } from '@/src/lib/utils' + +interface LessonCompleteButtonProps { + lesson: LessonResponse + completedLessons: string[] +} + +export function LessonCompleteButton({ + lesson, + completedLessons +}: LessonCompleteButtonProps) { + const { push, refresh } = useRouter() + + const isCompleted = completedLessons.includes(lesson.id) + + const { mutate, isPending } = useMutation({ + mutationKey: ['create progress course'], + mutationFn: () => + createProgress({ + isCompleted: !isCompleted, + lessonId: lesson.id + }), + onSuccess(data) { + refresh() + + if (data.nextLesson && data.isCompleted) + push(`/lesson/${data.nextLesson}`) + + if (!data.nextLesson && data.isCompleted) { + } + }, + onError(error) { + if (error.message) { + toast.error(error.message) + } else { + toast.error('Ошибка при обновлении прогресса') + } + } + }) + + const Icon = isCompleted ? CircleX : CircleCheckBig + + return ( +
    +
    +
    +

    + {isCompleted + ? 'Вы завершили этот урок!' + : 'Вы готовы завершить этот урок?'} +

    +

    + {isCompleted + ? 'Отличная работа! Вы можете посмотреть свою статистику в личном кабинете.' + : 'Не забудьте завершить урок, когда будете готовы.'} +

    +
    + +
    +
    + ) +} diff --git a/src/components/lesson/lesson-container.tsx b/src/components/lesson/lesson-container.tsx new file mode 100644 index 0000000..d9f33d7 --- /dev/null +++ b/src/components/lesson/lesson-container.tsx @@ -0,0 +1,19 @@ +import type { ReactNode } from 'react' + +interface LessonContainerProps { + children: ReactNode +} + +export function LessonContainer({ children }: LessonContainerProps) { + return ( +
    +
    +
    +
    + {children} +
    +
    +
    +
    + ) +} diff --git a/src/components/lesson/lesson-player.tsx b/src/components/lesson/lesson-player.tsx new file mode 100644 index 0000000..221ce33 --- /dev/null +++ b/src/components/lesson/lesson-player.tsx @@ -0,0 +1,37 @@ +'use client' + +import dynamic from 'next/dynamic' +import { useRef, useState } from 'react' + +import { type KinescopePlayer } from '../shared/player' + +interface LessonPlayerProps { + videoId: string +} + +const Player = dynamic( + () => import('../shared/player').then(mod => mod.Player), + { + ssr: false + } +) + +export function LessonPlayer({ videoId }: LessonPlayerProps) { + const playerRef = useRef(null) + const [isLoading, setIsLoading] = useState(true) + + function handlePlayerReady() { + setIsLoading(false) + } + + return ( +
    + {isLoading &&

    Загрузка...

    } + +
    + ) +} diff --git a/src/components/lesson/lesson-sidebar.tsx b/src/components/lesson/lesson-sidebar.tsx new file mode 100644 index 0000000..e40da80 --- /dev/null +++ b/src/components/lesson/lesson-sidebar.tsx @@ -0,0 +1,119 @@ +'use client' + +import { + ArrowLeft, + CircleCheckBig, + Library, + PauseCircle, + PlayCircle +} from 'lucide-react' +import Link from 'next/link' +import { usePathname } from 'next/navigation' + +import { CourseProgress } from '../shared/course-progress' +import { buttonVariants } from '../ui/button' +import { ScrollArea } from '../ui/scroll-area' + +import type { CourseResponse, LessonResponse } from '@/src/generated' +import { cn } from '@/src/lib/utils' + +interface LessonSidebarProps { + course: CourseResponse + lessons: LessonResponse[] + completedLessons: string[] + progressCount: number +} + +export function LessonSidebar({ + course, + lessons, + completedLessons, + progressCount +}: LessonSidebarProps) { + const pathname = usePathname() + + return ( + + ) +} diff --git a/src/components/providers/account-provider.tsx b/src/components/providers/account-provider.tsx new file mode 100644 index 0000000..d7c94ee --- /dev/null +++ b/src/components/providers/account-provider.tsx @@ -0,0 +1,31 @@ +'use client' + +import { useQuery } from '@tanstack/react-query' +import { Loader } from 'lucide-react' +import { usePathname, useRouter } from 'next/navigation' +import { useEffect, useState } from 'react' +import { type ReactNode } from 'react' + +import { EllipsisLoader } from '../shared/ellipsis-loader' + +import { fetchMfaStatus } from '@/src/api' +import { useCurrent } from '@/src/hooks' + +export function AccountProvider({ children }: { children: ReactNode }) { + const { isLoading } = useCurrent() + + const { isLoading: isLoadingStatus } = useQuery({ + queryKey: ['mfa status'], + queryFn: () => fetchMfaStatus() + }) + + if (isLoading || isLoadingStatus) { + return ( +
    + +
    + ) + } + + return <>{children} +} diff --git a/src/components/providers/course-provider.tsx b/src/components/providers/course-provider.tsx new file mode 100644 index 0000000..6f47d7c --- /dev/null +++ b/src/components/providers/course-provider.tsx @@ -0,0 +1,24 @@ +'use client' + +import { useMutation } from '@tanstack/react-query' +import { type ReactNode, useEffect } from 'react' + +import { incrementCourseViews } from '@/src/api' + +interface CourseProviderProps { + id: string + children: ReactNode +} + +export function CourseProvider({ id, children }: CourseProviderProps) { + const { mutate, isPending } = useMutation({ + mutationKey: ['increment course views', id], + mutationFn: () => incrementCourseViews(id) + }) + + useEffect(() => { + mutate() + }, [mutate]) + + return <>{children} +} diff --git a/src/components/providers/tanstack-query-provider.tsx b/src/components/providers/tanstack-query-provider.tsx new file mode 100644 index 0000000..986fea6 --- /dev/null +++ b/src/components/providers/tanstack-query-provider.tsx @@ -0,0 +1,22 @@ +'use client' + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { type ReactNode, useState } from 'react' + +export function TanstackQueryProvider({ children }: { children: ReactNode }) { + const [client] = useState( + new QueryClient({ + defaultOptions: { + queries: { + staleTime: Infinity, + refetchInterval: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchOnMount: false + } + } + }) + ) + + return {children} +} diff --git a/src/components/providers/theme-provider.tsx b/src/components/providers/theme-provider.tsx new file mode 100644 index 0000000..a714e7c --- /dev/null +++ b/src/components/providers/theme-provider.tsx @@ -0,0 +1,11 @@ +'use client' + +import { ThemeProvider as NextThemesProvider } from 'next-themes' +import type { ComponentProps } from 'react' + +export function ThemeProvider({ + children, + ...props +}: ComponentProps) { + return {children} +} diff --git a/src/components/shared/confirm-dialog.tsx b/src/components/shared/confirm-dialog.tsx new file mode 100644 index 0000000..c770b55 --- /dev/null +++ b/src/components/shared/confirm-dialog.tsx @@ -0,0 +1,71 @@ +import { JSX, ReactNode } from 'react' + +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger +} from '../ui/alert-dialog' +import { Button } from '../ui/button' + +import { cn } from '@/src/lib/utils' + +interface ConfirmDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + title: ReactNode + description: JSX.Element | string + cancelBtnText?: string + confirmText?: ReactNode + destructive?: boolean + handleConfirm: () => void + isLoading?: boolean + isDisabled?: boolean + className?: string + children?: ReactNode +} + +export function ConfirmDialog({ + title, + description, + children, + className, + confirmText, + cancelBtnText, + destructive, + isLoading, + isDisabled = false, + handleConfirm, + ...actions +}: ConfirmDialogProps) { + return ( + + {children} + + + {title} + +
    {description}
    +
    +
    + + + {cancelBtnText ?? 'Отмена'} + + + +
    +
    + ) +} diff --git a/src/components/shared/course-progress.tsx b/src/components/shared/course-progress.tsx new file mode 100644 index 0000000..34b4979 --- /dev/null +++ b/src/components/shared/course-progress.tsx @@ -0,0 +1,46 @@ +import { Progress } from '../ui/progress' + +import { cn } from '@/src/lib/utils' + +interface CourseProgressProps { + progress: number + variant?: 'default' | 'success' + size?: 'default' | 'sm' + isShowPercentage?: boolean + label?: string + className?: string +} + +export function CourseProgress({ + progress, + variant, + size, + isShowPercentage, + label, + className +}: CourseProgressProps) { + return ( +
    +
    + {label && ( + {label} + )} + {isShowPercentage && ( + + {progress}% + + )} +
    + div]:bg-emerald-600' + : '[&>div]:bg-blue-600' + )} + /> +
    + ) +} diff --git a/src/components/shared/ellipsis-loader.tsx b/src/components/shared/ellipsis-loader.tsx new file mode 100644 index 0000000..2127ab6 --- /dev/null +++ b/src/components/shared/ellipsis-loader.tsx @@ -0,0 +1,25 @@ +'use client' + +import { motion } from 'framer-motion' + +export function EllipsisLoader() { + return ( +
    + {[...Array(3)].map((_, i) => ( + + ))} +
    + ) +} diff --git a/src/components/shared/heading.tsx b/src/components/shared/heading.tsx new file mode 100644 index 0000000..8874553 --- /dev/null +++ b/src/components/shared/heading.tsx @@ -0,0 +1,13 @@ +interface HeadingProps { + title: string + description: string +} + +export function Heading({ title, description }: HeadingProps) { + return ( +
    +

    {title}

    +

    {description}

    +
    + ) +} diff --git a/src/components/shared/logo.tsx b/src/components/shared/logo.tsx new file mode 100644 index 0000000..83fbced --- /dev/null +++ b/src/components/shared/logo.tsx @@ -0,0 +1,40 @@ +interface Props { + className?: string +} + +export function Logo({ className = 'size-14' }: Props) { + return ( + + + + + + + + + + + + + ) +} diff --git a/src/components/shared/player.tsx b/src/components/shared/player.tsx new file mode 100644 index 0000000..a6b9405 --- /dev/null +++ b/src/components/shared/player.tsx @@ -0,0 +1,16 @@ +'use client' + +import KinescopePlayer, { + PlayerPropsTypes +} from '@kinescope/react-kinescope-player' +import type { RefObject } from 'react' + +interface PlayerProps extends PlayerPropsTypes { + forwardRef?: RefObject +} + +export function Player({ forwardRef, ...props }: PlayerProps) { + return +} + +export { KinescopePlayer } diff --git a/src/components/shared/sonner.tsx b/src/components/shared/sonner.tsx new file mode 100644 index 0000000..57cb882 --- /dev/null +++ b/src/components/shared/sonner.tsx @@ -0,0 +1,25 @@ +'use client' + +import type { ComponentProps } from 'react' +import { Toaster as Sonner } from 'sonner' + +type ToasterProps = ComponentProps + +export function Toaster({ ...props }: ToasterProps) { + return ( + + ) +} diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..14d1b15 --- /dev/null +++ b/src/components/ui/alert-dialog.tsx @@ -0,0 +1,144 @@ +import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog' +import { + type ComponentPropsWithoutRef, + type ComponentRef, + type HTMLAttributes, + forwardRef +} from 'react' + +import { buttonVariants } from './button' +import { cn } from '@/src/lib/utils' + +const AlertDialog = AlertDialogPrimitive.Root + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger + +const AlertDialogPortal = AlertDialogPrimitive.Portal + +const AlertDialogOverlay = forwardRef< + ComponentRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = forwardRef< + ComponentRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ + className, + ...props +}: HTMLAttributes) => ( +
    +) +AlertDialogHeader.displayName = 'AlertDialogHeader' + +const AlertDialogFooter = ({ + className, + ...props +}: HTMLAttributes) => ( +
    +) +AlertDialogFooter.displayName = 'AlertDialogFooter' + +const AlertDialogTitle = forwardRef< + ComponentRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = forwardRef< + ComponentRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = forwardRef< + ComponentRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = forwardRef< + ComponentRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogOverlay, + AlertDialogPortal, + AlertDialogTitle, + AlertDialogTrigger +} diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx new file mode 100644 index 0000000..84d9992 --- /dev/null +++ b/src/components/ui/alert.tsx @@ -0,0 +1,64 @@ +import { type VariantProps, cva } from 'class-variance-authority' +import { type HTMLAttributes, forwardRef } from 'react' + +import { cn } from '@/src/lib/utils' + +const alertVariants = cva( + 'relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7', + { + variants: { + variant: { + default: 'bg-background text-foreground', + warning: + 'border-yellow-600/30 bg-yellow-50 text-yellow-800 [&>svg]:text-yellow-800', + destructive: + 'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive' + } + }, + defaultVariants: { + variant: 'default' + } + } +) + +const Alert = forwardRef< + HTMLDivElement, + HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
    +)) +Alert.displayName = 'Alert' + +const AlertTitle = forwardRef< + HTMLParagraphElement, + HTMLAttributes +>(({ className, ...props }, ref) => ( +
    +)) +AlertTitle.displayName = 'AlertTitle' + +const AlertDescription = forwardRef< + HTMLParagraphElement, + HTMLAttributes +>(({ className, ...props }, ref) => ( +
    +)) +AlertDescription.displayName = 'AlertDescription' + +export { Alert, AlertDescription, AlertTitle } diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx new file mode 100644 index 0000000..f7fec67 --- /dev/null +++ b/src/components/ui/avatar.tsx @@ -0,0 +1,52 @@ +import * as AvatarPrimitive from '@radix-ui/react-avatar' +import { + type ComponentPropsWithoutRef, + type ComponentRef, + forwardRef +} from 'react' + +import { cn } from '@/src/lib/utils' + +const Avatar = forwardRef< + ComponentRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Avatar.displayName = AvatarPrimitive.Root.displayName + +const AvatarImage = forwardRef< + ComponentRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarImage.displayName = AvatarPrimitive.Image.displayName + +const AvatarFallback = forwardRef< + ComponentRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName + +export { Avatar, AvatarFallback, AvatarImage } diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000..faa88a0 --- /dev/null +++ b/src/components/ui/badge.tsx @@ -0,0 +1,42 @@ +import { type VariantProps, cva } from 'class-variance-authority' +import { type ComponentPropsWithoutRef, forwardRef } from 'react' + +import { cn } from '@/src/lib/utils' + +const badgeVariants = cva( + 'inline-flex items-center gap-x-1 whitespace-nowrap rounded-full px-2.5 py-1 text-xs font-medium ring-1 ring-inset', + { + variants: { + variant: { + default: 'bg-blue-50 text-blue-900 ring-blue-500/30', + neutral: 'bg-gray-50 text-gray-900 ring-gray-500/30', + success: 'bg-emerald-50 text-emerald-900 ring-emerald-600/30 ', + error: 'bg-red-50 text-red-900 ring-red-600/20', + warning: 'bg-yellow-50 text-yellow-900 ring-yellow-600/30' + } + }, + defaultVariants: { + variant: 'default' + } + } +) + +interface BadgeProps + extends ComponentPropsWithoutRef<'span'>, + VariantProps {} + +const Badge = forwardRef( + ({ className, variant, ...props }: BadgeProps, forwardedRef) => { + return ( + + ) + } +) + +Badge.displayName = 'Badge' + +export { Badge, badgeVariants, type BadgeProps } diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 20452e6..2fb5a6a 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -1,36 +1,40 @@ -import { Slot } from '@radix-ui/react-slot' -import { cva, type VariantProps } from 'class-variance-authority' +import { Slot, Slottable } from '@radix-ui/react-slot' +import { type VariantProps, cva } from 'class-variance-authority' +import { Loader2 } from 'lucide-react' +import { type ButtonHTMLAttributes, type ReactNode, forwardRef } from 'react' import { cn } from '@/src/lib/utils' -import { type ButtonHTMLAttributes, forwardRef } from 'react' const buttonVariants = cva( - 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', + 'inline-flex items-center justify-center gap-2 whitespace-nowrap select-none rounded-lg transition-all will-change-transform active:hover:scale-[0.98] active:hover:transform text-sm font-medium ring-offset-background focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', { variants: { variant: { default: - 'bg-primary text-primary-foreground shadow hover:bg-primary/90', + 'bg-primary text-primary-foreground hover:bg-primary/90', destructive: - 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90', + 'bg-destructive text-destructive-foreground hover:bg-destructive/90', outline: - 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground', + 'border border-input bg-transparent hover:bg-accent hover:text-accent-foreground', secondary: - 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80', + 'bg-secondary text-secondary-foreground hover:bg-secondary/80', ghost: 'hover:bg-accent hover:text-accent-foreground', link: 'text-primary underline-offset-4 hover:underline', + primary: + 'bg-blue-600 text-primary-foreground hover:bg-blue-600/90' }, size: { - default: 'h-9 px-4 py-2', - sm: 'h-8 rounded-md px-3 text-xs', - lg: 'h-10 rounded-md px-8', - icon: 'h-9 w-9', - }, + default: 'h-10 px-4 py-2', + sm: 'h-9 px-4 py-2', + xs: 'h-9 rounded-lg px-3 text-xs', + lg: 'h-11 rounded-lg px-8', + icon: 'h-10 w-10' + } }, defaultVariants: { variant: 'default', - size: 'default', - }, + size: 'default' + } } ) @@ -38,17 +42,37 @@ export interface ButtonProps extends ButtonHTMLAttributes, VariantProps { asChild?: boolean + isLoading?: boolean + children?: ReactNode } const Button = forwardRef( - ({ className, variant, size, asChild = false, ...props }, ref) => { + ( + { + className, + variant, + size, + children, + isLoading = false, + asChild = false, + ...props + }, + ref + ) => { const Comp = asChild ? Slot : 'button' return ( + > + {isLoading && } + {children} + ) } ) diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx new file mode 100644 index 0000000..63e7510 --- /dev/null +++ b/src/components/ui/card.tsx @@ -0,0 +1,74 @@ +import { type HTMLAttributes, forwardRef } from 'react' + +import { cn } from '@/src/lib/utils' + +const Card = forwardRef>( + ({ className, ...props }, ref) => ( +
    + ) +) +Card.displayName = 'Card' + +const CardHeader = forwardRef>( + ({ className, ...props }, ref) => ( +
    + ) +) +CardHeader.displayName = 'CardHeader' + +const CardTitle = forwardRef>( + ({ className, ...props }, ref) => ( +
    + ) +) +CardTitle.displayName = 'CardTitle' + +const CardDescription = forwardRef< + HTMLDivElement, + HTMLAttributes +>(({ className, ...props }, ref) => ( +
    +)) +CardDescription.displayName = 'CardDescription' + +const CardContent = forwardRef>( + ({ className, ...props }, ref) => ( +
    + ) +) +CardContent.displayName = 'CardContent' + +const CardFooter = forwardRef>( + ({ className, ...props }, ref) => ( +
    + ) +) +CardFooter.displayName = 'CardFooter' + +export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx new file mode 100644 index 0000000..fb53845 --- /dev/null +++ b/src/components/ui/dialog.tsx @@ -0,0 +1,122 @@ +import * as DialogPrimitive from '@radix-ui/react-dialog' +import { X } from 'lucide-react' +import { + type ComponentPropsWithoutRef, + type ComponentRef, + type HTMLAttributes, + forwardRef +} from 'react' + +import { cn } from '@/src/lib/utils' + +const Dialog = DialogPrimitive.Root + +const DialogTrigger = DialogPrimitive.Trigger + +const DialogPortal = DialogPrimitive.Portal + +const DialogClose = DialogPrimitive.Close + +const DialogOverlay = forwardRef< + ComponentRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = forwardRef< + ComponentRef, + ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ + className, + ...props +}: HTMLAttributes) => ( +
    +) +DialogHeader.displayName = 'DialogHeader' + +const DialogFooter = ({ + className, + ...props +}: HTMLAttributes) => ( +
    +) +DialogFooter.displayName = 'DialogFooter' + +const DialogTitle = forwardRef< + ComponentRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = forwardRef< + ComponentRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger +} diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..42b3118 --- /dev/null +++ b/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,209 @@ +'use client' + +import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu' +import { Check, ChevronRight, Circle } from 'lucide-react' +import { + type ComponentPropsWithoutRef, + type ComponentRef, + type HTMLAttributes, + forwardRef +} from 'react' + +import { cn } from '@/src/lib/utils' + +const DropdownMenu = DropdownMenuPrimitive.Root + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger + +const DropdownMenuGroup = DropdownMenuPrimitive.Group + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal + +const DropdownMenuSub = DropdownMenuPrimitive.Sub + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup + +const DropdownMenuSubTrigger = forwardRef< + ComponentRef, + ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)) +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName + +const DropdownMenuSubContent = forwardRef< + ComponentRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName + +const DropdownMenuContent = forwardRef< + ComponentRef, + ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)) +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName + +const DropdownMenuItem = forwardRef< + ComponentRef, + ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + svg]:size-4 [&>svg]:shrink-0', + inset && 'pl-8', + className + )} + {...props} + /> +)) +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName + +const DropdownMenuCheckboxItem = forwardRef< + ComponentRef, + ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName + +const DropdownMenuRadioItem = forwardRef< + ComponentRef, + ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName + +const DropdownMenuLabel = forwardRef< + ComponentRef, + ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName + +const DropdownMenuSeparator = forwardRef< + ComponentRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName + +const DropdownMenuShortcut = ({ + className, + ...props +}: HTMLAttributes) => { + return ( + + ) +} +DropdownMenuShortcut.displayName = 'DropdownMenuShortcut' + +export { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuPortal, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger +} diff --git a/src/components/ui/form.tsx b/src/components/ui/form.tsx new file mode 100644 index 0000000..30d5598 --- /dev/null +++ b/src/components/ui/form.tsx @@ -0,0 +1,187 @@ +import type * as LabelPrimitive from '@radix-ui/react-label' +import { Slot } from '@radix-ui/react-slot' +import { + type ComponentPropsWithoutRef, + type ComponentRef, + type HTMLAttributes, + createContext, + forwardRef, + useContext, + useId +} from 'react' +import { + Controller, + type ControllerProps, + type FieldPath, + type FieldValues, + FormProvider, + useFormContext +} from 'react-hook-form' + +import { Label } from './label' +import { cn } from '@/src/lib/utils' + +const Form = FormProvider + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +> = { + name: TName +} + +const FormFieldContext = createContext( + {} as FormFieldContextValue +) + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +>({ + ...props +}: ControllerProps) => { + return ( + + + + ) +} + +const useFormField = () => { + const fieldContext = useContext(FormFieldContext) + const itemContext = useContext(FormItemContext) + const { getFieldState, formState } = useFormContext() + + const fieldState = getFieldState(fieldContext.name, formState) + + if (!fieldContext) { + throw new Error('useFormField should be used within ') + } + + const { id } = itemContext + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState + } +} + +type FormItemContextValue = { + id: string +} + +const FormItemContext = createContext( + {} as FormItemContextValue +) + +const FormItem = forwardRef>( + ({ className, ...props }, ref) => { + const id = useId() + + return ( + +
    + + ) + } +) +FormItem.displayName = 'FormItem' + +const FormLabel = forwardRef< + ComponentRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { formItemId } = useFormField() + + return ( +