// ─── Discovery wizard (4a–c) ──────────────────────────────────────────────────
// The first page after Clerk login. Walks the YAML-driven question sections, then
// shows the fork screen (Email vs Manual). Pure presentational component: App
// passes the questionnaire + saved answers in, and the submit/navigation handlers.
//
// Props:
// data { sections, profile, contacts, completed } (from GET /wizard)
// onSubmit async (answers) => void (POST /wizard/answers)
// onPickPath (path: "email" | "manual") => void (first-run only)
// editMode bool — Wizard tab: pre-filled, ends on Save (no fork)
function DiscoveryWizard({ data, onSubmit, onPickPath, editMode = false }) {
const { useState } = React;
// Personal details (name/DOB/address) are edited only on the "Edit personal
// details" card, never in this linear flow — drop that section from the steps.
const sections = (data.sections || []).filter(s => s.id !== "personal_details");
// Repeatable people: children, parents and siblings are fixed slots in the YAML
// (child_1.., parent_1.., sibling_1..) but we reveal them one at a time via an
// "Add a X / Add another" button instead of showing every empty slot at once.
// Defined before `answers` because the seed below needs it to know which
// synthesized slot ids (executor_2, executor_3, ...) to look for.
const REPEAT_GROUPS = [
{ prefix: "child_", addFirst: "+ Add a child" },
{ prefix: "parent_", addFirst: "+ Add a parent" },
{ prefix: "sibling_", addFirst: "+ Add a sibling" },
...sections
.flatMap(s => s.questions)
.filter(q => q.target.repeatable)
.map(q => ({
prefix: q.target.role.replace(/_1$/, "_"),
max: q.target.max_repeat,
addFirst: `+ Add ${q.prompt.toLowerCase()}`,
base: q,
})),
];
// Seed answers: scalar profile facts + any saved contact matched to its question
// by (contact_type, role) so the Wizard tab pre-fills people too.
const [answers, setAnswers] = useState(() => {
const seed = { ...(data.profile || {}) };
const contacts = data.contacts || [];
for (const s of sections) {
for (const q of s.questions) {
// bool answers come back from the DB as strings/numbers — coerce to a real
// boolean so the Yes/No buttons (compared with ===) preselect correctly.
if (q.type === "bool" && q.id in seed) {
const v = seed[q.id];
seed[q.id] = v === true || v === "true" || v === "True" || v === 1 || v === "1";
continue;
}
if (q.target.sink !== "contact") continue;
const match = contacts.find(c => c.contact_type === q.target.contact_type && c.role === q.target.role);
if (match) seed[q.id] = { name: match.name, email: match.email || "" };
}
}
// The loop above only matches literal YAML questions (e.g. executor_1). Slots
// beyond n=1 (executor_2..8) are synthesized client-side and never appear in
// s.questions, so they'd otherwise be left out of the seed even when a saved
// contact exists for that role — pre-fill those here from REPEAT_GROUPS.
for (const g of REPEAT_GROUPS) {
if (!g.max) continue; // legacy fixed-slot groups (child_/parent_/sibling_) are all literal YAML questions, already seeded above
for (let n = 2; n <= g.max; n++) {
const role = `${g.prefix}${n}`;
const match = contacts.find(c => c.contact_type === g.base.target.contact_type && c.role === role);
if (match) seed[role] = { name: match.name, email: match.email || "" };
}
}
return seed;
});
const [sectionIdx, setSectionIdx] = useState(0);
const [phase, setPhase] = useState("questions"); // "questions" | "fork"
const [busy, setBusy] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState(null);
const [repeatCounts, setRepeatCounts] = useState(() => {
const contacts = data.contacts || [];
const counts = {};
for (const g of REPEAT_GROUPS) {
counts[g.prefix] = contacts.filter(c => c.role && c.role.startsWith(g.prefix) && c.name).length;
}
return counts;
});
const setAnswer = (qid, value) => { setSaved(false); setAnswers(a => ({ ...a, [qid]: value })); };
// A question is shown only if every show_if entry matches the answers so far.
const isVisible = (q) =>
Object.entries(q.show_if || {}).every(([k, v]) => String(answers[k]) === String(v));
const section = sections[sectionIdx];
const visible = section ? section.questions.filter(isVisible) : [];
const isLast = sectionIdx >= sections.length - 1;
// Split the repeatable people (child/parent/sibling) out of the visible list so
// we can reveal each group progressively; everything else renders normally.
const groupOf = (q) => REPEAT_GROUPS.find(g => q.target.role && q.target.role.startsWith(g.prefix));
const otherQuestions = visible.filter(q => !groupOf(q));
// First-run: every visible question must be answered before advancing. People
// (contact questions) stay optional — you may genuinely have no spouse/solicitor.
const isAnswered = (q) => {
const v = answers[q.id];
if (q.type === "contact") {
// Next of kin is mandatory; other contacts (spouse, advisors) stay optional.
if (q.target.role === "next_of_kin") return !!(v && v.name);
return true;
}
return v !== undefined && v !== null && v !== "";
};
const unanswered = visible.filter(q => !isAnswered(q));
const canAdvance = editMode || unanswered.length === 0;
async function save() {
setBusy(true);
setError(null);
try {
await onSubmit(answers);
if (editMode) setSaved(true);
else setPhase("fork");
} catch (e) {
setError(e.message || "Could not save your answers");
} finally {
setBusy(false);
}
}
function primaryAction() {
if (editMode || isLast) return save();
setSectionIdx(i => i + 1);
}
if (phase === "fork") {
return (
How do you want to add your accounts?
Pick one — you can do the other later.
);
}
return (
Step {sectionIdx + 1} of {sections.length}
{section ? section.title : "About you"}
{otherQuestions.map(q => (
setAnswer(q.id, v)}
onRemove={q.type === "contact" ? () => setAnswer(q.id, { name: "", email: "" }) : null} />
))}
{REPEAT_GROUPS.map(g => {
const groupQuestions = visible.filter(q => groupOf(q) === g);
if (groupQuestions.length === 0) return null;
const count = repeatCounts[g.prefix];
// Legacy groups (child_/parent_/sibling_) cap at however many literal
// YAML slots exist. New repeatable groups (executor_ etc.) cap at
// target.max_repeat and synthesize slots beyond the single declared
// YAML question — same id/role naming convention (`${prefix}${n}`).
const max = g.max || groupQuestions.length;
const slots = [];
for (let n = 1; n <= Math.min(count, max); n++) {
const slotId = `${g.prefix}${n}`;
const literal = groupQuestions.find(q => q.target.role === slotId);
slots.push(literal || { ...g.base, id: slotId, target: { ...g.base.target, role: slotId } });
}
return (
{slots.map(q => (
setAnswer(q.id, v)}
onRemove={() => setAnswer(q.id, { name: "", email: "" })} />
))}
{count < max && (
)}
);
})}
);
}
if (typeof window !== "undefined") window.DiscoveryWizard = DiscoveryWizard;
// ─── Wizard tab hub ───────────────────────────────────────────────────────────
// The Wizard tab landing: section cards. Click one to open its form.
// About you → the profile/people wizard (edit mode, pre-filled)
// Your estate → manual preset-category builder (resource sink) [next build]
// Fill AI gaps → complete what email ingestion missed [next build]
// ─── Life graph ───────────────────────────────────────────────────────────────
// A radial relationship map: "You" in the centre, spokes out to the family and
// advisers the user added in "About you". Pure SVG (no chart lib — the frontend
// has no build step). Family vs adviser are coloured differently.
const _ROLE_LABEL = {
spouse: "Spouse", next_of_kin: "Next of kin",
child_1: "Child", child_2: "Child", child_3: "Child", child_4: "Child",
parent_1: "Parent", parent_2: "Parent",
sibling_1: "Sibling", sibling_2: "Sibling", sibling_3: "Sibling", sibling_4: "Sibling",
accountant: "Accountant", solicitor: "Solicitor", ifa: "Financial adviser",
trustee: "Trustee", guardian: "Guardian",
};
// Repeatable estate roles (executor_2, poa_holder_3, ...) aren't individually
// listed above — strip the trailing "_N" and label by the base role instead.
const _REPEATABLE_ROLE_LABEL = {
executor: "Executor", beneficiary: "Beneficiary",
poa_holder: "POA holder", business_partner: "Business partner",
};
function _roleLabel(role) {
if (_ROLE_LABEL[role]) return _ROLE_LABEL[role];
const base = String(role || "").replace(/_\d+$/, "");
return _REPEATABLE_ROLE_LABEL[base] || role;
}
// Estimate the half-width (px) a single text line needs, so a circle can grow to
// fit its label instead of clipping it. ~0.6 of font size per glyph is a safe avg.
const _halfTextW = (s, fontPx) => (String(s || "").length * fontPx * 0.6) / 2;
// Three relationship arcs, each a clustered third of the circle instead of one
// flat ring — family, estate roles (new), and professional advisors read as
// visually distinct groups.
function _kindOf(c) {
if (c.contact_type === "legal_role") return "legal";
if (c.contact_type === "advisor") return "advisor";
return c.role === "next_of_kin" ? "nok" : "family";
}
function _arcOf(kind) { return kind === "legal" ? "legal" : kind === "advisor" ? "advisor" : "family"; }
const _ARC_ORDER = ["family", "legal", "advisor"];
function LifeGraph({ contacts, profileName }) {
const people = (contacts || []).filter(c => c && c.name);
const W = 620, H = 480, cx = W / 2, cy = H / 2, R = 150;
const groups = _ARC_ORDER.map(arc => people.filter(c => _arcOf(_kindOf(c)) === arc));
const arcSpan = (2 * Math.PI) / 3;
return (
Your relationships
{people.length === 0 ? (
Add your family, estate roles and advisers in the People tab to build your relationship map.
) : (
)}
);
}
// ─── People tab ────────────────────────────────────────────────────────────
// Top-level "People" nav tab: the relationship graph plus the same
// people-editing sections DiscoveryWizard renders in edit mode (about_you,
// connected_people, estate_roles, advisors). Replaces the old "People &
// relationships" card under Manual Entry (see WizardHub, Task 8).
function PeopleTab({ data, onSubmit }) {
return (
People
Your people
Family, estate roles and professional advisers.
);
}
if (typeof window !== "undefined") window.PeopleTab = PeopleTab;
// ─── Personal details (edit) ──────────────────────────────────────────────────
// Focused editor for name / date of birth / address. Lives ONLY here — these fields
// are not asked in the linear discovery wizard. Saves via the same POST /wizard/answers
// as the wizard (a partial answers object is fine; keys match the hidden
// personal_details YAML section). Validation is client-side:
// - first name + surname required (middle name optional)
// - date of birth must give an age between 18 and 120
// - postcode validated via postcodes.io ("Look up" button), proxied through
// GET /wizard/address-lookup; a valid postcode auto-fills town/county, the user
// types their own street line. Lookup failure falls back to manual entry.
function _ageFromDob(dob) {
if (!dob) return null;
const d = new Date(dob);
if (isNaN(d.getTime())) return null;
const now = new Date();
let a = now.getFullYear() - d.getFullYear();
const m = now.getMonth() - d.getMonth();
if (m < 0 || (m === 0 && now.getDate() < d.getDate())) a -= 1;
return a;
}
// onboarding=true is the first-run capture step shown before the discovery wizard
// (blocking, "Continue"); false is the editable card in the wizard hub ("Save").
function PersonalDetailsSection({ data, onSubmit, addressApi, onboarding = false }) {
const { useState } = React;
const profile = data.profile || {};
const [f, setF] = useState({
first_name: profile.first_name || "",
middle_name: profile.middle_name || "",
surname: profile.surname || "",
dob: profile.dob || "",
address_line1: profile.address_line1 || "",
town: profile.town || "",
county: profile.county || "",
postcode: profile.postcode || "",
});
const [looking, setLooking] = useState(false);
const [lookupMsg, setLookupMsg] = useState(null);
const [busy, setBusy] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState(null);
const set = (k, v) => { setSaved(false); setF(s => ({ ...s, [k]: v })); };
const age = _ageFromDob(f.dob);
const dobOk = age !== null && age >= 18 && age <= 120;
const today = new Date().toISOString().slice(0, 10);
async function lookup() {
const pc = f.postcode.trim();
if (!pc) return;
setLooking(true); setLookupMsg(null);
try {
const res = await addressApi.lookup(pc);
if (!res.valid) {
setLookupMsg("That postcode wasn't recognised — please check it or type your address below.");
} else {
// Auto-fill town/county; the user types their own street line.
setSaved(false);
setF(s => ({
...s,
town: res.town || s.town,
county: res.county || s.county,
postcode: res.postcode || s.postcode,
}));
setLookupMsg("Postcode found — town and county filled in below.");
}
} catch (e) {
// Service down. Let the user type the address manually rather than block them.
setLookupMsg("Postcode lookup is unavailable — please type your address below.");
} finally { setLooking(false); }
}
const canSave = !!f.first_name.trim() && !!f.surname.trim() && dobOk
&& !!f.address_line1.trim() && !!f.postcode.trim() && !busy;
async function save() {
setBusy(true); setError(null);
try { await onSubmit(f); setSaved(true); }
catch (e) { setError(e.message || "Could not save"); }
finally { setBusy(false); }
}
const invalidStyle = { borderColor: "#c0532f" };
return (
{onboarding ? "Tell us about you" : "Edit personal details"}
{onboarding
? "First, a few details about you — your name, date of birth and address."
: "Your name, date of birth and address."}
)}
{/* Edit view: grouped cards. Hidden while the edit form is open (category set). */}
{isEdit && !category && state.items.length === 0 &&
No resources yet — add some in “Add your resources”.
}
{isEdit && !category && state.items.length > 0 && (() => {
// Bucket items by family using familyOf(), then order the groups by the
// categories list so the layout is stable. Unknown families fall to the end.
const byFamily = {};
for (const it of state.items) {
const fam = familyOf(it.resource_type_id) || "unknown";
(byFamily[fam] ||= []).push(it);
}
const groups = state.categories
.filter(c => byFamily[c.family])
.map(c => ({ family: c.family, title: c.title, items: byFamily[c.family] }));
const known = new Set(groups.map(g => g.family));
for (const fam of Object.keys(byFamily)) {
if (!known.has(fam)) groups.push({ family: fam, title: fam, items: byFamily[fam] });
}
const total = state.items.length;
const shown = groups.filter(g => filter === "all" || g.family === filter);
return (
);
}
if (typeof window !== "undefined") window.ResourcesSection = ResourcesSection;
// ─── Fill AI gaps section ─────────────────────────────────────────────────────
// Three kinds of gap, all surfaced in the post-ingest popup (app.jsx):
// 1. Missing details — complete fields AI left blank (writes user_resources).
// 2. "You might also have" — inferred likely resources (YAML rules engine).
// 3. Open-ended — a free-text "anything else we missed?" box (saved to profile).
// Props: api = { load, fill, saveOpen }; resourcesApi = manual-add API (load/add/
// remove/edit); onChanged = () => reload the dashboard/report after a change.
function GapsSection({ api, resourcesApi, onChanged }) {
const { useState, useEffect } = React;
const [gaps, setGaps] = useState(null);
const [adding, setAdding] = useState(false); // true = show the manual add form in place
const [active, setActive] = useState(null); // resource_id whose form is open
const [values, setValues] = useState({});
const [openNotes, setOpenNotes] = useState("");
const [openSaved, setOpenSaved] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
let on = true;
api.load().then(d => { if (on) { setGaps(d); setOpenNotes(d.open_notes || ""); } })
.catch(e => on && setError(e.message));
return () => { on = false; };
}, []);
if (error && !gaps) return
{error}
;
if (!gaps) return
Loading…
;
// "Add" on a gap opens the manual add form IN PLACE — the popup stays open, and
// "← Back to gaps" returns to the list. onChanged keeps the dashboard live.
if (adding) {
return (