B2B-88: add starter kit structure and elements

This commit is contained in:
devmc-ee
2025-06-08 16:18:30 +03:00
parent 657a36a298
commit e7b25600cb
1280 changed files with 77893 additions and 5688 deletions

View File

@@ -0,0 +1 @@
export { VerifyOtpForm } from './verify-otp-form';

View File

@@ -0,0 +1,257 @@
'use client';
import { useState, useTransition } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { Button } from '@kit/ui/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormMessage,
} from '@kit/ui/form';
import { If } from '@kit/ui/if';
import {
InputOTP,
InputOTPGroup,
InputOTPSeparator,
InputOTPSlot,
} from '@kit/ui/input-otp';
import { Spinner } from '@kit/ui/spinner';
import { Trans } from '@kit/ui/trans';
import { sendOtpEmailAction } from '../server/server-actions';
// Email form schema
const SendOtpSchema = z.object({
email: z.string().email({ message: 'Please enter a valid email address' }),
});
// OTP verification schema
const VerifyOtpSchema = z.object({
otp: z.string().min(6, { message: 'Please enter a valid OTP code' }).max(6),
});
type VerifyOtpFormProps = {
// Purpose of the OTP (e.g., 'email-verification', 'password-reset')
purpose: string;
// Callback when OTP is successfully verified
onSuccess: (otp: string) => void;
// Email address to send the OTP to
email: string;
// Customize form appearance
className?: string;
// Optional cancel button
CancelButton?: React.ReactNode;
};
export function VerifyOtpForm({
purpose,
email,
className,
CancelButton,
onSuccess,
}: VerifyOtpFormProps) {
// Track the current step (email entry or OTP verification)
const [step, setStep] = useState<'email' | 'otp'>('email');
const [isPending, startTransition] = useTransition();
// Track errors
const [error, setError] = useState<string | null>(null);
// Track verification success
const [, setVerificationSuccess] = useState(false);
// Email form
const emailForm = useForm<z.infer<typeof SendOtpSchema>>({
resolver: zodResolver(SendOtpSchema),
defaultValues: {
email,
},
});
// OTP verification form
const otpForm = useForm<z.infer<typeof VerifyOtpSchema>>({
resolver: zodResolver(VerifyOtpSchema),
defaultValues: {
otp: '',
},
});
// Handle sending OTP email
const handleSendOtp = () => {
setError(null);
startTransition(async () => {
try {
const result = await sendOtpEmailAction({
purpose,
email,
});
if (result.success) {
setStep('otp');
} else {
setError(result.error || 'Failed to send OTP. Please try again.');
}
} catch (err) {
setError('An unexpected error occurred. Please try again.');
console.error('Error sending OTP:', err);
}
});
};
// Handle OTP verification
const handleVerifyOtp = (data: z.infer<typeof VerifyOtpSchema>) => {
setVerificationSuccess(true);
onSuccess(data.otp);
};
return (
<div className={className}>
{step === 'email' ? (
<Form {...emailForm}>
<form
className="flex flex-col gap-y-8"
onSubmit={emailForm.handleSubmit(handleSendOtp)}
>
<div className="flex flex-col gap-y-2">
<p className="text-muted-foreground text-sm">
<Trans
i18nKey="common:otp.requestVerificationCodeDescription"
values={{ email }}
/>
</p>
</div>
<If condition={Boolean(error)}>
<Alert variant="destructive">
<ExclamationTriangleIcon className="h-4 w-4" />
<AlertTitle>
<Trans i18nKey="common:otp.errorSendingCode" />
</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
</If>
<div className="flex w-full justify-end gap-2">
{CancelButton}
<Button
type="submit"
disabled={isPending}
data-test="otp-send-verification-button"
>
{isPending ? (
<>
<Spinner className="mr-2 h-4 w-4" />
<Trans i18nKey="common:otp.sendingCode" />
</>
) : (
<Trans i18nKey="common:otp.sendVerificationCode" />
)}
</Button>
</div>
</form>
</Form>
) : (
<Form {...otpForm}>
<div className="flex w-full flex-col items-center gap-y-8">
<div className="text-muted-foreground text-sm">
<Trans i18nKey="common:otp.codeSentToEmail" values={{ email }} />
</div>
<form
className="flex w-full flex-col items-center space-y-8"
onSubmit={otpForm.handleSubmit(handleVerifyOtp)}
>
<If condition={Boolean(error)}>
<Alert variant="destructive">
<ExclamationTriangleIcon className="h-4 w-4" />
<AlertTitle>
<Trans i18nKey="common:error" />
</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
</If>
<FormField
name="otp"
control={otpForm.control}
render={({ field }) => (
<FormItem>
<FormControl>
<InputOTP
maxLength={6}
{...field}
disabled={isPending}
data-test="otp-input"
>
<InputOTPGroup>
<InputOTPSlot index={0} data-slot="0" />
<InputOTPSlot index={1} data-slot="1" />
<InputOTPSlot index={2} data-slot="2" />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={3} data-slot="3" />
<InputOTPSlot index={4} data-slot="4" />
<InputOTPSlot index={5} data-slot="5" />
</InputOTPGroup>
</InputOTP>
</FormControl>
<FormDescription>
<Trans i18nKey="common:otp.enterCodeFromEmail" />
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex w-full justify-between gap-2">
{CancelButton}
<div className="flex justify-end gap-2">
<Button
type="button"
variant="ghost"
disabled={isPending}
onClick={() => setStep('email')}
>
<Trans i18nKey="common:otp.requestNewCode" />
</Button>
<Button
type="submit"
disabled={isPending}
data-test="otp-verify-button"
>
{isPending ? (
<>
<Spinner className="mr-2 h-4 w-4" />
<Trans i18nKey="common:otp.verifying" />
</>
) : (
<Trans i18nKey="common:otp.verifyCode" />
)}
</Button>
</div>
</div>
</form>
</div>
</Form>
)}
</div>
);
}