/***********************************************************************************************************************************************************************
* 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 */}
Login with Google
Or continue with
setStep(0)}>
Proceed
Already have an account?{' '}
Sign in
)}
{/* ── Step 1: Personal info ── */}
{step === 0 && (
)}
{/* ── Step 2: Credentials ── */}
{step === 1 && (
)}
{/* ── 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.
setConfirmCreateOpen(false)}>
Cancel
{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?
setResumeDialogOpen(false)}>
Cancel
{isResuming ? (
Sending...
) : (
'Resend code'
)}
>
)
}