// components/RegistrationWizard.tsx // Client Component. New-company registration wizard. "use client"; import { useState } from "react"; import type { DirectorInput, ShareholderInput, CompanyInput, WizardState } from "@/lib/wizard-types"; import DirectorStep from "@/components/DirectorStep"; import ActionButton from "@/components/ActionButton"; const SA_PROVINCES = [ "Eastern Cape", "Free State", "Gauteng", "KwaZulu-Natal", "Limpopo", "Mpumalanga", "Northern Cape", "North West", "Western Cape", ]; const MONTHS = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]; const STEPS = [ { id: "directors", label: "Directors" }, { id: "company", label: "Company" }, { id: "shareholders",label: "Shareholders" }, { id: "review", label: "Review" }, { id: "step2", label: "Step 2" }, ] as const; type StepId = (typeof STEPS)[number]["id"]; function uid(): string { return typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" ? crypto.randomUUID() : Math.random().toString(36).slice(2) + Date.now().toString(36); } function blankDirector(): DirectorInput { return { ui_key: uid(), first_names: "", surname: "", id_no: "", date_of_birth: "", residential_address_line_1: "", residential_address_line_2: "", residential_town_city: "", residential_province: "", residential_postal_code: "", postal_address_line_1: "", postal_address_line_2: "", postal_town_city: "", postal_province: "", postal_postal_code: "", cell_phone: "", email: "", residential_address: "", postal_address: "", section69: { q1_probation: null, q2_court_prohibited: null, q3_unrehabilitated_insolvent: null, q4_public_regulation: null, q5_removed_from_office: null, q6_convicted_offence: null, }, }; } function blankShareholder(): ShareholderInput { return { ui_key: uid(), is_director: false, director_index: null, name: "", id_or_entity_no: "", quantity_of_shares: 0, share_percent: 0 }; } function emptyCompany(): CompanyInput { return { proposed_name_1: "", proposed_name_2: "", proposed_name_3: "", proposed_name_4: "", company_type: "", physical_address_line_1: "", physical_address_line_2: "", physical_town_city: "", physical_province: "", physical_postal_code: "", postal_address_line_1: "", postal_address_line_2: "", postal_town_city: "", postal_province: "", postal_postal_code: "", financial_year_end: 2, // February default authorised_shares: "1000", value_per_share: 1, // R1.00 default company_cell: "", company_email: "", public_officer: "", company_secretary: "", }; } // ── Company Step ────────────────────────────────────────────────────────────── function CompanyStep({ company, directors, onUpdate, }: { company: CompanyInput; directors: DirectorInput[]; onUpdate: (p: Partial) => void; }) { const set = (p: Partial) => onUpdate(p); const filledDirectors = directors.filter((d) => d.first_names.trim() && d.surname.trim()); return (
{/* Proposed names */}

Proposed company names

{([ { key: "proposed_name_1" as const, placeholder: "First choice" }, { key: "proposed_name_2" as const, placeholder: "Second choice" }, { key: "proposed_name_3" as const, placeholder: "Third choice" }, { key: "proposed_name_4" as const, placeholder: "Fourth choice (optional)" }, ]).map(({ key, placeholder }) => (
set({ [key]: e.target.value })} />
))}
{/* Company type */}
{/* Addresses */}

Addresses

{/* Physical / trading address */}

Physical / trading address

{filledDirectors.length > 0 && ( )}
set({ physical_address_line_1: e.target.value })} />
set({ physical_address_line_2: e.target.value })} />
set({ physical_town_city: e.target.value })} />
set({ physical_postal_code: e.target.value })} />
{/* Postal address */}

Postal address

{filledDirectors.length > 0 && ( )}
set({ postal_address_line_1: e.target.value })} />
set({ postal_address_line_2: e.target.value })} />
set({ postal_town_city: e.target.value })} />
set({ postal_postal_code: e.target.value })} />
{/* Financial year end + share capital */}
R { const v = parseFloat(e.target.value); set({ value_per_share: isNaN(v) ? 0 : v }); }} />
{/* Contact + officers */}

Company contact & officers

{/* Company cell */}
{filledDirectors.length > 0 && ( )}
set({ company_cell: e.target.value })} />
{/* Company email */}
{filledDirectors.length > 0 && ( )}
set({ company_email: e.target.value })} />
{/* Public officer + secretary (not compulsory) */}
set({ public_officer: e.target.value })} />
set({ company_secretary: e.target.value })} />
); } // ── Shareholder Step ───────────────────────────────────────────────────────── function ShareholderStep({ state, onUpdate }: { state: WizardState; onUpdate: (s: WizardState) => void }) { const updateSH = (i: number, patch: Partial) => { const shareholders = [...state.shareholders]; shareholders[i] = { ...shareholders[i], ...patch }; onUpdate({ ...state, shareholders }); }; const totalPct = state.shareholders.reduce((s, sh) => s + (sh.share_percent || 0), 0); return (

How many shareholders?

{[1, 2, 3, 4, 5].map((n) => ( ))}

Total: {totalPct}% {totalPct !== 100 && (100% recommended but optional)}

{state.shareholders.map((sh, i) => (

Shareholder {i + 1}

{state.directors.length > 0 && (
Same as director:
)}
{!sh.is_director && (
updateSH(i, { name: e.target.value })} />
updateSH(i, { id_or_entity_no: e.target.value })} />
)}
{ const qty = Math.max(0, Math.floor(Number(e.target.value) || 0)); updateSH(i, { quantity_of_shares: qty }); }} /> shares {sh.share_percent > 0 && ( ({sh.share_percent.toFixed(2)}%) )}
))}
); } // ── Review Step ─────────────────────────────────────────────────────────────── function ReviewStep({ state }: { state: WizardState }) { return (

Directors

{state.directors.map((d, i) => (

{d.first_names} {d.surname}

ID: {d.id_no || "—"} · {d.cell_phone}

))}

Company

Type

{state.company.company_type === "pty_ltd" ? "Private Company — (PTY) LTD" : state.company.company_type === "npc" ? "Non Profit Company — NPC" : "—"}

Name 1

{state.company.proposed_name_1 || "—"}

Name 2

{state.company.proposed_name_2 || "—"}

Name 3

{state.company.proposed_name_3 || "—"}

{state.company.proposed_name_4 ? <>

Name 4

{state.company.proposed_name_4}

: null}

Physical address

{state.company.physical_address_line_1 ? `${state.company.physical_address_line_1}${state.company.physical_address_line_2 ? `, ${state.company.physical_address_line_2}` : ""}, ${state.company.physical_town_city} ${state.company.physical_postal_code}` : "—"}

Postal address

{state.company.postal_address_line_1 ? `${state.company.postal_address_line_1}${state.company.postal_address_line_2 ? `, ${state.company.postal_address_line_2}` : ""}, ${state.company.postal_town_city} ${state.company.postal_postal_code}` : "—"}

Year end

{MONTHS[state.company.financial_year_end - 1]}

Authorised shares

{state.company.authorised_shares}

Value per share

R{Number(state.company.value_per_share || 0).toFixed(2)}

Public officer

{state.company.public_officer || "—"}

Secretary

{state.company.company_secretary || "—"}

Shareholders

{state.shareholders.map((sh, i) => (

{sh.name || "—"}

{sh.id_or_entity_no || "—"}

{sh.quantity_of_shares.toLocaleString()} shares

{sh.share_percent > 0 && (

{sh.share_percent.toFixed(2)}%

)}
))}

Allocated: {state.shareholders.reduce((s, sh) => s + sh.quantity_of_shares, 0).toLocaleString()} {" / "} {state.company.authorised_shares} authorised {state.shareholders.reduce((s, sh) => s + sh.share_percent, 0) > 0 && ( <> · {state.shareholders.reduce((s, sh) => s + sh.share_percent, 0).toFixed(2)}% total )}

); } // ── Validation ─────────────────────────────────────────────────────────────── function canProceed(step: StepId, _state: WizardState, _isUpdate: boolean): boolean { // All fields are optional — a blank application can be submitted. // Continue is always enabled; Submit on Review does the actual write. return true; } // ── Main component ──────────────────────────────────────────────────────────── interface PrefillData { directors: { first_names: string; surname: string; id_no: string; date_of_birth: string; residential_address: string; // legacy PB field residential_address_line_1: string; residential_address_line_2: string; residential_town_city: string; residential_province: string; residential_postal_code: string; postal_address: string; // legacy PB field postal_address_line_1: string; postal_address_line_2: string; postal_town_city: string; postal_province: string; postal_postal_code: string; cell_phone: string; email: string; section69_answers?: string; }[]; shareholders: { name: string; id_or_entity_no: string; share_percent: string; quantity_of_shares?: string; is_director: string; }[]; company: { proposed_name_1: string; proposed_name_2: string; proposed_name_3: string; proposed_name_4: string; company_type: string; trading_address: string; // legacy PB field postal_address: string; // legacy PB field physical_address_line_1: string; physical_address_line_2: string; physical_town_city: string; physical_province: string; physical_postal_code: string; postal_address_line_1: string; postal_address_line_2: string; postal_town_city: string; postal_province: string; postal_postal_code: string; financial_year_end: string; authorised_shares: string; value_per_share?: string; company_cell: string; company_email: string; public_officer: string; company_secretary: string; }; } export default function RegistrationWizard({ registrationId, prefill, token, referenceName, status, }: { registrationId: string; prefill?: PrefillData; token?: string; referenceName?: string; status?: string; }) { const [step, setStep] = useState("directors"); const [submitting, setSubmitting] = useState(false); const [submitted, setSubmitted] = useState(false); const [submitError, setSubmitError] = useState(null); const isUpdate = !!prefill; // Initialize state — either blank or from prefill data const [state, setState] = useState(() => { if (prefill && prefill.directors.length > 0) { return { director_count: prefill.directors.length, directors: prefill.directors.map((d) => { let section69 = { q1_probation: null as boolean | null, q2_court_prohibited: null as boolean | null, q3_unrehabilitated_insolvent: null as boolean | null, q4_public_regulation: null as boolean | null, q5_removed_from_office: null as boolean | null, q6_convicted_offence: null as boolean | null, }; try { const raw = typeof d.section69_answers === "string" ? JSON.parse(d.section69_answers) : d.section69_answers; if (raw) { section69 = { q1_probation: raw.q1_probation ?? null, q2_court_prohibited: raw.q2_court_prohibited ?? null, q3_unrehabilitated_insolvent: raw.q3_unrehabilitated_insolvent ?? null, q4_public_regulation: raw.q4_public_regulation ?? null, q5_removed_from_office: raw.q5_removed_from_office ?? null, q6_convicted_offence: raw.q6_convicted_offence ?? null, }; } } catch { /* use defaults */ } return { ui_key: uid(), first_names: d.first_names || "", surname: d.surname || "", id_no: d.id_no || "", date_of_birth: d.date_of_birth || "", residential_address_line_1: d.residential_address_line_1 || d.residential_address || "", residential_address_line_2: d.residential_address_line_2 || "", residential_town_city: d.residential_town_city || "", residential_province: d.residential_province || "", residential_postal_code: d.residential_postal_code || "", postal_address_line_1: d.postal_address_line_1 || d.postal_address || "", postal_address_line_2: d.postal_address_line_2 || "", postal_town_city: d.postal_town_city || "", postal_province: d.postal_province || "", postal_postal_code: d.postal_postal_code || "", cell_phone: d.cell_phone || "", email: d.email || "", section69, }; }), company: { proposed_name_1: prefill.company?.proposed_name_1 || "", proposed_name_2: prefill.company?.proposed_name_2 || "", proposed_name_3: prefill.company?.proposed_name_3 || "", proposed_name_4: prefill.company?.proposed_name_4 || "", company_type: (prefill.company as Record)?.company_type as string || "", physical_address_line_1: prefill.company?.physical_address_line_1 || prefill.company?.trading_address || "", physical_address_line_2: prefill.company?.physical_address_line_2 || "", physical_town_city: prefill.company?.physical_town_city || "", physical_province: prefill.company?.physical_province || "", physical_postal_code: prefill.company?.physical_postal_code || "", postal_address_line_1: prefill.company?.postal_address_line_1 || prefill.company?.postal_address || "", postal_address_line_2: prefill.company?.postal_address_line_2 || "", postal_town_city: prefill.company?.postal_town_city || "", postal_province: prefill.company?.postal_province || "", postal_postal_code: prefill.company?.postal_postal_code || "", financial_year_end: (() => { const v = parseInt(prefill.company?.financial_year_end || "2", 10); return isNaN(v) ? 2 : (v as CompanyInput["financial_year_end"]); })(), authorised_shares: ((prefill.company?.authorised_shares || "1000") === "100" ? "100" : "1000") as CompanyInput["authorised_shares"], value_per_share: parseFloat(prefill.company?.value_per_share || "1") || 1, company_cell: prefill.company?.company_cell || "", company_email: prefill.company?.company_email || "", public_officer: prefill.company?.public_officer || "", company_secretary: prefill.company?.company_secretary || "", }, shareholders: prefill.shareholders.length > 0 ? prefill.shareholders.map((s) => ({ ui_key: uid(), is_director: s.is_director === "true", director_index: null, name: s.name || "", id_or_entity_no: s.id_or_entity_no || "", quantity_of_shares: parseInt(s.quantity_of_shares || "0", 10) || 0, share_percent: parseFloat(s.share_percent) || 0, })) : [blankShareholder()], } as WizardState; } return { director_count: 1, directors: [blankDirector()], company: emptyCompany(), shareholders: [blankShareholder()], }; }); const stepIndex = STEPS.findIndex((s) => s.id === step); const handleSubmit = async () => { setSubmitting(true); setSubmitError(null); try { // Strip id_file (File) from payload before sending to JSON endpoint. const payload = { registrationId, ...state, directors: state.directors.map(({ id_file, ...rest }) => rest), isUpdate, }; const res = await fetch("/api/register/submit", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); if (!res.ok) throw new Error(await res.text()); const data = await res.json(); // After the reg is saved, upload each director's ID file (if any). // The director's PB id isn't known to the wizard; we send the // first_names+surname so the server can build the canonical filename. for (const d of state.directors) { if (!d.id_file) continue; const fd = new FormData(); fd.append("type", "director_id"); fd.append("director_first_names", d.first_names || ""); fd.append("director_surname", d.surname || ""); fd.append("files", d.id_file); try { const up = await fetch(`/api/register/${registrationId}/upload`, { method: "POST", body: fd, }); if (!up.ok) { const txt = await up.text().catch(() => ""); console.error(`[register/upload] director_id failed: ${up.status} ${txt}`); } } catch (err) { console.error("[register/upload] network error", err); } } if (isUpdate) { alert("Registration saved. Your changes have been recorded."); setSubmitted(true); } else { setSubmitted(true); } void data; // unused } catch (err: unknown) { setSubmitError(err instanceof Error ? err.message : "Submission failed"); } finally { setSubmitting(false); } }; if (submitted) { return (

{isUpdate ? "Saved" : "Registration submitted"}

{isUpdate ? "Your changes have been recorded. You can close this tab." : "Your information has been received. Your registration agent will be in touch shortly."}

); } return (
{/* Step indicator */}
{STEPS.map((s, i) => { const isActive = s.id === step; const isPast = i < stepIndex; return (
{i < STEPS.length - 1 &&
}
); })}
{/* Update-mode banner */} {isUpdate && (
Continuing existing registration. Existing data has been loaded. Fill in any missing fields below, then click Continue → to advance.
)} {/* Step content */}
{step === "directors" && (

Step 1

Director details

How many directors?

{[1, 2, 3, 4, 5].map((n) => ( ))}

All fields below are optional. You can leave anything blank and still submit.

{state.directors.map((director, i) => ( { const directors = [...state.directors]; directors[i] = { ...directors[i], ...patch }; setState({ ...state, directors }); }} /> ))}
)} {step === "company" && (

Step 2

Company information

setState({ ...state, company: { ...state.company, ...p } })} />
)} {step === "shareholders" && (

Step 3

Shareholders

)} {step === "review" && (

Step 4

Review & submit

)} {step === "step2" && (

Step 5 — Actions

BizPortal Actions

Update the status as you work through BizPortal.

{/* Action Buttons */}
{/* Navigation */}
)}
{submitError && (
{submitError}
)}
{step !== "review" && step !== "step2" ? ( ) : ( )}
); }