Workflow-App/Workflow/components/OnboardingWizard.tsx

1508 lines
54 KiB
TypeScript
Raw Permalink Normal View History

// 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<StepId>("company");
const [draft, setDraft] = useState<DraftState>(EMPTY_DRAFT);
const [docs, setDocs] = useState<UploadedDoc[]>([]);
const [hydrated, setHydrated] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [submittedRef, setSubmittedRef] = useState<string | null>(null);
const [submitError, setSubmitError] = useState<string | null>(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<OnboardingCompany>) => {
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<OnboardingDirector>) => {
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<OnboardingShareholder>) => {
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<string, string[]> = {};
for (const dir of draft.directors) {
m[dir.ui_key] = validateDirector(dir);
}
return m;
}, [draft.directors]);
const shareholderErrMap = useMemo(() => {
const m: Record<string, string[]> = {};
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 <SubmittedSuccess referenceNo={submittedRef} />;
}
return (
<div className="space-y-8" data-od-id="onboarding-wizard">
<Stepper step={step} />
{step === "company" && (
<CompanyStep
clientName={draft.client_name}
clientEmail={draft.client_email}
clientTelephone={draft.client_telephone}
clientNotes={draft.client_notes}
company={draft.company}
errors={companyErrs}
onClientName={(v) => 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" && (
<DirectorsStep
directors={draft.directors}
errorsByKey={directorErrMap}
docs={docs}
onUpdate={updateDirector}
onAdd={addDirector}
onRemove={removeDirector}
onAddDoc={addDoc}
onRemoveDoc={removeDoc}
/>
)}
{step === "shareholders" && (
<ShareholdersStep
shareholders={draft.shareholders}
errorsByKey={shareholderErrMap}
totalShares={totalShares}
onUpdate={updateShareholder}
onAdd={addShareholder}
onRemove={removeShareholder}
/>
)}
{step === "review" && (
<ReviewStep
clientName={draft.client_name}
clientEmail={draft.client_email}
clientTelephone={draft.client_telephone}
company={draft.company}
directors={draft.directors}
shareholders={draft.shareholders}
docs={docs}
totalShares={totalShares}
/>
)}
<Nav
step={step}
canProceed={
(step === "company" && canSubmitCompany) ||
(step === "directors" && canSubmitDirectors) ||
(step === "shareholders" && canSubmitShareholders) ||
step === "review"
}
submitting={submitting}
submitError={submitError}
onPrev={goPrev}
onNext={goNext}
onSubmit={submit}
/>
</div>
);
}
// ── Sub-components ───────────────────────────────────────────────────────
function Stepper({ step }: { step: StepId }) {
const currentIdx = STEPS.findIndex((s) => s.id === step);
return (
<ol className="flex items-center gap-2 sm:gap-4 overflow-x-auto pb-2" data-od-id="wizard-stepper">
{STEPS.map((s, idx) => {
const isActive = s.id === step;
const isDone = idx < currentIdx;
return (
<li
key={s.id}
className={
"flex items-center gap-2 px-3 py-1.5 rounded-pill text-sm font-semibold whitespace-nowrap " +
(isActive
? "bg-surface-warm text-fg"
: isDone
? "text-fg"
: "text-muted")
}
>
<span
className={
"inline-flex items-center justify-center w-5 h-5 rounded-pill text-xs font-bold " +
(isDone
? "bg-accent text-accent-on"
: isActive
? "bg-fg text-surface"
: "bg-surface-warm text-muted")
}
>
{isDone ? "✓" : idx + 1}
</span>
<span>{s.label}</span>
</li>
);
})}
</ol>
);
}
function Field({
label,
hint,
error,
required,
children
}: {
label: string;
hint?: string;
error?: string | null;
required?: boolean;
children: React.ReactNode;
}) {
return (
<label className="flex flex-col gap-1.5">
<span className="text-sm font-semibold text-fg">
{label}
{required ? <span className="text-danger ml-1">*</span> : null}
</span>
{children}
{hint && !error ? (
<span className="text-xs text-muted">{hint}</span>
) : null}
{error ? (
<span className="text-xs text-danger font-medium">{error}</span>
) : null}
</label>
);
}
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<OnboardingCompany>) => void;
onProposedName: (idx: 0 | 1 | 2, value: string) => void;
}) {
return (
<section className="space-y-8" data-od-id="step-company">
<header>
<h2 className="text-xl font-bold text-fg">Client & company details</h2>
<p className="text-sm text-muted mt-1">
This is the primary contact for the submission and the new
company&apos;s setup information.
</p>
</header>
<div className="space-y-5">
<h3 className="text-sm font-semibold text-fg uppercase tracking-wider">Primary contact</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Field label="Full name" required>
<input
type="text"
className={INPUT_CLS}
value={clientName}
onChange={(e) => onClientName(e.target.value)}
placeholder="Jane Citizen"
required
/>
</Field>
<Field label="Email" required>
<input
type="email"
className={INPUT_CLS}
value={clientEmail}
onChange={(e) => onClientEmail(e.target.value)}
placeholder="jane@example.co.za"
required
/>
</Field>
<Field label="Telephone" hint="Optional">
<input
type="tel"
className={INPUT_CLS}
value={clientTelephone}
onChange={(e) => onClientTelephone(e.target.value)}
placeholder="+27 81 234 5678"
/>
</Field>
</div>
<VoiceField
id="client_notes"
label="Anything else? (voice-friendly)"
hint="Tap the mic to dictate, or the speaker to hear it read back."
value={clientNotes}
onChange={onClientNotes}
placeholder="e.g. preferred effective date, special share classes, contact-by-phone only, …"
rows={3}
/>
</div>
<div className="space-y-5">
<h3 className="text-sm font-semibold text-fg uppercase tracking-wider">Company details</h3>
<div className="space-y-4">
<Field
label="Proposed company names (in priority order)"
hint="Provide up to three names. CIPC will approve the first available name."
required
>
<div className="space-y-2">
<input
type="text"
className={INPUT_CLS}
value={company.proposed_names[0]}
onChange={(e) => onProposedName(0, e.target.value)}
placeholder="1. Acme Trading (Pty) Ltd"
required
/>
<input
type="text"
className={INPUT_CLS}
value={company.proposed_names[1]}
onChange={(e) => onProposedName(1, e.target.value)}
placeholder="2. Acme Trading SA (Pty) Ltd (optional)"
/>
<input
type="text"
className={INPUT_CLS}
value={company.proposed_names[2]}
onChange={(e) => onProposedName(2, e.target.value)}
placeholder="3. Acme Holdings (Pty) Ltd (optional)"
/>
</div>
</Field>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Field label="Financial year end" required>
<select
className={INPUT_CLS}
value={company.financial_year_end_month}
onChange={(e) => onCompanyChange({ financial_year_end_month: Number(e.target.value) })}
>
{MONTH_NAMES.map((name, idx) => (
<option key={idx + 1} value={idx + 1}>{name}</option>
))}
</select>
</Field>
<Field label="Authorised shares" required>
<select
className={INPUT_CLS}
value={company.authorised_shares}
onChange={(e) => onCompanyChange({ authorised_shares: e.target.value as "100" | "1000" })}
>
<option value="100">100 shares</option>
<option value="1000">1 000 shares</option>
</select>
</Field>
</div>
<div className="space-y-1">
<p className="text-xs font-semibold text-muted uppercase tracking-wider">Trading / physical address</p>
<Field label="Address line 1" required>
<input className={INPUT_CLS} value={company.physical_address_line_1}
onChange={(e) => onCompanyChange({ physical_address_line_1: e.target.value })}
placeholder="Unit 12, 55 Long Street" required />
</Field>
<Field label="Address line 2">
<input className={INPUT_CLS} value={company.physical_address_line_2}
onChange={(e) => onCompanyChange({ physical_address_line_2: e.target.value })}
placeholder="Sea Point" />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Town / City" required>
<input className={INPUT_CLS} value={company.physical_town_city}
onChange={(e) => onCompanyChange({ physical_town_city: e.target.value })}
placeholder="Cape Town" required />
</Field>
<Field label="Province" required>
<select className={INPUT_CLS} value={company.physical_province}
onChange={(e) => onCompanyChange({ physical_province: e.target.value })} required>
<option value="">Select province</option>
{["Eastern Cape","Free State","Gauteng","KwaZulu-Natal","Limpopo","Mpumalanga","Northern Cape","North West","Western Cape"].map(p => (
<option key={p} value={p}>{p}</option>
))}
</select>
</Field>
</div>
<Field label="Postal code" required>
<input className={INPUT_CLS} value={company.physical_postal_code}
onChange={(e) => onCompanyChange({ physical_postal_code: e.target.value })}
placeholder="8001" required />
</Field>
</div>
<div className="space-y-1">
<p className="text-xs font-semibold text-muted uppercase tracking-wider">Postal address</p>
<Field label="Address line 1">
<input className={INPUT_CLS} value={company.postal_address_line_1}
onChange={(e) => onCompanyChange({ postal_address_line_1: e.target.value })}
placeholder="PO Box 1234" />
</Field>
<Field label="Address line 2">
<input className={INPUT_CLS} value={company.postal_address_line_2}
onChange={(e) => onCompanyChange({ postal_address_line_2: e.target.value })}
placeholder="Cape Town" />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Town / City">
<input className={INPUT_CLS} value={company.postal_town_city}
onChange={(e) => onCompanyChange({ postal_town_city: e.target.value })}
placeholder="Cape Town" />
</Field>
<Field label="Province">
<select className={INPUT_CLS} value={company.postal_province}
onChange={(e) => onCompanyChange({ postal_province: e.target.value })}>
<option value="">Select province</option>
{["Eastern Cape","Free State","Gauteng","KwaZulu-Natal","Limpopo","Mpumalanga","Northern Cape","North West","Western Cape"].map(p => (
<option key={p} value={p}>{p}</option>
))}
</select>
</Field>
</div>
<Field label="Postal code">
<input className={INPUT_CLS} value={company.postal_postal_code}
onChange={(e) => onCompanyChange({ postal_postal_code: e.target.value })}
placeholder="8000" />
</Field>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Field label="Company cell phone" required>
<input
type="tel"
className={INPUT_CLS}
value={company.company_cell_phone}
onChange={(e) => onCompanyChange({ company_cell_phone: e.target.value })}
placeholder="+27 81 234 5678"
required
/>
</Field>
<Field label="Company email" required>
<input
type="email"
className={INPUT_CLS}
value={company.company_email}
onChange={(e) => onCompanyChange({ company_email: e.target.value })}
placeholder="info@acme.example.co.za"
required
/>
</Field>
<Field label="Public officer (name)" required>
<input
type="text"
className={INPUT_CLS}
value={company.public_officer_name}
onChange={(e) => onCompanyChange({ public_officer_name: e.target.value })}
placeholder="Jane Citizen"
required
/>
</Field>
<Field label="Company secretary (name)" required>
<input
type="text"
className={INPUT_CLS}
value={company.company_secretary_name}
onChange={(e) => onCompanyChange({ company_secretary_name: e.target.value })}
placeholder="John Smith"
required
/>
</Field>
</div>
</div>
</div>
{errors.length > 0 ? (
<div className="bg-[color-mix(in_oklch,var(--danger)_8%,white)] border border-[color:var(--danger)]/30 rounded-md p-4">
<p className="text-sm font-semibold text-danger mb-2">Fix the following before continuing:</p>
<ul className="text-sm text-danger space-y-1 list-disc pl-5">
{errors.map((e) => <li key={e}>{e}</li>)}
</ul>
</div>
) : null}
</section>
);
}
function DirectorsStep({
directors,
errorsByKey,
docs,
onUpdate,
onAdd,
onRemove,
onAddDoc,
onRemoveDoc
}: {
directors: OnboardingDirector[];
errorsByKey: Record<string, string[]>;
docs: UploadedDoc[];
onUpdate: (key: string, patch: Partial<OnboardingDirector>) => void;
onAdd: () => void;
onRemove: (key: string) => void;
onAddDoc: (directorKey: string, kind: UploadedDoc["kind"], file: File) => void;
onRemoveDoc: (uiKey: string) => void;
}) {
return (
<section className="space-y-8" data-od-id="step-directors">
<header>
<h2 className="text-xl font-bold text-fg">Director information</h2>
<p className="text-sm text-muted mt-1">
Each director needs their ID number, contact details, and copies
of their ID document plus proof of residence.
</p>
</header>
<div className="space-y-6">
{directors.map((dir, idx) => {
const errs = errorsByKey[dir.ui_key] ?? [];
return (
<div
key={dir.ui_key}
className="bg-surface border border-border rounded-lg p-5 sm:p-6 space-y-5"
>
<div className="flex items-center justify-between gap-3">
<h3 className="text-base font-bold text-fg">Director {idx + 1}</h3>
{directors.length > 1 ? (
<button
type="button"
onClick={() => onRemove(dir.ui_key)}
className="text-xs font-semibold text-muted hover:text-danger px-2 py-1 rounded transition-colors"
>
Remove
</button>
) : null}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Field label="First names" required>
<input
type="text"
className={INPUT_CLS}
value={dir.first_names}
onChange={(e) => onUpdate(dir.ui_key, { first_names: e.target.value })}
placeholder="Jane Maria"
required
/>
</Field>
<Field label="Surname" required>
<input
type="text"
className={INPUT_CLS}
value={dir.surname}
onChange={(e) => onUpdate(dir.ui_key, { surname: e.target.value })}
placeholder="Citizen"
required
/>
</Field>
<Field label="ID number" required hint="13 digits, no spaces">
<input
type="tel"
inputMode="numeric"
pattern="\d{13}"
maxLength={13}
className={INPUT_CLS}
value={dir.id_no}
onChange={(e) => onUpdate(dir.ui_key, { id_no: e.target.value.replace(/\D/g, "") })}
placeholder="8001015009087"
required
/>
</Field>
<Field label="Date of birth" required>
<input
type="date"
className={INPUT_CLS}
value={dir.date_of_birth}
onChange={(e) => onUpdate(dir.ui_key, { date_of_birth: e.target.value })}
required
/>
</Field>
<Field label="Cell phone" required>
<input
type="tel"
className={INPUT_CLS}
value={dir.cell_phone}
onChange={(e) => onUpdate(dir.ui_key, { cell_phone: e.target.value })}
placeholder="+27 81 234 5678"
required
/>
</Field>
<Field label="Email" required>
<input
type="email"
className={INPUT_CLS}
value={dir.email}
onChange={(e) => onUpdate(dir.ui_key, { email: e.target.value })}
placeholder="jane@example.co.za"
required
/>
</Field>
</div>
<div className="space-y-1">
<p className="text-xs font-semibold text-muted uppercase tracking-wider">Residential address</p>
<Field label="Address line 1" required>
<input className={INPUT_CLS} value={dir.residential_address_line_1}
onChange={(e) => onUpdate(dir.ui_key, { residential_address_line_1: e.target.value })}
placeholder="12 Beach Road" required />
</Field>
<Field label="Address line 2">
<input className={INPUT_CLS} value={dir.residential_address_line_2}
onChange={(e) => onUpdate(dir.ui_key, { residential_address_line_2: e.target.value })}
placeholder="Sea Point" />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Town / City" required>
<input className={INPUT_CLS} value={dir.residential_town_city}
onChange={(e) => onUpdate(dir.ui_key, { residential_town_city: e.target.value })}
placeholder="Cape Town" required />
</Field>
<Field label="Province" required>
<select className={INPUT_CLS} value={dir.residential_province}
onChange={(e) => onUpdate(dir.ui_key, { residential_province: e.target.value })} required>
<option value="">Select province</option>
{["Eastern Cape","Free State","Gauteng","KwaZulu-Natal","Limpopo","Mpumalanga","Northern Cape","North West","Western Cape"].map(p => (
<option key={p} value={p}>{p}</option>
))}
</select>
</Field>
</div>
<Field label="Postal code" required>
<input className={INPUT_CLS} value={dir.residential_postal_code}
onChange={(e) => onUpdate(dir.ui_key, { residential_postal_code: e.target.value })}
placeholder="8005" required />
</Field>
</div>
<div className="space-y-1">
<p className="text-xs font-semibold text-muted uppercase tracking-wider">Postal address <span className="text-muted font-normal normal-case">(leave blank if same as residential)</span></p>
<Field label="Address line 1">
<input className={INPUT_CLS} value={dir.postal_address_line_1}
onChange={(e) => onUpdate(dir.ui_key, { postal_address_line_1: e.target.value })}
placeholder="PO Box 1234" />
</Field>
<Field label="Address line 2">
<input className={INPUT_CLS} value={dir.postal_address_line_2}
onChange={(e) => onUpdate(dir.ui_key, { postal_address_line_2: e.target.value })}
placeholder="Cape Town" />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Town / City">
<input className={INPUT_CLS} value={dir.postal_town_city}
onChange={(e) => onUpdate(dir.ui_key, { postal_town_city: e.target.value })}
placeholder="Cape Town" />
</Field>
<Field label="Province">
<select className={INPUT_CLS} value={dir.postal_province}
onChange={(e) => onUpdate(dir.ui_key, { postal_province: e.target.value })}>
<option value="">Select province</option>
{["Eastern Cape","Free State","Gauteng","KwaZulu-Natal","Limpopo","Mpumalanga","Northern Cape","North West","Western Cape"].map(p => (
<option key={p} value={p}>{p}</option>
))}
</select>
</Field>
</div>
<Field label="Postal code">
<input className={INPUT_CLS} value={dir.postal_postal_code}
onChange={(e) => onUpdate(dir.ui_key, { postal_postal_code: e.target.value })}
placeholder="8000" />
</Field>
</div>
<DocUploadBlock
directorKey={dir.ui_key}
directorIndex={idx}
docs={docs.filter((d) => d.directorKey === dir.ui_key)}
onAddDoc={onAddDoc}
onRemoveDoc={onRemoveDoc}
/>
{errs.length > 0 ? (
<ul className="text-xs text-danger space-y-1 list-disc pl-5">
{errs.map((e) => <li key={e}>{e}</li>)}
</ul>
) : null}
</div>
);
})}
<button
type="button"
onClick={onAdd}
className="w-full sm:w-auto px-4 py-2.5 rounded-md border border-border text-sm font-semibold text-fg hover:bg-surface-warm transition-colors"
>
+ Add another director
</button>
</div>
</section>
);
}
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 (
<div className="space-y-3 pt-3 border-t border-border-soft">
<h4 className="text-sm font-semibold text-fg">Documents for director {directorIndex + 1}</h4>
<p className="text-xs text-muted">
PDF, JPG, or PNG. After submission these will be emailed to
bertus@dyfins.com — please also keep your originals.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<DocSlot
label="ID document (both sides)"
kind="id"
docs={docs.filter((d) => d.kind === "id")}
directorKey={directorKey}
onAddDoc={onAddDoc}
onRemoveDoc={onRemoveDoc}
/>
<DocSlot
label="Proof of residence"
hint="Not older than 3 months"
kind="proof_of_residence"
docs={docs.filter((d) => d.kind === "proof_of_residence")}
directorKey={directorKey}
onAddDoc={onAddDoc}
onRemoveDoc={onRemoveDoc}
/>
</div>
</div>
);
}
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 (
<div className="border border-border-soft rounded-md p-3 space-y-2 bg-surface-warm/40">
<div className="flex items-center justify-between gap-2">
<div>
<p className="text-sm font-semibold text-fg">{label}</p>
{hint ? <p className="text-xs text-muted">{hint}</p> : null}
</div>
<label
htmlFor={inputId}
className="px-3 py-1.5 text-xs font-semibold border border-border rounded-pill cursor-pointer hover:bg-surface transition-colors"
>
Choose file
</label>
<input
id={inputId}
type="file"
accept="application/pdf,image/jpeg,image/png"
multiple
className="sr-only"
onChange={(e) => {
const files = e.target.files;
if (!files) return;
for (const file of Array.from(files)) {
onAddDoc(directorKey, kind, file);
}
e.target.value = "";
}}
/>
</div>
{docs.length > 0 ? (
<ul className="text-xs text-fg space-y-1">
{docs.map((d) => (
<li key={d.ui_key} className="flex items-center justify-between gap-2 bg-surface border border-border-soft rounded px-2 py-1">
<span className="truncate">{d.file.name}</span>
<button
type="button"
onClick={() => onRemoveDoc(d.ui_key)}
className="text-muted hover:text-danger font-semibold"
aria-label={`Remove ${d.file.name}`}
>
×
</button>
</li>
))}
</ul>
) : (
<p className="text-xs text-muted italic">No file uploaded yet.</p>
)}
</div>
);
}
function ShareholdersStep({
shareholders,
errorsByKey,
totalShares,
onUpdate,
onAdd,
onRemove
}: {
shareholders: OnboardingShareholder[];
errorsByKey: Record<string, string[]>;
totalShares: number;
onUpdate: (key: string, patch: Partial<OnboardingShareholder>) => void;
onAdd: () => void;
onRemove: (key: string) => void;
}) {
return (
<section className="space-y-6" data-od-id="step-shareholders">
<header>
<h2 className="text-xl font-bold text-fg">Shareholders</h2>
<p className="text-sm text-muted mt-1">
Add up to five shareholders. Total share allocation must equal
exactly 100%.
</p>
</header>
<div className="space-y-4">
{shareholders.map((s, idx) => {
const errs = errorsByKey[s.ui_key] ?? [];
return (
<div key={s.ui_key} className="bg-surface border border-border rounded-lg p-5 space-y-4">
<div className="flex items-center justify-between gap-3">
<h3 className="text-base font-bold text-fg">Shareholder {idx + 1}</h3>
{shareholders.length > 1 ? (
<button
type="button"
onClick={() => onRemove(s.ui_key)}
className="text-xs font-semibold text-muted hover:text-danger px-2 py-1 rounded transition-colors"
>
Remove
</button>
) : null}
</div>
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4">
<Field label="Type" required>
<select
className={INPUT_CLS}
value={s.kind}
onChange={(e) => onUpdate(s.ui_key, { kind: e.target.value as "person" | "entity" })}
>
<option value="person">Person</option>
<option value="entity">Entity / company</option>
</select>
</Field>
<Field label={s.kind === "person" ? "Full names" : "Entity name"} required>
<input
type="text"
className={INPUT_CLS}
value={s.name}
onChange={(e) => onUpdate(s.ui_key, { name: e.target.value })}
placeholder={s.kind === "person" ? "Jane Citizen" : "Acme Holdings (Pty) Ltd"}
required
/>
</Field>
<Field
label={s.kind === "person" ? "ID number" : "Registration number"}
required
>
<input
type="text"
className={INPUT_CLS}
value={s.id_or_company_no}
onChange={(e) => onUpdate(s.ui_key, { id_or_company_no: e.target.value })}
placeholder={s.kind === "person" ? "8001015009087" : "K2020123456"}
required
/>
</Field>
<Field label="Share %" required>
<input
type="number"
min={0}
max={100}
step={0.01}
className={INPUT_CLS}
value={Number.isFinite(s.share_percent) ? s.share_percent : 0}
onChange={(e) => onUpdate(s.ui_key, { share_percent: Number(e.target.value) })}
required
/>
</Field>
</div>
{errs.length > 0 ? (
<ul className="text-xs text-danger space-y-1 list-disc pl-5">
{errs.map((e) => <li key={e}>{e}</li>)}
</ul>
) : null}
</div>
);
})}
{shareholders.length < 5 ? (
<button
type="button"
onClick={onAdd}
className="w-full sm:w-auto px-4 py-2.5 rounded-md border border-border text-sm font-semibold text-fg hover:bg-surface-warm transition-colors"
>
+ Add shareholder
</button>
) : (
<p className="text-xs text-muted italic">Maximum of 5 shareholders reached.</p>
)}
</div>
<div
className={
"flex items-center justify-between gap-3 p-3 rounded-md border " +
(Math.abs(totalShares - 100) < 0.01
? "bg-[color-mix(in_oklch,var(--success)_8%,white)] border-[color:var(--success)]/30"
: "bg-[color-mix(in_oklch,var(--warn)_8%,white)] border-[color:var(--warn)]/30")
}
>
<p className="text-sm font-semibold text-fg">Total share allocation</p>
<p
className={
"text-base font-bold " +
(Math.abs(totalShares - 100) < 0.01 ? "text-success" : "text-warn")
}
>
{totalShares.toFixed(2)}%
</p>
</div>
</section>
);
}
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 (
<section className="space-y-6" data-od-id="step-review">
<header>
<h2 className="text-xl font-bold text-fg">Review &amp; submit</h2>
<p className="text-sm text-muted mt-1">
Once submitted we&apos;ll create the records and email a
confirmation. A unique reference number will be assigned at
submission time.
</p>
</header>
<SummaryCard title="Primary contact">
<SummaryRow label="Name" value={clientName || "—"} />
<SummaryRow label="Email" value={clientEmail || "—"} />
<SummaryRow label="Telephone" value={clientTelephone || "—"} />
</SummaryCard>
<SummaryCard title="Company">
<SummaryRow label="Proposed name (primary)" value={company.proposed_names[0] || "—"} />
{company.proposed_names[1] ? <SummaryRow label="Proposed name 2" value={company.proposed_names[1]} /> : null}
{company.proposed_names[2] ? <SummaryRow label="Proposed name 3" value={company.proposed_names[2]} /> : null}
<SummaryRow label="Financial year end" value={MONTH_NAMES[company.financial_year_end_month - 1]} />
<SummaryRow label="Authorised shares" value={company.authorised_shares} />
<SummaryRow label="Physical address" value={[
company.physical_address_line_1,
company.physical_address_line_2,
company.physical_town_city,
company.physical_province,
company.physical_postal_code,
].filter(Boolean).join(", ") || "—"} />
<SummaryRow label="Postal address" value={[
company.postal_address_line_1,
company.postal_address_line_2,
company.postal_town_city,
company.postal_province,
company.postal_postal_code,
].filter(Boolean).join(", ") || "—"} />
<SummaryRow label="Cell phone" value={company.company_cell_phone || "—"} />
<SummaryRow label="Email" value={company.company_email || "—"} />
<SummaryRow label="Public officer" value={company.public_officer_name || "—"} />
<SummaryRow label="Company secretary" value={company.company_secretary_name || "—"} />
</SummaryCard>
<SummaryCard title={`Directors (${directors.length})`}>
{directors.map((d, idx) => (
<div key={d.ui_key} className="pt-3 first:pt-0 border-t border-border-soft first:border-t-0">
<p className="text-sm font-semibold text-fg mb-2">Director {idx + 1}</p>
<SummaryRow label="Name" value={`${d.first_names} ${d.surname}`.trim() || "—"} />
<SummaryRow label="ID number" value={d.id_no || "—"} />
<SummaryRow label="Date of birth" value={d.date_of_birth || "—"} />
<SummaryRow label="Cell phone" value={d.cell_phone || "—"} />
<SummaryRow label="Email" value={d.email || "—"} />
<SummaryRow label="Residential address" value={[
d.residential_address_line_1,
d.residential_address_line_2,
d.residential_town_city,
d.residential_province,
d.residential_postal_code,
].filter(Boolean).join(", ") || "—"} />
<SummaryRow label="Postal address" value={[
d.postal_address_line_1,
d.postal_address_line_2,
d.postal_town_city,
d.postal_province,
d.postal_postal_code,
].filter(Boolean).join(", ") || "—"} />
<SummaryRow
label="ID document"
value={docs.some((doc) => doc.directorKey === d.ui_key && doc.kind === "id") ? "Uploaded" : "Missing"}
/>
<SummaryRow
label="Proof of residence"
value={docs.some((doc) => doc.directorKey === d.ui_key && doc.kind === "proof_of_residence") ? "Uploaded" : "Missing"}
/>
</div>
))}
</SummaryCard>
<SummaryCard title={`Shareholders (${shareholders.length})`}>
{shareholders.map((s, idx) => (
<div key={s.ui_key} className="pt-3 first:pt-0 border-t border-border-soft first:border-t-0">
<p className="text-sm font-semibold text-fg mb-2">Shareholder {idx + 1}</p>
<SummaryRow label="Type" value={s.kind === "person" ? "Person" : "Entity"} />
<SummaryRow label="Name" value={s.name || "—"} />
<SummaryRow label="ID / Reg no." value={s.id_or_company_no || "—"} />
<SummaryRow label="Share" value={`${s.share_percent.toFixed(2)}%`} />
</div>
))}
<div className="pt-3 border-t border-border-soft">
<SummaryRow label="Total allocation" value={`${totalShares.toFixed(2)}%`} />
</div>
</SummaryCard>
</section>
);
}
function SummaryCard({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="bg-surface border border-border rounded-lg p-5 space-y-2">
<h3 className="text-sm font-semibold text-fg uppercase tracking-wider mb-3">{title}</h3>
<div className="space-y-2">{children}</div>
</div>
);
}
function SummaryRow({ label, value }: { label: string; value: string }) {
return (
<div className="grid grid-cols-3 gap-3 text-sm">
<span className="text-muted font-medium">{label}</span>
<span className="col-span-2 text-fg break-words">{value}</span>
</div>
);
}
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 (
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 pt-4 border-t border-border">
<button
type="button"
onClick={onPrev}
disabled={step === "company" || submitting}
className="px-4 py-2.5 rounded-md border border-border text-sm font-semibold text-fg hover:bg-surface-warm disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
← Back
</button>
<div className="flex flex-col items-end gap-2">
{submitError ? (
<p className="text-sm text-danger font-medium">{submitError}</p>
) : null}
{isReview ? (
<button
type="button"
onClick={onSubmit}
disabled={submitting}
className="px-6 py-3 rounded-md bg-accent text-accent-on text-base font-bold hover:bg-accent-hover active:bg-accent-active transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{submitting ? "Submitting…" : "Submit onboarding"}
</button>
) : (
<button
type="button"
onClick={onNext}
disabled={!canProceed}
className="px-5 py-2.5 rounded-md bg-fg text-surface text-sm font-semibold hover:bg-fg-2 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Continue →
</button>
)}
</div>
</div>
);
}
function SubmittedSuccess({ referenceNo }: { referenceNo: string }) {
return (
<div className="text-center py-12 space-y-4 max-w-xl mx-auto" data-od-id="onboarding-success">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-pill bg-[color-mix(in_oklch,var(--success)_15%,white)] text-success">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
</div>
<h2 className="text-2xl font-bold text-fg">Submission received</h2>
<p className="text-base text-muted">
Thanks — we&apos;ll review and process your submission. You&apos;ll
receive an email confirmation shortly.
</p>
<div className="bg-surface-warm border border-border rounded-lg p-5 inline-block">
<p className="text-xs uppercase tracking-wider text-muted font-semibold mb-1">Reference number</p>
<p className="text-lg font-bold text-fg font-mono">{referenceNo}</p>
</div>
<p className="text-sm text-muted">
Please keep this reference for your records.
</p>
</div>
);
}