1508 lines
54 KiB
TypeScript
1508 lines
54 KiB
TypeScript
|
|
// 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'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 & submit</h2>
|
|||
|
|
<p className="text-sm text-muted mt-1">
|
|||
|
|
Once submitted we'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'll review and process your submission. You'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>
|
|||
|
|
);
|
|||
|
|
}
|