mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
adjusted
This commit is contained in:
@@ -0,0 +1,691 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: RegisterForm.jsx
|
||||
* Type of Program: Frontend Component
|
||||
* Description: Multi-step registration form with Zod + react-hook-form (no shadcn Form wrapper).
|
||||
* Step 1 — Personal Info (given name, last name, middle name, extension, birthday, occupation, phone)
|
||||
* Step 2 — Credentials (email, password, confirm, group_code from URL)
|
||||
* Step 3 — OTP Verify (6-cell input, resend countdown)
|
||||
* Reads optional group_code from URL (/register?group_code=XXX).
|
||||
* Module: User Credentials
|
||||
* Author: lash0000
|
||||
* Date Created: May 23, 2026
|
||||
***********************************************************************************************************************************************************************
|
||||
* Change History:
|
||||
* DATE AUTHOR LOG NUMBER DESCRIPTION
|
||||
* May 23, 2026 lash0000 001 Initial creation - STAR Phase 1 Project
|
||||
* May 23, 2026 lash0000 002 birthday + occupation required; all calls via useAuth (register, verifyOTP, resendOTP)
|
||||
***********************************************************************************************************************************************************************/
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useNavigate, Link, useSearchParams } from 'react-router-dom'
|
||||
import { useForm, Controller } from 'react-hook-form'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { z } from 'zod'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Eye, EyeOff, LoaderCircle, Users, Check } from 'lucide-react'
|
||||
|
||||
// ─── Step schemas ─────────────────────────────────────────────────────────────
|
||||
const personalSchema = z.object({
|
||||
given_name: z.string().min(1, 'First name is required').max(50),
|
||||
last_name: z.string().min(1, 'Last name is required').max(50),
|
||||
middle_name: z.string().max(50).optional(),
|
||||
extension_name: z.string().max(10).optional(),
|
||||
date_of_birth: z
|
||||
.string()
|
||||
.min(1, 'Birthday is required')
|
||||
.refine((v) => !isNaN(Date.parse(v)), { message: 'Invalid date' })
|
||||
.refine((v) => {
|
||||
const age = (Date.now() - new Date(v)) / (1000 * 60 * 60 * 24 * 365.25)
|
||||
return age >= 13 && age <= 120
|
||||
}, { message: 'Must be at least 13 years old' }),
|
||||
occupation: z.string().min(1, 'Occupation is required').max(100),
|
||||
phone: z.string().regex(/^\+?[0-9\s\-()]{7,20}$/, 'Invalid phone number').optional().or(z.literal('')),
|
||||
})
|
||||
|
||||
const credentialsSchema = z
|
||||
.object({
|
||||
email: z.string().email('Invalid email address'),
|
||||
password: z
|
||||
.string()
|
||||
.min(8, 'Password must be at least 8 characters')
|
||||
.regex(/[A-Z]/, 'Must contain at least one uppercase letter')
|
||||
.regex(/[0-9]/, 'Must contain at least one number'),
|
||||
confirm_password: z.string().min(1, 'Please confirm your password'),
|
||||
group_code: z.string().optional(),
|
||||
})
|
||||
.refine((d) => d.password === d.confirm_password, {
|
||||
message: 'Passwords do not match',
|
||||
path: ['confirm_password'],
|
||||
})
|
||||
|
||||
const otpSchema = z.object({
|
||||
otp: z
|
||||
.string()
|
||||
.length(6, 'Enter all 6 digits')
|
||||
.regex(/^\d{6}$/, 'OTP must contain only digits'),
|
||||
})
|
||||
|
||||
// ─── Stepper indicator ────────────────────────────────────────────────────────
|
||||
const STEPS = [
|
||||
{ label: 'Personal info' },
|
||||
{ label: 'Credentials' },
|
||||
{ label: 'Verify email' },
|
||||
]
|
||||
|
||||
function StepIndicator({ current }) {
|
||||
return (
|
||||
<div className="flex items-center gap-0 mb-6">
|
||||
{STEPS.map((s, i) => {
|
||||
const done = i < current
|
||||
const active = i === current
|
||||
const isLast = i === STEPS.length - 1
|
||||
|
||||
return (
|
||||
<div key={i} className="flex items-center flex-1 last:flex-none">
|
||||
{/* Circle */}
|
||||
<div className="flex flex-col items-center gap-1 shrink-0">
|
||||
<div
|
||||
className={cn(
|
||||
'w-7 h-7 rounded-full flex items-center justify-center text-xs font-semibold border transition-colors',
|
||||
done && 'bg-primary border-primary text-primary-foreground',
|
||||
active && 'border-primary text-primary bg-background',
|
||||
!done && !active && 'border-muted-foreground/30 text-muted-foreground/50 bg-background',
|
||||
)}
|
||||
>
|
||||
{done ? <Check className="size-3.5" strokeWidth={2.5} /> : i + 1}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] font-medium whitespace-nowrap',
|
||||
active ? 'text-primary' : done ? 'text-foreground' : 'text-muted-foreground/50',
|
||||
)}
|
||||
>
|
||||
{s.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Connector */}
|
||||
{!isLast && (
|
||||
<div
|
||||
className={cn(
|
||||
'h-px flex-1 mx-2 mb-4 transition-colors',
|
||||
done ? 'bg-primary' : 'bg-muted-foreground/20',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── OTP Cell Input ───────────────────────────────────────────────────────────
|
||||
function OtpInput({ value = '', onChange }) {
|
||||
const cellRefs = Array.from({ length: 6 }, () => useRef(null))
|
||||
const digits = value.split('')
|
||||
|
||||
const handleChange = (i, e) => {
|
||||
const char = e.target.value.replace(/\D/g, '').slice(-1)
|
||||
const next = [...digits]
|
||||
next[i] = char
|
||||
onChange(next.join(''))
|
||||
if (char && i < 5) cellRefs[i + 1].current?.focus()
|
||||
}
|
||||
|
||||
const handleKeyDown = (i, e) => {
|
||||
if (e.key === 'Backspace' && !digits[i] && i > 0) {
|
||||
const next = [...digits]
|
||||
next[i - 1] = ''
|
||||
onChange(next.join(''))
|
||||
cellRefs[i - 1].current?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
const handlePaste = (e) => {
|
||||
e.preventDefault()
|
||||
const pasted = e.clipboardData.getData('text').replace(/\D/g, '').slice(0, 6)
|
||||
onChange(pasted)
|
||||
cellRefs[Math.min(pasted.length, 5)].current?.focus()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 justify-center" onPaste={handlePaste}>
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Input
|
||||
key={i}
|
||||
ref={cellRefs[i]}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
maxLength={1}
|
||||
value={digits[i] || ''}
|
||||
onChange={(e) => handleChange(i, e)}
|
||||
onKeyDown={(e) => handleKeyDown(i, e)}
|
||||
className="w-11 h-12 text-center text-lg font-semibold p-0"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── RegisterForm ─────────────────────────────────────────────────────────────
|
||||
export function RegisterForm({ className, ...props }) {
|
||||
const navigate = useNavigate()
|
||||
const { register: authRegister, verifyOTP, resendOTP } = useAuth()
|
||||
const [searchParams] = useSearchParams()
|
||||
const groupCode = searchParams.get('group_code') || ''
|
||||
|
||||
// 0 = personal, 1 = credentials, 2 = otp
|
||||
const [step, setStep] = useState(0)
|
||||
const [pendingEmail, setPendingEmail] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [showConfirm, setShowConfirm] = useState(false)
|
||||
const [resendCooldown, setResendCooldown] = useState(0)
|
||||
const [errorDialogOpen, setErrorDialogOpen] = useState(false)
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
|
||||
// Accumulated data across steps
|
||||
const [personalData, setPersonalData] = useState({})
|
||||
|
||||
// ── Resend countdown ──────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (resendCooldown <= 0) return
|
||||
const t = setTimeout(() => setResendCooldown((c) => c - 1), 1000)
|
||||
return () => clearTimeout(t)
|
||||
}, [resendCooldown])
|
||||
|
||||
// ── Step 1: Personal info ─────────────────────────────────────────────────
|
||||
const {
|
||||
register: regPersonal,
|
||||
handleSubmit: submitPersonal,
|
||||
formState: { errors: errPersonal },
|
||||
} = useForm({
|
||||
resolver: zodResolver(personalSchema),
|
||||
defaultValues: { given_name: '', last_name: '', middle_name: '', extension_name: '', date_of_birth: '', occupation: '', phone: '' },
|
||||
})
|
||||
|
||||
const onPersonalNext = (data) => {
|
||||
setPersonalData(data)
|
||||
setStep(1)
|
||||
}
|
||||
|
||||
// ── Step 2: Credentials ───────────────────────────────────────────────────
|
||||
const {
|
||||
register: regCreds,
|
||||
handleSubmit: submitCreds,
|
||||
formState: { errors: errCreds, isSubmitting: isRegistering },
|
||||
} = useForm({
|
||||
resolver: zodResolver(credentialsSchema),
|
||||
defaultValues: { email: '', password: '', confirm_password: '', group_code: groupCode },
|
||||
})
|
||||
|
||||
const onCredentialsSubmit = async ({ email, password, group_code }) => {
|
||||
const result = await authRegister({
|
||||
email,
|
||||
password,
|
||||
personal_info: {
|
||||
name: {
|
||||
given_name: personalData.given_name,
|
||||
middle_name: personalData.middle_name || '',
|
||||
last_name: personalData.last_name,
|
||||
extension_name: personalData.extension_name || '',
|
||||
full_name: [
|
||||
`${personalData.last_name},`,
|
||||
personalData.given_name,
|
||||
personalData.middle_name || '',
|
||||
personalData.extension_name || '',
|
||||
].filter(Boolean).join(' ').trim(),
|
||||
},
|
||||
date_of_birth: personalData.date_of_birth,
|
||||
occupation: personalData.occupation,
|
||||
phone_number: personalData.phone
|
||||
? (() => {
|
||||
const digits = personalData.phone.replace(/\D/g, '')
|
||||
const number = digits.startsWith('63')
|
||||
? digits.slice(2) // strip country code if user typed +63...
|
||||
: digits.replace(/^0/, '') // strip leading 0 if user typed 09...
|
||||
return [{
|
||||
number,
|
||||
country_code: '63',
|
||||
full_number: `63${number}`,
|
||||
phone_type: 'mobile',
|
||||
}]
|
||||
})()
|
||||
: [],
|
||||
addresses: [],
|
||||
},
|
||||
...(group_code ? { group_code } : {}),
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
setErrorMessage(result.message)
|
||||
setErrorDialogOpen(true)
|
||||
return
|
||||
}
|
||||
|
||||
setPendingEmail(email)
|
||||
setResendCooldown(30)
|
||||
setStep(2)
|
||||
}
|
||||
|
||||
// ── Step 3: OTP ───────────────────────────────────────────────────────────
|
||||
const {
|
||||
control: otpControl,
|
||||
handleSubmit: submitOtp,
|
||||
reset: resetOtp,
|
||||
formState: { errors: errOtp, isSubmitting: isVerifying },
|
||||
} = useForm({
|
||||
resolver: zodResolver(otpSchema),
|
||||
defaultValues: { otp: '' },
|
||||
})
|
||||
|
||||
const onVerifyOTP = async ({ otp }) => {
|
||||
const result = await verifyOTP({ email: pendingEmail, otp })
|
||||
|
||||
if (!result.success) {
|
||||
setErrorMessage(result.message)
|
||||
setErrorDialogOpen(true)
|
||||
return
|
||||
}
|
||||
|
||||
navigate('/dashboard')
|
||||
}
|
||||
|
||||
const handleResend = async () => {
|
||||
if (resendCooldown > 0) return
|
||||
|
||||
const result = await resendOTP({ email: pendingEmail })
|
||||
|
||||
if (!result.success) {
|
||||
setErrorMessage(result.message)
|
||||
setErrorDialogOpen(true)
|
||||
return
|
||||
}
|
||||
|
||||
setResendCooldown(30)
|
||||
resetOtp()
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<>
|
||||
<div className={cn('flex flex-col', className)} {...props}>
|
||||
<StepIndicator current={step} />
|
||||
|
||||
{/* ── Step 1: Personal info ── */}
|
||||
{step === 0 && (
|
||||
<form onSubmit={submitPersonal(onPersonalNext)} className="flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-2xl font-bold tracking-tighter">Personal information.</h1>
|
||||
<p className="text-muted-foreground text-sm">Tell us a bit about yourself.</p>
|
||||
</div>
|
||||
|
||||
{/* Given + Last name row */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="given_name" className="text-sm font-medium">
|
||||
First name <span className="text-destructive">*</span>
|
||||
</label>
|
||||
<Input
|
||||
id="given_name"
|
||||
placeholder="Juan"
|
||||
{...regPersonal('given_name')}
|
||||
/>
|
||||
{errPersonal.given_name && (
|
||||
<p className="text-xs text-destructive">{errPersonal.given_name.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="last_name" className="text-sm font-medium">
|
||||
Last name <span className="text-destructive">*</span>
|
||||
</label>
|
||||
<Input
|
||||
id="last_name"
|
||||
placeholder="Dela Cruz"
|
||||
{...regPersonal('last_name')}
|
||||
/>
|
||||
{errPersonal.last_name && (
|
||||
<p className="text-xs text-destructive">{errPersonal.last_name.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Middle name */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="middle_name" className="text-sm font-medium flex items-center gap-1.5">
|
||||
Middle name
|
||||
<span className="text-xs font-normal text-muted-foreground">(optional)</span>
|
||||
</label>
|
||||
<Input
|
||||
id="middle_name"
|
||||
placeholder="Santos"
|
||||
{...regPersonal('middle_name')}
|
||||
/>
|
||||
{errPersonal.middle_name && (
|
||||
<p className="text-xs text-destructive">{errPersonal.middle_name.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Extension name + Birthday row */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="extension_name" className="text-sm font-medium flex items-center gap-1.5">
|
||||
Extension name
|
||||
<span className="text-xs font-normal text-muted-foreground">(optional)</span>
|
||||
</label>
|
||||
<Input
|
||||
id="extension_name"
|
||||
placeholder="Jr., Sr., III"
|
||||
{...regPersonal('extension_name')}
|
||||
/>
|
||||
{errPersonal.extension_name && (
|
||||
<p className="text-xs text-destructive">{errPersonal.extension_name.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="date_of_birth" className="text-sm font-medium">
|
||||
Birthday <span className="text-destructive">*</span>
|
||||
</label>
|
||||
<Input
|
||||
id="date_of_birth"
|
||||
type="date"
|
||||
max={new Date().toISOString().split('T')[0]}
|
||||
{...regPersonal('date_of_birth')}
|
||||
/>
|
||||
{errPersonal.date_of_birth && (
|
||||
<p className="text-xs text-destructive">{errPersonal.date_of_birth.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Occupation */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="occupation" className="text-sm font-medium">
|
||||
Occupation <span className="text-destructive">*</span>
|
||||
</label>
|
||||
<Input
|
||||
id="occupation"
|
||||
placeholder="e.g. Real Estate Broker"
|
||||
{...regPersonal('occupation')}
|
||||
/>
|
||||
{errPersonal.occupation && (
|
||||
<p className="text-xs text-destructive">{errPersonal.occupation.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Phone */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="phone" className="text-sm font-medium flex items-center gap-1.5">
|
||||
Phone number
|
||||
<span className="text-xs font-normal text-muted-foreground">(optional)</span>
|
||||
</label>
|
||||
<Input
|
||||
id="phone"
|
||||
type="tel"
|
||||
placeholder="+63 912 345 6789"
|
||||
{...regPersonal('phone')}
|
||||
/>
|
||||
{errPersonal.phone && (
|
||||
<p className="text-xs text-destructive">{errPersonal.phone.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full mt-1">
|
||||
Next: Credentials →
|
||||
</Button>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
Already have an account?{' '}
|
||||
<Link to="/login" className="font-medium underline underline-offset-4">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* ── Step 2: Credentials ── */}
|
||||
{step === 1 && (
|
||||
<form onSubmit={submitCreds(onCredentialsSubmit)} className="flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-2xl font-bold tracking-tighter">Account credentials.</h1>
|
||||
<p className="text-muted-foreground text-sm">Set your login email and password.</p>
|
||||
</div>
|
||||
|
||||
{/* Group code notice */}
|
||||
{groupCode && (
|
||||
<div className="flex items-center gap-2 rounded-md border border-dashed px-3 py-2 bg-muted/40">
|
||||
<Users className="size-4 text-muted-foreground shrink-0" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Joining group{' '}
|
||||
<span className="font-semibold text-foreground">{groupCode}</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="email" className="text-sm font-medium">
|
||||
Email address <span className="text-destructive">*</span>
|
||||
</label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
autoComplete="off"
|
||||
disabled={isRegistering}
|
||||
readOnly
|
||||
onFocus={(e) => e.target.removeAttribute('readonly')}
|
||||
{...regCreds('email')}
|
||||
/>
|
||||
{errCreds.email && (
|
||||
<p className="text-xs text-destructive">{errCreds.email.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="password" className="text-sm font-medium">
|
||||
Password <span className="text-destructive">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="Min. 8 chars, 1 uppercase, 1 number"
|
||||
autoComplete="new-password"
|
||||
disabled={isRegistering}
|
||||
className="pr-10"
|
||||
{...regCreds('password')}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showPassword ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
{errCreds.password && (
|
||||
<p className="text-xs text-destructive">{errCreds.password.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirm password */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="confirm_password" className="text-sm font-medium">
|
||||
Confirm password <span className="text-destructive">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="confirm_password"
|
||||
type={showConfirm ? 'text' : 'password'}
|
||||
placeholder="Repeat your password"
|
||||
autoComplete="new-password"
|
||||
disabled={isRegistering}
|
||||
className="pr-10"
|
||||
{...regCreds('confirm_password')}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setShowConfirm((v) => !v)}
|
||||
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showConfirm ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
{errCreds.confirm_password && (
|
||||
<p className="text-xs text-destructive">{errCreds.confirm_password.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Group code — disabled, auto-filled from URL */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="group_code" className="text-sm font-medium flex items-center gap-2">
|
||||
Group code
|
||||
<Badge variant="secondary" className="text-xs font-normal">Auto-filled</Badge>
|
||||
</label>
|
||||
<Input
|
||||
id="group_code"
|
||||
placeholder="No group code"
|
||||
disabled
|
||||
{...regCreds('group_code')}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Provided by your group admin via invite link.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex gap-2 mt-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => setStep(0)}
|
||||
disabled={isRegistering}
|
||||
>
|
||||
← Back
|
||||
</Button>
|
||||
<Button type="submit" className="flex-1" disabled={isRegistering}>
|
||||
{isRegistering ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<LoaderCircle className="size-4 animate-spin" />
|
||||
Creating...
|
||||
</span>
|
||||
) : (
|
||||
'Create account →'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
Already have an account?{' '}
|
||||
<Link to="/login" className="font-medium underline underline-offset-4">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* ── Step 3: OTP ── */}
|
||||
{step === 2 && (
|
||||
<form onSubmit={submitOtp(onVerifyOTP)} className="flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-2xl font-bold tracking-tighter">Check your email.</h1>
|
||||
<p className="text-muted-foreground text-sm text-balance">
|
||||
We sent a 6-digit code to{' '}
|
||||
<span className="font-medium text-foreground">{pendingEmail}</span>.
|
||||
It expires in 10 minutes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Controller
|
||||
control={otpControl}
|
||||
name="otp"
|
||||
render={({ field }) => (
|
||||
<OtpInput value={field.value} onChange={field.onChange} />
|
||||
)}
|
||||
/>
|
||||
{errOtp.otp && (
|
||||
<p className="text-xs text-destructive text-center">{errOtp.otp.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isVerifying}>
|
||||
{isVerifying ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<LoaderCircle className="size-4 animate-spin" />
|
||||
Verifying...
|
||||
</span>
|
||||
) : (
|
||||
'Verify & sign in'
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{resendCooldown > 0 ? `Resend in ${resendCooldown}s` : "Didn't receive it?"}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="p-0 h-auto font-medium"
|
||||
disabled={resendCooldown > 0}
|
||||
onClick={handleResend}
|
||||
>
|
||||
Resend code
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="w-full text-muted-foreground"
|
||||
onClick={() => { setStep(1); resetOtp() }}
|
||||
>
|
||||
← Back to credentials
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AlertDialog open={errorDialogOpen} onOpenChange={setErrorDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{step === 2 ? 'Verification failed' : 'Registration failed'}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>{errorMessage}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogAction onClick={() => setErrorDialogOpen(false)}>Okay</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user