From c47a491260b6d23f6ad9b0dbf6d8f76a20d8e6a1 Mon Sep 17 00:00:00 2001 From: Alexandr Nelyubov Date: Wed, 10 Jun 2026 18:09:37 +0300 Subject: [PATCH 1/2] fix(team): fixed validation schemas --- src/entities/user/model/schemas.ts | 2 +- .../teams/active-team/model/useTeamsQueryWithTeamIdSync.ts | 2 +- src/pages/profile/ui/teams-page/TeamList.tsx | 4 ++-- src/widgets/app-sidebar/model/useTeamsDropdown.ts | 2 +- src/widgets/app-sidebar/ui/teams/TeamTrigger.tsx | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/entities/user/model/schemas.ts b/src/entities/user/model/schemas.ts index 100fef2..82a91b5 100644 --- a/src/entities/user/model/schemas.ts +++ b/src/entities/user/model/schemas.ts @@ -126,7 +126,7 @@ export const UserTeamResponse = z.object({ permissions: TeamPermissions, }); -export const UserTeamsListResponse = PaginatedResponseSchema(UserTeamResponse); +export const UserTeamsListResponse = UserTeamResponse.array(); export const UserInvitationResponse = z.object({ code: z.string(), diff --git a/src/features/teams/active-team/model/useTeamsQueryWithTeamIdSync.ts b/src/features/teams/active-team/model/useTeamsQueryWithTeamIdSync.ts index 4a850b1..1c59909 100644 --- a/src/features/teams/active-team/model/useTeamsQueryWithTeamIdSync.ts +++ b/src/features/teams/active-team/model/useTeamsQueryWithTeamIdSync.ts @@ -11,7 +11,7 @@ export function useTeamsQueryWithTeamIdSync() { useEffect(() => { if (!query.data) return; - const items = query.data.items; + const items = query.data; const hasTeamId = !!teamId && items.some((d) => d.id === teamId); if (hasTeamId) return; diff --git a/src/pages/profile/ui/teams-page/TeamList.tsx b/src/pages/profile/ui/teams-page/TeamList.tsx index fb7af79..ca0db0c 100644 --- a/src/pages/profile/ui/teams-page/TeamList.tsx +++ b/src/pages/profile/ui/teams-page/TeamList.tsx @@ -22,7 +22,7 @@ export function TeamsList() { const teamId = useTeamStore.use.teamId(); const { switchTeam } = useSwitchTeam({ - teams: teamsQuery.data?.items, + teams: teamsQuery.data, defaultOptions: { redirect: true }, }); @@ -44,7 +44,7 @@ export function TeamsList() { ); } - const teams = teamsQuery.data?.items ?? []; + const teams = teamsQuery.data ?? []; if (teams.length === 0) { return ; } diff --git a/src/widgets/app-sidebar/model/useTeamsDropdown.ts b/src/widgets/app-sidebar/model/useTeamsDropdown.ts index 8563583..956ffab 100644 --- a/src/widgets/app-sidebar/model/useTeamsDropdown.ts +++ b/src/widgets/app-sidebar/model/useTeamsDropdown.ts @@ -9,7 +9,7 @@ export function useTeamsDropdown() { const [open, setOpen] = useState(false); const query = useQuery(UserQueries.getMyTeams()); - const teams = useMemo(() => query.data?.items ?? [], [query.data]); + const teams = useMemo(() => query.data ?? [], [query.data]); const { switchTeam } = useSwitchTeam({ teams }); diff --git a/src/widgets/app-sidebar/ui/teams/TeamTrigger.tsx b/src/widgets/app-sidebar/ui/teams/TeamTrigger.tsx index c2a71c7..abb90a8 100644 --- a/src/widgets/app-sidebar/ui/teams/TeamTrigger.tsx +++ b/src/widgets/app-sidebar/ui/teams/TeamTrigger.tsx @@ -15,7 +15,7 @@ export function TeamTrigger({ query }: TeamTriggerProps) { const activeTeam = useMemo(() => { if (query.data) { - return query.data.items.find((d) => d.id === teamId); + return query.data.find((d) => d.id === teamId); } }, [teamId, query.data]); From 7a4f7c537e5c1fa816631e113ce5003e08d54004 Mon Sep 17 00:00:00 2001 From: Alexandr Nelyubov Date: Wed, 10 Jun 2026 22:22:44 +0300 Subject: [PATCH 2/2] feat(auth/otp-form): implement resend code functionality - add ResendCodeBody/Response schemas and types in entities/auth - add AuthHttp.resendCode POST /auth/resend - add useResendCode mutation hook in features/otp-form - lift cooldown state (nextResendAt) to parent page - remove LocalStorageDraft from ResendCodeControl --- src/entities/auth/api/http.ts | 11 ++++ src/entities/auth/model/schemas.ts | 13 +++- src/entities/auth/model/types.ts | 3 + src/features/otp-form/model/useResend.ts | 17 +++++ .../otp-form/ui/ResendCodeControl.tsx | 64 ++++++++----------- .../forgot-password/ui/ForgotPasswordPage.tsx | 42 ++++++++++-- src/pages/auth/signup/ui/SignupPage.tsx | 36 +++++++++-- 7 files changed, 135 insertions(+), 51 deletions(-) create mode 100644 src/features/otp-form/model/useResend.ts diff --git a/src/entities/auth/api/http.ts b/src/entities/auth/api/http.ts index cbf2c97..fe7ecd1 100644 --- a/src/entities/auth/api/http.ts +++ b/src/entities/auth/api/http.ts @@ -86,4 +86,15 @@ export class AuthHttp { }, }); } + static resendCode(data: TAuth.ResendCodeBody): Promise { + return api({ + url: '/auth/resend', + method: 'POST', + data: data, + contracts: { + body: SAuth.ResendCodeBody, + response: SAuth.ResendCodeResponse, + }, + }); + } } diff --git a/src/entities/auth/model/schemas.ts b/src/entities/auth/model/schemas.ts index 2b0dee9..9ef3983 100644 --- a/src/entities/auth/model/schemas.ts +++ b/src/entities/auth/model/schemas.ts @@ -1,5 +1,5 @@ import { z } from 'zod/v4'; -import { GlobalSuccess } from 'shared/api'; +import { DateTimeString, GlobalSuccess } from 'shared/api'; import { MAX_NAME_LENGTH, MAX_PASS_LENGTH, @@ -76,3 +76,14 @@ export const ResetPasswordConfirmBody = z.object({ }); export const ResetPasswordConfirmResponse = GlobalSuccess; + +export const ResendCodeBody = z.object({ + context: z.enum(['sign-up', 'reset-password']), + email: Email, +}); + +export const ResendCodeResponse = GlobalSuccess.extend({ + nextResendAt: DateTimeString, + retryAfterSeconds: z.number(), + retries: z.number(), +}); diff --git a/src/entities/auth/model/types.ts b/src/entities/auth/model/types.ts index c83f166..7cfe1e5 100644 --- a/src/entities/auth/model/types.ts +++ b/src/entities/auth/model/types.ts @@ -20,3 +20,6 @@ export type ResetPasswordVerifyBody = z.infer; export type ResetPasswordConfirmBody = z.infer; export type ResetPasswordConfirmResponse = z.infer; + +export type ResendCodeBody = z.infer; +export type ResendCodeResponse = z.infer; diff --git a/src/features/otp-form/model/useResend.ts b/src/features/otp-form/model/useResend.ts new file mode 100644 index 0000000..85ed2ac --- /dev/null +++ b/src/features/otp-form/model/useResend.ts @@ -0,0 +1,17 @@ +import { type DefaultError, useMutation, UseMutationOptions } from '@tanstack/react-query'; +import { AuthHttp, TAuth } from 'entities/auth'; + +export type UseResendOptions = Omit< + UseMutationOptions, + 'mutationFn' +>; + +export function useResendCode({ ...rest }: UseResendOptions = {}) { + return useMutation, DefaultError, TAuth.ResendCodeBody>({ + ...rest, + mutationFn: AuthHttp.resendCode, + meta: { + skipGlobalValidationToast: true, + }, + }); +} diff --git a/src/features/otp-form/ui/ResendCodeControl.tsx b/src/features/otp-form/ui/ResendCodeControl.tsx index 76312f5..a5b04fb 100644 --- a/src/features/otp-form/ui/ResendCodeControl.tsx +++ b/src/features/otp-form/ui/ResendCodeControl.tsx @@ -1,61 +1,47 @@ 'use client'; -import { ComponentProps, useEffect, useMemo } from 'react'; -import { toast } from 'sonner'; -import { LocalStorageDraft } from 'shared/lib/classes'; +import { ComponentProps } from 'react'; import { useTimer } from 'shared/lib/hooks'; import { classNames, formatTime } from 'shared/lib/utils'; import { Button } from 'shared/ui'; -import { DRAFT_TTL_MS, RESEND_CODE_DELAY_MS } from '../model/const'; +import { RESEND_CODE_DELAY_MS } from '../model/const'; +import { useResendCode, UseResendOptions } from '../model/useResend'; +import { TAuth } from 'entities/auth'; +import { toast } from 'sonner'; interface ResendCodeControlProps extends Omit, 'children'> { resendDelayMs?: number; - storageKey?: string; storageTtlMs?: number; + data: TAuth.ResendCodeBody; + nextResendAt: string | null; + mutateOptions?: UseResendOptions; } -interface LastSentCodeDraft extends Record { - lastSentAt: number; -} - -const getTimestampMs = (value?: string | number | Date): number => - value === undefined ? Date.now() : new Date(value).getTime(); - export function ResendCodeControl(props: ResendCodeControlProps) { - const { - className, - resendDelayMs = RESEND_CODE_DELAY_MS, - storageKey = 'last-sent-code', - storageTtlMs = DRAFT_TTL_MS, - ...divProps - } = props; + const { className, data, nextResendAt, mutateOptions = {}, ...divProps } = props; - const lastSentCodeDraft = useMemo( - () => new LocalStorageDraft(storageKey), - [storageKey] - ); + const initialRemainingMs = nextResendAt + ? Math.max(0, new Date(nextResendAt).getTime() - new Date().getTime()) + : RESEND_CODE_DELAY_MS; const { isFinished, remainingMs, restart } = useTimer({ - durationMs: resendDelayMs, - autoStart: false, + durationMs: initialRemainingMs, + autoStart: initialRemainingMs > 0, }); - useEffect(() => { - const draft = lastSentCodeDraft.read(); - const now = getTimestampMs(); - const lastSentAt = draft?.lastSentAt ?? now; - - if (!draft) { - lastSentCodeDraft.set({ lastSentAt }, storageTtlMs); - } - - restart(resendDelayMs - (now - lastSentAt)); - }, [lastSentCodeDraft, resendDelayMs, restart, storageTtlMs]); + const resend = useResendCode({ + ...mutateOptions, + onSuccess: (data, ...args) => { + mutateOptions.onSuccess?.(data, ...args); + restart(data.retryAfterSeconds * 1000); + toast.success( + data.message || 'Повторный код для восстановления пароля отправлен на вашу почту' + ); + }, + }); const handleResendCode = () => { - lastSentCodeDraft.set({ lastSentAt: getTimestampMs() }, storageTtlMs); - restart(); - toast.warning('Функционал в разработке!'); + resend.mutate(data); }; return ( diff --git a/src/pages/auth/forgot-password/ui/ForgotPasswordPage.tsx b/src/pages/auth/forgot-password/ui/ForgotPasswordPage.tsx index 5aae267..6946f33 100644 --- a/src/pages/auth/forgot-password/ui/ForgotPasswordPage.tsx +++ b/src/pages/auth/forgot-password/ui/ForgotPasswordPage.tsx @@ -6,7 +6,13 @@ import { EmailForm } from './EmailForm'; import { PasswordForm } from './PasswordForm'; import { useRouter } from 'next/navigation'; import { toast } from 'sonner'; -import { DRAFT_TTL_MS, OTPForm, OTPFormLoader, ResendCodeControl } from 'features/otp-form'; +import { + DRAFT_TTL_MS, + OTPForm, + OTPFormLoader, + RESEND_CODE_DELAY_MS, + ResendCodeControl, +} from 'features/otp-form'; import { useSendCode } from '../model/useSendCode'; import { useLocalStorageDraft } from 'shared/lib/hooks'; @@ -15,6 +21,7 @@ type ForgotPasswordStep = 'email' | 'password' | 'otp' | null; interface ForgotPasswordDraft extends Record { email: string; step: ForgotPasswordStep; + nextResendAt: string | null; } const DRAFT_KEY = 'drafted-forgot-password'; @@ -23,12 +30,12 @@ function ForgotPasswordPage() { const router = useRouter(); const sendCode = useSendCode(); const { draft, setDraft, clearDraft } = useLocalStorageDraft(DRAFT_KEY, { - defaultValues: { email: '', step: 'email' }, + defaultValues: { email: '', step: 'email', nextResendAt: null }, }); const email = draft?.email ?? ''; const step: ForgotPasswordStep = draft?.step ?? null; - const resendCodeStorageKey = `${DRAFT_KEY}:last-sent-code:${email}`; + const nextResendAt = draft?.nextResendAt ?? null; if (!step) { return ( @@ -49,7 +56,15 @@ function ForgotPasswordPage() { {step === 'email' ? ( setDraft({ email, step: 'otp' }, DRAFT_TTL_MS), + onSuccess: (_res, { email }) => + setDraft( + { + email, + step: 'otp', + nextResendAt: new Date(Date.now() + RESEND_CODE_DELAY_MS).toISOString(), + }, + DRAFT_TTL_MS + ), }} /> ) : null} @@ -58,10 +73,25 @@ function ForgotPasswordPage() { email={email} mutation={sendCode} mutateOptions={{ - onSuccess: () => setDraft({ email, step: 'password' }), + onSuccess: () => + setDraft({ + email, + step: 'password', + nextResendAt, + }), }} > - + + setDraft({ email, step, nextResendAt: data.nextResendAt }, DRAFT_TTL_MS), + onError: () => { + setDraft({ step: 'email', email, nextResendAt: null }); + }, + }} + nextResendAt={nextResendAt} + data={{ email, context: 'reset-password' }} + /> )} diff --git a/src/pages/auth/signup/ui/SignupPage.tsx b/src/pages/auth/signup/ui/SignupPage.tsx index 459a420..7ee77aa 100644 --- a/src/pages/auth/signup/ui/SignupPage.tsx +++ b/src/pages/auth/signup/ui/SignupPage.tsx @@ -2,7 +2,13 @@ import { FieldDescription, Link, Logo, Spinner } from 'shared/ui'; import { SignupForm } from './SignupForm'; -import { DRAFT_TTL_MS, OTPForm, OTPFormLoader, ResendCodeControl } from 'features/otp-form'; +import { + DRAFT_TTL_MS, + OTPForm, + OTPFormLoader, + RESEND_CODE_DELAY_MS, + ResendCodeControl, +} from 'features/otp-form'; import { useRouter } from 'next/navigation'; import { AccessToken } from 'shared/api'; import { routes } from 'shared/config'; @@ -15,6 +21,7 @@ type SignupStep = 'signup' | 'otp' | null; interface SignupDraft extends Record { email: string; step: SignupStep; + nextResendAt: string | null; } const DRAFT_KEY = 'drafted-signup'; @@ -23,12 +30,12 @@ function SignupPage() { const router = useRouter(); const sendConfirm = useSignupConfirm(); const { draft, setDraft, resetDraft, clearDraft } = useLocalStorageDraft(DRAFT_KEY, { - defaultValues: { email: '', step: 'signup' }, + defaultValues: { email: '', step: 'signup', nextResendAt: null }, }); const email = draft?.email ?? ''; const step: SignupStep = draft?.step ?? null; - const resendCodeStorageKey = `${DRAFT_KEY}:last-sent-code:${email}`; + const nextResendAt = draft?.nextResendAt ?? null; if (!step) { return ( @@ -50,7 +57,15 @@ function SignupPage() { {step === 'signup' ? ( setDraft({ email, step: 'otp' }, DRAFT_TTL_MS), + onSuccess: (_res, { email }) => + setDraft( + { + email, + step: 'otp', + nextResendAt: new Date(Date.now() + RESEND_CODE_DELAY_MS).toISOString(), + }, + DRAFT_TTL_MS + ), }} /> ) : null} @@ -71,7 +86,18 @@ function SignupPage() { }, }} > - + { + setDraft({ email, step, nextResendAt: data.nextResendAt }, DRAFT_TTL_MS); + }, + onError: () => { + setDraft({ step: 'signup', email, nextResendAt: null }); + }, + }} + nextResendAt={nextResendAt} + data={{ email, context: 'reset-password' }} + /> ) : null}