// components/OnboardingWizard.tsx // Client-side 4-step intake wizard that posts to /api/onboarding/submit. // Stepper pattern: Company → Directors → Shareholders → Review. // All state persists to localStorage on every change so a client can // close the tab and resume — the reference_no + access_token combine // to scope the draft to this submission only. "use client"; import { useEffect, useMemo, useState } from "react"; import type { OnboardingCompany, OnboardingDirector, OnboardingShareholder, OnboardingSubmissionPayload } from "@/lib/types"; import VoiceField from "@/components/VoiceField"; interface Props { /** Public token from the URL — server already validated the lookup. */ accessToken: string; } interface UploadedDoc { /** Client-side key, not a PB id. */ ui_key: string; /** Director ui_key this doc belongs to. */ directorKey: string; /** "id" or "proof_of_residence" — drives filename labelling. */ kind: "id" | "proof_of_residence"; file: File; } const STEPS = [ { id: "company", label: "Company" }, { id: "directors", label: "Directors" }, { id: "shareholders", label: "Shareholders" }, { id: "review", label: "Review" } ] as const; type StepId = (typeof STEPS)[number]["id"]; const EMPTY_COMPANY: OnboardingCompany = { proposed_names: ["", "", ""], financial_year_end_month: 2, authorised_shares: "1000", physical_address: "", physical_address_line_1: "", physical_address_line_2: "", physical_town_city: "", physical_province: "", physical_postal_code: "", postal_address: "", postal_address_line_1: "", postal_address_line_2: "", postal_town_city: "", postal_province: "", postal_postal_code: "", company_cell_phone: "", company_email: "", public_officer_name: "", company_secretary_name: "" }; function blankDirector(): OnboardingDirector { return { ui_key: crypto.randomUUID(), first_names: "", surname: "", id_no: "", date_of_birth: "", residential_address: "", residential_address_line_1: "", residential_address_line_2: "", residential_town_city: "", residential_province: "", residential_postal_code: "", postal_address: "", postal_address_line_1: "", postal_address_line_2: "", postal_town_city: "", postal_province: "", postal_postal_code: "", cell_phone: "", email: "" }; } function blankShareholder(): OnboardingShareholder { return { ui_key: crypto.randomUUID(), kind: "person", name: "", id_or_company_no: "", share_percent: 0 }; } interface DraftState { client_name: string; client_email: string; client_telephone: string; client_notes: string; company: OnboardingCompany; directors: OnboardingDirector[]; shareholders: OnboardingShareholder[]; } const EMPTY_DRAFT: DraftState = { client_name: "", client_email: "", client_telephone: "", client_notes: "", company: EMPTY_COMPANY, directors: [blankDirector()], shareholders: [blankShareholder()] }; const MONTH_NAMES = [ "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December" ]; const SA_ID_REGEX = /^\d{13}$/; function validateIdNo(value: string): string | null { if (!value) return "ID number is required."; if (!SA_ID_REGEX.test(value)) { return "ID number must be exactly 13 digits."; } // Luhn check on SA ID numbers const digits = value.split("").map(Number); let sum = 0; for (let i = 0; i < 12; i++) { if (i % 2 === 0) { const doubled = digits[i] * 2; sum += doubled > 9 ? doubled - 9 : doubled; } else { sum += digits[i]; } } const checkDigit = (10 - (sum % 10)) % 10; if (checkDigit !== digits[12]) { return "ID number fails checksum — please double-check."; } return null; } function validateEmail(value: string): string | null { if (!value) return "Email is required."; if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) { return "Enter a valid email address."; } return null; } function validateCellPhone(value: string): string | null { if (!value) return null; // optional if (!/^[0-9 +()\-]{0,30}$/.test(value)) { return "Use digits, spaces, +, (, ), - only."; } return null; } function validateDirector(d: OnboardingDirector): string[] { const errs: string[] = []; if (!d.first_names.trim()) errs.push("First names required"); if (!d.surname.trim()) errs.push("Surname required"); const idErr = validateIdNo(d.id_no); if (idErr) errs.push(idErr); if (!d.date_of_birth) errs.push("Date of birth required"); if (!d.residential_address_line_1.trim()) errs.push("Residential address line 1 required"); if (!d.residential_town_city.trim()) errs.push("Residential town/city required"); if (!d.residential_province.trim()) errs.push("Residential province required"); if (!d.residential_postal_code.trim()) errs.push("Residential postal code required"); if (!d.postal_address_line_1.trim()) errs.push("Postal address line 1 required"); if (!d.postal_town_city.trim()) errs.push("Postal town/city required"); if (!d.postal_province.trim()) errs.push("Postal province required"); if (!d.postal_postal_code.trim()) errs.push("Postal postal code required"); if (!d.cell_phone.trim()) errs.push("Cell phone required"); const emailErr = validateEmail(d.email); if (emailErr) errs.push(emailErr); return errs; } function validateShareholder(s: OnboardingShareholder): string[] { const errs: string[] = []; if (!s.name.trim()) errs.push("Name required"); if (!s.id_or_company_no.trim()) { errs.push(s.kind === "person" ? "ID number required" : "Company number required"); } if (s.share_percent <= 0 || s.share_percent > 100) { errs.push("Share % must be between 0 and 100"); } return errs; } function validateCompany(c: OnboardingCompany): string[] { const errs: string[] = []; if (!c.proposed_names[0].trim()) errs.push("At least one proposed name required"); if (c.financial_year_end_month < 1 || c.financial_year_end_month > 12) { errs.push("Financial year end must be a month"); } if (!c.physical_address_line_1.trim()) errs.push("Physical address line 1 required"); if (!c.physical_town_city.trim()) errs.push("Physical town/city required"); if (!c.physical_province.trim()) errs.push("Physical province required"); if (!c.physical_postal_code.trim()) errs.push("Physical postal code required"); if (!c.postal_address_line_1.trim()) errs.push("Postal address line 1 required"); if (!c.postal_town_city.trim()) errs.push("Postal town/city required"); if (!c.postal_province.trim()) errs.push("Postal province required"); if (!c.postal_postal_code.trim()) errs.push("Postal postal code required"); if (!c.company_cell_phone.trim()) errs.push("Company cell phone required"); const emailErr = validateEmail(c.company_email); if (emailErr) errs.push(emailErr); if (!c.public_officer_name.trim()) errs.push("Public officer name required"); if (!c.company_secretary_name.trim()) errs.push("Company secretary name required"); return errs; } export default function OnboardingWizard({ accessToken }: Props) { const draftKey = useMemo( () => `onboard-draft:${accessToken.slice(0, 12)}`, [accessToken], ); const [step, setStep] = useState("company"); const [draft, setDraft] = useState(EMPTY_DRAFT); const [docs, setDocs] = useState([]); const [hydrated, setHydrated] = useState(false); const [submitting, setSubmitting] = useState(false); const [submittedRef, setSubmittedRef] = useState(null); const [submitError, setSubmitError] = useState(null); // Hydrate from localStorage on mount. useEffect(() => { try { const raw = localStorage.getItem(draftKey); if (raw) { const parsed = JSON.parse(raw) as DraftState; // Ensure at least one director / shareholder row exists. if (!parsed.directors?.length) parsed.directors = [blankDirector()]; if (!parsed.shareholders?.length) parsed.shareholders = [blankShareholder()]; setDraft(parsed); } } catch { // Ignore corrupt drafts. } setHydrated(true); }, [draftKey]); // Persist draft on every change (after hydration). useEffect(() => { if (!hydrated) return; try { localStorage.setItem(draftKey, JSON.stringify(draft)); } catch { // Quota or private-mode — best effort. } }, [draft, draftKey, hydrated]); const updateCompany = (patch: Partial) => { setDraft((d) => ({ ...d, company: { ...d.company, ...patch } })); }; const updateProposedName = (idx: 0 | 1 | 2, value: string) => { setDraft((d) => { const next: [string, string, string] = [...d.company.proposed_names]; next[idx] = value; return { ...d, company: { ...d.company, proposed_names: next } }; }); }; const updateDirector = (key: string, patch: Partial) => { setDraft((d) => ({ ...d, directors: d.directors.map((dir) => dir.ui_key === key ? { ...dir, ...patch } : dir, ), })); }; const addDirector = () => { setDraft((d) => ({ ...d, directors: [...d.directors, blankDirector()] })); }; const removeDirector = (key: string) => { setDraft((d) => ({ ...d, directors: d.directors.length > 1 ? d.directors.filter((dir) => dir.ui_key !== key) : d.directors, })); setDocs((ds) => ds.filter((doc) => doc.directorKey !== key)); }; const updateShareholder = (key: string, patch: Partial) => { setDraft((d) => ({ ...d, shareholders: d.shareholders.map((s) => s.ui_key === key ? { ...s, ...patch } : s, ), })); }; const addShareholder = () => { setDraft((d) => ({ ...d, shareholders: [...d.shareholders, blankShareholder()] })); }; const removeShareholder = (key: string) => { setDraft((d) => ({ ...d, shareholders: d.shareholders.length > 1 ? d.shareholders.filter((s) => s.ui_key !== key) : d.shareholders, })); }; const addDoc = (directorKey: string, kind: UploadedDoc["kind"], file: File) => { setDocs((ds) => [ ...ds, { ui_key: crypto.randomUUID(), directorKey, kind, file }, ]); }; const removeDoc = (uiKey: string) => { setDocs((ds) => ds.filter((d) => d.ui_key !== uiKey)); }; const totalShares = draft.shareholders.reduce( (sum, s) => sum + (Number.isFinite(s.share_percent) ? s.share_percent : 0), 0, ); const companyErrs = validateCompany(draft.company); const directorErrMap = useMemo(() => { const m: Record = {}; for (const dir of draft.directors) { m[dir.ui_key] = validateDirector(dir); } return m; }, [draft.directors]); const shareholderErrMap = useMemo(() => { const m: Record = {}; for (const s of draft.shareholders) { m[s.ui_key] = validateShareholder(s); } return m; }, [draft.shareholders]); const allDirectorErrs = Object.values(directorErrMap).flat(); const allShareholderErrs = Object.values(shareholderErrMap).flat(); const canSubmitCompany = companyErrs.length === 0; const canSubmitDirectors = allDirectorErrs.length === 0; const canSubmitShareholders = allShareholderErrs.length === 0 && Math.abs(totalShares - 100) < 0.01; const goNext = () => { if (step === "company") setStep("directors"); else if (step === "directors") setStep("shareholders"); else if (step === "shareholders") setStep("review"); }; const goPrev = () => { if (step === "review") setStep("shareholders"); else if (step === "shareholders") setStep("directors"); else if (step === "directors") setStep("company"); }; const submit = async () => { setSubmitting(true); setSubmitError(null); try { const payload: OnboardingSubmissionPayload = { client_name: draft.client_name.trim(), client_email: draft.client_email.trim(), client_telephone: draft.client_telephone.trim(), client_notes: draft.client_notes.trim(), company: draft.company, directors: draft.directors.map(({ ui_key: _k, ...rest }) => rest), shareholders: draft.shareholders.map(({ ui_key: _k, ...rest }) => rest), }; const form = new FormData(); form.set("access_token", accessToken); form.set("payload", JSON.stringify(payload)); for (const doc of docs) { form.append("documents", doc.file, `${doc.kind}__${doc.directorKey.slice(0, 8)}__${doc.file.name}`); } const res = await fetch("/api/onboarding/submit", { method: "POST", body: form, }); if (!res.ok) { const body = await res.json().catch(() => ({ error: "Unknown error" })); throw new Error(body.error ?? `Submission failed (${res.status})`); } const json = (await res.json()) as { reference_no: string }; // Wipe draft on success. try { localStorage.removeItem(draftKey); } catch { // Best effort. } setSubmittedRef(json.reference_no); } catch (err) { const message = err instanceof Error ? err.message : "Submission failed"; setSubmitError(message); } finally { setSubmitting(false); } }; if (submittedRef) { return ; } return (
{step === "company" && ( setDraft((d) => ({ ...d, client_name: v }))} onClientEmail={(v) => setDraft((d) => ({ ...d, client_email: v }))} onClientTelephone={(v) => setDraft((d) => ({ ...d, client_telephone: v }))} onClientNotes={(v) => setDraft((d) => ({ ...d, client_notes: v }))} onCompanyChange={updateCompany} onProposedName={updateProposedName} /> )} {step === "directors" && ( )} {step === "shareholders" && ( )} {step === "review" && ( )}
); } // ── Sub-components ─────────────────────────────────────────────────────── function Stepper({ step }: { step: StepId }) { const currentIdx = STEPS.findIndex((s) => s.id === step); return (
    {STEPS.map((s, idx) => { const isActive = s.id === step; const isDone = idx < currentIdx; return (
  1. {isDone ? "✓" : idx + 1} {s.label}
  2. ); })}
); } function Field({ label, hint, error, required, children }: { label: string; hint?: string; error?: string | null; required?: boolean; children: React.ReactNode; }) { return ( ); } const INPUT_CLS = "block w-full px-3 py-2.5 bg-surface border border-border rounded-md text-base text-fg placeholder:text-meta focus:border-fg focus:outline-none focus:ring-2 focus:ring-fg/20 transition-colors"; function CompanyStep({ clientName, clientEmail, clientTelephone, clientNotes, company, errors, onClientName, onClientEmail, onClientTelephone, onClientNotes, onCompanyChange, onProposedName }: { clientName: string; clientEmail: string; clientTelephone: string; clientNotes: string; company: OnboardingCompany; errors: string[]; onClientName: (v: string) => void; onClientEmail: (v: string) => void; onClientTelephone: (v: string) => void; onClientNotes: (v: string) => void; onCompanyChange: (patch: Partial) => void; onProposedName: (idx: 0 | 1 | 2, value: string) => void; }) { return (

Client & company details

This is the primary contact for the submission and the new company's setup information.

Primary contact

onClientName(e.target.value)} placeholder="Jane Citizen" required /> onClientEmail(e.target.value)} placeholder="jane@example.co.za" required /> onClientTelephone(e.target.value)} placeholder="+27 81 234 5678" />

Company details

onProposedName(0, e.target.value)} placeholder="1. Acme Trading (Pty) Ltd" required /> onProposedName(1, e.target.value)} placeholder="2. Acme Trading SA (Pty) Ltd (optional)" /> onProposedName(2, e.target.value)} placeholder="3. Acme Holdings (Pty) Ltd (optional)" />

Trading / physical address

onCompanyChange({ physical_address_line_1: e.target.value })} placeholder="Unit 12, 55 Long Street" required /> onCompanyChange({ physical_address_line_2: e.target.value })} placeholder="Sea Point" />
onCompanyChange({ physical_town_city: e.target.value })} placeholder="Cape Town" required />
onCompanyChange({ physical_postal_code: e.target.value })} placeholder="8001" required />

Postal address

onCompanyChange({ postal_address_line_1: e.target.value })} placeholder="PO Box 1234" /> onCompanyChange({ postal_address_line_2: e.target.value })} placeholder="Cape Town" />
onCompanyChange({ postal_town_city: e.target.value })} placeholder="Cape Town" />
onCompanyChange({ postal_postal_code: e.target.value })} placeholder="8000" />
onCompanyChange({ company_cell_phone: e.target.value })} placeholder="+27 81 234 5678" required /> onCompanyChange({ company_email: e.target.value })} placeholder="info@acme.example.co.za" required /> onCompanyChange({ public_officer_name: e.target.value })} placeholder="Jane Citizen" required /> onCompanyChange({ company_secretary_name: e.target.value })} placeholder="John Smith" required />
{errors.length > 0 ? (

Fix the following before continuing:

    {errors.map((e) =>
  • {e}
  • )}
) : null}
); } function DirectorsStep({ directors, errorsByKey, docs, onUpdate, onAdd, onRemove, onAddDoc, onRemoveDoc }: { directors: OnboardingDirector[]; errorsByKey: Record; docs: UploadedDoc[]; onUpdate: (key: string, patch: Partial) => void; onAdd: () => void; onRemove: (key: string) => void; onAddDoc: (directorKey: string, kind: UploadedDoc["kind"], file: File) => void; onRemoveDoc: (uiKey: string) => void; }) { return (

Director information

Each director needs their ID number, contact details, and copies of their ID document plus proof of residence.

{directors.map((dir, idx) => { const errs = errorsByKey[dir.ui_key] ?? []; return (

Director {idx + 1}

{directors.length > 1 ? ( ) : null}
onUpdate(dir.ui_key, { first_names: e.target.value })} placeholder="Jane Maria" required /> onUpdate(dir.ui_key, { surname: e.target.value })} placeholder="Citizen" required /> onUpdate(dir.ui_key, { id_no: e.target.value.replace(/\D/g, "") })} placeholder="8001015009087" required /> onUpdate(dir.ui_key, { date_of_birth: e.target.value })} required /> onUpdate(dir.ui_key, { cell_phone: e.target.value })} placeholder="+27 81 234 5678" required /> onUpdate(dir.ui_key, { email: e.target.value })} placeholder="jane@example.co.za" required />

Residential address

onUpdate(dir.ui_key, { residential_address_line_1: e.target.value })} placeholder="12 Beach Road" required /> onUpdate(dir.ui_key, { residential_address_line_2: e.target.value })} placeholder="Sea Point" />
onUpdate(dir.ui_key, { residential_town_city: e.target.value })} placeholder="Cape Town" required />
onUpdate(dir.ui_key, { residential_postal_code: e.target.value })} placeholder="8005" required />

Postal address (leave blank if same as residential)

onUpdate(dir.ui_key, { postal_address_line_1: e.target.value })} placeholder="PO Box 1234" /> onUpdate(dir.ui_key, { postal_address_line_2: e.target.value })} placeholder="Cape Town" />
onUpdate(dir.ui_key, { postal_town_city: e.target.value })} placeholder="Cape Town" />
onUpdate(dir.ui_key, { postal_postal_code: e.target.value })} placeholder="8000" />
d.directorKey === dir.ui_key)} onAddDoc={onAddDoc} onRemoveDoc={onRemoveDoc} /> {errs.length > 0 ? (
    {errs.map((e) =>
  • {e}
  • )}
) : null}
); })}
); } function DocUploadBlock({ directorKey, directorIndex, docs, onAddDoc, onRemoveDoc }: { directorKey: string; directorIndex: number; docs: UploadedDoc[]; onAddDoc: (directorKey: string, kind: UploadedDoc["kind"], file: File) => void; onRemoveDoc: (uiKey: string) => void; }) { return (

Documents for director {directorIndex + 1}

PDF, JPG, or PNG. After submission these will be emailed to bertus@dyfins.com — please also keep your originals.

d.kind === "id")} directorKey={directorKey} onAddDoc={onAddDoc} onRemoveDoc={onRemoveDoc} /> d.kind === "proof_of_residence")} directorKey={directorKey} onAddDoc={onAddDoc} onRemoveDoc={onRemoveDoc} />
); } function DocSlot({ label, hint, kind, docs, directorKey, onAddDoc, onRemoveDoc }: { label: string; hint?: string; kind: UploadedDoc["kind"]; docs: UploadedDoc[]; directorKey: string; onAddDoc: (directorKey: string, kind: UploadedDoc["kind"], file: File) => void; onRemoveDoc: (uiKey: string) => void; }) { const inputId = `doc-${kind}-${directorKey}`; return (

{label}

{hint ?

{hint}

: null}
{ const files = e.target.files; if (!files) return; for (const file of Array.from(files)) { onAddDoc(directorKey, kind, file); } e.target.value = ""; }} />
{docs.length > 0 ? (
    {docs.map((d) => (
  • {d.file.name}
  • ))}
) : (

No file uploaded yet.

)}
); } function ShareholdersStep({ shareholders, errorsByKey, totalShares, onUpdate, onAdd, onRemove }: { shareholders: OnboardingShareholder[]; errorsByKey: Record; totalShares: number; onUpdate: (key: string, patch: Partial) => void; onAdd: () => void; onRemove: (key: string) => void; }) { return (

Shareholders

Add up to five shareholders. Total share allocation must equal exactly 100%.

{shareholders.map((s, idx) => { const errs = errorsByKey[s.ui_key] ?? []; return (

Shareholder {idx + 1}

{shareholders.length > 1 ? ( ) : null}
onUpdate(s.ui_key, { name: e.target.value })} placeholder={s.kind === "person" ? "Jane Citizen" : "Acme Holdings (Pty) Ltd"} required /> onUpdate(s.ui_key, { id_or_company_no: e.target.value })} placeholder={s.kind === "person" ? "8001015009087" : "K2020123456"} required /> onUpdate(s.ui_key, { share_percent: Number(e.target.value) })} required />
{errs.length > 0 ? (
    {errs.map((e) =>
  • {e}
  • )}
) : null}
); })} {shareholders.length < 5 ? ( ) : (

Maximum of 5 shareholders reached.

)}

Total share allocation

{totalShares.toFixed(2)}%

); } function ReviewStep({ clientName, clientEmail, clientTelephone, company, directors, shareholders, docs, totalShares }: { clientName: string; clientEmail: string; clientTelephone: string; company: OnboardingCompany; directors: OnboardingDirector[]; shareholders: OnboardingShareholder[]; docs: UploadedDoc[]; totalShares: number; }) { return (

Review & submit

Once submitted we'll create the records and email a confirmation. A unique reference number will be assigned at submission time.

{company.proposed_names[1] ? : null} {company.proposed_names[2] ? : null} {directors.map((d, idx) => (

Director {idx + 1}

doc.directorKey === d.ui_key && doc.kind === "id") ? "Uploaded" : "Missing"} /> doc.directorKey === d.ui_key && doc.kind === "proof_of_residence") ? "Uploaded" : "Missing"} />
))}
{shareholders.map((s, idx) => (

Shareholder {idx + 1}

))}
); } function SummaryCard({ title, children }: { title: string; children: React.ReactNode }) { return (

{title}

{children}
); } function SummaryRow({ label, value }: { label: string; value: string }) { return (
{label} {value}
); } function Nav({ step, canProceed, submitting, submitError, onPrev, onNext, onSubmit }: { step: StepId; canProceed: boolean; submitting: boolean; submitError: string | null; onPrev: () => void; onNext: () => void; onSubmit: () => void; }) { const isReview = step === "review"; return (
{submitError ? (

{submitError}

) : null} {isReview ? ( ) : ( )}
); } function SubmittedSuccess({ referenceNo }: { referenceNo: string }) { return (

Submission received

Thanks — we'll review and process your submission. You'll receive an email confirmation shortly.

Reference number

{referenceNo}

Please keep this reference for your records.

); }