/* eslint-disable */ // ─── tweaks-panel.jsx (inlined) ────────────────────────────────────────────── const __TWEAKS_STYLE = ` .twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px; max-height:calc(100vh - 32px);display:flex;flex-direction:column; transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom right; background:rgba(250,249,247,.78);color:#29261b; -webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%); border:.5px solid rgba(255,255,255,.6);border-radius:14px; box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18); font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden} .twk-hd{display:flex;align-items:center;justify-content:space-between; padding:10px 8px 10px 14px;cursor:move;user-select:none} .twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em} .twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55); width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1} .twk-x:hover{background:rgba(0,0,0,.06);color:#29261b} .twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px; overflow-y:auto;overflow-x:hidden;min-height:0; scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent} .twk-body::-webkit-scrollbar{width:8px} .twk-body::-webkit-scrollbar-track{background:transparent;margin:2px} .twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px; border:2px solid transparent;background-clip:content-box} .twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25); border:2px solid transparent;background-clip:content-box} .twk-row{display:flex;flex-direction:column;gap:5px} .twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px} .twk-lbl{display:flex;justify-content:space-between;align-items:baseline; color:rgba(41,38,27,.72)} .twk-lbl>span:first-child{font-weight:500} .twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums} .twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase; color:rgba(41,38,27,.45);padding:10px 0 0} .twk-sect:first-child{padding-top:0} .twk-field{appearance:none;box-sizing:border-box;width:100%;min-width:0;height:26px;padding:0 8px; border:.5px solid rgba(0,0,0,.1);border-radius:7px; background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none} .twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)} select.twk-field{padding-right:22px; background-image:url("data:image/svg+xml;utf8,"); background-repeat:no-repeat;background-position:right 8px center} .twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0; border-radius:999px;background:rgba(0,0,0,.12);outline:none} .twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none; width:14px;height:14px;border-radius:50%;background:#fff; border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default} .twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%; background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default} .twk-seg{position:relative;display:flex;padding:2px;border-radius:8px; background:rgba(0,0,0,.06);user-select:none} .twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px; background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12); transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s} .twk-seg.dragging .twk-seg-thumb{transition:none} .twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0; background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px; border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2; overflow-wrap:anywhere} .twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px; background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0} .twk-toggle[data-on="1"]{background:#34c759} .twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%; background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s} .twk-toggle[data-on="1"] i{transform:translateX(14px)} .twk-num{display:flex;align-items:center;box-sizing:border-box;min-width:0;height:26px;padding:0 0 0 8px; border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)} .twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize; user-select:none;padding-right:8px} .twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent; font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0; outline:none;color:inherit;-moz-appearance:textfield} .twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{ -webkit-appearance:none;margin:0} .twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)} .twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px; background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default} .twk-btn:hover{background:rgba(0,0,0,.88)} .twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit} .twk-btn.secondary:hover{background:rgba(0,0,0,.1)} .twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px; border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default; background:transparent;flex-shrink:0} .twk-swatch::-webkit-color-swatch-wrapper{padding:0} .twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px} .twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px} .twk-chips{display:flex;gap:6px} .twk-chip{position:relative;appearance:none;flex:1;min-width:0;height:46px; padding:0;border:0;border-radius:6px;overflow:hidden;cursor:default; box-shadow:0 0 0 .5px rgba(0,0,0,.12),0 1px 2px rgba(0,0,0,.06); transition:transform .12s cubic-bezier(.3,.7,.4,1),box-shadow .12s} .twk-chip:hover{transform:translateY(-1px); box-shadow:0 0 0 .5px rgba(0,0,0,.18),0 4px 10px rgba(0,0,0,.12)} .twk-chip[data-on="1"]{box-shadow:0 0 0 1.5px rgba(0,0,0,.85), 0 2px 6px rgba(0,0,0,.15)} .twk-chip>span{position:absolute;top:0;bottom:0;right:0;width:34%; display:flex;flex-direction:column;box-shadow:-1px 0 0 rgba(0,0,0,.1)} .twk-chip>span>i{flex:1;box-shadow:0 -1px 0 rgba(0,0,0,.1)} .twk-chip>span>i:first-child{box-shadow:none} .twk-chip svg{position:absolute;top:6px;left:6px;width:13px;height:13px; filter:drop-shadow(0 1px 1px rgba(0,0,0,.3))} `; // ── useTweaks ──────────────────────────────────────────────────────────────── function useTweaks(defaults) { const [values, setValues] = React.useState(defaults); const setTweak = React.useCallback((keyOrEdits, val) => { const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null ? keyOrEdits : { [keyOrEdits]: val }; setValues((prev) => ({ ...prev, ...edits })); window.parent.postMessage({ type: '__edit_mode_set_keys', edits }, '*'); window.dispatchEvent(new CustomEvent('tweakchange', { detail: edits })); }, []); return [values, setTweak]; } // ── TweaksPanel ────────────────────────────────────────────────────────────── function TweaksPanel({ title = 'Tweaks', noDeckControls = false, children }) { const [open, setOpen] = React.useState(false); const dragRef = React.useRef(null); const hasDeckStage = React.useMemo( () => typeof document !== 'undefined' && !!document.querySelector('deck-stage'), [], ); const [railEnabled, setRailEnabled] = React.useState( () => hasDeckStage && !!document.querySelector('deck-stage')?._railEnabled, ); React.useEffect(() => { if (!hasDeckStage || railEnabled) return undefined; const onMsg = (e) => { if (e.data && e.data.type === '__omelette_rail_enabled') setRailEnabled(true); }; window.addEventListener('message', onMsg); return () => window.removeEventListener('message', onMsg); }, [hasDeckStage, railEnabled]); const [railVisible, setRailVisible] = React.useState(() => { try { return localStorage.getItem('deck-stage.railVisible') !== '0'; } catch (e) { return true; } }); const toggleRail = (on) => { setRailVisible(on); window.postMessage({ type: '__deck_rail_visible', on }, '*'); }; const offsetRef = React.useRef({ x: 16, y: 16 }); const PAD = 16; const clampToViewport = React.useCallback(() => { const panel = dragRef.current; if (!panel) return; const w = panel.offsetWidth, h = panel.offsetHeight; const maxRight = Math.max(PAD, window.innerWidth - w - PAD); const maxBottom = Math.max(PAD, window.innerHeight - h - PAD); offsetRef.current = { x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)), y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)), }; panel.style.right = offsetRef.current.x + 'px'; panel.style.bottom = offsetRef.current.y + 'px'; }, []); React.useEffect(() => { if (!open) return; clampToViewport(); if (typeof ResizeObserver === 'undefined') { window.addEventListener('resize', clampToViewport); return () => window.removeEventListener('resize', clampToViewport); } const ro = new ResizeObserver(clampToViewport); ro.observe(document.documentElement); return () => ro.disconnect(); }, [open, clampToViewport]); React.useEffect(() => { const onMsg = (e) => { const t = e?.data?.type; if (t === '__activate_edit_mode') setOpen(true); else if (t === '__deactivate_edit_mode') setOpen(false); }; window.addEventListener('message', onMsg); window.parent.postMessage({ type: '__edit_mode_available' }, '*'); return () => window.removeEventListener('message', onMsg); }, []); const dismiss = () => { setOpen(false); window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*'); }; const onDragStart = (e) => { const panel = dragRef.current; if (!panel) return; const r = panel.getBoundingClientRect(); const sx = e.clientX, sy = e.clientY; const startRight = window.innerWidth - r.right; const startBottom = window.innerHeight - r.bottom; const move = (ev) => { offsetRef.current = { x: startRight - (ev.clientX - sx), y: startBottom - (ev.clientY - sy), }; clampToViewport(); }; const up = () => { window.removeEventListener('mousemove', move); window.removeEventListener('mouseup', up); }; window.addEventListener('mousemove', move); window.addEventListener('mouseup', up); }; if (!open) return null; return ( <>
{title}
{children} {hasDeckStage && railEnabled && !noDeckControls && ( )}
); } function TweakSection({ title, label, children }) { return ( <>
{title || label}
{children} ); } function TweakRow({ label, value, children, inline = false }) { return (
{label} {value != null && {value}}
{children}
); } function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) { return ( onChange(Number(e.target.value))} /> ); } function TweakToggle({ label, value, onChange }) { return (
{label}
); } function TweakRadio({ label, value, options, onChange }) { const trackRef = React.useRef(null); const [dragging, setDragging] = React.useState(false); const valueRef = React.useRef(value); valueRef.current = value; const labelLen = (o) => String(typeof o === 'object' ? o.label : o).length; const maxLen = options.reduce((m, o) => Math.max(m, labelLen(o)), 0); const fitsAsSegments = maxLen <= ({ 2: 16, 3: 10 }[options.length] ?? 0); if (!fitsAsSegments) { const resolve = (s) => { const m = options.find((o) => String(typeof o === 'object' ? o.value : o) === s); return m === undefined ? s : typeof m === 'object' ? m.value : m; }; return onChange(resolve(s))} />; } const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o })); const idx = Math.max(0, opts.findIndex((o) => o.value === value)); const n = opts.length; const segAt = (clientX) => { const r = trackRef.current.getBoundingClientRect(); const inner = r.width - 4; const i = Math.floor(((clientX - r.left - 2) / inner) * n); return opts[Math.max(0, Math.min(n - 1, i))].value; }; const onPointerDown = (e) => { setDragging(true); const v0 = segAt(e.clientX); if (v0 !== valueRef.current) onChange(v0); const move = (ev) => { if (!trackRef.current) return; const v = segAt(ev.clientX); if (v !== valueRef.current) onChange(v); }; const up = () => { setDragging(false); window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up); }; window.addEventListener('pointermove', move); window.addEventListener('pointerup', up); }; return (
{opts.map((o) => ( ))}
); } function TweakSelect({ label, value, options, onChange }) { return ( ); } // ─── End tweaks-panel ──────────────────────────────────────────────────────── const { useState, useEffect, useRef, useMemo, useCallback } = React; // ─── Taxonomy (display) ───────────────────────────────────────────────────── // Backend returns the canonical `category` and `family` keys per org. // These constants are display-only. const CATEGORY_LABELS = { physical_assets: "Physical Asset", personal_valuables: "Personal Valuable", digital_devices: "Digital Devices", financial_accounts: "Financial Accounts", investment_business: "Investment & Business", financial_protection: "Financial Protection", employment_benefits: "Employment Benefits", digital_accounts: "Digital Accounts", government_legal: "Government & Legal", cards_memberships: "Cards & Memberships", subscriptions: "Subscriptions", unknown: "Other", }; const CATEGORY_ORDER = [ "physical_assets", "personal_valuables", "digital_devices", "financial_accounts", "investment_business", "financial_protection", "employment_benefits", "digital_accounts", "government_legal", "cards_memberships", "subscriptions", "unknown", ]; // CATEGORY_TO_FAMILY now comes from the backend (/org-summaries -> category_family), // threaded to as a prop. Kept out of the frontend to avoid a duplicate list. if (typeof window !== "undefined") { window.CATEGORY_LABELS = CATEGORY_LABELS; window.CATEGORY_ORDER = CATEGORY_ORDER; } // ─── Clerk (person identity) ───────────────────────────────────────────────── let clerk = null; function loadScript(src, attrs = {}) { return new Promise((resolve, reject) => { const s = document.createElement("script"); s.src = src; s.crossOrigin = "anonymous"; for (const [k, v] of Object.entries(attrs)) s.setAttribute(k, v); s.onload = resolve; s.onerror = () => reject(new Error(`Failed to load ${src}`)); document.head.appendChild(s); }); } // Memoised so the mount effect + checkAuth share one init (no duplicate script // tags or repeat clerk.load() calls). let __clerkInit = null; function initClerk() { if (__clerkInit) return __clerkInit; __clerkInit = _initClerk(); return __clerkInit; } async function _initClerk() { const res = await fetch("/auth/config"); const { publishable_key } = await res.json(); if (!publishable_key) return null; // Clerk not configured (dev bypass) // Frontend API domain is encoded in the publishable key (Clerk-documented derivation) const fapi = atob(publishable_key.split("_")[2]).slice(0, -1); // @clerk/ui must load first; clerk.browser.js reads the key from its data-attr // and exposes window.Clerk as a ready-made INSTANCE (not a constructor). await loadScript(`https://${fapi}/npm/@clerk/ui@1/dist/ui.browser.js`); await loadScript(`https://${fapi}/npm/@clerk/clerk-js@6/dist/clerk.browser.js`, { "data-clerk-publishable-key": publishable_key, }); if (!window.Clerk) throw new Error("ClerkJS failed to initialise"); clerk = window.Clerk; // global instance, no `new` await clerk.load({ ui: { ClerkUI: window.__internal_ClerkUICtor } }); window.__clerk = clerk; return clerk; } async function authHeaders() { if (!clerk || !clerk.session) return {}; const token = await clerk.session.getToken(); // cached by ClerkJS, ~60s TTL return token ? { Authorization: `Bearer ${token}` } : {}; } // ─── API ───────────────────────────────────────────────────────────────────── // Build a readable message from a failed response. FastAPI/pydantic 422s return // { detail: [{ loc, msg, type }] } — pull out the field name + reason so the UI // can flag exactly which value didn't match its expected type. async function errorFromRes(res, fallback) { const body = await res.json().catch(() => null); const detail = body && body.detail; if (Array.isArray(detail)) { return detail .map(e => `${(e.loc || []).slice(-1)[0] || "value"}: ${e.msg}`) .join("; "); } if (typeof detail === "string") return detail; return fallback || `Request failed: ${res.status}`; } async function apiGet(path) { const res = await fetch(path, { credentials: "include", headers: await authHeaders() }); if (res.status === 401) throw Object.assign(new Error("Not authenticated"), { status: 401 }); if (!res.ok) throw new Error(`${path} failed: ${res.status}`); return res; } async function fetchMe() { const res = await apiGet("/auth/status"); // { status, person_id } return res.json(); } async function fetchOrgSummaries() { const res = await apiGet("/org-summaries"); return res.json(); } async function fetchAccounts() { const res = await apiGet("/accounts"); return res.json(); } async function postAccount(grantId, email) { const res = await fetch("/accounts", { method: "POST", credentials: "include", headers: { "Content-Type": "application/json", ...(await authHeaders()) }, body: JSON.stringify({ grant_id: grantId, email }), }); if (!res.ok) throw new Error(await errorFromRes(res, `Couldn't connect mailbox (${res.status})`)); return res.json(); } // NOTE: backend endpoint DELETE /accounts/{email_account_id} may not exist yet. async function deleteAccount(emailAccountId) { const res = await fetch(`/accounts/${encodeURIComponent(emailAccountId)}`, { method: "DELETE", credentials: "include", headers: await authHeaders(), }); if (!res.ok && res.status !== 204) { throw new Error(`DELETE /accounts/${emailAccountId} failed: ${res.status}`); } } async function triggerIngest() { const res = await fetch("/emails/ingest", { method: "POST", credentials: "include", headers: await authHeaders() }); if (!res.ok) throw new Error(`POST /emails/ingest failed: ${res.status}`); return res.json(); } async function shareReport(to) { const res = await fetch("/report/share", { method: "POST", credentials: "include", headers: { "Content-Type": "application/json", ...(await authHeaders()) }, body: JSON.stringify({ to }), }); if (!res.ok) { const detail = await res.json().catch(() => ({})); throw new Error(detail.detail || `Could not send (${res.status})`); } } // Print a single report doc to PDF. `cls` ("print-report" | "print-org") scopes // the print CSS so only that doc is shown. Cleaned up after the dialog closes. function printDoc(cls) { document.body.classList.add(cls); const cleanup = () => { document.body.classList.remove(cls); window.removeEventListener("afterprint", cleanup); }; window.addEventListener("afterprint", cleanup); window.print(); } async function fetchIngestStatus() { const res = await apiGet("/emails/ingest/status"); return res.json(); // { status: queued|active|complete|failed|idle|no_user } } async function fetchWizard() { const res = await apiGet("/wizard"); // { sections, profile, contacts, completed } return res.json(); } async function postWizardAnswers(answers) { const res = await fetch("/wizard/answers", { method: "POST", credentials: "include", headers: { "Content-Type": "application/json", ...(await authHeaders()) }, body: JSON.stringify({ answers }), }); if (!res.ok) throw new Error(`/wizard/answers failed: ${res.status}`); return res.json(); } async function fetchResources() { const res = await apiGet("/wizard/resources"); // { categories, items } return res.json(); } async function fetchUserResources() { const res = await apiGet("/user-resources"); // { total, user_resources } return res.json(); } async function postResourceItem(body) { const res = await fetch("/wizard/resources", { method: "POST", credentials: "include", headers: { "Content-Type": "application/json", ...(await authHeaders()) }, body: JSON.stringify(body), }); if (!res.ok) throw new Error(await errorFromRes(res, `/wizard/resources failed: ${res.status}`)); return res.json(); } async function deleteResourceItem(id) { const res = await fetch(`/wizard/resources/${encodeURIComponent(id)}`, { method: "DELETE", credentials: "include", headers: await authHeaders(), }); if (!res.ok) throw new Error(`DELETE /wizard/resources failed: ${res.status}`); return res.json(); } async function patchResourceItem(id, body) { const res = await fetch(`/wizard/resources/${encodeURIComponent(id)}`, { method: "PATCH", credentials: "include", headers: { "Content-Type": "application/json", ...(await authHeaders()) }, body: JSON.stringify(body), }); if (!res.ok) throw new Error(await errorFromRes(res, `PATCH /wizard/resources failed: ${res.status}`)); return res.json(); } async function confirmResourceItem(id) { const res = await fetch(`/wizard/resources/${encodeURIComponent(id)}/confirm`, { method: "POST", credentials: "include", headers: await authHeaders(), }); if (!res.ok) throw new Error(`POST /wizard/resources/confirm failed: ${res.status}`); return res.json(); } const RESOURCES_API = { load: fetchResources, add: postResourceItem, remove: deleteResourceItem, edit: patchResourceItem, confirm: confirmResourceItem }; async function fetchGaps() { const res = await apiGet("/wizard/gaps"); // { missing_details, coverage } return res.json(); } async function fillGapDetails(body) { const res = await fetch("/wizard/gaps/details", { method: "POST", credentials: "include", headers: { "Content-Type": "application/json", ...(await authHeaders()) }, body: JSON.stringify(body), }); if (!res.ok) throw new Error(`/wizard/gaps/details failed: ${res.status}`); return res.json(); } async function saveGapOpenNotes(body) { const res = await fetch("/wizard/gaps/open", { method: "POST", credentials: "include", headers: { "Content-Type": "application/json", ...(await authHeaders()) }, body: JSON.stringify(body), }); if (!res.ok) throw new Error(`/wizard/gaps/open failed: ${res.status}`); return res.json(); } const GAPS_API = { load: fetchGaps, fill: fillGapDetails, saveOpen: saveGapOpenNotes }; async function lookupAddress(postcode) { const res = await apiGet(`/wizard/address-lookup?postcode=${encodeURIComponent(postcode)}`); if (!res.ok) throw new Error(`address lookup failed: ${res.status}`); return res.json(); // { valid, town, county, postcode } } const ADDRESS_API = { lookup: lookupAddress }; // First-run gate: the personal-details step shows until these core profile fields // exist. County/town are optional, so they're not required here. const REQUIRED_PROFILE_KEYS = ["first_name", "surname", "dob", "address_line1", "postcode"]; function needsPersonalDetails(profile) { return REQUIRED_PROFILE_KEYS.some(k => !(profile?.[k] || "").trim()); } async function fetchFilters() { const res = await apiGet("/filters"); return res.json(); } async function postSender(email) { const res = await fetch("/filters/senders", { method: "POST", credentials: "include", headers: { "Content-Type": "application/json", ...(await authHeaders()) }, body: JSON.stringify({ email }), }); if (!res.ok) { const text = await res.text(); throw new Error(`POST /filters/senders failed: ${res.status} ${text}`); } return res.json(); } async function deleteSender(email) { const res = await fetch(`/filters/senders/${encodeURIComponent(email)}`, { method: "DELETE", credentials: "include", headers: await authHeaders(), }); if (!res.ok && res.status !== 204) { throw new Error(`DELETE /filters/senders/${email} failed: ${res.status}`); } } async function putSelectedSender(email) { const res = await fetch("/filters/selected", { method: "PUT", credentials: "include", headers: { "Content-Type": "application/json", ...(await authHeaders()) }, body: JSON.stringify({ email }), }); if (!res.ok) { throw new Error(`PUT /filters/selected failed: ${res.status}`); } return res.json(); } // ─── Transform — backend already returns the canonical shape, we just group ── function transformApiData(apiResponse) { const orgs = (apiResponse.summaries || []).map(s => ({ ...s, category: s.category || "unknown", family: s.family || "unknown", accounts: s.accounts || [], })); const orgs_by_category = {}; for (const c of CATEGORY_ORDER) orgs_by_category[c] = []; for (const o of orgs) (orgs_by_category[o.category] ||= []).push(o); const categoryCount = Object.keys(orgs_by_category).filter(k => orgs_by_category[k].length > 0).length; return { org_count: orgs.length, category_count: categoryCount, orgs, orgs_by_category, category_types: apiResponse.category_types || {}, category_family: apiResponse.category_family || {}, }; } // ─── Heuristic — only used in the per-org report modal to colour the // priority pills when the backend doesn't supply an explicit priority. const HIGH_KW = /(overdue|expir|urgent|verify|suspend|locked|fraud|breach|cancel|close|deadline|outstanding)/i; const MEDIUM_KW = /(renew|update|change|confirm|review|upcoming|file|rebalance|reconcile|transfer|add )/i; function actionPriority(text) { const s = String(text || ""); if (HIGH_KW.test(s)) return "HIGH"; if (MEDIUM_KW.test(s)) return "MEDIUM"; return "LOW"; } if (typeof window !== "undefined") window.actionPriority = actionPriority; // ─── Org-level helpers (over accounts) ─────────────────────────────────────── function orgHasAttention(org) { return (org.accounts || []).some(a => (a.actions && a.actions.length > 0) || a.status === "lapsing"); } function orgPaymentLine(org) { for (const a of (org.accounts || [])) { const p = a.payment; if (!p || !p.amount) continue; if (p.cadence === "month") return `${p.amount}/month`; if (p.cadence === "annual") return `${p.amount}/year`; if (p.cadence && p.cadence !== "n/a" && p.cadence !== "—") return `${p.amount} (${p.cadence})`; return p.amount; } return null; } function orgNextCharge(org) { for (const a of (org.accounts || [])) { const n = a.payment?.next; if (n) return n; } return null; } function orgAllActions(org) { const out = []; for (const a of (org.accounts || [])) for (const x of (a.actions || [])) out.push({ acct: a, text: x }); return out; } function orgAllDocs(org) { const out = []; for (const a of (org.accounts || [])) for (const d of (a.docs || [])) out.push({ acct: a, ...d }); return out; } function orgAllEvents(org) { const out = []; for (const a of (org.accounts || [])) for (const e of (a.events || [])) out.push({ acct: a, ...e }); out.sort((x, y) => (x.date < y.date ? 1 : -1)); return out; } // ─── Brand ─────────────────────────────────────────────────────────────────── function Wordmark({ size = 28, tone = "ink" }) { return (
myhandover
); } // ─── Ambient gradient backdrop (login) ─────────────────────────────────────── function AmbientCanvas({ enabled }) { const ref = useRef(null); useEffect(() => { if (!enabled || !ref.current) return; const canvas = ref.current; const ctx = canvas.getContext("2d"); let raf, t = 0; function resize() { const dpr = Math.min(window.devicePixelRatio || 1, 2); canvas.width = canvas.clientWidth * dpr; canvas.height = canvas.clientHeight * dpr; ctx.scale(dpr, dpr); } resize(); window.addEventListener("resize", resize); function tick() { t += 0.0035; const w = canvas.clientWidth, h = canvas.clientHeight; ctx.clearRect(0, 0, w, h); const blobs = [ { x: w * (0.3 + 0.12 * Math.sin(t)), y: h * (0.35 + 0.1 * Math.cos(t * 0.7)), r: w * 0.55, c: "oklch(0.82 0.06 90 / 0.55)" }, { x: w * (0.7 + 0.14 * Math.cos(t * 0.5)), y: h * (0.65 + 0.12 * Math.sin(t * 0.6)), r: w * 0.5, c: "oklch(0.72 0.05 140 / 0.5)" }, { x: w * (0.55 + 0.1 * Math.sin(t * 1.2)), y: h * (0.2 + 0.1 * Math.cos(t * 0.9)), r: w * 0.45, c: "oklch(0.88 0.04 60 / 0.55)" }, ]; for (const b of blobs) { const g = ctx.createRadialGradient(b.x, b.y, 0, b.x, b.y, b.r); g.addColorStop(0, b.c); g.addColorStop(1, "oklch(0.97 0.008 85 / 0)"); ctx.fillStyle = g; ctx.fillRect(0, 0, w, h); } raf = requestAnimationFrame(tick); } tick(); return () => { cancelAnimationFrame(raf); window.removeEventListener("resize", resize); }; }, [enabled]); return ; } // ─── UI primitives ─────────────────────────────────────────────────────────── function StatusBadge({ source }) { // Confirmation state, to match the wizard: AI guesses stay "Unconfirmed" until a // human confirms/edits them (source flips to MANUAL); everything else is "Active". const unconfirmed = source === "AI_INGESTION"; const label = unconfirmed ? "Unconfirmed" : "Active"; return {label}; } function ArrowIcon() { return ; } function DocIcon() { return ; } function CheckTinyIcon() { return ; } // ─── OrgCard helpers ───────────────────────────────────────────────────────── // Domain for the favicon lookup, derived from whatever URL the org carries. function orgDomain(org) { const url = org.website_url || org.signin || org.website || ""; if (!url) return ""; try { const u = new URL(url.startsWith("http") ? url : `https://${url}`); return u.hostname.replace(/^www\./, ""); } catch { return ""; } } // First 1–2 letters, for the monogram behind a missing favicon. function orgMonogram(name) { const n = (name || "").trim(); if (!n) return "?"; const parts = n.split(/\s+/); if (parts.length >= 2 && parts[0] && parts[1]) return (parts[0][0] + parts[1][0]).toUpperCase(); return n.slice(0, 2).toUpperCase(); } // Map the org's relationship status → status-pill variant + label. function orgStatusPill(org) { const s = org.relationship_status || "unknown"; if (s === "lapsing" || orgHasAttention(org)) return { variant: "attention", label: "Attention" }; if (s === "active") return { variant: "active", label: "Active" }; if (s === "churned") return { variant: "expired", label: "Expired" }; return { variant: "neutral", label: s.charAt(0).toUpperCase() + s.slice(1) }; } // One-line payment note for the footer (mirrors the mock's "payment" slot). function orgPaymentNote(org) { const pay = orgPaymentLine(org); if (!pay) return "No recurring payment"; const next = orgNextCharge(org); return next ? `${pay} · next ${next}` : pay; } // ─── OrgDetailsModal — docs + actions popup, opened from a card ────────────── function OrgDetailsModal({ org, docs, actions, onClose }) { const closeRef = React.useRef(onClose); closeRef.current = onClose; React.useEffect(() => { function onKey(e) { if (e.key === "Escape") closeRef.current && closeRef.current(); } document.addEventListener("keydown", onKey); document.body.style.overflow = "hidden"; return () => { document.removeEventListener("keydown", onKey); document.body.style.overflow = ""; }; }, []); // Local-only "done" state — not persisted to the backend, matches the old card. const [doneActions, setDoneActions] = React.useState({}); const toggle = (i) => setDoneActions((prev) => ({ ...prev, [i]: !prev[i] })); return (
{ if (e.target === e.currentTarget) onClose(); }}>
{org.organisation_name}

Documents{docs.length > 0 && {docs.length}}

{docs.length === 0 ? (

No documents on file.

) : (
    {docs.map((d, i) => (
  • {d.name} {d.date && {d.date}}
  • ))}
)}

Actions required{actions.length > 0 && {actions.length}}

{actions.length === 0 ? (

No actions required.

) : (
    {actions.map((a, i) => { const done = !!doneActions[i]; return (
  • ); })}
)}
); } // ─── OrgCard — logo-forward card (shared by every category grid) ───────────── function OrgCard({ org }) { const [showDetails, setShowDetails] = React.useState(false); const [favOk, setFavOk] = React.useState(true); const domain = orgDomain(org); const fav = domain ? `https://www.google.com/s2/favicons?domain=${domain}&sz=128` : ""; const pill = orgStatusPill(org); const docs = orgAllDocs(org); const actions = orgAllActions(org); const resCount = org.accounts.length; const hasDetails = docs.length > 0 || actions.length > 0; const desc = org.relationship_summary || `${resCount} ${resCount === 1 ? "resource" : "resources"} held with ${org.organisation_name}.`; return ( <>
{fav && favOk && ( setFavOk(false)} /> )}
{CATEGORY_LABELS[org.category] || org.category}
{org.organisation_name}

{desc}

{orgPaymentNote(org)}
{hasDetails && ( )} {resCount} {resCount === 1 ? "resource" : "resources"}
{showDetails && ( setShowDetails(false)} /> )} ); } // ─── LoginScreen ────────────────────────────────────────────────────────────── // Two-step landing: marketing hero + "Get Started" first; Clerk's SignIn only // mounts once the user opts in. After auth, Clerk redirects to "/" and // checkAuth() takes the signed-in user straight to the dashboard. Connecting an // inbox (Nylas) is a separate action, handled on the dashboard. function LoginScreen({ tweaks }) { const [showAuth, setShowAuth] = React.useState(false); const signInRef = React.useRef(null); React.useEffect(() => { if (showAuth && signInRef.current && window.__clerk) { window.__clerk.mountSignIn(signInRef.current, { signInForceRedirectUrl: "/", signUpForceRedirectUrl: "/", }); const el = signInRef.current; return () => window.__clerk.unmountSignIn(el); } }, [showAuth]); // Auth view: a distinct, centered page — same branding (wordmark + ambient // background), Clerk's SignIn in the middle. Not the split landing. if (showAuth) { return (
); } // Landing: marketing hero on the left, Get Started on the right. return (

A considered handover, for the moments that matter.

Every account.
Every renewal.
One clear record.

Connect your inbox once. We organise subscriptions, financial accounts and digital identities into a single, deterministic handover document — ready to print, share, or settle.

All data encrypted in transit · GDPR · UK

Your handover, in minutes.

Create an account or sign in to begin.

); } // ─── Category icons (sidebar) ──────────────────────────────────────────────── const CATEGORY_ICON = { physical_assets: (s) => , personal_valuables: (s) => , digital_devices: (s) => , financial_accounts: (s) => , investment_business: (s) => , financial_protection: (s) => , employment_benefits: (s) => , digital_accounts: (s) => , government_legal: (s) => , cards_memberships: (s) => , subscriptions: (s) => , }; // Per-TYPE icon for the category type-summary tiles. A few type-specific glyphs, // otherwise reuse the category icons by keyword, with a cube fallback. const _ICON_ART = (s) => ; const _ICON_WATCH = (s) => ; const _ICON_CUBE = (s) => ; const _ICON_HOUSE = (s) => ; const _ICON_TOOL = (s) => ; const _ICON_PHONE = (s) => ; const _ICON_PLAY = (s) => ; function typeIcon(name, s = 18) { const n = (name || "").toLowerCase(); if (n.includes("art")) return _ICON_ART(s); if (n.includes("watch")) return _ICON_WATCH(s); if (n.includes("jewel")) return CATEGORY_ICON.personal_valuables(s); // These two must run before the broader account/subscription/share/insurance/car // checks below, which are loose enough to shadow several employment_benefits and // digital_accounts leaf-type names (e.g. "Death In Service Benefit" contains // "benefit", "Gaming Account" contains "account"). if (/(employer|benefit|rsu|restricted stock|redundancy|notice pay|share option|deferred bonus|death in service|company car|car allowance)/.test(n)) return CATEGORY_ICON.employment_benefits(s); if (/(email|cloud|password|vpn|domain|social media|gaming account|software development|other digital)/.test(n)) return CATEGORY_ICON.digital_accounts(s); if (/(car|vehicle|van|truck|motor|boat|aircraft)/.test(n)) return CATEGORY_ICON.physical_assets(s); if (/(house|land|property)/.test(n)) return _ICON_HOUSE(s); if (/(tractor|generator|power tools|workshop|machinery|equipment)/.test(n)) return _ICON_TOOL(s); if (/(laptop|computer|phone|tablet|device)/.test(n)) return CATEGORY_ICON.digital_devices(s); if (n.includes("card")) return CATEGORY_ICON.cards_memberships(s); if (/(account|mortgage|savings|bank|benefit)/.test(n)) return CATEGORY_ICON.financial_accounts(s); if (/(insurance|protection)/.test(n)) return CATEGORY_ICON.financial_protection(s); if (/(invest|broker|share|crypto)/.test(n)) return CATEGORY_ICON.investment_business(s); if (/(passport|licence|license|visa|legal|government)/.test(n)) return CATEGORY_ICON.government_legal(s); if (/(subscription|streaming|plan)/.test(n)) return CATEGORY_ICON.subscriptions(s); if (/(member|loyalty|club)/.test(n)) return CATEGORY_ICON.cards_memberships(s); return _ICON_CUBE(s); } // Sub-grids shown on a category's landing page, for the categories whose // resource types split into genuine, evenly-sized concepts. Categories not // listed here (e.g. Personal Valuables, only 3 types with no real split) // fall back to the flat type-tile grid. const ASSET_GROUPS = [ { key: "vehicles", label: "Vehicles", icon: s => CATEGORY_ICON.physical_assets(s), types: ["Car", "Motorcycle", "Van", "Truck", "Boat", "Aircraft"] }, { key: "property", label: "Property", icon: _ICON_HOUSE, types: ["House", "Land"] }, { key: "machinery", label: "Machinery / Equipment", icon: _ICON_TOOL, types: ["Tractor", "Generator", "Power Tools", "Workshop Equipment"] }, ]; const CATEGORY_GROUPS = { physical_assets: ASSET_GROUPS, digital_devices: [ { key: "mobile", label: "Mobile", icon: s => _ICON_PHONE(s), types: ["Smartphone", "Tablet"] }, { key: "computers", label: "Computers", icon: s => CATEGORY_ICON.digital_devices(s), types: ["Laptop", "Desktop Computer"] }, ], financial_accounts: [ { key: "banking", label: "Everyday Banking", icon: s => CATEGORY_ICON.financial_accounts(s), types: ["Current Account", "Savings Account"] }, { key: "credit", label: "Credit & Lending", icon: s => CATEGORY_ICON.cards_memberships(s), types: ["Credit Card Account", "Mortgage Account"] }, ], financial_protection: [ { key: "personal", label: "Personal Insurance", icon: s => CATEGORY_ICON.financial_protection(s), types: ["Life Insurance Policy", "Health Insurance Policy"] }, { key: "property", label: "Property & Vehicle", icon: s => _ICON_HOUSE(s), types: ["Vehicle Insurance Policy", "Home Insurance Policy"] }, ], subscriptions: [ { key: "entertainment", label: "Entertainment", icon: s => _ICON_PLAY(s), types: ["Video Streaming Subscription", "Music Streaming Subscription"] }, { key: "utilities", label: "Utilities & Services", icon: s => CATEGORY_ICON.subscriptions(s), types: ["Software Subscription", "Mobile Phone Plan"] }, ], }; // ─── Sidebar (Dashboard only) ──────────────────────────────────────────────── function Sidebar({ activeCategory, onSelect, collapsed, onToggleCollapsed, counts, categoryFamily }) { const item = (key, label) => { const Icon = CATEGORY_ICON[key]; const active = activeCategory === key; const n = counts?.[key] || 0; return ( ); }; return ( ); } // ─── ReportTab ──────────────────────────────────────────────────────────────── function ReportTab({ report, user, onOpenOrgReport }) { if (!window.SlimReportDoc) return
Loading…
; return ( <>
); } // ─── DashboardView (router) ─────────────────────────────────────────────────── function DashboardView({ report, activeCategory, onOpenOrgReport, onSelectCategory }) { if (activeCategory) { return ; } return ; } // ─── Actions & Documents panels ────────────────────────────────────────────── // Category → accent colour for the Actions panel. The spec names three; every // other category falls back to the neutral "Other" grey. const ACTION_ACCENTS = { subscriptions: "#8B5CF6", financial_accounts: "#2F9E5B", }; function actionAccent(category) { return ACTION_ACCENTS[category] || "#6E6A57"; } // Actions are stored as plain strings with the start date baked in as // "… [Initiated 12 Jun 2026]" (see org_summaries/mapper.py). Pull it back out // so the row can show the date on its own line. function splitInitiated(text) { const s = String(text || ""); const m = s.match(/\s*\[Initiated ([^\]]+)\]\s*$/); if (m) return { title: s.slice(0, m.index).trim(), initiated: m[1].trim() }; return { title: s.trim(), initiated: null }; } function buildActionRows(orgs) { const out = []; for (const o of orgs) { for (const a of (o.accounts || [])) { for (const t of (a.actions || [])) { const { title, initiated } = splitInitiated(t); if (title) out.push({ org: o, acct: a, title, initiated }); } } } return out; } // File tile — infer format from the document name's extension. The data has no // size and no reliable file type, so anything but PDF/DOC gets a neutral tile. function docFileKind(name) { const m = String(name || "").match(/\.([a-z0-9]+)$/i); const ext = m ? m[1].toLowerCase() : ""; if (ext === "pdf") return { label: "PDF", variant: "pdf", fg: "#C0392B" }; if (ext === "doc" || ext === "docx") return { label: "DOC", variant: "doc", fg: "#2A5CB8" }; return { label: ext ? ext.toUpperCase().slice(0, 4) : "FILE", variant: "file", fg: "#6E6A57" }; } function buildDocRows(orgs) { const out = []; for (const o of orgs) { for (const a of (o.accounts || [])) { for (const d of (a.docs || [])) { out.push({ org: o, acct: a, name: d.name, date: d.date, type: d.kind }); } } } return out; } // Shared panel shell — cream card with a serif title, mono subtitle + count pill. function Panel({ title, subtitle, count, noun, children }) { return (

{title}

{subtitle}
{count} {count === 1 ? noun : noun + "s"}
{children}
); } function ActionStatusPill({ source }) { // Mirrors StatusBadge: AI guesses stay "Unconfirmed" until a human confirms. const unconfirmed = source === "AI_INGESTION"; return ( {unconfirmed ? "Unconfirmed" : "Active"} ); } function ActionsPanel({ rows, onOpenOrgReport }) { return ( {rows.length === 0 ? (
Nothing outstanding right now.
) : (
    {rows.map((r, i) => { const accent = actionAccent(r.org.category); const href = r.org.website_url || r.org.signin || r.org.website; return (
  • ); })}
)}
); } function DocumentsPanel({ rows, onOpenOrgReport }) { return ( {rows.length === 0 ? (
No documents yet.
) : (
    {rows.map((r, i) => { const fk = docFileKind(r.name); const cat = CATEGORY_LABELS[r.org.category] || r.org.category; return (
  • ); })}
)}
); } // ── Display helpers for LLM-generated text that may be too long for cards ── function firstSentence(s, fallbackMax = 160) { if (!s) return ""; const m = String(s).match(/^(.{20,}?[.!?])(\s|$)/); if (m) return truncateText(m[1], fallbackMax); return truncateText(s, fallbackMax); } function truncateText(s, max = 100) { if (!s) return ""; const t = String(s); if (t.length <= max) return t; return t.slice(0, max - 1).trimEnd() + "…"; } function DashboardSummaryView({ report, onOpenOrgReport, onSelectCategory }) { const orgs = report.orgs || []; const actionRows = useMemo(() => buildActionRows(orgs), [orgs]); const docRows = useMemo(() => buildDocRows(orgs), [orgs]); const totals = useMemo(() => { let activities = 0, actions = 0, relationships = 0; for (const o of orgs) { for (const a of (o.accounts || [])) { relationships += 1; activities += (a.events || []).length; actions += (a.actions || []).length; } } return { activities, actions, relationships }; }, [orgs]); const byCat = useMemo(() => { const out = {}; for (const c of CATEGORY_ORDER) { const list = report.orgs_by_category[c] || []; out[c] = list.reduce((n, o) => n + (o.accounts?.length || 0), 0); } return out; }, [report]); const visibleCats = CATEGORY_ORDER.filter(c => (byCat[c] || 0) > 0); return (

Summary · live

Your handover, at a glance.

{totals.relationships} resources across {visibleCats.length} categories. {" "}{totals.actions} need your attention.

By category

{visibleCats.length} categories
    {visibleCats.map(c => { const fam = (report.orgs_by_category[c][0] || {}).family || "unknown"; return (
  • ); })}
); } function KpiTile({ n, label, sub }) { return (
{n}
{label}
{sub &&
{sub}
}
); } function DashboardCategoryView({ report, activeCategory, onOpenOrgReport }) { const list = (report.orgs_by_category && report.orgs_by_category[activeCategory]) || []; const [openGroup, setOpenGroup] = React.useState(null); const [openType, setOpenType] = React.useState(null); const groups = CATEGORY_GROUPS[activeCategory]; // categories with a genuine 2-way type split get a group sub-grid; others fall back to the flat list // Tally the resource types held in this category, from each org's accounts. const typeCounts = {}; for (const o of list) for (const a of (o.accounts || [])) { const t = a.resource_type || "Other"; typeCounts[t] = (typeCounts[t] || 0) + 1; } // Show every type this category can hold, even with 0 records, plus any // unexpected types that turned up in the data. Counts overlaid from typeCounts. const knownTypes = (report.category_types && report.category_types[activeCategory]) || []; const extraTypes = Object.keys(typeCounts).filter(t => !knownTypes.includes(t)); const typeEntries = [...knownTypes, ...extraTypes].map(t => [t, typeCounts[t] || 0]); const totalRecords = typeEntries.reduce((s, [, n]) => s + n, 0); const label = CATEGORY_LABELS[activeCategory]; const group = groups && openGroup ? groups.find(g => g.key === openGroup) : null; // Drill-in view: one resource type, showing its cards. Reached by tapping a tile. if (openType) { const shown = list.filter(o => (o.accounts || []).some(a => (a.resource_type || "Other") === openType)); const backLabel = group ? group.label : label; return (

{openType}

{shown.length === 0 &&
Nothing here yet.
} {shown.map(o => (
))}
); } // Grouped category landing: sub-grid of the category's groups. if (groups && !openGroup) { return (

{label}

{groups.length} categories · {totalRecords} record{totalRecords === 1 ? "" : "s"}

Pick a category to explore what we found.

{groups.map(g => { const n = g.types.reduce((sum, t) => sum + (typeCounts[t] || 0), 0); return ( ); })}
); } // Landing view (or, for grouped categories, a group's type list): tap a type to open its records. const shownEntries = group ? typeEntries.filter(([t]) => group.types.includes(t)) : typeEntries; const shownTotal = group ? shownEntries.reduce((sum, [, n]) => sum + n, 0) : totalRecords; const heading = group ? group.label : label; return (
{group && }

{heading}

{shownEntries.length} type{shownEntries.length === 1 ? "" : "s"} · {shownTotal} record{shownTotal === 1 ? "" : "s"}

Tap a type to see what we found.

{shownEntries.length === 0 ? (
Nothing in {heading.toLowerCase()} yet.
) : (
{shownEntries.map(([t, n]) => ( ))}
)}
); } // ─── OrgReportPicker ───────────────────────────────────────────────────────── function OrgReportPicker({ orgs, onPick }) { const [open, setOpen] = React.useState(false); const [q, setQ] = React.useState(""); const ref = React.useRef(null); React.useEffect(() => { function onDoc(e) { if (ref.current && !ref.current.contains(e.target)) setOpen(false); } document.addEventListener("mousedown", onDoc); return () => document.removeEventListener("mousedown", onDoc); }, []); const grouped = React.useMemo(() => { const byCat = {}; for (const o of orgs) (byCat[o.category] ||= []).push(o); const out = []; for (const c of CATEGORY_ORDER) if (byCat[c]) out.push([c, byCat[c]]); if (byCat.unknown) out.push(["unknown", byCat.unknown]); return out; }, [orgs]); const needle = q.trim().toLowerCase(); const matches = (o) => !needle || o.organisation_name.toLowerCase().includes(needle); return (
{open && (
setQ(e.target.value)} autoFocus />
{grouped.map(([cat, list]) => { const shown = list.filter(matches); if (!shown.length) return null; return (
{CATEGORY_LABELS[cat] || cat}
{shown.map(o => ( ))}
); })}
)}
); } // ─── ShareMenu ─────────────────────────────────────────────────────────────── function ShareMenu() { const [open, setOpen] = React.useState(false); const [emailMode, setEmailMode] = React.useState(false); const [to, setTo] = React.useState(""); const [busy, setBusy] = React.useState(false); const [msg, setMsg] = React.useState(null); // { ok: bool, text } const ref = React.useRef(null); React.useEffect(() => { function onDoc(e) { if (ref.current && !ref.current.contains(e.target)) close(); } document.addEventListener("mousedown", onDoc); return () => document.removeEventListener("mousedown", onDoc); }, []); function close() { setOpen(false); setEmailMode(false); setMsg(null); } async function handleSend(e) { e?.preventDefault(); const recipient = to.trim(); if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(recipient)) { setMsg({ ok: false, text: "Enter a valid email address." }); return; } setBusy(true); setMsg(null); try { await shareReport(recipient); setMsg({ ok: true, text: `Sent to ${recipient}.` }); setTo(""); } catch (err) { setMsg({ ok: false, text: err.message || "Could not send." }); } finally { setBusy(false); } } return (
{open && (
{!emailMode ? ( <>
) : (
setTo(e.target.value)} autoFocus disabled={busy} /> {msg && (
{msg.text}
)}
)}
)}
); } // ─── SenderFilter ──────────────────────────────────────────────────────────── function SenderFilter({ filter, onFilterChange }) { const [open, setOpen] = React.useState(false); const [draft, setDraft] = React.useState(""); const [err, setErr] = React.useState(null); const [busy, setBusy] = React.useState(false); const ref = React.useRef(null); React.useEffect(() => { function onDoc(e) { if (ref.current && !ref.current.contains(e.target)) setOpen(false); } document.addEventListener("mousedown", onDoc); return () => document.removeEventListener("mousedown", onDoc); }, []); const senders = filter.senders || []; const selected = filter.selected || null; const pillLabel = selected ? selected : "All senders"; async function handleAdd(e) { e?.preventDefault(); const email = draft.trim(); if (!email) return; setBusy(true); setErr(null); try { const next = await postSender(email); onFilterChange(next); setDraft(""); } catch (e) { setErr(e.message || "Could not add sender"); } finally { setBusy(false); } } async function handleRemove(email) { setBusy(true); setErr(null); try { await deleteSender(email); const nextSelected = selected === email ? null : selected; if (selected === email) await putSelectedSender(null); onFilterChange({ senders: senders.filter(s => s !== email), selected: nextSelected }); } catch (e) { setErr(e.message || "Could not remove sender"); } finally { setBusy(false); } } async function handleSelect(value) { const email = value === "__all__" ? null : value; setBusy(true); setErr(null); try { const next = await putSelectedSender(email); onFilterChange(next); } catch (e) { setErr(e.message || "Could not change selection"); } finally { setBusy(false); } } return (
{open && (
{senders.length === 0 ? (

No saved senders yet.

) : (
    {senders.map(s => (
  • {s}
  • ))}
)}
setDraft(e.target.value)} disabled={busy} />

Pick one to limit Sync to that sender, or “All senders” for no filter.

{err &&

{err}

}
)}
); } // ─── Nylas Connect (mailbox identity) ──────────────────────────────────────── // @nylas/connect is ESM-only; index.html loads it as a module and sets // window.NylasConnect. We init a single client lazily and reuse it. let nylasConnect = null; let nylasCallbackHandled = false; function defaultNylasRedirectUri() { return `${window.location.origin}/auth/callback`; } function hasNylasCallbackParams() { const params = new URLSearchParams(window.location.search); return window.location.pathname === "/auth/callback" && (params.has("code") || params.has("error")); } function waitForNylasConnect() { if (window.NylasConnect) return Promise.resolve(); return new Promise((resolve, reject) => { const timeout = window.setTimeout(() => { window.removeEventListener("nylasconnect:ready", onReady); reject(new Error("Nylas Connect SDK not loaded")); }, 10000); function onReady() { window.clearTimeout(timeout); resolve(); } window.addEventListener("nylasconnect:ready", onReady, { once: true }); }); } async function getNylasConnect() { if (nylasConnect) return nylasConnect; await waitForNylasConnect(); const res = await fetch("/auth/config"); const { nylas_client_id, nylas_redirect_uri } = await res.json(); if (!nylas_client_id) throw new Error("Nylas client id not configured"); nylasConnect = new window.NylasConnect({ clientId: nylas_client_id, redirectUri: nylas_redirect_uri || defaultNylasRedirectUri(), apiUrl: "https://api.eu.nylas.com", // SDK defaults to US; our Nylas app is EU // DIAGNOSTIC: idp_claims disabled to test the Google 45002 (grant creation // failed) bug. This Clerk JWT is injected during Nylas token exchange; if // window.__clerk is absent in the popup/callback context it returns undefined // and breaks the exchange. Returning null skips idp_claims entirely. // If Google connects with this off, the Clerk token was the cause. identityProviderToken: async () => null, // Google needs explicit scopes or it won't grant mail access (Microsoft works // on the SDK defaults). Matches the Nylas connector's configured scopes. defaultScopes: { google: [ "openid", "https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/userinfo.profile", "https://www.googleapis.com/auth/gmail.modify", ], }, }); return nylasConnect; } async function handleNylasCallbackIfPresent() { if (nylasCallbackHandled || !hasNylasCallbackParams()) return false; nylasCallbackHandled = true; const client = await getNylasConnect(); await client.callback(window.location.href); return true; } const InboxIcon = (s) => ( ); const _ICON_CHEVRON = (s) => ( ); function HomeSection({ eyebrow, sub, emptyText, items, onOpenOrgReport }) { return (

{eyebrow}

{sub}

{items.length === 0 ? (

{emptyText}

) : (
{items.map((it, i) => (
onOpenOrgReport(it.org)}>

{it.label}

{it.title}

{it.hint}

{_ICON_CHEVRON(16)}
))}
)}
); } // Reuses the same action rows + priority heuristic as the Dashboard's Actions // panel (buildActionRows / actionPriority) — HIGH-worded actions ("overdue", // "expir…", "urgent"…) surface as Today, everything else as This month. function HomeTab({ user, report, onOpenOrgReport }) { const [now, setNow] = React.useState(new Date()); React.useEffect(() => { const id = setInterval(() => setNow(new Date()), 1000); return () => clearInterval(id); }, []); const timeStr = now.toLocaleTimeString("en-GB", { hour12: false }); const dateStr = now.toLocaleDateString("en-GB", { weekday: "long", day: "numeric", month: "long" }); const actionRows = useMemo(() => buildActionRows(report.orgs || []), [report]); const toHomeItem = (r) => ({ label: CATEGORY_LABELS[r.org.category] || r.org.category, title: r.title, hint: r.initiated ? `Initiated ${r.initiated}` : r.org.organisation_name, org: r.org, }); const todayItems = actionRows.filter(r => actionPriority(r.title) === "HIGH").map(toHomeItem); const monthItems = actionRows.filter(r => actionPriority(r.title) !== "HIGH").map(toHomeItem); return (
{timeStr}
{dateStr}

{_greeting(user.name)}.

); } // ─── IngestionTab ───────────────────────────────────────────────────────────── // All mailbox + ingestion controls live here, segregated from the report. // Live ingest progress. Scanning = count up (no total yet, Nylas gives none until // paged); processing = accurate "N of total" bar where total is locked once. function IngestProgress({ progress }) { const { phase, processed, total } = progress || {}; const isProcessing = phase === "processing" && total > 0; if (isProcessing) { const pct = Math.min(100, Math.round((processed / total) * 100)); return (
Found {total.toLocaleString()} emails. Processed {processed.toLocaleString()} of {total.toLocaleString()}.
); } return (
Scanning inbox… {(processed || 0).toLocaleString()} emails found
); } function IngestionTab({ filter, onFilterChange, onIngest, ingestState, ingestProgress, user, resourcesApi }) { const [accounts, setAccounts] = React.useState(null); // null = loading const [connecting, setConnecting] = React.useState(false); const [error, setError] = React.useState(null); const [removed, setRemoved] = React.useState(null); // last removed email, for the toast const [aiResources, setAiResources] = React.useState([]); // AI_INGESTION resources awaiting confirmation const [selected, setSelected] = React.useState(() => new Set()); // ids ticked for bulk confirm/remove const [editId, setEditId] = React.useState(null); // ai resource being inline-renamed const [editName, setEditName] = React.useState(""); const [bulkBusy, setBulkBusy] = React.useState(false); const refresh = React.useCallback(async () => { try { setAccounts(await fetchAccounts()); } catch (err) { setError(err.message); setAccounts([]); } }, []); React.useEffect(() => { refresh(); }, [refresh]); const refreshAi = React.useCallback(async () => { try { const d = await resourcesApi.load(); setAiResources((d.items || []).filter(it => it.source === "AI_INGESTION")); } catch (err) { /* leave list as-is on error */ } }, [resourcesApi]); React.useEffect(() => { refreshAi(); }, [refreshAi]); function toggleOne(id) { setSelected(s => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; }); } function toggleAll() { setSelected(s => s.size === aiResources.length ? new Set() : new Set(aiResources.map(r => r.id))); } async function bulkConfirm() { setBulkBusy(true); try { for (const id of selected) await resourcesApi.confirm(id); setSelected(new Set()); await refreshAi(); } catch (err) { setError(err.message); } finally { setBulkBusy(false); } } async function bulkRemove() { if (!window.confirm(`Remove ${selected.size} resource(s)? This can't be undone.`)) return; setBulkBusy(true); try { for (const id of selected) await resourcesApi.remove(id); setSelected(new Set()); await refreshAi(); } catch (err) { setError(err.message); } finally { setBulkBusy(false); } } function startEditAi(it) { setEditId(it.id); setEditName(it.name || ""); } async function saveEditAi(it) { setBulkBusy(true); try { await resourcesApi.edit(it.id, { resource_type_id: it.resource_type_id, name: editName.trim(), fields: it.details || {} }); setEditId(null); await refreshAi(); } catch (err) { setError(err.message); } finally { setBulkBusy(false); } } async function handleConnect() { setError(null); setConnecting(true); try { const client = await getNylasConnect(); // connect() reuses any cached session and skips the popup, so you can't add // a second mailbox (or re-pick after deleting one). logout() clears the // cached session first, forcing a fresh account picker. Safe: the SDK's // local session isn't used after connect — the grant lives in our DB. try { await client.logout(); } catch (e) {} const result = await client.connect({ method: "popup" }); const grantId = result?.grantId; const email = result?.grantInfo?.email || result?.email; if (!grantId) throw new Error("Connection cancelled"); await postAccount(grantId, email); await refresh(); } catch (err) { console.error("Nylas connect failed:", err); setError(err.message || "Could not connect mailbox"); } finally { setConnecting(false); } } async function handleRemove(id, email) { if (!window.confirm(`Remove ${email || "this mailbox"}? This permanently deletes its synced email data — your handover won't include it again unless you reconnect and re-ingest.`)) return; setError(null); setRemoved(null); try { await deleteAccount(id); // No need to clear the SDK session here — handleConnect calls logout() // before every connect(), so a reconnect always forces a fresh picker. await refresh(); setRemoved(email); } catch (err) { setError(err.message || "Could not remove mailbox"); } } const busy = ingestState === "loading"; if (accounts === null) { return

Loading…

; } // Welcome — no mailbox yet if (accounts.length === 0) { const firstName = (user?.name || "").trim().split(/\s+/)[0] || ""; return (

AI Mailbox Ingestion

Welcome{firstName ? " " + firstName : ""}.

Would you like to connect your mailbox? We read it once and turn renewals, accounts and subscriptions into one clear handover.

{removed &&

Removed mailbox ({removed}).

} {error &&

{error}

}
); } // Connected — guided steps return (

AI Mailbox Ingestion

Your mailbox, organised.

  1. 1

    Connected mailboxes

      {accounts.map(a => (
    • {InboxIcon(18)} {a.email_address}
    • ))}
    {removed &&

    Removed mailbox ({removed}).

    } {error &&

    {error}

    }
  2. 2

    Ignore any organisations?

    Narrow ingestion to specific senders, or leave it open to all.

  3. 3

    Run ingestion

    Pull your emails and build the handover.

    {busy && ingestProgress && }
  4. 4

    Confirm your AI-discovered resources

    Review what the AI found. Tick the ones to confirm or remove, edit a name inline, or describe changes below.

    {aiResources.length === 0 ? (

    Nothing to confirm yet — run an ingestion above.

    ) : (
    {aiResources.map(it => (
    toggleOne(it.id)} /> {editId === it.id ? setEditName(e.target.value)} /> :
    {it.name}
    } {editId === it.id ? : }
    ))}
    )}
); } // ─── Ask AI (global button + popup) ────────────────────────────────────────── function AskAiButton({ onClick }) { return ( ); } function AskAiModal({ onClose }) { const [text, setText] = React.useState(""); const [recording, setRecording] = React.useState(false); const [seconds, setSeconds] = React.useState(0); const [submitted, setSubmitted] = React.useState(false); // recording timer — visual only, no real microphone access (coming-soon shell) React.useEffect(() => { if (!recording) return; const id = setInterval(() => setSeconds(s => s + 1), 1000); return () => clearInterval(id); }, [recording]); // Escape closes the popup React.useEffect(() => { const onKey = (e) => { if (e.key === "Escape") onClose(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [onClose]); const toggleRec = () => { if (recording) { setRecording(false); return; } setSeconds(0); setRecording(true); }; const fmt = (s) => `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`; const canSubmit = text.trim().length > 0 || seconds > 0; return (
{ if (e.target === e.currentTarget) onClose(); }}>
Ask AI

What would you like to change?

{submitted ? (

Thanks — this is coming soon.

) : (

Speak or type what you’d like to change.

{recording ? `Recording… ${fmt(seconds)}` : seconds > 0 ? `Recorded ${fmt(seconds)} — tap to re-record` : "Tap to record"}