From d78e9c66f79e02f1f35b42f2e0c2459ec551465f Mon Sep 17 00:00:00 2001 From: TeaCoder52 Date: Thu, 6 Feb 2025 16:02:15 +0300 Subject: [PATCH 01/13] feat: implement login/signup pages and API requests --- bun.lockb | Bin 74892 -> 78988 bytes next.config.mjs | 4 - next.config.ts | 27 +++ package.json | 10 +- public/next.svg | 1 - public/vercel.svg | 1 - src/api/account.ts | 32 +++ src/api/api.ts | 14 ++ src/api/session.ts | 26 +++ src/app/account/page.tsx | 17 ++ src/app/auth/login/page.tsx | 11 ++ src/app/auth/register/page.tsx | 11 ++ src/app/docs/agreement/page.tsx | 9 + src/app/docs/privacy/page.tsx | 9 + src/app/layout.tsx | 25 ++- .../account/settings/settings-form.tsx | 14 ++ src/components/auth/auth-wrapper.tsx | 66 +++++++ src/components/auth/login-form.tsx | 132 +++++++++++++ src/components/auth/register-form.tsx | 150 ++++++++++++++ src/components/shared/logo.tsx | 40 ++++ src/components/ui/button.tsx | 59 ++++-- src/components/ui/card.tsx | 74 +++++++ src/components/ui/form.tsx | 187 ++++++++++++++++++ src/components/ui/input.tsx | 70 +++++++ src/components/ui/label.tsx | 28 +++ src/constants/app.ts | 2 +- src/env.d.ts | 2 +- src/lib/client/api.ts | 144 ++++++++++++++ src/lib/client/error.ts | 10 + src/lib/client/types.ts | 17 ++ src/lib/cookies/session.ts | 20 ++ src/providers/tanstack-query-provider.tsx | 19 ++ src/styles/globals.css | 4 +- src/types/auth.ts | 5 + tailwind.config.ts | 32 ++- 35 files changed, 1212 insertions(+), 60 deletions(-) delete mode 100644 next.config.mjs create mode 100644 next.config.ts delete mode 100644 public/next.svg delete mode 100644 public/vercel.svg create mode 100644 src/api/account.ts create mode 100644 src/api/api.ts create mode 100644 src/api/session.ts create mode 100644 src/app/account/page.tsx create mode 100644 src/app/auth/login/page.tsx create mode 100644 src/app/auth/register/page.tsx create mode 100644 src/app/docs/agreement/page.tsx create mode 100644 src/app/docs/privacy/page.tsx create mode 100644 src/components/account/settings/settings-form.tsx create mode 100644 src/components/auth/auth-wrapper.tsx create mode 100644 src/components/auth/login-form.tsx create mode 100644 src/components/auth/register-form.tsx create mode 100644 src/components/shared/logo.tsx create mode 100644 src/components/ui/card.tsx create mode 100644 src/components/ui/form.tsx create mode 100644 src/components/ui/input.tsx create mode 100644 src/components/ui/label.tsx create mode 100644 src/lib/client/api.ts create mode 100644 src/lib/client/error.ts create mode 100644 src/lib/client/types.ts create mode 100644 src/lib/cookies/session.ts create mode 100644 src/providers/tanstack-query-provider.tsx create mode 100644 src/types/auth.ts diff --git a/bun.lockb b/bun.lockb index 36571e25926841a4f8e3108e296a54f1567bc45d..d451178270be44e3709d8832d8a86bff0820bcce 100644 GIT binary patch delta 15982 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 diff --git a/next.config.mjs b/next.config.mjs deleted file mode 100644 index 4678774..0000000 --- a/next.config.mjs +++ /dev/null @@ -1,4 +0,0 @@ -/** @type {import('next').NextConfig} */ -const nextConfig = {}; - -export default nextConfig; diff --git a/next.config.ts b/next.config.ts new file mode 100644 index 0000000..81e346e --- /dev/null +++ b/next.config.ts @@ -0,0 +1,27 @@ +import type { NextConfig } from 'next' + +const config: NextConfig = { + reactStrictMode: true, + poweredByHeader: false, + output: 'standalone', + trailingSlash: false, + skipTrailingSlashRedirect: true, + images: { + remotePatterns: [ + { + protocol: 'https', + hostname: '**' + } + ] + }, + experimental: { + optimizePackageImports: ['tailwindcss'] + }, + env: { + APP_PORT: process.env['APP_PORT'], + APP_URL: process.env['APP_URL'], + API_URL: process.env['API_URL'] + } +} + +export default config diff --git a/package.json b/package.json index e40e83a..330499d 100644 --- a/package.json +++ b/package.json @@ -9,19 +9,27 @@ "lint": "next lint" }, "dependencies": { + "@hookform/resolvers": "^3.10.0", + "@radix-ui/react-label": "^2.1.1", "@radix-ui/react-slot": "^1.1.1", + "@tanstack/react-query": "^5.66.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dotenv": "^16.4.7", + "geist": "^1.3.1", + "js-cookie": "^3.0.5", "lucide-react": "^0.474.0", "next": "15.1.6", "react": "19.0.0", "react-dom": "19.0.0", + "react-hook-form": "^7.54.2", "tailwind-merge": "^3.0.1", - "tailwindcss-animate": "^1.0.7" + "tailwindcss-animate": "^1.0.7", + "zod": "^3.24.1" }, "devDependencies": { "@trivago/prettier-plugin-sort-imports": "^5.2.2", + "@types/js-cookie": "^3.0.6", "@types/node": "^20", "@types/react": "19", "@types/react-dom": "19", diff --git a/public/next.svg b/public/next.svg deleted file mode 100644 index 5174b28..0000000 --- a/public/next.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/vercel.svg b/public/vercel.svg deleted file mode 100644 index d2f8422..0000000 --- a/public/vercel.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/api/account.ts b/src/api/account.ts new file mode 100644 index 0000000..6189bff --- /dev/null +++ b/src/api/account.ts @@ -0,0 +1,32 @@ +import type { Register } from '../components/auth/register-form' +import { sessionCookies } from '../lib/cookies/session' +import type { AuthResponse } from '../types/auth' + +import { api, instance } from './api' + +class AccountAPI { + public async me() { + const response = await instance.get('/auth/account') + + return response + } + + public async create(data: Register) { + const response = await api.post( + '/auth/account/create', + data + ) + + if (response.token) { + sessionCookies.set(response.token) + + instance.headers = { + 'X-Session-Token': response.token + } + } + + return response + } +} + +export const accountAPI = new AccountAPI() diff --git a/src/api/api.ts b/src/api/api.ts new file mode 100644 index 0000000..2d10e51 --- /dev/null +++ b/src/api/api.ts @@ -0,0 +1,14 @@ +import { APP_CONFIG } from '../constants/app' +import { API } from '../lib/client/api' +import { sessionCookies } from '../lib/cookies/session' + +export const api = new API({ + baseUrl: APP_CONFIG.apiUrl +}) + +export const instance = new API({ + baseUrl: APP_CONFIG.apiUrl, + headers: { + 'X-Session-Token': sessionCookies.get() ?? '' + } +}) diff --git a/src/api/session.ts b/src/api/session.ts new file mode 100644 index 0000000..44019ae --- /dev/null +++ b/src/api/session.ts @@ -0,0 +1,26 @@ +import type { Login } from '../components/auth/login-form' +import { sessionCookies } from '../lib/cookies/session' +import type { AuthResponse } from '../types/auth' + +import { api, instance } from './api' + +class SessionAPI { + public async login(data: Login) { + const response = await api.post( + '/auth/session/login', + data + ) + + if (response.token) { + sessionCookies.set(response.token) + + instance.headers = { + 'X-Session-Token': response.token + } + } + + return response + } +} + +export const sessionAPI = new SessionAPI() diff --git a/src/app/account/page.tsx b/src/app/account/page.tsx new file mode 100644 index 0000000..b8014ab --- /dev/null +++ b/src/app/account/page.tsx @@ -0,0 +1,17 @@ +import type { Metadata } from 'next' + +import { SettingsForm } from '@/src/components/account/settings/settings-form' + +export const metadata: Metadata = { + title: 'Аккаунт' +} + +export default function AccountPage() { + return ( +
+
+ +
+
+ ) +} diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx new file mode 100644 index 0000000..d52a4e7 --- /dev/null +++ b/src/app/auth/login/page.tsx @@ -0,0 +1,11 @@ +import type { Metadata } from 'next' + +import { LoginForm } from '@/src/components/auth/login-form' + +export const metadata: Metadata = { + title: 'Войти в аккаунт' +} + +export default function LoginPage() { + return +} diff --git a/src/app/auth/register/page.tsx b/src/app/auth/register/page.tsx new file mode 100644 index 0000000..90a6d55 --- /dev/null +++ b/src/app/auth/register/page.tsx @@ -0,0 +1,11 @@ +import type { Metadata } from 'next' + +import { RegisterForm } from '@/src/components/auth/register-form' + +export const metadata: Metadata = { + title: 'Создать аккаунт' +} + +export default function RegisterPage() { + return +} diff --git a/src/app/docs/agreement/page.tsx b/src/app/docs/agreement/page.tsx new file mode 100644 index 0000000..66a2ec4 --- /dev/null +++ b/src/app/docs/agreement/page.tsx @@ -0,0 +1,9 @@ +import type { Metadata } from 'next' + +export const metadata: Metadata = { + title: 'Пользовательское соглашение' +} + +export default function AgreementPage() { + return
Пользовательское соглашение
+} diff --git a/src/app/docs/privacy/page.tsx b/src/app/docs/privacy/page.tsx new file mode 100644 index 0000000..ca442e3 --- /dev/null +++ b/src/app/docs/privacy/page.tsx @@ -0,0 +1,9 @@ +import type { Metadata } from 'next' + +export const metadata: Metadata = { + title: 'Политика конфиденциальности' +} + +export default function PrivacyPage() { + return
Политика конфиденциальности
+} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 559370a..612668c 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,25 +1,22 @@ -import '@/src/styles/globals.css' +import { GeistSans } from 'geist/font/sans' import type { Metadata } from 'next' -import { Geist } from 'next/font/google' +import type { ReactNode } from 'react' + +import { TanstackQueryProvider } from '../providers/tanstack-query-provider' -const geistSans = Geist({ - variable: '--font-geist-sans', - subsets: ['latin'], -}) +import '@/src/styles/globals.css' export const metadata: Metadata = { title: 'Create Next App', - description: 'Generated by create next app', + description: 'Generated by create next app' } -export default function RootLayout({ - children, -}: Readonly<{ - children: React.ReactNode -}>) { +export default function RootLayout({ children }: { children: ReactNode }) { return ( - - {children} + + + {children} + ) } diff --git a/src/components/account/settings/settings-form.tsx b/src/components/account/settings/settings-form.tsx new file mode 100644 index 0000000..ffd82b2 --- /dev/null +++ b/src/components/account/settings/settings-form.tsx @@ -0,0 +1,14 @@ +'use client' + +import { useQuery } from '@tanstack/react-query' + +import { accountAPI } from '@/src/api/account' + +export function SettingsForm() { + const { data: user, isLoading } = useQuery({ + queryKey: ['profile'], + queryFn: async () => await accountAPI.me() + }) + + return
{isLoading ?
Loading...
: JSON.stringify(user)}
+} diff --git a/src/components/auth/auth-wrapper.tsx b/src/components/auth/auth-wrapper.tsx new file mode 100644 index 0000000..cbf533e --- /dev/null +++ b/src/components/auth/auth-wrapper.tsx @@ -0,0 +1,66 @@ +import Link from 'next/link' +import type { ReactNode } from 'react' + +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle +} from '../ui/card' + +interface Props { + children: ReactNode + heading: string + description?: string + backButtonLabel?: string + backButtonHref?: string +} + +export function AuthWrapper({ + children, + heading, + description, + backButtonLabel, + backButtonHref +}: Props) { + return ( + + + {heading} + {description} + + + {/* {isShowSocial && } */} + {children} + + + {/* {backButtonLabel && backButtonHref && ( + + )} */} +

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

+
+
+ ) +} diff --git a/src/components/auth/login-form.tsx b/src/components/auth/login-form.tsx new file mode 100644 index 0000000..6aad0df --- /dev/null +++ b/src/components/auth/login-form.tsx @@ -0,0 +1,132 @@ +'use client' + +import { zodResolver } from '@hookform/resolvers/zod' +import { useMutation } from '@tanstack/react-query' +import { useRouter } from 'next/navigation' +import { useEffect } from 'react' +import { useForm } from 'react-hook-form' +import { z } from 'zod' + +import { Button } from '../ui/button' +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from '../ui/form' +import { Input } from '../ui/input' + +import { AuthWrapper } from './auth-wrapper' +import { sessionAPI } from '@/src/api/session' + +const loginSchema = z.object({ + email: z + .string() + .min(1, { message: 'Email обязателен' }) + .email({ message: 'Введите корректный адрес электронной почты' }), + password: z + .string() + .min(6, { message: 'Пароль должен содержать хотя бы 6 символов' }) +}) + +export type Login = z.infer + +export function LoginForm() { + const { push } = useRouter() + + const { mutateAsync, isPending } = useMutation({ + mutationKey: ['login'], + mutationFn: async (data: Login) => await sessionAPI.login(data), + onSuccess() { + push('/account') + }, + onError(error) { + console.log(error.message) + } + }) + + const form = useForm({ + resolver: zodResolver(loginSchema), + defaultValues: { + email: '', + password: '' + } + }) + + useEffect(() => { + form.reset() + }, [form, form.reset, form.formState.isSubmitSuccessful]) + + async function onSubmit(data: Login) { + await mutateAsync(data) + } + + return ( +
+
+ +
+ +
+ ( + + Почта + + + + + + )} + /> + ( + + Пароль + + + + + + )} + /> + + +
+
+ +
+
+
+ ) +} diff --git a/src/components/auth/register-form.tsx b/src/components/auth/register-form.tsx new file mode 100644 index 0000000..df56e11 --- /dev/null +++ b/src/components/auth/register-form.tsx @@ -0,0 +1,150 @@ +'use client' + +import { zodResolver } from '@hookform/resolvers/zod' +import { useMutation } from '@tanstack/react-query' +import { useRouter } from 'next/navigation' +import { useEffect } from 'react' +import { useForm } from 'react-hook-form' +import { z } from 'zod' + +import { Button } from '../ui/button' +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from '../ui/form' +import { Input } from '../ui/input' + +import { AuthWrapper } from './auth-wrapper' +import { accountAPI } from '@/src/api/account' + +const registerSchema = z.object({ + name: z.string({ message: 'Имя обязательно' }), + email: z + .string() + .min(1, { message: 'Email обязателен' }) + .email({ message: 'Введите корректный адрес электронной почты' }), + password: z + .string() + .min(6, { message: 'Пароль должен содержать хотя бы 6 символов' }) +}) + +export type Register = z.infer + +export function RegisterForm() { + const { push } = useRouter() + + const { mutateAsync, isPending } = useMutation({ + mutationKey: ['register'], + mutationFn: async (data: Register) => await accountAPI.create(data), + onSuccess() { + push('/account') + }, + onError(error) { + console.log(error.message) + } + }) + + const form = useForm({ + resolver: zodResolver(registerSchema), + defaultValues: { + email: '', + password: '' + } + }) + + useEffect(() => { + form.reset() + }, [form, form.reset, form.formState.isSubmitSuccessful]) + + async function onSubmit(data: Register) { + await mutateAsync(data) + } + + return ( +
+
+ +
+ +
+ ( + + Имя + + + + + + )} + /> + ( + + Почта + + + + + + )} + /> + ( + + Пароль + + + + + + )} + /> + + +
+
+ +
+
+
+ ) +} diff --git a/src/components/shared/logo.tsx b/src/components/shared/logo.tsx new file mode 100644 index 0000000..3cbb9fa --- /dev/null +++ b/src/components/shared/logo.tsx @@ -0,0 +1,40 @@ +interface Props { + className?: string +} + +export function Logo({ className = 'size-14' }: Props) { + return ( + + + + + + + + + + + + + ) +} diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 20452e6..bc63467 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -1,36 +1,39 @@ -import { Slot } from '@radix-ui/react-slot' -import { cva, type VariantProps } from 'class-variance-authority' +import { Slot, Slottable } from '@radix-ui/react-slot' +import { type VariantProps, cva } from 'class-variance-authority' +import { Loader2 } from 'lucide-react' +import { type ButtonHTMLAttributes, type ReactNode, forwardRef } from 'react' import { cn } from '@/src/lib/utils' -import { type ButtonHTMLAttributes, forwardRef } from 'react' const buttonVariants = cva( - 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', + 'inline-flex items-center justify-center 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 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', { variants: { variant: { default: - 'bg-primary text-primary-foreground shadow hover:bg-primary/90', + 'bg-primary text-primary-foreground hover:bg-primary/90', destructive: - 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90', + 'bg-destructive text-destructive-foreground hover:bg-destructive/70', outline: - 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground', + 'border border-input bg-background hover:bg-accent hover:text-accent-foreground', secondary: - 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80', + 'bg-secondary text-secondary-foreground hover:bg-secondary/80', ghost: 'hover:bg-accent hover:text-accent-foreground', link: 'text-primary underline-offset-4 hover:underline', + primary: + 'bg-blue-500 text-primary-foreground hover:bg-blue-500/90' }, size: { - default: 'h-9 px-4 py-2', - sm: 'h-8 rounded-md px-3 text-xs', - lg: 'h-10 rounded-md px-8', - icon: 'h-9 w-9', - }, + default: 'h-10 px-4 py-2', + sm: 'h-9 rounded-md px-3 text-xs', + lg: 'h-11 rounded-md px-8', + icon: 'h-10 w-10' + } }, defaultVariants: { variant: 'default', - size: 'default', - }, + size: 'default' + } } ) @@ -38,17 +41,37 @@ export interface ButtonProps extends ButtonHTMLAttributes, VariantProps { asChild?: boolean + isLoading?: boolean + children?: ReactNode } const Button = forwardRef( - ({ className, variant, size, asChild = false, ...props }, ref) => { + ( + { + className, + variant, + size, + children, + isLoading = false, + asChild = false, + ...props + }, + ref + ) => { const Comp = asChild ? Slot : 'button' return ( + > + {isLoading && } + {children} + ) } ) diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx new file mode 100644 index 0000000..b43504c --- /dev/null +++ b/src/components/ui/card.tsx @@ -0,0 +1,74 @@ +import { type HTMLAttributes, forwardRef } from 'react' + +import { cn } from '@/src/lib/utils' + +const Card = forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +) +Card.displayName = 'Card' + +const CardHeader = forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +) +CardHeader.displayName = 'CardHeader' + +const CardTitle = forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +) +CardTitle.displayName = 'CardTitle' + +const CardDescription = forwardRef< + HTMLDivElement, + HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardDescription.displayName = 'CardDescription' + +const CardContent = forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +) +CardContent.displayName = 'CardContent' + +const CardFooter = forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +) +CardFooter.displayName = 'CardFooter' + +export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } diff --git a/src/components/ui/form.tsx b/src/components/ui/form.tsx new file mode 100644 index 0000000..30d5598 --- /dev/null +++ b/src/components/ui/form.tsx @@ -0,0 +1,187 @@ +import type * as LabelPrimitive from '@radix-ui/react-label' +import { Slot } from '@radix-ui/react-slot' +import { + type ComponentPropsWithoutRef, + type ComponentRef, + type HTMLAttributes, + createContext, + forwardRef, + useContext, + useId +} from 'react' +import { + Controller, + type ControllerProps, + type FieldPath, + type FieldValues, + FormProvider, + useFormContext +} from 'react-hook-form' + +import { Label } from './label' +import { cn } from '@/src/lib/utils' + +const Form = FormProvider + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +> = { + name: TName +} + +const FormFieldContext = createContext( + {} as FormFieldContextValue +) + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +>({ + ...props +}: ControllerProps) => { + return ( + + + + ) +} + +const useFormField = () => { + const fieldContext = useContext(FormFieldContext) + const itemContext = useContext(FormItemContext) + const { getFieldState, formState } = useFormContext() + + const fieldState = getFieldState(fieldContext.name, formState) + + if (!fieldContext) { + throw new Error('useFormField should be used within ') + } + + const { id } = itemContext + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState + } +} + +type FormItemContextValue = { + id: string +} + +const FormItemContext = createContext( + {} as FormItemContextValue +) + +const FormItem = forwardRef>( + ({ className, ...props }, ref) => { + const id = useId() + + return ( + +
+ + ) + } +) +FormItem.displayName = 'FormItem' + +const FormLabel = forwardRef< + ComponentRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { formItemId } = useFormField() + + return ( +
diff --git a/src/components/shared/dots-loader.tsx b/src/components/shared/dots-loader.tsx deleted file mode 100644 index 2e90713..0000000 --- a/src/components/shared/dots-loader.tsx +++ /dev/null @@ -1,11 +0,0 @@ -export function DotsLoader() { - return ( -
-
-
-
-
-
-
- ) -} diff --git a/src/components/shared/ellipsis-loader.tsx b/src/components/shared/ellipsis-loader.tsx new file mode 100644 index 0000000..2127ab6 --- /dev/null +++ b/src/components/shared/ellipsis-loader.tsx @@ -0,0 +1,25 @@ +'use client' + +import { motion } from 'framer-motion' + +export function EllipsisLoader() { + return ( +
+ {[...Array(3)].map((_, i) => ( + + ))} +
+ ) +} diff --git a/src/hooks/use-current.ts b/src/hooks/use-current.ts index 1f36cfd..54e5630 100644 --- a/src/hooks/use-current.ts +++ b/src/hooks/use-current.ts @@ -1,15 +1,39 @@ import { useQuery } from '@tanstack/react-query' +import { usePathname, useRouter } from 'next/navigation' +import { useEffect } from 'react' import { getMe } from '../api/account' +import { removeSessionToken } from '../lib/cookies/session' + +import { useAuth } from './use-auth' export function useCurrent() { - const { data: user, isLoading } = useQuery({ + const { isAuthorized } = useAuth() + + const router = useRouter() + + const { + data: user, + isLoading, + error + } = useQuery({ queryKey: ['profile'], - queryFn: () => getMe() + queryFn: () => getMe(), + retry: false, + enabled: isAuthorized }) + useEffect(() => { + if (error) { + removeSessionToken() + + router.push('/auth/login') + } + }, [error, router]) + return { user, - isLoading + isLoading, + error } } From 8b2428153b3eb3272708db30268c24f550104c62 Mon Sep 17 00:00:00 2001 From: TeaCoder52 Date: Mon, 24 Mar 2025 19:25:41 +0300 Subject: [PATCH 13/13] feat: add course and progress tracking pages, fix small issues --- bun.lockb | Bin 252514 -> 258605 bytes package.json | 10 +- src/api/account.ts | 15 +- src/api/course.ts | 23 +- src/api/instance.ts | 11 +- src/api/lesson.ts | 11 +- src/api/mfa.ts | 23 +- src/api/progress.ts | 11 +- src/api/session.ts | 20 +- src/api/users.ts | 30 +- src/app/(public)/about/page.tsx | 70 +++++ src/app/(public)/courses/[slug]/page.tsx | 42 +-- src/app/(public)/courses/page.tsx | 83 +---- .../(public)/document/privacy-policy/page.tsx | 288 +++++++++++++++++- .../(public)/document/terms-of-use/page.tsx | 16 +- src/app/(public)/page.tsx | 11 +- src/app/layout.tsx | 2 +- src/app/lesson/[slug]/page.tsx | 20 +- src/app/not-found.tsx | 30 ++ src/app/sitemap.ts | 16 +- .../account/progress/courses-list.tsx | 16 +- .../account/progress/courses-tab.tsx | 38 ++- .../account/progress/leaderboard.tsx | 86 ++++-- .../account/progress/learning-progress.tsx | 47 --- src/components/account/progress/mock.ts | 77 ----- src/components/account/progress/progress.tsx | 12 - .../account/progress/recent-courses.tsx | 38 --- .../account/progress/user-stats.tsx | 61 ++-- .../account/settings/avatar-form.tsx | 6 +- .../account/settings/email-form.tsx | 9 +- .../account/settings/enable-totp-form.tsx | 7 +- .../account/settings/password-form.tsx | 18 +- .../account/settings/two-step-auth-form.tsx | 6 +- src/components/auth/login-form.tsx | 4 +- src/components/auth/mfa-form.tsx | 6 +- src/components/course/course-card.tsx | 62 ++++ src/components/course/course-details.tsx | 92 ++++++ src/components/course/course-info.tsx | 90 ++++++ src/components/course/course-lessons.tsx | 74 +++++ src/components/course/course-overview.tsx | 41 +++ src/components/course/course-progress.tsx | 28 ++ src/components/course/course-summary.tsx | 108 +++++++ src/components/course/index.tsx | 39 --- src/components/home/features.tsx | 59 ++++ src/components/home/hero.tsx | 11 +- src/components/home/popular.tsx | 27 ++ src/components/home/telegram-cta.tsx | 35 +++ src/components/layout/footer.tsx | 22 +- src/components/layout/header.tsx | 61 +--- src/components/layout/mobile-nav.tsx | 135 ++++++++ src/components/layout/nav-links.tsx | 27 +- .../{profile-menu.tsx => user-menu.tsx} | 16 +- .../lesson/lesson-complete-button.tsx | 10 +- src/components/providers/course-provider.tsx | 24 ++ src/components/providers/index.ts | 2 - src/components/ui/avatar.tsx | 2 +- src/components/ui/dialog.tsx | 2 +- src/components/ui/dropdown-menu.tsx | 10 +- src/components/ui/sheet.tsx | 143 +++++++++ src/components/ui/skeleton.tsx | 14 + src/generated/confirmDeletionRequest.ts | 12 - src/generated/courseProgressResponse.ts | 16 + src/generated/courseResponse.ts | 2 +- src/generated/index.ts | 10 +- src/generated/lastLessonResponse.ts | 16 + src/generated/leaderResponse.ts | 18 ++ src/generated/meProgressResponse.ts | 28 ++ src/generated/meProgressResponseLastLesson.ts | 14 + src/generated/meStatisticsResponse.ts | 22 ++ src/generated/mfaControllerVerifyBody.ts | 12 + ...{mfaTotpRequest.ts => mfaVerifyRequest.ts} | 12 +- src/hooks/use-current.ts | 5 +- src/hooks/use-image-gradient.ts | 0 src/lib/client/api.ts | 1 + src/lib/utils/get-media-source.ts | 11 +- src/styles/globals.css | 74 +++-- tailwind.config.ts | 136 ++++++--- 77 files changed, 2016 insertions(+), 670 deletions(-) create mode 100644 src/app/(public)/about/page.tsx create mode 100644 src/app/not-found.tsx delete mode 100644 src/components/account/progress/learning-progress.tsx delete mode 100644 src/components/account/progress/mock.ts delete mode 100644 src/components/account/progress/recent-courses.tsx create mode 100644 src/components/course/course-card.tsx create mode 100644 src/components/course/course-details.tsx create mode 100644 src/components/course/course-info.tsx create mode 100644 src/components/course/course-lessons.tsx create mode 100644 src/components/course/course-overview.tsx create mode 100644 src/components/course/course-progress.tsx create mode 100644 src/components/course/course-summary.tsx delete mode 100644 src/components/course/index.tsx create mode 100644 src/components/home/features.tsx create mode 100644 src/components/home/popular.tsx create mode 100644 src/components/home/telegram-cta.tsx create mode 100644 src/components/layout/mobile-nav.tsx rename src/components/layout/{profile-menu.tsx => user-menu.tsx} (84%) create mode 100644 src/components/providers/course-provider.tsx delete mode 100644 src/components/providers/index.ts create mode 100644 src/components/ui/sheet.tsx create mode 100644 src/components/ui/skeleton.tsx delete mode 100644 src/generated/confirmDeletionRequest.ts create mode 100644 src/generated/courseProgressResponse.ts create mode 100644 src/generated/lastLessonResponse.ts create mode 100644 src/generated/leaderResponse.ts create mode 100644 src/generated/meProgressResponse.ts create mode 100644 src/generated/meProgressResponseLastLesson.ts create mode 100644 src/generated/meStatisticsResponse.ts create mode 100644 src/generated/mfaControllerVerifyBody.ts rename src/generated/{mfaTotpRequest.ts => mfaVerifyRequest.ts} (56%) create mode 100644 src/hooks/use-image-gradient.ts diff --git a/bun.lockb b/bun.lockb index 6cbdbd7cbd6ac1c4b67dd42bc699cfa0f32fff05..843e90c52a5a3c2363e0e59df91abd081e0224c4 100644 GIT binary patch delta 50421 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 delta 47106 zcmeEvcU)D+*7iADk8R>$6rEwgKhe{*~O88xc7 z&&(F_+SaOKyE}7Mj-21+^6h(fo8Mct;L(!M&>dYZ6+Zv=Vm1>${bqJ7lU6T6GZd4l zPkija#K?iYQ(}7?%m`!@Hks0gK+6r8GrP%D95N9yA7n&ALS#aO$@B}n(Jlef3Yi=p zGk^|mLoWdRJS6SoViOWk#xx{{$&?rR4Dh+qkQf*~Fg6->9#@9(kqNN_21lAS2a~A` z@?YkJA>;!{I*JNUh#fM}WC}oSbhKFI$3^!^X2lbs7XiOl`P&T14y;l#I>D{)fY@H9 zK#*y4yZ{-Lhd|KbIHfnsBYV^Ht$(EcrB*Plva<1YR2_>R7#$a$kWd;tJNg7X%Qh$>TV7Q0SD~}6vyk+8qNKE6 z1jzsEjR-HaHfNbNB`3 z721}51rl|o_b4m*FQKCk>HVS;qfI9Le6~VqaaaZ%m4w)V10&;2CL46RijNGBNJM;( zl(Xv*QQ`5432Vzs`-v5#tNW0QQHqlNmE2KL+I3aB2PEqWQF@TnbEWsL~q23~XX_Or!}iJU%`=*}IBtMpxvsdEg_%6CzCuRCA^%8DCWnO&WAYHUW}D z5ME7Ala7!aBBOh5(Fu8~%lvDQ@LMk3j0DFBOF1z)F4FWI1=#NI-Q?tMq5E3Oq=h3& z>~#l7S~pgD1xO5fdI`l3j2y}!n(WcEN7o+JhaS@YTP62Ea^RLjV)CU=R5Bit1KSl6 z6FDut2@>1{5-=!ns9=LP8V`LkWLd}oklb8CAu(yv-IXj1iOH7!!pmgBk_{=Mq)Evqb>upAv&l#$CJcz~g@)b+ z&xj>N#ti0sGsYf#w;(wX>mV8L-yvxifR1ti%PU!&G7UstBsh|`U}=~gI%kG`AMTZu z&?`3CWNKGWI>?J5p`+}OwENvGW#7o?1dh>T=$tWb4OE4Y^xr!cSB=3Y(|P1GG8vF* zTtqt>%ARio!3b!LO{PkahaouxKQ@vf-qb|O*^mtJY49B5`KsJl=nVCC#m7T(8|?{+ z^_*U{xyj@Lxd=KNfO2U@h3RlWN0E?R<+obMxgP?ZhCYyNsJ%ib=p6glP}$&SEv4g= zkmbPFfaIJg3`u`)U{?(Cu982rmVUozjrg;IsUSF3TOeszxUFnJ93)%b1F|6GLgcd{ z-@_4mcD9`yvX<>-`NPn;pKgGpoqq>8G_#>IGAB@ubHRcRu%0LAwUPJ^k}WucWsPm;LOQ;AWf(!sGkKg~unPMeafa*l_M3y<;Ox8NFl&k|JuvL`THO zj*pOfOz))l0cc1nbk4QNfr;3eBNGy$qWedKZyzbAOa({={0Jl)G`NqHCzXtk>>D{0 z_EFIDadqZ|AxGv$Z&{HwO7=VeJf}=i<>(diY4;G4lfNBm<%p#WiB60_nacfS55IwA zxjm3F^-orli3P#NVZI}DeRYBn74X9TK%aDi-whHwbBrAo&& zp|e6S6m*2F0DCs9UqU1TirzzKLk332#9)G(@V;(Xt1UvV0k(_l=Fi^fZ~mptE7M zAUR2Ej*|^<0G%D^K2h>%dy!yBPK=isePZKrT8M~xs&p$P9nFSh&ukN<{c`B6I0TaA zFHDw^d^bt<*rf7bPmz=M0UE$Lv3aVTGc9GkY3YNf$+5^eT{>K%D!|d)SgNIfB9QMGjNU)_okZf6bB@08c;MW*EC&*op?7=xq&k~SzFx43R z7fWS@_655G`DMWuTQ1ALUM3efE*Sk1PzK9|2O85&W+p&TQ77?S#Dkj6-TDyP3t ze%C{1eh-x6lndM_1HVezr=|Z31S2{~H8=v2jygcH!dSKU_Exevc-jX;(qW$jH=Lv6 z!z*o(74^f-3NJKH&}kQ$kdzobAi)%w&?_k#Jq+3==WZ3qV!Rv{HWIj8eJ1C~T_wL! zaz7-e$QmVe$Rg0wlGnW-5WPzk>S13gt$J@1ADtA#ig0t4@-lJ zO2)Yjh>gJgJY;lCbfT$ZhRlx~8W|CeDS82VVN`e$k|Tczk{#Hlr2X!F9dtHq8Kf05 zc3`A&I~W765pI13MubPen|`oxxn}E+$-!O%Nmnzzkf+%sNUpN!;JFwW%5Y4Qj4!3W z1+pOcQOIXg;*V{sTI7cuX>Oa>yR;6N5EMGEQ_oL+bo_m{NP52VhoytfzsBbr|FZ7o zk~>c17bD?pU8>X=*$IZSw$*HTkN2Q{5w&u!RtK2}ZVX2qD?px%` z^-J;%_+~=0eX~;9_sKc4N5LuKV~#Xm6W^oQ>q=3-4oYccTGx6|M8o{Qjh|If zgRLFy1@&dl?(TubZcTA4>RMpG+w)+r#YZNrSk*i3i+w{2T~1or;N9^$W7h15sCy*e znRR1+EKqh%&Vrim>fB0y>D;8i{N3rTPS!f~ORHPEcDwJ}TWVX+QungCIxZbHf9l$H zp7C!tygq+(O2vvHF3XMvT`7Fd`S6vt+mEh#6V&>|?kF)|4|8!X&^999`D%;0@5;Go z=Be_de_WxbyEwW(+#I;P$d2}|b1NQ?Oe)m&NL*aLVm}1bdRrslNaVdqZ99CnIfwqe zN2AFud7rI#m7~7H9f`EX0E31aSbw; z)?c{>Sx03znHpd$%=*YCJ`PZ7L&>I(Y~gLqZZVlcp=H<2HGT9~Rf5b@bl<8$+J1|k zQq^XCgKU<}p^vQUZ4TC7RShza(S550Xxj_1@t}Df~*a4noJF0mR}DZ<)iyn z53;&A;L#u$RN2DYG9Frhp6cqa9dXcKSGQRmF#FgDO%JZ(ZEXszo?&c;HW^xgy{Pu3 zRe$Yfv%Ub?3S@RR#M&5>n9a(jn}_*0K(QGLJ9I*6Iq2D(Q}?YIWNnPaN{^a8vYNMf zw7v)2DR8V)(=%#%oAc?u?m_0Zda`?vbt)VpWF|r6G!5-qtzL8X*tkff2h0jTOiVSrw-v_7xi9Jsg^+Vaa*2 zDN{o^?Rs&2ub<6aPk-eXWKG3UgT-<()Ycu)I5Ji}qn5YzEi_f#dLMHM-LHzxIu}P0 zT3`nGduxwM=qUjSk2!Yp(oc1vd)HH1Le%R8O!l=rCE%)J%z@$WHVM) zZ9Tbukaa>ilgS@kVIvHupw+T#+PiYPUjv)jPfus>js6sAxjG?{12?M#i^&uwU4ndM5lO3Um<@5e%81;)I$oPg$~A7~ojpo~zJ zkGHjM6*&ml{xE-1lqQ?89h!8`iXT9uI|Ld_zeZK5neqk?M$U%hXP!x{s(2aIRDha7JM(RtXS{bS0SoSj27pYK#+mV^dh6&%o;96#;7G|dI zB85=5@wWyb&I~m+d2H(`NXor5qd9hKl_#g*3uuT5cA8+!?`l{tv@|v}bF}W;Dag7D z94DfQ18&Zt@97j|ZlJ%y&&j%P=OF8s*a6Xelf-M4#?HlU%t zH^OH54u_e>`hf_4Ye`%<@Q-7OZgpsk-+xLC8^(2L-7_^CmLm6kSYpHcikhBU&EHzK zDTde1TjQbe9Ag}OET2QeQ1uVU-i#v(b4xp8Z+T7}@cx&a`>aU}0+Nid=U$jljXsf40 z+pKTf%03u9(i*kXU&q+21>0wK8pGEO8pfVJ5BWGiVV|L3XT1-NqlBKJGYv41vT|PR zp>SYvoyLCnJ(4#4jkmwGP)B)%gad@8>w66~eFBYT%z8!}9|tHHXq=8S8hUFZ_~DP(o^DX*6m#~uRUG8EpMQC>Vf+M9I!>Qjk%1Y(aCQ1 z8IGB0$%lzpPY=W;vK3NwjjA!%wHe)YzeJn$SCEXYJa5F7C;HQzY||pc^_0Oj>z;6V{gMa2C(!8J zIOJ%Rd+B~dY}&+Lddd)+)fOSI%=wIYJqwz&#PD0bf@afG-Tkc&z3tl;8|w!R`yj46 z)CNOi8Jwqry|qofb-!erB{q^XE-=~O`YlqN$+_5QOSwKq&)od2W0{gpFrt?-`Lcaw zRd9|AY8z;BwxBA@TxbE(D^lJljZomyS~N;t`k-NN3x(E(R+eQ**^JZ=NEsz9C8H5h z!$%}iSk>x)>f0~#ZV0EH7-%i@z~%uCNOU$TVqY8d&s>Ssy{(I&wL_j9(5KMYQF&CY zJHTYZ8cxq^90p%P^Mr=u-#j0O82cfC5t$3Em!8_f-}(}%CNPp=<%9JwqX>^62cU8L z!95_F@eUeilC%tomBV42^{f-2VWP>wwpNL=FR5T0b~6>aJPnN%7-s{k@1XY^j<X0R|u(a>t^ z2mAxf3A*1HoAoqwUJh{W!$y!RQI4*mS@u9P##8$=N%tFTv))URi^8}eww4<#9pl*D z9kUS{XCoGU18>VFXaV|xV1Mfaq&Nat5m-A_hsbG#vBJ6?2+fB>pnWn#_Zx4sju|SA zhf=tSD%lnz(dh?}OrRIM3*WJsuht9}MexTlx=YY7=~KjTGv!PWwOnBAT_fncBNOT`s>Lyt;Go4ZwhX^(qt!50K*dl&0P;fL>8y< z5rg$22!;BqEaUE$mO81+NdqI%wmT^4Mz8$6S1vz=N41=-2Ecc;>8QW{yF*1;{u}h(~ zGA^%Akn+oHLY1-PdE8A#ilN4o#qg|yhT%ce?jY5ScT^U?amH3x&0iZjPTxDzro~Rw zU(d8zPE6!}g)QBZe-bU;%n88lx8E$AWvrp6_Vl+NL5fXBYutTs5=-fcyYk6$cLUeM z#{r6L7GryxS;ml6onpUAknabrgVDXiNJ;x(cW+DHsaWj1Qd_zpg>!_fzx4!CU1dkH zPgk5~G;(f0b|j41U@0&iE{sZ=A;oBLsw6!!tN*)SkBc&pV z4d4tkt^nyH&rI%(e2_G7roMNs&2r8l7{=kV=!#ds3rI027&>eWMf6M!tFIL_?k;ja z8Ix7B?1qM$wOan#W8Ii}RcFf`Ox8Udnmp2di&O)9o2M09smXNsyfxCp2NXc$;245v_xa5kgCG3-oS{gJNag2%eGBoa;W<9tOEa%x* zfAA1*>o{o2CpN*u&}8_zCFYnfn~Ba;_10R=*ZmgTtgAr!7|t*atXH6Mr^n?K2bIzb z^w*1RmWTx$c~03~NYynW`~oR@HqPIcz=g)yV{U*065I!5yiAL@28<&KQoOntYsNYc z8aEiM2sG;^w08C~R7WRjsZv+<8IlGRClAL zg_hcvYd@sg%F4XFwF66azZEtu+cG_6h0WSvnVedkW6Nmetvz0@?_Fur%B|2}ue4d)udr`?Bd2<6b5`gnt8A9DAlqWm1URfT8Mm(J zXbA2dpvjhCTeR+m#*G!%gc{yj-c|bE)i!I7RkA_(jPu80Xsj3mjj?&7@~|!U@^M%# z=QJKh;Z|-4v`A@)tI!o_3^uO!^L-pXk@3XU3{&R;v|7k2s0Rff|aps^Y7iJR4%(0rlg=G3ryZm~}>#ws2f*Q@aiP+Px6-@COAUv+QmGH4tH<5tD`12o3ZIIn7D_US1tY}QWuwEX0_ZOrZ=O^LY9= zK;a}Zj*Axm!zg0h86-0WUY`DUL1PhlsC)vAW#n1aJwsaJ2@v8w5E{b>OI#Iyf))x5 zw}GiX4xh^q8ADj z?*5wp7rNgOoA%ikddd--#qmpCU{a6xTjG$yMe&Hg^(<1XIJX`=*4yH6oIEVFCdc)? zM{SnnAaL{parL2nHe6;=uZdu z$tuauHdwrb#snSARYX>4);C_R+?v%b1L$@oz~ZX_ekjSWp@L6VNxQWGC9c`VhccTU zc%=+hiOGaZkMa4Cq*q*VjJDyLVSN5KQq!*$<&OaQfNub{>05xGtdjh91}i1? z@0Ffal0T>Tf0KI1iQ;LTYL@|8T~YF?l0QK5^LLWvt{GPUF6s9+z!7->Fh-96ekfV) zG2Zi{&V&)Xr-o)SQPSX<(mzIWVmw#neo^vQC4Ylt{!4({O+GlM|NLgu&z2QZL}9Y{ z{9hz1sG|J*SK01w44G9GKPamySzSptC2KGfA4(=`@`qmPS{c2;wE{+PYbgs##-NUp z_#L8Af^T{AlU0)PRXioj`71rEWaw<*nH8k+DVeOx*Ub4r!f;SeWz>h{tY}IrC7Eod zbV??h;|D9|>*)MY=7H=4$%?zG{O*u>q4!sM3?%ovBy)L_iPJCz1Pi1pgES>aLYnlz z)5X)+oGHp`IwVWYhNRo&v{I6GYgGQHN^XVB4Sqi)hyGhgE97NJ*7^e^yKx;dC*)m7 z`gsJ&CH_0(M8mfrD9yB1k~+U|Kq`mgDPd~L3CV`$fu!TYki7C0hvbKnLP*pB!N3JVaued0Qs|%=BtMi)w!jY-3{gB~LFnC;PFVu_5J>tnuG@c- zET4{iHe`(5V2pC`Kap(UcvX&)jwdLclKMnQhJ3n`b5y=vrr8seEHGCYP_hS0AX(9J z#ZyvW1xe}?#Z$8U8l_V*xlZxx6;H|Jr;<-IZIlVqCP=n)J0vI7AxM5Gnaoi7-$_os zuax~MCC@;zVP8YC;omC$dxOX9Imd(^^K}_x&0bX5mm&F~WbrFX&nn4ZRXip2AC&$f z$^2``F9P`-lHGi(>><+(2k#W|A;~N=IzdMoJELTN$bw3zWPTw?+80qgCD)3R;!7!6 zM&*~uCig582|6gNGO|hzP$k8GNU}#Bs=Oy8=eQSSQOM?sZvn|qR!P5Y(o{wpNG99k z2OH8s$&N~fF%ut3=68Z*PrE3dl2bPVk`3q!NxLYO{~^gNxBkk2k`7}aNyRCivKI7} zkU8|!Z_8AHV%h+W)*B(YckF`Xhm!mrNVA^$WAQXL<_I+Q_5@kTuORuMWd0dQZf)N| zGK=4lrQ|nrnST|MUau*6L&;l^^miMQA4=Bq0FuG@IUD8|H}!WQ=qMY0(2<48f01;Y z19mjcg&)*&L$Z8cNH(|-GnHh;j>@hCBC|Mm6|4i=q!2;fj z_k(0b0g$XH7?Kqh+2FO3Pcss1**ayIRdVTW2hX1DQ~7@<>G+_s`;g?M`vUpw=t;v4 zlUK|61{XCyo8}?+oDQqa6J&?Tq?Assb)ABmPPJE^MVjf5q%n!+s7gVw^B<+e&*8?AvNn>saX#7wzx1^Fz zN;<22N*)g?Dm|-Yxk`%vJ4w6B$_~;Ksw9}^GxZVXG;G(Q$KwLgwN?KEq7+`dIM*S4Lxy6)VY zYa;VKzBp~n-LuEpKE7h`cUKB*-xX5Fd_fc4g3Qg$m&Ljub4PL9$DCJ$Gy!o{ zbgBzts4s{EbGSxiFVf{9yA7XkIcFHD0E*8s*ghR0zn*T4wIiuVg(B|v4OEP1M|ov+B5_6 zlFSY=PfS8<4rXl-m`Ij-W)ittfC;M$CZ+`(KNr?uICcmo!3O_o5_@T~n@p(?n7k0l z4PX*g55!eA_?1aiZ3q*``e3Fu1oOK|Tq1LvOkg80Z%krRBQQf7fO$mbok{qyQ?3oc zEN%?PW)U3%#}`PPW=+{diBJ#|8i7a+1z`~=BCqgm1L7r##ce?37xzi5Z3ZHwEr^0*ep?V>w0%ROuxQ#2 zghLAu8{2^>DqfM;O`=YZ#NJxNi6OLqNcb{Vr_d6A>BcE zi22jvzMn08vN0BC(r9x1J!p#k!s#qQXGr4F};XI)#I9>;&Qf z34dYj1>!h~m|h?P#U2tvJA)_{0U}65M}Tnc0^&4@U{RtshzlfAdxNMiPLP<;6@*(P zh=w9L5`<4T5LZbw7FGIyxJP1Y9}rE&MH2J6g9z*kqPdvZ7etdDARdtj5x!9%UXoZG z1)`<6PhxFP5FybZT8sJ7Ai}~yydlw6H0=k%p%;ja{Xn!AuSo1B(XBs-j$&PZ{D_JG zk#_)yPNLHQ5RSb;93as}SYtpOClM0^qMO)5VrV3YQUgKs5YYocxb^{YnnbuL5ewo1 ziPTsS5#j`i34KAh#es+v$#EcjqCi|F(N|O%1mYfvse?d7i;E=YMS}>82hm?lj0e%A zABaaJVuWu3h?gW5CxD0*_eren4M zqT65)L&UnlAfg6>$U6i?vgkAfgkvm-10;qC>rfEKNyH2Vkt+6(7#atnR5FM(5uFUe zbr6WtBu0u7DIhM8NKFATTAUy;As&R=Fc4!!@-PrS2_UYL7%!>}2XT+Y)Zy5bCYr^9 zK^MD*#NBB-jL|!e**jO!5cwlFS@1=IP=_D%`9c45Hx(5HrQh z5g@{bfcO;zUMv<>{ZPhXD43~!FqtEQ(_pfj#OgECg7j*(a=3XTCWFAYS}7!Vso28kx=AS#Xpu~7^f z3*se-b0jtkmvJE0js!7w9Eh#r8xmooK-3uzVw)H_9)!ba5O+xI5FQgi>?Sd10*GDW z28pOKAR0~tu}93D2*PnJh+j$U6Ty=}94E1Q5{Luh8Hu6eK(wC>;*eNA8HDS25SA$* zGDPbsATE&DLE?xoPX#ey0*J_|AdZQxBzz`5{RVf zAWn)55=|z9s5k?}X)$O9h?gYJk@#A;%mlG^3W%{YL3|^=ArUqeM4ee6z7r#7fpC}x z;tmMyoLPM9i^1JZCQygTzr;ixCQ;KtJR)&X_|695I0MAu*&r^9`y`H&2$=)os+d0q z#L$@_-jKK^n$88`It#?cxgc(cS0pZw=q5ni66*wr2|9?p^FZ7Yo#uh?nGNCqiMzr& zAH+QpG4ny(7kfy|n**ZM0uVom=mj8}%ms0p#3NB+A&8eGQWt`FB2JK4D?qp{0`W{F zF9H!Z55!dv+H)+L#pu7od@xfNgZb4gE|J+yCU6Ou7g&r-z(g$o^N7qVERv;Q92bIF zycEpuX7PZ`aWWyxz`VgSUj}CAA~0{ryfcet%fYxV2D5QFnqwBPmZLcrNOW5PBAZyZ z0>p$RAo8vRVG*5Hg78@i;sA-9!nz8?JrXgiKv=~d67!aUD76|yZV|m2M3dzpPLs$h zN_+z1C5hBeK;#!ENUU7}!fg$Rf+Behh_IC)u97G$s;mX!unNS~wIGU$izIfF2wVrE zxR|&OMAT{!k4TgdzU$2$%_YT53MX-&!dV1wfG8#AQVVcrB$QFNlHB(_pi7S_!Wt|FYGir7O@RTSI;QB6csR2LZ(Zlc6ih#F!L zMNM&n!dCQ|r_8x+36cL#)@m`UL; z?o$Ma;GGbGVm^gUJfjE_O?M$QuGOm0g#YGakNdz7O(NRo1gdb7+Ks+MR zN%$TH;kX~f;=>@ii2EdtlL*NG(M`tEJrXg;Ktzi@$IRbpUt|~4zA%4g)YyZE-AJ`QCyS8h~x8~WJz|Y>Y z-nv)K?j=^?OCbeJ#*3|{*!aQxkA+PS!4;%4C$rI*e(FDa-Vl!Wf5pr%%r#x?|IPD= zUdHQ?>HM{0hDuk9z?;1+Zm*Ly*EP#ZFWfPQX{*(5RgQ(C3ypSPY=4$ z0P*yx*+DC_m4z0_{)I=x#t%fDrSK&#tAOQ6wltUhe{)=OaMy@m%u6(F%5hQpg}H`S z=ESbh7v@%jb4^fx9phA_s2HaOrJ3HolOH|`Xp3&}?F_zf!jD~N@A4T8zNMpV`1S_^ zXMEZ#j&E%6wPb$y|K!sbUj%U>flo)p@jadXNE=@!2vZ~<1;iL6UX4^7U&itR=%_O| zX0vhL06$%o9bZ@pW+pz}6qg(6Z7hgScg68#^e4)$2RXi5#J3BdDYBBBd|%mL&eCl2w$w;qPX6QV^k&s{6s3QFw$vrsw~!usS!B(U?kZXM_>WKH(3WNjxT;Kl3YtutRhQ-SRzRi z|3U-{I{}MT0|zP285~~(d}a@!F*{zC%5l0(p#P@pBiSBd<)Zb5I6)J2KdTVBcL%*2k-&>0DpilZ`B6UQT0e*6fhbvUeeAcrVY_5r4>iM zBfwYgCjpazDZo^q76Si0C$1=z(e2>@EG7adkS!A@xSuq zcMFCB$v_G)97yG0jX(lBuJQf9_K+Qb;Xo=d0^k}=2Sx(1z#t$I7z{)Jy@5VJ51=d1 z3TO>91R4Q(;6E>rA1DCu&pKoPT;Db`Rw2JHQy3@$6a|Wjn88}}QrA%SI&cH{0XPC2 z1&#q2I(aJ$+HYy+O7u3vy(f!~0az-!=l;0-Va7zcDjd&UD3 zfJp$iw<*9VU^Fm>Bi9p&AwV?H59kCqp>SbC#{ConGg`Epf9;gWLLf{7k z0`-9UKtrH0&;)1-@TKbF*-Yh4g`q5jlf}SNpcfDWWJe<`fVWw!PtZ!Iok02!a2VjX zWOyOq#y=UD3h?U+U4d>ucc3Q_4)g-r0BwPGKnTzhCh|34R8`z z4!^U2+1W96bC8$^@OuyafLMTEWf%m+0|`JPkOT|{h5#wRFo1u+Dis(3qyg!`7+@?g z9+&`31SSEKfhoXLU>YzTn89zA%tS&5W&?A8xqtxX0rLU=m92%qB49DF1Xv0z1C|3T zfJmS-a0$IT4eRXT1@IDh1LTFxJ4h3x8PEVPs5Vqy8CZk)_zL<@Nax1lh(|gBhy;?5cNq8#Wjg!; z1HMG{5E-6Tofb%C5Yr z+S2TJ+O=_{R+yuDGUllV)X@%=>ljmWW=60F2n7zmixG44R}llCGv4a(&as1J38rfice zX)&52oAFeN2ZktN@6*q)&1^5ysxN29Y1xDAHfBVc{o=vog|g2YKE~oV;5zUtz;Ln+ z&yA`IvYAFW6#a`qm-XioYYt^XMNCY%dQ;oWhwUkd~$%S%b-9_Q<_K zn#0QT!#m)uf{bXXobb*+HIxB5JER8Ki)ItwjMd8I&5?z&kSbiq0o0%4lr=_dU)I<> zhc08B*B&V2b%7v&TS6e<5AZ9x6#$-r$^vD8(m*MIbr$D$UW*~Y0}?~y4Cw@v z1WEwq02;dhKyiR`pb+4KkQapHnDg+P58#o62jXWa_Y~*?6ajb$ z8v-N&y#e~|3Gi&w1F}2EzZ()=ftDaU1HF)r03HJmfkD7f!0sp->Arvqzy|jLIA0;)2#W`TIfKR1RAVMUn5rojLk zlC=T$hO;3V@L?}c-}iuPKs3ky2jDVb?~&cvIB+8XI!%Qf4Gaf{0m(oH_!LND6p#*# z1XzalGU8l8z_p#oq;?J%mq#X zp8zL;HNbvg2QVM_3SeVS0LOtZfiHk#z-r(qaD?OkIT9JbVc-yO0QeMG0n7rj_F@in zHcAKBAU0w#z=l(1>6sBpc82!$#&d)U_@2{v|&Db%{1#_)R<1Q_lBV*!JhqDvI35yz4r_=E8nR2tn-IG zvk&7Yr0I)8OTT-7-M}tjCxAwz8RNAb3R}M!V92)tp8;EeEdZU+DJ8+M(xJUa`?<2ZdNj}B@6S4ij~rJR?Kq+7 zn?4rW$Da|fJ77;aUUrU!X>6ZNT$Z%q?B;B?m$O&Qvdm*+2-?~kYuD+Ikx2U*2`XGJ z-vZwNX8{_LXJO`%|1)PVmz8H|cLSVw_VOGW`}`uuWvj}hWmSyyC1l#e%8=QeoK}S> zuK-s87a$7w5x4AIN}%M5RAa zy1lYT&?5myfLt+vK62m+m=cm@0nyaZkVzX2Ul$FCg!Ux4R8I}q&w zX3!}eIb?B0M}GixW(IEp-XZ-KcmptPm*l9kQS_6w5xgPK30MIayTjZhSTST#pol^- z5j<0?Rk9+~3P5?F9N+?!1#7md7*9$$rv?-YymU}LNpu%Ly>3+gaI9a4nRW~wufv7v<2D# zt$`-s`P%^ACC)~gkA(XI5kOC%hX|gf74B6C?dSzH9Owhs%_5Pe8IMT4Rhq?V!)HeM z0G6Rmw35u{-EbeE?pP@J%*ZEp&eE#xx;;z7J37v|Slj=~l4kDiQNyE#m%JIpzMpRY zJ4`C4mb|rKPJvgOhv)sVY5DhMoW$3&wGwrF(0r`A^tIJTO=|m#QzaO9!l{Qm2s$F? za?IFc)7HEkt%`c0iK5gTt%OrLik5&uj>NE;A=S@ae{V2fw4S3C;~&Z0DEh-x%P@;& zb2LZoyjgrdM{A)yHj8p|HBTqbd<<~<(n=TRRI){%HC)uLfjY8_*tuE>N8`{4gQKSV zZGWEHw;BxGYt;6xEtbvIobfB=19P=nPMmTjVbrViD3=b|XZmqX$&XFs7HBEwLw*?4 zU0cIr;j?A#@Zw#g4(sV4f?$AOICc_RRW2jrBPI*Y$teyhMk#$$q{#PX>bEW^RjYql@!;`%nCk`z}^)p2HHmyhwBkT)A_RT*EFVsSu?SFKBWxv5m$#1)qf&u257h3c(r+BzfbHs0H zEsIcJfLOj$DNFUvD{xfb8JG$!Y*TCxyF;ZSa|Vv*Lu+)O-Kg!tS4qI}@< z!&|>9P$vq8-f||}|HgdwFwb_UbF4pxoH}wKJBdKV->DG7Q~-5M?mc_!{!1sW8Fd(O zC?^JSfL4jgi_tD)xI(mB`9!`Y2;YAeLv(A@!4&cr!AnqVyy&t-Tj;e)4dRk<`|7WK zlkXGFJc1*HFx&r3eS`d4)+A0nI|(^leR6oKFV#HFjYN;7S_!Xacs|9+)xPJ$cduuJ z|7Nrhrnu+(P!85e<_vL)Eri<-lwDYK*MpK{_xDFx_Zqe2^t-VX-I^|nF4IDsmcgzF z>>jj^7}jdC>j=ZnqlPcGJ(0Q$VT#9G!LKaS+e|#asdQMgTrl8tK_k+{)@52X>v0&M zIqA|~yj`Z1tYiN>_J>PW%(1UWPwo=6WasUF?Y4||vwQ2ukK>Tz3wLOFWf8I*^?71r z;;@t`RA@lE^7+44MG>8PVk8W-Uy6!F%dxGzD=Ky_*IGD_!&Yak^P<=0`5kgdMFEZo z3S2HGO0UowSj%8X<5uA-`maC@_P-k2>Cj`#q8}c)ssiXy1F;kabC* zqTh~%fe$=l=&mA%ja!@iHn!F>r{L_2st3m|T0ElfDX51AFJBLf`aEp*YR#Mkh6Axk zxUAGlMDK&j_1U}AkPNNWSnliJ-x}bcHVg~;D(A}yMi2)PJdM}vj5xKr{^0vwQG2|n_iR=-B+VKJ<7`N z^lTcDyWGU~myGThvAI%4%v!Bgtq@fXrxy6&Ln@BKFh~E_(c|0oDPXqe94RNRuh!DB zFNS}DPNa!p5KdFd%kf{_e!u(m;8IFY)FGV8Pwh5xf@k)k;=tOH@ka1IeM>u@pg7W=?D#lvd> zurJczHfeRL=dWmhCxV8dY$2Y&0Ppl1TCWwg_<#x*<=1P~0*6%ltN7V-p2LDGtJvFe z4G(SpZW^58de5ENEGDmq(>x;Y2D|TCPX9U3wm@8#aA8t*@uKnut(Nn(>aqv-)=tWQ z{(KHBnXkdH_0Ow`!5cJB?Rz(|euGxksZ&iElmGPGNj!lo?Osh$;Zq!dmS7oucrGjv zgFe;#@siTUPZ6fP2nd6$ee(3HK2f`I4&oIAM?X_>aRUbC^5S=h!U3@34qR(iL`ufu zWtcfNMdgi}OPwaj;5ISR<6L5qYI<09vvEp*hpx!s5phYF-+ApwMCtcA@gj92>YfM# z9t3ARU3;q0;8W$_8!Q)FVW92t5T`a`X?+Vr>_+KL#+jN`UlXRkH+<+ZcN2P>1N$b! zb#V2zV@)=0Z&HNowl=SX#YD&^t%RE^47hx2?_53idYJ<8MKUMde@3#t*m@q*sy>RK z-t;`8)Mm5=OY`g|Oirl^ZL?NVrbD#bb;N+pxNJYcJSd0~*E5Q~9djY-c6Ku^hqa9B zwpa=ub&4P=My$TpDz_c2rM@?CRXNRvn47hJex<+k&i>NoqBCcL+sEP`NAX9End8K(Da*T(T-yM3YW4gNYu7X2IFvU0p_ z$)-5}bzAMd`Ro3=iTa;mEvd^8-YcB>89EmzHb6M93zB<4<aE47*=%Gxh*nw|V9M zJxE;t3`Z-QD7;Oph8HJ;wrMW%KwARe3K+HxPTGk%$g6X+o?M>zTf`BGh~JF67k(%) zvJi&{Y?Ky^h32|z>R#My1P`K{as^&U@yH@T8JsXKupW_4+-$;zu3puut_+dBX zqDJEBSuDxzjpRL={SN|bm@LM{&UCtwsCy8WKQ09^S8#2NUrv0WJq676 zoWqTU?WpD`()VfAHP^=C>;cU)mp$_@8uSqjncYNOIf;gBY9b1pf>hPm8|G}U;m?6$ z!;Cgq?H2RJ^0U|q?6I)-R5=!Pk7FuBZ88}c}el~fL6#J8N1Ak zP}Szb{Y%aB-;6Rd?VXoKA2S;NQE{d(^gBJ{qek>r3(@lng#3R$i|p;T58B7KKg%e6 zWNYPk77@LUYt24(9E*xRC$!R{z!zHej~L89O=9QRPOFssRYH&oQ+>Ua|>_G4jZPVSFi=xTjA7j7x9vG%{> zEw=>hizd~0=Nho!6~_MezNG~&vL^dxVISsZEk(~g*a~Kx`lv->pI?7kk@lsIZ?N3i ztM&MZ0s7l5=r6o;zbg5$Gr;cSY(Krm_uD3^9d2U+QveY+=W_`PlS2P}y@dUlCXpw>T+9le}wQT$Eo9yg% z@*H$@q5H9yqZ}&2rzf6G;M}pHop^g%^K?E83!WLh59Hjn&$;E7My<8@SnX^((c+BO zAlGGBU@fM%Y%dm^(YkAw+lkj_@Q_i;vYLMf(eLXlcJRFWYpuJ*xLXi!&S<5yk6eU* zvH0xc^nY4fYAM+-cOS6`{=E16&u52Pk7|!`w%}=OF$D7c!(tv5{$`sqE~OvQ(uQGT zz3<%l};%elzmlWmVD9;d2j zd^YNR&gRbIAj^KG%0}ExKAmgX!Ys91Q#s!aS=zkA=ZVkXm;I%)$a4WZM3FAC?Z@Yr zb2<9_Mwj;%-d#jpSa@}W1vlWkeTU!q{mhyx?=2FL!yBaYwbE})J=}QA`iivwyv@!TLTg+7)Lo<4b30JkM+NoY*`&G?P zON|guucE&35u(Bm*rsmu7H4nb2}fV5eQ!}AQn=qh00Qt^i)*lK56hCUJWxKvz3G@SW8YgU zU)qsK(G`}ODk-L2(@Hu=;dfAcgyOJ#o9B1tHp%a!Fu0F6dJPwfldxbD9*#J>x!X6d z$H0PHH{$fyIa&H@Z>Kd%;GwXiW^aw1x9fJ9rKqBu+eOO@(&!R>PkmLnM@h6mJwUcQ z`R_rHZXGo=g(^Yd3fwtUs&Scz_}7KP{iFv?RKAk zZTV-PcVp%8DdXkcj8$)r%z59}cd;VR9r!E~C+cRE6$gpVcVrKZDQ6#hwI7O0ceJxw zaH80P4NU&7r-ar#QC$BKzSK_W9IJM~idA?2&~m=kIwk5QzW|HXnNGn-G!DMirSmfnxIuS0iP0q6PbhIdS{*yw~y z*LLhrYu}w8!x66wwNAY8O6nG5{8Ao{a9e|4m1cj6N8x$X$yUjSoTy)cg~$ID{=d+ zoa>VXvo5OE!lG2V2zZA6;Ke!P(BpXQ$r%Qn z_)M$X(Ql;O&iW-p#-aJ9OFhPp&Nlsz8wJe&J?8@(0q-af2Knn%=P9FPCr?Zrp0Bue z4#ya*Cu7elGg@@bfs<#g(V|s0$OfZD-FJAW#M9Jrw7C8p4@Trm0$Lahe#7wzdqc0$ zq5yK7Q%1{9ZpnQqX6V?R-ytx(3L>_4ugZt>Ian4!IO5-Kczyon6yARF7m4qEX~AQ} zr|_96Wl2wayF~S0Fd@n)H|LxWUunDj=&JW4A)8dxf-SA)I1y^a4BjSQ=k_@2)&@xFwwRdT0(kS(H;u&1RqRqQ$SMD0qyVL5@p@&7Zoq9WPb1zzk9W z(n7|Id>97juJFYPov-fP2TsGh!M-3mm zJT+cyF=s1kLHn1F7w!((ik3mS^{{6r^Jiajvxld%eSRZHj;N=mhEqE{UOdf?PM?6> zi7n{+@#5icsPP6oa4U#eu-|27_(2{}xCPb0>*eD`*%uhJ-^UBT7buZ)f*1(hxzq%C z$tv>wl==rhIr&Jn5&f++K^%OG=~c&6dxF?cTN`ZYeQw3$kx7Nx9#OVvU&sXUl-|{V zYF#FX3NNt*N1+6JR6eaymFEx3@;C4vSkf4y5y;`H8|qf=r{Psg-c!DiGkt=X@)ETz zQMC45GW85#DL=LZnadn4pIGpC6 z&nWB8_cm@#5cyuA>>F5c^uBvA#>^+IO_gQ{_$E zzM&6VUug6uNDTrequL3|U@ZGjleh6E-(Lo=XgqnCGQ!3p`|79-o;K$?Rv+g3RoL?I z_8@$dB{1=mb85`s@W*K)^bd^c3s@9@MYi@sCjB_qnQt6%MxyLrucDmO#Uhkd+mn68 z{@v`B&3CkiPk6n-?Uy1&KPqxkh$ZhcjJ;mX71e$2{(r?d|8-ojoyNVzEx26$j$VHq z=CGM^ZJW-vI{K#Zty?O#UhlW$f4@*=_9%1q*k?fIF6iid1WviMjyE{;^{(l*P{S#f zw0%_na#KaMvbSC?v*g+v_Tp!^j2@r#feUrR=ZPHNhB!AGIr!fHYvfp?nmn>57ZLD5 zc!dzdO9WBGN5qu6zFJ+}dZ>%LqP49Lu&5v;5Q4U-fZ9E)hpPC%Yi*059%w{X2)J3T zRpN@an&YAv%>$>9WxLsF!*0%1QZ@wW&L7N}>zAyJVckbLfb7#I>QijU?M?E}w zYw3I%W^=Zdz72Y1-1^c3bdg*`qst^J*6SB4D`q8)K|YO?Qs?{)=&yi8-ATe=^OEv2 zOH_A{xGj(+>ZxqA6;~;Uai=~JL$=dToo?mv$dV&(&NISbCo+Dkkv&7Vu#`8nR8a$w z*XHuzDy=O$(Qr{6G(CsgE*X|Nmgw29*I@>bspPoSW85=5KknfXH;C&iY zs?%L6<|}>MLHBK6jZP?*I!|hotY7yG;zT%LYKw3sxKrqn+}$;&5`pdMHa{OOB9|}> z&CN>KTuty3iCY9vp^1RY&p5RYXq8=s3zzv^iq9H6)fq`h6dZZC>Yp*=6YNB?mJ@Cl zu~JA8%!Qh=1|506lRNQSS4xrtNOxR?0;16dFVN}=Kj8h_U;X{-_up${2fXBTFIpxh z8D~Ghaxc&rT8M%T%&70p>tAu7>n?i)#fm!wVRP8_{)-~a%?rg^kp`sDQt;%e1oWmE zlFVM$Gu!7Ai=eF(x^^M~2Sv--Sc-yB0?CbHTdD zG&yATXH}we_IPvj2H9R6R*`yKmSyM}xrfO*nO*p;@ES1)y6S)Rk2qq$(>l^msRKZ- zd$gPD=xG{%>ekF%R6Q)9(~+#s%NNBOL(esbZ_9a6Bc|=`%lA)SStFHo*%GJM|H|=S7zU}Lvx$YB{%Rj3h6%jMdE(Wn;Lwr0&jXlkzD-*nEbf4cHAOh9kyJgnTI zra`r@lBbTOX)piv;qANeWB|%_Y*k~EAthJ#_*I8*yC}O4t!5f4@jYKMuahu*$N-g7 zzX@-hzE;fGN#iS(*ysxl^bcfo?pDo0ac~uu_>sJ!RovhQ)2qLB`W;gfSC*}bCY~US z+x06mPp97vo-aY9+1BS;Y$aMcDN84JgE-S5(I#EbdM>cyLe^?oU!}7WxBJ6lWtkO! zpx<@-t-LmHz@%|Id}ICZNg`W4k+ya#jtU^k6IOK6Rd?F8Z9!Hd z%z|HpA$TbTZ{7h)TP14MuvpjU0Dlo7Ju$lG;LUuKY#5p=?LUAYQdjv;sNsJ1Xngae zc>%}iII6w|KU2dv<=<=2Esz{zU@gY8Yg8>}29o;XO23!WvmvF9b7*Zn{!z95(-+x^ zI=%F+Nv;e@KRo{9IQOg@F32D1@E)T@8N>0zlhNV z`&79w{v|{D2TpA-`_uB%F368IY+;)D)pOd+g?pY{+;W%>*9*Br>rthp-knsB!?aLD zFW*#aAxKwxg!|~Z>z`)t-1J}m28UFiP=A7LT5=1q=_xiSw;tvG-|T-<>!@_F`f(gT z%^)!ORWEh~Iyi_e0ooM(iEX3uZB4bZ6r--vt1c!V_^=MAu1qi$~e z@`O`TM+yKRq9Pc^=pq|A!t9x6XQZea*f|J!3uROzCIwTxi*#UFFvZex2VdJ=oRLs< zd-K&ec`(sbahC=64cZ^Apo%d3Sj*~&KW6b>NihLThK4C{@ge0VkaeO5Oi>0_xp zQhS>7oPOv@TkD%`$wI2W*Yh=ZoW>I3t}JW9{b7)3=)MI^O82ej@^N@LXa@fLSh?6n zlimvp()HW~62Cnh^kcghZ=lT5y?6#O;p1=!Qu6mA)cwZxYGeN_MW4wkj2{G(1Eq&v zmX!3&73s#eroC}K>RwFUkZ{~E2tpO%Xr)WIk1zM6%gHPE&8ZFx($O>P$u)t=1D^QD zwMJw6Aef|FIv0mUKuGGlMLZVz&K^{yzNIyGEl_ub7g>11%=j^SUhx#)je{xal7ym} zBYt~(fd`fw=$VPzefXp;z~|~KFIVFJ2pHj&t>R5N(`>&M0bU9$TnBwo6$w!|GZJd- z0a1_@hI>~-n0-$I6o)Bah_jDe0@X?^$_6it$c95`%72RieJ%lml{gRK@iTF|~4Mxiqj zJne3);BN{HDj;2Bb6_}z6%xa*3LqSvIrR0CnMhi4zz5TE;R1v1LRBHC?B6bj34q?S zz}?kwiFd9Tel{*Pypu`!48XJkxSx)68a*ITsTTLc1?LzLmB(-Ix}eZ8l}6 znJw>NPBbajV59~l)j|?p&w^iJ1-l!X1*7c$u7zSnh$%HAZILB5#bV0JB^J49mRO4^ z*J4gLF0rI6p?}(iVZP}`_MDr(XbC}yyTydNZ4labX$ld%la*@9%*A6i$nefeu`G@4 zz|OTt*Mm`kJC{K+-kJbf%sxy8R%gOEtZe|q`9?CP)CWnKFa-M^B6Y82!Ze(lMNJJc d!VZSwXQ$Ju3`S$nKR{;>Jp}`X{~se-8gl>u diff --git a/package.json b/package.json index 4f56fb1..f2093a1 100644 --- a/package.json +++ b/package.json @@ -13,17 +13,21 @@ "@hookform/resolvers": "^3.10.0", "@kinescope/react-kinescope-player": "^0.5.3", "@next/third-parties": "^15.1.7", + "@radix-ui/react-accordion": "^1.2.3", "@radix-ui/react-alert-dialog": "^1.1.6", "@radix-ui/react-avatar": "^1.1.3", + "@radix-ui/react-collapsible": "^1.1.3", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-label": "^2.1.1", "@radix-ui/react-progress": "^1.1.2", "@radix-ui/react-scroll-area": "^1.2.3", "@radix-ui/react-separator": "^1.1.2", - "@radix-ui/react-slot": "^1.1.1", + "@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-tabs": "^1.1.3", + "@radix-ui/react-tooltip": "^1.1.8", "@tanstack/react-query": "^5.66.0", + "axios": "^1.8.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dotenv": "^16.4.7", @@ -31,7 +35,7 @@ "geist": "^1.3.1", "input-otp": "^1.4.2", "js-cookie": "^3.0.5", - "lucide-react": "^0.474.0", + "lucide-react": "^0.483.0", "next": "15.1.6", "next-themes": "^0.4.4", "react": "19.0.0", @@ -42,7 +46,7 @@ "react-turnstile": "^1.1.4", "recharts": "^2.15.1", "sonner": "^1.7.4", - "tailwind-merge": "^3.0.1", + "tailwind-merge": "^3.0.2", "tailwindcss-animate": "^1.0.7", "zod": "^3.24.1" }, diff --git a/src/api/account.ts b/src/api/account.ts index 72ab05f..7509e93 100644 --- a/src/api/account.ts +++ b/src/api/account.ts @@ -11,7 +11,10 @@ import { setSessionToken } from '../lib/cookies/session' import { api, instance } from './instance' -export const getMe = () => instance.get('/auth/account') +export const getMe = async () => + await instance + .get('/auth/account') + .then(response => response.data) export const createAccount = async (data: CreateUserRequest) => { const response = await api.post( @@ -19,15 +22,13 @@ export const createAccount = async (data: CreateUserRequest) => { data ) - if (response.token) { - setSessionToken(response.token) + if (response.data.token) { + setSessionToken(response.data.token) - instance.headers = { - 'X-Session-Token': response.token - } + instance.defaults.headers['X-Session-Token'] = response.data.token } - return response + return response.data } export const sendPasswordReset = (data: SendPasswordResetRequest) => diff --git a/src/api/course.ts b/src/api/course.ts index dba2bdc..5cf5d7b 100644 --- a/src/api/course.ts +++ b/src/api/course.ts @@ -2,10 +2,23 @@ import type { CourseResponse, LessonResponse } from '../generated' import { api, instance } from './instance' -export const getAllCourses = () => api.get('/courses') +export const getCourses = async () => + await api.get('/courses').then(response => response.data) -export const getCourseBySlug = (slug: string) => - api.get(`/courses/${slug}`) +export const getPopularCourses = async () => + await api + .get('/courses/popular') + .then(response => response.data) -export const getCourseLessons = (id: string) => - instance.get(`/courses/${id}/lessons`) +export const getCourse = async (slug: string) => + await api + .get(`/courses/${slug}`) + .then(response => response.data) + +export const getCourseLessons = async (id: string) => + await instance + .get(`/courses/${id}/lessons`) + .then(response => response.data) + +export const incrementCourseViews = (id: string) => + api.patch(`/courses/${id}/views`) diff --git a/src/api/instance.ts b/src/api/instance.ts index a810a8f..6e10b8f 100644 --- a/src/api/instance.ts +++ b/src/api/instance.ts @@ -1,13 +1,14 @@ +import axios from 'axios' + import { APP_CONFIG } from '../constants/app' -import { API } from '../lib/client/api' import { getSessionToken } from '../lib/cookies/session' -export const api = new API({ - baseUrl: APP_CONFIG.apiUrl +export const api = axios.create({ + baseURL: APP_CONFIG.apiUrl }) -export const instance = new API({ - baseUrl: APP_CONFIG.apiUrl, +export const instance = axios.create({ + baseURL: APP_CONFIG.apiUrl, headers: { 'X-Session-Token': getSessionToken() ?? '' } diff --git a/src/api/lesson.ts b/src/api/lesson.ts index 6638201..61e9768 100644 --- a/src/api/lesson.ts +++ b/src/api/lesson.ts @@ -2,8 +2,15 @@ import type { CreateLessonRequest, LessonResponse } from '../generated' import { api, instance } from './instance' -export const getLesson = (slug: string) => - api.get(`/lessons/${slug}`) +export const getLesson = async (slug: string) => + await api + .get(`/lessons/${slug}`) + .then(response => response.data) + +export const getCompletedLessons = async (courseId: string) => + await instance + .get(`/lessons/${courseId}/progress`) + .then(response => response.data) export const createLesson = (data: CreateLessonRequest) => instance.post('/lessons', data) diff --git a/src/api/mfa.ts b/src/api/mfa.ts index 2e47e58..bf796e2 100644 --- a/src/api/mfa.ts +++ b/src/api/mfa.ts @@ -1,5 +1,7 @@ import type { + LoginSessionResponse, MfaStatusResponse, + MfaVerifyRequest, TotpDisableRequest, TotpEnableRequest, TotpGenerateSecretResponse @@ -7,9 +9,15 @@ import type { import { api, instance } from './instance' -export const fetchMfaStatus = () => instance.get('/auth/mfa') +export const fetchMfaStatus = async () => + await instance + .get('/auth/mfa') + .then(response => response.data) -export const fetchRecovery = () => instance.get('/auth/mfa/recovery') +export const fetchRecovery = async () => + await instance + .get('/auth/mfa/recovery') + .then(response => response.data) export const regenerateRecovery = () => instance.patch('/auth/mfa/recovery') @@ -17,8 +25,13 @@ export const regenerateRecovery = () => export const totpEnable = (data: TotpEnableRequest) => instance.put('/auth/mfa/totp', data) -export const totpGenerateSecret = () => - instance.post('/auth/mfa/totp') +export const totpGenerateSecret = async () => + await instance + .post('/auth/mfa/totp') + .then(response => response.data) export const totpDisable = (data: TotpDisableRequest) => - instance.delete('/auth/mfa/totp', data) + instance.delete('/auth/mfa/totp', { data }) + +export const verifyMfa = (data: MfaVerifyRequest) => + api.post('/auth/mfa/verify', data) diff --git a/src/api/progress.ts b/src/api/progress.ts index 7f32632..7615b5b 100644 --- a/src/api/progress.ts +++ b/src/api/progress.ts @@ -5,5 +5,12 @@ import type { import { instance } from './instance' -export const createProgress = (data: CreateProgressRequest) => - instance.put('/progress', data) +export const createProgress = async (data: CreateProgressRequest) => + await instance + .put('/progress', data) + .then(response => response.data) + +export const getCourseProgress = async (courseId: string) => + await instance + .get(`/progress/${courseId}`) + .then(response => response.data) diff --git a/src/api/session.ts b/src/api/session.ts index 9e4192e..c789fe6 100644 --- a/src/api/session.ts +++ b/src/api/session.ts @@ -8,22 +8,22 @@ import { removeSessionToken } from '../lib/cookies/session' import { api, instance } from './instance' -export const login = async (data: LoginRequest) => { - const response = await api.post( - '/auth/session/login', - data - ) - - return response -} +export const login = async (data: LoginRequest) => + await api + .post< + LoginSessionResponse | LoginMfaResponse + >('/auth/session/login', data) + .then(response => response.data) export const logout = async () => await instance .post('/auth/session/logout') .then(() => removeSessionToken()) -export const getSessions = () => - instance.get('/auth/session/all') +export const getSessions = async () => + await instance + .get('/auth/session/all') + .then(response => response.data) export const revokeSession = (id: string) => instance.delete(`/auth/session/${id}`) diff --git a/src/api/users.ts b/src/api/users.ts index 077298b..f2a0668 100644 --- a/src/api/users.ts +++ b/src/api/users.ts @@ -1,15 +1,33 @@ -import type { PatchUserRequest } from '../generated' +import type { + LeaderResponse, + MeProgressResponse, + MeStatisticsResponse, + PatchUserRequest +} from '../generated' -import { instance } from './instance' +import { api, instance } from './instance' -export const changeAvatar = (file: FormData) => { - console.log(file) - return instance.patch('/users/@me/avatar', file, { +export const getLeaders = async () => + await api + .get('/users/leaders') + .then(response => response.data) + +export const getMeStatistics = async () => + await instance + .get('/users/@me/statistics') + .then(response => response.data) + +export const getMeProgress = async () => + await instance + .get('/users/@me/progress') + .then(response => response.data) + +export const changeAvatar = (formData: FormData) => + instance.patch('/users/@me/avatar', formData, { headers: { 'Content-Type': 'multipart/form-data' } }) -} export const patchUser = (data: PatchUserRequest) => instance.patch('/users/@me', data) diff --git a/src/app/(public)/about/page.tsx b/src/app/(public)/about/page.tsx new file mode 100644 index 0000000..f83fdf4 --- /dev/null +++ b/src/app/(public)/about/page.tsx @@ -0,0 +1,70 @@ +import type { Metadata } from 'next' + +export const metadata: Metadata = { + title: 'Об основателе' +} + +export default function AboutPage() { + return ( +
+
+
+

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

+
+

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

+

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

+

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

+

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

+

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

+

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

+
+
+
+
+ ) +} diff --git a/src/app/(public)/courses/[slug]/page.tsx b/src/app/(public)/courses/[slug]/page.tsx index 6459a49..aeeef03 100644 --- a/src/app/(public)/courses/[slug]/page.tsx +++ b/src/app/(public)/courses/[slug]/page.tsx @@ -1,12 +1,14 @@ import type { Metadata } from 'next' +import { cookies } from 'next/headers' import { notFound } from 'next/navigation' -import { getAllCourses, getCourseBySlug } from '@/src/api' -import { Course } from '@/src/components/course' +import { api, getCourse, getCourseLessons, getCourses } from '@/src/api' +import { CourseDetails } from '@/src/components/course/course-details' +import { CourseProvider } from '@/src/components/providers/course-provider' import { getMediaSource } from '@/src/lib/utils' export async function generateStaticParams() { - const courses = await getAllCourses() + const courses = await getCourses() const paths = courses.map(course => { return { @@ -17,24 +19,16 @@ export async function generateStaticParams() { return paths } -async function fetchCourse(slug: string) { - try { - const course = await getCourseBySlug(slug) - - return { course } - } catch (error) { - notFound() - } -} - export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise { - const slug = (await params).slug + const { slug } = await params - const { course } = await fetchCourse(slug) + const course = await getCourse(slug).catch(error => { + notFound() + }) return { title: course.title, @@ -42,7 +36,7 @@ export async function generateMetadata({ openGraph: { images: [ { - url: getMediaSource(course.thumbnail ?? ''), + url: getMediaSource(course.thumbnail ?? '', 'courses'), alt: course.title } ] @@ -52,7 +46,7 @@ export async function generateMetadata({ description: course.description ?? '', images: [ { - url: getMediaSource(course.thumbnail ?? ''), + url: getMediaSource(course.thumbnail ?? '', 'courses'), alt: course.title } ] @@ -65,9 +59,17 @@ export default async function CoursePage({ }: { params: Promise<{ slug: string }> }) { - const slug = (await params).slug + const { slug } = await params + + const course = await getCourse(slug).catch(error => { + notFound() + }) - const { course } = await fetchCourse(slug) + const lessons = await getCourseLessons(course.id) - return + return ( + + + + ) } diff --git a/src/app/(public)/courses/page.tsx b/src/app/(public)/courses/page.tsx index fd50582..7bbf624 100644 --- a/src/app/(public)/courses/page.tsx +++ b/src/app/(public)/courses/page.tsx @@ -4,16 +4,11 @@ import Image from 'next/image' import Link from 'next/link' import { FaYoutube } from 'react-icons/fa6' -import { getAllCourses } from '@/src/api' +import { getCourses } from '@/src/api' +import { CourseCard } from '@/src/components/course/course-card' import { Badge } from '@/src/components/ui/badge' import { getMediaSource } from '@/src/lib/utils' -async function fetchCourses() { - const courses = await getAllCourses() - - return { courses } -} - export const metadata: Metadata = { title: 'Курсы', description: @@ -21,76 +16,24 @@ export const metadata: Metadata = { } export default async function CoursesPage() { - const { courses } = await fetchCourses() + const courses = await getCourses() return ( -
-
-

- Все курсы +
+
+

+ Курсы

-

+

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

-
- {courses.length ? ( - courses.map((course, index) => ( - -
- {course.title} -
-
-

- {course.title} -

-

- {course.description} -

- 0 ? 'default' : 'error' - } - className='mt-3' - > - {course.lessons > 0 ? ( - <> - - {course.lessons} уроков` - - ) : ( - <> - - Youtube - - )} - -
- - )) - ) : ( -
Нечего не найдено
- )} +
+ {courses.map((course, index) => ( + + ))}
-
+
) } diff --git a/src/app/(public)/document/privacy-policy/page.tsx b/src/app/(public)/document/privacy-policy/page.tsx index 271921d..a44fe9e 100644 --- a/src/app/(public)/document/privacy-policy/page.tsx +++ b/src/app/(public)/document/privacy-policy/page.tsx @@ -1,4 +1,5 @@ import type { Metadata } from 'next' +import Link from 'next/link' export const metadata: Metadata = { title: 'Политика конфиденциальности' @@ -6,13 +7,294 @@ export const metadata: Metadata = { export default function PrivacyPage() { return ( -
+
-

+

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

-
+
+

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

+

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

+

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

+

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

+

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

+

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

+

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

+

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

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

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

+

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

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

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

+

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

+

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

+

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

+

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

+

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

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

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

+

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

+

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

+

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

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

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

+

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

+

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

+

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

+

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

+

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

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

+

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

@@ -94,7 +95,11 @@ export default function AgreementPage() {
  • 1.4.1. Политики конфиденциальности, размещённой и/или доступной на Сайте в сети Интернет по - адресу teaocder.ru/docs/privacy. + адресу{' '} + + https://teacoder.ru/document/privacy-policy + + .
  • @@ -124,7 +129,10 @@ export default function AgreementPage() {

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

    2. Порядок регистрации Пользователя и использование diff --git a/src/app/(public)/page.tsx b/src/app/(public)/page.tsx index e9ba165..15ac011 100644 --- a/src/app/(public)/page.tsx +++ b/src/app/(public)/page.tsx @@ -1,16 +1,25 @@ import type { Metadata } from 'next' import { Fragment } from 'react' +import { getPopularCourses } from '@/src/api' +import { Features } from '@/src/components/home/features' import { Hero } from '@/src/components/home/hero' +import { Popular } from '@/src/components/home/popular' +import { TelegramCTA } from '@/src/components/home/telegram-cta' export const metadata: Metadata = { title: 'Образовательная платформа по веб разработке' } -export default function HomePage() { +export default async function HomePage() { + const courses = await getPopularCourses() + return ( + + + ) } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index dda299b..43351d7 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,7 +4,7 @@ import type { Metadata } from 'next' import type { ReactNode } from 'react' import { YandexMetrika } from '../components/analitycs/yandex-metrika' -import { TanstackQueryProvider } from '../components/providers' +import { TanstackQueryProvider } from '../components/providers/tanstack-query-provider' import { Toaster } from '../components/shared/sonner' import { APP_CONFIG, SEO } from '../constants' diff --git a/src/app/lesson/[slug]/page.tsx b/src/app/lesson/[slug]/page.tsx index e0f3600..26d21bb 100644 --- a/src/app/lesson/[slug]/page.tsx +++ b/src/app/lesson/[slug]/page.tsx @@ -10,18 +10,21 @@ import { LessonSidebar } from '@/src/components/lesson/lesson-sidebar' export const revalidate = 60 -async function getUserProgress(courseId: string, lessonId: string) { +async function getUserProgress(courseId: string) { const cookie = await cookies() const token = cookie.get('token')?.value - const progressCount = await api.get(`/progress/${courseId}`, { - headers: { - 'X-Session-Token': token ?? '' + const { data: progressCount } = await api.get( + `/progress/${courseId}`, + { + headers: { + 'X-Session-Token': token ?? '' + } } - }) + ) - const completedLessons = await api.get( + const { data: completedLessons } = await api.get( `/lessons/${courseId}/progress`, { headers: { @@ -41,7 +44,6 @@ export async function generateMetadata({ const { slug } = await params const lesson = await getLesson(slug).catch(error => { - console.log(error) notFound() }) @@ -59,15 +61,13 @@ export default async function LessonPage({ const { slug } = await params const lesson = await getLesson(slug).catch(error => { - console.log(error) notFound() }) const lessons = await getCourseLessons(lesson.course.id) const { progressCount, completedLessons } = await getUserProgress( - lesson.course.id, - lesson.id + lesson.course.id ) return ( diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx new file mode 100644 index 0000000..211f231 --- /dev/null +++ b/src/app/not-found.tsx @@ -0,0 +1,30 @@ +import { ChevronLeft } from 'lucide-react' +import type { Metadata } from 'next' +import Link from 'next/link' + +import { Button } from '../components/ui/button' + +export const metadata: Metadata = { + title: 'Страница не найдена' +} + +export default function NotFoundPage() { + return ( +
    +
    +

    404

    +

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

    +
    + +
    +
    +
    + ) +} diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index 975d85f..3ed0d4e 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -1,17 +1,15 @@ import type { MetadataRoute } from 'next' -import { getAllCourses } from '../api' +import { getCourses } from '../api' import { APP_CONFIG } from '../constants' export default async function sitemap(): Promise { - const courses: MetadataRoute.Sitemap = (await getAllCourses()).map( - course => ({ - url: `${APP_CONFIG.baseUrl}/${course.slug}`, - lastModified: course.updatedAt, - changeFrequency: 'monthly', - priority: 0.9 - }) - ) + const courses: MetadataRoute.Sitemap = (await getCourses()).map(course => ({ + url: `${APP_CONFIG.baseUrl}/${course.slug}`, + lastModified: course.updatedAt, + changeFrequency: 'monthly', + priority: 0.9 + })) return [ { diff --git a/src/components/account/progress/courses-list.tsx b/src/components/account/progress/courses-list.tsx index 77c0607..071a543 100644 --- a/src/components/account/progress/courses-list.tsx +++ b/src/components/account/progress/courses-list.tsx @@ -1,8 +1,9 @@ +import { useQuery } from '@tanstack/react-query' import { BookOpen, ChevronRight } from 'lucide-react' import { CourseProgress } from '../../shared/course-progress' -import { mockCourses } from './mock' +import { getMeProgress } from '@/src/api' import { Button } from '@/src/components/ui/button' import { Card, @@ -18,17 +19,22 @@ interface CoursesListProps { } export function CoursesList({ onViewAll }: CoursesListProps) { + const { data, isLoading } = useQuery({ + queryKey: ['get me progress'], + queryFn: () => getMeProgress() + }) + return ( - Курсы + Все курсы Ваш прогресс по курсам
    - {mockCourses.map(course => ( + {data?.map(course => (
    @@ -59,8 +65,8 @@ export function CoursesList({ onViewAll }: CoursesListProps) { className='w-full' onClick={onViewAll} > - Посмотреть все курсы{' '} - + Подробнее + diff --git a/src/components/account/progress/courses-tab.tsx b/src/components/account/progress/courses-tab.tsx index 6cc361d..b623b46 100644 --- a/src/components/account/progress/courses-tab.tsx +++ b/src/components/account/progress/courses-tab.tsx @@ -1,6 +1,9 @@ +import { useQuery } from '@tanstack/react-query' +import Link from 'next/link' + import { CourseProgress } from '../../shared/course-progress' -import { mockCourses } from './mock' +import { getMeProgress } from '@/src/api' import { Button } from '@/src/components/ui/button' import { Card, @@ -12,15 +15,20 @@ import { import { Separator } from '@/src/components/ui/separator' export function CoursesTab() { + const { data, isLoading } = useQuery({ + queryKey: ['get me progress'], + queryFn: () => getMeProgress() + }) + return ( - Все курсы + Курсы Ваш прогресс по всем курсам
    - {mockCourses.map(course => ( + {data?.map(course => (
    @@ -52,16 +60,22 @@ export function CoursesTab() { course.lastAccessed ).toLocaleDateString()} - + {course.lastLesson && ( + + )}
    - {course.id !== - mockCourses[mockCourses.length - 1].id && ( + {course.id !== data[data.length - 1].id && ( )}
    diff --git a/src/components/account/progress/leaderboard.tsx b/src/components/account/progress/leaderboard.tsx index fd9f8f4..fcecf0d 100644 --- a/src/components/account/progress/leaderboard.tsx +++ b/src/components/account/progress/leaderboard.tsx @@ -1,7 +1,9 @@ +import { useQuery } from '@tanstack/react-query' import { ChevronRight, Trophy, Users } from 'lucide-react' -import { mockLeaderboard } from './mock' -import { Badge } from '@/src/components/ui/badge' +import { Avatar, AvatarFallback, AvatarImage } from '../../ui/avatar' + +import { getLeaders } from '@/src/api' import { Button } from '@/src/components/ui/button' import { Card, @@ -11,6 +13,7 @@ import { CardHeader, CardTitle } from '@/src/components/ui/card' +import { getMediaSource } from '@/src/lib/utils' interface LeaderboardProps { limit?: number @@ -23,15 +26,18 @@ export function Leaderboard({ showButton, onViewAll }: LeaderboardProps) { - const usersToShow = limit - ? mockLeaderboard.slice(0, limit) - : mockLeaderboard + const { data, isLoading } = useQuery({ + queryKey: ['get leaders'], + queryFn: () => getLeaders() + }) + + const users = limit ? data?.slice(0, limit) : data return ( - Рейтинг пользователей + Рейтинг пользователей Пользователи с наибольшим количеством очков @@ -39,33 +45,51 @@ export function Leaderboard({
    - {usersToShow.map(user => ( -
    -
    -
    - {user.position === 1 && ( - - )} - {user.position === 2 && ( - - )} - {user.position === 3 && ( - - )} - {user.position > 3 && user.position} + {users?.map((user, index) => { + const position = index + 1 + + return ( +
    +
    +
    + {position === 1 && ( + + )} + {position === 2 && ( + + )} + {position === 3 && ( + + )} + {position > 3 && position} +
    +
    + + + + {user?.displayName.slice(0, 1)} + + +

    + {user.displayName} +

    +
    -
    - {user.username} +
    + {user.points} очков
    -
    - {user.points} очков -
    -
    - ))} + ) + })}
    {showButton && ( @@ -76,7 +100,7 @@ export function Leaderboard({ onClick={onViewAll} > Посмотреть полный рейтинг{' '} - + )} diff --git a/src/components/account/progress/learning-progress.tsx b/src/components/account/progress/learning-progress.tsx deleted file mode 100644 index f3c0484..0000000 --- a/src/components/account/progress/learning-progress.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { BookOpen } from 'lucide-react' -import { CircularProgressbar, buildStyles } from 'react-circular-progressbar' - -import { mockUserData } from './mock' -import { - Card, - CardContent, - CardHeader, - CardTitle -} from '@/src/components/ui/card' - -export function LearningProgress() { - return ( - - - - - Прогресс обучения - - - -
    -
    -
    - {mockUserData.completedLessons}/ - {mockUserData.totalLessons} -
    -
    - Пройдено уроков -
    -
    -
    - -
    -
    -
    -
    - ) -} diff --git a/src/components/account/progress/mock.ts b/src/components/account/progress/mock.ts deleted file mode 100644 index 9fb9ac5..0000000 --- a/src/components/account/progress/mock.ts +++ /dev/null @@ -1,77 +0,0 @@ -export const mockUserData = { - points: 850, - rank: 12, - totalUsers: 1245, - completedCourses: 3, - inProgressCourses: 2, - totalLessons: 45, - completedLessons: 32, - completionPercentage: 71 -} - -export const mockCourses = [ - { - id: '1', - title: 'Основы веб-разработки', - slug: 'web-development-basics', - completedLessons: 8, - totalLessons: 10, - progress: 80, - lastAccessed: '2023-05-15T10:30:00Z' - }, - { - id: '2', - title: 'JavaScript для начинающих', - slug: 'javascript-basics', - completedLessons: 12, - totalLessons: 15, - progress: 80, - lastAccessed: '2023-05-18T14:20:00Z' - }, - { - id: '3', - title: 'React: продвинутый уровень', - slug: 'advanced-react', - completedLessons: 6, - totalLessons: 12, - progress: 50, - lastAccessed: '2023-05-20T09:15:00Z' - }, - { - id: '4', - title: 'TypeScript с нуля', - slug: 'typescript-basics', - completedLessons: 4, - totalLessons: 8, - progress: 50, - lastAccessed: '2023-05-22T16:45:00Z' - }, - { - id: '5', - title: 'Основы UI/UX дизайна', - slug: 'ui-ux-basics', - completedLessons: 2, - totalLessons: 10, - progress: 20, - lastAccessed: '2023-05-25T11:30:00Z' - } -] - -export const mockLeaderboard = [ - { id: '1', username: 'alex_dev', points: 1250, position: 1 }, - { id: '2', username: 'maria_code', points: 1120, position: 2 }, - { id: '3', username: 'john_programmer', points: 980, position: 3 }, - { id: '4', username: 'dev_master', points: 920, position: 4 }, - { id: '5', username: 'code_ninja', points: 890, position: 5 }, - { - id: '6', - username: 'current_user', - points: 850, - position: 6, - isCurrentUser: true - }, - { id: '7', username: 'web_wizard', points: 820, position: 7 }, - { id: '8', username: 'js_lover', points: 780, position: 8 }, - { id: '9', username: 'react_fan', points: 750, position: 9 }, - { id: '10', username: 'typescript_dev', points: 720, position: 10 } -] diff --git a/src/components/account/progress/progress.tsx b/src/components/account/progress/progress.tsx index 17544f0..80b3587 100644 --- a/src/components/account/progress/progress.tsx +++ b/src/components/account/progress/progress.tsx @@ -1,6 +1,5 @@ 'use client' -import { Award, ChevronRight, Trophy, Users } from 'lucide-react' import { useState } from 'react' import { Heading } from '../../shared/heading' @@ -8,18 +7,7 @@ import { Heading } from '../../shared/heading' import { CoursesList } from './courses-list' import { CoursesTab } from './courses-tab' import { Leaderboard } from './leaderboard' -import { mockLeaderboard } from './mock' import { UserStats } from './user-stats' -import { Badge } from '@/src/components/ui/badge' -import { Button } from '@/src/components/ui/button' -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle -} from '@/src/components/ui/card' import { Tabs, TabsContent, diff --git a/src/components/account/progress/recent-courses.tsx b/src/components/account/progress/recent-courses.tsx deleted file mode 100644 index c29f9e3..0000000 --- a/src/components/account/progress/recent-courses.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { mockCourses } from './mock' -import { - Card, - CardContent, - CardHeader, - CardTitle -} from '@/src/components/ui/card' -import { Progress } from '@/src/components/ui/progress' - -export function RecentCourses() { - return ( - - - - Последние курсы - - - -
    - {mockCourses.map(course => ( -
    -
    -
    - {course.title} -
    -
    - {course.completedLessons}/ - {course.totalLessons} уроков -
    -
    - -
    - ))} -
    -
    -
    - ) -} diff --git a/src/components/account/progress/user-stats.tsx b/src/components/account/progress/user-stats.tsx index 5df051b..e67c50d 100644 --- a/src/components/account/progress/user-stats.tsx +++ b/src/components/account/progress/user-stats.tsx @@ -1,8 +1,10 @@ -import { BookOpen, Trophy } from 'lucide-react' -import { CircularProgressbar, buildStyles } from 'react-circular-progressbar' +import { useQuery } from '@tanstack/react-query' +import { BookOpen, Crown, Medal, Trophy } from 'lucide-react' +import React from 'react' +import { CircularProgressbar } from 'react-circular-progressbar' import 'react-circular-progressbar/dist/styles.css' -import { mockUserData } from './mock' +import { getMeStatistics } from '@/src/api' import { Card, CardContent, @@ -11,7 +13,17 @@ import { } from '@/src/components/ui/card' export function UserStats() { - return ( + const { data, isLoading } = useQuery({ + queryKey: ['get me statistics'], + queryFn: () => getMeStatistics() + }) + + return isLoading ? ( +
    + {/* */} + {/* */} +
    + ) : (
    @@ -24,37 +36,12 @@ export function UserStats() {
    - {mockUserData.points} + {data?.totalPoints}
    Всего очков
    -
    - -
    -
    -
    - Ваш рейтинг:{' '} - {mockUserData.rank} из {mockUserData.totalUsers}{' '} - пользователей
    @@ -70,8 +57,7 @@ export function UserStats() {
    - {mockUserData.completedLessons}/ - {mockUserData.totalLessons} + {data?.lessonsCompleted}
    Пройдено уроков @@ -79,8 +65,8 @@ export function UserStats() {
    -
    - Завершено курсов:{' '} - {mockUserData.completedCourses} -
    - В процессе:{' '} - {mockUserData.inProgressCourses} -
    diff --git a/src/components/account/settings/avatar-form.tsx b/src/components/account/settings/avatar-form.tsx index 5f8f18a..a1fa3b2 100644 --- a/src/components/account/settings/avatar-form.tsx +++ b/src/components/account/settings/avatar-form.tsx @@ -15,7 +15,7 @@ interface AvatarFormProps { export function AvatarForm({ user }: AvatarFormProps) { const [preview, setPreview] = useState( - user?.avatar ? getMediaSource(`/avatars/${user.avatar}`) : null + user?.avatar ? getMediaSource(user.avatar, 'users') : null ) const { mutate } = useMutation({ @@ -36,7 +36,11 @@ export function AvatarForm({ user }: AvatarFormProps) { const formData = new FormData() formData.append('file', file) + console.log('File added to FormData:', formData.get('file')) + mutate(formData) + } else { + toast.error('Пожалуйста, выберите файл') } } diff --git a/src/components/account/settings/email-form.tsx b/src/components/account/settings/email-form.tsx index f4766bf..fe9227e 100644 --- a/src/components/account/settings/email-form.tsx +++ b/src/components/account/settings/email-form.tsx @@ -31,7 +31,10 @@ import { changeEmail } from '@/src/api' import type { AccountResponse } from '@/src/generated' const emailSchema = z.object({ - email: z.string().min(6).max(128) + email: z + .string() + .min(1, { message: 'Email обязателен' }) + .email({ message: 'Введите корректный адрес электронной почты' }) }) export type Email = z.infer @@ -49,7 +52,7 @@ export function EmailForm({ user }: EmailFormProps) { mutationKey: ['change email'], mutationFn: (data: Email) => changeEmail(data), onSuccess() { - queryClient.invalidateQueries({ queryKey: ['profile'] }) + queryClient.invalidateQueries({ queryKey: ['get current'] }) setIsOpen(false) }, onError(error) { @@ -71,7 +74,7 @@ export function EmailForm({ user }: EmailFormProps) { return (
    -
    +
    diff --git a/src/components/account/settings/enable-totp-form.tsx b/src/components/account/settings/enable-totp-form.tsx index 67d00e4..13ab56b 100644 --- a/src/components/account/settings/enable-totp-form.tsx +++ b/src/components/account/settings/enable-totp-form.tsx @@ -1,8 +1,8 @@ 'use client' import { zodResolver } from '@hookform/resolvers/zod' -import { useMutation, useQuery } from '@tanstack/react-query' -import { Check, Copy, Loader, Loader2, TriangleAlert } from 'lucide-react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { Check, Copy, Loader2, TriangleAlert } from 'lucide-react' import { useEffect, useState } from 'react' import { useForm } from 'react-hook-form' import { toast } from 'sonner' @@ -51,6 +51,8 @@ export function EnableTotpForm() { const [isCopied, setIsCopied] = useState(false) + const queryClient = useQueryClient() + const { mutate, data: totp, @@ -75,6 +77,7 @@ export function EnableTotpForm() { mutationFn: (data: EnableTotp) => totpEnable(data), onSuccess() { refetch() + queryClient.invalidateQueries({ queryKey: ['mfa status'] }) setStep(2) }, onError(error) { diff --git a/src/components/account/settings/password-form.tsx b/src/components/account/settings/password-form.tsx index d5207b7..e25e969 100644 --- a/src/components/account/settings/password-form.tsx +++ b/src/components/account/settings/password-form.tsx @@ -30,8 +30,20 @@ import { Input } from '../../ui/input' import { changePassword } from '@/src/api' const passwordSchema = z.object({ - currentPassword: z.string().min(6).max(128), - newPassword: z.string().min(6).max(128) + currentPassword: z + .string() + .min(6, { + message: 'Текущий пароль должен содержать хотя бы 6 символов' + }) + .max(128, { + message: 'Текущий пароль должен содержать не более 128 символов' + }), + newPassword: z + .string() + .min(6, { message: 'Новый пароль должен содержать хотя бы 6 символов' }) + .max(128, { + message: 'Новый пароль должен содержать не более 128 символов' + }) }) export type Password = z.infer @@ -69,7 +81,7 @@ export function PasswordForm() { return (
    -
    +
    diff --git a/src/components/account/settings/two-step-auth-form.tsx b/src/components/account/settings/two-step-auth-form.tsx index 62b9a6e..c79d1df 100644 --- a/src/components/account/settings/two-step-auth-form.tsx +++ b/src/components/account/settings/two-step-auth-form.tsx @@ -21,9 +21,9 @@ export function TwoStepAuthForm({ status }: TwoFactorAuthFormProps) {
    -
    +
    -
    +
    @@ -60,7 +60,7 @@ export function TwoStepAuthForm({ status }: TwoFactorAuthFormProps) { {status?.recoveryActive && (
    -
    +
    diff --git a/src/components/auth/login-form.tsx b/src/components/auth/login-form.tsx index 9a04237..579e838 100644 --- a/src/components/auth/login-form.tsx +++ b/src/components/auth/login-form.tsx @@ -57,9 +57,7 @@ export function LoginForm() { if ('token' in data && typeof data.token === 'string') { setSessionToken(data.token) - instance.headers = { - 'X-Session-Token': data.token - } + instance.defaults.headers['X-Session-Token'] = data.token push('/account/settings') } diff --git a/src/components/auth/mfa-form.tsx b/src/components/auth/mfa-form.tsx index 03f0a8e..31581ed 100644 --- a/src/components/auth/mfa-form.tsx +++ b/src/components/auth/mfa-form.tsx @@ -75,14 +75,12 @@ export function MfaForm({ ticket, methods }: MfaFormProps) { payload ) - return response + return response.data }, onSuccess(data) { setSessionToken(data.token) - instance.headers = { - 'X-Session-Token': data.token - } + instance.defaults.headers['X-Session-Token'] = data.token push('/account/settings') }, diff --git a/src/components/course/course-card.tsx b/src/components/course/course-card.tsx new file mode 100644 index 0000000..da64751 --- /dev/null +++ b/src/components/course/course-card.tsx @@ -0,0 +1,62 @@ +import { BookOpen } from 'lucide-react' +import Image from 'next/image' +import Link from 'next/link' +import { FaYoutube } from 'react-icons/fa' + +import { Badge } from '../ui/badge' + +import type { CourseResponse } from '@/src/generated' +import { getMediaSource } from '@/src/lib/utils' + +interface CourseCardProps { + course: CourseResponse +} + +export function CourseCard({ course }: CourseCardProps) { + return ( + +
    + {course.title} +
    +
    +

    + {course.title} +

    +

    + {course.description} +

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

    + Добавлен +

    +

    {formattedDate}

    +
    + +
    +

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

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

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

    + ) : ( +

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

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

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

    +

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

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

    Уроки

    + {isAuthorized && ( +

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

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

      + {lesson.title} +

      + {lesson.description && ( +

      + {lesson.description} +

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

    О курсе

    +

    {course.description}

    +
    + {/*
    +

    What you'll learn

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

    + {course.title} +

    + +

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

    + +
    + {lessons.length > 0 && ( + + )} + + {course.youtubeUrl && ( + + )} + + {course.attachment && ( + + )} +
    +
    + +
    + {course.title} +
    +
    +
    + ) +} diff --git a/src/components/course/index.tsx b/src/components/course/index.tsx deleted file mode 100644 index e405ff3..0000000 --- a/src/components/course/index.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import Image from 'next/image' - -import { Button } from '../ui/button' - -import { CourseResponse } from '@/src/generated' -import { getMediaSource } from '@/src/lib/utils' - -interface CourseProps { - course: CourseResponse -} - -export function Course({ course }: CourseProps) { - return ( -
    -
    -
    -

    {course.title}

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

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

    +

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

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

    + {reason.title} +

    +

    + {reason.description} +

    +
    +
    + ))} +
    +
    + ) +} diff --git a/src/components/home/hero.tsx b/src/components/home/hero.tsx index 59d0be8..1ee6819 100644 --- a/src/components/home/hero.tsx +++ b/src/components/home/hero.tsx @@ -5,17 +5,16 @@ import { Button } from '../ui/button' export function Hero() { return ( -
    +

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

    -

    - Lorem ipsum dolor sit amet consectetur adipisicing elit. - Dignissimos pariatur, similique distinctio illo eos quia - nostrum, veritatis ratione saepe numquam voluptate! - Voluptates magnam velit ut. +

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

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

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

    +

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

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

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

    + +

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

    + + +
    + ) +} diff --git a/src/components/layout/footer.tsx b/src/components/layout/footer.tsx index 4957921..4f4fbf2 100644 --- a/src/components/layout/footer.tsx +++ b/src/components/layout/footer.tsx @@ -22,14 +22,14 @@ const generalLinks: NavLink[] = [ { title: 'Об основателе', href: '/about' }, { title: 'API документация', - href: 'https://docs.teacoder.ru', + href: 'https://api.teacoder.ru/docs', isExternal: true } ] const documentsLinks: NavLink[] = [ - { title: 'Пользовательское соглашение', href: '/docs/agreement' }, - { title: 'Политика конфиденциальности', href: '/docs/privacy' } + { title: 'Пользовательское соглашение', href: '/document/terms-of-use' }, + { title: 'Политика конфиденциальности', href: '/document/privacy-policy' } ] const socialLinks: SocialLink[] = [ @@ -61,7 +61,7 @@ export function Footer() {
    -

    TeaCoder

    +

    TeaCoder

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

    @@ -69,17 +69,17 @@ export function Footer() {

    Общие ссылки

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

    Информация

    +

    Документы

      - {documentsLinks.map(link => ( - + {documentsLinks.map((link, index) => ( + ))}
    @@ -87,8 +87,8 @@ export function Footer() {

    Соц. сети

    - {socialLinks.map(link => ( - + {socialLinks.map((link, index) => ( + ))}
    diff --git a/src/components/layout/header.tsx b/src/components/layout/header.tsx index b5acc1d..63b341c 100644 --- a/src/components/layout/header.tsx +++ b/src/components/layout/header.tsx @@ -5,47 +5,17 @@ import Link from 'next/link' import { Logo } from '../shared/logo' import { Button } from '../ui/button' +import { MobileNav } from './mobile-nav' import { NavLinks } from './nav-links' -import { ProfileMenu } from './profile-menu' +import { UserMenu } from './user-menu' import { useAuth } from '@/src/hooks' export function Header() { const { isAuthorized } = useAuth() return ( -
    - {/*
    -
    -
    - - - TeaCoder - - - -
    -
    -
    */} -
    +
    +
    -
    +
    {isAuthorized ? ( - + ) : ( -
    - - - - - + - + +
    )}
    +
    diff --git a/src/components/layout/mobile-nav.tsx b/src/components/layout/mobile-nav.tsx new file mode 100644 index 0000000..881463b --- /dev/null +++ b/src/components/layout/mobile-nav.tsx @@ -0,0 +1,135 @@ +import { useMutation } from '@tanstack/react-query' +import { ChartArea, LogOut, Menu, Settings } from 'lucide-react' +import Link from 'next/link' +import { useRouter } from 'next/navigation' +import { useState } from 'react' +import { toast } from 'sonner' + +import { Button } from '../ui/button' +import { Separator } from '../ui/separator' +import { + Sheet, + SheetContent, + SheetDescription, + SheetTitle, + SheetTrigger +} from '../ui/sheet' + +import { navLinks } from './nav-links' +import { logout } from '@/src/api' +import { useAuth } from '@/src/hooks' + +export function MobileNav() { + const [isOpen, setIsOpen] = useState(false) + + const router = useRouter() + + const { isAuthorized } = useAuth() + + const { mutate } = useMutation({ + mutationKey: ['logout'], + mutationFn: () => logout(), + onSuccess() { + setIsOpen(false) + router.push('/auth/login') + }, + onError(error) { + toast.error(error.message ?? 'Ошибка при выходе') + } + }) + + return ( + + + + + +
    + TeaCoder + + Образовательная платформа по веб разработке + +
    +
    + {navLinks.map((link, index) => ( + + ))} + + {isAuthorized ? ( + <> + + + + + ) : ( + <> + + + + )} +
    +
    +
    + ) +} diff --git a/src/components/layout/nav-links.tsx b/src/components/layout/nav-links.tsx index 160c4c0..6cc9a89 100644 --- a/src/components/layout/nav-links.tsx +++ b/src/components/layout/nav-links.tsx @@ -1,21 +1,24 @@ import Link from 'next/link' -const navLinks = [ - { href: '/courses', label: 'Курсы' }, - { href: '/about', label: 'Об основателе' } +import { Button } from '../ui/button' + +interface NavLink { + title: string + href: string +} + +export const navLinks: NavLink[] = [ + { title: 'Курсы', href: '/courses' }, + { title: 'Об основателе', href: '/about' } ] export function NavLinks() { return ( -