7ce1121b94
Two features scoped from the stickiness brainstorm, sharing one digest notification pipeline: Sadaqah tracker (new tab): a private daily giving journal, not a payment processor — the app logs, it never moves money. Streak tracking follows the proven pattern from dedicated apps (Sidq, Daily Sadaqa). Family visibility is strictly limited to streak counts via a SECURITY DEFINER RPC (nf_family_sadaqah_streaks) — amounts, causes, and notes never cross the member boundary, respecting the Islamic preference for giving privately. Entries can be dedicated 'in memory of' a deceased person from the Family Tree, with a memorial count (nf_memorial_sadaqah_count, count only) surfacing on that person's card. Family Tree social loop: a 'give sadaqah in memory of' link on every deceased person's card (cross-tab jump via a small requestedTab store in nav.js), plus completeness hints (missing birth date, missing photo, no relationships linked) feeding directly into the existing Coverage Dashboard recommendations engine rather than a new UI surface. Daily digest Edge Function + pg_cron (07:00 UTC): batches into one email per family member per day, sent only when there's real content — tree activity in the last 24h, a birthday/death-anniversary today, or a weekly sadaqah recap on Sundays. An empty day sends nothing, deliberately avoiding the notification-spam failure mode the research flagged. Protected by a shared secret header since it's cron-invoked, not user-triggered. Verified with a live manual invocation before relying on the schedule. Found and fixed two real bugs in nf_family_sadaqah_streaks during E2E testing: an ambiguous unqualified 'member_id' column reference colliding with the function's OUT parameter (42702), and a bigint/int type mismatch from count(*) (42804) — both would have 400'd on every call in production. Covered by e2e-sadaqah-tree.cjs (12/12). Full regression: 280/280 across all suites.
206 lines
9.4 KiB
Svelte
206 lines
9.4 KiB
Svelte
<script>
|
|
import FaraidCalculator from './lib/FaraidCalculator.svelte';
|
|
import AssetRegistry from './lib/AssetRegistry.svelte';
|
|
import WassiyahGenerator from './lib/WassiyahGenerator.svelte';
|
|
import HibahTracker from './lib/HibahTracker.svelte';
|
|
import FamilyWaqfDesignator from './lib/FamilyWaqfDesignator.svelte';
|
|
import NominationRegistry from './lib/NominationRegistry.svelte';
|
|
import CoverageDashboard from './lib/CoverageDashboard.svelte';
|
|
import DeathTrigger from './lib/DeathTrigger.svelte';
|
|
import DigitalClaims from './lib/horizon2/DigitalClaims.svelte';
|
|
import { exportAll, deleteAll } from './lib/storage.js';
|
|
import { lang, setLang } from './lib/i18n.js';
|
|
import InfoPanel from './lib/InfoPanel.svelte';
|
|
import { session, authLoading, signOut } from './lib/auth.js';
|
|
import { activeFamilyId, listMyFamilies } from './lib/family.js';
|
|
import { requestedTab } from './lib/nav.js';
|
|
import AuthScreen from './lib/AuthScreen.svelte';
|
|
import FamilySwitcher from './lib/FamilySwitcher.svelte';
|
|
import FamilyManagement from './lib/FamilyManagement.svelte';
|
|
import MutawalliDashboard from './lib/MutawalliDashboard.svelte';
|
|
import FamilyTree from './lib/FamilyTree.svelte';
|
|
import InsurancePolicies from './lib/InsurancePolicies.svelte';
|
|
import ZakatCalculator from './lib/ZakatCalculator.svelte';
|
|
import JurisdictionSetting from './lib/JurisdictionSetting.svelte';
|
|
import SadaqahTracker from './lib/SadaqahTracker.svelte';
|
|
|
|
let currentLang = $state('en');
|
|
lang.subscribe(v => currentLang = v);
|
|
|
|
let currentSession = $state(null);
|
|
session.subscribe(v => currentSession = v);
|
|
let loadingAuth = $state(true);
|
|
authLoading.subscribe(v => loadingAuth = v);
|
|
|
|
let familyId = $state(null);
|
|
activeFamilyId.subscribe(v => familyId = v);
|
|
|
|
// If the stored active family isn't one this user actually belongs to
|
|
// (e.g. after switching accounts), fall back to the family picker.
|
|
let familyValid = $state(null);
|
|
$effect(() => {
|
|
if (currentSession && familyId) {
|
|
listMyFamilies().then(fams => { familyValid = fams.some(f => f.id === familyId); });
|
|
} else {
|
|
familyValid = null;
|
|
}
|
|
});
|
|
|
|
const tabs = ['Coverage', 'Faraid', 'Assets', 'Insurance', 'Zakat', 'Sadaqah', 'Wassiyah', 'Hibah', 'Family Waqf', 'Nominate', 'Trigger', 'Mutawalli', 'Tree', 'Claims (H2)', 'Family', 'Settings'];
|
|
const icons = ['🎯', '📊', '📁', '🛡️', '🌙', '🤲', '📜', '🎁', '⛲', '📇', '⚡', '🕋', '🌳', '🔗', '👥', '⚙️'];
|
|
let activeTab = $state(0);
|
|
requestedTab.subscribe(name => {
|
|
if (!name) return;
|
|
const idx = tabs.indexOf(name);
|
|
if (idx >= 0) activeTab = idx;
|
|
requestedTab.set(null);
|
|
});
|
|
|
|
function handleKeydown(e) {
|
|
if (e.key === 'ArrowRight') activeTab = (activeTab + 1) % tabs.length;
|
|
if (e.key === 'ArrowLeft') activeTab = (activeTab - 1 + tabs.length) % tabs.length;
|
|
}
|
|
|
|
function doExport() {
|
|
const data = exportAll();
|
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url; a.download = 'nur-falah-full-export.json'; a.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
function doDelete() {
|
|
if (confirm('This permanently deletes all locally stored data. This cannot be undone. Continue?')) {
|
|
deleteAll();
|
|
location.reload();
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<svelte:window onkeydown={handleKeydown} />
|
|
|
|
{#if loadingAuth}
|
|
<div class="loading-screen">Loading…</div>
|
|
{:else if !currentSession}
|
|
<AuthScreen />
|
|
{:else if !familyId || familyValid === false}
|
|
<FamilySwitcher />
|
|
{:else}
|
|
<div class="app">
|
|
<header>
|
|
<div class="header-brand">
|
|
<div class="brand-line brand-line-first">Nur</div>
|
|
<div class="brand-line brand-line-second">Falah</div>
|
|
</div>
|
|
<span class="header-tagline">{currentLang === 'ms' ? 'RANGKAIAN HARTA & WAKAF' : 'ESTATE & WAQF SUITE'}</span>
|
|
<div class="header-divider"></div>
|
|
</header>
|
|
|
|
<nav>
|
|
{#each tabs as tab, i}
|
|
<button class="tab" class:active={activeTab === i} onclick={() => activeTab = i} aria-label={tab}>
|
|
<span class="tab-icon">{icons[i]}</span>
|
|
<span class="tab-label">{tab}</span>
|
|
</button>
|
|
{/each}
|
|
</nav>
|
|
|
|
<main>
|
|
{#if activeTab === 0}<CoverageDashboard />
|
|
{:else if activeTab === 1}<FaraidCalculator />
|
|
{:else if activeTab === 2}<AssetRegistry />
|
|
{:else if activeTab === 3}<InsurancePolicies />
|
|
{:else if activeTab === 4}<ZakatCalculator />
|
|
{:else if activeTab === 5}<SadaqahTracker />
|
|
{:else if activeTab === 6}<WassiyahGenerator />
|
|
{:else if activeTab === 7}<HibahTracker />
|
|
{:else if activeTab === 8}<FamilyWaqfDesignator />
|
|
{:else if activeTab === 9}<NominationRegistry />
|
|
{:else if activeTab === 10}<DeathTrigger />
|
|
{:else if activeTab === 11}<MutawalliDashboard />
|
|
{:else if activeTab === 12}<FamilyTree />
|
|
{:else if activeTab === 13}<DigitalClaims />
|
|
{:else if activeTab === 14}<FamilyManagement />
|
|
{:else if activeTab === 15}
|
|
<div class="module">
|
|
<div class="module-header">
|
|
<h2>Settings</h2>
|
|
<InfoPanel
|
|
title="Settings"
|
|
what="Where you control your data and the app's language. Estate data (assets, Hibah, Waqf, nominations) now lives in your family's shared account, visible to you and anyone you've assigned as an estate agent."
|
|
how="Switch between English and Bahasa Malaysia here. Sign out when you're done, especially on a shared device."
|
|
fields={[]}
|
|
/>
|
|
</div>
|
|
<p class="sub">Signed in as {currentSession.user.email}.</p>
|
|
|
|
<JurisdictionSetting />
|
|
|
|
<div class="lang-switch">
|
|
<span class="lang-label">Language / Bahasa</span>
|
|
<div class="lang-buttons">
|
|
<button class="lang-btn" class:active={currentLang === 'en'} onclick={() => setLang('en')}>English</button>
|
|
<button class="lang-btn" class:active={currentLang === 'ms'} onclick={() => setLang('ms')}>Bahasa Malaysia</button>
|
|
</div>
|
|
</div>
|
|
|
|
<button class="btn-secondary" onclick={doExport}>Export local export/H2-demo data (JSON)</button>
|
|
<button class="btn-secondary" onclick={signOut}>Sign out</button>
|
|
<button class="btn-danger" onclick={doDelete}>Delete local device data — irreversible</button>
|
|
</div>
|
|
{/if}
|
|
</main>
|
|
</div>
|
|
{/if}
|
|
|
|
<style>
|
|
:global(*) { box-sizing: border-box; margin: 0; padding: 0; }
|
|
:global(body) {
|
|
font-family: 'DM Sans', sans-serif;
|
|
background: #070A0D;
|
|
color: #E8E4DC;
|
|
min-height: 100dvh;
|
|
overflow-x: hidden;
|
|
position: relative;
|
|
}
|
|
:global(body::before) {
|
|
content: '';
|
|
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
|
|
background:
|
|
radial-gradient(ellipse 80% 50% at 50% -20%, rgba(201,168,76,0.08) 0%, transparent 70%),
|
|
radial-gradient(ellipse 60% 40% at 20% 80%, rgba(46,204,113,0.05) 0%, transparent 60%);
|
|
pointer-events: none; z-index: 0;
|
|
}
|
|
:global(#app) { position: relative; z-index: 2; }
|
|
.loading-screen { display: flex; align-items: center; justify-content: center; min-height: 100dvh; color: #8A8478; font-size: 14px; }
|
|
|
|
.app { max-width: 480px; margin: 0 auto; min-height: 100dvh; display: flex; flex-direction: column; padding-bottom: 80px; }
|
|
|
|
header { text-align: center; padding: 28px 16px 16px; background: rgba(12,17,23,0.95); backdrop-filter: blur(12px); border-bottom: 1px solid rgba(201,168,76,0.15); position: sticky; top: 0; z-index: 10; }
|
|
.header-brand { display: flex; justify-content: center; gap: 8px; }
|
|
.brand-line { font-family: 'DM Serif Display', serif; font-size: 26px; }
|
|
.brand-line-first { color: #E8E4DC; }
|
|
.brand-line-second { color: #C9A84C; }
|
|
.header-tagline { font-size: 10px; letter-spacing: 2px; color: #8A8478; }
|
|
.header-divider { height: 2px; width: 40px; background: #C9A84C; margin: 10px auto 0; border-radius: 2px; }
|
|
|
|
nav { display: flex; overflow-x: auto; gap: 4px; padding: 10px 8px; background: rgba(12,17,23,0.7); border-bottom: 1px solid rgba(201,168,76,0.1); position: sticky; top: 92px; z-index: 9; }
|
|
.tab { flex-shrink: 0; display: flex; flex-direction: column; align-items: center; gap: 3px; padding: 8px 12px; background: none; border: none; border-radius: 10px; cursor: pointer; color: #8A8478; }
|
|
.tab.active { background: rgba(201,168,76,0.12); color: #C9A84C; }
|
|
.tab-icon { font-size: 18px; }
|
|
.tab-label { font-size: 10px; white-space: nowrap; }
|
|
|
|
main { flex: 1; padding: 16px; }
|
|
|
|
:global(.btn-danger) { width: 100%; padding: 12px; border-radius: 8px; border: 1px solid rgba(239,68,68,0.4); background: rgba(239,68,68,0.1); color: #EF4444; font-weight: 600; cursor: pointer; margin-top: 12px; }
|
|
:global(.btn-secondary) { width: 100%; padding: 12px; border-radius: 8px; border: none; background: rgba(255,255,255,0.08); color: #E8E4DC; font-weight: 600; cursor: pointer; }
|
|
|
|
.module-header { display: flex; align-items: center; margin-bottom: 4px; }
|
|
.lang-switch { margin-bottom: 16px; }
|
|
.lang-label { display: block; font-size: 12px; color: #B8B2A6; margin-bottom: 8px; }
|
|
.lang-buttons { display: flex; gap: 8px; }
|
|
.lang-btn { flex: 1; padding: 10px; border-radius: 8px; border: 1px solid rgba(201,168,76,0.2); background: rgba(255,255,255,0.05); color: #8A8478; font-size: 13px; cursor: pointer; }
|
|
.lang-btn.active { background: rgba(201,168,76,0.15); color: #C9A84C; border-color: rgba(201,168,76,0.4); font-weight: 600; }
|
|
</style>
|