Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .lintstagedrc.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export default {

Check warning on line 1 in .lintstagedrc.mjs

View workflow job for this annotation

GitHub Actions / CI

Assign object to a variable before exporting as module 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'],
};
56 changes: 56 additions & 0 deletions src/app/styles/animations.scss
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
13 changes: 1 addition & 12 deletions src/app/styles/global.css
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@import 'shadcn/tailwind.css';
@import './animations.scss';

@custom-variant dark (&:is(.dark *));

Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions src/entities/auth/index.ts
Original file line number Diff line number Diff line change
@@ -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';
3 changes: 3 additions & 0 deletions src/entities/auth/model/const.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const MIN_PASS_LENGTH = 8;
export const MAX_PASS_LENGTH = 32;
export const OTP_LENGTH = 6;
5 changes: 1 addition & 4 deletions src/entities/auth/model/schemas.ts
Original file line number Diff line number Diff line change
@@ -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'));

Expand Down
3 changes: 3 additions & 0 deletions src/features/otp-form/index.ts
Original file line number Diff line number Diff line change
@@ -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';
2 changes: 2 additions & 0 deletions src/features/otp-form/model/const.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const DRAFT_TTL_MS = 1.5 * 60 * 1000;
export const RESEND_CODE_DELAY_MS = 60 * 1000;
6 changes: 1 addition & 5 deletions src/features/otp-form/model/schemas.ts
Original file line number Diff line number Diff line change
@@ -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,
});
3 changes: 1 addition & 2 deletions src/features/otp-form/model/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { z } from 'zod/v4';
import * as SOtpForm from './schemas';

export type OtpForm = z.infer<typeof SOtpForm.OtpForm>;
export type FormBody = z.infer<typeof SOtpForm.otpFormBody>;
export type OTPFormBody = z.infer<typeof SOtpForm.OTPFormBody>;
171 changes: 82 additions & 89 deletions src/features/otp-form/ui/OTPForm.tsx
Original file line number Diff line number Diff line change
@@ -1,123 +1,116 @@
'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<TData> extends Omit<ComponentProps<'form'>, 'children'> {
interface OTPFormProps<TData> extends Omit<ComponentProps<typeof CardContent>, 'onAnimationEnd'> {
email: string;
onSuccess?: (body: FormBody, res: TData) => void;
autoFocusCode?: boolean;
query: UseMutationResult<TData, DefaultError, FormBody>;
mutation: UseMutationResult<TData, DefaultError, OTPFormBody>;
mutateOptions?: MutateOptions<TData, DefaultError, OTPFormBody>;
codeLength?: number;
}

export function OTPForm<TData>({
className,
email,
onSuccess,
autoFocusCode = false,
query,
...props
}: OTPFormProps<TData>) {
const form = useForm<OtpForm>({
resolver: zodResolver(OtpFormSchema),
defaultValues: {
code: '',
},
});
export function OTPForm<TData>(props: OTPFormProps<TData>) {
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 (
<Card>
<CardHeader className="text-center">
<CardTitle className="text-xl">Введите код</CardTitle>
<CardDescription>Код подтверждения отправлен на вашу почту.</CardDescription>
</CardHeader>
<CardContent>
<form
className={cn('flex flex-col gap-6', className)}
onSubmit={form.handleSubmit(onSubmit)}
{...props}
<CardContent onAnimationEnd={() => setHasCodeError(false)} {...containerProps}>
<InputOtp
containerClassName="justify-center gap-3"
maxLength={codeLength}
pattern={REGEXP_ONLY_DIGITS}
inputMode="numeric"
autoComplete="one-time-code"
autoFocus
aria-label={`Код подтверждения из ${codeLength} цифр`}
value={code}
onChange={handleCodeChange}
readOnly={mutation.isPending || mutation.isSuccess}
>
<FieldGroup>
<Controller
name="code"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<InputOtp
{...field}
containerClassName="justify-center"
maxLength={6}
pattern={REGEXP_ONLY_DIGITS}
inputMode="numeric"
autoComplete="one-time-code"
autoFocus={autoFocusCode}
aria-label="Код подтверждения из 6 цифр"
disabled={disabled}
>
<InputOTPGroup>
<InputOTPSlot index={0} aria-invalid={fieldState.invalid} />
<InputOTPSlot index={1} aria-invalid={fieldState.invalid} />
<InputOTPSlot index={2} aria-invalid={fieldState.invalid} />
<InputOTPSlot index={3} aria-invalid={fieldState.invalid} />
<InputOTPSlot index={4} aria-invalid={fieldState.invalid} />
<InputOTPSlot index={5} aria-invalid={fieldState.invalid} />
</InputOTPGroup>
</InputOtp>
{fieldState.invalid && (
<FieldError className="text-center" errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Field>
<Button type="submit" disabled={disabled}>
{disabled ? <Spinner className="size-4" /> : <div className="size-4" />}
Продолжить
</Button>
</Field>
</FieldGroup>
</form>
<InputOTPGroup
className={classNames('', {
'animate-head-shake': hasCodeError,
'opacity-40': mutation.isPending,
})}
>
{Array.from({ length: codeLength }, (_, index) => (
<InputOTPSlot
className={classNames('', {
'animate-fade-destructive-input': hasCodeError,
})}
key={index}
index={index}
/>
))}
</InputOTPGroup>
</InputOtp>
</CardContent>
{children ? (
<CardFooter className="flex items-center justify-between gap-3">{children}</CardFooter>
) : null}
</Card>
);
}
25 changes: 25 additions & 0 deletions src/features/otp-form/ui/OTPFormLoader.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
className={classNames('text-muted-foreground flex items-center justify-center gap-2', {}, [
className,
spinnerClass,
])}
{...props}
>
<Spinner className="size-8" />
</div>
);
}
Loading
Loading