B2B-88: add starter kit structure and elements
This commit is contained in:
1
packages/otp/src/components/index.ts
Normal file
1
packages/otp/src/components/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { VerifyOtpForm } from './verify-otp-form';
|
||||
257
packages/otp/src/components/verify-otp-form.tsx
Normal file
257
packages/otp/src/components/verify-otp-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user