Workflow-App/Workflow/components/OnboardingWizard.tsx
sjcoetzeesj-coder 11d0178ab1 feat: commit Workflow app source; move CIPC credentials to env
Adds the full Next.js app (app/, components/, lib/, scripts/, assets/,
docs/, Dockerfile, compose, configs) to version control.

Security:
- CIPC BizPortal customer-code logins (Login 1 + Login 3) now read
  CIPC_LOGIN1_CODE / CIPC_LOGIN1_PASSWORD / CIPC_LOGIN3_CODE /
  CIPC_LOGIN3_PASSWORD from .env.local instead of source.
- BizPortal personal login in apply-bizportal route reads
  BIZPORTAL_ID_NO / BIZPORTAL_PASSWORD.
- Added .env.example as the committed template.

.gitignore excludes .env*, node_modules, .next, logs, sqlite, Playwright
storage_state.json (live CIPC cookies), scratch dirs, client PDFs and
debug screenshots.
2026-10-18 15:21:45 +02:00

1507 lines
54 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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>
);
}