├── src ├── lib │ ├── analytics │ │ ├── events │ │ │ └── index.ts │ │ ├── script-providers │ │ │ ├── index.ts │ │ │ └── yandex.tsx │ │ ├── providers │ │ │ ├── index.ts │ │ │ ├── console.ts │ │ │ ├── metrika.ts │ │ │ └── posthog.ts │ │ ├── trigger.ts │ │ └── index.ts │ ├── utils │ │ ├── focus-ring.ts │ │ ├── tw-merge.ts │ │ ├── index.ts │ │ ├── translators.ts │ │ ├── get-lesson-label.ts │ │ ├── get-media-source.ts │ │ ├── format-date.ts │ │ └── get-browser-icon.ts │ └── cookie.ts ├── components │ ├── account │ │ ├── security │ │ │ └── security.tsx │ │ ├── settings │ │ │ ├── preferences.tsx │ │ │ ├── subscription.tsx │ │ │ ├── account-form.tsx │ │ │ ├── profile-form.tsx │ │ │ ├── settings.tsx │ │ │ ├── account-actions.tsx │ │ │ ├── avatar-form.tsx │ │ │ └── auto-billing-form.tsx │ │ ├── sessions │ │ │ ├── remove-all-sessions.tsx │ │ │ ├── remove-session.tsx │ │ │ ├── sessions.tsx │ │ │ └── session-item.tsx │ │ ├── connections │ │ │ └── unlink-provider.tsx │ │ └── progress │ │ │ ├── progress.tsx │ │ │ ├── courses-list.tsx │ │ │ ├── courses-tab.tsx │ │ │ └── user-stats.tsx │ ├── icons │ │ ├── index.tsx │ │ ├── tbank-icon.tsx │ │ ├── yoomoney-icon.tsx │ │ ├── sberbank-icon.tsx │ │ └── sbp-icon.tsx │ ├── ui │ │ ├── skeleton.tsx │ │ ├── label.tsx │ │ ├── separator.tsx │ │ ├── progress.tsx │ │ ├── switch.tsx │ │ ├── avatar.tsx │ │ ├── radio-group.tsx │ │ ├── badge.tsx │ │ ├── scroll-area.tsx │ │ ├── alert.tsx │ │ ├── card.tsx │ │ ├── accordion.tsx │ │ ├── input.tsx │ │ ├── input-otp.tsx │ │ └── button.tsx │ ├── shared │ │ ├── heading.tsx │ │ ├── player.tsx │ │ ├── ellipsis-loader.tsx │ │ ├── captcha.tsx │ │ ├── sonner.tsx │ │ ├── course-progress.tsx │ │ └── confirm-dialog.tsx │ ├── lesson │ │ ├── lesson-container.tsx │ │ ├── lesson-player.tsx │ │ └── lesson-complete-button.tsx │ ├── layout │ │ ├── nav-links.tsx │ │ ├── user-navigation.tsx │ │ └── header.tsx │ ├── home │ │ ├── popular.tsx │ │ ├── telegram-cta.tsx │ │ └── features.tsx │ ├── auth │ │ ├── verify-email.tsx │ │ ├── passkey-login-button.tsx │ │ └── auth-social.tsx │ └── course │ │ ├── course-content.tsx │ │ ├── course-card.tsx │ │ ├── course-actions.tsx │ │ ├── course-details.tsx │ │ └── course-lessons.tsx ├── hooks │ ├── index.ts │ ├── useAuth.ts │ ├── useFingerprint.ts │ └── useCurrent.ts ├── constants │ ├── index.ts │ ├── app.ts │ ├── payment-icons.ts │ ├── mfa-methods.ts │ ├── seo.ts │ ├── routes.ts │ └── sso-providers.ts ├── app │ ├── loading.tsx │ ├── (public) │ │ ├── premium │ │ │ └── page.tsx │ │ ├── page.tsx │ │ ├── layout.tsx │ │ └── courses │ │ │ ├── page.tsx │ │ │ └── [slug] │ │ │ └── page.tsx │ ├── auth │ │ ├── login │ │ │ └── page.tsx │ │ ├── register │ │ │ └── page.tsx │ │ ├── recovery │ │ │ ├── page.tsx │ │ │ └── [token] │ │ │ │ └── page.tsx │ │ ├── verify │ │ │ └── [token] │ │ │ │ └── page.tsx │ │ ├── callback │ │ │ └── page.tsx │ │ └── telegram-oauth-finish │ │ │ └── page.tsx │ ├── account │ │ ├── page.tsx │ │ ├── sessions │ │ │ └── page.tsx │ │ ├── settings │ │ │ └── page.tsx │ │ ├── connections │ │ │ └── page.tsx │ │ └── layout.tsx │ ├── payment │ │ └── success │ │ │ └── page.tsx │ ├── robots.ts │ ├── sitemap.ts │ ├── manifest.ts │ └── not-found.tsx ├── providers │ ├── index.ts │ ├── theme-provider.tsx │ ├── analytics-provider.tsx │ ├── course-provider.tsx │ ├── tanstack-query-provider.tsx │ ├── account-provider.tsx │ └── fingerprint-provider.tsx ├── api │ ├── requests │ │ ├── restriction.ts │ │ ├── index.ts │ │ ├── payment.ts │ │ ├── progress.ts │ │ ├── lesson.ts │ │ ├── session.ts │ │ ├── passkey.ts │ │ ├── users.ts │ │ ├── course.ts │ │ ├── account.ts │ │ ├── sso.ts │ │ └── mfa.ts │ ├── generated │ │ ├── changeEmailRequest.ts │ │ ├── patchUserRequest.ts │ │ ├── createCourseRequest.ts │ │ ├── createCourseResponse.ts │ │ ├── createLessonResponse.ts │ │ ├── initPaymentResponse.ts │ │ ├── ssoControllerCallbackParams.ts │ │ ├── generateDownloadLinkResponse.ts │ │ ├── ssoConnectResponse.ts │ │ ├── ssoLoginResponse.ts │ │ ├── totpDisableRequest.ts │ │ ├── heleketPaymentWebhookResponseFrom.ts │ │ ├── createLessonRequest.ts │ │ ├── heleketPaymentConvertResponseCommission.ts │ │ ├── ssoLoginRequest.ts │ │ ├── heleketPaymentWebhookResponseTransferId.ts │ │ ├── sendPasswordResetRequest.ts │ │ ├── createProgressRequest.ts │ │ ├── createUserResponse.ts │ │ ├── heleketPaymentWebhookResponseAdditionalData.ts │ │ ├── heleketPaymentWebhookResponsePaymentAmount.ts │ │ ├── heleketPaymentWebhookResponseWalletAddressUuid.ts │ │ ├── lastLessonResponse.ts │ │ ├── loginSessionResponse.ts │ │ ├── passwordResetRequest.ts │ │ ├── registrationsResponse.ts │ │ ├── telegramAuthResponse.ts │ │ ├── totpGenerateSecretResponse.ts │ │ ├── heleketPaymentWebhookResponseTxid.ts │ │ ├── meProgressResponseLastLesson.ts │ │ ├── heleketPaymentWebhookResponseMerchantAmount.ts │ │ ├── sessionControllerLogin200.ts │ │ ├── totpEnableRequest.ts │ │ ├── createProgressResponse.ts │ │ ├── sessionControllerLoginAdmin200.ts │ │ ├── loginMfaResponse.ts │ │ ├── initPaymentRequest.ts │ │ ├── statisticsResponse.ts │ │ ├── changePasswordRequest.ts │ │ ├── mfaStatusResponse.ts │ │ ├── mfaControllerVerifyBody.ts │ │ ├── passkeyResponse.ts │ │ ├── activeRestrictionResponse.ts │ │ ├── heleketPaymentWebhookResponseType.ts │ │ ├── loginRequest.ts │ │ ├── leaderResponse.ts │ │ ├── ssoStatusResponse.ts │ │ ├── createRestrictionRequest.ts │ │ ├── sessionResponse.ts │ │ ├── createUserRequest.ts │ │ ├── createRestrictionRequestReason.ts │ │ ├── paymentMethodResponse.ts │ │ ├── progressResponse.ts │ │ ├── coursesResponse.ts │ │ ├── initPaymentRequestMethod.ts │ │ ├── accountResponse.ts │ │ ├── paymentMethodResponseId.ts │ │ ├── userResponse.ts │ │ ├── heleketPaymentConvertResponse.ts │ │ ├── heleketPaymentWebhookResponseStatus.ts │ │ ├── meStatisticsResponse.ts │ │ ├── telegramAuthRequest.ts │ │ ├── meProgressResponse.ts │ │ ├── lessonResponse.ts │ │ └── courseResponse.ts │ ├── instance.ts │ └── hooks │ │ ├── useLogout.ts │ │ ├── useGetMe.ts │ │ ├── useGetAvailableSsoProviders.ts │ │ ├── useRemoveAllSessions.ts │ │ ├── useGetSessions.ts │ │ ├── useFetchMfaStatus.ts │ │ ├── useFetchSsoStauts.ts │ │ ├── useRevokeSession.ts │ │ ├── useGetPaymentMethods.ts │ │ ├── useUnlinkAccount.ts │ │ ├── useInitPayment.ts │ │ ├── useTelegramConnect.ts │ │ ├── useRegister.ts │ │ ├── useTelegramAuth.ts │ │ ├── useSsoConnect.ts │ │ ├── useLogin.ts │ │ ├── index.ts │ │ └── useVerifyMfa.ts ├── proxy.ts └── styles │ └── globals.css ├── .dockerignore ├── public ├── favicon.ico ├── opengraph.png ├── images │ ├── owl.png │ └── bg-auth.png ├── touch-icons │ ├── 192x192.png │ └── 512x512.png └── payment-logos │ ├── bank-card.svg │ ├── international-card.svg │ ├── crypto.svg │ └── sbp.svg ├── postcss.config.mjs ├── orval.config.ts ├── Dockerfile ├── .gitignore ├── next.config.ts ├── .prettierrc ├── tsconfig.json ├── README.md └── .github └── workflows └── ci.yaml /src/lib/analytics/events/index.ts: -------------------------------------------------------------------------------- 1 | export * from './auth' 2 | -------------------------------------------------------------------------------- /src/lib/analytics/script-providers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './yandex' 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .next 3 | *.log 4 | .env 5 | .env.* 6 | .git 7 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/teacoder-team/frontend/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/opengraph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/teacoder-team/frontend/HEAD/public/opengraph.png -------------------------------------------------------------------------------- /public/images/owl.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/teacoder-team/frontend/HEAD/public/images/owl.png -------------------------------------------------------------------------------- /public/images/bg-auth.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/teacoder-team/frontend/HEAD/public/images/bg-auth.png -------------------------------------------------------------------------------- /src/components/account/security/security.tsx: -------------------------------------------------------------------------------- 1 | export function Security() { 2 | return
3 | } 4 | -------------------------------------------------------------------------------- /public/touch-icons/192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/teacoder-team/frontend/HEAD/public/touch-icons/192x192.png -------------------------------------------------------------------------------- /public/touch-icons/512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/teacoder-team/frontend/HEAD/public/touch-icons/512x512.png -------------------------------------------------------------------------------- /src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from './useAuth' 2 | export * from './useCurrent' 3 | export * from './useFingerprint' 4 | -------------------------------------------------------------------------------- /src/lib/analytics/providers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './console' 2 | export * from './metrika' 3 | export * from './posthog' 4 | -------------------------------------------------------------------------------- /src/components/icons/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './sberbank-icon' 2 | export * from './sbp-icon' 3 | export * from './tbank-icon' 4 | export * from './yoomoney-icon' 5 | -------------------------------------------------------------------------------- /src/lib/utils/focus-ring.ts: -------------------------------------------------------------------------------- 1 | export const focusRing = [ 2 | 'outline outline-offset-2 outline-0 focus-visible:outline-2', 3 | 'outline-blue-600 dark:outline-blue-600' 4 | ] 5 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /src/constants/index.ts: -------------------------------------------------------------------------------- 1 | export * from './app' 2 | export * from './mfa-methods' 3 | export * from './payment-icons' 4 | export * from './routes' 5 | export * from './seo' 6 | export * from './sso-providers' 7 | -------------------------------------------------------------------------------- /src/lib/utils/tw-merge.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from 'clsx' 2 | import { twMerge } from 'tailwind-merge' 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /src/hooks/useAuth.ts: -------------------------------------------------------------------------------- 1 | import { cookies } from '../lib/cookie' 2 | 3 | export function useAuth() { 4 | const token = cookies.get('token') 5 | const isAuthorized = token !== undefined && token !== '' 6 | 7 | return { isAuthorized } 8 | } 9 | -------------------------------------------------------------------------------- /src/lib/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './focus-ring' 2 | export * from './format-date' 3 | export * from './get-browser-icon' 4 | export * from './get-lesson-label' 5 | export * from './get-media-source' 6 | export * from './translators' 7 | export * from './tw-merge' 8 | -------------------------------------------------------------------------------- /src/app/loading.tsx: -------------------------------------------------------------------------------- 1 | import { EllipsisLoader } from '../components/shared/ellipsis-loader' 2 | 3 | export default function LoadingPage() { 4 | return ( 5 |
6 | 7 |
8 | ) 9 | } 10 | -------------------------------------------------------------------------------- /src/providers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './account-provider' 2 | export * from './analytics-provider' 3 | export * from './ban-checker' 4 | export * from './course-provider' 5 | export * from './fingerprint-provider' 6 | export * from './tanstack-query-provider' 7 | export * from './theme-provider' 8 | -------------------------------------------------------------------------------- /src/app/(public)/premium/page.tsx: -------------------------------------------------------------------------------- 1 | import { Metadata } from 'next' 2 | 3 | import { Premium } from '@/src/components/premium/premium' 4 | 5 | export const metadata: Metadata = { 6 | title: 'Подписка' 7 | } 8 | 9 | export default async function PremiumPage() { 10 | return 11 | } 12 | -------------------------------------------------------------------------------- /src/api/requests/restriction.ts: -------------------------------------------------------------------------------- 1 | import type { ActiveRestrictionResponse } from '../generated' 2 | import { instance } from '../instance' 3 | 4 | export const getActiveRestriction = async () => 5 | await instance 6 | .get('/restriction') 7 | .then(response => response.data) 8 | -------------------------------------------------------------------------------- /src/app/auth/login/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next' 2 | 3 | import { LoginForm } from '@/src/components/auth/login-form' 4 | 5 | export const metadata: Metadata = { 6 | title: 'Войти в аккаунт' 7 | } 8 | 9 | export default function LoginPage() { 10 | return 11 | } 12 | -------------------------------------------------------------------------------- /src/constants/app.ts: -------------------------------------------------------------------------------- 1 | export const APP_CONFIG = { 2 | baseUrl: process.env['NEXT_PUBLIC_APP_URL'] ?? 'https://teacoder.ru', 3 | apiUrl: process.env['NEXT_PUBLIC_API_URL'] ?? 'https://api.teacoder.ru', 4 | storageUrl: 5 | process.env['NEXT_PUBLIC_STORAGE_URL'] ?? 'https://orion.teacoder.ru' 6 | } as const 7 | -------------------------------------------------------------------------------- /src/lib/analytics/providers/console.ts: -------------------------------------------------------------------------------- 1 | export const consoleProvider = { 2 | init() { 3 | console.log('%c[Analytics] Dev mode enabled', 'color: #4ade80') 4 | }, 5 | 6 | track(event: string, data?: Record) { 7 | console.log('%c[Track]', 'color: #60a5fa', event, data) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/app/account/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next' 2 | 3 | import { Progress } from '@/src/components/account/progress/progress' 4 | 5 | export const metadata: Metadata = { 6 | title: 'Мой прогресс' 7 | } 8 | 9 | export default function ProgressPage() { 10 | return 11 | } 12 | -------------------------------------------------------------------------------- /orval.config.ts: -------------------------------------------------------------------------------- 1 | import { config } from 'dotenv' 2 | import { defineConfig } from 'orval' 3 | 4 | config({ path: '.env' }) 5 | 6 | export default defineConfig({ 7 | client: { 8 | input: 'http://localhost:14702/openapi.json', 9 | output: { 10 | schemas: './src/api/generated' 11 | } 12 | } 13 | }) 14 | -------------------------------------------------------------------------------- /src/app/account/sessions/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next' 2 | 3 | import { Sessions } from '@/src/components/account/sessions/sessions' 4 | 5 | export const metadata: Metadata = { 6 | title: 'Уcтройства' 7 | } 8 | 9 | export default function SessionsPage() { 10 | return 11 | } 12 | -------------------------------------------------------------------------------- /src/app/auth/register/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next' 2 | 3 | import { RegisterForm } from '@/src/components/auth/register-form' 4 | 5 | export const metadata: Metadata = { 6 | title: 'Создать аккаунт' 7 | } 8 | 9 | export default function RegisterPage() { 10 | return 11 | } 12 | -------------------------------------------------------------------------------- /src/app/account/settings/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next' 2 | 3 | import { Settings } from '@/src/components/account/settings/settings' 4 | 5 | export const metadata: Metadata = { 6 | title: 'Настройки аккаунта' 7 | } 8 | 9 | export default function SettingsPage() { 10 | return 11 | } 12 | -------------------------------------------------------------------------------- /src/api/generated/changeEmailRequest.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface ChangeEmailRequest { 10 | /** Email address */ 11 | email: string; 12 | } 13 | -------------------------------------------------------------------------------- /src/api/generated/patchUserRequest.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface PatchUserRequest { 10 | /** Display name */ 11 | displayName: string; 12 | } 13 | -------------------------------------------------------------------------------- /src/lib/utils/translators.ts: -------------------------------------------------------------------------------- 1 | export function lessonsTranslator(count: number) { 2 | const mod10 = count % 10 3 | const mod100 = count % 100 4 | 5 | if (mod100 >= 11 && mod100 <= 14) return 'уроков' 6 | if (mod10 === 1) return 'урок' 7 | if (mod10 >= 2 && mod10 <= 4) return 'урока' 8 | 9 | return 'уроков' 10 | } 11 | -------------------------------------------------------------------------------- /src/api/generated/createCourseRequest.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface CreateCourseRequest { 10 | /** Title of the course */ 11 | title: string; 12 | } 13 | -------------------------------------------------------------------------------- /src/api/generated/createCourseResponse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface CreateCourseResponse { 10 | /** Unique identifier */ 11 | id: string; 12 | } 13 | -------------------------------------------------------------------------------- /src/app/auth/recovery/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next' 2 | 3 | import { ResetPasswordForm } from '@/src/components/auth/reset-password-form' 4 | 5 | export const metadata: Metadata = { 6 | title: 'Сброс пароля' 7 | } 8 | 9 | export default function ResetPasswordPage() { 10 | return 11 | } 12 | -------------------------------------------------------------------------------- /src/app/auth/verify/[token]/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next' 2 | 3 | import { VerifyEmail } from '@/src/components/auth/verify-email' 4 | 5 | export const metadata: Metadata = { 6 | title: 'Верификация почты' 7 | } 8 | 9 | export default async function VerifyEmailPage() { 10 | return 11 | } 12 | -------------------------------------------------------------------------------- /src/api/generated/createLessonResponse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface CreateLessonResponse { 10 | /** Unique lesson identifier */ 11 | id: string; 12 | } 13 | -------------------------------------------------------------------------------- /src/api/generated/initPaymentResponse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface InitPaymentResponse { 10 | /** URL to complete the payment */ 11 | url: string; 12 | } 13 | -------------------------------------------------------------------------------- /src/app/account/connections/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next' 2 | 3 | import { Connections } from '@/src/components/account/connections/connections' 4 | 5 | export const metadata: Metadata = { 6 | title: 'Сторонние сервисы' 7 | } 8 | 9 | export default function ConnectionsPage() { 10 | return 11 | } 12 | -------------------------------------------------------------------------------- /src/app/auth/recovery/[token]/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next' 2 | 3 | import { NewPasswordForm } from '@/src/components/auth/new-password-form' 4 | 5 | export const metadata: Metadata = { 6 | title: 'Новый пароль' 7 | } 8 | 9 | export default async function NewPasswordPage() { 10 | return 11 | } 12 | -------------------------------------------------------------------------------- /src/api/generated/ssoControllerCallbackParams.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export type SsoControllerCallbackParams = { 10 | code: string; 11 | error: string; 12 | state: string; 13 | }; 14 | -------------------------------------------------------------------------------- /src/api/requests/index.ts: -------------------------------------------------------------------------------- 1 | export * from './account' 2 | export * from './course' 3 | export * from './lesson' 4 | export * from './mfa' 5 | export * from './passkey' 6 | export * from './payment' 7 | export * from './progress' 8 | export * from './restriction' 9 | export * from './session' 10 | export * from './sso' 11 | export * from './users' 12 | -------------------------------------------------------------------------------- /src/lib/utils/get-lesson-label.ts: -------------------------------------------------------------------------------- 1 | export function getLessonLabel(count: number) { 2 | if (count % 10 === 1 && count % 100 !== 11) { 3 | return 'урок' 4 | } else if ( 5 | count % 10 >= 2 && 6 | count % 10 <= 4 && 7 | (count % 100 < 10 || count % 100 >= 20) 8 | ) { 9 | return 'урока' 10 | } else { 11 | return 'уроков' 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /public/payment-logos/bank-card.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/api/generated/generateDownloadLinkResponse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface GenerateDownloadLinkResponse { 10 | /** URL to download the course */ 11 | url: string; 12 | } 13 | -------------------------------------------------------------------------------- /public/payment-logos/international-card.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/providers/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { ThemeProvider as NextThemesProvider } from 'next-themes' 4 | import type { ComponentProps } from 'react' 5 | 6 | export function ThemeProvider({ 7 | children, 8 | ...props 9 | }: ComponentProps) { 10 | return {children} 11 | } 12 | -------------------------------------------------------------------------------- /src/api/generated/ssoConnectResponse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface SsoConnectResponse { 10 | /** The URL for authorization via the external provider (e.g., Google, GitHub) */ 11 | url: string; 12 | } 13 | -------------------------------------------------------------------------------- /src/api/generated/ssoLoginResponse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface SsoLoginResponse { 10 | /** The URL for authorization via the external provider (e.g., Google, GitHub) */ 11 | url: string; 12 | } 13 | -------------------------------------------------------------------------------- /src/lib/utils/get-media-source.ts: -------------------------------------------------------------------------------- 1 | import { APP_CONFIG } from '../../constants/app' 2 | 3 | export function getMediaSource( 4 | path: string, 5 | tag: 'users' | 'courses' | 'attachments' 6 | ) { 7 | if (!path) { 8 | return '' 9 | } 10 | 11 | if (path.startsWith('https://')) { 12 | return path 13 | } 14 | 15 | return `${APP_CONFIG.storageUrl}/${tag}/${path}` 16 | } 17 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM oven/bun:1 AS base 2 | 3 | WORKDIR /usr/src/app 4 | 5 | COPY package.json bun.lock ./ 6 | RUN bun install --frozen-lockfile 7 | 8 | FROM oven/bun:1 AS release 9 | 10 | WORKDIR /usr/src/app 11 | 12 | COPY --from=base /usr/src/app/node_modules node_modules 13 | COPY . . 14 | 15 | RUN bun --bun run build 16 | 17 | CMD bun --bun run start 18 | 19 | EXPOSE 14701 20 | -------------------------------------------------------------------------------- /src/api/generated/totpDisableRequest.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface TotpDisableRequest { 10 | /** 11 | * Password 12 | * @minLength 6 13 | * @maxLength 128 14 | */ 15 | password: string; 16 | } 17 | -------------------------------------------------------------------------------- /src/components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import type { HTMLAttributes } from 'react' 2 | 3 | import { cn } from '@/src/lib/utils' 4 | 5 | function Skeleton({ className, ...props }: HTMLAttributes) { 6 | return ( 7 |
11 | ) 12 | } 13 | 14 | export { Skeleton } 15 | -------------------------------------------------------------------------------- /src/components/shared/heading.tsx: -------------------------------------------------------------------------------- 1 | interface HeadingProps { 2 | title: string 3 | description: string 4 | } 5 | 6 | export function Heading({ title, description }: HeadingProps) { 7 | return ( 8 |
9 |

{title}

10 |

{description}

11 |
12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /src/api/generated/heleketPaymentWebhookResponseFrom.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | /** 10 | * Payer's wallet address 11 | * @nullable 12 | */ 13 | export type HeleketPaymentWebhookResponseFrom = { [key: string]: unknown } | null; 14 | -------------------------------------------------------------------------------- /src/api/generated/createLessonRequest.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface CreateLessonRequest { 10 | /** Lesson title */ 11 | title: string; 12 | /** Course ID to which the lesson belongs */ 13 | courseId: string; 14 | } 15 | -------------------------------------------------------------------------------- /src/api/instance.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | 3 | import { APP_CONFIG } from '../constants/app' 4 | import { cookies } from '../lib/cookie' 5 | 6 | export const api = axios.create({ 7 | baseURL: APP_CONFIG.apiUrl 8 | }) 9 | 10 | export const instance = axios.create({ 11 | baseURL: APP_CONFIG.apiUrl, 12 | headers: { 13 | 'X-Session-Token': cookies.get('token') ?? '' 14 | } 15 | }) 16 | -------------------------------------------------------------------------------- /src/app/payment/success/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next' 2 | 3 | import { PaymentSuccess } from '@/src/components/payment/payment-success' 4 | 5 | export const metadata: Metadata = { 6 | title: 'Успешная оплата', 7 | robots: { 8 | index: false, 9 | follow: false 10 | } 11 | } 12 | 13 | export default function PaymentSuccessPage() { 14 | return 15 | } 16 | -------------------------------------------------------------------------------- /src/api/generated/heleketPaymentConvertResponseCommission.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | /** 10 | * Conversion commission 11 | * @nullable 12 | */ 13 | export type HeleketPaymentConvertResponseCommission = { [key: string]: unknown } | null; 14 | -------------------------------------------------------------------------------- /src/api/generated/ssoLoginRequest.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface SsoLoginRequest { 10 | /** Visitor fingerprint ID */ 11 | visitorId: string; 12 | /** Request ID for fingerprint tracking */ 13 | requestId: string; 14 | } 15 | -------------------------------------------------------------------------------- /src/api/generated/heleketPaymentWebhookResponseTransferId.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | /** 10 | * Transfer identifier (if available) 11 | * @nullable 12 | */ 13 | export type HeleketPaymentWebhookResponseTransferId = { [key: string]: unknown } | null; 14 | -------------------------------------------------------------------------------- /src/api/hooks/useLogout.ts: -------------------------------------------------------------------------------- 1 | import { type UseMutationOptions, useMutation } from '@tanstack/react-query' 2 | 3 | import { logout } from '../requests' 4 | 5 | export const useLogout = ( 6 | options?: Omit< 7 | UseMutationOptions, 8 | 'mutationKey' | 'mutationFn' 9 | > 10 | ) => 11 | useMutation({ 12 | mutationKey: ['logout'], 13 | mutationFn: logout, 14 | ...options 15 | }) 16 | -------------------------------------------------------------------------------- /src/api/generated/sendPasswordResetRequest.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface SendPasswordResetRequest { 10 | /** Email associated with the account */ 11 | email: string; 12 | /** Captcha verification code */ 13 | captcha: string; 14 | } 15 | -------------------------------------------------------------------------------- /src/hooks/useFingerprint.ts: -------------------------------------------------------------------------------- 1 | import { useVisitorData } from '@fingerprintjs/fingerprintjs-pro-react' 2 | 3 | export function useFingerprint() { 4 | const { isLoading, error, data, getData } = useVisitorData( 5 | { 6 | extendedResult: true 7 | }, 8 | { immediate: true } 9 | ) 10 | 11 | return { 12 | isLoading, 13 | error, 14 | data, 15 | refresh: () => getData({ ignoreCache: true }) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/lib/utils/format-date.ts: -------------------------------------------------------------------------------- 1 | export function formatDate(date: string | Date): string { 2 | const createdAt = new Date(date) 3 | const formattedDate = new Intl.DateTimeFormat('ru-RU', { 4 | day: '2-digit', 5 | month: 'long', 6 | hour: '2-digit', 7 | minute: '2-digit' 8 | }).format(createdAt) 9 | 10 | const [day, month, year, time] = formattedDate.split(' ') 11 | 12 | return `${day} ${month} в ${time}` 13 | } 14 | -------------------------------------------------------------------------------- /src/api/generated/createProgressRequest.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface CreateProgressRequest { 10 | /** Indicates whether the lesson is completed */ 11 | isCompleted: boolean; 12 | /** Unique identifier of the lesson */ 13 | lessonId: string; 14 | } 15 | -------------------------------------------------------------------------------- /src/api/generated/createUserResponse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface CreateUserResponse { 10 | /** Unique session identifier */ 11 | id: string; 12 | /** Session token */ 13 | token: string; 14 | /** Unique user identifier */ 15 | userId: string; 16 | } 17 | -------------------------------------------------------------------------------- /src/api/generated/heleketPaymentWebhookResponseAdditionalData.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | /** 10 | * Additional data provided during invoice creation 11 | * @nullable 12 | */ 13 | export type HeleketPaymentWebhookResponseAdditionalData = { [key: string]: unknown } | null; 14 | -------------------------------------------------------------------------------- /src/api/generated/heleketPaymentWebhookResponsePaymentAmount.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | /** 10 | * Amount actually paid by the customer (may be null) 11 | * @nullable 12 | */ 13 | export type HeleketPaymentWebhookResponsePaymentAmount = { [key: string]: unknown } | null; 14 | -------------------------------------------------------------------------------- /src/api/generated/heleketPaymentWebhookResponseWalletAddressUuid.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | /** 10 | * UUID of the static wallet (if applicable) 11 | * @nullable 12 | */ 13 | export type HeleketPaymentWebhookResponseWalletAddressUuid = { [key: string]: unknown } | null; 14 | -------------------------------------------------------------------------------- /src/api/generated/lastLessonResponse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface LastLessonResponse { 10 | /** Unique identifier */ 11 | id: string; 12 | /** Unique lesson slug */ 13 | slug: string; 14 | /** Lesson position in course */ 15 | position: number; 16 | } 17 | -------------------------------------------------------------------------------- /src/api/generated/loginSessionResponse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface LoginSessionResponse { 10 | /** Unique session identifier */ 11 | id: string; 12 | /** Session token */ 13 | token: string; 14 | /** Unique user identifier */ 15 | userId: string; 16 | } 17 | -------------------------------------------------------------------------------- /src/api/generated/passwordResetRequest.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface PasswordResetRequest { 10 | /** Reset token */ 11 | token: string; 12 | /** 13 | * New password 14 | * @minLength 6 15 | * @maxLength 128 16 | */ 17 | password: string; 18 | } 19 | -------------------------------------------------------------------------------- /src/api/generated/registrationsResponse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface RegistrationsResponse { 10 | /** Date of user registrations in YYYY-MM-DD format */ 11 | date: string; 12 | /** Number of users registered on the given date */ 13 | users: number; 14 | } 15 | -------------------------------------------------------------------------------- /src/api/generated/telegramAuthResponse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface TelegramAuthResponse { 10 | /** Unique session identifier */ 11 | id: string; 12 | /** Session token */ 13 | token: string; 14 | /** Unique user identifier */ 15 | userId: string; 16 | } 17 | -------------------------------------------------------------------------------- /src/api/generated/totpGenerateSecretResponse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface TotpGenerateSecretResponse { 10 | /** QR code URL for TOTP setup */ 11 | qrCodeUrl: string; 12 | /** TOTP secret key for generating one-time passwords */ 13 | secret: string; 14 | } 15 | -------------------------------------------------------------------------------- /src/api/generated/heleketPaymentWebhookResponseTxid.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | /** 10 | * Blockchain transaction hash (may be absent for internal or failed transactions) 11 | * @nullable 12 | */ 13 | export type HeleketPaymentWebhookResponseTxid = { [key: string]: unknown } | null; 14 | -------------------------------------------------------------------------------- /src/api/generated/meProgressResponseLastLesson.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | import type { LastLessonResponse } from './lastLessonResponse'; 9 | 10 | /** 11 | * Последний просмотренный урок 12 | * @nullable 13 | */ 14 | export type MeProgressResponseLastLesson = LastLessonResponse | null; 15 | -------------------------------------------------------------------------------- /src/api/hooks/useGetMe.ts: -------------------------------------------------------------------------------- 1 | import { type UseQueryOptions, useQuery } from '@tanstack/react-query' 2 | 3 | import type { AccountResponse } from '../generated' 4 | import { getMe } from '../requests' 5 | 6 | export const useGetMe = ( 7 | options?: Omit< 8 | UseQueryOptions, 9 | 'queryKey' | 'queryFn' 10 | > 11 | ) => 12 | useQuery({ 13 | queryKey: ['get me'], 14 | queryFn: getMe, 15 | ...options 16 | }) 17 | -------------------------------------------------------------------------------- /src/api/generated/heleketPaymentWebhookResponseMerchantAmount.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | /** 10 | * Amount credited to the merchant balance after Heleket commission 11 | * @nullable 12 | */ 13 | export type HeleketPaymentWebhookResponseMerchantAmount = { [key: string]: unknown } | null; 14 | -------------------------------------------------------------------------------- /src/api/generated/sessionControllerLogin200.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | import type { LoginSessionResponse } from './loginSessionResponse'; 9 | import type { LoginMfaResponse } from './loginMfaResponse'; 10 | 11 | export type SessionControllerLogin200 = LoginSessionResponse | LoginMfaResponse; 12 | -------------------------------------------------------------------------------- /src/api/generated/totpEnableRequest.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface TotpEnableRequest { 10 | /** 11 | * PIN code for enabling TOTP 2FA 12 | * @minLength 6 13 | * @maxLength 6 14 | */ 15 | pin: string; 16 | /** TOTP secret key */ 17 | secret: string; 18 | } 19 | -------------------------------------------------------------------------------- /src/api/hooks/useGetAvailableSsoProviders.ts: -------------------------------------------------------------------------------- 1 | import { type UseQueryOptions, useQuery } from '@tanstack/react-query' 2 | 3 | import { getAvailableSsoProviders } from '../requests' 4 | 5 | export const useGetAvailableSsoProviders = ( 6 | options?: Omit, 'queryKey' | 'queryFn'> 7 | ) => 8 | useQuery({ 9 | queryKey: ['get available sso providers'], 10 | queryFn: getAvailableSsoProviders, 11 | ...options 12 | }) 13 | -------------------------------------------------------------------------------- /src/api/generated/createProgressResponse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface CreateProgressResponse { 10 | /** Next lesson identifier or null if no next lesson exists */ 11 | nextLesson: string; 12 | /** Indicates whether the lesson is completed */ 13 | isCompleted: boolean; 14 | } 15 | -------------------------------------------------------------------------------- /src/api/generated/sessionControllerLoginAdmin200.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | import type { LoginSessionResponse } from './loginSessionResponse'; 9 | import type { LoginMfaResponse } from './loginMfaResponse'; 10 | 11 | export type SessionControllerLoginAdmin200 = LoginSessionResponse | LoginMfaResponse; 12 | -------------------------------------------------------------------------------- /src/api/generated/loginMfaResponse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface LoginMfaResponse { 10 | /** MFA ticket for further verification */ 11 | ticket: string; 12 | /** Allowed MFA methods */ 13 | allowedMethods: string[]; 14 | /** Unique user identifier */ 15 | userId: string; 16 | } 17 | -------------------------------------------------------------------------------- /src/api/hooks/useRemoveAllSessions.ts: -------------------------------------------------------------------------------- 1 | import { type UseMutationOptions, useMutation } from '@tanstack/react-query' 2 | 3 | import { removeAllSessions } from '../requests' 4 | 5 | export const useRemoveAllSessions = ( 6 | options?: Omit< 7 | UseMutationOptions, 8 | 'mutationKey' | 'mutationFn' 9 | > 10 | ) => 11 | useMutation({ 12 | mutationKey: ['remove all sessions'], 13 | mutationFn: removeAllSessions, 14 | ...options 15 | }) 16 | -------------------------------------------------------------------------------- /src/lib/analytics/providers/metrika.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | interface Window { 3 | ym?: any 4 | } 5 | } 6 | 7 | export const metrikaProvider = { 8 | init() {}, 9 | 10 | track(event: string, data?: Record) { 11 | if (typeof window !== 'undefined' && typeof window.ym === 'function') { 12 | window.ym( 13 | Number(process.env.NEXT_PUBLIC_YANDEX_METRIKA_ID), 14 | 'reachGoal', 15 | event, 16 | data 17 | ) 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/api/hooks/useGetSessions.ts: -------------------------------------------------------------------------------- 1 | import { type UseQueryOptions, useQuery } from '@tanstack/react-query' 2 | 3 | import type { SessionResponse } from '../generated' 4 | import { getSessions } from '../requests' 5 | 6 | export const useGetSessions = ( 7 | options?: Omit< 8 | UseQueryOptions, 9 | 'queryKey' | 'queryFn' 10 | > 11 | ) => 12 | useQuery({ 13 | queryKey: ['get sessions'], 14 | queryFn: getSessions, 15 | ...options 16 | }) 17 | -------------------------------------------------------------------------------- /src/api/hooks/useFetchMfaStatus.ts: -------------------------------------------------------------------------------- 1 | import { type UseQueryOptions, useQuery } from '@tanstack/react-query' 2 | 3 | import type { MfaStatusResponse } from '../generated' 4 | import { fetchMfaStatus } from '../requests' 5 | 6 | export const useFetchMfaStatus = ( 7 | options?: Omit< 8 | UseQueryOptions, 9 | 'queryKey' | 'queryFn' 10 | > 11 | ) => 12 | useQuery({ 13 | queryKey: ['mfa status'], 14 | queryFn: fetchMfaStatus, 15 | ...options 16 | }) 17 | -------------------------------------------------------------------------------- /src/api/hooks/useFetchSsoStauts.ts: -------------------------------------------------------------------------------- 1 | import { type UseQueryOptions, useQuery } from '@tanstack/react-query' 2 | 3 | import type { SsoStatusResponse } from '../generated' 4 | import { fetchSsoStatus } from '../requests' 5 | 6 | export const useFetchSsoStatus = ( 7 | options?: Omit< 8 | UseQueryOptions, 9 | 'queryKey' | 'queryFn' 10 | > 11 | ) => 12 | useQuery({ 13 | queryKey: ['sso status'], 14 | queryFn: fetchSsoStatus, 15 | ...options 16 | }) 17 | -------------------------------------------------------------------------------- /src/api/requests/payment.ts: -------------------------------------------------------------------------------- 1 | import type { InitPaymentResponse, PaymentMethodResponse } from '../generated' 2 | import { api, instance } from '../instance' 3 | 4 | export const getPaymentMethods = async () => 5 | await api 6 | .get('/payment/methods') 7 | .then(res => res.data) 8 | 9 | export const initPayment = async (data: { method: string }) => 10 | await instance 11 | .post('/payment/init', data) 12 | .then(res => res.data) 13 | -------------------------------------------------------------------------------- /public/payment-logos/crypto.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/api/hooks/useRevokeSession.ts: -------------------------------------------------------------------------------- 1 | import { type UseMutationOptions, useMutation } from '@tanstack/react-query' 2 | 3 | import { revokeSession } from '../requests' 4 | 5 | export const useRevokeSession = ( 6 | options?: Omit< 7 | UseMutationOptions, 8 | 'mutationKey' | 'mutationFn' 9 | > 10 | ) => 11 | useMutation({ 12 | mutationKey: ['revoke session'], 13 | mutationFn: ({ id }: { id: string }) => revokeSession(id), 14 | ...options 15 | }) 16 | -------------------------------------------------------------------------------- /src/components/shared/player.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import KinescopePlayer, { 4 | PlayerPropsTypes 5 | } from '@kinescope/react-kinescope-player' 6 | import type { RefObject } from 'react' 7 | 8 | interface PlayerProps extends PlayerPropsTypes { 9 | forwardRef?: RefObject 10 | } 11 | 12 | export function Player({ forwardRef, ...props }: PlayerProps) { 13 | return 14 | } 15 | 16 | export { KinescopePlayer } 17 | -------------------------------------------------------------------------------- /src/lib/analytics/trigger.ts: -------------------------------------------------------------------------------- 1 | import { track } from './index' 2 | 3 | export function attachAnalyticsTriggers() { 4 | document.addEventListener('click', e => { 5 | const target = e.target as HTMLElement 6 | 7 | const eventName = target.getAttribute('data-analytics-event') 8 | 9 | if (eventName) { 10 | const dataAttr = target.getAttribute('data-analytics-data') 11 | const data = dataAttr ? JSON.parse(dataAttr) : undefined 12 | 13 | track(eventName, data) 14 | } 15 | }) 16 | } 17 | -------------------------------------------------------------------------------- /src/api/generated/initPaymentRequest.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | import type { InitPaymentRequestMethod } from './initPaymentRequestMethod'; 9 | 10 | export interface InitPaymentRequest { 11 | /** Payment method */ 12 | method: InitPaymentRequestMethod; 13 | /** User email (required if the account does not have one) */ 14 | email?: string; 15 | } 16 | -------------------------------------------------------------------------------- /src/api/hooks/useGetPaymentMethods.ts: -------------------------------------------------------------------------------- 1 | import { type UseQueryOptions, useQuery } from '@tanstack/react-query' 2 | 3 | import type { PaymentMethodResponse } from '../generated' 4 | import { getPaymentMethods } from '../requests' 5 | 6 | export const useGetPaymentMethods = ( 7 | options?: Omit< 8 | UseQueryOptions, 9 | 'queryKey' | 'queryFn' 10 | > 11 | ) => 12 | useQuery({ 13 | queryKey: ['get payment methods'], 14 | queryFn: getPaymentMethods, 15 | ...options 16 | }) 17 | -------------------------------------------------------------------------------- /src/api/hooks/useUnlinkAccount.ts: -------------------------------------------------------------------------------- 1 | import { type UseMutationOptions, useMutation } from '@tanstack/react-query' 2 | 3 | import { unlinkAccount } from '../requests' 4 | 5 | export const useUnlinkAccount = ( 6 | options?: Omit< 7 | UseMutationOptions, 8 | 'mutationKey' | 'mutationFn' 9 | > 10 | ) => 11 | useMutation({ 12 | mutationKey: ['unlink account'], 13 | mutationFn: ({ provider }: { provider: string }) => 14 | unlinkAccount(provider), 15 | ...options 16 | }) 17 | -------------------------------------------------------------------------------- /src/api/generated/statisticsResponse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface StatisticsResponse { 10 | /** Total number of users */ 11 | users: number; 12 | /** Total number of courses */ 13 | courses: number; 14 | /** Total number of views across all course */ 15 | views: number; 16 | /** Total number of lessons */ 17 | lessons: number; 18 | } 19 | -------------------------------------------------------------------------------- /src/api/hooks/useInitPayment.ts: -------------------------------------------------------------------------------- 1 | import { type UseMutationOptions, useMutation } from '@tanstack/react-query' 2 | 3 | import { InitPaymentRequest } from '../generated' 4 | import { initPayment } from '../requests' 5 | 6 | export const useInitPayment = ( 7 | options?: Omit< 8 | UseMutationOptions, 9 | 'mutationKey' | 'mutationFn' 10 | > 11 | ) => 12 | useMutation({ 13 | mutationKey: ['login'], 14 | mutationFn: (data: InitPaymentRequest) => initPayment(data), 15 | ...options 16 | }) 17 | -------------------------------------------------------------------------------- /src/api/requests/progress.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | CreateProgressRequest, 3 | CreateProgressResponse 4 | } from '../generated' 5 | import { instance } from '../instance' 6 | 7 | export const createProgress = async (data: CreateProgressRequest) => 8 | await instance 9 | .put('/progress', data) 10 | .then(response => response.data) 11 | 12 | export const getCourseProgress = async (courseId: string) => 13 | await instance 14 | .get(`/progress/${courseId}`) 15 | .then(response => response.data) 16 | -------------------------------------------------------------------------------- /src/api/generated/changePasswordRequest.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface ChangePasswordRequest { 10 | /** 11 | * New password 12 | * @minLength 6 13 | * @maxLength 128 14 | */ 15 | newPassword: string; 16 | /** 17 | * Confirmation of the new password 18 | * @minLength 6 19 | * @maxLength 128 20 | */ 21 | confirmPassword: string; 22 | } 23 | -------------------------------------------------------------------------------- /src/api/hooks/useTelegramConnect.ts: -------------------------------------------------------------------------------- 1 | import { type UseMutationOptions, useMutation } from '@tanstack/react-query' 2 | 3 | import type { TelegramAuthRequest } from '../generated' 4 | import { connectWithTelegram } from '../requests' 5 | 6 | export const useTelegramConnect = ( 7 | options?: Omit< 8 | UseMutationOptions, 9 | 'mutationKey' | 'mutationFn' 10 | > 11 | ) => 12 | useMutation({ 13 | mutationKey: ['telegram connect'], 14 | mutationFn: connectWithTelegram, 15 | ...options 16 | }) 17 | -------------------------------------------------------------------------------- /src/components/account/settings/preferences.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardContent } from '../../ui/card' 2 | 3 | import { AppearanceForm } from './appearance' 4 | 5 | export function Preferences() { 6 | return ( 7 |
8 |

Внешний вид

9 | 10 | 11 |
12 | 13 |
14 |
15 |
16 |
17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /src/api/generated/mfaStatusResponse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface MfaStatusResponse { 10 | /** Indicates if TOTP MFA is enabled for the account */ 11 | totpMfa: boolean; 12 | /** Indicates if Passkey MFA is enabled for the account */ 13 | passkeyMfa: boolean; 14 | /** Indicates if recovery codes are active for the account */ 15 | recoveryActive: boolean; 16 | } 17 | -------------------------------------------------------------------------------- /src/api/generated/mfaControllerVerifyBody.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | // @ts-nocheck 9 | import type { MfaPasskeyRequest } from './mfaPasskeyRequest' 10 | import type { MfaRecoveryRequest } from './mfaRecoveryRequest' 11 | import type { MfaTotpRequest } from './mfaTotpRequest' 12 | 13 | export type MfaControllerVerifyBody = 14 | | MfaTotpRequest 15 | | MfaPasskeyRequest 16 | | MfaRecoveryRequest 17 | -------------------------------------------------------------------------------- /src/app/robots.ts: -------------------------------------------------------------------------------- 1 | import type { MetadataRoute } from 'next' 2 | 3 | import { APP_CONFIG } from '../constants' 4 | 5 | export default function robots(): MetadataRoute.Robots { 6 | return { 7 | rules: { 8 | userAgent: '*', 9 | allow: '/', 10 | disallow: [ 11 | '/*?', 12 | '/*.html', 13 | '/auth/recovery/*', 14 | '/account/*', 15 | '/lesson/*', 16 | '*?*=*', 17 | '*?*=*&*=*', 18 | '*?*=*=*' 19 | ] 20 | }, 21 | host: APP_CONFIG.baseUrl, 22 | sitemap: `${APP_CONFIG.baseUrl}/sitemap.xml` 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/lib/analytics/providers/posthog.ts: -------------------------------------------------------------------------------- 1 | import posthog from 'posthog-js' 2 | 3 | export const posthogProvider = { 4 | init() { 5 | if (typeof window === 'undefined') return 6 | 7 | posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY as string, { 8 | api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST, 9 | person_profiles: 'always', 10 | defaults: '2025-05-24' 11 | }) 12 | }, 13 | 14 | track(event: string, data?: Record) { 15 | if (typeof window === 'undefined') return 16 | 17 | posthog.capture(event, data) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/api/hooks/useRegister.ts: -------------------------------------------------------------------------------- 1 | import { type UseMutationOptions, useMutation } from '@tanstack/react-query' 2 | 3 | import type { CreateUserRequest, CreateUserResponse } from '../generated' 4 | import { createAccount } from '../requests' 5 | 6 | export const useRegister = ( 7 | options?: Omit< 8 | UseMutationOptions, 9 | 'mutationKey' | 'mutationFn' 10 | > 11 | ) => 12 | useMutation({ 13 | mutationKey: ['register'], 14 | mutationFn: (data: CreateUserRequest) => createAccount(data), 15 | ...options 16 | }) 17 | -------------------------------------------------------------------------------- /src/api/hooks/useTelegramAuth.ts: -------------------------------------------------------------------------------- 1 | import { type UseMutationOptions, useMutation } from '@tanstack/react-query' 2 | 3 | import type { TelegramAuthRequest, TelegramAuthResponse } from '../generated' 4 | import { loginWithTelegram } from '../requests' 5 | 6 | export const useTelegramAuth = ( 7 | options?: Omit< 8 | UseMutationOptions, 9 | 'mutationKey' | 'mutationFn' 10 | > 11 | ) => 12 | useMutation({ 13 | mutationKey: ['telegram oauth finish'], 14 | mutationFn: loginWithTelegram, 15 | ...options 16 | }) 17 | -------------------------------------------------------------------------------- /src/api/generated/passkeyResponse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface PasskeyResponse { 10 | /** Unique identifier */ 11 | id: string; 12 | /** Name of the device associated with the passkey */ 13 | deviceName: string; 14 | /** Timestamp of the last time the user accessed the device */ 15 | lastUsedAt: string; 16 | /** Timestamp when the passkey was created */ 17 | createdAt: string; 18 | } 19 | -------------------------------------------------------------------------------- /src/components/lesson/lesson-container.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from 'react' 2 | 3 | interface LessonContainerProps { 4 | children: ReactNode 5 | } 6 | 7 | export function LessonContainer({ children }: LessonContainerProps) { 8 | return ( 9 |
10 |
11 |
12 |
13 | {children} 14 |
15 |
16 |
17 |
18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /src/api/generated/activeRestrictionResponse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface ActiveRestrictionResponse { 10 | /** Date of restriction creation */ 11 | createdAt: string; 12 | /** Reason for the user restriction */ 13 | reason: string; 14 | /** End date of the restriction, if temporary */ 15 | until?: string; 16 | /** Information on whether the ban is permanent */ 17 | isPermanent: boolean; 18 | } 19 | -------------------------------------------------------------------------------- /src/api/hooks/useSsoConnect.ts: -------------------------------------------------------------------------------- 1 | import { type UseMutationOptions, useMutation } from '@tanstack/react-query' 2 | 3 | import type { SsoConnectResponse } from '../generated' 4 | import { getConnectUrl } from '../requests' 5 | 6 | export const useSsoConnect = ( 7 | options?: Omit< 8 | UseMutationOptions, 9 | 'mutationKey' | 'mutationFn' 10 | > 11 | ) => 12 | useMutation({ 13 | mutationKey: ['sso connect'], 14 | mutationFn: ({ provider }: { provider: string }) => 15 | getConnectUrl(provider), 16 | ...options 17 | }) 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env 30 | .env*.local 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | 39 | /design -------------------------------------------------------------------------------- /src/constants/payment-icons.ts: -------------------------------------------------------------------------------- 1 | import { BitcoinIcon, CreditCardIcon, GlobeIcon, StarIcon } from 'lucide-react' 2 | import type { ComponentType, SVGProps } from 'react' 3 | 4 | import { SberbankIcon, SbpIcon, TBankIcon } from '../components/icons' 5 | 6 | type IconType = ComponentType> 7 | 8 | export const PAYMENT_METHOD_ICONS: Record = { 9 | BANK_CARD: CreditCardIcon, 10 | SBP: SbpIcon, 11 | T_PAY: TBankIcon, 12 | SBER_PAY: SberbankIcon, 13 | CRYPTO: BitcoinIcon, 14 | INTERNATIONAL_CARD: GlobeIcon, 15 | TELEGRAM_STARS: StarIcon 16 | } 17 | -------------------------------------------------------------------------------- /src/providers/analytics-provider.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import posthog from 'posthog-js' 4 | import { PostHogProvider as PHProvider } from 'posthog-js/react' 5 | import { ReactNode, useEffect } from 'react' 6 | 7 | import { initAnalytics } from '../lib/analytics' 8 | import { attachAnalyticsTriggers } from '../lib/analytics/trigger' 9 | 10 | export function AnalyticsProvider({ children }: { children: ReactNode }) { 11 | useEffect(() => { 12 | initAnalytics() 13 | attachAnalyticsTriggers() 14 | }, []) 15 | 16 | return {children} 17 | } 18 | -------------------------------------------------------------------------------- /src/api/generated/heleketPaymentWebhookResponseType.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | /** 10 | * Type of webhook object 11 | */ 12 | export type HeleketPaymentWebhookResponseType = typeof HeleketPaymentWebhookResponseType[keyof typeof HeleketPaymentWebhookResponseType]; 13 | 14 | 15 | // eslint-disable-next-line @typescript-eslint/no-redeclare 16 | export const HeleketPaymentWebhookResponseType = { 17 | payment: 'payment', 18 | } as const; 19 | -------------------------------------------------------------------------------- /src/api/requests/lesson.ts: -------------------------------------------------------------------------------- 1 | import type { CreateLessonRequest, LessonResponse } from '../generated' 2 | import { api, instance } from '../instance' 3 | 4 | export const getLesson = async (slug: string) => 5 | await api 6 | .get(`/lessons/${slug}`) 7 | .then(response => response.data) 8 | 9 | export const getCompletedLessons = async (courseId: string) => 10 | await instance 11 | .get(`/lessons/${courseId}/progress`) 12 | .then(response => response.data) 13 | 14 | export const createLesson = (data: CreateLessonRequest) => 15 | instance.post('/lessons', data) 16 | -------------------------------------------------------------------------------- /src/api/generated/loginRequest.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface LoginRequest { 10 | /** Email address */ 11 | email: string; 12 | /** 13 | * Password 14 | * @minLength 6 15 | * @maxLength 128 16 | */ 17 | password: string; 18 | /** Captcha verification code */ 19 | captcha: string; 20 | /** Fingerprint visitor ID */ 21 | visitorId?: string; 22 | /** Fingerprint request ID */ 23 | requestId?: string; 24 | } 25 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from 'next' 2 | 3 | const config: NextConfig = { 4 | reactStrictMode: true, 5 | poweredByHeader: false, 6 | output: 'standalone', 7 | trailingSlash: false, 8 | images: { 9 | remotePatterns: [ 10 | { 11 | protocol: 'https', 12 | hostname: '**' 13 | } 14 | ], 15 | dangerouslyAllowSVG: false 16 | }, 17 | typedRoutes: false, 18 | experimental: { 19 | optimizePackageImports: ['tailwindcss'], 20 | serverActions: { 21 | bodySizeLimit: '2mb' 22 | }, 23 | mdxRs: false 24 | }, 25 | compress: true 26 | } 27 | 28 | export default config 29 | -------------------------------------------------------------------------------- /src/api/generated/leaderResponse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface LeaderResponse { 10 | /** Unique identifier of the user */ 11 | id: string; 12 | /** Display name of the user */ 13 | displayName: string; 14 | /** User avatar URL or identifier */ 15 | avatar: string; 16 | /** Points accumulated by the user */ 17 | points: number; 18 | /** Whether the user has an active premium subscription */ 19 | isPremium: boolean; 20 | } 21 | -------------------------------------------------------------------------------- /src/api/generated/ssoStatusResponse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface SsoStatusResponse { 10 | /** Indicates whether the GitHub account is connected */ 11 | github: boolean; 12 | /** Indicates whether the Google account is connected */ 13 | google: boolean; 14 | /** Indicates whether the Discord account is connected */ 15 | discord: boolean; 16 | /** Indicates whether the Telegram account is connected */ 17 | telegram: boolean; 18 | } 19 | -------------------------------------------------------------------------------- /src/api/hooks/useLogin.ts: -------------------------------------------------------------------------------- 1 | import { type UseMutationOptions, useMutation } from '@tanstack/react-query' 2 | 3 | import type { 4 | LoginMfaResponse, 5 | LoginRequest, 6 | LoginSessionResponse 7 | } from '../generated' 8 | import { login } from '../requests' 9 | 10 | export const useLogin = ( 11 | options?: Omit< 12 | UseMutationOptions< 13 | LoginSessionResponse | LoginMfaResponse, 14 | unknown, 15 | LoginRequest 16 | >, 17 | 'mutationKey' | 'mutationFn' 18 | > 19 | ) => 20 | useMutation({ 21 | mutationKey: ['login'], 22 | mutationFn: (data: LoginRequest) => login(data), 23 | ...options 24 | }) 25 | -------------------------------------------------------------------------------- /src/api/generated/createRestrictionRequest.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | import type { CreateRestrictionRequestReason } from './createRestrictionRequestReason'; 9 | 10 | export interface CreateRestrictionRequest { 11 | /** Reason for banning the user */ 12 | reason: CreateRestrictionRequestReason; 13 | /** Date until the ban is active. If not provided, the ban is indefinite */ 14 | until?: string; 15 | /** ID of the user to be banned */ 16 | userId: string; 17 | } 18 | -------------------------------------------------------------------------------- /src/components/shared/ellipsis-loader.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { motion } from 'framer-motion' 4 | 5 | export function EllipsisLoader() { 6 | return ( 7 |
8 | {[...Array(3)].map((_, i) => ( 9 | 22 | ))} 23 |
24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /src/lib/analytics/index.ts: -------------------------------------------------------------------------------- 1 | import { authEvents } from './events' 2 | import { consoleProvider, metrikaProvider, posthogProvider } from './providers' 3 | 4 | const providers = [ 5 | posthogProvider, 6 | metrikaProvider, 7 | ...(process.env.NODE_ENV === 'development' ? [consoleProvider] : []) 8 | ] 9 | 10 | export function initAnalytics() { 11 | if (typeof window === 'undefined') return 12 | providers.forEach(p => p.init?.()) 13 | } 14 | 15 | export function track(event: string, data?: Record) { 16 | providers.forEach(p => p.track(event, data)) 17 | } 18 | 19 | export const analytics = { 20 | auth: authEvents 21 | } 22 | -------------------------------------------------------------------------------- /src/providers/course-provider.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useMutation } from '@tanstack/react-query' 4 | import { type ReactNode, useEffect } from 'react' 5 | 6 | import { incrementCourseViews } from '@/src/api/requests' 7 | 8 | interface CourseProviderProps { 9 | id: string 10 | children: ReactNode 11 | } 12 | 13 | export function CourseProvider({ id, children }: CourseProviderProps) { 14 | const { mutate } = useMutation({ 15 | mutationKey: ['increment course views', id], 16 | mutationFn: () => incrementCourseViews(id) 17 | }) 18 | 19 | useEffect(() => { 20 | mutate() 21 | }, [mutate]) 22 | 23 | return <>{children} 24 | } 25 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "none", 3 | "tabWidth": 4, 4 | "useTabs": true, 5 | "semi": false, 6 | "jsxSingleQuote": true, 7 | "singleQuote": true, 8 | "arrowParens": "avoid", 9 | "importOrder": [ 10 | "", 11 | "^@/app/(.*)$", 12 | "^@/components/(.*)$", 13 | "^@/constants/(.*)$", 14 | "^@/hooks/(.*)$", 15 | "^@/lib/(.*)$", 16 | "^@/server/(.*)$", 17 | "^@/styles/(.*)$", 18 | "^../(.*)$", 19 | "^./(.*)$" 20 | ], 21 | "importOrderSeparation": true, 22 | "importOrderSortSpecifiers": true, 23 | "plugins": [ 24 | "@trivago/prettier-plugin-sort-imports", 25 | "prettier-plugin-tailwindcss" 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /src/api/generated/sessionResponse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface SessionResponse { 10 | /** Unique identifier */ 11 | id: string; 12 | /** Date and time when the session was created */ 13 | createdAt: string; 14 | /** Country from which the login occurred */ 15 | country: string; 16 | /** City from which the login occurred */ 17 | city: string; 18 | /** Name of the browser used */ 19 | browser: string; 20 | /** Operating system of the user */ 21 | os: string; 22 | } 23 | -------------------------------------------------------------------------------- /src/app/sitemap.ts: -------------------------------------------------------------------------------- 1 | import type { MetadataRoute } from 'next' 2 | 3 | import { getCourses } from '../api/requests' 4 | import { APP_CONFIG } from '../constants' 5 | 6 | export default async function sitemap(): Promise { 7 | const courses: MetadataRoute.Sitemap = (await getCourses()).map(course => ({ 8 | url: `${APP_CONFIG.baseUrl}/${course.slug}`, 9 | lastModified: new Date(), 10 | changeFrequency: 'monthly', 11 | priority: 0.9 12 | })) 13 | 14 | return [ 15 | { 16 | url: APP_CONFIG.baseUrl, 17 | lastModified: new Date(), 18 | changeFrequency: 'yearly', 19 | priority: 1 20 | }, 21 | ...courses 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /src/api/generated/createUserRequest.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface CreateUserRequest { 10 | /** Display name */ 11 | name: string; 12 | /** Email address */ 13 | email: string; 14 | /** 15 | * Password 16 | * @minLength 6 17 | * @maxLength 128 18 | */ 19 | password: string; 20 | /** Captcha verification code */ 21 | captcha: string; 22 | /** Fingerprint visitor ID */ 23 | visitorId?: string; 24 | /** Fingerprint request ID */ 25 | requestId?: string; 26 | } 27 | -------------------------------------------------------------------------------- /src/api/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from './useFetchMfaStatus' 2 | export * from './useFetchSsoStauts' 3 | export * from './useGetAvailableSsoProviders' 4 | export * from './useGetMe' 5 | export * from './useGetPaymentMethods' 6 | export * from './useGetSessions' 7 | export * from './useInitPayment' 8 | export * from './useLogin' 9 | export * from './useLogout' 10 | export * from './useRegister' 11 | export * from './useRemoveAllSessions' 12 | export * from './useRevokeSession' 13 | export * from './useSsoConnect' 14 | export * from './useTelegramAuth' 15 | export * from './useTelegramConnect' 16 | export * from './useUnlinkAccount' 17 | export * from './useVerifyMfa' 18 | -------------------------------------------------------------------------------- /src/providers/tanstack-query-provider.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query' 4 | import { type ReactNode, useState } from 'react' 5 | 6 | export function TanstackQueryProvider({ children }: { children: ReactNode }) { 7 | const [client] = useState( 8 | new QueryClient({ 9 | defaultOptions: { 10 | queries: { 11 | staleTime: Infinity, 12 | refetchInterval: false, 13 | refetchOnWindowFocus: false, 14 | refetchOnReconnect: false, 15 | refetchOnMount: false 16 | } 17 | } 18 | }) 19 | ) 20 | 21 | return {children} 22 | } 23 | -------------------------------------------------------------------------------- /src/api/generated/createRestrictionRequestReason.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | /** 10 | * Reason for banning the user 11 | */ 12 | export type CreateRestrictionRequestReason = typeof CreateRestrictionRequestReason[keyof typeof CreateRestrictionRequestReason]; 13 | 14 | 15 | // eslint-disable-next-line @typescript-eslint/no-redeclare 16 | export const CreateRestrictionRequestReason = { 17 | INAPPROPRIATE_USERNAME: 'INAPPROPRIATE_USERNAME', 18 | SPAM: 'SPAM', 19 | OFFENSIVE_BEHAVIOR: 'OFFENSIVE_BEHAVIOR', 20 | } as const; 21 | -------------------------------------------------------------------------------- /src/api/generated/paymentMethodResponse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | import type { PaymentMethodResponseId } from './paymentMethodResponseId'; 9 | 10 | export interface PaymentMethodResponse { 11 | /** Unique identifier of the payment method */ 12 | id: PaymentMethodResponseId; 13 | /** Display name of the payment method */ 14 | name: string; 15 | /** Description of the payment method */ 16 | description: string; 17 | /** Indicates whether this payment method is available for the user */ 18 | isAvailable: boolean; 19 | } 20 | -------------------------------------------------------------------------------- /src/api/generated/progressResponse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface ProgressResponse { 10 | /** Unique identifier */ 11 | id: string; 12 | /** Indicates whether the lesson is completed */ 13 | isCompleted: boolean; 14 | /** User ID associated with the progress */ 15 | userId: string; 16 | /** Lesson ID associated with the progress */ 17 | lessonId: string; 18 | /** Date when the progress was created */ 19 | createdAt: string; 20 | /** Date when the progress was last updated */ 21 | updatedAt: string; 22 | } 23 | -------------------------------------------------------------------------------- /src/providers/account-provider.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import type { ReactNode } from 'react' 4 | 5 | import { EllipsisLoader } from '../components/shared/ellipsis-loader' 6 | 7 | import { useFetchMfaStatus } from '@/src/api/hooks' 8 | import { useCurrent } from '@/src/hooks' 9 | 10 | export function AccountProvider({ children }: { children: ReactNode }) { 11 | const { isLoading } = useCurrent() 12 | 13 | const { isLoading: isLoadingStatus } = useFetchMfaStatus() 14 | 15 | if (isLoading || isLoadingStatus) { 16 | return ( 17 |
18 | 19 |
20 | ) 21 | } 22 | 23 | return <>{children} 24 | } 25 | -------------------------------------------------------------------------------- /src/providers/fingerprint-provider.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { FpjsProvider } from '@fingerprintjs/fingerprintjs-pro-react' 4 | import { type ReactNode } from 'react' 5 | 6 | interface FingerprintProviderProps { 7 | children: ReactNode 8 | } 9 | 10 | export function FingerprintProvider({ children }: FingerprintProviderProps) { 11 | return ( 12 | //loader_v.js` 17 | }} 18 | > 19 | {children} 20 | 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /src/components/account/settings/subscription.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardContent } from '../../ui/card' 2 | 3 | import { AutoBillingForm } from './auto-billing-form' 4 | import type { AccountResponse } from '@/src/api/generated' 5 | 6 | interface SubscriptionProps { 7 | user: AccountResponse | undefined 8 | } 9 | 10 | export function Subscription({ user }: SubscriptionProps) { 11 | return ( 12 |
13 |

Подписка

14 | 15 | 16 |
17 | 18 |
19 |
20 |
21 |
22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /src/app/manifest.ts: -------------------------------------------------------------------------------- 1 | import { MetadataRoute } from 'next' 2 | 3 | import { SEO } from '../constants' 4 | 5 | export default function manifest(): MetadataRoute.Manifest { 6 | return { 7 | name: SEO.name, 8 | short_name: SEO.name, 9 | categories: SEO.keywords, 10 | lang: 'ru_RU', 11 | description: SEO.description, 12 | start_url: '/', 13 | display: 'standalone', 14 | background_color: '#FFFFFF', 15 | theme_color: '#2563EB', 16 | orientation: 'portrait', 17 | icons: [ 18 | { 19 | src: '/touch-icons/192x192.png', 20 | sizes: '192x192', 21 | type: 'image/png' 22 | }, 23 | { 24 | src: '/touch-icons/512x512.png', 25 | sizes: '512x512', 26 | type: 'image/png' 27 | } 28 | ] 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/api/generated/coursesResponse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface CoursesResponse { 10 | /** Unique identifier */ 11 | id: string; 12 | /** Title of the course */ 13 | title: string; 14 | /** Slug of the course (unique URL identifier) */ 15 | slug: string; 16 | /** 17 | * Short description of the course 18 | * @nullable 19 | */ 20 | shortDescription: string | null; 21 | /** 22 | * Identifier of the course thumbnail 23 | * @nullable 24 | */ 25 | thumbnail: string | null; 26 | /** Number of lessons in the course */ 27 | lessons: number; 28 | } 29 | -------------------------------------------------------------------------------- /src/hooks/useCurrent.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | 3 | import { useGetMe } from '../api/hooks' 4 | import { instance } from '../api/instance' 5 | import { cookies } from '../lib/cookie' 6 | 7 | import { useAuth } from './useAuth' 8 | 9 | export function useCurrent() { 10 | const { isAuthorized } = useAuth() 11 | 12 | const { 13 | data: user, 14 | isLoading, 15 | error 16 | } = useGetMe({ 17 | retry: false, 18 | enabled: isAuthorized 19 | }) 20 | 21 | useEffect(() => { 22 | if (error) { 23 | cookies.remove('token') 24 | 25 | delete instance.defaults.headers['X-Session-Token'] 26 | 27 | window.location.reload() 28 | } 29 | }, [error]) 30 | 31 | return { 32 | user, 33 | isLoading, 34 | error 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/api/generated/initPaymentRequestMethod.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | /** 10 | * Payment method 11 | */ 12 | export type InitPaymentRequestMethod = typeof InitPaymentRequestMethod[keyof typeof InitPaymentRequestMethod]; 13 | 14 | 15 | // eslint-disable-next-line @typescript-eslint/no-redeclare 16 | export const InitPaymentRequestMethod = { 17 | BANK_CARD: 'BANK_CARD', 18 | SBP: 'SBP', 19 | T_PAY: 'T_PAY', 20 | SBER_PAY: 'SBER_PAY', 21 | YOOMONEY: 'YOOMONEY', 22 | CRYPTO: 'CRYPTO', 23 | INTERNATIONAL_CARD: 'INTERNATIONAL_CARD', 24 | TELEGRAM_STARS: 'TELEGRAM_STARS', 25 | } as const; 26 | -------------------------------------------------------------------------------- /src/api/generated/accountResponse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface AccountResponse { 10 | /** Unique identifier */ 11 | id: string; 12 | /** Display name */ 13 | displayName: string; 14 | /** Email address */ 15 | email: string; 16 | /** Identifier of the user avatar */ 17 | avatar: string; 18 | /** Indicates whether the user has verified their email address */ 19 | isEmailVerified: boolean; 20 | /** Indicates whether auto billing is enabled for the user */ 21 | isAutoBilling: boolean; 22 | /** Indicates whether the user has an active subscription */ 23 | isPremium: boolean; 24 | } 25 | -------------------------------------------------------------------------------- /src/components/shared/captcha.tsx: -------------------------------------------------------------------------------- 1 | import { useTheme } from 'next-themes' 2 | import Turnstile, { type TurnstileProps } from 'react-turnstile' 3 | 4 | interface CaptchaProps extends Omit { 5 | onVerify: (token: string) => void 6 | } 7 | 8 | export function Captcha({ onVerify, ...props }: CaptchaProps) { 9 | const { resolvedTheme } = useTheme() 10 | 11 | return ( 12 | 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /src/api/generated/paymentMethodResponseId.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | /** 10 | * Unique identifier of the payment method 11 | */ 12 | export type PaymentMethodResponseId = typeof PaymentMethodResponseId[keyof typeof PaymentMethodResponseId]; 13 | 14 | 15 | // eslint-disable-next-line @typescript-eslint/no-redeclare 16 | export const PaymentMethodResponseId = { 17 | BANK_CARD: 'BANK_CARD', 18 | SBP: 'SBP', 19 | T_PAY: 'T_PAY', 20 | SBER_PAY: 'SBER_PAY', 21 | YOOMONEY: 'YOOMONEY', 22 | CRYPTO: 'CRYPTO', 23 | INTERNATIONAL_CARD: 'INTERNATIONAL_CARD', 24 | TELEGRAM_STARS: 'TELEGRAM_STARS', 25 | } as const; 26 | -------------------------------------------------------------------------------- /src/app/(public)/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next' 2 | import { Fragment } from 'react' 3 | 4 | import { getPopularCourses } from '@/src/api/requests' 5 | import { Features } from '@/src/components/home/features' 6 | import { Hero } from '@/src/components/home/hero' 7 | import { Popular } from '@/src/components/home/popular' 8 | import { TelegramCTA } from '@/src/components/home/telegram-cta' 9 | 10 | export const metadata: Metadata = { 11 | title: 'Образовательная платформа по веб разработке' 12 | } 13 | 14 | export default async function HomePage() { 15 | const courses = await getPopularCourses() 16 | 17 | return ( 18 | 19 | 20 | 21 | 22 | 23 | 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /src/components/account/settings/account-form.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardContent } from '../../ui/card' 2 | 3 | import { EmailForm } from './email-form' 4 | import { PasswordForm } from './password-form' 5 | import type { AccountResponse } from '@/src/api/generated' 6 | 7 | interface AccountFormProps { 8 | user: AccountResponse | undefined 9 | } 10 | 11 | export function AccountForm({ user }: AccountFormProps) { 12 | return ( 13 |
14 |

Аккаунт

15 | 16 | 17 |
18 | 19 | 20 |
21 |
22 |
23 |
24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /src/api/generated/userResponse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface UserResponse { 10 | /** Unique identifier */ 11 | id: string; 12 | /** Account creation date */ 13 | createdAt: string; 14 | /** Email address */ 15 | email: string; 16 | /** Username */ 17 | username: string; 18 | /** Display name */ 19 | displayName: string; 20 | /** 21 | * Identifier of the user avatar 22 | * @nullable 23 | */ 24 | avatar: string | null; 25 | /** Indicates whether the user is banned */ 26 | isBanned: boolean; 27 | /** Indicates whether multi-factor authentication is enabled */ 28 | isMfaEnabled: boolean; 29 | } 30 | -------------------------------------------------------------------------------- /src/components/icons/tbank-icon.tsx: -------------------------------------------------------------------------------- 1 | import type { SVGProps } from 'react' 2 | 3 | interface TBankIconProps extends SVGProps {} 4 | 5 | export function TBankIcon({ ...props }: TBankIconProps) { 6 | return ( 7 | 16 | 22 | 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /src/components/account/settings/profile-form.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardContent } from '../../ui/card' 2 | 3 | import { AvatarForm } from './avatar-form' 4 | import { DisplayNameForm } from './display-name-form' 5 | import type { AccountResponse } from '@/src/api/generated' 6 | 7 | interface ProfileForm { 8 | user: AccountResponse | undefined 9 | } 10 | 11 | export function ProfileForm({ user }: ProfileForm) { 12 | return ( 13 |
14 |

Профиль

15 | 16 | 17 |
18 | 19 | 20 |
21 |
22 |
23 |
24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /src/app/account/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from 'react' 2 | 3 | import { Header } from '@/src/components/layout/header' 4 | import { UserNavigation } from '@/src/components/layout/user-navigation' 5 | import { AccountProvider } from '@/src/providers' 6 | 7 | export default function AccountLayout({ children }: { children: ReactNode }) { 8 | return ( 9 | 10 |
11 |
12 |
13 |
14 |
15 | 16 |
17 | {children} 18 |
19 |
20 |
21 | 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /src/api/generated/heleketPaymentConvertResponse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | import type { HeleketPaymentConvertResponseCommission } from './heleketPaymentConvertResponseCommission'; 9 | 10 | export interface HeleketPaymentConvertResponse { 11 | /** Currency code to which the payment was converted */ 12 | to_currency: string; 13 | /** 14 | * Conversion commission 15 | * @nullable 16 | */ 17 | commission: HeleketPaymentConvertResponseCommission; 18 | /** Conversion rate between payer currency and to_currency */ 19 | rate: string; 20 | /** Converted amount in to_currency credited to merchant balance (after commission) */ 21 | amount: string; 22 | } 23 | -------------------------------------------------------------------------------- /public/payment-logos/sbp.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/components/shared/sonner.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import type { ComponentProps } from 'react' 4 | import { Toaster as Sonner } from 'sonner' 5 | 6 | type ToasterProps = ComponentProps 7 | 8 | export function Toaster({ ...props }: ToasterProps) { 9 | return ( 10 | 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /src/constants/mfa-methods.ts: -------------------------------------------------------------------------------- 1 | import { KeyIcon, ShieldIcon, SmartphoneIcon } from 'lucide-react' 2 | import type { ComponentType } from 'react' 3 | 4 | export type MfaMethod = 'totp' | 'passkey' | 'recovery' 5 | 6 | export interface MfaOption { 7 | id: MfaMethod 8 | name: string 9 | description: string 10 | icon: ComponentType<{ className?: string }> 11 | } 12 | 13 | export const MFA_OPTIONS: MfaOption[] = [ 14 | { 15 | id: 'totp', 16 | name: 'Приложение-аутентификатор', 17 | description: 'Коды из приложения на телефоне', 18 | icon: SmartphoneIcon 19 | }, 20 | { 21 | id: 'passkey', 22 | name: 'Passkey', 23 | description: 'Биометрия или ключ доступа', 24 | icon: KeyIcon 25 | }, 26 | { 27 | id: 'recovery', 28 | name: 'Резервный код', 29 | description: 'Используйте одноразовые запасные коды', 30 | icon: ShieldIcon 31 | } 32 | ] 33 | -------------------------------------------------------------------------------- /src/api/requests/session.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | LoginMfaResponse, 3 | LoginRequest, 4 | LoginSessionResponse, 5 | SessionResponse 6 | } from '../generated' 7 | import { api, instance } from '../instance' 8 | 9 | export const login = async (data: LoginRequest) => 10 | await api 11 | .post< 12 | LoginSessionResponse | LoginMfaResponse 13 | >('/auth/session/login', data) 14 | .then(response => response.data) 15 | 16 | export const logout = async () => 17 | await instance.post('/auth/session/logout') 18 | 19 | export const getSessions = async () => 20 | await instance 21 | .get('/auth/session/all') 22 | .then(response => response.data) 23 | 24 | export const revokeSession = (id: string) => 25 | instance.delete(`/auth/session/${id}`) 26 | 27 | export const removeAllSessions = () => instance.delete('/auth/session/all') 28 | -------------------------------------------------------------------------------- /src/app/(public)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { type ReactNode } from 'react' 2 | 3 | import { Footer } from '@/src/components/layout/footer' 4 | import { Header } from '@/src/components/layout/header' 5 | 6 | export default function PublicLayout({ children }: { children: ReactNode }) { 7 | return ( 8 |
9 | {/*
10 |
11 |
12 |
13 |
*/} 14 | 15 |
16 |
17 | {children} 18 |
19 |
20 |
21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /src/proxy.ts: -------------------------------------------------------------------------------- 1 | import { type NextRequest, NextResponse } from 'next/server' 2 | 3 | export default async function proxy(request: NextRequest) { 4 | const { cookies, url } = request 5 | 6 | const token = cookies.get('token')?.value 7 | 8 | const isAuthPage = url.includes('/auth') 9 | const isVerifyPage = url.includes('/auth/verify') 10 | 11 | if (isVerifyPage) { 12 | if (!token) { 13 | return NextResponse.redirect(new URL('/auth/login', url)) 14 | } 15 | return NextResponse.next() 16 | } 17 | 18 | if (isAuthPage) { 19 | if (token) { 20 | return NextResponse.redirect(new URL('/account', url)) 21 | } 22 | return NextResponse.next() 23 | } 24 | 25 | if (!token) { 26 | return NextResponse.redirect(new URL('/auth/login', url)) 27 | } 28 | } 29 | 30 | export const config = { 31 | matcher: ['/auth/:path*', '/account/:path*', '/lesson/:path*'] 32 | } 33 | -------------------------------------------------------------------------------- /src/components/layout/nav-links.tsx: -------------------------------------------------------------------------------- 1 | import type { Route } from 'next' 2 | import Link from 'next/link' 3 | 4 | import { ROUTES } from '@/src/constants' 5 | 6 | interface NavLink { 7 | title: string 8 | href: Route 9 | } 10 | 11 | export const navLinks: NavLink[] = [ 12 | { title: 'Курсы', href: ROUTES.COURSES.ROOT }, 13 | { title: 'Об основателе', href: ROUTES.ABOUT }, 14 | { title: 'Подписка', href: ROUTES.PREMIUM } 15 | ] 16 | 17 | export function NavLinks() { 18 | return ( 19 | 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /src/api/generated/heleketPaymentWebhookResponseStatus.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | /** 10 | * Payment status 11 | */ 12 | export type HeleketPaymentWebhookResponseStatus = typeof HeleketPaymentWebhookResponseStatus[keyof typeof HeleketPaymentWebhookResponseStatus]; 13 | 14 | 15 | // eslint-disable-next-line @typescript-eslint/no-redeclare 16 | export const HeleketPaymentWebhookResponseStatus = { 17 | confirm_check: 'confirm_check', 18 | paid: 'paid', 19 | paid_over: 'paid_over', 20 | fail: 'fail', 21 | wrong_amount: 'wrong_amount', 22 | cancel: 'cancel', 23 | system_fail: 'system_fail', 24 | refund_process: 'refund_process', 25 | refund_fail: 'refund_fail', 26 | refund_paid: 'refund_paid', 27 | } as const; 28 | -------------------------------------------------------------------------------- /src/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | import * as LabelPrimitive from '@radix-ui/react-label' 2 | import { type VariantProps, cva } from 'class-variance-authority' 3 | import { 4 | type ComponentPropsWithoutRef, 5 | type ComponentRef, 6 | forwardRef 7 | } from 'react' 8 | 9 | import { cn } from '@/src/lib/utils' 10 | 11 | const labelVariants = cva( 12 | 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70' 13 | ) 14 | 15 | const Label = forwardRef< 16 | ComponentRef, 17 | ComponentPropsWithoutRef & 18 | VariantProps 19 | >(({ className, ...props }, ref) => ( 20 | 25 | )) 26 | Label.displayName = LabelPrimitive.Root.displayName 27 | 28 | export { Label } 29 | -------------------------------------------------------------------------------- /src/api/generated/meStatisticsResponse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface MeStatisticsResponse { 10 | /** Общее количество очков пользователя */ 11 | totalPoints: number; 12 | /** Место пользователя в рейтинге (чем меньше число, тем выше пользователь) */ 13 | ranking: number; 14 | /** Количество завершенных уроков и общее количество уроков (в формате X/Y) */ 15 | lessonsCompleted: string; 16 | /** Прогресс обучения в процентах */ 17 | learningProgressPercentage: number; 18 | /** Количество завершенных курсов (в которых пройдены все уроки) */ 19 | completedCourses: number; 20 | /** Количество курсов, которые находятся в процессе изучения (но еще не завершены) */ 21 | coursesInProgress: number; 22 | } 23 | -------------------------------------------------------------------------------- /src/api/generated/telegramAuthRequest.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface TelegramAuthRequest { 10 | /** Telegram user ID */ 11 | id: number; 12 | /** First name of the Telegram user */ 13 | first_name: string; 14 | /** Last name of the Telegram user */ 15 | last_name?: string; 16 | /** Username of the Telegram user */ 17 | username?: string; 18 | /** URL to the user's profile photo */ 19 | photo_url?: string; 20 | /** Authentication date in UNIX timestamp */ 21 | auth_date: number; 22 | /** Hash for validating the data integrity */ 23 | hash: string; 24 | /** Visitor fingerprint ID */ 25 | visitorId: string; 26 | /** Request ID for fingerprint tracking */ 27 | requestId: string; 28 | } 29 | -------------------------------------------------------------------------------- /src/api/hooks/useVerifyMfa.ts: -------------------------------------------------------------------------------- 1 | import { type UseMutationOptions, useMutation } from '@tanstack/react-query' 2 | 3 | import type { LoginSessionResponse } from '../generated' 4 | import { 5 | type VerifyPasskeyRequest, 6 | type VerifyRecoveryRequest, 7 | type VerifyTotpRequest, 8 | verifyMfa 9 | } from '../requests' 10 | 11 | type AnalyticsMeta = { 12 | method?: string 13 | } 14 | 15 | export type MfaVerifyRequest = 16 | | (VerifyTotpRequest & AnalyticsMeta) 17 | | (VerifyPasskeyRequest & AnalyticsMeta) 18 | | (VerifyRecoveryRequest & AnalyticsMeta) 19 | 20 | export const useVerifyMfa = ( 21 | options?: Omit< 22 | UseMutationOptions, 23 | 'mutationKey' | 'mutationFn' 24 | > 25 | ) => { 26 | return useMutation({ 27 | mutationKey: ['verify mfa'], 28 | mutationFn: (data: MfaVerifyRequest) => verifyMfa(data), 29 | ...options 30 | }) 31 | } 32 | -------------------------------------------------------------------------------- /src/lib/utils/get-browser-icon.ts: -------------------------------------------------------------------------------- 1 | import { CircleHelp } from 'lucide-react' 2 | import { 3 | FaChrome, 4 | FaEdge, 5 | FaFirefoxBrowser, 6 | FaOpera, 7 | FaSafari, 8 | FaYandex 9 | } from 'react-icons/fa' 10 | import { FaBrave } from 'react-icons/fa6' 11 | 12 | const browsers = [ 13 | { names: ['chrome'], icon: FaChrome }, 14 | { names: ['firefox'], icon: FaFirefoxBrowser }, 15 | { names: ['safari'], icon: FaSafari }, 16 | { names: ['edge', 'microsoft edge'], icon: FaEdge }, 17 | { names: ['opera'], icon: FaOpera }, 18 | { names: ['yandex', 'yandex browser'], icon: FaYandex }, 19 | { names: ['brave'], icon: FaBrave } 20 | ] 21 | 22 | export function getBrowserIcon(browser: string) { 23 | const browserName = browser.toLowerCase() 24 | 25 | const found = browsers.find(({ names }) => 26 | names.some(name => browserName.includes(name)) 27 | ) 28 | 29 | return found ? found.icon : CircleHelp 30 | } 31 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": [ 4 | "dom", 5 | "dom.iterable", 6 | "esnext" 7 | ], 8 | "allowJs": true, 9 | "skipLibCheck": true, 10 | "strict": true, 11 | "noEmit": true, 12 | "esModuleInterop": true, 13 | "module": "esnext", 14 | "moduleResolution": "bundler", 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "jsx": "react-jsx", 18 | "incremental": true, 19 | "plugins": [ 20 | { 21 | "name": "next" 22 | } 23 | ], 24 | "baseUrl": ".", 25 | "paths": { 26 | "@/*": [ 27 | "./*" 28 | ] 29 | }, 30 | "target": "ES2017" 31 | }, 32 | "include": [ 33 | "next-env.d.ts", 34 | "**/*.ts", 35 | "**/*.tsx", 36 | ".next/types/**/*.ts", 37 | ".next/dev/types/**/*.ts" 38 | ], 39 | "exclude": [ 40 | "node_modules" 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /src/api/generated/meProgressResponse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | import type { MeProgressResponseLastLesson } from './meProgressResponseLastLesson'; 9 | 10 | export interface MeProgressResponse { 11 | /** Уникальный идентификатор курса */ 12 | id: string; 13 | /** Название курса */ 14 | title: string; 15 | /** Общее количество уроков в курсе */ 16 | totalLessons: number; 17 | /** Количество завершенных пользователем уроков */ 18 | completedLessons: number; 19 | /** Прогресс прохождения курса в процентах */ 20 | progress: number; 21 | /** Дата последнего прогресса в курсе (последний доступ) */ 22 | lastAccessed: string; 23 | /** 24 | * Последний просмотренный урок 25 | * @nullable 26 | */ 27 | lastLesson: MeProgressResponseLastLesson; 28 | } 29 | -------------------------------------------------------------------------------- /src/app/auth/callback/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useRouter } from 'next/navigation' 4 | import { useEffect } from 'react' 5 | 6 | import { instance } from '@/src/api/instance' 7 | import { EllipsisLoader } from '@/src/components/shared/ellipsis-loader' 8 | import { ROUTES } from '@/src/constants' 9 | import { cookies } from '@/src/lib/cookie' 10 | 11 | export default function AuthCallbackPage() { 12 | const router = useRouter() 13 | 14 | useEffect(() => { 15 | const hash = window.location.hash 16 | const token = new URLSearchParams(hash.slice(1)).get('token') 17 | 18 | if (token) { 19 | cookies.set('token', token, { expires: 30 }) 20 | 21 | instance.defaults.headers['X-Session-Token'] = token 22 | 23 | router.push(ROUTES.ACCOUNT.ROOT) 24 | } 25 | }, [router]) 26 | 27 | return ( 28 |
29 | 30 |
31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /src/components/icons/yoomoney-icon.tsx: -------------------------------------------------------------------------------- 1 | import type { SVGProps } from 'react' 2 | 3 | interface YoomoneyIconProps extends SVGProps {} 4 | 5 | export function YoomoneyIcon({ ...props }: YoomoneyIconProps) { 6 | return ( 7 | 15 | 19 | 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /src/components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | import * as SeparatorPrimitive from '@radix-ui/react-separator' 2 | import { 3 | type ComponentPropsWithoutRef, 4 | type ComponentRef, 5 | forwardRef 6 | } from 'react' 7 | 8 | import { cn } from '@/src/lib/utils' 9 | 10 | const Separator = forwardRef< 11 | ComponentRef, 12 | ComponentPropsWithoutRef 13 | >( 14 | ( 15 | { className, orientation = 'horizontal', decorative = true, ...props }, 16 | ref 17 | ) => ( 18 | 31 | ) 32 | ) 33 | Separator.displayName = SeparatorPrimitive.Root.displayName 34 | 35 | export { Separator } 36 | -------------------------------------------------------------------------------- /src/components/ui/progress.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as ProgressPrimitive from '@radix-ui/react-progress' 4 | import { 5 | type ComponentPropsWithoutRef, 6 | type ComponentRef, 7 | forwardRef 8 | } from 'react' 9 | 10 | import { cn } from '@/src/lib/utils' 11 | 12 | const Progress = forwardRef< 13 | ComponentRef, 14 | ComponentPropsWithoutRef 15 | >(({ className, value, ...props }, ref) => ( 16 | 24 | 28 | 29 | )) 30 | Progress.displayName = ProgressPrimitive.Root.displayName 31 | 32 | export { Progress } 33 | -------------------------------------------------------------------------------- /src/api/generated/lessonResponse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | import type { CourseResponse } from './courseResponse'; 9 | 10 | export interface LessonResponse { 11 | /** Unique identifier */ 12 | id: string; 13 | /** Lesson title */ 14 | title: string; 15 | /** Unique lesson slug */ 16 | slug: string; 17 | /** Lesson description */ 18 | description: string; 19 | /** Lesson position in course */ 20 | position: number; 21 | /** Kinescope video ID */ 22 | kinescopeId: string; 23 | /** Is lesson published? */ 24 | isPublished: boolean; 25 | /** Course the lesson belongs to */ 26 | course: CourseResponse; 27 | /** Course ID the lesson belongs to */ 28 | courseId: string; 29 | /** Lesson creation date */ 30 | createdAt: string; 31 | /** Lesson last update date */ 32 | updatedAt: string; 33 | } 34 | -------------------------------------------------------------------------------- /src/constants/seo.ts: -------------------------------------------------------------------------------- 1 | import { APP_CONFIG } from './app' 2 | 3 | export const SEO = { 4 | name: 'TeaCoder', 5 | description: 6 | 'Образовательная платформа для программистов и IT-специалистов. Здесь вы найдёте качественные курсы по веб-разработке, актуальные новости IT-сферы, полезные статьи и активное сообщество разработчиков.', 7 | url: APP_CONFIG.baseUrl, 8 | keywords: [ 9 | 'веб-разработка', 10 | 'курсы по программированию', 11 | 'онлайн-курсы веб-разработки', 12 | 'программирование для начинающих', 13 | 'веб-технологии', 14 | 'фронтенд разработка', 15 | 'бэкенд разработка', 16 | 'создание сайтов', 17 | 'web development', 18 | 'programming courses', 19 | 'web development online courses', 20 | 'programming for beginners', 21 | 'web technologies', 22 | 'frontend development', 23 | 'backend development', 24 | 'website creation' 25 | ], 26 | formatDetection: { 27 | email: false, 28 | address: false, 29 | telephone: false 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/constants/routes.ts: -------------------------------------------------------------------------------- 1 | import type { Route } from 'next' 2 | 3 | export const ROUTES = { 4 | HOME: '/' as Route, 5 | ABOUT: '/about' as Route, 6 | PREMIUM: '/premium' as Route, 7 | 8 | AUTH: { 9 | LOGIN: (redirectTo?: string) => 10 | (redirectTo 11 | ? `/auth/login?redirectTo=${redirectTo}` 12 | : '/auth/login') as Route, 13 | 14 | REGISTER: '/auth/register' as Route, 15 | RECOVERY: '/auth/recovery' as Route 16 | }, 17 | 18 | DOCUMENTS: { 19 | PRIVACY: '/document/privacy-policy' as Route, 20 | TERMS: '/document/terms-of-use' as Route 21 | }, 22 | 23 | COURSES: { 24 | ROOT: '/courses' as Route, 25 | 26 | SINGLE: (slug: string) => `/courses/${slug}` as any, 27 | 28 | LESSON: (slug: string) => `/lesson/${slug}` as any 29 | }, 30 | 31 | ACCOUNT: { 32 | ROOT: '/account' as Route, 33 | SETTINGS: '/account/settings' as Route, 34 | SESSIONS: '/account/sessions' as Route, 35 | CONNECTIONS: '/account/connections' as Route 36 | } 37 | } as const 38 | -------------------------------------------------------------------------------- /src/api/requests/passkey.ts: -------------------------------------------------------------------------------- 1 | import { PasskeyResponse } from '../generated' 2 | import { instance } from '../instance' 3 | 4 | export const fetchPasskeys = () => 5 | instance 6 | .get('/auth/passkey') 7 | .then(response => response.data) 8 | 9 | export const generateRegistrationOptions = () => 10 | instance 11 | .post('/auth/passkey/register/options') 12 | .then(response => response.data) 13 | 14 | export const verifyRegistration = (data: { 15 | deviceName: string 16 | attestationResponse: any 17 | }) => 18 | instance 19 | .post('/auth/passkey/register/verify', data) 20 | .then(response => response.data) 21 | 22 | export const generateAuthenticationOptions = (data: { userId: string }) => 23 | instance 24 | .post('/auth/passkey/login/options', data) 25 | .then(response => response.data) 26 | 27 | export const deletePasskey = (id: string) => 28 | instance 29 | .delete(`/auth/passkey/${id}`) 30 | .then(response => response.data) 31 | -------------------------------------------------------------------------------- /src/components/lesson/lesson-player.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import dynamic from 'next/dynamic' 4 | import { useRef, useState } from 'react' 5 | 6 | import { type KinescopePlayer } from '../shared/player' 7 | 8 | interface LessonPlayerProps { 9 | videoId: string 10 | } 11 | 12 | const Player = dynamic( 13 | () => import('../shared/player').then(mod => mod.Player), 14 | { 15 | ssr: false 16 | } 17 | ) 18 | 19 | export function LessonPlayer({ videoId }: LessonPlayerProps) { 20 | const playerRef = useRef(null) 21 | const [isLoading, setIsLoading] = useState(true) 22 | 23 | function handlePlayerReady() { 24 | setIsLoading(false) 25 | } 26 | 27 | return ( 28 |
29 | {isLoading &&

Загрузка...

} 30 | 35 |
36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /src/components/home/popular.tsx: -------------------------------------------------------------------------------- 1 | import { CourseCard } from '../course/course-card' 2 | 3 | import { CoursesResponse } from '@/src/api/generated' 4 | 5 | interface PopularProps { 6 | courses: CoursesResponse[] 7 | } 8 | 9 | export function Popular({ courses }: PopularProps) { 10 | return ( 11 |
12 |

13 | Популярные курсы 14 |

15 |

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

18 |
19 | {courses.map((course, index) => ( 20 | 21 | ))} 22 |
23 |
24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /src/app/not-found.tsx: -------------------------------------------------------------------------------- 1 | import { ChevronLeft } from 'lucide-react' 2 | import type { Metadata } from 'next' 3 | import Link from 'next/link' 4 | 5 | import { Button } from '../components/ui/button' 6 | import { ROUTES } from '../constants/routes' 7 | 8 | export const metadata: Metadata = { 9 | title: 'Страница не найдена' 10 | } 11 | 12 | export default function NotFoundPage() { 13 | return ( 14 |
15 |
16 |

404

17 |

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

20 |
21 | 27 |
28 |
29 |
30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /src/api/generated/courseResponse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * TeaCoder API 5 | * API for Teacoder educational platform 6 | * OpenAPI spec version: 1.0.0 7 | */ 8 | 9 | export interface CourseResponse { 10 | /** Unique identifier */ 11 | id: string; 12 | /** Title of the course */ 13 | title: string; 14 | /** Slug of the course (unique URL identifier) */ 15 | slug: string; 16 | /** 17 | * Short description of the course 18 | * @nullable 19 | */ 20 | shortDescription: string | null; 21 | /** 22 | * Full description of the course 23 | * @nullable 24 | */ 25 | fullDescription: string | null; 26 | /** 27 | * Identifier of the course thumbnail 28 | * @nullable 29 | */ 30 | thumbnail: string | null; 31 | /** 32 | * YouTube URL for course content or trailer 33 | * @nullable 34 | */ 35 | youtubeUrl: string | null; 36 | /** Number of views the course has */ 37 | views: number; 38 | /** Date when the course was created */ 39 | createdAt: string; 40 | } 41 | -------------------------------------------------------------------------------- /src/components/home/telegram-cta.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link' 2 | import { FaTelegram } from 'react-icons/fa6' 3 | 4 | import { Button } from '../ui/button' 5 | 6 | export function TelegramCTA() { 7 | return ( 8 |
9 |

10 | Присоединяйся в Telegram 11 |

12 | 13 |

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

17 | 18 | 33 |
34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /src/components/icons/sberbank-icon.tsx: -------------------------------------------------------------------------------- 1 | import type { SVGProps } from 'react' 2 | 3 | interface SberbankIconProps extends SVGProps {} 4 | 5 | export function SberbankIcon({ ...props }: SberbankIconProps) { 6 | return ( 7 | 15 | 19 | 23 | 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /src/api/requests/users.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | LeaderResponse, 3 | MeProgressResponse, 4 | MeStatisticsResponse, 5 | PatchUserRequest 6 | } from '../generated' 7 | import { api, instance } from '../instance' 8 | 9 | export const getLeaders = async () => 10 | await api 11 | .get('/users/leaders') 12 | .then(response => response.data) 13 | 14 | export const getMeStatistics = async () => 15 | await instance 16 | .get('/users/@me/statistics') 17 | .then(response => response.data) 18 | 19 | export const getMeProgress = async () => 20 | await instance 21 | .get('/users/@me/progress') 22 | .then(response => response.data) 23 | 24 | export const changeAvatar = async (formData: FormData) => 25 | await instance 26 | .patch('/users/@me/avatar', formData, { 27 | headers: { 28 | 'Content-Type': 'multipart/form-data' 29 | } 30 | }) 31 | .then(response => response.data) 32 | 33 | export const patchUser = (data: PatchUserRequest) => 34 | instance.patch('/users/@me', data) 35 | 36 | export const toggleAutoBilling = async () => 37 | await instance.patch('/users/@me/billing').then(res => res.data) 38 | -------------------------------------------------------------------------------- /src/components/shared/course-progress.tsx: -------------------------------------------------------------------------------- 1 | import { Progress } from '../ui/progress' 2 | 3 | import { cn } from '@/src/lib/utils' 4 | 5 | interface CourseProgressProps { 6 | progress: number 7 | variant?: 'default' | 'success' 8 | size?: 'default' | 'sm' 9 | isShowPercentage?: boolean 10 | label?: string 11 | className?: string 12 | } 13 | 14 | export function CourseProgress({ 15 | progress, 16 | variant, 17 | size, 18 | isShowPercentage, 19 | label, 20 | className 21 | }: CourseProgressProps) { 22 | return ( 23 |
24 |
25 | {label && ( 26 | {label} 27 | )} 28 | {isShowPercentage && ( 29 | 30 | {progress}% 31 | 32 | )} 33 |
34 | div]:bg-emerald-600' 41 | : '[&>div]:bg-blue-500' 42 | )} 43 | /> 44 |
45 | ) 46 | } 47 | -------------------------------------------------------------------------------- /src/constants/sso-providers.ts: -------------------------------------------------------------------------------- 1 | import type { IconType } from 'react-icons' 2 | import { FaDiscord, FaGithub } from 'react-icons/fa6' 3 | import { FcGoogle } from 'react-icons/fc' 4 | import { RiTelegram2Fill } from 'react-icons/ri' 5 | 6 | export interface SsoProviderMeta { 7 | id: string 8 | name: string 9 | description: string 10 | icon: IconType 11 | color?: string 12 | } 13 | 14 | export const SSO_PROVIDERS: Record = { 15 | google: { 16 | id: 'google', 17 | name: 'Google', 18 | icon: FcGoogle, 19 | description: 'Настройте вход через Google для быстрой авторизации' 20 | }, 21 | github: { 22 | id: 'github', 23 | name: 'GitHub', 24 | icon: FaGithub, 25 | description: 'Настройте вход через GitHub для удобной авторизации' 26 | }, 27 | discord: { 28 | id: 'discord', 29 | name: 'Discord', 30 | icon: FaDiscord, 31 | description: 'Настройте вход через Discord для авторизации в 1 клик', 32 | color: '#5D6AF2' 33 | }, 34 | telegram: { 35 | id: 'telegram', 36 | name: 'Telegram', 37 | icon: RiTelegram2Fill, 38 | description: 'Настройте вход через Telegram для быстрой авторизации', 39 | color: '#0088CC' 40 | } 41 | } as const 42 | -------------------------------------------------------------------------------- /src/api/requests/course.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | CourseResponse, 3 | CoursesResponse, 4 | LessonResponse 5 | } from '../generated' 6 | import { api, instance } from '../instance' 7 | 8 | export const getCourses = async () => 9 | await api.get('/courses').then(response => response.data) 10 | 11 | export const getPopularCourses = async () => 12 | await api 13 | .get('/courses/popular') 14 | .then(response => response.data) 15 | 16 | export const getCourse = async (slug: string) => 17 | await api 18 | .get(`/courses/${slug}`) 19 | .then(response => response.data) 20 | 21 | export const getCourseLessons = async (id: string) => 22 | await instance 23 | .get(`/courses/${id}/lessons`) 24 | .then(response => response.data) 25 | 26 | export const incrementCourseViews = (id: string) => 27 | api.patch(`/courses/${id}/views`) 28 | 29 | export const generateDownloadLink = async (id: string) => 30 | await instance.post(`/courses/${id}/download-link`).then(res => res.data) 31 | 32 | export const resolveDownloadToken = async (token: string) => 33 | await instance 34 | .get(`/courses/download/${token}`, { responseType: 'blob' }) 35 | .then(res => res.data) 36 | -------------------------------------------------------------------------------- /src/api/requests/account.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | AccountResponse, 3 | ChangeEmailRequest, 4 | ChangePasswordRequest, 5 | CreateUserRequest, 6 | CreateUserResponse, 7 | PasswordResetRequest, 8 | SendPasswordResetRequest 9 | } from '../generated' 10 | import { api, instance } from '../instance' 11 | 12 | export const getMe = async () => 13 | await instance.get('/auth/account').then(res => res.data) 14 | 15 | export const createAccount = async (data: CreateUserRequest) => 16 | await api 17 | .post('/auth/account/create', data) 18 | .then(res => res.data) 19 | 20 | export const sendEmailVerification = () => instance.post('/auth/account/verify') 21 | 22 | export const verifyEmail = (code: string) => 23 | instance.post(`/auth/account/verify/${code}`) 24 | 25 | export const sendPasswordReset = (data: SendPasswordResetRequest) => 26 | api.post('/auth/account/reset_password', data) 27 | 28 | export const passwordReset = (data: PasswordResetRequest) => 29 | api.patch('/auth/account/reset_password', data) 30 | 31 | export const changeEmail = (data: ChangeEmailRequest) => 32 | instance.patch('/auth/account/change/email', data) 33 | 34 | export const changePassword = (data: ChangePasswordRequest) => 35 | instance.patch('/auth/account/change/password', data) 36 | -------------------------------------------------------------------------------- /src/app/(public)/courses/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next' 2 | 3 | import { getCourses } from '@/src/api/requests' 4 | import { CourseCard } from '@/src/components/course/course-card' 5 | 6 | export const metadata: Metadata = { 7 | title: 'Курсы', 8 | description: 9 | 'Здесь собраны курсы по веб-разработке, которые помогут вам освоить самые востребованные технологии и инструменты.' 10 | } 11 | 12 | export default async function CoursesPage() { 13 | const courses = await getCourses() 14 | 15 | return ( 16 |
17 |
18 |

19 | Курсы 20 |

21 |

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

25 |
26 |
27 | {courses.map((course, index) => ( 28 | 29 | ))} 30 |
31 |
32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /src/lib/analytics/script-providers/yandex.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import Script from 'next/script' 4 | 5 | export function YandexMetrikaScript() { 6 | const id = process.env.NEXT_PUBLIC_YANDEX_METRIKA_ID 7 | 8 | if (!id) return null 9 | 10 | return ( 11 | <> 12 | 31 | 32 | 40 | 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /src/components/auth/verify-email.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useMutation } from '@tanstack/react-query' 4 | import { useParams, useRouter } from 'next/navigation' 5 | import { toast } from 'sonner' 6 | 7 | import { Button } from '../ui/button' 8 | 9 | import { AuthWrapper } from './auth-wrapper' 10 | import { verifyEmail } from '@/src/api/requests' 11 | import { ROUTES } from '@/src/constants' 12 | 13 | export function VerifyEmail() { 14 | const router = useRouter() 15 | const { token } = useParams<{ token: string }>() 16 | 17 | const { mutate, isPending } = useMutation({ 18 | mutationKey: ['verify email'], 19 | mutationFn: () => verifyEmail(token), 20 | onSuccess() { 21 | router.push(ROUTES.ACCOUNT.SETTINGS) 22 | }, 23 | onError(error: any) { 24 | toast.error( 25 | error.response?.data?.message ?? 'Ошибка при верификации' 26 | ) 27 | } 28 | }) 29 | 30 | return ( 31 | 32 |

33 | Чтобы завершить подтверждение почты, нажми на кнопку ниже. 34 |

35 | 44 |
45 | ) 46 | } 47 | -------------------------------------------------------------------------------- /src/components/account/settings/settings.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Fragment } from 'react' 4 | 5 | import { Heading } from '../../shared/heading' 6 | 7 | import { AccountActions } from './account-actions' 8 | import { AccountForm } from './account-form' 9 | import { Preferences } from './preferences' 10 | import { ProfileForm } from './profile-form' 11 | import { Subscription } from './subscription' 12 | import { TwoStepAuthForm } from './two-step-auth-form' 13 | import { useFetchMfaStatus } from '@/src/api/hooks' 14 | import { useCurrent } from '@/src/hooks' 15 | 16 | export function Settings() { 17 | const { user } = useCurrent() 18 | 19 | const { data: status } = useFetchMfaStatus() 20 | 21 | return ( 22 |
23 |
24 | 25 | 29 |
30 | 31 | 32 | 33 | {user?.isPremium && } 34 | 35 | 36 |
37 |
38 |
39 |
40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /src/components/account/sessions/remove-all-sessions.tsx: -------------------------------------------------------------------------------- 1 | import { useQueryClient } from '@tanstack/react-query' 2 | import { useState } from 'react' 3 | import { toast } from 'sonner' 4 | 5 | import { ConfirmDialog } from '../../shared/confirm-dialog' 6 | import { Button } from '../../ui/button' 7 | 8 | import { useRemoveAllSessions } from '@/src/api/hooks' 9 | 10 | export function RemoveAllSessions() { 11 | const [isOpen, setIsOpen] = useState(false) 12 | 13 | const queryClient = useQueryClient() 14 | 15 | const { mutate, isPending } = useRemoveAllSessions({ 16 | onSuccess() { 17 | queryClient.invalidateQueries({ queryKey: ['get sessions'] }) 18 | setIsOpen(false) 19 | }, 20 | onError(error: any) { 21 | toast.error( 22 | error.response?.data?.message ?? 'Ошибка при отключении' 23 | ) 24 | } 25 | }) 26 | 27 | return ( 28 | mutate()} 34 | isLoading={isPending} 35 | open={isOpen} 36 | onOpenChange={setIsOpen} 37 | > 38 | 41 | 42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /src/components/ui/switch.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as SwitchPrimitives from '@radix-ui/react-switch' 4 | import { 5 | type ComponentPropsWithoutRef, 6 | type ComponentRef, 7 | forwardRef 8 | } from 'react' 9 | 10 | import { cn } from '@/src/lib/utils' 11 | 12 | const Switch = forwardRef< 13 | ComponentRef, 14 | ComponentPropsWithoutRef 15 | >(({ className, ...props }, ref) => ( 16 | 24 | 29 | 30 | )) 31 | Switch.displayName = SwitchPrimitives.Root.displayName 32 | 33 | export { Switch } 34 | -------------------------------------------------------------------------------- /src/components/account/sessions/remove-session.tsx: -------------------------------------------------------------------------------- 1 | import { useQueryClient } from '@tanstack/react-query' 2 | import { useState } from 'react' 3 | import { toast } from 'sonner' 4 | 5 | import { ConfirmDialog } from '../../shared/confirm-dialog' 6 | import { Button } from '../../ui/button' 7 | 8 | import { useRevokeSession } from '@/src/api/hooks' 9 | 10 | interface RevokeSessionProps { 11 | id: string 12 | } 13 | 14 | export function RevokeSession({ id }: RevokeSessionProps) { 15 | const [isOpen, setIsOpen] = useState(false) 16 | 17 | const queryClient = useQueryClient() 18 | 19 | const { mutate, isPending } = useRevokeSession({ 20 | onSuccess() { 21 | queryClient.invalidateQueries({ queryKey: ['get sessions'] }) 22 | setIsOpen(false) 23 | }, 24 | onError(error: any) { 25 | toast.error( 26 | error.response?.data?.message ?? 'Ошибка при удалении сессии' 27 | ) 28 | } 29 | }) 30 | 31 | return ( 32 | mutate({ id })} 38 | isLoading={isPending} 39 | open={isOpen} 40 | onOpenChange={setIsOpen} 41 | > 42 | 45 | 46 | ) 47 | } 48 | -------------------------------------------------------------------------------- /src/api/requests/sso.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | SsoConnectResponse, 3 | SsoLoginRequest, 4 | SsoStatusResponse, 5 | TelegramAuthRequest, 6 | TelegramAuthResponse 7 | } from '../generated' 8 | import { api, instance } from '../instance' 9 | 10 | export const fetchSsoStatus = async () => 11 | await instance 12 | .get('/auth/sso') 13 | .then(response => response.data) 14 | 15 | export const getAvailableSsoProviders = async () => 16 | await api.get('/auth/sso/available').then(res => res.data) 17 | 18 | export const getAuthUrl = async (provider: string, data: SsoLoginRequest) => 19 | await instance 20 | .post(`/auth/sso/login/${provider}`, data) 21 | .then(response => response.data) 22 | 23 | export const getConnectUrl = async (provider: string) => 24 | await instance 25 | .post(`/auth/sso/connect/${provider}`) 26 | .then(response => response.data) 27 | 28 | export const loginWithTelegram = async (data: TelegramAuthRequest) => 29 | await instance 30 | .post('/auth/sso/callback/telegram', data) 31 | .then(res => res.data) 32 | 33 | export const connectWithTelegram = async (data: TelegramAuthRequest) => 34 | await instance 35 | .post('/auth/sso/telegram/connect-callback', data) 36 | .then(res => res.data) 37 | 38 | export const unlinkAccount = async (provider: string) => 39 | await instance.delete(`/auth/sso/${provider}`) 40 | -------------------------------------------------------------------------------- /src/components/account/sessions/sessions.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Loader2 } from 'lucide-react' 4 | import { Fragment } from 'react' 5 | 6 | import { Heading } from '../../shared/heading' 7 | 8 | import { RemoveAllSessions } from './remove-all-sessions' 9 | import { SessionItem } from './session-item' 10 | import { useGetSessions } from '@/src/api/hooks' 11 | 12 | export function Sessions() { 13 | const { data, isLoading } = useGetSessions() 14 | 15 | return ( 16 |
17 |
18 | {isLoading ? ( 19 |
20 | 21 |
22 | ) : ( 23 | 24 |
25 | 29 | 30 |
31 |
32 | {data?.map((session, index) => ( 33 | 38 | ))} 39 |
40 |
41 | )} 42 |
43 |
44 | ) 45 | } 46 | -------------------------------------------------------------------------------- /src/components/account/connections/unlink-provider.tsx: -------------------------------------------------------------------------------- 1 | import { useQueryClient } from '@tanstack/react-query' 2 | import { useState } from 'react' 3 | import { toast } from 'sonner' 4 | 5 | import { ConfirmDialog } from '../../shared/confirm-dialog' 6 | import { Button } from '../../ui/button' 7 | 8 | import { useUnlinkAccount } from '@/src/api/hooks' 9 | 10 | interface UnlinkProviderProps { 11 | provider: string 12 | } 13 | 14 | export function UnlinkProvider({ provider }: UnlinkProviderProps) { 15 | const [isOpen, setIsOpen] = useState(false) 16 | 17 | const queryClient = useQueryClient() 18 | 19 | const { mutate, isPending } = useUnlinkAccount({ 20 | onSuccess() { 21 | queryClient.invalidateQueries({ 22 | queryKey: ['sso status'] 23 | }) 24 | setIsOpen(false) 25 | }, 26 | onError(error: any) { 27 | toast.error( 28 | error.response?.data?.message ?? 'Ошибка при отключении' 29 | ) 30 | } 31 | }) 32 | 33 | return ( 34 | mutate({ provider })} 40 | isLoading={isPending} 41 | open={isOpen} 42 | onOpenChange={setIsOpen} 43 | > 44 | 45 | 46 | ) 47 | } 48 | -------------------------------------------------------------------------------- /src/components/icons/sbp-icon.tsx: -------------------------------------------------------------------------------- 1 | import type { SVGProps } from 'react' 2 | 3 | interface SbpIconProps extends SVGProps {} 4 | 5 | export function SbpIcon({ ...props }: SbpIconProps) { 6 | return ( 7 | 15 | 19 | 23 | 27 | 31 | 35 | 39 | 43 | 47 | 48 | ) 49 | } 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 37 | -------------------------------------------------------------------------------- /src/components/auth/passkey-login-button.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { startAuthentication } from '@simplewebauthn/browser' 4 | import { useMutation } from '@tanstack/react-query' 5 | import { KeyRound } from 'lucide-react' 6 | import { useState } from 'react' 7 | import { toast } from 'sonner' 8 | 9 | import { Button } from '../ui/button' 10 | 11 | export function PasskeyLoginButton() { 12 | const [isLoading, setIsLoading] = useState(false) 13 | 14 | const { mutate, isPending } = useMutation({ 15 | mutationFn: async () => { 16 | setIsLoading(true) 17 | 18 | // 1. Получаем challenge от сервера 19 | // const options = await generateLoginOptions() 20 | 21 | // 2. Преобразуем поле challenge и credentialID 22 | // options.challenge = Uint8Array.from(atob(options.challenge), c => 23 | // c.charCodeAt(0) 24 | // ) 25 | 26 | // const authenticationResponse = await startAuthentication(options) 27 | 28 | // console.log('PASSKEY AUTH DATA: ', authenticationResponse) 29 | 30 | // const login = await passkeyLogin({ 31 | // credential: authenticationResponse 32 | // }) 33 | 34 | // console.log('SUCCESS LOGIN: ', login) 35 | }, 36 | onError() { 37 | toast.error('Ошибка входа по ключу') 38 | }, 39 | onSettled() { 40 | setIsLoading(false) 41 | } 42 | }) 43 | 44 | return ( 45 | 54 | ) 55 | } 56 | -------------------------------------------------------------------------------- /src/components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | import * as AvatarPrimitive from '@radix-ui/react-avatar' 2 | import { 3 | type ComponentPropsWithoutRef, 4 | type ComponentRef, 5 | forwardRef 6 | } from 'react' 7 | 8 | import { cn } from '@/src/lib/utils' 9 | 10 | const Avatar = forwardRef< 11 | ComponentRef, 12 | ComponentPropsWithoutRef 13 | >(({ className, ...props }, ref) => ( 14 | 22 | )) 23 | Avatar.displayName = AvatarPrimitive.Root.displayName 24 | 25 | const AvatarImage = forwardRef< 26 | ComponentRef, 27 | ComponentPropsWithoutRef 28 | >(({ className, ...props }, ref) => ( 29 | 34 | )) 35 | AvatarImage.displayName = AvatarPrimitive.Image.displayName 36 | 37 | const AvatarFallback = forwardRef< 38 | ComponentRef, 39 | ComponentPropsWithoutRef 40 | >(({ className, ...props }, ref) => ( 41 | 49 | )) 50 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName 51 | 52 | export { Avatar, AvatarFallback, AvatarImage } 53 | -------------------------------------------------------------------------------- /src/components/ui/radio-group.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as RadioGroupPrimitive from '@radix-ui/react-radio-group' 4 | import { CircleIcon } from 'lucide-react' 5 | import * as React from 'react' 6 | 7 | import { cn } from '@/src/lib/utils' 8 | 9 | function RadioGroup({ 10 | className, 11 | ...props 12 | }: React.ComponentProps) { 13 | return ( 14 | 19 | ) 20 | } 21 | 22 | function RadioGroupItem({ 23 | className, 24 | ...props 25 | }: React.ComponentProps) { 26 | return ( 27 | 35 | 39 | 40 | 41 | 42 | ) 43 | } 44 | 45 | export { RadioGroup, RadioGroupItem } 46 | -------------------------------------------------------------------------------- /src/api/requests/mfa.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | LoginSessionResponse, 3 | MfaStatusResponse, 4 | TotpDisableRequest, 5 | TotpEnableRequest, 6 | TotpGenerateSecretResponse 7 | } from '../generated' 8 | import { api, instance } from '../instance' 9 | 10 | export const fetchMfaStatus = async () => 11 | await instance 12 | .get('/auth/mfa') 13 | .then(response => response.data) 14 | 15 | export const fetchRecovery = async () => 16 | await instance 17 | .get('/auth/mfa/recovery') 18 | .then(response => response.data) 19 | 20 | export const regenerateRecovery = () => 21 | instance.patch('/auth/mfa/recovery') 22 | 23 | export const totpEnable = (data: TotpEnableRequest) => 24 | instance.put('/auth/mfa/totp', data) 25 | 26 | export const totpGenerateSecret = async () => 27 | await instance 28 | .post('/auth/mfa/totp') 29 | .then(response => response.data) 30 | 31 | export const totpDisable = (data: TotpDisableRequest) => 32 | instance.delete('/auth/mfa/totp', { data }) 33 | 34 | export interface VerifyTotpRequest { 35 | ticket: string 36 | totpCode: string 37 | } 38 | 39 | export interface VerifyPasskeyRequest { 40 | ticket: string 41 | attestationResponse: any 42 | } 43 | 44 | export interface VerifyRecoveryRequest { 45 | ticket: string 46 | recoveryCode: string 47 | } 48 | 49 | export const verifyMfa = async ( 50 | data: VerifyTotpRequest | VerifyPasskeyRequest | VerifyRecoveryRequest 51 | ): Promise => 52 | await instance 53 | .post('/auth/mfa/verify', data) 54 | .then(res => res.data) 55 | -------------------------------------------------------------------------------- /src/components/course/course-content.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image' 2 | 3 | import { CourseLessons } from './course-lessons' 4 | import type { CourseResponse, LessonResponse } from '@/src/api/generated' 5 | import { getMediaSource } from '@/src/lib/utils' 6 | 7 | interface CourseContentProps { 8 | course: CourseResponse 9 | lessons: LessonResponse[] 10 | completedLessons: string[] 11 | } 12 | 13 | export function CourseContent({ 14 | course, 15 | lessons, 16 | completedLessons 17 | }: CourseContentProps) { 18 | return ( 19 |
20 |
21 | {course.title} 26 |
27 |
28 |

{course.title}

29 |

30 | {course.shortDescription} 31 |

32 |
33 |
34 |
35 |

О курсе

36 |

37 | {course.fullDescription} 38 |

39 |
40 | {lessons.length > 0 && ( 41 | <> 42 |
43 | 47 | 48 | )} 49 |
50 | ) 51 | } 52 | -------------------------------------------------------------------------------- /src/components/layout/user-navigation.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { ChartArea, LinkIcon, MonitorSmartphone, Settings } from 'lucide-react' 4 | import Link from 'next/link' 5 | import { usePathname } from 'next/navigation' 6 | 7 | import { buttonVariants } from '../ui/button' 8 | 9 | import { ROUTES } from '@/src/constants' 10 | import { cn } from '@/src/lib/utils' 11 | 12 | export const links = [ 13 | { 14 | title: 'Мой прогресс', 15 | href: ROUTES.ACCOUNT.ROOT, 16 | icon: ChartArea 17 | }, 18 | { 19 | title: 'Настройки аккаунта', 20 | href: ROUTES.ACCOUNT.SETTINGS, 21 | icon: Settings 22 | }, 23 | { 24 | title: 'Устройства', 25 | href: ROUTES.ACCOUNT.SESSIONS, 26 | icon: MonitorSmartphone 27 | }, 28 | { 29 | title: 'Связанные аккаунты', 30 | href: ROUTES.ACCOUNT.CONNECTIONS, 31 | icon: LinkIcon 32 | } 33 | ] 34 | 35 | export function UserNavigation() { 36 | const pathname = usePathname() 37 | 38 | return ( 39 |
40 | 64 |
65 | ) 66 | } 67 | -------------------------------------------------------------------------------- /src/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import { type VariantProps, cva } from 'class-variance-authority' 2 | import { type ComponentPropsWithoutRef, forwardRef } from 'react' 3 | 4 | import { cn } from '@/src/lib/utils' 5 | 6 | const badgeVariants = cva( 7 | 'inline-flex items-center gap-x-1 whitespace-nowrap rounded-full px-2.5 py-1 text-xs font-medium ring-1 ring-inset', 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | 'bg-blue-50 text-blue-900 ring-blue-600/30 dark:bg-blue-400/10 dark:text-blue-400 dark:ring-blue-400/30', 13 | neutral: 14 | 'bg-gray-50 text-gray-900 ring-gray-500/30 dark:bg-gray-400/10 dark:text-gray-400 dark:ring-gray-400/20', 15 | success: 16 | 'bg-emerald-50 text-emerald-900 ring-emerald-600/30 dark:bg-emerald-400/10 dark:text-emerald-400 dark:ring-emerald-400/20', 17 | error: 'bg-red-50 text-red-900 ring-red-600/20 dark:bg-red-400/10 dark:text-red-400 dark:ring-red-400/20', 18 | warning: 19 | 'bg-yellow-50 text-yellow-900 ring-yellow-600/30 dark:bg-yellow-400/10 dark:text-yellow-500 dark:ring-yellow-400/20' 20 | } 21 | }, 22 | defaultVariants: { 23 | variant: 'default' 24 | } 25 | } 26 | ) 27 | 28 | interface BadgeProps 29 | extends ComponentPropsWithoutRef<'span'>, 30 | VariantProps {} 31 | 32 | const Badge = forwardRef( 33 | ({ className, variant, ...props }: BadgeProps, forwardedRef) => { 34 | return ( 35 | 40 | ) 41 | } 42 | ) 43 | 44 | Badge.displayName = 'Badge' 45 | 46 | export { Badge, badgeVariants, type BadgeProps } 47 | -------------------------------------------------------------------------------- /src/components/ui/scroll-area.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area' 4 | import { 5 | type ComponentPropsWithoutRef, 6 | type ComponentRef, 7 | forwardRef 8 | } from 'react' 9 | 10 | import { cn } from '@/src/lib/utils' 11 | 12 | const ScrollArea = forwardRef< 13 | ComponentRef, 14 | ComponentPropsWithoutRef 15 | >(({ className, children, ...props }, ref) => ( 16 | 21 | 22 | {children} 23 | 24 | 25 | 26 | 27 | )) 28 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName 29 | 30 | const ScrollBar = forwardRef< 31 | ComponentRef, 32 | ComponentPropsWithoutRef 33 | >(({ className, orientation = 'vertical', ...props }, ref) => ( 34 | 47 | 48 | 49 | )) 50 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName 51 | 52 | export { ScrollArea, ScrollBar } 53 | -------------------------------------------------------------------------------- /src/components/course/course-card.tsx: -------------------------------------------------------------------------------- 1 | import { BookOpen } from 'lucide-react' 2 | import Image from 'next/image' 3 | import Link from 'next/link' 4 | import { FaYoutube } from 'react-icons/fa' 5 | 6 | import { Badge } from '../ui/badge' 7 | 8 | import type { CoursesResponse } from '@/src/api/generated' 9 | import { ROUTES } from '@/src/constants' 10 | import { getLessonLabel, getMediaSource } from '@/src/lib/utils' 11 | 12 | interface CourseCardProps { 13 | course: CoursesResponse 14 | } 15 | 16 | export function CourseCard({ course }: CourseCardProps) { 17 | return ( 18 | 22 |
23 | {course.title} 28 |
29 |
30 |

31 | {course.title} 32 |

33 |

42 | {course.shortDescription} 43 |

44 | 0 ? 'default' : 'error'} 46 | className='mt-3' 47 | > 48 | {course.lessons > 0 ? ( 49 | <> 50 | 51 | {course.lessons} {getLessonLabel(course.lessons)} 52 | 53 | ) : ( 54 | <> 55 | 56 | Youtube 57 | 58 | )} 59 | 60 |
61 | 62 | ) 63 | } 64 | -------------------------------------------------------------------------------- /src/components/account/sessions/session-item.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardContent } from '../../ui/card' 2 | 3 | import { RevokeSession } from './remove-session' 4 | import { SessionResponse } from '@/src/api/generated' 5 | import { formatDate, getBrowserIcon } from '@/src/lib/utils' 6 | 7 | interface SessionItemProps { 8 | session: SessionResponse 9 | isCurrentSession?: boolean 10 | } 11 | 12 | export function SessionItem({ session, isCurrentSession }: SessionItemProps) { 13 | const Icon = getBrowserIcon(session.browser) 14 | 15 | return ( 16 | 17 | 18 |
19 |
20 | 21 |
22 |
23 |

24 | {session.browser}, {session.os} 25 |

26 |

27 | {isCurrentSession && ( 28 | 29 | 30 | 31 | 32 | 33 | 34 | Текущее устройство 35 | 36 | 37 | 38 | )} 39 | {session.city}, {session.country} 40 | {!isCurrentSession && ( 41 | <> • {formatDate(session.createdAt)} 42 | )} 43 |

44 |
45 |
46 | {!isCurrentSession && } 47 |
48 |
49 | ) 50 | } 51 | -------------------------------------------------------------------------------- /src/components/account/progress/progress.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useState } from 'react' 4 | 5 | import { Heading } from '../../shared/heading' 6 | 7 | import { CoursesList } from './courses-list' 8 | import { CoursesTab } from './courses-tab' 9 | import { Leaderboard } from './leaderboard' 10 | import { UserStats } from './user-stats' 11 | import { 12 | Tabs, 13 | TabsContent, 14 | TabsList, 15 | TabsTrigger 16 | } from '@/src/components/ui/tabs' 17 | 18 | export function Progress() { 19 | const [activeTab, setActiveTab] = useState('overview') 20 | 21 | return ( 22 |
23 |
24 | 28 | 29 | 35 | 36 | Обзор 37 | Курсы 38 | Рейтинг 39 | 40 | 41 | 42 | 43 | setActiveTab('courses')} 45 | /> 46 | setActiveTab('leaderboard')} 50 | /> 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 |
62 |
63 | ) 64 | } 65 | -------------------------------------------------------------------------------- /src/app/(public)/courses/[slug]/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next' 2 | import { notFound } from 'next/navigation' 3 | 4 | import { getCourse, getCourseLessons, getCourses } from '@/src/api/requests' 5 | import { CourseDetails } from '@/src/components/course/course-details' 6 | import { getMediaSource } from '@/src/lib/utils' 7 | import { CourseProvider } from '@/src/providers/course-provider' 8 | 9 | export async function generateStaticParams() { 10 | const courses = await getCourses() 11 | 12 | const paths = courses.map(course => { 13 | return { 14 | params: { slug: course.slug } 15 | } 16 | }) 17 | 18 | return paths 19 | } 20 | 21 | export async function generateMetadata({ 22 | params 23 | }: { 24 | params: Promise<{ slug: string }> 25 | }): Promise { 26 | const { slug } = await params 27 | 28 | const course = await getCourse(slug).catch(error => { 29 | notFound() 30 | }) 31 | 32 | return { 33 | title: course.title, 34 | description: course.shortDescription, 35 | openGraph: { 36 | images: [ 37 | { 38 | url: getMediaSource(course.thumbnail ?? '', 'courses'), 39 | alt: course.title 40 | } 41 | ] 42 | }, 43 | twitter: { 44 | title: course.title, 45 | description: course.shortDescription ?? '', 46 | images: [ 47 | { 48 | url: getMediaSource(course.thumbnail ?? '', 'courses'), 49 | alt: course.title 50 | } 51 | ] 52 | } 53 | } 54 | } 55 | 56 | export default async function CoursePage({ 57 | params 58 | }: { 59 | params: Promise<{ slug: string }> 60 | }) { 61 | const { slug } = await params 62 | 63 | const course = await getCourse(slug).catch(error => { 64 | notFound() 65 | }) 66 | 67 | const lessons = await getCourseLessons(course.id) 68 | 69 | return ( 70 | 71 | 72 | 73 | ) 74 | } 75 | -------------------------------------------------------------------------------- /src/components/shared/confirm-dialog.tsx: -------------------------------------------------------------------------------- 1 | import { JSX, ReactNode } from 'react' 2 | 3 | import { 4 | AlertDialog, 5 | AlertDialogCancel, 6 | AlertDialogContent, 7 | AlertDialogDescription, 8 | AlertDialogFooter, 9 | AlertDialogHeader, 10 | AlertDialogTitle, 11 | AlertDialogTrigger 12 | } from '../ui/alert-dialog' 13 | import { Button } from '../ui/button' 14 | 15 | import { cn } from '@/src/lib/utils' 16 | 17 | interface ConfirmDialogProps { 18 | open: boolean 19 | onOpenChange: (open: boolean) => void 20 | title: ReactNode 21 | description: JSX.Element | string 22 | cancelBtnText?: string 23 | confirmText?: ReactNode 24 | destructive?: boolean 25 | handleConfirm: () => void 26 | isLoading?: boolean 27 | isDisabled?: boolean 28 | className?: string 29 | children?: ReactNode 30 | } 31 | 32 | export function ConfirmDialog({ 33 | title, 34 | description, 35 | children, 36 | className, 37 | confirmText, 38 | cancelBtnText, 39 | destructive, 40 | isLoading, 41 | isDisabled = false, 42 | handleConfirm, 43 | ...actions 44 | }: ConfirmDialogProps) { 45 | return ( 46 | 47 | {children} 48 | 49 | 50 | {title} 51 | 52 |
{description}
53 |
54 |
55 | 56 | 57 | {cancelBtnText ?? 'Отмена'} 58 | 59 | 67 | 68 |
69 |
70 | ) 71 | } 72 | -------------------------------------------------------------------------------- /src/components/ui/alert.tsx: -------------------------------------------------------------------------------- 1 | import { type VariantProps, cva } from 'class-variance-authority' 2 | import { type HTMLAttributes, forwardRef } from 'react' 3 | 4 | import { cn } from '@/src/lib/utils' 5 | 6 | const alertVariants = cva( 7 | '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', 8 | { 9 | variants: { 10 | variant: { 11 | default: 'bg-background text-foreground', 12 | warning: 13 | 'border-yellow-600/30 bg-yellow-50 text-yellow-800 [&>svg]:text-yellow-800 dark:bg-yellow-400/10 dark:text-yellow-500 dark:border-yellow-400/20', 14 | destructive: 15 | 'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive' 16 | } 17 | }, 18 | defaultVariants: { 19 | variant: 'default' 20 | } 21 | } 22 | ) 23 | 24 | const Alert = forwardRef< 25 | HTMLDivElement, 26 | HTMLAttributes & VariantProps 27 | >(({ className, variant, ...props }, ref) => ( 28 |
34 | )) 35 | Alert.displayName = 'Alert' 36 | 37 | const AlertTitle = forwardRef< 38 | HTMLParagraphElement, 39 | HTMLAttributes 40 | >(({ className, ...props }, ref) => ( 41 |
49 | )) 50 | AlertTitle.displayName = 'AlertTitle' 51 | 52 | const AlertDescription = forwardRef< 53 | HTMLParagraphElement, 54 | HTMLAttributes 55 | >(({ className, ...props }, ref) => ( 56 |
61 | )) 62 | AlertDescription.displayName = 'AlertDescription' 63 | 64 | export { Alert, AlertDescription, AlertTitle } 65 | -------------------------------------------------------------------------------- /src/components/account/progress/courses-list.tsx: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query' 2 | import { BookOpen, ChevronRight } from 'lucide-react' 3 | 4 | import { CourseProgress } from '../../shared/course-progress' 5 | 6 | import { getMeProgress } from '@/src/api/requests' 7 | import { Button } from '@/src/components/ui/button' 8 | import { 9 | Card, 10 | CardContent, 11 | CardDescription, 12 | CardFooter, 13 | CardHeader, 14 | CardTitle 15 | } from '@/src/components/ui/card' 16 | 17 | interface CoursesListProps { 18 | onViewAll: () => void 19 | } 20 | 21 | export function CoursesList({ onViewAll }: CoursesListProps) { 22 | const { data } = useQuery({ 23 | queryKey: ['get me progress'], 24 | queryFn: () => getMeProgress() 25 | }) 26 | 27 | return ( 28 | 29 | 30 | 31 | Все курсы 32 | 33 | Ваш прогресс по курсам 34 | 35 | 36 |
37 | {data?.map(course => ( 38 |
39 |
40 |
41 | {course.title} 42 |
43 |
44 | {course.completedLessons}/ 45 | {course.totalLessons} уроков 46 |
47 |
48 | 53 |
54 | ))} 55 |
56 |
57 | 58 | 59 | 67 | 68 |
69 | ) 70 | } 71 | -------------------------------------------------------------------------------- /src/components/account/settings/account-actions.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/navigation' 2 | import { useState } from 'react' 3 | import { toast } from 'sonner' 4 | 5 | import { ConfirmDialog } from '../../shared/confirm-dialog' 6 | import { Button } from '../../ui/button' 7 | import { Card, CardContent } from '../../ui/card' 8 | 9 | import { useLogout } from '@/src/api/hooks' 10 | import { instance } from '@/src/api/instance' 11 | import { ROUTES } from '@/src/constants' 12 | import { cookies } from '@/src/lib/cookie' 13 | 14 | export function AccountActions() { 15 | const [isOpen, setIsOpen] = useState(false) 16 | 17 | const { push } = useRouter() 18 | 19 | const { mutate } = useLogout({ 20 | onSuccess() { 21 | cookies.remove('token') 22 | 23 | delete instance.defaults.headers['X-Session-Token'] 24 | 25 | setIsOpen(false) 26 | push(ROUTES.AUTH.LOGIN()) 27 | }, 28 | onError(error: any) { 29 | toast.error(error.response?.data?.message ?? 'Ошибка при выходе') 30 | } 31 | }) 32 | 33 | return ( 34 |
35 |

Действия

36 | 37 | 38 |
39 |
40 |
41 |

Выход

42 |

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

46 |
47 | mutate()} 53 | > 54 | 55 | 56 |
57 |
58 |
59 |
60 |
61 | ) 62 | } 63 | -------------------------------------------------------------------------------- /src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import { type HTMLAttributes, forwardRef } from 'react' 2 | 3 | import { cn } from '@/src/lib/utils' 4 | 5 | const Card = forwardRef>( 6 | ({ className, ...props }, ref) => ( 7 |
15 | ) 16 | ) 17 | Card.displayName = 'Card' 18 | 19 | const CardHeader = forwardRef>( 20 | ({ className, ...props }, ref) => ( 21 |
26 | ) 27 | ) 28 | CardHeader.displayName = 'CardHeader' 29 | 30 | const CardTitle = forwardRef>( 31 | ({ className, ...props }, ref) => ( 32 |
40 | ) 41 | ) 42 | CardTitle.displayName = 'CardTitle' 43 | 44 | const CardDescription = forwardRef< 45 | HTMLDivElement, 46 | HTMLAttributes 47 | >(({ className, ...props }, ref) => ( 48 |
53 | )) 54 | CardDescription.displayName = 'CardDescription' 55 | 56 | const CardContent = forwardRef>( 57 | ({ className, ...props }, ref) => ( 58 |
59 | ) 60 | ) 61 | CardContent.displayName = 'CardContent' 62 | 63 | const CardFooter = forwardRef>( 64 | ({ className, ...props }, ref) => ( 65 |
70 | ) 71 | ) 72 | CardFooter.displayName = 'CardFooter' 73 | 74 | export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } 75 | -------------------------------------------------------------------------------- /src/components/ui/accordion.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as AccordionPrimitive from '@radix-ui/react-accordion' 4 | import { ChevronDownIcon } from 'lucide-react' 5 | import * as React from 'react' 6 | 7 | import { cn } from '@/src/lib/utils' 8 | 9 | function Accordion({ 10 | ...props 11 | }: React.ComponentProps) { 12 | return 13 | } 14 | 15 | function AccordionItem({ 16 | className, 17 | ...props 18 | }: React.ComponentProps) { 19 | return ( 20 | 25 | ) 26 | } 27 | 28 | function AccordionTrigger({ 29 | className, 30 | children, 31 | ...props 32 | }: React.ComponentProps) { 33 | return ( 34 | 35 | svg]:rotate-180', 39 | className 40 | )} 41 | {...props} 42 | > 43 | {children} 44 | 45 | 46 | 47 | ) 48 | } 49 | 50 | function AccordionContent({ 51 | className, 52 | children, 53 | ...props 54 | }: React.ComponentProps) { 55 | return ( 56 | 61 |
{children}
62 |
63 | ) 64 | } 65 | 66 | export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } 67 | -------------------------------------------------------------------------------- /src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import { Eye, EyeOff } from 'lucide-react' 2 | import { type ComponentProps, forwardRef, useState } from 'react' 3 | 4 | import { cn } from '@/src/lib/utils' 5 | 6 | const Input = forwardRef>( 7 | ({ className, type, ...props }, ref) => { 8 | const [typeState, setTypeState] = useState(type) 9 | 10 | const isPassword = type === 'password' 11 | 12 | return ( 13 |
14 | 24 | {isPassword && ( 25 |
30 | 62 |
63 | )} 64 |
65 | ) 66 | } 67 | ) 68 | Input.displayName = 'Input' 69 | 70 | export { Input } 71 | -------------------------------------------------------------------------------- /src/components/course/course-actions.tsx: -------------------------------------------------------------------------------- 1 | import { useMutation } from '@tanstack/react-query' 2 | import { DownloadCloud } from 'lucide-react' 3 | import Link from 'next/link' 4 | import { useRouter } from 'next/navigation' 5 | import { FaYoutube } from 'react-icons/fa' 6 | import { toast } from 'sonner' 7 | 8 | import { Button } from '../ui/button' 9 | 10 | import type { CourseResponse } from '@/src/api/generated' 11 | import { generateDownloadLink, resolveDownloadToken } from '@/src/api/requests' 12 | import { APP_CONFIG, ROUTES } from '@/src/constants' 13 | import { useAuth, useCurrent } from '@/src/hooks' 14 | 15 | interface CourseActionsProps { 16 | course: CourseResponse 17 | } 18 | 19 | export function CourseActions({ course }: CourseActionsProps) { 20 | const router = useRouter() 21 | const { isAuthorized } = useAuth() 22 | const { user } = useCurrent() 23 | 24 | const { mutateAsync: generate, isPending: isGenerating } = useMutation({ 25 | mutationFn: (courseId: string) => generateDownloadLink(courseId), 26 | onError() { 27 | toast.error('Не удалось сгенерировать ссылку') 28 | } 29 | }) 30 | 31 | const handleDownload = async () => { 32 | if (!isAuthorized || !user?.isPremium) 33 | return router.push(ROUTES.PREMIUM) 34 | 35 | try { 36 | const { url } = await generate(course.id) 37 | 38 | window.open(url) 39 | } catch (err) { 40 | console.error(err) 41 | } 42 | } 43 | 44 | return ( 45 |
46 |

47 | Дополнительно 48 |

49 |

50 | Скачайте готовый код или смотрите курс на YouTube 51 |

52 |
53 | 62 | {course.youtubeUrl && ( 63 | 69 | )} 70 |
71 |
72 | ) 73 | } 74 | -------------------------------------------------------------------------------- /src/app/auth/telegram-oauth-finish/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useRouter } from 'next/navigation' 4 | import { useEffect } from 'react' 5 | 6 | import { TelegramAuthRequest } from '@/src/api/generated' 7 | import { useTelegramAuth } from '@/src/api/hooks' 8 | import { instance } from '@/src/api/instance' 9 | import { EllipsisLoader } from '@/src/components/shared/ellipsis-loader' 10 | import { ROUTES } from '@/src/constants' 11 | import { useFingerprint } from '@/src/hooks' 12 | import { cookies } from '@/src/lib/cookie' 13 | 14 | function base64DecodeUnicode(str: string) { 15 | try { 16 | return decodeURIComponent( 17 | atob(str.replace(/-/g, '+').replace(/_/g, '/')) 18 | .split('') 19 | .map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)) 20 | .join('') 21 | ) 22 | } catch (err) { 23 | console.error('Ошибка декодирования Base64:', err) 24 | return null 25 | } 26 | } 27 | 28 | export default function TelegramAuthFinishPage() { 29 | const router = useRouter() 30 | 31 | const { data: fingerprint } = useFingerprint() 32 | 33 | const { mutate } = useTelegramAuth({ 34 | onSuccess(data) { 35 | cookies.set('token', data.token, { expires: 30 }) 36 | 37 | instance.defaults.headers['X-Session-Token'] = data.token 38 | 39 | router.push(ROUTES.ACCOUNT.ROOT) 40 | } 41 | }) 42 | 43 | useEffect(() => { 44 | const hashString = window.location.hash.replace('#tgAuthResult=', '') 45 | 46 | if (hashString) { 47 | const decoded = base64DecodeUnicode(hashString) 48 | 49 | if (decoded) { 50 | try { 51 | const user: TelegramAuthRequest = JSON.parse(decoded) 52 | 53 | if (typeof user !== 'object' || user === null) 54 | throw new Error('Decoded value is not an object') 55 | 56 | mutate({ 57 | ...user, 58 | ...(fingerprint && { 59 | visitorId: fingerprint.visitorId, 60 | requestId: fingerprint.requestId 61 | }) 62 | }) 63 | } catch (err) { 64 | console.error( 65 | 'Ошибка парсинга JSON после декодирования:', 66 | err 67 | ) 68 | router.push(ROUTES.AUTH.LOGIN()) 69 | } 70 | } else { 71 | router.push(ROUTES.AUTH.LOGIN()) 72 | } 73 | } 74 | }, [fingerprint, mutate, router]) 75 | 76 | return ( 77 |
78 | 79 |
80 | ) 81 | } 82 | -------------------------------------------------------------------------------- /src/components/course/course-details.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useQuery } from '@tanstack/react-query' 4 | 5 | import { Skeleton } from '../ui/skeleton' 6 | 7 | import { CourseContent } from './course-content' 8 | import { CourseSidebar } from './course-sidebar' 9 | import type { CourseResponse, LessonResponse } from '@/src/api/generated' 10 | import { getCompletedLessons } from '@/src/api/requests' 11 | import { useAuth, useCurrent } from '@/src/hooks' 12 | 13 | interface CourseDetailsProps { 14 | course: CourseResponse 15 | lessons: LessonResponse[] 16 | } 17 | 18 | export function CourseDetails({ course, lessons }: CourseDetailsProps) { 19 | const { isAuthorized } = useAuth() 20 | 21 | const { isLoading: isUserLoading } = useCurrent() 22 | 23 | const { data: completedLessons, isLoading } = useQuery({ 24 | queryKey: ['get completed lessons'], 25 | queryFn: () => getCompletedLessons(course.id), 26 | enabled: isAuthorized 27 | }) 28 | 29 | if (isUserLoading || isLoading) { 30 | return ( 31 |
32 |
33 |
34 | {' '} 35 | {' '} 36 | 37 | 38 | 39 | {' '} 40 | 41 | 42 |
43 | 44 |
45 | 46 | 47 |
48 |
49 |
50 | ) 51 | } 52 | 53 | return ( 54 |
55 |
56 | 61 | 66 |
67 |
68 | ) 69 | } 70 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: Build and Deploy 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | workflow_dispatch: 8 | 9 | jobs: 10 | install: 11 | name: 📦 Install dependencies 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout repository 15 | uses: actions/checkout@v4 16 | 17 | - name: Setup Bun 18 | uses: oven-sh/setup-bun@v2 19 | with: 20 | bun-version: latest 21 | 22 | - name: Install dependencies 23 | run: bun install 24 | 25 | build: 26 | name: 🏗️ Build Next.js 27 | runs-on: ubuntu-latest 28 | needs: install 29 | env: 30 | NEXT_PUBLIC_APP_URL: ${{ secrets.NEXT_PUBLIC_APP_URL }} 31 | NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }} 32 | steps: 33 | - name: Checkout repository 34 | uses: actions/checkout@v4 35 | 36 | - name: Setup Bun 37 | uses: oven-sh/setup-bun@v2 38 | with: 39 | bun-version: latest 40 | 41 | - name: Restore dependencies 42 | run: bun install 43 | 44 | - name: Build Next.js project 45 | run: bun run build 46 | 47 | deploy: 48 | name: 🚀 Deploy to Dokploy 49 | runs-on: ubuntu-latest 50 | needs: build 51 | env: 52 | DOKPLOY_URL: ${{ secrets.DOKPLOY_URL }} 53 | DOKPLOY_API_KEY: ${{ secrets.DOKPLOY_API_KEY }} 54 | DOKPLOY_APP_ID: ${{ secrets.DOKPLOY_APP_ID }} 55 | steps: 56 | - name: Checkout repository 57 | uses: actions/checkout@v4 58 | 59 | - name: Setup Bun 60 | uses: oven-sh/setup-bun@v2 61 | with: 62 | bun-version: latest 63 | 64 | - name: Deploy to Dokploy 65 | run: | 66 | echo "🚀 Deploying frontend to Dokploy..." 67 | curl -X POST "$DOKPLOY_URL/api/application.deploy" \ 68 | -H "accept: application/json" \ 69 | -H "Content-Type: application/json" \ 70 | -H "x-api-key: $DOKPLOY_API_KEY" \ 71 | -d '{ 72 | "applicationId": "'"$DOKPLOY_APP_ID"'" 73 | }' 74 | -------------------------------------------------------------------------------- /src/lib/cookie.ts: -------------------------------------------------------------------------------- 1 | export interface CookieSetOptions { 2 | domain?: string 3 | path?: string 4 | expires?: number | Date 5 | secure?: boolean 6 | httpOnly?: boolean 7 | sameSite?: 'lax' | 'strict' | 'none' 8 | } 9 | 10 | const DEFAULT_DOMAIN = 11 | typeof process !== 'undefined' 12 | ? process.env.NEXT_PUBLIC_COOKIES_DOMAIN 13 | : undefined 14 | 15 | function serializeCookie( 16 | name: string, 17 | value: string, 18 | options: CookieSetOptions = {} 19 | ): string { 20 | let cookie = `${encodeURIComponent(name)}=${encodeURIComponent(value)}` 21 | 22 | if (options.expires) { 23 | const expires = 24 | typeof options.expires === 'number' 25 | ? new Date(Date.now() + options.expires * 864e5) 26 | : options.expires 27 | 28 | cookie += `; Expires=${expires.toUTCString()}` 29 | } 30 | 31 | cookie += `; Path=${options.path ?? '/'}` 32 | 33 | const domain = options.domain ?? DEFAULT_DOMAIN 34 | if (domain) cookie += `; Domain=${domain}` 35 | 36 | if (options.secure !== false) cookie += `; Secure` 37 | if (options.httpOnly) cookie += `; HttpOnly` 38 | if (options.sameSite) cookie += `; SameSite=${options.sameSite}` 39 | 40 | return cookie 41 | } 42 | 43 | function parseCookieString(cookieString: string) { 44 | const output: Record = {} 45 | 46 | cookieString.split(';').forEach(part => { 47 | const [name, ...rest] = part.trim().split('=') 48 | if (!name) return 49 | output[name] = decodeURIComponent(rest.join('=')) 50 | }) 51 | 52 | return output 53 | } 54 | 55 | export const cookies = { 56 | set(name: string, value: string, options: CookieSetOptions = {}) { 57 | if (typeof document === 'undefined') return 58 | 59 | const cookie = serializeCookie(name, value, options) 60 | document.cookie = cookie 61 | }, 62 | 63 | get(name: string): string | undefined { 64 | if (typeof document === 'undefined') return 65 | 66 | const parsed = parseCookieString(document.cookie) 67 | return parsed[name] 68 | }, 69 | 70 | remove(name: string, options: CookieSetOptions = {}) { 71 | if (typeof document === 'undefined') return 72 | 73 | const cookie = serializeCookie(name, '', { 74 | ...options, 75 | expires: new Date(0) 76 | }) 77 | 78 | document.cookie = cookie 79 | }, 80 | 81 | getAll(): Record { 82 | if (typeof document === 'undefined') return {} 83 | 84 | return parseCookieString(document.cookie) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/components/account/settings/avatar-form.tsx: -------------------------------------------------------------------------------- 1 | import { useMutation, useQueryClient } from '@tanstack/react-query' 2 | import { ChangeEvent, useState } from 'react' 3 | import { toast } from 'sonner' 4 | 5 | import { Avatar, AvatarFallback, AvatarImage } from '../../ui/avatar' 6 | import { Input } from '../../ui/input' 7 | 8 | import type { AccountResponse } from '@/src/api/generated' 9 | import { changeAvatar } from '@/src/api/requests' 10 | import { getMediaSource } from '@/src/lib/utils' 11 | 12 | interface AvatarFormProps { 13 | user: AccountResponse | undefined 14 | } 15 | 16 | export function AvatarForm({ user }: AvatarFormProps) { 17 | const [preview, setPreview] = useState( 18 | user?.avatar ? getMediaSource(user.avatar, 'users') : null 19 | ) 20 | 21 | const queryClient = useQueryClient() 22 | 23 | const { mutate } = useMutation({ 24 | mutationKey: ['change user avatar'], 25 | mutationFn: (data: FormData) => changeAvatar(data), 26 | onSuccess: data => { 27 | setPreview(getMediaSource(data.file_id, 'users')) 28 | queryClient.invalidateQueries({ queryKey: ['get me'] }) 29 | toast.success('Аватар успешно обновлён') 30 | }, 31 | onError(error: any) { 32 | toast.error( 33 | error.response?.data?.message ?? 'Ошибка при обновлении аватара' 34 | ) 35 | } 36 | }) 37 | 38 | async function handleFileChange(event: ChangeEvent) { 39 | const file = event.target.files?.[0] 40 | 41 | if (file) { 42 | const formData = new FormData() 43 | formData.append('file', file) 44 | 45 | mutate(formData) 46 | } else { 47 | toast.error('Пожалуйста, выберите файл') 48 | } 49 | } 50 | 51 | return ( 52 |
53 | 67 |
68 |

Аватарка

69 |

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

72 |
73 |
74 | ) 75 | } 76 | -------------------------------------------------------------------------------- /src/components/home/features.tsx: -------------------------------------------------------------------------------- 1 | import { AreaChart, BookOpen, Code } from 'lucide-react' 2 | 3 | export const Features = () => { 4 | const features = [ 5 | { 6 | icon: , 7 | title: 'Разнообразие курсов', 8 | description: 9 | 'На платформе есть курсы по программированию и другим темам. Все уроки доступны в любое время, так что ты можешь учиться в удобном ритме.' 10 | }, 11 | { 12 | icon: , 13 | title: 'Отслеживание прогресса', 14 | description: 15 | 'Следи за своими достижениями, выполняй задания и зарабатывай очки. Вся статистика сохраняется в профиле, чтобы ты видел свой рост.' 16 | }, 17 | { 18 | icon: , 19 | title: 'Практика с кодом', 20 | description: 21 | 'Все курсы включают видеоуроки и реальные примеры кода. Ты сможешь не только изучать теорию, но и сразу применять её на практике.' 22 | } 23 | ] 24 | 25 | return ( 26 |
27 |
28 |
29 |

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

32 |

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

37 |
38 |
39 | {features.map((reason, index) => ( 40 |
44 |
45 |
46 | {reason.icon} 47 |
48 |

49 | {reason.title} 50 |

51 |

52 | {reason.description} 53 |

54 |
55 |
56 | ))} 57 |
58 |
59 |
60 | ) 61 | } 62 | -------------------------------------------------------------------------------- /src/components/ui/input-otp.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { OTPInput, OTPInputContext } from 'input-otp' 4 | import { MinusIcon } from 'lucide-react' 5 | import { type ComponentProps, useContext } from 'react' 6 | 7 | import { cn } from '@/src/lib/utils' 8 | 9 | function InputOTP({ 10 | className, 11 | containerClassName, 12 | ...props 13 | }: ComponentProps & { 14 | containerClassName?: string 15 | }) { 16 | return ( 17 | 26 | ) 27 | } 28 | 29 | function InputOTPGroup({ className, ...props }: ComponentProps<'div'>) { 30 | return ( 31 |
36 | ) 37 | } 38 | 39 | function InputOTPSlot({ 40 | index, 41 | className, 42 | ...props 43 | }: ComponentProps<'div'> & { 44 | index: number 45 | }) { 46 | const inputOTPContext = useContext(OTPInputContext) 47 | const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {} 48 | 49 | return ( 50 |
59 | {char} 60 | {hasFakeCaret && ( 61 |
62 |
63 |
64 | )} 65 |
66 | ) 67 | } 68 | 69 | function InputOTPSeparator({ ...props }: ComponentProps<'div'>) { 70 | return ( 71 |
72 | 73 |
74 | ) 75 | } 76 | 77 | export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator } 78 | -------------------------------------------------------------------------------- /src/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 0 0% 3.9%; 9 | 10 | --card: 0 0% 100%; 11 | --card-foreground: 0 0% 3.9%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 0 0% 3.9%; 15 | 16 | --primary: 0 0% 9%; 17 | --primary-foreground: 0 0% 98%; 18 | 19 | --secondary: 0 0% 96.1%; 20 | --secondary-foreground: 0 0% 9%; 21 | 22 | --muted: 0 0% 96.1%; 23 | --muted-foreground: 0 0% 45.1%; 24 | 25 | --accent: 0 0% 96.1%; 26 | --accent-foreground: 0 0% 9%; 27 | 28 | --destructive: 0 84.2% 60.2%; 29 | --destructive-foreground: 0 0% 98%; 30 | 31 | --border: 0 0% 89.8%; 32 | --input: 0 0% 89.8%; 33 | --ring: 0 0% 3.9%; 34 | 35 | --chart-1: 12 76% 61%; 36 | --chart-2: 173 58% 39%; 37 | --chart-3: 197 37% 24%; 38 | --chart-4: 43 74% 66%; 39 | --chart-5: 27 87% 67%; 40 | 41 | --radius: 0.5rem; 42 | } 43 | 44 | .dark { 45 | --background: 0 0% 10%; 46 | --foreground: 0 0% 98%; 47 | 48 | --card: 0 0% 10%; 49 | --card-foreground: 0 0% 98%; 50 | 51 | --popover: 0 0% 10%; 52 | --popover-foreground: 0 0% 98%; 53 | 54 | --primary: 0 0% 98%; 55 | --primary-foreground: 0 0% 98%; 56 | 57 | --secondary: 0 0% 15%; 58 | --secondary-foreground: 0 0% 98%; 59 | 60 | --muted: 0 0% 15%; 61 | --muted-foreground: 0 0% 63.9%; 62 | 63 | --accent: 0 0% 15%; 64 | --accent-foreground: 0 0% 98%; 65 | 66 | --destructive: 0 84% 60%; 67 | --destructive-foreground: 0 0% 98%; 68 | 69 | --border: 0 0% 20%; 70 | --input: 0 0% 20%; 71 | --ring: 0 0% 83.1%; 72 | 73 | --chart-1: 0 0% 60%; 74 | --chart-2: 0 0% 45%; 75 | --chart-3: 0 0% 30%; 76 | --chart-4: 0 0% 75%; 77 | --chart-5: 0 0% 50%; 78 | } 79 | } 80 | 81 | @layer base { 82 | * { 83 | @apply border-border; 84 | } 85 | 86 | body { 87 | @apply bg-background text-foreground; 88 | 89 | font-family: var(--font-geist-sans), sans-serif; 90 | } 91 | 92 | ::-webkit-scrollbar { 93 | width: 5px; 94 | } 95 | 96 | ::-webkit-scrollbar-track { 97 | background: transparent; 98 | } 99 | 100 | ::-webkit-scrollbar-thumb { 101 | background: hsl(var(--border)); 102 | border-radius: 5px; 103 | } 104 | 105 | * { 106 | scrollbar-width: thin; 107 | scrollbar-color: hsl(var(--border)) transparent; 108 | } 109 | } -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import { Slot, Slottable } from '@radix-ui/react-slot' 2 | import { type VariantProps, cva } from 'class-variance-authority' 3 | import { Loader2 } from 'lucide-react' 4 | import { type ButtonHTMLAttributes, type ReactNode, forwardRef } from 'react' 5 | 6 | import { cn } from '@/src/lib/utils' 7 | 8 | const buttonVariants = cva( 9 | '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', 10 | { 11 | variants: { 12 | variant: { 13 | default: 14 | 'bg-primary text-primary-foreground hover:bg-primary/90', 15 | destructive: 16 | 'bg-gradient-to-t from-red-600 to-red-500 text-destructive-foreground hover:opacity-90', 17 | outline: 18 | 'border border-input bg-transparent hover:bg-accent hover:text-accent-foreground', 19 | secondary: 'bg-white text-black hover:bg-white/90', 20 | ghost: 'hover:bg-accent hover:text-accent-foreground', 21 | link: 'text-primary underline-offset-4 hover:underline', 22 | primary: 23 | 'bg-gradient-to-t from-blue-600 to-blue-500 text-primary-foreground hover:opacity-90' 24 | }, 25 | size: { 26 | default: 'h-10 px-4 py-2', 27 | sm: 'h-9 rounded-lg px-5 py-2', 28 | xs: 'h-9 rounded-lg px-3 text-xs', 29 | lg: 'h-11 rounded-lg px-8', 30 | icon: 'h-10 w-10' 31 | } 32 | }, 33 | defaultVariants: { 34 | variant: 'default', 35 | size: 'default' 36 | } 37 | } 38 | ) 39 | 40 | export interface ButtonProps 41 | extends ButtonHTMLAttributes, 42 | VariantProps { 43 | asChild?: boolean 44 | isLoading?: boolean 45 | children?: ReactNode 46 | } 47 | 48 | const Button = forwardRef( 49 | ( 50 | { 51 | className, 52 | variant, 53 | size, 54 | children, 55 | isLoading = false, 56 | asChild = false, 57 | ...props 58 | }, 59 | ref 60 | ) => { 61 | const Comp = asChild ? Slot : 'button' 62 | return ( 63 | 72 | {isLoading && } 73 | {children} 74 | 75 | ) 76 | } 77 | ) 78 | Button.displayName = 'Button' 79 | 80 | export { Button, buttonVariants } 81 | -------------------------------------------------------------------------------- /src/components/layout/header.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import Link from 'next/link' 4 | import { useEffect, useState } from 'react' 5 | 6 | import { Logo } from '../shared/logo' 7 | import { Button } from '../ui/button' 8 | 9 | import { MobileNav } from './mobile-nav' 10 | import { NavLinks } from './nav-links' 11 | import { UserMenu } from './user-menu' 12 | import { ROUTES } from '@/src/constants' 13 | import { useAuth } from '@/src/hooks' 14 | import { cn } from '@/src/lib/utils' 15 | 16 | export function Header() { 17 | const { isAuthorized } = useAuth() 18 | 19 | const [isScrolled, setIsScrolled] = useState(false) 20 | 21 | useEffect(() => { 22 | const handleScroll = () => { 23 | setIsScrolled(window.scrollY > 20) 24 | } 25 | window.addEventListener('scroll', handleScroll) 26 | return () => window.removeEventListener('scroll', handleScroll) 27 | }, []) 28 | 29 | return ( 30 |
38 |
39 |
40 |
41 | 45 | 46 | TeaCoder 47 | 48 |
49 |
50 | 51 |
52 |
53 |
54 | {isAuthorized ? ( 55 | 56 | ) : ( 57 |
58 | 68 | 78 |
79 | )} 80 |
81 |
82 | 83 |
84 |
85 |
86 |
87 |
88 | ) 89 | } 90 | -------------------------------------------------------------------------------- /src/components/auth/auth-social.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useMutation } from '@tanstack/react-query' 4 | import { useRouter } from 'next/navigation' 5 | import { toast } from 'sonner' 6 | 7 | import { Button } from '../ui/button' 8 | import { Skeleton } from '../ui/skeleton' 9 | 10 | import { useGetAvailableSsoProviders } from '@/src/api/hooks' 11 | import { getAuthUrl } from '@/src/api/requests' 12 | import { SSO_PROVIDERS } from '@/src/constants' 13 | import { useFingerprint } from '@/src/hooks' 14 | import { analytics } from '@/src/lib/analytics' 15 | 16 | export function AuthSocial() { 17 | const router = useRouter() 18 | 19 | const { data, isLoading } = useGetAvailableSsoProviders() 20 | const { 21 | data: fingerprint, 22 | isLoading: isFpLoading, 23 | error: fpError 24 | } = useFingerprint() 25 | 26 | const { mutate, isPending } = useMutation({ 27 | mutationKey: ['oauth login'], 28 | mutationFn: (provider: string) => { 29 | analytics.auth.social.redirect(provider) 30 | 31 | const payload = 32 | fingerprint && !fpError 33 | ? { 34 | visitorId: fingerprint.visitorId, 35 | requestId: fingerprint.requestId 36 | } 37 | : { visitorId: '', requestId: '' } 38 | 39 | return getAuthUrl(provider, payload) 40 | }, 41 | onSuccess(data, variables) { 42 | analytics.auth.social.success(variables) 43 | 44 | router.push(data.url as any) 45 | }, 46 | onError(error: any, variables) { 47 | analytics.auth.social.fail(variables, error.message) 48 | 49 | toast.error( 50 | error.response?.data?.message ?? 'Ошибка при создании URL' 51 | ) 52 | } 53 | }) 54 | 55 | return ( 56 |
57 |
58 | {isLoading || isFpLoading 59 | ? Array.from({ length: 4 }).map((_, i) => ( 60 | 64 | )) 65 | : data?.map((provider, index) => { 66 | const meta = 67 | SSO_PROVIDERS[ 68 | provider as keyof typeof SSO_PROVIDERS 69 | ] 70 | 71 | if (!meta) return null 72 | 73 | return ( 74 | 90 | ) 91 | })} 92 |
93 | {/* */} 94 |
95 | ) 96 | } 97 | -------------------------------------------------------------------------------- /src/components/account/settings/auto-billing-form.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useMutation, useQueryClient } from '@tanstack/react-query' 4 | import { CreditCardIcon } from 'lucide-react' 5 | import { useState } from 'react' 6 | import { toast } from 'sonner' 7 | 8 | import { ConfirmDialog } from '../../shared/confirm-dialog' 9 | import { Button } from '../../ui/button' 10 | 11 | import type { AccountResponse } from '@/src/api/generated' 12 | import { toggleAutoBilling } from '@/src/api/requests' 13 | 14 | interface AutoBillingFormProps { 15 | user: AccountResponse | undefined 16 | } 17 | 18 | export function AutoBillingForm({ user }: AutoBillingFormProps) { 19 | const [isOpen, setIsOpen] = useState(false) 20 | 21 | const queryClient = useQueryClient() 22 | 23 | const { mutate } = useMutation({ 24 | mutationKey: ['toggle auto billing', user?.id], 25 | mutationFn: toggleAutoBilling, 26 | onSuccess: () => { 27 | setIsOpen(false) 28 | queryClient.invalidateQueries({ queryKey: ['get me'] }) 29 | toast.success( 30 | user?.isAutoBilling 31 | ? 'Автосписания отключены' 32 | : 'Автосписания включены' 33 | ) 34 | }, 35 | onError: (error: any) => { 36 | toast.error( 37 | error.response?.data?.message ?? 38 | 'Не удалось изменить автосписания' 39 | ) 40 | } 41 | }) 42 | 43 | return ( 44 |
45 |
46 |
47 | 48 |
49 |
50 |

51 | Автоматическое списание 52 |

53 |

54 | Ежемесячно плата списывается автоматически. 55 | Автопродление можно отключить в любой момент. 56 |

57 |
58 |
59 |
60 | {user?.isAutoBilling ? ( 61 | 68 | 74 | 75 | ) : ( 76 | 79 | )} 80 |
81 |
82 | ) 83 | } 84 | -------------------------------------------------------------------------------- /src/components/account/progress/courses-tab.tsx: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query' 2 | import Link from 'next/link' 3 | 4 | import { CourseProgress } from '../../shared/course-progress' 5 | 6 | import { getMeProgress } from '@/src/api/requests' 7 | import { Button } from '@/src/components/ui/button' 8 | import { 9 | Card, 10 | CardContent, 11 | CardDescription, 12 | CardHeader, 13 | CardTitle 14 | } from '@/src/components/ui/card' 15 | import { Separator } from '@/src/components/ui/separator' 16 | import { ROUTES } from '@/src/constants' 17 | 18 | export function CoursesTab() { 19 | const { data, isLoading } = useQuery({ 20 | queryKey: ['get me progress'], 21 | queryFn: () => getMeProgress() 22 | }) 23 | 24 | return ( 25 | 26 | 27 | Курсы 28 | Ваш прогресс по всем курсам 29 | 30 | 31 |
32 | {data?.map(course => ( 33 |
34 |
35 |
36 | {course.title} 37 |
38 |
39 | {course.completedLessons}/ 40 | {course.totalLessons} уроков 41 |
42 |
43 |
44 | 53 | 54 | {course.progress}% 55 | 56 |
57 |
58 | 59 | Последний доступ:{' '} 60 | {new Date( 61 | course.lastAccessed 62 | ).toLocaleDateString()} 63 | 64 | {course.lastLesson && ( 65 | 79 | )} 80 |
81 | {course.id !== data[data.length - 1].id && ( 82 | 83 | )} 84 |
85 | ))} 86 |
87 |
88 |
89 | ) 90 | } 91 | -------------------------------------------------------------------------------- /src/components/lesson/lesson-complete-button.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useMutation } from '@tanstack/react-query' 4 | import { CircleCheckBig, CircleX } from 'lucide-react' 5 | import { useRouter } from 'next/navigation' 6 | import { toast } from 'sonner' 7 | 8 | import { Button } from '../ui/button' 9 | 10 | import type { LessonResponse } from '@/src/api/generated' 11 | import { createProgress } from '@/src/api/requests' 12 | import { cn } from '@/src/lib/utils' 13 | 14 | interface LessonCompleteButtonProps { 15 | lesson: LessonResponse 16 | completedLessons: string[] 17 | } 18 | 19 | export function LessonCompleteButton({ 20 | lesson, 21 | completedLessons 22 | }: LessonCompleteButtonProps) { 23 | const { push, refresh } = useRouter() 24 | 25 | const isCompleted = completedLessons.includes(lesson.id) 26 | 27 | const { mutate, isPending } = useMutation({ 28 | mutationKey: ['create progress course'], 29 | mutationFn: () => 30 | createProgress({ 31 | isCompleted: !isCompleted, 32 | lessonId: lesson.id 33 | }), 34 | onSuccess(data) { 35 | refresh() 36 | 37 | if (data.nextLesson && data.isCompleted) 38 | push(`/lesson/${data.nextLesson}`) 39 | 40 | if (!data.nextLesson && data.isCompleted) { 41 | } 42 | }, 43 | onError(error: any) { 44 | toast.error( 45 | error.response?.data?.message ?? 46 | 'Ошибка при обновлении прогресса' 47 | ) 48 | } 49 | }) 50 | 51 | const Icon = isCompleted ? CircleX : CircleCheckBig 52 | 53 | return ( 54 |
55 |
56 |
57 |

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

62 |

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

67 |
68 | 88 |
89 |
90 | ) 91 | } 92 | -------------------------------------------------------------------------------- /src/components/account/progress/user-stats.tsx: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query' 2 | import { BookOpen, Crown, Medal, Trophy } from 'lucide-react' 3 | import React from 'react' 4 | import { CircularProgressbar } from 'react-circular-progressbar' 5 | import 'react-circular-progressbar/dist/styles.css' 6 | 7 | import { getMeStatistics } from '@/src/api/requests' 8 | import { 9 | Card, 10 | CardContent, 11 | CardHeader, 12 | CardTitle 13 | } from '@/src/components/ui/card' 14 | 15 | export function UserStats() { 16 | const { data, isLoading } = useQuery({ 17 | queryKey: ['get me statistics'], 18 | queryFn: () => getMeStatistics() 19 | }) 20 | 21 | return isLoading ? ( 22 |
23 | {/* */} 24 | {/* */} 25 |
26 | ) : ( 27 |
28 | 29 | 30 | 31 | 32 | Очки и рейтинг 33 | 34 | 35 | 36 |
37 |
38 |
39 | {data?.totalPoints} 40 |
41 |
42 | Всего очков 43 |
44 |
45 |
46 |
47 |
48 | 49 | 50 | 51 | 52 | 53 | Прогресс обучения 54 | 55 | 56 | 57 |
58 |
59 |
60 | {data?.lessonsCompleted} 61 |
62 |
63 | Пройдено уроков 64 |
65 |
66 |
67 | 84 |
85 |
86 |
87 |
88 |
89 | ) 90 | } 91 | -------------------------------------------------------------------------------- /src/components/course/course-lessons.tsx: -------------------------------------------------------------------------------- 1 | import { CheckCircle } from 'lucide-react' 2 | import Link from 'next/link' 3 | 4 | import type { LessonResponse } from '@/src/api/generated' 5 | import { ROUTES } from '@/src/constants' 6 | import { useAuth } from '@/src/hooks' 7 | import { lessonsTranslator } from '@/src/lib/utils' 8 | 9 | interface CourseLessonsProps { 10 | lessons: LessonResponse[] 11 | completedLessons: string[] 12 | } 13 | 14 | export function CourseLessons({ 15 | lessons = [], 16 | completedLessons = [] 17 | }: CourseLessonsProps) { 18 | const { isAuthorized } = useAuth() 19 | 20 | const totalLessons = lessons?.length ?? 0 21 | const completedCount = completedLessons?.length ?? 0 22 | 23 | return ( 24 |
25 |
26 |

Уроки

27 | {isAuthorized && ( 28 |

29 | {totalLessons} {lessonsTranslator(totalLessons)} •{' '} 30 | {completedCount} выполнено 31 |

32 | )} 33 |
34 |
    35 | {lessons.map(lesson => { 36 | const isCompleted = completedLessons.includes(lesson.id) 37 | 38 | return ( 39 |
  • 40 | 52 |
    53 |
    54 | {isCompleted ? ( 55 | 56 | ) : ( 57 | 58 | {lesson.position} 59 | 60 | )} 61 |
    62 | 63 |
    64 |

    65 | {lesson.title} 66 |

    67 | {lesson.description && ( 68 |

    76 | {lesson.description} 77 |

    78 | )} 79 |
    80 |
    81 | 82 |
  • 83 | ) 84 | })} 85 |
86 |
87 | ) 88 | } 89 | --------------------------------------------------------------------------------