mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
779 lines
33 KiB
React
779 lines
33 KiB
React
/***********************************************************************************************************************************************************************
|
|
* 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, 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 { format, parseISO, isValid as isValidDate } from 'date-fns'
|
|
import { isValidPhoneNumber, parsePhoneNumber } from 'react-phone-number-input'
|
|
import { cn } from '@/lib/utils'
|
|
import { useDetectedCountry } from '@/hooks/useDetectedCountry'
|
|
import { OtpVerifyForm } from './OtpVerifyForm'
|
|
|
|
import { Button } from '@/components/ui/button'
|
|
import { Input } from '@/components/ui/input'
|
|
import { PhoneInput } from '@/components/ui/phone-input'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Calendar } from '@/components/ui/calendar'
|
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
|
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, CalendarIcon } from 'lucide-react'
|
|
|
|
// Birthday must put the user's birth year between 3 and 120 years ago (mirrors personalSchema below).
|
|
// Bounds are whole calendar years (Jan 1 / Dec 31) so every month/day is selectable within a boundary year.
|
|
const MIN_BIRTHDATE = new Date(new Date().getFullYear() - 120, 0, 1)
|
|
const MAX_BIRTHDATE = new Date(new Date().getFullYear() - 3, 11, 31)
|
|
|
|
// ─── 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 yearsAgo = new Date().getFullYear() - new Date(v).getFullYear()
|
|
return yearsAgo >= 3 && yearsAgo <= 120
|
|
}, { message: 'Must be at least 3 years old' }),
|
|
occupation: z.string().min(1, 'Occupation is required').max(100),
|
|
phone: z
|
|
.string()
|
|
.min(1, 'Phone number is required')
|
|
.refine((v) => isValidPhoneNumber(v), { message: 'Invalid phone number' }),
|
|
})
|
|
|
|
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'],
|
|
})
|
|
|
|
// ─── 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>
|
|
)
|
|
}
|
|
|
|
// ─── RegisterForm ─────────────────────────────────────────────────────────────
|
|
export function RegisterForm({ className, ...props }) {
|
|
const navigate = useNavigate()
|
|
const { register: authRegister } = useAuth()
|
|
const [searchParams] = useSearchParams()
|
|
const groupCode = searchParams.get('group_code') || ''
|
|
|
|
// 'choice' = Google vs. manual signup entry screen, 0 = personal, 1 = credentials, 2 = otp
|
|
const [step, setStep] = useState('choice')
|
|
const [pendingEmail, setPendingEmail] = useState('')
|
|
const [showPassword, setShowPassword] = useState(false)
|
|
const [showConfirm, setShowConfirm] = useState(false)
|
|
const [errorDialogOpen, setErrorDialogOpen] = useState(false)
|
|
const [errorMessage, setErrorMessage] = useState('')
|
|
const [birthdayOpen, setBirthdayOpen] = useState(false)
|
|
|
|
const detectedCountry = useDetectedCountry()
|
|
|
|
// Accumulated data across steps
|
|
const [personalData, setPersonalData] = useState({})
|
|
|
|
// ── Pending-verification resume (email already has an unverified account) ──
|
|
const [resumeDialogOpen, setResumeDialogOpen] = useState(false)
|
|
const [isResuming, setIsResuming] = useState(false)
|
|
const [pendingCreds, setPendingCreds] = useState(null)
|
|
|
|
// ── Confirm-before-create (asked before the register request ever fires) ──
|
|
const [confirmCreateOpen, setConfirmCreateOpen] = useState(false)
|
|
const [isCreating, setIsCreating] = useState(false)
|
|
const [pendingCredsData, setPendingCredsData] = useState(null)
|
|
|
|
// ── Warn before the tab is closed/reloaded mid-registration ────────────────
|
|
// Once the user reaches Credentials, submitting can already create a
|
|
// server-side account + send an OTP — losing the tab here (slow internet,
|
|
// accidental reload) is what leaves an orphaned unverified account behind.
|
|
useEffect(() => {
|
|
if (step === 'choice' || step === 0) return
|
|
|
|
const handleBeforeUnload = (e) => {
|
|
e.preventDefault()
|
|
e.returnValue = ''
|
|
}
|
|
|
|
window.addEventListener('beforeunload', handleBeforeUnload)
|
|
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
|
|
}, [step])
|
|
|
|
// ── Step 1: Personal info ─────────────────────────────────────────────────
|
|
const {
|
|
register: regPersonal,
|
|
handleSubmit: submitPersonal,
|
|
control: controlPersonal,
|
|
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)
|
|
}
|
|
|
|
const handleGoogle = () => {
|
|
const params = groupCode ? `?group_code=${encodeURIComponent(groupCode)}` : ''
|
|
window.location.href = `${import.meta.env.VITE_API_URL}/auth/google${params}`
|
|
}
|
|
|
|
// ── 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 buildPersonalInfo = () => ({
|
|
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 parsed = parsePhoneNumber(personalData.phone)
|
|
return [{
|
|
number: parsed.nationalNumber,
|
|
country_code: parsed.countryCallingCode,
|
|
full_number: `${parsed.countryCallingCode}${parsed.nationalNumber}`,
|
|
phone_type: 'mobile',
|
|
}]
|
|
})()
|
|
: [],
|
|
addresses: [],
|
|
})
|
|
|
|
const submitRegistration = async ({ email, password, group_code }, { confirmResume = false } = {}) => {
|
|
const result = await authRegister({
|
|
email,
|
|
password,
|
|
personal_info: buildPersonalInfo(),
|
|
...(group_code ? { group_code } : {}),
|
|
...(confirmResume ? { confirm_resume: true } : {}),
|
|
})
|
|
|
|
if (!result.success) {
|
|
if (result.errors?.pendingVerification && !confirmResume) {
|
|
// Same email already has an unverified account sitting server-side —
|
|
// likely this same user retrying after a dropped connection. Ask
|
|
// before resending the OTP and reusing that account rather than
|
|
// dead-ending on a generic "already registered" error.
|
|
setPendingCreds({ email, password, group_code })
|
|
setResumeDialogOpen(true)
|
|
return
|
|
}
|
|
|
|
setErrorMessage(result.message)
|
|
setErrorDialogOpen(true)
|
|
return
|
|
}
|
|
|
|
setPendingEmail(email)
|
|
setStep(2)
|
|
}
|
|
|
|
// Validated credentials don't submit yet — stage them and ask for explicit
|
|
// confirmation first, since this is the step that actually creates the
|
|
// server-side account and sends an OTP.
|
|
const onCredentialsSubmit = (data) => {
|
|
setPendingCredsData(data)
|
|
setConfirmCreateOpen(true)
|
|
}
|
|
|
|
const handleConfirmCreate = async () => {
|
|
if (!pendingCredsData) return
|
|
setIsCreating(true)
|
|
try {
|
|
await submitRegistration(pendingCredsData)
|
|
} finally {
|
|
setIsCreating(false)
|
|
setConfirmCreateOpen(false)
|
|
}
|
|
}
|
|
|
|
const handleConfirmResume = async () => {
|
|
if (!pendingCreds) return
|
|
setIsResuming(true)
|
|
try {
|
|
await submitRegistration(pendingCreds, { confirmResume: true })
|
|
} finally {
|
|
setIsResuming(false)
|
|
setResumeDialogOpen(false)
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
return (
|
|
<>
|
|
<div className={cn('flex flex-col', className)} {...props}>
|
|
{step !== 'choice' && <StepIndicator current={step} />}
|
|
|
|
{/* ── Entry: Google vs. manual signup ── */}
|
|
{step === 'choice' && (
|
|
<div className="flex flex-col gap-5">
|
|
<div className="flex flex-col gap-1">
|
|
<h1 className="text-2xl font-bold tracking-tighter">Create your account.</h1>
|
|
<p className="text-muted-foreground text-sm">Join Philproperties to start learning.</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>
|
|
)}
|
|
|
|
{/* Google OAuth */}
|
|
<Button variant="outline" type="button" onClick={handleGoogle} className="w-full">
|
|
<svg viewBox="0 0 128 128" className="size-4 mr-2">
|
|
<path fill="#fff" d="M44.59 4.21a63.28 63.28 0 004.33 120.9 67.6 67.6 0 0032.36.35 57.13 57.13 0 0025.9-13.46 57.44 57.44 0 0016-26.26 74.33 74.33 0 001.61-33.58H65.27v24.69h34.47a29.72 29.72 0 01-12.66 19.52 36.16 36.16 0 01-13.93 5.5 41.29 41.29 0 01-15.1 0A37.16 37.16 0 0144 95.74a39.3 39.3 0 01-14.5-19.42 38.31 38.31 0 010-24.63 39.25 39.25 0 019.18-14.91A37.17 37.17 0 0176.13 27a34.28 34.28 0 0113.64 8q5.83-5.8 11.64-11.63c2-2.09 4.18-4.08 6.15-6.22A61.22 61.22 0 0087.2 4.59a64 64 0 00-42.61-.38z" />
|
|
<path fill="#e33629" d="M44.59 4.21a64 64 0 0142.61.37 61.22 61.22 0 0120.35 12.62c-2 2.14-4.11 4.14-6.15 6.22Q95.58 29.23 89.77 35a34.28 34.28 0 00-13.64-8 37.17 37.17 0 00-37.46 9.74 39.25 39.25 0 00-9.18 14.91L8.76 35.6A63.53 63.53 0 0144.59 4.21z" />
|
|
<path fill="#f8bd00" d="M3.26 51.5a62.93 62.93 0 015.5-15.9l20.73 16.09a38.31 38.31 0 000 24.63q-10.36 8-20.73 16.08a63.33 63.33 0 01-5.5-40.9z" />
|
|
<path fill="#587dbd" d="M65.27 52.15h59.52a74.33 74.33 0 01-1.61 33.58 57.44 57.44 0 01-16 26.26c-6.69-5.22-13.41-10.4-20.1-15.62a29.72 29.72 0 0012.66-19.54H65.27c-.01-8.22 0-16.45 0-24.68z" />
|
|
<path fill="#319f43" d="M8.75 92.4q10.37-8 20.73-16.08A39.3 39.3 0 0044 95.74a37.16 37.16 0 0014.08 6.08 41.29 41.29 0 0015.1 0 36.16 36.16 0 0013.93-5.5c6.69 5.22 13.41 10.4 20.1 15.62a57.13 57.13 0 01-25.9 13.47 67.6 67.6 0 01-32.36-.35 63 63 0 01-23-11.59A63.73 63.73 0 018.75 92.4z" />
|
|
</svg>
|
|
Login with Google
|
|
</Button>
|
|
|
|
<div className="relative flex items-center gap-3 text-xs text-muted-foreground">
|
|
<Separator className="flex-1" />
|
|
<span>Or continue with</span>
|
|
<Separator className="flex-1" />
|
|
</div>
|
|
|
|
<Button type="button" className="w-full" onClick={() => setStep(0)}>
|
|
Proceed
|
|
</Button>
|
|
|
|
<p className="text-center text-sm text-muted-foreground">
|
|
Already have an account?{' '}
|
|
<Link
|
|
to={groupCode ? `/login?group_code=${groupCode}` : '/login'}
|
|
className="font-medium underline underline-offset-4"
|
|
>
|
|
Sign in
|
|
</Link>
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── 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"
|
|
className="text-sm"
|
|
{...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"
|
|
className="text-sm"
|
|
{...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"
|
|
className="text-sm"
|
|
{...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"
|
|
className="text-sm"
|
|
{...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>
|
|
<Controller
|
|
name="date_of_birth"
|
|
control={controlPersonal}
|
|
render={({ field }) => {
|
|
const selectedDate = field.value ? parseISO(field.value) : undefined
|
|
const validSelected = selectedDate && isValidDate(selectedDate) ? selectedDate : undefined
|
|
|
|
return (
|
|
<Popover open={birthdayOpen} onOpenChange={setBirthdayOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
id="date_of_birth"
|
|
variant="outline"
|
|
className="w-full justify-between font-normal text-sm"
|
|
>
|
|
{validSelected ? format(validSelected, 'MMM d, yyyy') : 'Select date'}
|
|
<CalendarIcon className="h-4 w-4 opacity-50" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-auto overflow-hidden p-0" align="start">
|
|
<Calendar
|
|
mode="single"
|
|
selected={validSelected}
|
|
captionLayout="dropdown"
|
|
defaultMonth={validSelected ?? MAX_BIRTHDATE}
|
|
startMonth={MIN_BIRTHDATE}
|
|
endMonth={MAX_BIRTHDATE}
|
|
disabled={{ before: MIN_BIRTHDATE, after: MAX_BIRTHDATE }}
|
|
onSelect={(date) => {
|
|
field.onChange(date ? format(date, 'yyyy-MM-dd') : '')
|
|
setBirthdayOpen(false)
|
|
}}
|
|
/>
|
|
</PopoverContent>
|
|
</Popover>
|
|
)
|
|
}}
|
|
/>
|
|
{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"
|
|
className="text-sm"
|
|
{...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">
|
|
Phone number <span className="text-destructive">*</span>
|
|
</label>
|
|
<Controller
|
|
name="phone"
|
|
control={controlPersonal}
|
|
render={({ field }) => (
|
|
<PhoneInput
|
|
{...field}
|
|
id="phone"
|
|
international
|
|
defaultCountry={detectedCountry}
|
|
className="text-sm"
|
|
/>
|
|
)}
|
|
/>
|
|
{errPersonal.phone && (
|
|
<p className="text-xs text-destructive">{errPersonal.phone.message}</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex gap-2 mt-1">
|
|
<Button type="button" variant="outline" className="flex-1" onClick={() => setStep('choice')}>
|
|
← Back
|
|
</Button>
|
|
<Button type="submit" className="flex-1">
|
|
Proceed
|
|
</Button>
|
|
</div>
|
|
|
|
<p className="text-center text-sm text-muted-foreground">
|
|
Already have an account?{' '}
|
|
<Link
|
|
to={groupCode ? `/login?group_code=${groupCode}` : '/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')}
|
|
className="text-sm"
|
|
{...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 text-sm"
|
|
{...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 text-sm"
|
|
{...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
|
|
className="text-sm"
|
|
{...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 || isCreating}>
|
|
Create account →
|
|
</Button>
|
|
</div>
|
|
|
|
<p className="text-center text-sm text-muted-foreground">
|
|
Already have an account?{' '}
|
|
<Link
|
|
to={groupCode ? `/login?group_code=${groupCode}` : '/login'}
|
|
className="font-medium underline underline-offset-4"
|
|
>
|
|
Sign in
|
|
</Link>
|
|
</p>
|
|
</form>
|
|
)}
|
|
|
|
{/* ── Step 3: OTP ── */}
|
|
{step === 2 && (
|
|
<OtpVerifyForm
|
|
email={pendingEmail}
|
|
onSuccess={() => navigate('/dashboard', { state: { justRegistered: true } })}
|
|
onBack={() => setStep(1)}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<AlertDialog open={errorDialogOpen} onOpenChange={setErrorDialogOpen}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Registration failed</AlertDialogTitle>
|
|
<AlertDialogDescription>{errorMessage}</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogAction onClick={() => setErrorDialogOpen(false)}>Okay</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
|
|
<AlertDialog open={confirmCreateOpen} onOpenChange={(open) => !isCreating && setConfirmCreateOpen(open)}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Create this account?</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
We'll create your account with{' '}
|
|
<span className="font-medium text-foreground">{pendingCredsData?.email}</span>{' '}
|
|
and email a 6-digit verification code to that address.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<Button variant="outline" disabled={isCreating} onClick={() => setConfirmCreateOpen(false)}>
|
|
Cancel
|
|
</Button>
|
|
<AlertDialogAction onClick={handleConfirmCreate} disabled={isCreating}>
|
|
{isCreating ? (
|
|
<span className="flex items-center gap-2">
|
|
<LoaderCircle className="size-4 animate-spin" />
|
|
Creating...
|
|
</span>
|
|
) : (
|
|
'Proceed'
|
|
)}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
|
|
<AlertDialog open={resumeDialogOpen} onOpenChange={(open) => !isResuming && setResumeDialogOpen(open)}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Pending verification found</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
This email already started registration but was never verified — likely from a dropped
|
|
connection. Resend the verification code and continue with the details you just entered?
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<Button variant="outline" disabled={isResuming} onClick={() => setResumeDialogOpen(false)}>
|
|
Cancel
|
|
</Button>
|
|
<AlertDialogAction onClick={handleConfirmResume} disabled={isResuming}>
|
|
{isResuming ? (
|
|
<span className="flex items-center gap-2">
|
|
<LoaderCircle className="size-4 animate-spin" />
|
|
Sending...
|
|
</span>
|
|
) : (
|
|
'Resend code'
|
|
)}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</>
|
|
)
|
|
} |