diff --git a/app/(auth)/forgot-password/page.tsx b/app/(auth)/forgot-password/page.tsx new file mode 100644 index 0000000..f118efc --- /dev/null +++ b/app/(auth)/forgot-password/page.tsx @@ -0,0 +1 @@ +export { ForgotPasswordPage as default } from 'pages/forgot-password'; diff --git a/eslint.config.mjs b/eslint.config.mjs index ffb6887..5b30e07 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -31,9 +31,10 @@ const eslintConfig = defineConfig([ 'PASCAL_CASE', '**/*{Error,Type,Types,Interface,Props,Dto,Response,Request,Contract,Contracts}.ts': 'PASCAL_CASE', - '**/!(*{Error,Type,Types,Interface,Props,Dto,Response,Request,Contract,Contracts}*).ts': + '**/!(*{Error,Type,Types,Interface,Props,Dto,Response,Request,Contract,Contracts,use}*).ts': 'KEBAB_CASE', '**/*.{js,mjs,cjs,mts,cts}': 'KEBAB_CASE', + '**/use*.{ts,tsx}': 'CAMEL_CASE', }, { ignoreMiddleExtensions: true, diff --git a/infra/dev/compose.dev.yaml b/infra/dev/compose.dev.yaml index 3a53172..9f28a58 100644 --- a/infra/dev/compose.dev.yaml +++ b/infra/dev/compose.dev.yaml @@ -31,7 +31,7 @@ services: hostname: api container_name: api platform: linux/amd64 - image: ghcr.io/task-tracker-lab/backend:sha-9b66ff7 + image: ghcr.io/task-tracker-lab/backend:dev env_file: - .env ports: diff --git a/src/entities/auth/api/http.ts b/src/entities/auth/api/http.ts new file mode 100644 index 0000000..cbf2c97 --- /dev/null +++ b/src/entities/auth/api/http.ts @@ -0,0 +1,89 @@ +import { api } from 'shared/api'; +import * as SAuth from '../model/schemas'; +import * as TAuth from '../model/types'; + +export class AuthHttp { + static signin(data: TAuth.SigninBody) { + return api({ + url: '/auth/sign-in', + method: 'POST', + data: data, + skipAuthRefresh: true, + contracts: { + body: SAuth.SigninBody, + response: SAuth.SigninResponse, + }, + }); + } + + static signout() { + return api({ + url: '/auth/sign-out', + method: 'POST', + contracts: { + response: SAuth.SignoutResponse, + }, + }); + } + + static signup(data: TAuth.SignupBody) { + return api({ + url: '/auth/sign-up', + method: 'POST', + data: data, + contracts: { + body: SAuth.SignupBody, + response: SAuth.SignupResponse, + }, + }); + } + + static signupConfirm(data: TAuth.SignupConfirmBody) { + return api({ + url: '/auth/sign-up/confirm', + method: 'POST', + data: data, + skipAuthRefresh: true, + contracts: { + body: SAuth.SignupConfirmBody, + response: SAuth.SignupConfirmResponse, + }, + }); + } + + static resetPassword(data: TAuth.ResetPasswordBody) { + return api({ + url: '/auth/password/reset', + method: 'POST', + data: data, + contracts: { + body: SAuth.ResetPasswordBody, + response: SAuth.ResetPasswordResponse, + }, + }); + } + + static resetPasswordVerify(data: TAuth.ResetPasswordVerifyBody) { + return api({ + url: '/auth/password/reset/verify', + method: 'POST', + data: data, + contracts: { + body: SAuth.ResetPasswordVerifyBody, + response: SAuth.ResetPasswordVerifyResponse, + }, + }); + } + + static resetPasswordConfirm(data: TAuth.ResetPasswordConfirmBody) { + return api({ + url: '/auth/password/reset/confirm', + method: 'POST', + data: data, + contracts: { + body: SAuth.ResetPasswordConfirmBody, + response: SAuth.ResetPasswordConfirmResponse, + }, + }); + } +} diff --git a/src/entities/auth/index.ts b/src/entities/auth/index.ts new file mode 100644 index 0000000..8b6d089 --- /dev/null +++ b/src/entities/auth/index.ts @@ -0,0 +1,3 @@ +export * as SAuth from './model/schemas'; +export * as TAuth from './model/types'; +export { AuthHttp } from './api/http'; diff --git a/src/entities/auth/model/schemas.ts b/src/entities/auth/model/schemas.ts new file mode 100644 index 0000000..3389e21 --- /dev/null +++ b/src/entities/auth/model/schemas.ts @@ -0,0 +1,67 @@ +import { z } from 'zod/v4'; +import { GlobalSuccess } from 'shared/api'; + +const MIN_PASS_LENGTH = 8; +const MAX_PASS_LENGTH = 32; +const OTP_LENGTH = 6; + +export const Email = z.string().min(1, 'Обязательное поле').check(z.email('Неверный формат email')); + +export const Password = z + .string() + .min(1, 'Обязательное поле') + .min(MIN_PASS_LENGTH, `Минимум ${MIN_PASS_LENGTH} символов`) + .max(MAX_PASS_LENGTH, 'Слишком длинный пароль'); + +export const OTPCode = z.string().min(OTP_LENGTH, 'Обязательное поле').max(OTP_LENGTH); + +export const SigninBody = z.object({ + email: Email, + password: Password, +}); + +export const SigninResponse = GlobalSuccess.extend({ + token: z.string(), +}); + +export const SignoutResponse = GlobalSuccess; + +export const SignupBody = z.object({ + email: Email, + password: Password, + firstName: z.string().min(2, 'Имя должно содержать минимум 2 символа').max(50).trim(), + lastName: z.string().min(2, 'Фамилия должна содержать минимум 2 символа').max(50).trim(), + middleName: z.string().max(50).trim().optional().or(z.literal('')), +}); + +export const SignupResponse = GlobalSuccess; + +export const SignupConfirmBody = z.object({ + email: Email, + code: OTPCode, +}); + +export const SignupConfirmResponse = GlobalSuccess.extend({ + token: z.string(), +}); + +export const ResetPasswordBody = z.object({ + email: Email, +}); + +export const ResetPasswordResponse = GlobalSuccess; + +export const ResetPasswordVerifyBody = z.object({ + email: Email, + code: OTPCode, +}); + +export const ResetPasswordVerifyResponse = GlobalSuccess; + +export const ResetPasswordConfirmBody = z.object({ + email: Email, + password: Password, + confirmPassword: Password, +}); + +export const ResetPasswordConfirmResponse = GlobalSuccess; diff --git a/src/entities/auth/model/types.ts b/src/entities/auth/model/types.ts new file mode 100644 index 0000000..c83f166 --- /dev/null +++ b/src/entities/auth/model/types.ts @@ -0,0 +1,22 @@ +import { z } from 'zod/v4'; +import * as SAuth from './schemas'; + +export type Email = z.infer; +export type Password = z.infer; +export type OTPCode = z.infer; + +export type SigninBody = z.infer; +export type SigninResponse = z.infer; +export type SignoutResponse = z.infer; + +export type SignupBody = z.infer; +export type SignupResponse = z.infer; +export type SignupConfirmBody = z.infer; +export type SignupConfirmResponse = z.infer; + +export type ResetPasswordBody = z.infer; +export type ResetPasswordResponse = z.infer; +export type ResetPasswordVerifyBody = z.infer; +export type ResetPasswordVerifyResponse = z.infer; +export type ResetPasswordConfirmBody = z.infer; +export type ResetPasswordConfirmResponse = z.infer; diff --git a/src/entities/user/api/http.ts b/src/entities/user/api/http.ts new file mode 100644 index 0000000..30960bd --- /dev/null +++ b/src/entities/user/api/http.ts @@ -0,0 +1,68 @@ +import { api } from 'shared/api'; +import * as TUser from '../model/types'; +import * as SUser from '../model/schemas'; + +export class UserHttp { + static getUser(signal?: AbortSignal) { + return api({ + url: '/users/me', + method: 'GET', + contracts: { + response: SUser.UserResponse, + }, + signal, + }); + } + + //todo ручка пока в разработке + static getUserActivity(signal?: AbortSignal) { + return api({ + url: '/users/me/activity', + method: 'GET', + contracts: {}, + signal, + }); + } + + static updateAvatar(file: File) { + const formData = new FormData(); + + formData.append('file', file); + + return api({ + url: '/users/me/avatar', + method: 'POST', + data: formData, + headers: { + 'Content-Type': 'multipart/form-data', + }, + contracts: { + response: SUser.AvatarUpdateResponse, + }, + }); + } + + static updateNotificationsConfig(data: TUser.NotificationsUpdateBody) { + return api({ + url: '/users/me/notifications', + method: 'PATCH', + data, + contracts: { + body: SUser.NotificationsUpdateBody, + response: SUser.NotificationsUpdateResponse, + }, + }); + } + + static updateUserConfig(data: TUser.ProfileUpdateBody) { + return api({ + url: '/users/me', + method: 'PATCH', + data, + contracts: { + body: SUser.ProfileUpdateBody, + response: SUser.ProfileUpdateResponse, + }, + }); + } +} diff --git a/src/entities/user/api/queries.ts b/src/entities/user/api/queries.ts new file mode 100644 index 0000000..d6d2b56 --- /dev/null +++ b/src/entities/user/api/queries.ts @@ -0,0 +1,23 @@ +import { userFabricKeys } from '../model/const'; +import { queryOptions } from '@tanstack/react-query'; +import { UserHttp } from './http'; + +export class UserQueries { + static getMe() { + return queryOptions({ + queryKey: userFabricKeys.me(), + queryFn: async ({ signal }) => UserHttp.getUser(signal), + staleTime: 60_000, + refetchOnMount: false, + }); + } + + static getMeActivity() { + return queryOptions({ + queryKey: userFabricKeys.meActivity(), + queryFn: async ({ signal }) => UserHttp.getUserActivity(signal), + staleTime: 60_000, + refetchOnMount: false, + }); + } +} diff --git a/src/entities/user/index.ts b/src/entities/user/index.ts new file mode 100644 index 0000000..464f23d --- /dev/null +++ b/src/entities/user/index.ts @@ -0,0 +1,5 @@ +export * as SUser from './model/schemas'; +export * as TUser from './model/types'; +export { UserHttp } from './api/http'; +export { UserQueries } from './api/queries'; +export { userFabricKeys } from './model/const'; diff --git a/src/entities/user/model/const.ts b/src/entities/user/model/const.ts new file mode 100644 index 0000000..145fd66 --- /dev/null +++ b/src/entities/user/model/const.ts @@ -0,0 +1,6 @@ +import { createEntityKeys } from 'shared/lib/utils'; + +export const userFabricKeys = createEntityKeys('user', { + me: () => ['users', 'me'], + meActivity: () => ['users', 'me', 'activity'], +}); diff --git a/src/entities/user/model/schemas.ts b/src/entities/user/model/schemas.ts new file mode 100644 index 0000000..7847f74 --- /dev/null +++ b/src/entities/user/model/schemas.ts @@ -0,0 +1,64 @@ +import { z } from 'zod/v4'; +import { GlobalSuccess } from 'shared/api'; + +export const UserResponse = z.object({ + id: z.string(), + email: z.email(), + profile: z.object({ + firstName: z.string(), + lastName: z.string(), + middleName: z.string().nullable(), + bio: z.string().nullable(), + avatarUrl: z.url().nullable(), + timezone: z.string(), + language: z.string(), + createdAt: z.iso.datetime({}), + updatedAt: z.iso.datetime({}), + }), + security: z.object({ + is2faEnabled: z.boolean(), + lastPasswordChange: z.iso.datetime({}), + }), + notifications: z.object({ + email: z.object({ + task_assigned: z.boolean(), + mentions: z.boolean(), + daily_summary: z.boolean(), + }), + push: z.object({ + task_assigned: z.boolean(), + reminders: z.boolean(), + }), + }), +}); + +export const AvatarUpdateResponse = GlobalSuccess; + +export const NotificationsUpdateBody = z.object({ + email: z + .object({ + task_assigned: z.boolean(), + mentions: z.boolean(), + daily_summary: z.boolean(), + }) + .optional(), + push: z + .object({ + task_assigned: z.boolean(), + reminders: z.boolean(), + }) + .optional(), +}); + +export const NotificationsUpdateResponse = GlobalSuccess; + +export const ProfileUpdateBody = z.object({ + firstName: z.string().min(1).max(50).optional(), + lastName: z.string().min(1).max(50).optional(), + middleName: z.string().max(50).nullish(), + bio: z.string().max(512).nullish(), + timezone: z.string().max(50).optional(), + language: z.string().min(2).max(2).optional(), +}); + +export const ProfileUpdateResponse = GlobalSuccess; diff --git a/src/entities/user/model/types.ts b/src/entities/user/model/types.ts new file mode 100644 index 0000000..b4c7e5e --- /dev/null +++ b/src/entities/user/model/types.ts @@ -0,0 +1,9 @@ +import { z } from 'zod/v4'; +import * as SUser from './schemas'; + +export type UserResponse = z.infer; +export type AvatarUpdateResponse = z.infer; +export type NotificationsUpdateBody = z.infer; +export type NotificationsUpdateResponse = z.infer; +export type ProfileUpdateBody = z.infer; +export type ProfileUpdateResponse = z.infer; diff --git a/src/features/otp-form/index.ts b/src/features/otp-form/index.ts new file mode 100644 index 0000000..720cb3d --- /dev/null +++ b/src/features/otp-form/index.ts @@ -0,0 +1 @@ +export { OTPForm } from './ui/OTPForm'; diff --git a/src/features/otp-form/model/schemas.ts b/src/features/otp-form/model/schemas.ts new file mode 100644 index 0000000..f99818f --- /dev/null +++ b/src/features/otp-form/model/schemas.ts @@ -0,0 +1,11 @@ +import { z } from 'zod/v4'; +import { SAuth } from 'entities/auth'; + +export const OtpForm = z.object({ + code: SAuth.OTPCode, +}); + +export const otpFormBody = z.object({ + email: SAuth.Email, + code: SAuth.OTPCode, +}); diff --git a/src/features/otp-form/model/types.ts b/src/features/otp-form/model/types.ts new file mode 100644 index 0000000..7faf162 --- /dev/null +++ b/src/features/otp-form/model/types.ts @@ -0,0 +1,5 @@ +import { z } from 'zod/v4'; +import * as SOtpForm from './schemas'; + +export type OtpForm = z.infer; +export type FormBody = z.infer; diff --git a/src/pages/signup/ui/OTPForm.tsx b/src/features/otp-form/ui/OTPForm.tsx similarity index 62% rename from src/pages/signup/ui/OTPForm.tsx rename to src/features/otp-form/ui/OTPForm.tsx index fc1195f..d2794e1 100644 --- a/src/pages/signup/ui/OTPForm.tsx +++ b/src/features/otp-form/ui/OTPForm.tsx @@ -1,6 +1,5 @@ 'use client'; -import type { FieldPath } from 'react-hook-form'; import { Controller, useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { @@ -18,86 +17,53 @@ import { InputOTPSlot, Spinner, } from 'shared/ui'; -import { ConfirmFormSchema } from '../model/schemas/confirm-form-schema'; -import { cn } from 'shared/lib/utils'; -import { z } from 'zod'; -import { useMutation } from '@tanstack/react-query'; -import { isAxiosError } from 'axios'; -import { - GlobalErrorResponseType, - isAxiosValidationError, - signupConfirm, - SignupConfirmBody, - SignupConfirmResponse, -} from 'shared/api'; +import { OtpForm as OtpFormSchema } from '../model/schemas'; +import { cn, setFormErrors } from 'shared/lib/utils'; +import { DefaultError, UseMutationResult } from '@tanstack/react-query'; +import { extractValidationIssues } from 'shared/api'; import { REGEXP_ONLY_DIGITS } from 'input-otp'; import { ComponentProps } from 'react'; +import type { FormBody, OtpForm } from '../model/types'; -type FSchema = z.infer; -type BSchema = z.infer; -type RSchema = z.infer; - -interface OTPFormProps extends Omit, 'children'> { +interface OTPFormProps extends Omit, 'children'> { email: string; - onSuccess?: (body: BSchema, res: RSchema) => void; + onSuccess?: (body: FormBody, res: TData) => void; autoFocusCode?: boolean; + query: UseMutationResult; } -export function OTPForm({ +export function OTPForm({ className, email, onSuccess, autoFocusCode = false, + query, ...props -}: OTPFormProps) { - const sendConfirm = useMutation({ - mutationFn: (data: BSchema) => { - return signupConfirm(data); - }, - meta: { - skipGlobalValidationToast: true, - }, - }); - - const form = useForm({ - resolver: zodResolver(ConfirmFormSchema), +}: OTPFormProps) { + const form = useForm({ + resolver: zodResolver(OtpFormSchema), defaultValues: { code: '', }, }); - function setFormErrors

(errors: { message: string; path: P[] }[]) { - errors.forEach(({ message, path: [path] }) => { - const typedPath = path as FieldPath; - - form.setError(typedPath, { message }); - }); - } - - const onSubmit = (data: FSchema) => { - const body: BSchema = { + const onSubmit = (data: OtpForm) => { + const body: FormBody = { code: data.code, - email: email, + email, }; - sendConfirm.mutate(body, { + query.mutate(body, { onSuccess: (res) => { onSuccess?.(body, res); }, onError: (err) => { - //ошибка валидации локальная - if (isAxiosValidationError(err)) { - setFormErrors(err?.issues ?? []); - } - //ошибка валидации серверная - if (isAxiosError(err)) { - setFormErrors(err?.response?.data?.details ?? []); - } + setFormErrors(extractValidationIssues(err), form); }, }); }; - const disabled = sendConfirm.isPending || sendConfirm.isSuccess; + const disabled = query.isPending || query.isSuccess; return ( diff --git a/src/pages/forgot-password/index.ts b/src/pages/forgot-password/index.ts new file mode 100644 index 0000000..ab740fe --- /dev/null +++ b/src/pages/forgot-password/index.ts @@ -0,0 +1 @@ +export { ForgotPasswordPage } from './ui/ForgotPasswordPage'; diff --git a/src/pages/forgot-password/model/schemas.ts b/src/pages/forgot-password/model/schemas.ts new file mode 100644 index 0000000..b1e65a0 --- /dev/null +++ b/src/pages/forgot-password/model/schemas.ts @@ -0,0 +1,16 @@ +import { z } from 'zod/v4'; +import { SAuth } from 'entities/auth'; + +export const EmailForm = z.object({ + email: SAuth.Email, +}); + +export const PasswordForm = z + .object({ + password: SAuth.Password, + confirmPassword: SAuth.Password, + }) + .refine((data) => data.password === data.confirmPassword, { + message: 'Пароли не совпадают', + path: ['confirmPassword'], + }); diff --git a/src/pages/forgot-password/model/types.ts b/src/pages/forgot-password/model/types.ts new file mode 100644 index 0000000..52f3638 --- /dev/null +++ b/src/pages/forgot-password/model/types.ts @@ -0,0 +1,5 @@ +import { z } from 'zod/v4'; +import * as SForgotPassword from './schemas'; + +export type EmailFormValues = z.infer; +export type PasswordFormValues = z.infer; diff --git a/src/pages/forgot-password/model/useResetPassword.ts b/src/pages/forgot-password/model/useResetPassword.ts new file mode 100644 index 0000000..91c0388 --- /dev/null +++ b/src/pages/forgot-password/model/useResetPassword.ts @@ -0,0 +1,23 @@ +import { type DefaultError, useMutation } from '@tanstack/react-query'; +import { AuthHttp, TAuth } from 'entities/auth'; + +interface ResetPasswordProps { + onSuccess?: (body: TAuth.ResetPasswordBody, res: TAuth.ResetPasswordResponse) => void; + onError?: (err: Error) => void; +} + +export function useResetPassword({ onSuccess, onError }: ResetPasswordProps = {}) { + return useMutation, DefaultError, TAuth.ResetPasswordBody>({ + mutationKey: [], + mutationFn: AuthHttp.resetPassword, + meta: { + skipGlobalValidationToast: true, + }, + onError: (err) => { + onError?.(err); + }, + onSuccess: (res, body) => { + onSuccess?.(body, res); + }, + }); +} diff --git a/src/pages/forgot-password/model/useSendCode.ts b/src/pages/forgot-password/model/useSendCode.ts new file mode 100644 index 0000000..39f5611 --- /dev/null +++ b/src/pages/forgot-password/model/useSendCode.ts @@ -0,0 +1,16 @@ +import { type DefaultError, useMutation } from '@tanstack/react-query'; +import { AuthHttp, TAuth } from 'entities/auth'; + +export function useSendCode() { + return useMutation< + Awaited, + DefaultError, + TAuth.ResetPasswordVerifyBody + >({ + mutationKey: [], + mutationFn: AuthHttp.resetPasswordVerify, + meta: { + skipGlobalValidationToast: true, + }, + }); +} diff --git a/src/pages/forgot-password/model/useSendPassword.ts b/src/pages/forgot-password/model/useSendPassword.ts new file mode 100644 index 0000000..07823b5 --- /dev/null +++ b/src/pages/forgot-password/model/useSendPassword.ts @@ -0,0 +1,30 @@ +import { type DefaultError, useMutation } from '@tanstack/react-query'; +import { AuthHttp, TAuth } from 'entities/auth'; + +interface SendPasswordProps { + onSuccess?: ( + body: TAuth.ResetPasswordConfirmBody, + res: TAuth.ResetPasswordConfirmResponse + ) => void; + onError?: (err: Error) => void; +} + +export function useSendPassword({ onSuccess, onError }: SendPasswordProps = {}) { + return useMutation< + Awaited, + DefaultError, + TAuth.ResetPasswordConfirmBody + >({ + mutationKey: [], + mutationFn: AuthHttp.resetPasswordConfirm, + meta: { + skipGlobalValidationToast: true, + }, + onError: (err) => { + onError?.(err); + }, + onSuccess: (res, body) => { + onSuccess?.(body, res); + }, + }); +} diff --git a/src/pages/forgot-password/ui/EmailForm.tsx b/src/pages/forgot-password/ui/EmailForm.tsx new file mode 100644 index 0000000..718c078 --- /dev/null +++ b/src/pages/forgot-password/ui/EmailForm.tsx @@ -0,0 +1,91 @@ +'use client'; + +import { + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + Field, + FieldError, + FieldGroup, + FieldLabel, + InputEmail, +} from 'shared/ui'; +import { TAuth } from 'entities/auth'; +import { Controller, useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import type { EmailFormValues } from '../model/types'; +import { EmailForm as EmailFormSchema } from '../model/schemas'; +import { ComponentProps } from 'react'; +import { useResetPassword } from '../model/useResetPassword'; +import { setFormErrors } from 'shared/lib/utils'; +import { extractValidationIssues } from 'shared/api'; + +interface EmailFormProps extends Omit, 'children' | 'onSubmit'> { + onSuccess?: (body: TAuth.ResetPasswordBody, res: TAuth.ResetPasswordResponse) => void; +} + +function EmailForm({ onSuccess, ...props }: EmailFormProps) { + const form = useForm({ + resolver: zodResolver(EmailFormSchema), + defaultValues: { + email: '', + }, + }); + + const resetPassword = useResetPassword({ + onSuccess, + onError: (err) => setFormErrors(extractValidationIssues(err), form), + }); + + const onSubmit = (data: EmailFormValues) => { + const body: TAuth.ResetPasswordBody = { + email: data.email, + }; + + resetPassword.mutate(body); + }; + + const disabled = resetPassword.isPending || resetPassword.isSuccess; + + return ( + + + Забыли пароль? + Введите email, чтобы получить код для восстановления. + + +

+ + ( + + Email + + {fieldState.invalid && } + + )} + /> + + + + +
+ + + ); +} + +export { EmailForm }; diff --git a/src/pages/forgot-password/ui/ForgotPasswordPage.tsx b/src/pages/forgot-password/ui/ForgotPasswordPage.tsx new file mode 100644 index 0000000..055322f --- /dev/null +++ b/src/pages/forgot-password/ui/ForgotPasswordPage.tsx @@ -0,0 +1,82 @@ +'use client'; + +import { FieldDescription, Link, Logo, Spinner } from 'shared/ui'; +import { routes } from 'shared/config'; +import { EmailForm } from './EmailForm'; +import { PasswordForm } from './PasswordForm'; +import { useRouter } from 'next/navigation'; +import { toast } from 'sonner'; +import { OTPForm } from 'features/otp-form'; +import { useSendCode } from '../model/useSendCode'; +import { useLocalStorageDraft } from 'shared/lib/hooks'; + +type ForgotPasswordStep = 'email' | 'password' | 'otp' | null; + +interface ForgotPasswordDraft extends Record { + email: string; + step: ForgotPasswordStep; +} + +const DRAFT_KEY = 'drafted-forgot-password'; +const DRAFT_TTL_MS = 15 * 60 * 1000; + +function ForgotPasswordPage() { + const router = useRouter(); + const sendCode = useSendCode(); + const { draft, setDraft, clearDraft } = useLocalStorageDraft(DRAFT_KEY, { + defaultTTLms: DRAFT_TTL_MS, + defaultValues: { email: '', step: 'email' }, + }); + + const email = draft?.email ?? ''; + const step: ForgotPasswordStep = draft?.step ?? null; + + if (!step) { + return ( +
+
+ +
+
+ ); + } + + return ( +
+
+ + + + {step === 'email' ? ( + setDraft({ email, step: 'otp' })} /> + ) : null} + {step === 'otp' && ( + setDraft({ email, step: 'password' })} + /> + )} + {step === 'password' && ( + { + clearDraft(); + router.replace(routes.auth.signin()); + toast.success(res.message); + }} + /> + )} + + Вспомнили пароль?{' '} + + Войти + + +
+
+ ); +} + +export { ForgotPasswordPage }; diff --git a/src/pages/forgot-password/ui/PasswordForm.tsx b/src/pages/forgot-password/ui/PasswordForm.tsx new file mode 100644 index 0000000..9767347 --- /dev/null +++ b/src/pages/forgot-password/ui/PasswordForm.tsx @@ -0,0 +1,128 @@ +'use client'; + +import { + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + Field, + FieldError, + FieldGroup, + FieldLabel, + InputPassword, +} from 'shared/ui'; +import { ComponentProps, useState } from 'react'; +import { TAuth } from 'entities/auth'; +import { Controller, useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { setFormErrors } from 'shared/lib/utils'; +import { extractValidationIssues } from 'shared/api'; +import { PasswordForm as PasswordFormSchema } from '../model/schemas'; +import type { PasswordFormValues } from '../model/types'; +import { useSendPassword } from '../model/useSendPassword'; + +interface PasswordFormProps extends Omit, 'children' | 'onSubmit'> { + onSuccess?: ( + body: TAuth.ResetPasswordConfirmBody, + res: TAuth.ResetPasswordConfirmResponse + ) => void; + email: string; +} + +function PasswordForm({ onSuccess, email, ...props }: PasswordFormProps) { + const [showPassword, setShowPassword] = useState(false); + + const sendPassword = useSendPassword({ + onSuccess, + onError: (err) => setFormErrors(extractValidationIssues(err), form), + }); + + const form = useForm({ + resolver: zodResolver(PasswordFormSchema), + defaultValues: { + password: '', + confirmPassword: '', + }, + }); + + const onSubmit = (data: PasswordFormValues) => { + const body: TAuth.ResetPasswordConfirmBody = { + email, + password: data.password, + confirmPassword: data.confirmPassword, + }; + + sendPassword.mutate(body); + }; + + const disabled = sendPassword.isPending || sendPassword.isSuccess; + + return ( + + + Восстановление пароля + Введите новый пароль для вашего аккаунта. + + +
+ + ( + + Пароль + { + field.onChange(event); + if (form.getFieldState('confirmPassword').isTouched) { + void form.trigger('confirmPassword'); + } + }} + id="password" + aria-invalid={fieldState.invalid} + visible={showPassword} + onVisibleChange={setShowPassword} + autoComplete="new-password" + disabled={disabled} + /> + {fieldState.invalid && } + + )} + /> + ( + + Повторите пароль + + {fieldState.invalid && } + + )} + /> + + + + +
+
+
+ ); +} + +export { PasswordForm }; diff --git a/src/pages/main/ui/MainPage.tsx b/src/pages/main/ui/MainPage.tsx index 7b1ab0f..fe03cd2 100644 --- a/src/pages/main/ui/MainPage.tsx +++ b/src/pages/main/ui/MainPage.tsx @@ -1,4 +1,4 @@ -import { Link } from 'shared/ui'; +import { Button, Link, Logo, Separator } from 'shared/ui'; import { routes } from 'shared/config'; interface MainPageProps { @@ -7,17 +7,77 @@ interface MainPageProps { function MainPage({ className }: MainPageProps) { return ( -
-

main page

- Signup -
- Signin -
- team -
- profile -
-
+
+
+
+
+
+
+
+ +
+
+
+
+ + Open Source Task Tracker +
+ +
+ + +
+
+ +
+

+ Планируйте спринты и держите фокус команды в одном рабочем пространстве +

+

+ Один трекер для продукта, разработки и QA. Все статусы и приоритеты прозрачны в + реальном времени. +

+
+
+ + + +
+
+

+ С чего начать +

+

+ Три шага до первого спринта +

+
+ + +
+
+ +
    +
  1. +

    01. Создайте проект

    +
  2. +
  3. +

    02. Добавьте команду

    +
  4. +
  5. +

    03. Запустите спринт

    +
  6. +
+
+
+
); } diff --git a/src/pages/profile/model/queries/use-current-user-activity.ts b/src/pages/profile/model/queries/use-current-user-activity.ts deleted file mode 100644 index 3cd2f2e..0000000 --- a/src/pages/profile/model/queries/use-current-user-activity.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { getUserActivity } from '../services/get-user-activity'; - -export const currentUserActivityQueryKey = ['users/me/activity'] as const; - -export function useCurrentUserActivity() { - return useQuery({ - queryKey: currentUserActivityQueryKey, - queryFn: ({ signal }) => getUserActivity(signal), - staleTime: 60_000, - refetchOnMount: false, - }); -} diff --git a/src/pages/profile/model/queries/use-current-user.ts b/src/pages/profile/model/queries/use-current-user.ts deleted file mode 100644 index 4ad00b8..0000000 --- a/src/pages/profile/model/queries/use-current-user.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { getUser } from '../services/get-user'; - -export const currentUserQueryKey = ['users/me'] as const; - -export function useCurrentUser() { - return useQuery({ - queryKey: currentUserQueryKey, - queryFn: ({ signal }) => getUser(signal), - staleTime: 60_000, - refetchOnMount: false, - }); -} diff --git a/src/pages/profile/model/schemas/profile-form.ts b/src/pages/profile/model/schemas.ts similarity index 79% rename from src/pages/profile/model/schemas/profile-form.ts rename to src/pages/profile/model/schemas.ts index 1083964..13c8155 100644 --- a/src/pages/profile/model/schemas/profile-form.ts +++ b/src/pages/profile/model/schemas.ts @@ -1,6 +1,6 @@ -import { z } from 'zod'; +import { z } from 'zod/v4'; -export const ProfileFormSchema = z.object({ +export const ProfileForm = z.object({ firstName: z .string() .trim() @@ -15,5 +15,3 @@ export const ProfileFormSchema = z.object({ .max(100, 'Слишком длинная фамилия'), bio: z.string().trim().max(512, 'Слишком длинное описание').optional().or(z.literal('')), }); - -export type ProfileFormSchemaType = z.infer; diff --git a/src/pages/profile/model/schemas/notifications-update.ts b/src/pages/profile/model/schemas/notifications-update.ts deleted file mode 100644 index 03c0d2b..0000000 --- a/src/pages/profile/model/schemas/notifications-update.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { z as zod, z } from 'zod'; - -export const NotificationsUpdateSchema = zod - .object({ - email: zod - .object({ - task_assigned: zod.boolean().describe('Уведомление на почту при назначении задачи'), - mentions: zod.boolean().describe('Уведомление на почту при упоминании в комментариях'), - daily_summary: zod.boolean().describe('Ежедневная сводка задач на почту'), - }) - .optional(), - push: zod - .object({ - task_assigned: zod.boolean().describe('Push-уведомление при назначении задачи'), - reminders: zod.boolean().describe('Push-уведомления о дедлайнах'), - }) - .optional(), - }) - .describe('Схема для частичного обновления настроек уведомлений'); - -export type NotificationsUpdateSchemaType = z.infer; diff --git a/src/pages/profile/model/schemas/profile-update.ts b/src/pages/profile/model/schemas/profile-update.ts deleted file mode 100644 index 046ba35..0000000 --- a/src/pages/profile/model/schemas/profile-update.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { z } from 'zod'; - -export const ProfileUpdateSchema = z - .object({ - firstName: z.string().min(1).max(50).optional(), - lastName: z.string().min(1).max(50).optional(), - middleName: z.string().max(50).nullish(), - bio: z.string().max(512).nullish(), - timezone: z.string().max(50).optional(), - language: z.string().min(2).max(2).optional(), - }) - .describe('Схема для частичного обновления данных профиля'); - -export type ProfileUpdateSchemaType = z.infer; diff --git a/src/pages/profile/model/schemas/user-response.ts b/src/pages/profile/model/schemas/user-response.ts deleted file mode 100644 index 1a2f3df..0000000 --- a/src/pages/profile/model/schemas/user-response.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { z } from 'zod'; - -export const UserResponseSchema = z.object({ - id: z.string().describe('Уникальный идентификатор (CUID/UUID)'), - email: z.email().describe('Электронная почта'), - profile: z.object({ - firstName: z.string().describe('Имя пользователя'), - lastName: z.string().describe('Фамилия'), - middleName: z.string().nullable().describe('Отчество'), - bio: z.string().nullable().describe('О себе'), - avatarUrl: z.url().nullable().describe('Ссылка на аватар в S3'), - timezone: z.string().describe('Временная зона'), - language: z.string().describe('Язык интерфейса'), - createdAt: z.iso.datetime({}).describe('Дата регистрации'), - updatedAt: z.iso.datetime({}).describe('Дата последнего обновления профиля'), - }), - security: z - .object({ - is2faEnabled: z.boolean().describe('Статус двухфакторной аутентификации'), - lastPasswordChange: z.iso.datetime({}).describe('Дата последнего изменения пароля'), - }) - .describe('Данные безопасности аккаунта'), - notifications: z - .object({ - email: z.object({ - task_assigned: z.boolean().describe('Уведомление на п��чту при назначении задачи'), - mentions: z.boolean().describe('Уведомление на почту при упоминании в комментариях'), - daily_summary: z.boolean().describe('Ежедневная сводка задач на почту'), - }), - push: z.object({ - task_assigned: z.boolean().describe('Push-уведомление при назначении задачи'), - reminders: z.boolean().describe('Push-уведомления о дедлайнах'), - }), - }) - .describe('Настройки уведомлений пользователя'), -}); - -export type UserResponseSchemaType = z.infer; diff --git a/src/pages/profile/model/services/get-user-activity.ts b/src/pages/profile/model/services/get-user-activity.ts deleted file mode 100644 index 5ca0285..0000000 --- a/src/pages/profile/model/services/get-user-activity.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { api } from 'shared/api'; - -//todo в разработке -export const getUserActivity = (signal?: AbortSignal) => - api( - { - url: '/users/me/activity', - method: 'GET', - contracts: {}, - }, - { - signal, - } - ); diff --git a/src/pages/profile/model/services/get-user.ts b/src/pages/profile/model/services/get-user.ts deleted file mode 100644 index d7ea7e0..0000000 --- a/src/pages/profile/model/services/get-user.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { api } from 'shared/api'; -import { UserResponseSchema, type UserResponseSchemaType } from '../schemas/user-response'; - -export const getUser = (signal?: AbortSignal) => - api( - { - url: '/users/me', - method: 'GET', - contracts: { - response: UserResponseSchema, - }, - }, - { - signal, - } - ); diff --git a/src/pages/profile/model/services/patch-notifications.ts b/src/pages/profile/model/services/patch-notifications.ts deleted file mode 100644 index 2f2cc64..0000000 --- a/src/pages/profile/model/services/patch-notifications.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { api } from 'shared/api'; -import { - NotificationsUpdateSchema, - NotificationsUpdateSchemaType, -} from '../schemas/notifications-update'; - -export const patchUser = (data: NotificationsUpdateSchemaType) => - api({ - url: '/users/me/notifications', - method: 'PATCH', - data, - contracts: { - body: NotificationsUpdateSchema, - }, - }); diff --git a/src/pages/profile/model/services/patch-user.ts b/src/pages/profile/model/services/patch-user.ts deleted file mode 100644 index 19ea226..0000000 --- a/src/pages/profile/model/services/patch-user.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { api } from 'shared/api'; -import { ProfileUpdateSchema, type ProfileUpdateSchemaType } from '../schemas/profile-update'; - -export const patchUser = (data: ProfileUpdateSchemaType) => - api({ - url: '/users/me', - method: 'PATCH', - data, - contracts: { - body: ProfileUpdateSchema, - }, - }); diff --git a/src/pages/profile/model/services/post-avatar.ts b/src/pages/profile/model/services/post-avatar.ts deleted file mode 100644 index 76a73c7..0000000 --- a/src/pages/profile/model/services/post-avatar.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { api } from 'shared/api'; - -export const postAvatar = (file: File) => { - const formData = new FormData(); - formData.append('file', file); - - return api({ - url: '/users/me/avatar', - method: 'POST', - data: formData, - headers: { - 'Content-Type': 'multipart/form-data', - }, - }); -}; diff --git a/src/pages/profile/model/types.ts b/src/pages/profile/model/types.ts new file mode 100644 index 0000000..f74058f --- /dev/null +++ b/src/pages/profile/model/types.ts @@ -0,0 +1,4 @@ +import { z } from 'zod/v4'; +import * as SProfile from './schemas'; + +export type ProfileFormValues = z.infer; diff --git a/src/pages/profile/model/useSignOut.ts b/src/pages/profile/model/useSignOut.ts new file mode 100644 index 0000000..e149ee3 --- /dev/null +++ b/src/pages/profile/model/useSignOut.ts @@ -0,0 +1,19 @@ +import { type DefaultError, useMutation } from '@tanstack/react-query'; +import { AuthHttp, TAuth } from 'entities/auth'; + +interface UseSignOutProps { + onSuccess?: (res: TAuth.SignoutResponse) => void; + onError?: (err: Error) => void; +} + +export function useSignOut({ onSuccess, onError }: UseSignOutProps = {}) { + return useMutation, DefaultError, void>({ + mutationFn: AuthHttp.signout, + onError: (err) => { + onError?.(err); + }, + onSuccess: (res) => { + onSuccess?.(res); + }, + }); +} diff --git a/src/pages/profile/model/useUpdateAvatar.ts b/src/pages/profile/model/useUpdateAvatar.ts new file mode 100644 index 0000000..a16897d --- /dev/null +++ b/src/pages/profile/model/useUpdateAvatar.ts @@ -0,0 +1,19 @@ +import { type DefaultError, useMutation } from '@tanstack/react-query'; +import { TUser, UserHttp } from 'entities/user'; + +interface UseUpdateAvatarProps { + onSuccess?: (file: File, res: TUser.AvatarUpdateResponse) => void; + onError?: (err: Error) => void; +} + +export function useUpdateAvatar({ onSuccess, onError }: UseUpdateAvatarProps = {}) { + return useMutation, DefaultError, File>({ + mutationFn: UserHttp.updateAvatar, + onError: (err) => { + onError?.(err); + }, + onSuccess: (res, file) => { + onSuccess?.(file, res); + }, + }); +} diff --git a/src/pages/profile/model/useUpdateNotifications.ts b/src/pages/profile/model/useUpdateNotifications.ts new file mode 100644 index 0000000..7a01b92 --- /dev/null +++ b/src/pages/profile/model/useUpdateNotifications.ts @@ -0,0 +1,23 @@ +import { type DefaultError, useMutation } from '@tanstack/react-query'; +import { TUser, UserHttp } from 'entities/user'; + +interface UseUpdateNotificationsProps { + onSuccess?: (body: TUser.NotificationsUpdateBody, res: TUser.NotificationsUpdateResponse) => void; + onError?: (err: Error) => void; +} + +export function useUpdateNotifications({ onSuccess, onError }: UseUpdateNotificationsProps = {}) { + return useMutation< + Awaited, + DefaultError, + TUser.NotificationsUpdateBody + >({ + mutationFn: UserHttp.updateNotificationsConfig, + onError: (err) => { + onError?.(err); + }, + onSuccess: (res, body) => { + onSuccess?.(body, res); + }, + }); +} diff --git a/src/pages/profile/model/useUpdateProfile.ts b/src/pages/profile/model/useUpdateProfile.ts new file mode 100644 index 0000000..e88f1f4 --- /dev/null +++ b/src/pages/profile/model/useUpdateProfile.ts @@ -0,0 +1,19 @@ +import { type DefaultError, useMutation } from '@tanstack/react-query'; +import { TUser, UserHttp } from 'entities/user'; + +interface UseUpdateProfileProps { + onSuccess?: (body: TUser.ProfileUpdateBody, res: TUser.ProfileUpdateResponse) => void; + onError?: (err: Error) => void; +} + +export function useUpdateProfile({ onSuccess, onError }: UseUpdateProfileProps = {}) { + return useMutation, DefaultError, TUser.ProfileUpdateBody>({ + mutationFn: UserHttp.updateUserConfig, + onError: (err) => { + onError?.(err); + }, + onSuccess: (res, body) => { + onSuccess?.(body, res); + }, + }); +} diff --git a/src/pages/profile/ui/ProfileAvatarSection.tsx b/src/pages/profile/ui/ProfileAvatarSection.tsx index 8386f6f..abca32e 100644 --- a/src/pages/profile/ui/ProfileAvatarSection.tsx +++ b/src/pages/profile/ui/ProfileAvatarSection.tsx @@ -1,9 +1,8 @@ -import { useMutation } from '@tanstack/react-query'; import { Pencil } from 'lucide-react'; import { type ChangeEvent, useRef } from 'react'; -import { postAvatar } from '../model/services/post-avatar'; import { Avatar, AvatarFallback, AvatarImage, Button } from 'shared/ui'; import { toast } from 'sonner'; +import { useUpdateAvatar } from '../model/useUpdateAvatar'; interface ProfileAvatarSectionProps { avatarUrl: string | null; @@ -22,8 +21,7 @@ function ProfileAvatarSection({ }: ProfileAvatarSectionProps) { const fileInputRef = useRef(null); - const uploadAvatarMutation = useMutation({ - mutationFn: postAvatar, + const uploadAvatarMutation = useUpdateAvatar({ onSuccess: async () => { toast.success('Аватар обновлён'); await onUploaded(); diff --git a/src/pages/profile/ui/ProfileIdentityCard.tsx b/src/pages/profile/ui/ProfileIdentityCard.tsx index 3c167e1..4ad65f5 100644 --- a/src/pages/profile/ui/ProfileIdentityCard.tsx +++ b/src/pages/profile/ui/ProfileIdentityCard.tsx @@ -12,22 +12,22 @@ import { Input, Textarea, } from 'shared/ui'; -import { useCurrentUser } from '../model/queries/use-current-user'; -import { useMutation } from '@tanstack/react-query'; -import { patchUser } from '../model/services/patch-user'; import { toast } from 'sonner'; import { Controller, useForm, useWatch } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; -import { ProfileFormSchema, ProfileFormSchemaType } from '../model/schemas/profile-form'; -import { ProfileUpdateSchemaType } from '../model/schemas/profile-update'; import { ProfileAvatarSection } from './ProfileAvatarSection'; +import { TUser, UserQueries } from 'entities/user'; +import { ProfileForm as ProfileFormSchema } from '../model/schemas'; +import type { ProfileFormValues } from '../model/types'; +import { useUpdateProfile } from '../model/useUpdateProfile'; +import { useQuery } from '@tanstack/react-query'; function ProfileIdentityCard(props: Omit, 'children'>) { - const query = useCurrentUser(); + const query = useQuery(UserQueries.getMe()); const profile = query.data?.profile; const email = query.data?.email; - const form = useForm({ + const form = useForm({ resolver: zodResolver(ProfileFormSchema), defaultValues: { firstName: '', @@ -37,8 +37,7 @@ function ProfileIdentityCard(props: Omit, 'children' }); const formValues = useWatch({ control: form.control }); - const updateProfileMutation = useMutation({ - mutationFn: patchUser, + const updateProfileMutation = useUpdateProfile({ onSuccess: async () => { toast.success('Профиль обновлён'); await query.refetch(); @@ -69,13 +68,13 @@ function ProfileIdentityCard(props: Omit, 'children' } const fullName = `${profile.firstName} ${profile.lastName}`; - const profileFormKeys: Array = ['firstName', 'lastName', 'bio']; + const profileFormKeys: Array = ['firstName', 'lastName', 'bio']; const hasProfileChanges = profileFormKeys.some( (key) => (formValues[key] ?? '').trim() !== (profile[key] ?? '').trim() ); - const onSubmit = (data: ProfileFormSchemaType) => { - const body: ProfileUpdateSchemaType = { + const onSubmit = (data: ProfileFormValues) => { + const body: TUser.ProfileUpdateBody = { firstName: data.firstName.trim(), lastName: data.lastName.trim(), bio: data.bio ? data.bio.trim() : '', diff --git a/src/pages/profile/ui/ProfileNotificationsCard.tsx b/src/pages/profile/ui/ProfileNotificationsCard.tsx index 3507792..20cf8ab 100644 --- a/src/pages/profile/ui/ProfileNotificationsCard.tsx +++ b/src/pages/profile/ui/ProfileNotificationsCard.tsx @@ -1,8 +1,4 @@ -import { useMutation } from '@tanstack/react-query'; import { ComponentProps, useEffect, useId, useReducer } from 'react'; -import { useCurrentUser } from '../model/queries/use-current-user'; -import { patchUser as patchNotifications } from '../model/services/patch-notifications'; -import { type UserResponseSchemaType } from '../model/schemas/user-response'; import { useQueuedDebouncedMutation } from 'shared/lib/hooks'; import { Card, @@ -15,10 +11,13 @@ import { Switch, } from 'shared/ui'; import { toast } from 'sonner'; +import { TUser, UserQueries } from 'entities/user'; +import { useUpdateNotifications } from '../model/useUpdateNotifications'; +import { useQuery } from '@tanstack/react-query'; const SAVE_DEBOUNCE_MS = 500; -type Notifications = UserResponseSchemaType['notifications']; +type Notifications = TUser.UserResponse['notifications']; type NotificationsState = Notifications | null; type NotificationChannel = keyof Pick; @@ -63,14 +62,13 @@ function SwitchItem({ label, ...props }: SwitchItemProps) { } function ProfileNotificationsCard() { - const query = useCurrentUser(); + const query = useQuery(UserQueries.getMe()); const notifications = query.data?.notifications; const [localNotifications, dispatchLocalNotifications] = useReducer( notificationsReducer, notifications ?? null ); - const sendSettings = useMutation({ - mutationFn: patchNotifications, + const sendSettings = useUpdateNotifications({ onSuccess: () => { toast.success('Настройки уведомлений обновлены'); query.refetch(); diff --git a/src/pages/profile/ui/ProfilePage.tsx b/src/pages/profile/ui/ProfilePage.tsx index 9d98694..e08ef84 100644 --- a/src/pages/profile/ui/ProfilePage.tsx +++ b/src/pages/profile/ui/ProfilePage.tsx @@ -2,13 +2,13 @@ import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from 'shared/ui'; import { cn } from 'shared/lib/utils'; -import { currentUserQueryKey, useCurrentUser } from '../model/queries/use-current-user'; -import { useQueryClient } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { ProfilePageSkeleton } from './ProfilePage.skeleton'; import { ProfileIdentityCard } from './ProfileIdentityCard'; import { ProfileSecurityCard } from './ProfileSecurityCard'; import { ProfileNotificationsCard } from './ProfileNotificationsCard'; import { SignOut } from './SignOut'; +import { userFabricKeys, UserQueries } from 'entities/user'; interface ProfilePageProps { className?: string; @@ -17,9 +17,9 @@ interface ProfilePageProps { function ProfilePage({ className }: ProfilePageProps) { const queryClient = useQueryClient(); const invalidateUser = async () => - await queryClient.invalidateQueries({ queryKey: currentUserQueryKey }); + await queryClient.invalidateQueries({ queryKey: userFabricKeys.me() }); - const query = useCurrentUser(); + const query = useQuery(UserQueries.getMe()); if (query.isLoading) { return ; } diff --git a/src/pages/profile/ui/ProfileSecurityCard.tsx b/src/pages/profile/ui/ProfileSecurityCard.tsx index b910fab..bdde0b0 100644 --- a/src/pages/profile/ui/ProfileSecurityCard.tsx +++ b/src/pages/profile/ui/ProfileSecurityCard.tsx @@ -8,11 +8,12 @@ import { Switch, } from 'shared/ui'; import { ComponentProps } from 'react'; -import { useCurrentUser } from '../model/queries/use-current-user'; -import { formatDate } from '../model/utils/format-date'; +import { formatDate } from 'shared/lib/utils'; +import { useQuery } from '@tanstack/react-query'; +import { UserQueries } from 'entities/user'; function ProfileSecurityCard(props: Omit, 'children'>) { - const query = useCurrentUser(); + const query = useQuery(UserQueries.getMe()); const is2faEnabled = query.data?.security.is2faEnabled ?? false; const lastPasswordChange = formatDate(query.data?.security.lastPasswordChange ?? ''); diff --git a/src/pages/profile/ui/SignOut.tsx b/src/pages/profile/ui/SignOut.tsx index 4b1a6c2..ec9fe51 100644 --- a/src/pages/profile/ui/SignOut.tsx +++ b/src/pages/profile/ui/SignOut.tsx @@ -1,22 +1,21 @@ import { LogOut } from 'lucide-react'; import { ComponentProps } from 'react'; import { Button } from 'shared/ui'; -import { AccessToken, signout } from 'shared/api'; +import { AccessToken } from 'shared/api'; import { routes } from 'shared/config'; import { useRouter } from 'next/navigation'; import { toast } from 'sonner'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { currentUserQueryKey } from '../model/queries/use-current-user'; +import { useQueryClient } from '@tanstack/react-query'; +import { useSignOut } from '../model/useSignOut'; function SignOut(props: Omit, 'children'>) { const router = useRouter(); const queryClient = useQueryClient(); - const signoutMutation = useMutation({ - mutationFn: signout, + const signoutMutation = useSignOut({ onSuccess: (response) => { AccessToken.clear(); - queryClient.removeQueries({ queryKey: currentUserQueryKey }); + queryClient.clear(); router.replace(routes.auth.signin()); toast.success(response.message || 'Вы вышли из аккаунта'); }, diff --git a/src/pages/projects/ui/ProjectsPage.tsx b/src/pages/projects/ui/ProjectsPage.tsx index 2ac26ea..462a3d1 100644 --- a/src/pages/projects/ui/ProjectsPage.tsx +++ b/src/pages/projects/ui/ProjectsPage.tsx @@ -1,8 +1,14 @@ +'use client'; + +import { UserQueries } from 'entities/user'; +import { useQuery } from '@tanstack/react-query'; + interface ProjectsPageProps { className?: string; } function ProjectsPage({ className }: ProjectsPageProps) { + useQuery(UserQueries.getMe()); //todo временно для линтера fsd return
Проекты
; } diff --git a/src/pages/signin/model/schemas.ts b/src/pages/signin/model/schemas.ts new file mode 100644 index 0000000..e47d279 --- /dev/null +++ b/src/pages/signin/model/schemas.ts @@ -0,0 +1,7 @@ +import { z } from 'zod/v4'; +import { SAuth } from 'entities/auth'; + +export const SigninForm = z.object({ + email: SAuth.Email, + password: SAuth.Password, +}); diff --git a/src/pages/signin/model/schemas/sign-in-form-schema.ts b/src/pages/signin/model/schemas/sign-in-form-schema.ts deleted file mode 100644 index 4254135..0000000 --- a/src/pages/signin/model/schemas/sign-in-form-schema.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { z } from 'zod'; - -const MIN_PASS_LENGTH = 8; -const MAX_PASS_LENGTH = 32; - -export const SigninFormSchema = z.object({ - email: z.string().min(1, 'Обязательное поле').check(z.email('Неверный формат email')), - password: z - .string() - .min(1, 'Обязательное поле') - .min(MIN_PASS_LENGTH, `Минимум ${MIN_PASS_LENGTH} символов`) - .max(MAX_PASS_LENGTH, 'Слишком длинный пароль'), -}); diff --git a/src/pages/signin/model/types.ts b/src/pages/signin/model/types.ts new file mode 100644 index 0000000..3c80c20 --- /dev/null +++ b/src/pages/signin/model/types.ts @@ -0,0 +1,4 @@ +import { z } from 'zod/v4'; +import * as SSignin from './schemas'; + +export type SigninFormValues = z.infer; diff --git a/src/pages/signin/model/useSignin.ts b/src/pages/signin/model/useSignin.ts new file mode 100644 index 0000000..3d1046d --- /dev/null +++ b/src/pages/signin/model/useSignin.ts @@ -0,0 +1,22 @@ +import { type DefaultError, useMutation } from '@tanstack/react-query'; +import { AuthHttp, TAuth } from 'entities/auth'; + +interface UseSigninProps { + onSuccess?: (body: TAuth.SigninBody, res: TAuth.SigninResponse) => void; + onError?: (err: Error) => void; +} + +export function useSignin({ onSuccess, onError }: UseSigninProps = {}) { + return useMutation, DefaultError, TAuth.SigninBody>({ + mutationFn: AuthHttp.signin, + meta: { + skipGlobalValidationToast: true, + }, + onError: (err) => { + onError?.(err); + }, + onSuccess: (res, body) => { + onSuccess?.(body, res); + }, + }); +} diff --git a/src/pages/signin/ui/SigninForm.tsx b/src/pages/signin/ui/SigninForm.tsx index 5ba5fb3..c55e811 100644 --- a/src/pages/signin/ui/SigninForm.tsx +++ b/src/pages/signin/ui/SigninForm.tsx @@ -1,8 +1,9 @@ 'use client'; -import { Controller, type FieldPath, useForm } from 'react-hook-form'; +import { Controller, useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; -import { SigninFormSchema } from '../model/schemas/sign-in-form-schema'; +import { SigninForm as SigninFormSchema } from '../model/schemas'; +import type { SigninFormValues } from '../model/types'; import { Button, Card, @@ -19,37 +20,19 @@ import { InputPassword, Link, } from 'shared/ui'; -import { cn } from 'shared/lib/utils'; +import { cn, setFormErrors } from 'shared/lib/utils'; import { routes } from 'shared/config'; -import * as z from 'zod'; -import { useMutation } from '@tanstack/react-query'; -import { - extractValidationIssues, - signin, - SigninBody, - SigninResponse, - ValidationIssue, -} from 'shared/api'; - -type FSchema = z.infer; -type BSchema = z.infer; -type RSchema = z.infer; +import { extractValidationIssues } from 'shared/api'; +import { TAuth } from 'entities/auth'; +import { ComponentProps } from 'react'; +import { useSignin } from '../model/useSignin'; -interface SigninFormProps extends Omit, 'children' | 'onSubmit'> { - onSuccess?: (body: BSchema, res: RSchema) => void; +interface SigninFormProps extends Omit, 'children' | 'onSubmit'> { + onSuccess?: (body: TAuth.SigninBody, res: TAuth.SigninResponse) => void; } export function SigninForm({ className, onSuccess, ...props }: SigninFormProps) { - const sendUserData = useMutation({ - mutationFn: (data: BSchema) => { - return signin(data); - }, - meta: { - skipGlobalValidationToast: true, - }, - }); - - const form = useForm({ + const form = useForm({ resolver: zodResolver(SigninFormSchema), defaultValues: { email: '', @@ -57,29 +40,20 @@ export function SigninForm({ className, onSuccess, ...props }: SigninFormProps) }, }); - function setFormErrors(errors: ValidationIssue[]) { - if (Array.isArray(errors)) { - errors.forEach(({ message, path: [path] }) => { - const filedName = path as FieldPath; - form.setError(filedName, { message }); - }); - } - } + const sendUserData = useSignin({ + onSuccess, + onError: (err) => { + setFormErrors(extractValidationIssues(err), form); + }, + }); - const onSubmit = (data: FSchema) => { - const body: BSchema = { + const onSubmit = (data: SigninFormValues) => { + const body: TAuth.SigninBody = { email: data.email, password: data.password, }; - sendUserData.mutate(body, { - onSuccess: (res) => { - onSuccess?.(body, res); - }, - onError: (err) => { - setFormErrors(extractValidationIssues(err)); - }, - }); + sendUserData.mutate(body); }; return ( @@ -118,17 +92,24 @@ export function SigninForm({ className, onSuccess, ...props }: SigninFormProps)
Пароль - + Забыли пароль?
- + {fieldState.invalid && }
)} /> - + diff --git a/src/pages/signup/model/schemas.ts b/src/pages/signup/model/schemas.ts new file mode 100644 index 0000000..aafb263 --- /dev/null +++ b/src/pages/signup/model/schemas.ts @@ -0,0 +1,19 @@ +import { z } from 'zod/v4'; +import { SAuth } from 'entities/auth'; + +export const SignupForm = z + .object({ + name: z + .string() + .trim() + .min(1, 'Обязательное поле') + .min(2, 'Слишком короткое имя') + .max(100, 'Слишком длинное имя'), + email: SAuth.Email, + password: SAuth.Password, + confirmPassword: SAuth.Password, + }) + .refine((data) => data.password === data.confirmPassword, { + message: 'Пароли не совпадают', + path: ['confirmPassword'], + }); diff --git a/src/pages/signup/model/schemas/confirm-form-schema.ts b/src/pages/signup/model/schemas/confirm-form-schema.ts deleted file mode 100644 index 3e38549..0000000 --- a/src/pages/signup/model/schemas/confirm-form-schema.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { z } from 'zod'; - -export const ConfirmFormSchema = z.object({ - code: z.string().min(6, 'Обязательное поле'), -}); diff --git a/src/pages/signup/model/schemas/signup-form-schema.ts b/src/pages/signup/model/schemas/signup-form-schema.ts deleted file mode 100644 index de076a9..0000000 --- a/src/pages/signup/model/schemas/signup-form-schema.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { z } from 'zod'; - -const MIN_PASS_LENGTH = 8; -const MAX_PASS_LENGTH = 32; - -export const SignupFormSchema = z - .object({ - name: z - .string() - .trim() - .min(1, 'Обязательное поле') - .min(2, 'Слишком короткое имя') - .max(100, 'Слишком длинное имя'), - email: z.string().min(1, 'Обязательное поле').check(z.email('Неверный формат email')), - password: z - .string() - .min(1, 'Обязательное поле') - .min(MIN_PASS_LENGTH, `Минимум ${MIN_PASS_LENGTH} символов`) - .max(MAX_PASS_LENGTH, 'Слишком длинный пароль'), - confirmPassword: z.string().min(1, 'Обязательное поле'), - }) - .refine((data) => data.password === data.confirmPassword, { - message: 'Пароли не совпадают', - path: ['confirmPassword'], - }); diff --git a/src/pages/signup/model/types.ts b/src/pages/signup/model/types.ts new file mode 100644 index 0000000..9fb16a2 --- /dev/null +++ b/src/pages/signup/model/types.ts @@ -0,0 +1,4 @@ +import { z } from 'zod/v4'; +import * as SSignup from './schemas'; + +export type SignupFormValues = z.infer; diff --git a/src/pages/signup/model/useSignup.ts b/src/pages/signup/model/useSignup.ts new file mode 100644 index 0000000..37a68f5 --- /dev/null +++ b/src/pages/signup/model/useSignup.ts @@ -0,0 +1,22 @@ +import { type DefaultError, useMutation } from '@tanstack/react-query'; +import { AuthHttp, TAuth } from 'entities/auth'; + +interface UseSignupProps { + onSuccess?: (body: TAuth.SignupBody, res: TAuth.SignupResponse) => void; + onError?: (err: Error) => void; +} + +export function useSignup({ onSuccess, onError }: UseSignupProps = {}) { + return useMutation, DefaultError, TAuth.SignupBody>({ + mutationFn: AuthHttp.signup, + meta: { + skipGlobalValidationToast: true, + }, + onError: (err) => { + onError?.(err); + }, + onSuccess: (res, body) => { + onSuccess?.(body, res); + }, + }); +} diff --git a/src/pages/signup/model/useSignupConfirm.ts b/src/pages/signup/model/useSignupConfirm.ts new file mode 100644 index 0000000..d2dc7b8 --- /dev/null +++ b/src/pages/signup/model/useSignupConfirm.ts @@ -0,0 +1,11 @@ +import { type DefaultError, useMutation } from '@tanstack/react-query'; +import { AuthHttp, TAuth } from 'entities/auth'; + +export function useSignupConfirm() { + return useMutation, DefaultError, TAuth.SignupConfirmBody>({ + mutationFn: AuthHttp.signupConfirm, + meta: { + skipGlobalValidationToast: true, + }, + }); +} diff --git a/src/pages/signup/model/utils/field-name-mapper.ts b/src/pages/signup/model/utils/field-name-mapper.ts index 2bd13cd..144cbde 100644 --- a/src/pages/signup/model/utils/field-name-mapper.ts +++ b/src/pages/signup/model/utils/field-name-mapper.ts @@ -1,11 +1,10 @@ import type { FieldPath } from 'react-hook-form'; -import { SignupBody } from 'shared/api'; -import { SignupFormSchema } from '../schemas/signup-form-schema'; -import { z } from 'zod'; +import type { SignupFormValues } from '../types'; +import { TAuth } from 'entities/auth'; export const fieldNameMapper = ( - fieldName: FieldPath> -): FieldPath> => { + fieldName: FieldPath +): FieldPath => { switch (fieldName) { case 'firstName': case 'lastName': diff --git a/src/pages/signup/ui/SignupForm.tsx b/src/pages/signup/ui/SignupForm.tsx index a8e3293..07542f2 100644 --- a/src/pages/signup/ui/SignupForm.tsx +++ b/src/pages/signup/ui/SignupForm.tsx @@ -1,6 +1,5 @@ 'use client'; -import type { FieldPath } from 'react-hook-form'; import { Controller, useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { @@ -21,42 +20,25 @@ import { Link, Spinner, } from 'shared/ui'; -import { SignupFormSchema } from '../model/schemas/signup-form-schema'; -import { cn } from 'shared/lib/utils'; +import type { SignupFormValues } from '../model/types'; +import { SignupForm as SignupFormSchema } from '../model/schemas'; +import { cn, setFormErrors } from 'shared/lib/utils'; import { routes } from 'shared/config'; -import { z } from 'zod'; -import { useState } from 'react'; -import { useMutation } from '@tanstack/react-query'; +import { ComponentProps, useState } from 'react'; import { fieldNameMapper } from '../model/utils/field-name-mapper'; import { prepareFullName } from '../model/utils/prepare-fullname'; -import { - extractValidationIssues, - signup, - SignupBody, - SignupResponse, - ValidationIssue, -} from 'shared/api'; - -type FSchema = z.infer; -type BSchema = z.infer; -type RSchema = z.infer; +import { extractValidationIssues } from 'shared/api'; +import { TAuth } from 'entities/auth'; +import { useSignup } from '../model/useSignup'; -interface SignupFormProps extends Omit, 'children' | 'onSubmit'> { - onSuccess?: (body: BSchema, res: RSchema) => void; +interface SignupFormProps extends Omit, 'children' | 'onSubmit'> { + onSuccess?: (body: TAuth.SignupBody, res: TAuth.SignupResponse) => void; } export function SignupForm({ className, onSuccess, ...props }: SignupFormProps) { const [showPassword, setShowPassword] = useState(false); - const sendUserData = useMutation({ - mutationFn: (data: BSchema) => { - return signup(data); - }, - meta: { - skipGlobalValidationToast: true, - }, - }); - const form = useForm({ + const form = useForm({ resolver: zodResolver(SignupFormSchema), defaultValues: { name: '', @@ -66,32 +48,25 @@ export function SignupForm({ className, onSuccess, ...props }: SignupFormProps) }, }); - function setFormErrors(errors: ValidationIssue[]) { - if (Array.isArray(errors)) { - errors.forEach(({ message, path: [path] }) => { - const typedPath = path as FieldPath; - const filedName = fieldNameMapper(typedPath); - - form.setError(filedName, { message }); - }); - } - } + const sendUserData = useSignup({ + onSuccess, + onError: (err) => { + setFormErrors( + extractValidationIssues(err), + form, + fieldNameMapper + ); + }, + }); - const onSubmit = (data: FSchema) => { - const body: BSchema = { + const onSubmit = (data: SignupFormValues) => { + const body: TAuth.SignupBody = { email: data.email, password: data.password, ...prepareFullName(data.name), }; - sendUserData.mutate(body, { - onSuccess: (res) => { - onSuccess?.(body, res); - }, - onError: (err) => { - setFormErrors(extractValidationIssues(err)); - }, - }); + sendUserData.mutate(body); }; return ( @@ -112,7 +87,7 @@ export function SignupForm({ className, onSuccess, ...props }: SignupFormProps) control={form.control} render={({ field, fieldState }) => ( - Имя + Имя и фамилия {fieldState.invalid && } @@ -152,10 +128,17 @@ export function SignupForm({ className, onSuccess, ...props }: SignupFormProps) Пароль { + field.onChange(event); + if (form.getFieldState('confirmPassword').isTouched) { + void form.trigger('confirmPassword'); + } + }} id="password" aria-invalid={fieldState.invalid} visible={showPassword} onVisibleChange={setShowPassword} + autoComplete="new-password" disabled={sendUserData.isPending} /> {fieldState.invalid && } @@ -175,6 +158,7 @@ export function SignupForm({ className, onSuccess, ...props }: SignupFormProps) aria-invalid={fieldState.invalid} aria-label="Повторите пароль" visible={showPassword} + autoComplete="new-password" disabled={sendUserData.isPending} /> {fieldState.invalid && } diff --git a/src/pages/signup/ui/SignupPage.tsx b/src/pages/signup/ui/SignupPage.tsx index d50f96e..a85c79e 100644 --- a/src/pages/signup/ui/SignupPage.tsx +++ b/src/pages/signup/ui/SignupPage.tsx @@ -1,17 +1,45 @@ 'use client'; -import { Link, Logo } from 'shared/ui'; +import { Link, Logo, Spinner } from 'shared/ui'; import { SignupForm } from './SignupForm'; -import { OTPForm } from './OTPForm'; -import { useState } from 'react'; +import { OTPForm } from 'features/otp-form'; import { useRouter } from 'next/navigation'; import { AccessToken } from 'shared/api'; import { routes } from 'shared/config'; import { toast } from 'sonner'; +import { useSignupConfirm } from '../model/useSignupConfirm'; +import { useLocalStorageDraft } from 'shared/lib/hooks'; + +type SignupStep = 'signup' | 'otp' | null; + +interface SignupDraft extends Record { + email: string; + step: SignupStep; +} + +const DRAFT_KEY = 'drafted-signup'; +const DRAFT_TTL_MS = 15 * 60 * 1000; function SignupPage() { - const [email, setEmail] = useState(''); const router = useRouter(); + const sendConfirm = useSignupConfirm(); + const { draft, setDraft, clearDraft } = useLocalStorageDraft(DRAFT_KEY, { + defaultTTLms: DRAFT_TTL_MS, + defaultValues: { email: '', step: 'signup' }, + }); + + const email = draft?.email ?? ''; + const step: SignupStep = draft?.step ?? null; + + if (!step) { + return ( +
+
+ +
+
+ ); + } return (
@@ -20,14 +48,17 @@ function SignupPage() { - {!email ? ( - setEmail(email)} /> - ) : ( + {step === 'signup' ? ( + setDraft({ email, step: 'otp' })} /> + ) : null} + {step === 'otp' ? ( { if (res.success) { + clearDraft(); AccessToken.token = res.token; router.replace(routes.team.profile()); if (res.message) { @@ -36,7 +67,7 @@ function SignupPage() { } }} /> - )} + ) : null}
); diff --git a/src/shared/api/auth/index.ts b/src/shared/api/auth/index.ts deleted file mode 100644 index 1fc75d1..0000000 --- a/src/shared/api/auth/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { signin } from './services/sign-in'; -export { signup } from './services/sign-up'; -export { signupConfirm } from './services/sign-up-confirm'; -export { signout } from './services/sign-out'; -export { SigninBody, SigninResponse } from './schemas/sign-in-schema'; -export { SignoutResponse } from './schemas/sign-out-schema'; -export { SignupConfirmBody, SignupConfirmResponse } from './schemas/sign-up-confirm-schema'; -export { SignupBody, SignupResponse } from './schemas/sign-up-schema'; diff --git a/src/shared/api/auth/schemas/sign-in-schema.ts b/src/shared/api/auth/schemas/sign-in-schema.ts deleted file mode 100644 index 6c8f683..0000000 --- a/src/shared/api/auth/schemas/sign-in-schema.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { z } from 'zod'; - -export const SigninBody = z - .object({ - email: z.email().describe('Email пользователя'), - password: z.string().describe('Пароль пользователя'), - }) - .describe('Схема входа в систему'); - -export const SigninResponse = z.object({ - success: z.boolean().describe('Успешное обновление токенов'), - token: z.string().describe('Новый access token (JWT)'), - message: z.string().optional().describe('Дополнительное сообщение (опционально)'), -}); diff --git a/src/shared/api/auth/schemas/sign-out-schema.ts b/src/shared/api/auth/schemas/sign-out-schema.ts deleted file mode 100644 index 5f2b7f8..0000000 --- a/src/shared/api/auth/schemas/sign-out-schema.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { z } from 'zod'; - -export const SignoutResponse = z.object({ - success: z.boolean().describe('Статус операции'), - message: z.string().optional().describe('Сообщение для пользователя'), -}); diff --git a/src/shared/api/auth/schemas/sign-up-confirm-schema.ts b/src/shared/api/auth/schemas/sign-up-confirm-schema.ts deleted file mode 100644 index 8c20d17..0000000 --- a/src/shared/api/auth/schemas/sign-up-confirm-schema.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { z } from 'zod'; - -export const SignupConfirmBody = z - .object({ - email: z.email().describe('Email пользователя, на который был отправлен код'), - code: z.string().min(6).max(6).describe('6-значный OTP код подтверждения'), - }) - .describe('Схема верификации OTP кода'); - -export const SignupConfirmResponse = z.object({ - success: z.boolean().describe('Успешное подтверждение'), - token: z.string().describe('Token (JWT)'), - message: z.string().optional().describe('Дополнительное сообщение (опционально)'), -}); diff --git a/src/shared/api/auth/schemas/sign-up-schema.ts b/src/shared/api/auth/schemas/sign-up-schema.ts deleted file mode 100644 index bb2b74c..0000000 --- a/src/shared/api/auth/schemas/sign-up-schema.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { z } from 'zod'; - -const MIN_PASS_LENGTH = 8; -const MAX_PASS_LENGTH = 32; - -export const SignupBody = z - .object({ - email: z.email('Некорректный формат email').describe('Email пользователя'), - password: z - .string() - .min(MIN_PASS_LENGTH, `Пароль должен содержать минимум ${MIN_PASS_LENGTH} символов`) - .max(MAX_PASS_LENGTH, `Пароль должен содержать максимум ${MAX_PASS_LENGTH} символа`) - .describe('Пароль (минимум 8 символов)'), - firstName: z - .string() - .min(2, 'Имя должно содержать минимум 2 символа') - .max(50) - .trim() - .describe('Имя'), - lastName: z - .string() - .min(2, 'Фамилия должна содержать минимум 2 символа') - .max(50) - .trim() - .describe('Фамилия'), - middleName: z - .string() - .max(50) - .trim() - .optional() - .or(z.literal('')) - .describe('Отчество (опционально)'), - }) - .describe('Схема регистрации пользователя'); - -export const SignupResponse = z.object({ - success: z.boolean().describe('Статус операции'), - message: z.string().optional().describe('Сообщение для пользователя'), -}); diff --git a/src/shared/api/auth/services/sign-in.ts b/src/shared/api/auth/services/sign-in.ts deleted file mode 100644 index 509be6f..0000000 --- a/src/shared/api/auth/services/sign-in.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { z } from 'zod'; -import { instance } from '../../instance'; -import { SigninBody, SigninResponse } from '../schemas/sign-in-schema'; - -export function signin(data: z.infer): Promise> { - return instance( - { - url: '/auth/sign-in', - method: 'POST', - data: data, - skipAuthRefresh: true, - }, - { - contracts: { - body: SigninBody, - response: SigninResponse, - }, - } - ); -} diff --git a/src/shared/api/auth/services/sign-out.ts b/src/shared/api/auth/services/sign-out.ts deleted file mode 100644 index b63344b..0000000 --- a/src/shared/api/auth/services/sign-out.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { z } from 'zod'; -import { instance } from '../../instance'; -import { SignoutResponse } from '../schemas/sign-out-schema'; - -export function signout(): Promise> { - return instance( - { - url: '/auth/sign-out', - method: 'POST', - }, - { - contracts: { - response: SignoutResponse, - }, - } - ); -} diff --git a/src/shared/api/auth/services/sign-up-confirm.ts b/src/shared/api/auth/services/sign-up-confirm.ts deleted file mode 100644 index e40b259..0000000 --- a/src/shared/api/auth/services/sign-up-confirm.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { z } from 'zod'; -import { SignupConfirmBody, SignupConfirmResponse } from '../schemas/sign-up-confirm-schema'; -import { api } from 'shared/api'; - -export function signupConfirm( - data: z.infer -): Promise> { - return api( - { - url: '/auth/sign-up/confirm', - method: 'POST', - data: data, - skipAuthRefresh: true, - }, - { - contracts: { - body: SignupConfirmBody, - response: SignupConfirmResponse, - }, - } - ); -} diff --git a/src/shared/api/auth/services/sign-up.ts b/src/shared/api/auth/services/sign-up.ts deleted file mode 100644 index 4fe559d..0000000 --- a/src/shared/api/auth/services/sign-up.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { z } from 'zod'; -import { instance } from '../../instance'; -import { SignupBody, SignupResponse } from '../schemas/sign-up-schema'; - -export function signup(data: z.infer): Promise> { - return instance( - { - url: '/auth/sign-up', - method: 'POST', - data: data, - }, - { - contracts: { - body: SignupBody, - response: SignupResponse, - }, - } - ); -} diff --git a/src/shared/api/errors/error-utils.ts b/src/shared/api/error-utils.ts similarity index 97% rename from src/shared/api/errors/error-utils.ts rename to src/shared/api/error-utils.ts index 6133a7f..7be4c5f 100644 --- a/src/shared/api/errors/error-utils.ts +++ b/src/shared/api/error-utils.ts @@ -1,5 +1,6 @@ import axios, { AxiosError, AxiosResponse, HttpStatusCode } from 'axios'; -import { AxiosValidationError, GlobalErrorResponseType, isAxiosValidationError } from 'shared/api'; +import { AxiosValidationError, isAxiosValidationError } from './validation'; +import { GlobalError } from './types'; export type ErrorMessage = { message: string; @@ -115,7 +116,7 @@ export class ErrorUtils { } // здесь может быть несколько моделей ошибок - const data = response.data as GlobalErrorResponseType; + const data = response.data as GlobalError; let description: string[] = []; if (data.details && data.details.length > 0) { diff --git a/src/shared/api/errors/global-error-schema.ts b/src/shared/api/errors/global-error-schema.ts deleted file mode 100644 index dd6eece..0000000 --- a/src/shared/api/errors/global-error-schema.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { z } from 'zod'; - -const ValidationIssueSchema = z - .object({ - origin: z.string().optional(), - code: z.string().describe('Машиночитаемый код ошибки валидации'), - message: z.string().describe('Человекочитаемое сообщение об ошибке поля'), - path: z.array(z.string()).describe('Путь к полю, где произошла ошибка'), - }) - .catchall(z.unknown()); - -export const GlobalErrorSchema = z.object({ - success: z.boolean().describe('Признак успешного выполнения запроса'), - error: z.object({ - code: z.string().describe('Уникальный бизнес-код ошибки'), - message: z.string().describe('Краткое описание ошибки для пользователя или разработчика'), - retryable: z - .boolean() - .describe( - 'Флаг, указывающий клиенту, есть ли смысл повторять запрос без изменений (например, при 503)' - ), - }), - details: z - .array(ValidationIssueSchema.describe('Детальная информация о конкретном нарушении в запросе')) - .optional() - .describe('Список ошибок валидации (заполняется только для 400 ошибок)'), - meta: z - .object({ - service: z.string().optional().describe('Имя микросервиса, в котором произошел сбой'), - request: z - .object({ - requestId: z.string().describe('Уникальный ID запроса (Trace ID)'), - path: z.string().describe('URL-путь эндпоинта, который вернул ошибку'), - method: z.string().describe('HTTP метод запроса (GET, POST, etc.)'), - ip: z.string().optional().describe('IP-адрес клиента'), - }) - .optional(), - timestamp: z.iso - .datetime({}) - .describe('Точное время возникновения ошибки в формате ISO 8601'), - debug: z - .object({ - stack: z.string().optional().describe('Стек вызовов для отладки'), - }) - .optional(), - }) - .catchall(z.unknown()) - .describe('Техническая мета-информация для мониторинга и отладки'), -}); - -export type GlobalErrorResponseType = z.infer; diff --git a/src/shared/api/errors/index.ts b/src/shared/api/errors/index.ts deleted file mode 100644 index 597af23..0000000 --- a/src/shared/api/errors/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { ErrorUtils, type ErrorMessage } from './error-utils'; -export type { GlobalErrorResponseType } from './global-error-schema'; -export { GlobalErrorSchema } from './global-error-schema'; diff --git a/src/shared/api/index.ts b/src/shared/api/index.ts index b9477ca..39ec289 100644 --- a/src/shared/api/index.ts +++ b/src/shared/api/index.ts @@ -5,7 +5,6 @@ export { extractValidationIssues, type ValidationIssue, } from './validation'; -export { type GlobalErrorResponseType } from './errors'; +export { GlobalSuccess, GlobalError } from './schemas'; export { AccessToken } from './token'; export { queryClient } from './query-client'; -export * from './auth'; diff --git a/src/shared/api/instance.ts b/src/shared/api/instance.ts index 3750b70..e191ab7 100644 --- a/src/shared/api/instance.ts +++ b/src/shared/api/instance.ts @@ -1,4 +1,4 @@ -import Axios, { AxiosError, AxiosRequestConfig } from 'axios'; +import Axios, { AxiosRequestConfig } from 'axios'; import { applyInterceptors } from './interceptors'; const AXIOS_INSTANCE = Axios.create({ @@ -8,12 +8,6 @@ const AXIOS_INSTANCE = Axios.create({ applyInterceptors(AXIOS_INSTANCE); -export const instance = ( - config: AxiosRequestConfig, - options: AxiosRequestConfig = {} -): Promise => { - return AXIOS_INSTANCE({ ...config, ...options }).then(({ data }) => data); +export const instance = (config: AxiosRequestConfig): Promise => { + return AXIOS_INSTANCE(config).then(({ data }) => data); }; - -export type ErrorType = AxiosError; -export type BodyType = BodyData; diff --git a/src/shared/api/query-client.ts b/src/shared/api/query-client.ts index 20f8dab..18b8118 100644 --- a/src/shared/api/query-client.ts +++ b/src/shared/api/query-client.ts @@ -1,6 +1,6 @@ import { toast } from 'sonner'; import { MutationCache, QueryCache, QueryClient } from '@tanstack/react-query'; -import { ErrorMessage, ErrorUtils } from './errors'; +import { ErrorMessage, ErrorUtils } from './error-utils'; import { AxiosValidationError } from './validation'; interface AppQueryMeta extends Record { diff --git a/src/shared/api/schemas/global-error.ts b/src/shared/api/schemas/global-error.ts new file mode 100644 index 0000000..c7e77f6 --- /dev/null +++ b/src/shared/api/schemas/global-error.ts @@ -0,0 +1,34 @@ +import { z } from 'zod/v4'; + +const ValidationIssue = z + .object({ + origin: z.string().optional(), + code: z.string(), + message: z.string(), + path: z.array(z.string()), + }) + .catchall(z.unknown()); + +export const GlobalError = z.object({ + success: z.boolean(), + error: z.object({ + code: z.string(), + message: z.string(), + retryable: z.boolean(), + }), + details: z.array(ValidationIssue).optional(), + meta: z + .object({ + service: z.string().optional(), + request: z + .object({ + requestId: z.string(), + path: z.string(), + method: z.string(), + ip: z.string().optional(), + }) + .optional(), + timestamp: z.iso.datetime({}), + }) + .catchall(z.unknown()), +}); diff --git a/src/shared/api/schemas/global-success.ts b/src/shared/api/schemas/global-success.ts new file mode 100644 index 0000000..ec40030 --- /dev/null +++ b/src/shared/api/schemas/global-success.ts @@ -0,0 +1,6 @@ +import { z } from 'zod/v4'; + +export const GlobalSuccess = z.object({ + success: z.boolean(), + message: z.string().optional(), +}); diff --git a/src/shared/api/schemas/index.ts b/src/shared/api/schemas/index.ts new file mode 100644 index 0000000..8c9726b --- /dev/null +++ b/src/shared/api/schemas/index.ts @@ -0,0 +1,2 @@ +export { GlobalSuccess } from './global-success'; +export { GlobalError } from './global-error'; diff --git a/src/shared/api/token/access-token.ts b/src/shared/api/token/access-token.ts index d0b03a4..005674c 100644 --- a/src/shared/api/token/access-token.ts +++ b/src/shared/api/token/access-token.ts @@ -1,12 +1,12 @@ class AccessToken { - static _token: string | null = null; + static #token: string | null = null; static set token(token: string) { - this._token = token; + this.#token = token; } static get token(): string | null { - return this._token; + return this.#token; } static get header() { @@ -14,7 +14,7 @@ class AccessToken { } static clear() { - this._token = null; + this.#token = null; } static getHeader(token: string | null = this.token) { diff --git a/src/shared/api/token/refresh-interceptor.ts b/src/shared/api/token/refresh-interceptor.ts index 8c5782a..4bb4fe5 100644 --- a/src/shared/api/token/refresh-interceptor.ts +++ b/src/shared/api/token/refresh-interceptor.ts @@ -1,9 +1,8 @@ import { AxiosError, AxiosInstance, InternalAxiosRequestConfig } from 'axios'; -import { z } from 'zod'; -import { RefreshTokenResponse } from './response-schema'; +import { RefreshTokenResponse } from './response'; +import type { RefreshTokenResponse as RefreshTokenResponseType } from './types'; import { AccessToken } from './access-token'; -import { GlobalErrorResponseType } from '../errors'; -import { signout } from '../auth'; +import { GlobalError } from '../types'; declare module 'axios' { export interface AxiosRequestConfig { @@ -34,7 +33,7 @@ export const refreshInterceptor = (instance: AxiosInstance) => { instance.interceptors.response.use( (response) => response, - async (error: AxiosError) => { + async (error: AxiosError) => { const originalRequest = error.config as RetryRequestConfig; if ( @@ -58,7 +57,7 @@ export const refreshInterceptor = (instance: AxiosInstance) => { isRefreshing = true; try { - const response = await instance.request>({ + const response = await instance.request({ url: `/auth/refresh`, method: 'POST', contracts: { @@ -85,11 +84,6 @@ export const refreshInterceptor = (instance: AxiosInstance) => { return instance(originalRequest); } catch (refreshError) { processQueue(refreshError); - try { - await signout(); - } catch (err) { - return Promise.reject(err); - } AccessToken.clear(); return Promise.reject(refreshError); } finally { diff --git a/src/shared/api/token/response-schema.ts b/src/shared/api/token/response-schema.ts deleted file mode 100644 index 2845105..0000000 --- a/src/shared/api/token/response-schema.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { z } from 'zod'; - -export const RefreshTokenResponse = z - .object({ - success: z.boolean().describe('Успешное обновление токенов'), - token: z.string().describe('Новый access token (JWT)'), - message: z.string().optional().describe('Дополнительное сообщение (опционально)'), - }) - .describe('Ответ при обновлении пары access/refresh токенов'); diff --git a/src/shared/api/token/response.ts b/src/shared/api/token/response.ts new file mode 100644 index 0000000..a64bf13 --- /dev/null +++ b/src/shared/api/token/response.ts @@ -0,0 +1,6 @@ +import { z } from 'zod/v4'; +import { GlobalSuccess } from 'shared/api'; + +export const RefreshTokenResponse = GlobalSuccess.extend({ + token: z.string(), +}); diff --git a/src/shared/api/token/types.ts b/src/shared/api/token/types.ts new file mode 100644 index 0000000..481cd6d --- /dev/null +++ b/src/shared/api/token/types.ts @@ -0,0 +1,4 @@ +import { z } from 'zod/v4'; +import * as SToken from './response'; + +export type RefreshTokenResponse = z.infer; diff --git a/src/shared/api/types.ts b/src/shared/api/types.ts new file mode 100644 index 0000000..5cd10cc --- /dev/null +++ b/src/shared/api/types.ts @@ -0,0 +1,5 @@ +import { z } from 'zod/v4'; +import * as SApi from './schemas'; + +export type GlobalSuccess = z.infer; +export type GlobalError = z.infer; diff --git a/src/shared/api/validation/AxiosContracts.ts b/src/shared/api/validation/AxiosContracts.ts index 65131c2..5294609 100644 --- a/src/shared/api/validation/AxiosContracts.ts +++ b/src/shared/api/validation/AxiosContracts.ts @@ -1,5 +1,5 @@ import { AxiosHeaders, type AxiosResponse, type InternalAxiosRequestConfig } from 'axios'; -import type { ZodType } from 'zod'; +import type { ZodType } from 'zod/v4'; import { AxiosValidationError } from './AxiosValidationError'; declare module 'axios' { diff --git a/src/shared/api/validation/AxiosValidationError.ts b/src/shared/api/validation/AxiosValidationError.ts index 63444a0..8a3463f 100644 --- a/src/shared/api/validation/AxiosValidationError.ts +++ b/src/shared/api/validation/AxiosValidationError.ts @@ -4,7 +4,7 @@ import { type AxiosResponse, type InternalAxiosRequestConfig, } from 'axios'; -import type { ZodIssue } from 'zod'; +import type { ZodIssue } from 'zod/v4'; /** * Custom error class for handling validation errors in axios requests. diff --git a/src/shared/api/validation/extract-validation-issues.ts b/src/shared/api/validation/extract-validation-issues.ts index fe7cea2..47e8c9a 100644 --- a/src/shared/api/validation/extract-validation-issues.ts +++ b/src/shared/api/validation/extract-validation-issues.ts @@ -1,12 +1,14 @@ import { isAxiosError } from 'axios'; import { isAxiosValidationError } from './AxiosValidationError'; -import { GlobalErrorResponseType } from '../errors'; +import { GlobalError } from '../types'; export interface ValidationIssue { message: string; path: string[]; } +const VALIDATE_ERRORS_CODE: string[] = ['VALIDATION_FAILED']; + export function extractValidationIssues(err: unknown): ValidationIssue[] { // Локальная ошибка валидации (например, от контрактов на клиенте). if (isAxiosValidationError(err)) { @@ -17,7 +19,10 @@ export function extractValidationIssues(err: unknown): ValidationIssue[] { } // Ошибка валидации с бэкенда. - if (isAxiosError(err)) { + if ( + isAxiosError(err) && + VALIDATE_ERRORS_CODE.some((code) => code === err.response?.data?.error?.code) + ) { return ( err.response?.data?.details?.map(({ message, path }) => ({ message, diff --git a/src/shared/config/routes.ts b/src/shared/config/routes.ts index 3e68e90..7090c8c 100644 --- a/src/shared/config/routes.ts +++ b/src/shared/config/routes.ts @@ -5,6 +5,7 @@ export const routes = { auth: { signin: (): Route => '/signin', signup: (): Route => '/signup', + forgotPassword: (): Route => '/forgot-password', }, team: { root: (): Route => '/team', diff --git a/src/shared/lib/classes/index.ts b/src/shared/lib/classes/index.ts new file mode 100644 index 0000000..e5a9ed6 --- /dev/null +++ b/src/shared/lib/classes/index.ts @@ -0,0 +1 @@ +export { LocalStorageDraft, type DraftWithTTL, type DraftRecord } from './local-storage-draft'; diff --git a/src/shared/lib/classes/local-storage-draft.ts b/src/shared/lib/classes/local-storage-draft.ts new file mode 100644 index 0000000..b004a60 --- /dev/null +++ b/src/shared/lib/classes/local-storage-draft.ts @@ -0,0 +1,87 @@ +'use client'; + +type DraftObserver = (draft: DraftWithTTL | null) => void; + +export type DraftRecord = object; +export type DraftWithTTL = T & { + ttl: number; +}; + +export class LocalStorageDraft { + private observers = new Set>(); + + constructor(private readonly storageKey: string) {} + + read(): DraftWithTTL | null { + if (typeof window === 'undefined') { + return null; + } + + const now = Date.now(); + const rawValue = window.localStorage.getItem(this.storageKey); + + if (!rawValue) { + return null; + } + + try { + const parsedValue: DraftWithTTL = JSON.parse(rawValue); + + if ( + !parsedValue || + typeof parsedValue !== 'object' || + !('ttl' in parsedValue) || + typeof parsedValue.ttl !== 'number' || + parsedValue.ttl <= now + ) { + window.localStorage.removeItem(this.storageKey); + return null; + } + + return parsedValue; + } catch { + window.localStorage.removeItem(this.storageKey); + return null; + } + } + + set(payload: T, ttlMs: number): DraftWithTTL { + const nextDraft = this._create(payload, ttlMs); + + if (typeof window !== 'undefined') { + window.localStorage.setItem(this.storageKey, JSON.stringify(nextDraft)); + } + this._notify(nextDraft); + + return nextDraft; + } + + clear(): void { + if (typeof window !== 'undefined') { + window.localStorage.removeItem(this.storageKey); + } + this._notify(null); + } + + subscribe(observer: DraftObserver): () => void { + this.observers.add(observer); + return () => { + this.observers.delete(observer); + }; + } + + emitCurrent(): void { + this._notify(this.read()); + } + + private _create(payload: T, ttlMs: number): DraftWithTTL { + return { + ...payload, + ttl: Date.now() + ttlMs, + }; + } + + private _notify(draft: DraftWithTTL | null): void { + this.observers.forEach((observer) => observer(draft)); + } +} diff --git a/src/shared/lib/hooks/index.ts b/src/shared/lib/hooks/index.ts index 3e18835..38d20df 100644 --- a/src/shared/lib/hooks/index.ts +++ b/src/shared/lib/hooks/index.ts @@ -1,4 +1,5 @@ -export { useControllableState, type UseControllableStateProps } from './use-controllable-state'; -export { useIsMobile } from './use-mobile'; -export { useDebouncedCallback } from './use-debounced-callback'; -export { useQueuedDebouncedMutation } from './use-queued-debounced-mutation'; +export { useControllableState, type UseControllableStateProps } from './useControllableState'; +export { useIsMobile } from './useMobile'; +export { useDebouncedCallback } from './useDebouncedCallback'; +export { useQueuedDebouncedMutation } from './useQueuedDebouncedMutation'; +export { useLocalStorageDraft, type DraftWithTTL } from './useLocalStorageDraft'; diff --git a/src/shared/lib/hooks/use-controllable-state.ts b/src/shared/lib/hooks/useControllableState.ts similarity index 100% rename from src/shared/lib/hooks/use-controllable-state.ts rename to src/shared/lib/hooks/useControllableState.ts diff --git a/src/shared/lib/hooks/use-debounced-callback.ts b/src/shared/lib/hooks/useDebouncedCallback.ts similarity index 100% rename from src/shared/lib/hooks/use-debounced-callback.ts rename to src/shared/lib/hooks/useDebouncedCallback.ts diff --git a/src/shared/lib/hooks/useLocalStorageDraft.ts b/src/shared/lib/hooks/useLocalStorageDraft.ts new file mode 100644 index 0000000..7f14808 --- /dev/null +++ b/src/shared/lib/hooks/useLocalStorageDraft.ts @@ -0,0 +1,60 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { DraftRecord, DraftWithTTL, LocalStorageDraft } from '../classes'; + +interface UseLocalStorageDraftOptions { + defaultTTLms?: number; + defaultValues?: T; +} + +interface UseLocalStorageDraftReturn { + draft: DraftWithTTL | null; + setDraft: (payload: T, ttlMs?: number) => void; + clearDraft: () => void; + draftStorage: LocalStorageDraft; +} + +export function useLocalStorageDraft( + storageKey: string, + options: UseLocalStorageDraftOptions = {} +): UseLocalStorageDraftReturn { + const defaultTTLms = options.defaultTTLms ?? 15 * 60 * 1000; + const defaultValues = options.defaultValues ?? null; + const draftStorage = useMemo(() => new LocalStorageDraft(storageKey), [storageKey]); + + const [draft, setDraft] = useState | null>(null); + + useEffect(() => { + if (defaultValues && !draftStorage.read()) { + draftStorage.set(defaultValues, defaultTTLms); + } + + const unsubscribe = draftStorage.subscribe((nextDraft) => { + setDraft(nextDraft); + }); + draftStorage.emitCurrent(); + + return unsubscribe; + }, [draftStorage]); + + const setDraftWithTTL = useCallback( + (payload: T, ttlMs = defaultTTLms) => { + draftStorage.set(payload, ttlMs); + }, + [defaultTTLms, draftStorage] + ); + + const clearDraft = useCallback(() => { + draftStorage.clear(); + }, [draftStorage]); + + return { + draft, + setDraft: setDraftWithTTL, + clearDraft, + draftStorage, + }; +} + +export type { DraftWithTTL }; diff --git a/src/shared/lib/hooks/use-mobile.ts b/src/shared/lib/hooks/useMobile.ts similarity index 100% rename from src/shared/lib/hooks/use-mobile.ts rename to src/shared/lib/hooks/useMobile.ts diff --git a/src/shared/lib/hooks/use-queued-debounced-mutation.ts b/src/shared/lib/hooks/useQueuedDebouncedMutation.ts similarity index 97% rename from src/shared/lib/hooks/use-queued-debounced-mutation.ts rename to src/shared/lib/hooks/useQueuedDebouncedMutation.ts index 4831d1f..20c683e 100644 --- a/src/shared/lib/hooks/use-queued-debounced-mutation.ts +++ b/src/shared/lib/hooks/useQueuedDebouncedMutation.ts @@ -1,7 +1,7 @@ 'use client'; import { useCallback, useRef } from 'react'; -import { useDebouncedCallback } from './use-debounced-callback'; +import { useDebouncedCallback } from './useDebouncedCallback'; interface UseQueuedDebouncedMutationOptions { delayMs: number; diff --git a/src/shared/lib/utils/create-entity-keys.ts b/src/shared/lib/utils/create-entity-keys.ts new file mode 100644 index 0000000..95674db --- /dev/null +++ b/src/shared/lib/utils/create-entity-keys.ts @@ -0,0 +1,31 @@ +export const createEntityKeys = < + T extends string, + Extra extends Record readonly unknown[]>, + ExtraMapped extends Record< + keyof Extra, + (...args: Parameters) => ReturnType + > = { + [K in keyof Extra]: (...args: Parameters) => ReturnType; + }, +>( + entity: T, + extra?: Extra +) => { + const root = entity; + + const base = { + list: (filters?: string | string[]) => { + const arr = [root, 'list']; + if (filters) { + arr.push(...(Array.isArray(filters) ? filters : [filters])); + } + return arr; + }, + detail: (id: number | string) => [root, 'detail', id] as const, + create: () => [root, 'create'] as const, + update: () => [root, 'update'] as const, + remove: () => [root, 'remove'] as const, + }; + + return { ...base, ...((extra ? extra : {}) as ExtraMapped) }; +}; diff --git a/src/shared/lib/utils/format-date/format-date.test.ts b/src/shared/lib/utils/format-date/format-date.test.ts new file mode 100644 index 0000000..1cb526b --- /dev/null +++ b/src/shared/lib/utils/format-date/format-date.test.ts @@ -0,0 +1,20 @@ +import { afterEach, expect, test, vi } from 'vitest'; +import { formatDate } from './format-date'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +test('formatDate returns fallback for invalid value', () => { + expect(formatDate('')).toBe('Нет данных'); +}); + +test('formatDate formats date using ru-RU locale', () => { + const toLocaleStringSpy = vi.spyOn(Date.prototype, 'toLocaleString').mockReturnValue('formatted'); + + expect(formatDate('2026-01-10T15:30:00.000Z')).toBe('formatted'); + expect(toLocaleStringSpy).toHaveBeenCalledWith('ru-RU', { + dateStyle: 'medium', + timeStyle: 'short', + }); +}); diff --git a/src/pages/profile/model/utils/format-date.ts b/src/shared/lib/utils/format-date/format-date.ts similarity index 100% rename from src/pages/profile/model/utils/format-date.ts rename to src/shared/lib/utils/format-date/format-date.ts diff --git a/src/shared/lib/utils/index.ts b/src/shared/lib/utils/index.ts index 9fdf1a3..fd62f11 100644 --- a/src/shared/lib/utils/index.ts +++ b/src/shared/lib/utils/index.ts @@ -1,2 +1,5 @@ export { cn } from './cn'; export { capitalize } from './capitalize/capitalize'; +export { formatDate } from './format-date/format-date'; +export { setFormErrors } from './set-form-errors'; +export { createEntityKeys } from './create-entity-keys'; diff --git a/src/shared/lib/utils/set-form-errors.ts b/src/shared/lib/utils/set-form-errors.ts new file mode 100644 index 0000000..5852266 --- /dev/null +++ b/src/shared/lib/utils/set-form-errors.ts @@ -0,0 +1,28 @@ +import type { ValidationIssue } from 'shared/api'; +import type { FieldPath, FieldValues, UseFormReturn } from 'react-hook-form'; + +export function setFormErrors< + TFormValues extends FieldValues, + TErrorValues extends FieldValues = TFormValues, +>( + errors: ValidationIssue[], + form: Pick, 'setError'>, + prepareFieldName?: (path: FieldPath) => FieldPath +) { + if (!form || !Array.isArray(errors)) { + return; + } + + errors.forEach(({ message, path: [rawPath] }) => { + if (!rawPath) { + return; + } + + const issuePath = rawPath as FieldPath; + const fieldName = prepareFieldName + ? prepareFieldName(issuePath) + : (issuePath as unknown as FieldPath); + + form.setError(fieldName, { message }); + }); +}