'use client'; import { useMutation } from '@tanstack/react-query'; import { useSearchParams } from 'next/navigation'; import { useRef, useState } from 'react'; import { Button } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; import { Label } from '@/components/ui/label'; import { useRouter } from '@/i18n/navigation'; import { useT } from '@/i18n/t'; import { authClient } from '@/lib/auth-client'; import { cn } from '@/lib/utils'; const LENGTH = 6; /** Flow A3: six boxes that advance as you type, and accept a pasted code whole. */ export function VerifyForm() { const t = useT(); const router = useRouter(); const email = useSearchParams().get('email') ?? ''; const [digits, setDigits] = useState(() => Array.from({ length: LENGTH }, () => '')); const [resent, setResent] = useState(false); const inputs = useRef<(HTMLInputElement | null)[]>([]); const verify = useMutation({ mutationFn: async (otp: string) => { const { error } = await authClient.emailOtp.verifyEmail({ email, otp }); if (error) throw new Error(error.code ?? 'verify_failed'); }, onSuccess: () => router.push('/consentimiento'), }); const resend = useMutation({ mutationFn: async () => { setResent(false); const { error } = await authClient.emailOtp.sendVerificationOtp({ email, type: 'email-verification', }); if (error) throw new Error(error.code ?? 'resend_failed'); }, onSuccess: () => setResent(true), }); /** * Takes one typed digit or a whole pasted code and spreads it across the boxes from * `index` on. Deliberately computed outside the state updater: React invokes updaters * twice in development to check they are pure, which would fire the focus move and the * verify request twice. */ function place(index: number, raw: string) { const characters = raw.replace(/\D/g, '').split(''); if (characters.length === 0) return; const next = [...digits]; for (let offset = 0; offset < characters.length && index + offset < LENGTH; offset++) { next[index + offset] = characters[offset] as string; } setDigits(next); inputs.current[Math.min(index + characters.length, LENGTH - 1)]?.focus(); const code = next.join(''); if (code.length === LENGTH && !next.includes('')) verify.mutate(code); } return (

{t('auth.otp.title')}

{t('auth.otp.body', { email })}

{ event.preventDefault(); const code = digits.join(''); if (code.length === LENGTH) verify.mutate(code); }} >
{digits.map((digit, index) => ( { inputs.current[index] = element; }} id={`otp-${index}`} inputMode="numeric" autoComplete={index === 0 ? 'one-time-code' : 'off'} maxLength={LENGTH} value={digit} aria-label={`${index + 1}`} aria-invalid={verify.isError} onChange={(event) => place(index, event.target.value)} onKeyDown={(event) => { if (event.key === 'Backspace' && digit === '' && index > 0) { inputs.current[index - 1]?.focus(); } }} className={cn( 'tnum h-14 w-full rounded-2xl border bg-[var(--surface-raised)] text-center text-xl', 'focus-visible:border-accent-500 focus-visible:outline-2 focus-visible:outline-accent-500/40', verify.isError && 'border-overdue', )} /> ))}
{verify.isError ? (

{t('auth.otp.failed')}

) : null} {resent ?

{t('auth.otp.resent')}

: null}

{t('auth.otp.devHint')}

); }