diff --git a/.lintstagedrc.mjs b/.lintstagedrc.mjs index 48e2a43..2d04fd4 100644 --- a/.lintstagedrc.mjs +++ b/.lintstagedrc.mjs @@ -1,5 +1,5 @@ export default { '*.{ts,tsx,js,jsx,mjs}': ['eslint --fix --no-warn-ignored', 'prettier --write'], - '*.{json,css,md}': ['prettier --write'], + '*.*': ['prettier --write'], '*.{ts,tsx}': ['vitest related --run'], }; diff --git a/src/app/styles/animations.scss b/src/app/styles/animations.scss new file mode 100644 index 0000000..4966f20 --- /dev/null +++ b/src/app/styles/animations.scss @@ -0,0 +1,56 @@ +@theme { + --animate-head-shake: headShake 0.7s ease-in-out; + --animate-fade-destructive-input: fadeDestructiveInput 0.7s ease-in-out; + --animate-fade-in: fadeIn 0.7s ease-in-out forwards; + --animate-fade-out: fadeOut 0.7s ease-in-out forwards; + + @keyframes headShake { + 0% { + transform: translateX(0); + } + 6.5% { + transform: translateX(-6px) rotateY(-9deg); + } + 18.5% { + transform: translateX(5px) rotateY(7deg); + } + 31.5% { + transform: translateX(-3px) rotateY(-5deg); + } + 43.5% { + transform: translateX(2px) rotateY(3deg); + } + 50% { + transform: translateX(0); + } + } + + @keyframes fadeDestructiveInput { + from { + border-color: var(--destructive); + background-color: color-mix(in oklch, var(--destructive) 10%, transparent); + } + to { + border-color: var(--input); + background-color: unset; + } + } + + @keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } + } + + @keyframes fadeOut { + from { + opacity: 1; + } + to { + opacity: 0; + } + } +} diff --git a/src/app/styles/global.css b/src/app/styles/global.css index 2569a36..5515d55 100644 --- a/src/app/styles/global.css +++ b/src/app/styles/global.css @@ -1,6 +1,7 @@ @import 'tailwindcss'; @import 'tw-animate-css'; @import 'shadcn/tailwind.css'; +@import './animations.scss'; @custom-variant dark (&:is(.dark *)); @@ -45,18 +46,6 @@ --color-link-foreground: var(--link-foreground); } -@theme { - --animate-fade-in-6: fade-in 0.6s ease-in-out both; - @keyframes fade-in { - 0% { - opacity: 0; - } - 100% { - opacity: 1; - } - } -} - :root { --radius: 0.625rem; --background: oklch(1 0 0); diff --git a/src/entities/auth/index.ts b/src/entities/auth/index.ts index 8b6d089..7bdacbe 100644 --- a/src/entities/auth/index.ts +++ b/src/entities/auth/index.ts @@ -1,3 +1,4 @@ export * as SAuth from './model/schemas'; export * as TAuth from './model/types'; +export * as CAuth from './model/const'; export { AuthHttp } from './api/http'; diff --git a/src/entities/auth/model/const.ts b/src/entities/auth/model/const.ts new file mode 100644 index 0000000..7293574 --- /dev/null +++ b/src/entities/auth/model/const.ts @@ -0,0 +1,3 @@ +export const MIN_PASS_LENGTH = 8; +export const MAX_PASS_LENGTH = 32; +export const OTP_LENGTH = 6; diff --git a/src/entities/auth/model/schemas.ts b/src/entities/auth/model/schemas.ts index 3389e21..6342400 100644 --- a/src/entities/auth/model/schemas.ts +++ b/src/entities/auth/model/schemas.ts @@ -1,9 +1,6 @@ import { z } from 'zod/v4'; import { GlobalSuccess } from 'shared/api'; - -const MIN_PASS_LENGTH = 8; -const MAX_PASS_LENGTH = 32; -const OTP_LENGTH = 6; +import { MAX_PASS_LENGTH, MIN_PASS_LENGTH, OTP_LENGTH } from './const'; export const Email = z.string().min(1, 'Обязательное поле').check(z.email('Неверный формат email')); diff --git a/src/features/otp-form/index.ts b/src/features/otp-form/index.ts index 720cb3d..a2daa32 100644 --- a/src/features/otp-form/index.ts +++ b/src/features/otp-form/index.ts @@ -1 +1,4 @@ export { OTPForm } from './ui/OTPForm'; +export { OTPFormLoader } from './ui/OTPFormLoader'; +export { ResendCodeControl } from './ui/ResendCodeControl'; +export { DRAFT_TTL_MS, RESEND_CODE_DELAY_MS } from './model/const'; diff --git a/src/features/otp-form/model/const.ts b/src/features/otp-form/model/const.ts new file mode 100644 index 0000000..e932704 --- /dev/null +++ b/src/features/otp-form/model/const.ts @@ -0,0 +1,2 @@ +export const DRAFT_TTL_MS = 1.5 * 60 * 1000; +export const RESEND_CODE_DELAY_MS = 60 * 1000; diff --git a/src/features/otp-form/model/schemas.ts b/src/features/otp-form/model/schemas.ts index f99818f..9df7868 100644 --- a/src/features/otp-form/model/schemas.ts +++ b/src/features/otp-form/model/schemas.ts @@ -1,11 +1,7 @@ import { z } from 'zod/v4'; import { SAuth } from 'entities/auth'; -export const OtpForm = z.object({ - code: SAuth.OTPCode, -}); - -export const otpFormBody = z.object({ +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 index 7faf162..5e6fd62 100644 --- a/src/features/otp-form/model/types.ts +++ b/src/features/otp-form/model/types.ts @@ -1,5 +1,4 @@ import { z } from 'zod/v4'; import * as SOtpForm from './schemas'; -export type OtpForm = z.infer; -export type FormBody = z.infer; +export type OTPFormBody = z.infer; diff --git a/src/features/otp-form/ui/OTPForm.tsx b/src/features/otp-form/ui/OTPForm.tsx index d2794e1..95dd182 100644 --- a/src/features/otp-form/ui/OTPForm.tsx +++ b/src/features/otp-form/ui/OTPForm.tsx @@ -1,69 +1,75 @@ 'use client'; -import { Controller, useForm } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; import { - Button, Card, CardContent, CardDescription, + CardFooter, CardHeader, CardTitle, - Field, - FieldError, - FieldGroup, InputOtp, InputOTPGroup, InputOTPSlot, - Spinner, } from 'shared/ui'; -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 { DefaultError, MutateOptions, UseMutationResult } from '@tanstack/react-query'; import { REGEXP_ONLY_DIGITS } from 'input-otp'; -import { ComponentProps } from 'react'; -import type { FormBody, OtpForm } from '../model/types'; +import { ComponentProps, useState } from 'react'; +import { CAuth } from 'entities/auth'; +import type { OTPFormBody } from '../model/types'; +import { classNames } from 'shared/lib/utils'; -interface OTPFormProps extends Omit, 'children'> { +interface OTPFormProps extends Omit, 'onAnimationEnd'> { email: string; - onSuccess?: (body: FormBody, res: TData) => void; - autoFocusCode?: boolean; - query: UseMutationResult; + mutation: UseMutationResult; + mutateOptions?: MutateOptions; + codeLength?: number; } -export function OTPForm({ - className, - email, - onSuccess, - autoFocusCode = false, - query, - ...props -}: OTPFormProps) { - const form = useForm({ - resolver: zodResolver(OtpFormSchema), - defaultValues: { - code: '', - }, - }); +export function OTPForm(props: OTPFormProps) { + const { + email, + mutation, + mutateOptions = {}, + codeLength = CAuth.OTP_LENGTH, + children, + ...containerProps + } = props; + const { onError, onSuccess, ...restMutateOptions } = mutateOptions; + const [code, setCode] = useState(''); + const [hasCodeError, setHasCodeError] = useState(false); - const onSubmit = (data: OtpForm) => { - const body: FormBody = { - code: data.code, - email, - }; + const triggerCodeErrorAnimation = () => { + setHasCodeError(false); - query.mutate(body, { - onSuccess: (res) => { - onSuccess?.(body, res); - }, - onError: (err) => { - setFormErrors(extractValidationIssues(err), form); - }, + requestAnimationFrame(() => { + requestAnimationFrame(() => { + setHasCodeError(true); + setCode(''); + }); }); }; - const disabled = query.isPending || query.isSuccess; + const handleCodeChange = (value: string) => { + if (value.length <= codeLength) { + setCode(value); + } + + if (value.length >= codeLength) { + mutation.mutate( + { code: value, email }, + { + onSuccess: (...args) => { + onSuccess?.(...args); + }, + onError: (...args) => { + triggerCodeErrorAnimation(); + onError?.(...args); + }, + ...restMutateOptions, + } + ); + } + }; return ( @@ -71,53 +77,40 @@ export function OTPForm({ Введите код Код подтверждения отправлен на вашу почту. - -
setHasCodeError(false)} {...containerProps}> + - - ( - - - - - - - - - - - - {fieldState.invalid && ( - - )} - - )} - /> - - - - - + + {Array.from({ length: codeLength }, (_, index) => ( + + ))} + +
+ {children ? ( + {children} + ) : null}
); } diff --git a/src/features/otp-form/ui/OTPFormLoader.tsx b/src/features/otp-form/ui/OTPFormLoader.tsx new file mode 100644 index 0000000..4cc84cd --- /dev/null +++ b/src/features/otp-form/ui/OTPFormLoader.tsx @@ -0,0 +1,25 @@ +import { MutationStatus } from '@tanstack/react-query'; +import { ComponentProps } from 'react'; +import { classNames } from 'shared/lib/utils'; +import { Spinner } from 'shared/ui'; + +interface OTPFormLoaderProps extends ComponentProps<'div'> { + status: MutationStatus; +} + +export function OTPFormLoader({ className, status, ...props }: OTPFormLoaderProps) { + const spinnerClass = + status === 'pending' ? 'animate-fade-in' : status === 'idle' ? 'opacity-0' : 'animate-fade-out'; + + return ( +
+ +
+ ); +} diff --git a/src/features/otp-form/ui/ResendCodeControl.tsx b/src/features/otp-form/ui/ResendCodeControl.tsx new file mode 100644 index 0000000..76312f5 --- /dev/null +++ b/src/features/otp-form/ui/ResendCodeControl.tsx @@ -0,0 +1,75 @@ +'use client'; + +import { ComponentProps, useEffect, useMemo } from 'react'; +import { toast } from 'sonner'; +import { LocalStorageDraft } from 'shared/lib/classes'; +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'; + +interface ResendCodeControlProps extends Omit, 'children'> { + resendDelayMs?: number; + storageKey?: string; + storageTtlMs?: number; +} + +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 lastSentCodeDraft = useMemo( + () => new LocalStorageDraft(storageKey), + [storageKey] + ); + + const { isFinished, remainingMs, restart } = useTimer({ + durationMs: resendDelayMs, + autoStart: false, + }); + + 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 handleResendCode = () => { + lastSentCodeDraft.set({ lastSentAt: getTimestampMs() }, storageTtlMs); + restart(); + toast.warning('Функционал в разработке!'); + }; + + return ( +
+ + + {formatTime(remainingMs)} + +
+ ); +} diff --git a/src/pages/forgot-password/ui/ForgotPasswordPage.tsx b/src/pages/forgot-password/ui/ForgotPasswordPage.tsx index 055322f..69205f4 100644 --- a/src/pages/forgot-password/ui/ForgotPasswordPage.tsx +++ b/src/pages/forgot-password/ui/ForgotPasswordPage.tsx @@ -6,7 +6,7 @@ import { EmailForm } from './EmailForm'; import { PasswordForm } from './PasswordForm'; import { useRouter } from 'next/navigation'; import { toast } from 'sonner'; -import { OTPForm } from 'features/otp-form'; +import { DRAFT_TTL_MS, OTPForm, OTPFormLoader, ResendCodeControl } from 'features/otp-form'; import { useSendCode } from '../model/useSendCode'; import { useLocalStorageDraft } from 'shared/lib/hooks'; @@ -18,18 +18,17 @@ interface ForgotPasswordDraft extends Record { } 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; + const resendCodeStorageKey = `${DRAFT_KEY}:last-sent-code:${email}`; if (!step) { return ( @@ -48,15 +47,19 @@ function ForgotPasswordPage() { {step === 'email' ? ( - setDraft({ email, step: 'otp' })} /> + setDraft({ email, step: 'otp' }, DRAFT_TTL_MS)} /> ) : null} {step === 'otp' && ( setDraft({ email, step: 'password' })} - /> + mutation={sendCode} + mutateOptions={{ + onSuccess: () => setDraft({ email, step: 'password' }), + }} + > + + + )} {step === 'password' && ( name.split(/\s+/).length <= MAX_FULL_NAME_WORDS, { + message: 'Введите только имя и фамилию', + }), email: SAuth.Email, password: SAuth.Password, confirmPassword: SAuth.Password, diff --git a/src/pages/signup/ui/SignupPage.tsx b/src/pages/signup/ui/SignupPage.tsx index d1a5771..17d51d9 100644 --- a/src/pages/signup/ui/SignupPage.tsx +++ b/src/pages/signup/ui/SignupPage.tsx @@ -1,8 +1,8 @@ 'use client'; -import { Link, Logo, Spinner } from 'shared/ui'; +import { FieldDescription, Link, Logo, Spinner } from 'shared/ui'; import { SignupForm } from './SignupForm'; -import { OTPForm } from 'features/otp-form'; +import { DRAFT_TTL_MS, OTPForm, OTPFormLoader, ResendCodeControl } from 'features/otp-form'; import { useRouter } from 'next/navigation'; import { AccessToken } from 'shared/api'; import { routes } from 'shared/config'; @@ -18,18 +18,17 @@ interface SignupDraft extends Record { } const DRAFT_KEY = 'drafted-signup'; -const DRAFT_TTL_MS = 15 * 60 * 1000; function SignupPage() { const router = useRouter(); const sendConfirm = useSignupConfirm(); - const { draft, setDraft, clearDraft } = useLocalStorageDraft(DRAFT_KEY, { - defaultTTLms: DRAFT_TTL_MS, + const { draft, setDraft, resetDraft, clearDraft } = useLocalStorageDraft(DRAFT_KEY, { defaultValues: { email: '', step: 'signup' }, }); const email = draft?.email ?? ''; const step: SignupStep = draft?.step ?? null; + const resendCodeStorageKey = `${DRAFT_KEY}:last-sent-code:${email}`; if (!step) { return ( @@ -49,24 +48,36 @@ function SignupPage() { {step === 'signup' ? ( - setDraft({ email, step: 'otp' })} /> + setDraft({ email, step: 'otp' }, DRAFT_TTL_MS)} /> ) : null} {step === 'otp' ? ( { - if (res.success) { - clearDraft(); - AccessToken.token = res.token; - router.replace(routes.profile()); - if (res.message) { - toast.success(res.message); + mutation={sendConfirm} + mutateOptions={{ + onSuccess: (res) => { + if (res.success) { + clearDraft(); + AccessToken.token = res.token; + router.replace(routes.profile()); + if (res.message) { + toast.success(res.message); + } } - } + }, }} - /> + > + + + + ) : null} + {step === 'otp' ? ( + + Передумали?{' '} + + Назад + + ) : null} diff --git a/src/shared/lib/classes/local-storage-draft.ts b/src/shared/lib/classes/local-storage-draft.ts index b004a60..0916e1d 100644 --- a/src/shared/lib/classes/local-storage-draft.ts +++ b/src/shared/lib/classes/local-storage-draft.ts @@ -45,7 +45,7 @@ export class LocalStorageDraft { } } - set(payload: T, ttlMs: number): DraftWithTTL { + set(payload: T, ttlMs?: number): DraftWithTTL { const nextDraft = this._create(payload, ttlMs); if (typeof window !== 'undefined') { @@ -74,11 +74,21 @@ export class LocalStorageDraft { this._notify(this.read()); } - private _create(payload: T, ttlMs: number): DraftWithTTL { - return { - ...payload, - ttl: Date.now() + ttlMs, - }; + private _create(payload: T, ttlMs?: number): DraftWithTTL { + const ttl = this.read()?.ttl; + if (ttl && ttlMs === undefined) { + return { + ...payload, + ttl, + }; + } else if (typeof ttlMs === 'number') { + return { + ...payload, + ttl: Date.now() + ttlMs, + }; + } + + throw new Error('ttlMs is required'); } private _notify(draft: DraftWithTTL | null): void { diff --git a/src/shared/lib/hooks/index.ts b/src/shared/lib/hooks/index.ts index 38d20df..ae32932 100644 --- a/src/shared/lib/hooks/index.ts +++ b/src/shared/lib/hooks/index.ts @@ -3,3 +3,4 @@ export { useIsMobile } from './useMobile'; export { useDebouncedCallback } from './useDebouncedCallback'; export { useQueuedDebouncedMutation } from './useQueuedDebouncedMutation'; export { useLocalStorageDraft, type DraftWithTTL } from './useLocalStorageDraft'; +export { useTimer, type UseTimerOptions, type UseTimerReturn } from './use-timer/useTimer'; diff --git a/src/shared/lib/hooks/use-timer/useTimer.test.ts b/src/shared/lib/hooks/use-timer/useTimer.test.ts new file mode 100644 index 0000000..2eb1066 --- /dev/null +++ b/src/shared/lib/hooks/use-timer/useTimer.test.ts @@ -0,0 +1,124 @@ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { useTimer } from './useTimer'; + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('useTimer', () => { + test('starts automatically and completes the timer', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); + const onComplete = vi.fn(); + + const { result } = renderHook(() => useTimer({ durationMs: 3000, onComplete })); + + expect(result.current.remainingMs).toBe(3000); + expect(result.current.isRunning).toBe(true); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + + expect(result.current.remainingMs).toBe(2000); + expect(result.current.elapsedMs).toBe(1000); + expect(result.current.progress).toBe(1 / 3); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + + expect(result.current.remainingMs).toBe(0); + expect(result.current.isRunning).toBe(false); + expect(result.current.isFinished).toBe(true); + expect(onComplete).toHaveBeenCalledTimes(1); + }); + + test('pauses and resumes the timer', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); + + const { result } = renderHook(() => useTimer({ durationMs: 5000 })); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + + act(() => { + result.current.pause(); + }); + + expect(result.current.remainingMs).toBe(3000); + expect(result.current.isRunning).toBe(false); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + + expect(result.current.remainingMs).toBe(3000); + + act(() => { + result.current.start(); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + + expect(result.current.remainingMs).toBe(2000); + expect(result.current.isRunning).toBe(true); + }); + + test('resets and restarts the timer', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); + + const { result } = renderHook(() => useTimer({ durationMs: 4000, autoStart: false })); + + expect(result.current.remainingMs).toBe(4000); + expect(result.current.isRunning).toBe(false); + + act(() => { + result.current.start(); + vi.advanceTimersByTime(1000); + }); + + act(() => { + result.current.reset(); + }); + + expect(result.current.remainingMs).toBe(4000); + expect(result.current.isRunning).toBe(false); + + act(() => { + result.current.restart(2000); + }); + + expect(result.current.remainingMs).toBe(2000); + expect(result.current.isRunning).toBe(true); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + + expect(result.current.remainingMs).toBe(0); + expect(result.current.isFinished).toBe(true); + }); +}); diff --git a/src/shared/lib/hooks/use-timer/useTimer.ts b/src/shared/lib/hooks/use-timer/useTimer.ts new file mode 100644 index 0000000..c13db3f --- /dev/null +++ b/src/shared/lib/hooks/use-timer/useTimer.ts @@ -0,0 +1,156 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +interface UseTimerOptions { + durationMs: number; + intervalMs?: number; + autoStart?: boolean; + onComplete?: () => void; +} + +interface UseTimerReturn { + remainingMs: number; + elapsedMs: number; + progress: number; + isRunning: boolean; + isFinished: boolean; + start: () => void; + pause: () => void; + reset: (durationMs?: number) => void; + restart: (durationMs?: number) => void; +} + +const normalizeDuration = (durationMs: number) => Math.max(0, durationMs); + +export function useTimer({ + durationMs, + intervalMs = 1000, + autoStart = true, + onComplete, +}: UseTimerOptions): UseTimerReturn { + const normalizedDurationMs = normalizeDuration(durationMs); + const normalizedIntervalMs = Math.max(1, intervalMs); + const onCompleteRef = useRef(onComplete); + const endTimeRef = useRef(null); + + const [remainingMs, setRemainingMs] = useState(normalizedDurationMs); + const [activeDurationMs, setActiveDurationMs] = useState(normalizedDurationMs); + const [isRunning, setIsRunning] = useState(autoStart && normalizedDurationMs > 0); + + useEffect(() => { + onCompleteRef.current = onComplete; + }, [onComplete]); + + const completeTimer = useCallback(() => { + endTimeRef.current = null; + setRemainingMs(0); + setIsRunning(false); + onCompleteRef.current?.(); + }, []); + + const updateRemainingMs = useCallback(() => { + if (!endTimeRef.current) { + return; + } + + const nextRemainingMs = Math.max(0, endTimeRef.current - Date.now()); + + if (nextRemainingMs === 0) { + completeTimer(); + return; + } + + setRemainingMs(nextRemainingMs); + }, [completeTimer]); + + useEffect(() => { + if (!isRunning) { + return; + } + + endTimeRef.current ??= Date.now() + remainingMs; + const timeoutMs = Math.min(normalizedIntervalMs, remainingMs); + const timeoutId = setTimeout(updateRemainingMs, timeoutMs); + + return () => clearTimeout(timeoutId); + }, [isRunning, normalizedIntervalMs, remainingMs, updateRemainingMs]); + + const start = useCallback(() => { + if (isRunning) { + return; + } + + setRemainingMs((currentRemainingMs) => { + const nextRemainingMs = currentRemainingMs > 0 ? currentRemainingMs : activeDurationMs; + + if (nextRemainingMs > 0) { + endTimeRef.current = Date.now() + nextRemainingMs; + setIsRunning(true); + } + + return nextRemainingMs; + }); + }, [activeDurationMs, isRunning]); + + const pause = useCallback(() => { + if (!isRunning) { + return; + } + + const nextRemainingMs = endTimeRef.current + ? Math.max(0, endTimeRef.current - Date.now()) + : remainingMs; + + endTimeRef.current = null; + setRemainingMs(nextRemainingMs); + setIsRunning(false); + }, [isRunning, remainingMs]); + + const reset = useCallback( + (nextDurationMs = normalizedDurationMs) => { + const nextRemainingMs = normalizeDuration(nextDurationMs); + + endTimeRef.current = null; + setActiveDurationMs(nextRemainingMs); + setRemainingMs(nextRemainingMs); + setIsRunning(false); + }, + [normalizedDurationMs] + ); + + const restart = useCallback( + (nextDurationMs = normalizedDurationMs) => { + const nextRemainingMs = normalizeDuration(nextDurationMs); + + endTimeRef.current = nextRemainingMs > 0 ? Date.now() + nextRemainingMs : null; + setActiveDurationMs(nextRemainingMs); + setRemainingMs(nextRemainingMs); + setIsRunning(nextRemainingMs > 0); + }, + [normalizedDurationMs] + ); + + const elapsedMs = Math.max(0, activeDurationMs - remainingMs); + const progress = useMemo(() => { + if (activeDurationMs === 0) { + return 1; + } + + return Math.min(1, elapsedMs / activeDurationMs); + }, [activeDurationMs, elapsedMs]); + + return { + remainingMs, + elapsedMs, + progress, + isRunning, + isFinished: remainingMs === 0, + start, + pause, + reset, + restart, + }; +} + +export type { UseTimerOptions, UseTimerReturn }; diff --git a/src/shared/lib/hooks/useLocalStorageDraft.ts b/src/shared/lib/hooks/useLocalStorageDraft.ts index 7f14808..9a8e0d8 100644 --- a/src/shared/lib/hooks/useLocalStorageDraft.ts +++ b/src/shared/lib/hooks/useLocalStorageDraft.ts @@ -1,6 +1,6 @@ 'use client'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { DraftRecord, DraftWithTTL, LocalStorageDraft } from '../classes'; interface UseLocalStorageDraftOptions { @@ -11,6 +11,7 @@ interface UseLocalStorageDraftOptions { interface UseLocalStorageDraftReturn { draft: DraftWithTTL | null; setDraft: (payload: T, ttlMs?: number) => void; + resetDraft: () => void; clearDraft: () => void; draftStorage: LocalStorageDraft; } @@ -20,12 +21,14 @@ export function useLocalStorageDraft( options: UseLocalStorageDraftOptions = {} ): UseLocalStorageDraftReturn { const defaultTTLms = options.defaultTTLms ?? 15 * 60 * 1000; - const defaultValues = options.defaultValues ?? null; + const defaultValuesRef = useRef(options.defaultValues ?? null); const draftStorage = useMemo(() => new LocalStorageDraft(storageKey), [storageKey]); const [draft, setDraft] = useState | null>(null); useEffect(() => { + const defaultValues = defaultValuesRef.current; + if (defaultValues && !draftStorage.read()) { draftStorage.set(defaultValues, defaultTTLms); } @@ -36,7 +39,7 @@ export function useLocalStorageDraft( draftStorage.emitCurrent(); return unsubscribe; - }, [draftStorage]); + }, [defaultTTLms, draftStorage]); const setDraftWithTTL = useCallback( (payload: T, ttlMs = defaultTTLms) => { @@ -49,9 +52,21 @@ export function useLocalStorageDraft( draftStorage.clear(); }, [draftStorage]); + const resetDraft = useCallback(() => { + const defaultValues = defaultValuesRef.current; + + if (!defaultValues) { + draftStorage.clear(); + return; + } + + draftStorage.set(defaultValues, defaultTTLms); + }, [defaultTTLms, draftStorage]); + return { draft, setDraft: setDraftWithTTL, + resetDraft, clearDraft, draftStorage, }; diff --git a/src/shared/lib/utils/class-names/class-names.test.ts b/src/shared/lib/utils/class-names/class-names.test.ts new file mode 100644 index 0000000..8e166e7 --- /dev/null +++ b/src/shared/lib/utils/class-names/class-names.test.ts @@ -0,0 +1,28 @@ +import { expect, test } from 'vitest'; +import { classNames } from './class-names'; + +test('classNames returns base class only', () => { + expect(classNames('someClass')).toBe('someClass'); +}); + +test('classNames appends additional classes', () => { + expect(classNames('someClass', {}, ['class1', 'class2'])).toBe('someClass class1 class2'); +}); + +test('classNames appends truthy modifiers', () => { + expect(classNames('someClass', { hovered: true, scrollable: true }, ['class1', 'class2'])).toBe( + 'someClass class1 class2 hovered scrollable' + ); +}); + +test('classNames skips falsy modifiers', () => { + expect(classNames('someClass', { hovered: true, scrollable: false }, ['class1', 'class2'])).toBe( + 'someClass class1 class2 hovered' + ); +}); + +test('classNames skips empty string modifiers', () => { + expect(classNames('someClass', { hovered: true, scrollable: '' }, ['class1', 'class2'])).toBe( + 'someClass class1 class2 hovered' + ); +}); diff --git a/src/shared/lib/utils/class-names/class-names.ts b/src/shared/lib/utils/class-names/class-names.ts new file mode 100644 index 0000000..68ae99c --- /dev/null +++ b/src/shared/lib/utils/class-names/class-names.ts @@ -0,0 +1,15 @@ +type Mods = Record; + +export function classNames( + cls: string, + mods: Mods = {}, + additional: (string | undefined)[] = [] +): string { + return [ + cls, + ...additional.filter(Boolean), + ...Object.entries(mods) + .filter(([, value]) => Boolean(value)) + .map(([className]) => className), + ].join(' '); +} diff --git a/src/shared/lib/utils/format-time/format-time.test.ts b/src/shared/lib/utils/format-time/format-time.test.ts new file mode 100644 index 0000000..c8bf4c3 --- /dev/null +++ b/src/shared/lib/utils/format-time/format-time.test.ts @@ -0,0 +1,16 @@ +import { expect, test } from 'vitest'; +import { formatTime } from './format-time'; + +test('formatTime formats milliseconds as mm:ss', () => { + expect(formatTime(65_000)).toBe('01:05'); +}); + +test('formatTime rounds up incomplete seconds', () => { + expect(formatTime(1_001)).toBe('00:02'); +}); + +test('formatTime returns zero time for invalid values', () => { + expect(formatTime(-1)).toBe('00:00'); + expect(formatTime(Number.NaN)).toBe('00:00'); + expect(formatTime(Number.POSITIVE_INFINITY)).toBe('00:00'); +}); diff --git a/src/shared/lib/utils/format-time/format-time.ts b/src/shared/lib/utils/format-time/format-time.ts new file mode 100644 index 0000000..4c7cc5b --- /dev/null +++ b/src/shared/lib/utils/format-time/format-time.ts @@ -0,0 +1,12 @@ +const twoDigitFormatter = new Intl.NumberFormat('ru-RU', { + minimumIntegerDigits: 2, + useGrouping: false, +}); + +export function formatTime(valueMs: number) { + const totalSeconds = Number.isFinite(valueMs) ? Math.max(0, Math.ceil(valueMs / 1000)) : 0; + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + + return `${twoDigitFormatter.format(minutes)}:${twoDigitFormatter.format(seconds)}`; +} diff --git a/src/shared/lib/utils/index.ts b/src/shared/lib/utils/index.ts index fd62f11..7c1612a 100644 --- a/src/shared/lib/utils/index.ts +++ b/src/shared/lib/utils/index.ts @@ -1,5 +1,8 @@ export { cn } from './cn'; export { capitalize } from './capitalize/capitalize'; export { formatDate } from './format-date/format-date'; +export { formatTime } from './format-time/format-time'; export { setFormErrors } from './set-form-errors'; export { createEntityKeys } from './create-entity-keys'; +export { classNames } from './class-names/class-names'; +export { throttle } from './throttle/throttle'; diff --git a/src/shared/lib/utils/throttle/throttle.test.ts b/src/shared/lib/utils/throttle/throttle.test.ts new file mode 100644 index 0000000..7d052c2 --- /dev/null +++ b/src/shared/lib/utils/throttle/throttle.test.ts @@ -0,0 +1,63 @@ +import { afterEach, expect, test, vi } from 'vitest'; +import { throttle } from './throttle'; + +afterEach(() => { + vi.useRealTimers(); +}); + +test('throttle calls function immediately', () => { + vi.useFakeTimers(); + const callback = vi.fn(); + const throttled = throttle(callback, 1_000); + + throttled('first'); + + expect(callback).toHaveBeenCalledOnce(); + expect(callback).toHaveBeenCalledWith('first'); +}); + +test('throttle calls function with latest arguments after delay', () => { + vi.useFakeTimers(); + const callback = vi.fn(); + const throttled = throttle(callback, 1_000); + + throttled('first'); + throttled('second'); + throttled('third'); + + expect(callback).toHaveBeenCalledOnce(); + + vi.advanceTimersByTime(1_000); + + expect(callback).toHaveBeenCalledTimes(2); + expect(callback).toHaveBeenLastCalledWith('third'); +}); + +test('throttle allows immediate call after delay', () => { + vi.useFakeTimers(); + const callback = vi.fn(); + const throttled = throttle(callback, 1_000); + + throttled('first'); + vi.advanceTimersByTime(1_000); + throttled('second'); + + expect(callback).toHaveBeenCalledTimes(2); + expect(callback).toHaveBeenLastCalledWith('second'); +}); + +test('throttle preserves this context for delayed call', () => { + vi.useFakeTimers(); + const calls: string[] = []; + const firstContext = { prefix: 'first' }; + const secondContext = { prefix: 'second' }; + const throttled = throttle(function (this: typeof firstContext, value: string) { + calls.push(`${this.prefix}:${value}`); + }, 1_000); + + throttled.call(firstContext, 'value'); + throttled.call(secondContext, 'next-value'); + vi.advanceTimersByTime(1_000); + + expect(calls).toEqual(['first:value', 'second:next-value']); +}); diff --git a/src/shared/lib/utils/throttle/throttle.ts b/src/shared/lib/utils/throttle/throttle.ts new file mode 100644 index 0000000..b0d6847 --- /dev/null +++ b/src/shared/lib/utils/throttle/throttle.ts @@ -0,0 +1,37 @@ +interface SavedCall { + args: TArgs; + thisArg: TThis; +} + +export function throttle( + func: (this: TThis, ...args: TArgs) => void, + ms: number +): (this: TThis, ...args: TArgs) => void { + let isThrottled = false; + let savedCall: SavedCall | null = null; + + function wrapper(this: TThis, ...args: TArgs) { + if (isThrottled) { + savedCall = { + args, + thisArg: this, + }; + return; + } + + func.apply(this, args); + + isThrottled = true; + + setTimeout(() => { + isThrottled = false; + if (savedCall) { + const { args: savedArgs, thisArg } = savedCall; + savedCall = null; + wrapper.apply(thisArg, savedArgs); + } + }, ms); + } + + return wrapper; +}