a0c70e1411
Per explicit product direction: the app assumed a single user (head of family) with everything in per-device localStorage. There was no way for a family to delegate estate management to an agent (relative or professional) without literally handing over the device. This required real backend infrastructure, not a UI addition — added Supabase (Postgres + Auth) as a multi-tenant backend. Schema (nf_ prefixed to stay isolated from other tables in the reused "Falah OS demo" project): nf_families, nf_family_members (role: owner/ agent, status: invited/active), and family-scoped versions of every estate table — nf_assets, nf_trusted_contacts, nf_hibah_gifts, nf_waqf_designations/nf_waqf_beneficiaries, nf_nominations, nf_attestors, nf_death_triggers. Permission model, enforced by RLS at the database level (not just hidden in the UI): an agent can do everything an owner can — add/edit assets, draft Hibah/Waqf/Nominations, set up the Death Trigger — except fire it. nf_death_triggers' UPDATE/INSERT policies use a WITH CHECK that only allows triggered=true when the caller has role='owner' on that family. A professional agent can be invited to multiple families and switches between them from their own dashboard. New: auth.js, family.js, db.js, AuthScreen.svelte, FamilySwitcher.svelte, FamilyManagement.svelte (new "Family" tab: invite agents, see members, switch families). AssetRegistry, HibahTracker, FamilyWaqfDesignator, NominationRegistry, CoverageDashboard, and DeathTrigger all migrated from storage.js (localStorage) to db.js (Supabase), scoped to the active family_id. App.svelte now gates on auth + family selection before showing the main tab shell. Three real bugs found and fixed via testing against the live backend (not caught by the old localStorage-based suites, which had no cross-client concurrency to expose them): - RLS gap: pending-invite lookup joins nf_families(name), but the invitee isn't a family member yet, so the join was silently dropped — added a policy letting a pending invitee see just the family name. - Attestor row race: lazy "create on first blur" could double-fire from two different code paths, creating duplicate rows and confirming the wrong one. Fixed by eagerly creating attestor rows on first load so every row always has a real id — no more create-or-update ambiguity. - Out-of-order async clobber: three death-trigger setup fields each fired a full-snapshot upsert on every input; whichever request *finished* last (not fired last) won, silently reverting the other two fields to stale values. Fixed with per-field partial updates (updateDeathTriggerField) that can't clobber columns they don't touch. e2e-family-agent.cjs: full owner/agent flow against the live Supabase backend — invite, accept, shared live data, agent blocked from firing the trigger (button stays disabled and a direct RLS-level attempt would also fail), owner successfully fires it. 12/12 passing. e2e-smoke-authed.cjs: post-auth-gate sweep confirming every existing tab still renders and its info panel still opens under the new sign-in requirement. 24/24 passing, zero console errors. Known follow-up, not done here: the eight pre-auth E2E suites (e2e-uat.cjs, e2e-fastpath.cjs, e2e-trust.cjs, e2e-business.cjs, e2e-digital-vehicle.cjs, e2e-property.cjs, e2e-other.cjs, e2e-info.cjs) assume an anonymous landing page and need a sign-in prelude added before they're valid again — their detailed assertions were re-verified functionally via the smoke test and manual review, not by running them as-is.
192 lines
9.2 KiB
Svelte
192 lines
9.2 KiB
Svelte
<script>
|
|
import { onMount } from 'svelte';
|
|
import { activeFamilyId } from './family.js';
|
|
import { currentUser } from './auth.js';
|
|
import { listAssets, addAsset, updateAsset, removeAsset, listTrustedContacts, addTrustedContact, removeTrustedContact, estateTotal } from './db.js';
|
|
import Disclaimer from './Disclaimer.svelte';
|
|
import InfoPanel from './InfoPanel.svelte';
|
|
|
|
const TYPES = ['Property', 'Cash / Bank', 'Vehicle', 'Business interest', 'Digital assets', 'Jewelry / valuables', 'Other'];
|
|
|
|
let familyId = $state(null);
|
|
activeFamilyId.subscribe(v => familyId = v);
|
|
|
|
let assets = $state([]);
|
|
let trustedContacts = $state([]);
|
|
|
|
let form = $state(emptyForm());
|
|
let editingId = $state(null);
|
|
let contactForm = $state({ name: '', method: '' });
|
|
|
|
function emptyForm() {
|
|
return { type: TYPES[0], description: '', value: '', location: '', ownershipShare: 100 };
|
|
}
|
|
|
|
async function refresh() {
|
|
if (!familyId) return;
|
|
assets = await listAssets(familyId);
|
|
trustedContacts = await listTrustedContacts(familyId);
|
|
}
|
|
|
|
onMount(refresh);
|
|
$effect(() => { familyId; refresh(); });
|
|
|
|
async function addOrUpdate() {
|
|
if (!form.description || !form.value) return;
|
|
if (editingId) {
|
|
await updateAsset(editingId, form);
|
|
editingId = null;
|
|
} else {
|
|
await addAsset(familyId, currentUser()?.id, form);
|
|
}
|
|
form = emptyForm();
|
|
await refresh();
|
|
}
|
|
|
|
function edit(a) {
|
|
editingId = a.id;
|
|
form = { ...a, value: String(a.value) };
|
|
}
|
|
|
|
async function remove(id) {
|
|
await removeAsset(id);
|
|
if (editingId === id) { editingId = null; form = emptyForm(); }
|
|
await refresh();
|
|
}
|
|
|
|
async function addContact() {
|
|
if (!contactForm.name) return;
|
|
await addTrustedContact(familyId, contactForm.name, contactForm.method);
|
|
contactForm = { name: '', method: '' };
|
|
await refresh();
|
|
}
|
|
|
|
async function removeContact(id) {
|
|
await removeTrustedContact(id);
|
|
await refresh();
|
|
}
|
|
|
|
function exportSummary() {
|
|
const total = estateTotal(assets);
|
|
const lines = [
|
|
'NUR FALAH — ASSET REGISTRY SUMMARY',
|
|
`Generated: ${new Date().toISOString().slice(0, 10)}`,
|
|
`Total estate value: ${total.toLocaleString()}`,
|
|
'',
|
|
...assets.map(a => `${a.type} — ${a.description} — value ${Number(a.value).toLocaleString()} — ${a.ownershipShare}% owned — ${a.location || 'no location noted'}`),
|
|
'',
|
|
'This is a portable "what I own" summary. Not a fatwa, not legal advice.'
|
|
];
|
|
const blob = new Blob([lines.join('\n')], { type: 'text/plain' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url; a.download = 'asset-registry-summary.txt'; a.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
const total = $derived(estateTotal(assets));
|
|
</script>
|
|
|
|
<div class="module">
|
|
<div class="module-header">
|
|
<h2>Asset Registry</h2>
|
|
<InfoPanel
|
|
title="Asset Registry"
|
|
what="A simple list of everything you own — your house, bank accounts, car, business, crypto, jewelry, anything of value. This is the foundation everything else in the app builds on: your Faraid shares, wassiyah limit, and coverage percentage are all calculated from what you log here."
|
|
how="Add one asset at a time. Start with the big things — property, savings, your car — you can always add smaller items later. Nothing here is submitted anywhere; it just stays on your device."
|
|
fields={[
|
|
{ label: 'Type', hint: 'What kind of asset it is — property, cash, vehicle, business, digital, jewelry, or other.' },
|
|
{ label: 'Description', hint: 'A short name so you recognize it later, e.g. \'Terrace house, Shah Alam\'.' },
|
|
{ label: 'Estimated value', hint: 'A rough number is fine — you can update it anytime.' },
|
|
{ label: 'Ownership share', hint: 'If you only own part of it (e.g. jointly with a sibling), enter your percentage — otherwise leave at 100%.' }
|
|
]}
|
|
/>
|
|
</div>
|
|
<p class="sub">Log what you own — feeds the Faraid Calculator, Wassiyah one-third meter, and Waqf corpus selector.</p>
|
|
|
|
<div class="total-card">
|
|
<span>Estate total</span>
|
|
<strong>{total.toLocaleString()}</strong>
|
|
</div>
|
|
|
|
<div class="form-card">
|
|
<label class="field"><span>Type</span>
|
|
<select bind:value={form.type}>
|
|
{#each TYPES as t}<option value={t}>{t}</option>{/each}
|
|
</select>
|
|
</label>
|
|
<label class="field"><span>Description</span><input type="text" bind:value={form.description} placeholder="e.g. Terrace house, Shah Alam" /></label>
|
|
<label class="field"><span>Estimated value</span><input type="number" min="0" bind:value={form.value} /></label>
|
|
<label class="field"><span>Location / jurisdiction</span><input type="text" bind:value={form.location} placeholder="e.g. Selangor, UK" /></label>
|
|
<label class="field"><span>Ownership share (%)</span><input type="number" min="0" max="100" bind:value={form.ownershipShare} /></label>
|
|
<button class="btn-primary" onclick={addOrUpdate}>{editingId ? 'Update asset' : 'Add asset'}</button>
|
|
</div>
|
|
|
|
<div class="list">
|
|
{#each assets as a (a.id)}
|
|
<div class="asset-row">
|
|
<div class="asset-info">
|
|
<span class="asset-type">{a.type}</span>
|
|
<span class="asset-desc">{a.description}</span>
|
|
<span class="asset-meta">{a.ownershipShare}% owned · {a.location || '—'}</span>
|
|
</div>
|
|
<div class="asset-value">{Number(a.value).toLocaleString()}</div>
|
|
<div class="asset-actions">
|
|
<button onclick={() => edit(a)} aria-label="Edit">✎</button>
|
|
<button onclick={() => remove(a.id)} aria-label="Remove">✕</button>
|
|
</div>
|
|
</div>
|
|
{:else}
|
|
<p class="empty">No assets logged yet.</p>
|
|
{/each}
|
|
</div>
|
|
|
|
<button class="btn-secondary" onclick={exportSummary}>Export "what I own" summary</button>
|
|
|
|
<div class="contacts-section">
|
|
<h3>Trusted contacts</h3>
|
|
<p class="note">Notified or given a read-only export on a triggering event — not an automated death-detection or legal-transfer mechanism.</p>
|
|
<div class="form-card">
|
|
<label class="field"><span>Name</span><input type="text" bind:value={contactForm.name} /></label>
|
|
<label class="field"><span>Contact method</span><input type="text" bind:value={contactForm.method} placeholder="email or phone" /></label>
|
|
<button class="btn-secondary" onclick={addContact}>Add trusted contact</button>
|
|
</div>
|
|
{#each trustedContacts as c (c.id)}
|
|
<div class="contact-row"><span>{c.name} — {c.method}</span><button onclick={() => removeContact(c.id)}>✕</button></div>
|
|
{/each}
|
|
</div>
|
|
|
|
<Disclaimer text="Manual entry only — bank/brokerage account linking and live balance sync are explicitly out of scope for Horizon 1." />
|
|
</div>
|
|
|
|
<style>
|
|
.module { padding: 4px 0 40px; }
|
|
.module-header { display: flex; align-items: center; margin-bottom: 4px; }
|
|
h2 { font-family: 'DM Serif Display', serif; font-size: 24px; color: #E8E4DC; margin-bottom: 0; }
|
|
.sub { font-size: 13px; color: #8A8478; margin-bottom: 16px; }
|
|
.total-card { display: flex; justify-content: space-between; align-items: baseline; background: rgba(201,168,76,0.08); border: 1px solid rgba(201,168,76,0.25); border-radius: 12px; padding: 16px; margin-bottom: 18px; }
|
|
.total-card span { font-size: 12.5px; color: #B8B2A6; }
|
|
.total-card strong { font-size: 22px; color: #C9A84C; }
|
|
.form-card { background: rgba(255,255,255,0.03); border-radius: 12px; padding: 14px; margin-bottom: 16px; }
|
|
.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; }
|
|
.field span { font-size: 12px; color: #B8B2A6; }
|
|
.field select, .field input { background: rgba(255,255,255,0.05); border: 1px solid rgba(201,168,76,0.2); border-radius: 8px; padding: 10px 12px; color: #E8E4DC; font-size: 14px; }
|
|
.btn-primary, .btn-secondary { width: 100%; padding: 12px; border-radius: 8px; border: none; font-weight: 600; cursor: pointer; }
|
|
.btn-primary { background: #C9A84C; color: #070A0D; }
|
|
.btn-secondary { background: rgba(255,255,255,0.08); color: #E8E4DC; margin-bottom: 10px; }
|
|
.list { margin-bottom: 12px; }
|
|
.asset-row { display: flex; align-items: center; gap: 10px; padding: 12px 0; border-bottom: 1px solid rgba(255,255,255,0.06); }
|
|
.asset-info { display: flex; flex-direction: column; flex: 1; gap: 2px; }
|
|
.asset-type { font-size: 11px; color: #8A8478; text-transform: uppercase; letter-spacing: 0.4px; }
|
|
.asset-desc { font-size: 13.5px; color: #E8E4DC; }
|
|
.asset-meta { font-size: 11px; color: #8A8478; }
|
|
.asset-value { color: #2ECC71; font-weight: 600; font-size: 13px; }
|
|
.asset-actions button { background: none; border: none; color: #8A8478; cursor: pointer; padding: 4px 6px; }
|
|
.empty { font-size: 13px; color: #8A8478; text-align: center; padding: 20px 0; }
|
|
.contacts-section { margin-top: 24px; border-top: 1px solid rgba(201,168,76,0.15); padding-top: 16px; }
|
|
.contacts-section h3 { font-size: 15px; color: #C9A84C; margin-bottom: 4px; }
|
|
.note { font-size: 11.5px; color: #8A8478; margin-bottom: 12px; }
|
|
.contact-row { display: flex; justify-content: space-between; padding: 8px 0; font-size: 13px; color: #E8E4DC; border-bottom: 1px solid rgba(255,255,255,0.06); }
|
|
.contact-row button { background: none; border: none; color: #8A8478; cursor: pointer; }
|
|
</style>
|