/*********************************************************************************************************************************************************************** * 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 (
{STEPS.map((s, i) => { const done = i < current const active = i === current const isLast = i === STEPS.length - 1 return (
{/* Circle */}
{done ? : i + 1}
{s.label}
{/* Connector */} {!isLast && (
)}
) })}
) } // ─── 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 ( <>
{step !== 'choice' && } {/* ── Entry: Google vs. manual signup ── */} {step === 'choice' && (

Create your account.

Join Philproperties to start learning.

{/* Group code notice */} {groupCode && (

Joining group{' '} {groupCode}

)} {/* Google OAuth */}
Or continue with

Already have an account?{' '} Sign in

)} {/* ── Step 1: Personal info ── */} {step === 0 && (

Personal information.

Tell us a bit about yourself.

{/* Given + Last name row */}
{errPersonal.given_name && (

{errPersonal.given_name.message}

)}
{errPersonal.last_name && (

{errPersonal.last_name.message}

)}
{/* Middle name */}
{errPersonal.middle_name && (

{errPersonal.middle_name.message}

)}
{/* Extension name + Birthday row */}
{errPersonal.extension_name && (

{errPersonal.extension_name.message}

)}
{ const selectedDate = field.value ? parseISO(field.value) : undefined const validSelected = selectedDate && isValidDate(selectedDate) ? selectedDate : undefined return ( { field.onChange(date ? format(date, 'yyyy-MM-dd') : '') setBirthdayOpen(false) }} /> ) }} /> {errPersonal.date_of_birth && (

{errPersonal.date_of_birth.message}

)}
{/* Occupation */}
{errPersonal.occupation && (

{errPersonal.occupation.message}

)}
{/* Phone */}
( )} /> {errPersonal.phone && (

{errPersonal.phone.message}

)}

Already have an account?{' '} Sign in

)} {/* ── Step 2: Credentials ── */} {step === 1 && (

Account credentials.

Set your login email and password.

{/* Group code notice */} {groupCode && (

Joining group{' '} {groupCode}

)} {/* Email */}
e.target.removeAttribute('readonly')} className="text-sm" {...regCreds('email')} /> {errCreds.email && (

{errCreds.email.message}

)}
{/* Password */}
{errCreds.password && (

{errCreds.password.message}

)}
{/* Confirm password */}
{errCreds.confirm_password && (

{errCreds.confirm_password.message}

)}
{/* Group code — disabled, auto-filled from URL */}

Provided by your group admin via invite link.

{/* Navigation */}

Already have an account?{' '} Sign in

)} {/* ── Step 3: OTP ── */} {step === 2 && ( navigate('/dashboard', { state: { justRegistered: true } })} onBack={() => setStep(1)} /> )}
Registration failed {errorMessage} setErrorDialogOpen(false)}>Okay !isCreating && setConfirmCreateOpen(open)}> Create this account? We'll create your account with{' '} {pendingCredsData?.email}{' '} and email a 6-digit verification code to that address. {isCreating ? ( Creating... ) : ( 'Proceed' )} !isResuming && setResumeDialogOpen(open)}> Pending verification found 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? {isResuming ? ( Sending... ) : ( 'Resend code' )} ) }