14 Commits

Author SHA1 Message Date
wmj a0ecb18a57 docs: surface the demo neighbourhood join code (ALFALAH) at the top of DEMO_DATA.md
Was documented but buried at the bottom of the file — moved a summary
up top since it's the fastest way to see the Event Board feature with
real content: sign in as any demo owner, Family -> Neighbourhood, done.
2026-08-14 16:55:27 +08:00
wmj 5b4a05afb2 feat: add Event Board to the Neighbourhood community board + dummy content
Researched mosque-app and community-board best practices (MadinaAPPS,
Nextdoor) before building: the pattern that works is one-tap RSVP with
what/who/where/when up front, plus a clean separation between plain
announcements and dated events. Deliberately did NOT auto-populate a
global Islamic calendar (Eid/Ramadan dates) — those are moon-sighting-
dependent and hardcoding one as fact would be exactly the kind of
fabrication this app avoids everywhere else (real Overpass data, real
Quran API, no invented listings). The Event Board is community-submitted
only: a mosque posting its own Eid prayer time is real information: this
app guessing the date isn't.

Implementation extends the existing Neighbourhood board rather than
building a parallel system: nf_neighbourhood_posts gains is_event/
event_date/event_location, plus a new nf_event_rsvps table (RLS reuses
the existing nf_is_neighbourhood_member helper via a join, no duplicated
logic). UI adds an event toggle on the post form (reveals date/time/
location), a 📅-badged event card with one-tap 'I'm going' RSVP + live
count, and an All/Events/Announcements filter with events sorted
soonest-first.

Seeded a real demo neighbourhood, 'Masjid Al-Falah Kariah' (join code
ALFALAH), with all 5 demo family owners plus a mutawalli as members: 3
announcements and 4 dated, RSVP'd events (Jumu'ah khutbah, weekly Quran
circle, an Islamic finance/estate-planning talk tying back to the app's
own core purpose, and a Ramadan iftar potluck). Caught and fixed two
seeding bugs during verification: timestamps stored without timezone
context displayed several hours off from the intended Malaysia-local
time, and a 'Saturday' event that actually landed on a Sunday — both
fixed by computing against real weekdays/timezone rather than guessing
offsets.

Covered by e2e-event-board.cjs (14/14), including live verification that
the seeded demo neighbourhood renders its real content. Full regression:
all suites pass.
2026-08-14 16:53:40 +08:00
wmj 22ad077dab feat: add Support tab — donations + co-branding/white-label contact
New tab under the Giving hub, alongside Zakat/Sadaqah/Khairat. Two parts:

Donations: amount presets + custom amount, four frequencies (One-Time,
Monthly, Quarterly, Yearly), each mapped to its own live Polar.sh
checkout link (merchant of record — this app never touches payment
details, same 'log intent, don't move money' principle used everywhere
else). Design/copy modeled on the sibling moslem03.falahos.my app's
existing Support tab, per direct reference.

No donation product existed on the Polar account yet, so created one
via the Polar API with the user's explicit go-ahead: 'Support Nur
Falah' as 4 pay-what-you-want products (one-time + monthly/quarterly/
yearly recurring, quarterly via recurring_interval=month with count=3
since Polar has no native quarterly interval), plus a hosted checkout
link for each. Verified all four resolve to genuinely distinct, live
Stripe-backed checkout sessions before shipping — moslem03's own
checkout.polar.sh URL pattern turned out to be stale/non-resolving;
the current correct domain is buy.polar.sh via the /v1/checkout-links
API, discovered from Polar's live OpenAPI spec rather than guessed.

Partnership section: co-branding and white-label pitch with the VP
Sales contact info given directly — info@falahos.my and WhatsApp
+60132250691 — reachable via a 'View Partnership Opportunities' link
from the donation card, matching moslem03's UX pattern of surfacing
partnership discovery from the support flow.

Covered by e2e-support.cjs (14/14) — including opening all four
checkout links live and confirming each is a genuinely distinct,
resolving Stripe checkout session, not a dead or placeholder link.
Full regression: all suites pass (one confirmed transient flake on
rerun, unrelated to this change).
2026-08-14 16:24:42 +08:00
wmj 9cb0793347 fix: Qibla not detecting device compass on iOS and Android
Two real bugs, both explaining 'didn't detect compass':

1. iOS: DeviceOrientationEvent.requestPermission() was called *after*
   the async geolocation round-trip resolved. Safari's user-activation
   grant for sensor permissions expires almost immediately, so by the
   time a GPS fix comes back (often several seconds), the permission
   call silently fails with no prompt ever shown. Now requested
   synchronously inside the button's click handler, before geolocation
   starts.

2. Android: the heading was only accepted when e.absolute === true, but
   many Android browsers fire plain 'deviceorientation' with
   absolute:false even though alpha is a perfectly usable heading —
   compass detection silently never succeeded on those devices at all.
   Now accepts alpha regardless of the absolute flag, falling back to
   it when webkitCompassHeading (iOS) isn't present.

Also fixed a duplicate-listener bug: clicking 'Refresh location'
repeatedly re-registered deviceorientation listeners without removing
the previous ones, stacking duplicates on every retry.

Verified live: dispatched a synthetic non-absolute deviceorientation
event (the exact Android failure shape) against the deployed app —
compass detection now activates correctly where it previously stayed
silently stuck on the no-sensor fallback. Added as a permanent
regression assertion in e2e-khairat-lifestyle.cjs (16/16).
2026-08-14 15:23:17 +08:00
wmj 23f32872a5 fix: Locate returning 429/504 and silently-empty halal results
The Locate tab (mosque/halal/cemetery) hardcoded a single public
Overpass API endpoint. Public Overpass instances are individually
flaky under load — confirmed live: the same query returned 429 from
one mirror and 504 from the primary a few minutes apart, with no
retry or fallback, surfacing as a hard failure to the user.

Added a short fallback chain (overpass-api.de, then
overpass.kumi.systems) that retries on 429/503/504 and only reports
failure once every mirror has failed, with an honest 'try again
shortly' message distinct from a genuine empty-results state.

A third mirror (overpass.osm.ch) was tried too but dropped after
finding something worse than a failure: it returned a 'successful'
200 with 0 results for a query the other two mirrors correctly answer
with 30 (diet:halal=yes near Kuala Lumpur) — a stale/incomplete
regional replica that would have short-circuited the fallback loop
and silently told users nothing was nearby when it actually was.

e2e-khairat-lifestyle.cjs gains a tightened assertion (halal search
must return >0 results at a known-good test coordinate, not just
'results or empty') to catch this exact silently-wrong-mirror class of
bug in the future, plus a filter so expected fallback-path 429/504s
don't fail the 'no console errors' check — those are the retry
mechanism working, not a bug. Verified live against the deployed app:
30 real results returned.
2026-08-14 15:14:48 +08:00
wmj 33a821e61d refactor: restructure navigation into 5 grouped hubs, off the 22-item flat strip
Implements the researched IA fix: mobile nav UX consensus caps primary
destinations at 4-5 (uxpin.com, fintech/banking 2026 UX research), and
this app had grown to 22 tabs in one horizontally-scrolling row —
exactly the anti-pattern that research flags for choice paralysis and
slower task completion.

New structure — 5 bottom-nav hubs, grouped by what the user is actually
trying to do, not build order:
- Home: Coverage (unchanged, still the landing screen)
- Estate: Assets, Faraid, Insurance, Wassiyah, Hibah, Family Waqf,
  Nominate, Claims (H2)
- Giving: Zakat, Sadaqah, Khairat
- Family: Tree, Trigger, Mutawalli, Manage (was 'Family', renamed to
  avoid colliding with the hub's own label), Neighbourhood
- Daily: Prayer Times, Qibla, Quran, Locate

Each hub reveals its own sub-nav one tap in, instead of every tab
competing for space in a single row. Settings moved out of the tab
strip entirely into a header gear icon, matching the banking-app pattern
of keeping settings out of primary thumb-reach real estate. The bottom
nav is now fixed (thumb-zone), sub-nav keeps the old sticky-top position.

Cross-component navigation (nav.js requestedTab, used by the Family
Tree's 'give sadaqah in memory' link) now resolves a tab label to its
owning (hub, sub-tab) pair instead of a flat index — verified live.

This touches every E2E suite: a single click on a tab's old selector no
longer reaches it (hub, then sub-tab). Added a shared gotoTab(page, label)
helper to e2e-auth-helper.cjs encapsulating the two-step navigation, and
migrated all ~22 affected test files off direct nav-button selectors —
mechanical substitution followed by manual fixes for local clickTab
wrappers, template-literal selectors, and active-state assertions that
needed to target the new .hub-tab/.subnav structure specifically.

Full regression after migration: every suite passes (one isolated
Family Tree flake confirmed clean on rerun, unrelated to navigation).
2026-08-14 14:55:09 +08:00
wmj 78e025c27d docs: update personas/journeys with everything built today
Adds a 7th persona (Emergency/Community Contact — mosque, khairat
officer, police, ambulance, notified automatically the moment a trigger
fires) and extends every existing journey with what shipped since the
last version: Zakat, Khairat, Sadaqah, jurisdiction/professional review,
draft will PDF, and the prayer-times/Qibla/locate/Quran/neighbourhood
lifestyle tools. New footnote clarifying Neighbourhood is scoped to the
account, not the estate-planning family.
2026-08-14 14:28:35 +08:00
wmj 2549e9de0c feat: add Khairat, emergency-contact auto-notify, and 5 lifestyle-companion tabs
Big round covering two different asks: Khairat/emergency infrastructure
(tightly coupled to what's already built) and a Muslim-lifestyle
companion surface (a genuinely different product, added at the user's
explicit request after being offered a smaller scope).

Khairat & emergency contacts:
- nf_khairat_memberships (per-member scheme membership) + nf_emergency_fund
  (per-family shared reserve tracker) — records intent/contacts only,
  never holds or moves real money.
- nf_trusted_contacts gains category (mosque/police/ambulance/hospital/
  khairat/family/other) and email columns.
- New notify-emergency-contacts Edge Function, auto-invoked the moment a
  mutawalli fires a member's death trigger (the one place in this app
  where auto-send-on-trigger is actually correct), alongside — not
  instead of — the existing heir notification. Verified with a live fire
  end-to-end (e2e-emergency-notify.cjs, 5/5).

Lifestyle-companion tabs — all computed/searched live, nothing fabricated:
- Qibla: pure great-circle bearing math to the Kaaba from geolocation,
  no API/key. Verified against Kuala Lumpur (293°, matches expected ~292°).
- Prayer Times: client-side astronomical calculation (single-pass solar
  position, MWL angles), sanity-checked against known KL/London times
  before shipping.
- Locate: mosque/halal/cemetery search via the free, keyless OpenStreetMap
  Overpass API — real community-sourced results only, honest empty state
  when nothing's mapped nearby.
- Quran: Surah list + Arabic/translation via the free alquran.cloud API,
  fetched fresh each time, nothing stored in this app's own database.
- Neighbourhood: a join-by-code community announcement board — a
  genuinely separate multi-tenant concept from the estate-planning family
  structure, scoped to the user account. Found and fixed a real UX gap
  during testing: the create/join form was only reachable with zero
  existing neighbourhoods, with no way to join a second one.

Also found and fixed a real bug: the heir-notify and emergency-notify
status messages shared the same CSS class, breaking any script (including
the pre-existing e2e-per-member.cjs) that targeted '.notify-status'
without further filtering — gave each its own distinguishing class.

Covered by e2e-khairat-lifestyle.cjs (13/13) and e2e-emergency-notify.cjs
(5/5). Full regression: 316/316 across all suites (several transient
flakes under heavy mail-relay load during the sweep, all confirmed clean
on rerun — one led to the real class-collision fix above).
2026-08-14 14:25:26 +08:00
wmj 7ce1121b94 feat: add stickiness round — daily Sadaqah tracker + Family Tree social loop
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.
2026-08-14 13:42:36 +08:00
wmj 9767c0d626 docs: add personas and user journey map
Six personas mapped against the app's actual roles and features: Head of
Family (owner), Family Member, Estate Agent/Mutawalli, the multi-family
professional-mutawalli variant, plus two no-account personas reached only
through data others enter — the Heir/Beneficiary (notified by the
notify-heirs email relay once a trigger fires) and the Professional
Reviewer (tracked via the Wassiyah review-request workflow). Each journey
is stage-by-stage through real tabs, not generic template steps.
2026-08-14 12:52:31 +08:00
wmj cc8288f127 docs: date the competitive benchmark (2026-08-14) 2026-08-14 12:38:24 +08:00
wmj 97a1d2ebe2 feat: close final competitive gap — professional review workflow + multi-country (MY/SG/UK)
Closes the 4th and final gap from the competitive benchmark: the
human-in-the-loop professional tier every commercial competitor pairs
with their software. Deliberately NOT a marketplace or payment
integration — a structured review-request status on each member's
Wassiyah (not_requested -> requested -> reviewed, with reviewer name
recorded), surfaced on the Mutawalli dashboard so nothing quietly ships
as final without the legally-required review being flagged.

Paired with a new whole-app jurisdiction setting since 'necessary in
certain countries' only makes sense per-jurisdiction:

- nf_families.jurisdiction (MY/SG/UK), owner-only to change — first
  UPDATE policy ever added to nf_families, which previously had none.
- New Settings-tab jurisdiction selector (JurisdictionSetting.svelte).
- Wassiyah tab and the draft will document now carry jurisdiction-
  specific legal notes: Wills Act 1959 + state Syariah (Malaysia),
  Wills Act 1838 + AMLA (Singapore), Wills Act 1837 (UK). Singapore
  added as a full third jurisdiction option, not just a stub.
- Zakat calculator's currency label and Nisab default now follow the
  family's jurisdiction (RM/SGD/GBP) instead of a hardcoded RM guess.

Covered by e2e-jurisdiction-review.cjs (13/13). Full regression:
253/253 across all suites (2 reruns confirmed as pre-existing
parallel-load flakes, not regressions).
2026-08-14 12:37:25 +08:00
wmj 71aa4ae47c feat: close 3 more competitive gaps — recommendations engine, multi-madhab Faraid, draft will PDF
Closes 3 of the remaining 4 gaps from the competitive benchmark:

- Coverage Dashboard now has a rule-based recommendations engine
  (recommendations.js) surfacing plain-language next steps from data
  already in the app — exposed assets, missing Wassiyah, unverified
  assets, unlinked liabilities, missing insurance/Zakat setup,
  insufficient attestors, no agent assigned, no Waqf configured.

- Faraid Calculator gains a madhab selector (Shafi'i/Hanafi/Maliki/
  Hanbali) encoding the one well-documented divergence this engine's
  existing rules touch: whether radd extends to a sole-heir spouse
  (Hanafi: yes; Shafi'i/Maliki/Hanbali: no, residue unallocated).
  Ja'fari (Shia) is honestly gated as unsupported rather than silently
  computed with Sunni rules, since it's a structurally different
  classification system, not a parameter tweak. faraid.test.js grows
  from 14 to 17 cases covering the divergence and the gating.

- Wassiyah tab gains a 'Generate draft will document' button producing
  a formatted, statutory-style DRAFT will (declaration/revocation,
  executor appointment, bequest schedule, witness attestation blocks)
  via browser print-to-PDF — no new PDF dependency. Clearly watermarked
  'DRAFT — NOT EXECUTED, requires physical signing and witnessing.'
  Not a claim of legal validity, a lawyer-reviewable starting point.

The 4th gap (human-in-the-loop professional tier) remains open — out
of scope for a self-serve prevention tool. COMPETITIVE_BENCHMARK.md
updated to reflect closed/partially-closed status on each item.

Covered by e2e-gaps-round2.cjs (14/14) plus 3 new faraid.test.js cases.
Full regression: 226/226 across all suites.
2026-08-14 12:15:54 +08:00
wmj 972ad7ff68 feat: add Zakat calculator (per-member, Nisab + 2.5% rate)
Closes the Zakat gap identified in the competitive benchmark against
Rafiq. Per-member module (nf_zakat_records, same author-owns/family-reads
RLS pattern as Insurance/Wassiyah/Waqf) computing 2.5% due on zakatable
wealth (cash, gold, silver, business assets, investments) once above a
user-entered Nisab threshold, minus deductible short-term liabilities.
Manual entry only, matching the app's existing philosophy — no live gold
price feed. Covered by e2e-zakat.cjs (7/7).
2026-08-14 11:55:59 +08:00
54 changed files with 3964 additions and 161 deletions
+32 -14
View File
@@ -1,5 +1,7 @@
# Nur Falah vs. 5 Islamic Estate-Planning Competitors
Last updated: 2026-08-14
Compared against: Legacy Logic / Al Yusra+ (Malaysia, AI-powered), MyPusaka / Wasiyyah Shoppe
(Malaysia, legacy incumbent), My Islamic Wills (UK), True Wills (UK), Rafiq Islamic Finance OS
(US/global fintech).
@@ -45,21 +47,37 @@ differentiators:
- **Coverage %, genealogy tree, insurance/liabilities, asset verification** — this granularity
does not exist anywhere else in the set.
## Gaps to close
## Gaps — status
1. **No legally-filable Will output.** The single biggest gap — every commercial competitor's
core deliverable is a document that can be taken to a lawyer/court/Amanah Raya. Nur Falah
generates coverage data and "execution packets," not a notarizable legal instrument.
2. **No Zakat calculator.** Increasingly table-stakes for this category (Rafiq's core pitch),
and a natural extension of the existing Asset Registry.
3. **Single madhab assumption.** The Faraid calculator doesn't expose or let a user pick a
school of jurisprudence — Rafiq's explicit 5-school support is a credibility signal worth
matching for a global (not just Malaysia) audience.
4. **No human-in-the-loop / professional tier.** Every competitor pairs software with a lawyer
or consultant.
5. **No AI guidance layer.** Legacy Logic's "personalized report" and Rafiq's contextual
coaching both nudge users toward gaps in plain language; Nur Falah's Coverage Dashboard shows
the gap but doesn't recommend what to do about it beyond static info panels.
1. **Legally-filable Will output — partially closed.** Wassiyah tab now generates a formatted,
statutory-style DRAFT will document (declaration/revocation, executor appointment, bequest
schedule, witness attestation blocks) via a print-to-PDF flow, clearly watermarked "DRAFT —
NOT EXECUTED." This is a real time-saver for a lawyer to review and finalize, not a claim
that the app produces a legally executed document — that still requires physical signing,
witnessing, and jurisdiction-specific legal review. A fully legally-valid, notarized/filed
output remains out of scope.
2. **Zakat calculator — closed.** New per-member Zakat tab: Nisab check + 2.5% on cash, gold,
silver, business assets, and investments, minus deductible liabilities.
3. **Single madhab assumption — partially closed.** Faraid calculator now has a madhab
selector (Shafi'i/Hanafi/Maliki/Hanbali) encoding the one well-documented divergence this
engine's rules actually touch — whether radd (return of residue) extends to a sole-heir
spouse. Ja'fari (Shia) is deliberately gated as unsupported rather than silently computed
with Sunni rules, since it uses a structurally different classification system, not a
parameter tweak.
4. **Human-in-the-loop / professional tier — closed, as a review-request workflow.** Not a
marketplace or payment integration (out of scope) — a structured status on each member's
Wassiyah (Not reviewed → Review requested → Reviewed, with reviewer name recorded), surfaced
on the Mutawalli dashboard so nothing quietly ships as final without the legally-required
review being flagged. Pairs with a new whole-app jurisdiction setting (Malaysia/Singapore/UK,
owner-configurable in Settings) that drives jurisdiction-specific legal notes in both the
Wassiyah tab and the generated draft will document (Wills Act 1959/state Syariah for
Malaysia, Wills Act 1838 + AMLA for Singapore, Wills Act 1837 for the UK) and Zakat
currency/Nisab defaults.
5. **AI guidance layer — closed.** Coverage Dashboard now surfaces a rule-based
recommendations list (exposed assets, missing Wassiyah, unverified assets, unlinked
liabilities, no insurance logged, no Zakat set up, insufficient attestors, no agent
assigned, no Waqf configured) — every recommendation traces to a concrete fact already in
the app's data, not a generated guess.
## Sources
+19
View File
@@ -3,6 +3,14 @@
Seeded directly against the live Supabase backend (not through the UI) for
speed. All accounts share the password `DemoPassword123!`.
**Demo Neighbourhood join code: `ALFALAH`** — "Masjid Al-Falah Kariah",
already joined by all 5 demo owners below. Sign in as any of them, go to
Family → Neighbourhood, and the board is already populated (see
[Demo Neighbourhood](#demo-neighbourhood--masjid-al-falah-kariah) below for
what's seeded). To join from a *fresh* (non-demo) account instead, use
Family → Neighbourhood → "+ Join or start another neighbourhood" → enter
`ALFALAH`.
## Families
| Family | Owner login | Assets | Coverage | Notable features |
@@ -56,3 +64,14 @@ Three additions, covered by `e2e-insurance-verification.cjs` (12/12):
- All proof documents (policies, liabilities, assets) share one private
Storage bucket, `nf-asset-documents`, with the same family-membership RLS
pattern used for person photos.
## Demo Neighbourhood — "Masjid Al-Falah Kariah"
Join code `ALFALAH`. All 5 demo family owners plus `nf.demo.mutawalli@gmail.com`
(as "Ustaz Hafiz") are members — reachable from any demo account via
Family → Neighbourhood. Seeded with 3 plain announcements and 4 dated,
RSVP'd events (Jumu'ah khutbah, weekly Quran circle, an Islamic
finance/estate-planning talk, and a Ramadan iftar potluck), so the board
and Event filter are never empty for a demo. Event dates are computed
relative to "today" (real Fridays/Saturdays), not hardcoded — reseed with
fresh relative dates if this demo goes stale.
+524
View File
@@ -0,0 +1,524 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Nur Falah — Personas &amp; Journeys</title>
<style>
:root {
--bg: #14171c;
--surface: #1b1f26;
--surface-2: #20252d;
--ink: #e8e4da;
--muted: #8d8879;
--line: rgba(232,228,218,0.10);
--line-strong: rgba(232,228,218,0.18);
--accent: #b4884a;
--accent-soft: rgba(180,136,74,0.14);
--accent-2: #4e7a6e;
--accent-2-soft: rgba(78,122,110,0.16);
--good: #5fa876;
--warn: #c99a4a;
}
:root[data-theme="light"] {
--bg: #f3f0e8;
--surface: #fbfaf5;
--surface-2: #ffffff;
--ink: #1f1c14;
--muted: #6b6656;
--line: rgba(31,28,20,0.10);
--line-strong: rgba(31,28,20,0.20);
--accent: #96703a;
--accent-soft: rgba(150,112,58,0.10);
--accent-2: #3c6459;
--accent-2-soft: rgba(60,100,89,0.10);
--good: #3c7a52;
--warn: #97731f;
}
@media (prefers-color-scheme: light) {
:root:not([data-theme="dark"]) {
--bg: #f3f0e8;
--surface: #fbfaf5;
--surface-2: #ffffff;
--ink: #1f1c14;
--muted: #6b6656;
--line: rgba(31,28,20,0.10);
--line-strong: rgba(31,28,20,0.20);
--accent: #96703a;
--accent-soft: rgba(150,112,58,0.10);
--accent-2: #3c6459;
--accent-2-soft: rgba(60,100,89,0.10);
--good: #3c7a52;
--warn: #97731f;
}
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--ink);
font-family: -apple-system, "Segoe UI", "Helvetica Neue", Arial, sans-serif;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
}
.wrap { max-width: 980px; margin: 0 auto; padding: 56px 24px 96px; }
header.page-head { margin-bottom: 48px; }
.eyebrow {
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-size: 11.5px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent);
margin-bottom: 14px;
}
h1 {
font-family: Georgia, "Iowan Old Style", "Palatino Linotype", "Book Antiqua", serif;
font-weight: 400;
font-size: clamp(30px, 4vw, 42px);
line-height: 1.15;
letter-spacing: -0.01em;
text-wrap: balance;
margin: 0 0 14px;
}
.lede {
max-width: 62ch;
color: var(--muted);
font-size: 15.5px;
}
.legend { display: flex; gap: 22px; flex-wrap: wrap; margin-top: 28px; padding-top: 22px; border-top: 1px solid var(--line); }
.legend-item { display: flex; align-items: center; gap: 8px; font-size: 12.5px; color: var(--muted); }
.swatch { width: 10px; height: 10px; border-radius: 3px; flex-shrink: 0; }
.swatch.in-app { background: var(--accent); }
.swatch.external { background: var(--accent-2); }
.persona-grid { display: grid; grid-template-columns: 1fr; gap: 28px; }
.persona {
background: var(--surface);
border: 1px solid var(--line-strong);
border-radius: 14px;
padding: 30px 32px;
position: relative;
overflow: hidden;
}
.persona::before {
content: '';
position: absolute; top: 0; left: 0; width: 4px; height: 100%;
background: var(--rail, var(--accent));
}
.persona.external { border-style: dashed; }
.persona.external::before { background: var(--accent-2); }
.persona-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; margin-bottom: 8px; flex-wrap: wrap; }
.persona-name {
font-family: Georgia, "Iowan Old Style", "Palatino Linotype", serif;
font-size: 22px;
font-weight: 400;
margin: 0;
}
.persona-tag {
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-size: 10.5px;
letter-spacing: 0.06em;
text-transform: uppercase;
padding: 4px 9px;
border-radius: 999px;
white-space: nowrap;
background: var(--tag-bg, var(--accent-soft));
color: var(--tag-fg, var(--accent));
}
.persona.external .persona-tag { background: var(--accent-2-soft); color: var(--accent-2); }
.persona-goal { color: var(--muted); font-size: 14px; max-width: 68ch; margin: 0 0 22px; }
.persona-goal strong { color: var(--ink); font-weight: 600; }
.journey { display: flex; flex-direction: column; gap: 0; }
.step { display: grid; grid-template-columns: 22px 1fr; gap: 16px; }
.step-rail { display: flex; flex-direction: column; align-items: center; }
.step-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--rail, var(--accent)); margin-top: 5px; flex-shrink: 0; }
.persona.external .step-dot { background: var(--accent-2); }
.step-line { width: 1px; flex: 1; background: var(--line-strong); margin-top: 2px; }
.step:last-child .step-line { display: none; }
.step-body { padding-bottom: 20px; }
.step:last-child .step-body { padding-bottom: 0; }
.step-title { font-size: 14px; font-weight: 600; margin: 0 0 3px; }
.step-detail { font-size: 13px; color: var(--muted); margin: 0; }
.touchpoints { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.tp {
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-size: 10.5px;
padding: 3px 8px;
border-radius: 6px;
background: var(--surface-2);
border: 1px solid var(--line);
color: var(--ink);
}
.outcome {
margin-top: 22px;
padding: 14px 16px;
border-radius: 10px;
background: var(--accent-soft);
font-size: 13px;
}
.persona.external .outcome { background: var(--accent-2-soft); }
.outcome-label { font-family: ui-monospace, monospace; font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted); display: block; margin-bottom: 4px; }
.footnote {
margin-top: 48px;
padding: 22px 26px;
border: 1px solid var(--line-strong);
border-radius: 14px;
background: var(--surface);
}
.footnote h2 { font-family: Georgia, serif; font-weight: 400; font-size: 17px; margin: 0 0 8px; }
.footnote p { font-size: 13.5px; color: var(--muted); margin: 0; max-width: 70ch; }
@media (max-width: 640px) {
.wrap { padding: 36px 16px 72px; }
.persona { padding: 22px 18px; }
}
</style>
</head>
<body>
<div class="wrap">
<header class="page-head">
<div class="eyebrow">Nur Falah · Prevention Suite · updated 2026-08-14</div>
<h1>Seven people, one estate — mapped end to end</h1>
<p class="lede">Every role the app actually has, from the head of family logging their first asset to the mosque contact who's emailed automatically the moment a death trigger fires. Journeys are built from what's implemented today, not aspirational flows — updated with Zakat, Sadaqah, Khairat, jurisdiction/professional review, and the prayer-times/Qibla/locate/Quran/neighbourhood tools added since the first version of this map.</p>
<div class="legend">
<span class="legend-item"><span class="swatch in-app"></span>Has a Nur Falah account</span>
<span class="legend-item"><span class="swatch external"></span>Never signs in — reached by data others enter</span>
</div>
</header>
<div class="persona-grid">
<!-- 1: OWNER -->
<div class="persona" style="--rail:#b4884a; --tag-bg:rgba(180,136,74,0.16); --tag-fg:#b4884a;">
<div class="persona-head">
<h2 class="persona-name">The Head of Family</h2>
<span class="persona-tag">Owner</span>
</div>
<p class="persona-goal">Get everything they own arranged to skip the slow Faraid/probate queue, before anyone needs it to.</p>
<div class="journey">
<div class="step">
<div class="step-rail"><div class="step-dot"></div><div class="step-line"></div></div>
<div class="step-body">
<p class="step-title">Creates the family and sets jurisdiction</p>
<p class="step-detail">Signs up, starts a family, picks Malaysia/Singapore/UK once in Settings — this quietly drives every legal note downstream.</p>
<div class="touchpoints"><span class="tp">Auth</span><span class="tp">Family Switcher</span><span class="tp">Settings</span></div>
</div>
</div>
<div class="step">
<div class="step-rail"><div class="step-dot"></div><div class="step-line"></div></div>
<div class="step-body">
<p class="step-title">Logs everything they own</p>
<p class="step-detail">Property, cash, vehicles, business interests, digital assets — each with ownership share, then attaches proof (title, grant, cover note) for later verification.</p>
<div class="touchpoints"><span class="tp">Assets</span></div>
</div>
</div>
<div class="step">
<div class="step-rail"><div class="step-dot"></div><div class="step-line"></div></div>
<div class="step-body">
<p class="step-title">Sees the gap, not just the data</p>
<p class="step-detail">Coverage Dashboard shows the exposed percentage; the recommendations list turns that into a specific next tab to open.</p>
<div class="touchpoints"><span class="tp">Coverage</span><span class="tp">Faraid</span></div>
</div>
</div>
<div class="step">
<div class="step-rail"><div class="step-dot"></div><div class="step-line"></div></div>
<div class="step-body">
<p class="step-title">Closes the gap with the right instrument</p>
<p class="step-detail">Hibah for immediate lifetime gifts, Family Waqf for a dedicated corpus, Nomination Registry for EPF/Takaful/bank mandates/trusts/business continuity/digital custody.</p>
<div class="touchpoints"><span class="tp">Hibah</span><span class="tp">Family Waqf</span><span class="tp">Nominate</span></div>
</div>
</div>
<div class="step">
<div class="step-rail"><div class="step-dot"></div><div class="step-line"></div></div>
<div class="step-body">
<p class="step-title">Writes the discretionary third</p>
<p class="step-detail">Bequests non-heirs within the one-third cap, generates a draft will document, and requests a professional's review before treating it as final.</p>
<div class="touchpoints"><span class="tp">Wassiyah</span></div>
</div>
</div>
<div class="step">
<div class="step-rail"><div class="step-dot"></div><div class="step-line"></div></div>
<div class="step-body">
<p class="step-title">Rounds out the picture</p>
<p class="step-detail">Logs Takaful/insurance policies, runs the Zakat check, records a Khairat scheme membership and a shared emergency fund target, builds the family tree with photos, and sets up their own death-trigger attestors — though they can never fire their own trigger.</p>
<div class="touchpoints"><span class="tp">Insurance</span><span class="tp">Zakat</span><span class="tp">Khairat</span><span class="tp">Tree</span><span class="tp">Trigger</span></div>
</div>
</div>
<div class="step">
<div class="step-rail"><div class="step-dot"></div><div class="step-line"></div></div>
<div class="step-body">
<p class="step-title">Adds who to call, not just who inherits</p>
<p class="step-detail">Tags trusted contacts by category — mosque, khairat officer, police, ambulance/hospital — with an email address, so the right people are notified automatically the moment a trigger fires, not just the heirs.</p>
<div class="touchpoints"><span class="tp">Assets</span></div>
</div>
</div>
<div class="step">
<div class="step-rail"><div class="step-dot"></div><div class="step-line"></div></div>
<div class="step-body">
<p class="step-title">Delegates</p>
<p class="step-detail">Invites a mutawalli/agent and other adult family members, so the estate outlives any one person managing it alone.</p>
<div class="touchpoints"><span class="tp">Family</span></div>
</div>
</div>
<div class="step">
<div class="step-rail"><div class="step-dot"></div></div>
<div class="step-body">
<p class="step-title">Comes back for reasons that aren't death</p>
<p class="step-detail">Logs daily Sadaqah and keeps a giving streak, checks prayer times and Qibla, reads Quran, and finds a nearby mosque or halal option — the same app, opened for entirely different reasons than the estate plan that brought them here.</p>
<div class="touchpoints"><span class="tp">Sadaqah</span><span class="tp">Prayer Times</span><span class="tp">Qibla</span><span class="tp">Quran</span><span class="tp">Locate</span></div>
</div>
</div>
</div>
<div class="outcome"><span class="outcome-label">Outcome</span>A documented, mostly-covered estate with a trustee already in place, plus a reason to open the app on a day nothing in the estate changed.</div>
</div>
<!-- 2: MEMBER -->
<div class="persona" style="--rail:#b4884a; --tag-bg:rgba(180,136,74,0.16); --tag-fg:#b4884a;">
<div class="persona-head">
<h2 class="persona-name">The Family Member</h2>
<span class="persona-tag">Member</span>
</div>
<p class="persona-goal">Handle their own share of the estate planning without needing to run the whole family's affairs.</p>
<div class="journey">
<div class="step">
<div class="step-rail"><div class="step-dot"></div><div class="step-line"></div></div>
<div class="step-body">
<p class="step-title">Accepts an invite</p>
<p class="step-detail">Joins a family the owner already created — no family of their own to set up.</p>
<div class="touchpoints"><span class="tp">Pending Invites</span></div>
</div>
</div>
<div class="step">
<div class="step-rail"><div class="step-dot"></div><div class="step-line"></div></div>
<div class="step-body">
<p class="step-title">Sees the shared estate, adds to it</p>
<p class="step-detail">Views family-wide Assets, Hibah, and Nominations; can add assets and confirm another member's asset as a second witness — but can't manage who's in the family.</p>
<div class="touchpoints"><span class="tp">Assets</span></div>
</div>
</div>
<div class="step">
<div class="step-rail"><div class="step-dot"></div><div class="step-line"></div></div>
<div class="step-body">
<p class="step-title">Authors their own documents</p>
<p class="step-detail">Their Wassiyah, Waqf, Insurance, Zakat, and Khairat membership are theirs alone — visible to the mutawalli for execution, invisible to other members. Can request a professional review of their Wassiyah and generate a jurisdiction-aware draft will.</p>
<div class="touchpoints"><span class="tp">Wassiyah</span><span class="tp">Family Waqf</span><span class="tp">Insurance</span><span class="tp">Zakat</span><span class="tp">Khairat</span></div>
</div>
</div>
<div class="step">
<div class="step-rail"><div class="step-dot"></div><div class="step-line"></div></div>
<div class="step-body">
<p class="step-title">Adds themselves to the tree</p>
<p class="step-detail">Places themselves and their relatives in the family genealogy, with photos where they have them — and can dedicate Sadaqah in memory of a deceased relative right from that person's card.</p>
<div class="touchpoints"><span class="tp">Tree</span><span class="tp">Sadaqah</span></div>
</div>
</div>
<div class="step">
<div class="step-rail"><div class="step-dot"></div></div>
<div class="step-body">
<p class="step-title">Uses it like a daily companion, not just a filing cabinet</p>
<p class="step-detail">Keeps a Sadaqah streak, checks prayer times and Qibla, reads Quran, and posts on the mosque neighbourhood board — all independent of whatever estate role they hold in this family.</p>
<div class="touchpoints"><span class="tp">Prayer Times</span><span class="tp">Qibla</span><span class="tp">Quran</span><span class="tp">Neighbourhood</span></div>
</div>
</div>
</div>
<div class="outcome"><span class="outcome-label">Outcome</span>Their personal instruments exist under the family umbrella, isolated from everyone else's — and they have reasons to open the app that have nothing to do with the family estate at all.</div>
</div>
<!-- 3: MUTAWALLI -->
<div class="persona" style="--rail:#b4884a; --tag-bg:rgba(180,136,74,0.16); --tag-fg:#b4884a;">
<div class="persona-head">
<h2 class="persona-name">The Estate Agent / Mutawalli</h2>
<span class="persona-tag">Agent</span>
</div>
<p class="persona-goal">Execute what the family already planned, exactly when it's needed — and never for themselves.</p>
<div class="journey">
<div class="step">
<div class="step-rail"><div class="step-dot"></div><div class="step-line"></div></div>
<div class="step-body">
<p class="step-title">Accepts the mutawalli invite</p>
<p class="step-detail">Joins with agent-level access; switches into this family from any others they already serve.</p>
<div class="touchpoints"><span class="tp">Pending Invites</span><span class="tp">Family Switcher</span></div>
</div>
</div>
<div class="step">
<div class="step-rail"><div class="step-dot"></div><div class="step-line"></div></div>
<div class="step-body">
<p class="step-title">Reviews each member's documents</p>
<p class="step-detail">Picks a member from the chip list, reads their Wassiyah, Waqf, and insurance, and checks whether professional review has been requested or completed.</p>
<div class="touchpoints"><span class="tp">Mutawalli</span></div>
</div>
</div>
<div class="step">
<div class="step-rail"><div class="step-dot"></div><div class="step-line"></div></div>
<div class="step-body">
<p class="step-title">Sets up the trigger for that member</p>
<p class="step-detail">Executor name, death certificate reference, two attestors who each confirm independently — never for the member who happens to be themselves.</p>
<div class="touchpoints"><span class="tp">Mutawalli</span></div>
</div>
</div>
<div class="step">
<div class="step-rail"><div class="step-dot"></div><div class="step-line"></div></div>
<div class="step-body">
<p class="step-title">Fires it — heirs and emergency contacts both get word</p>
<p class="step-detail">Once both attestors confirm, fires the trigger. That single action automatically emails the tagged mosque/khairat/police/ambulance contacts, and a separate button sends the heir-notification email — real sends, not previews.</p>
<div class="touchpoints"><span class="tp">Mutawalli</span><span class="tp">Trigger</span></div>
</div>
</div>
<div class="step">
<div class="step-rail"><div class="step-dot"></div></div>
<div class="step-body">
<p class="step-title">Works the execution packets</p>
<p class="step-detail">Uses the per-asset packets generated for each covered instrument to actually carry out the transfer.</p>
<div class="touchpoints"><span class="tp">Trigger</span></div>
</div>
</div>
</div>
<div class="outcome"><span class="outcome-label">Outcome</span>Acts with real authority over everyone else's plan and none over their own — enforced by the system, not just the UI.</div>
</div>
<!-- 4: MULTI-FAMILY MUTAWALLI -->
<div class="persona" style="--rail:#b4884a; --tag-bg:rgba(180,136,74,0.16); --tag-fg:#b4884a;">
<div class="persona-head">
<h2 class="persona-name">The Professional Mutawalli</h2>
<span class="persona-tag">Agent · multi-family</span>
</div>
<p class="persona-goal">Serve several unrelated families as their appointed trustee, without one family ever seeing another's affairs.</p>
<div class="journey">
<div class="step">
<div class="step-rail"><div class="step-dot"></div><div class="step-line"></div></div>
<div class="step-body">
<p class="step-title">Holds the same role across families</p>
<p class="step-detail">Accepts separate agent invites from each client family, all under one login.</p>
<div class="touchpoints"><span class="tp">Family Switcher</span></div>
</div>
</div>
<div class="step">
<div class="step-rail"><div class="step-dot"></div></div>
<div class="step-body">
<p class="step-title">Switches, executes, switches again</p>
<p class="step-detail">Every action — trigger status, attestor count, review state — is scoped to the active family and the specific member, so firing one client's trigger can never touch another's, even if the same person happens to belong to both.</p>
<div class="touchpoints"><span class="tp">Mutawalli</span></div>
</div>
</div>
</div>
<div class="outcome"><span class="outcome-label">Outcome</span>One professional, many clients, zero cross-contamination — the case this app's data model was specifically hardened for.</div>
</div>
<!-- 5: HEIR -->
<div class="persona external" style="--rail:#4e7a6e;">
<div class="persona-head">
<h2 class="persona-name">The Heir / Beneficiary</h2>
<span class="persona-tag">No account</span>
</div>
<p class="persona-goal">Find out what was left to them, without needing to have planned any of it themselves.</p>
<div class="journey">
<div class="step">
<div class="step-rail"><div class="step-dot"></div><div class="step-line"></div></div>
<div class="step-body">
<p class="step-title">Named, not signed up</p>
<p class="step-detail">A family member enters their email against a Wassiyah bequest or a Waqf beneficiary — the heir never touches the app at this point, or maybe ever.</p>
<div class="touchpoints"><span class="tp">Wassiyah</span><span class="tp">Family Waqf</span></div>
</div>
</div>
<div class="step">
<div class="step-rail"><div class="step-dot"></div></div>
<div class="step-body">
<p class="step-title">Gets one email, once</p>
<p class="step-detail">After the mutawalli confirms death and fires the trigger, an email lands in their inbox — sent through the app's own mail relay, not a third-party marketing tool.</p>
<div class="touchpoints"><span class="tp">notify-heirs</span></div>
</div>
</div>
</div>
<div class="outcome"><span class="outcome-label">Outcome</span>Learns what's theirs at the moment it matters, with no account to forget the password to.</div>
</div>
<!-- 6: EMERGENCY / COMMUNITY CONTACT -->
<div class="persona external" style="--rail:#4e7a6e;">
<div class="persona-head">
<h2 class="persona-name">The Emergency / Community Contact</h2>
<span class="persona-tag">Mosque · Khairat · Police · Ambulance · No account</span>
</div>
<p class="persona-goal">Find out a death has happened the moment it's confirmed — without being on any heir list at all.</p>
<div class="journey">
<div class="step">
<div class="step-rail"><div class="step-dot"></div><div class="step-line"></div></div>
<div class="step-body">
<p class="step-title">Tagged, not invited</p>
<p class="step-detail">A family member adds them to Trusted Contacts with a category — mosque, khairat officer, police, ambulance/hospital — and an email address. Nothing is sent yet.</p>
<div class="touchpoints"><span class="tp">Assets</span></div>
</div>
</div>
<div class="step">
<div class="step-rail"><div class="step-dot"></div></div>
<div class="step-body">
<p class="step-title">One automatic email, the moment the trigger fires</p>
<p class="step-detail">No manual step from the mutawalli required — firing the trigger auto-sends this notice alongside the heir email, so the khairat process and burial logistics can start without someone remembering to make a phone call.</p>
<div class="touchpoints"><span class="tp">notify-emergency-contacts</span></div>
</div>
</div>
</div>
<div class="outcome"><span class="outcome-label">Outcome</span>The people who need to act fastest — the mosque, the khairat officer, emergency services — hear it automatically, not secondhand.</div>
</div>
<!-- 7: PROFESSIONAL REVIEWER -->
<div class="persona external" style="--rail:#4e7a6e;">
<div class="persona-head">
<h2 class="persona-name">The Professional Reviewer</h2>
<span class="persona-tag">Lawyer / consultant · no account</span>
</div>
<p class="persona-goal">Give a real legal opinion on a draft before a family relies on it — without the app pretending to replace them.</p>
<div class="journey">
<div class="step">
<div class="step-rail"><div class="step-dot"></div><div class="step-line"></div></div>
<div class="step-body">
<p class="step-title">Contacted outside the app</p>
<p class="step-detail">A family member marks their Wassiyah "review requested" and reaches out directly — no in-app marketplace or booking.</p>
<div class="touchpoints"><span class="tp">Wassiyah</span></div>
</div>
</div>
<div class="step">
<div class="step-rail"><div class="step-dot"></div><div class="step-line"></div></div>
<div class="step-body">
<p class="step-title">Reviews the actual draft</p>
<p class="step-detail">Reads the generated will document — clearly watermarked as a draft, not a claim of legal validity — with jurisdiction-specific notes already flagging what execution requires.</p>
<div class="touchpoints"><span class="tp">Draft will PDF</span></div>
</div>
</div>
<div class="step">
<div class="step-rail"><div class="step-dot"></div></div>
<div class="step-body">
<p class="step-title">Sign-off gets recorded</p>
<p class="step-detail">The family member marks it "reviewed" with the reviewer's name — visible to the mutawalli from then on.</p>
<div class="touchpoints"><span class="tp">Wassiyah</span><span class="tp">Mutawalli</span></div>
</div>
</div>
</div>
<div class="outcome"><span class="outcome-label">Outcome</span>A real professional opinion, tracked as a visible status — the app hands off, it doesn't substitute.</div>
</div>
</div>
<div class="footnote">
<h2>People in the tree who never log in</h2>
<p>Grandparents, deceased relatives, young children — most nodes in a real family tree have no account and never will. Anyone with access adds them as a lightweight person record with a name, dates, and an optional photo, and can link them to a real account later if one gets created. They're a data subject in this app, not a user of it.</p>
</div>
<div class="footnote">
<h2>Neighbourhood is a different circle than "family"</h2>
<p>Every persona above operates inside one estate-planning family. The Neighbourhood board doesn't — it's scoped to the individual account, joined by a code shared at the mosque, and completely independent of which family (or families, for a professional mutawalli) that person belongs to. The same signed-in person can be the Head of Family in one context and just another voice on the community board in another, with no data crossing between the two.</p>
</div>
</div>
</body>
</html>
+34 -1
View File
@@ -22,4 +22,37 @@ async function signInFreshFamily(page, BASE, familyLabel) {
return familyName;
}
module.exports = { signInFreshFamily, OWNER_EMAIL, OWNER_PASSWORD };
// Hub map mirroring App.svelte's HUBS config — the nav went from a single
// flat 22-item strip to 5 bottom-nav hubs, each revealing its own sub-nav.
// Reaching any given tab is now a two-step click (hub, then sub-tab)
// instead of one, so every test goes through this helper rather than
// hardcoding the old single-click selector.
const HUB_OF_TAB = {
'Coverage': 'Home',
'Assets': 'Estate', 'Faraid': 'Estate', 'Insurance': 'Estate', 'Wassiyah': 'Estate',
'Hibah': 'Estate', 'Family Waqf': 'Estate', 'Nominate': 'Estate', 'Claims (H2)': 'Estate',
'Zakat': 'Giving', 'Sadaqah': 'Giving', 'Khairat': 'Giving', 'Support': 'Giving',
'Tree': 'Family', 'Trigger': 'Family', 'Mutawalli': 'Family', 'Manage': 'Family', 'Neighbourhood': 'Family',
'Prayer Times': 'Daily', 'Qibla': 'Daily', 'Quran': 'Daily', 'Locate': 'Daily'
};
/** Navigates to a tab by its old flat label — clicks the owning hub first (if not already active), then the sub-tab. 'Settings' is a special case (a header icon, not a hub). */
async function gotoTab(page, label) {
if (label === 'Settings') {
await page.locator('button[aria-label="Settings"]').click();
return;
}
const hub = HUB_OF_TAB[label];
if (!hub) throw new Error(`gotoTab: unknown tab label "${label}"`);
const hubBtn = page.locator(`.hub-tab[aria-label="${hub}"]`);
const alreadyActive = await hubBtn.evaluate(el => el.classList.contains('active')).catch(() => false);
if (!alreadyActive) {
await hubBtn.click();
await page.waitForTimeout(200);
}
if (hub !== 'Home') {
await page.locator(`.subnav .tab[aria-label="${label}"]`).click();
}
}
module.exports = { signInFreshFamily, OWNER_EMAIL, OWNER_PASSWORD, gotoTab };
+2 -1
View File
@@ -1,11 +1,12 @@
const { chromium } = require('playwright');
const { gotoTab } = require('./e2e-auth-helper.cjs');
const BASE = 'https://moslem04.falahos.my/';
async function main() {
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
await page.goto(BASE, { waitUntil: 'networkidle' });
const clickTab = async label => { await page.locator('nav button.tab', { hasText: label }).click(); await page.waitForTimeout(200); };
const clickTab = async label => { await gotoTab(page, label); await page.waitForTimeout(200); };
await clickTab('Assets');
await page.locator('.field:has-text("Description") input').fill('Bakery Sdn Bhd shares');
+2 -2
View File
@@ -1,7 +1,7 @@
// Verifies business interests get covered via a proper continuity instrument,
// including the sole-proprietorship warning and the waqf-enterprise redirect.
const { chromium } = require('playwright');
const { signInFreshFamily } = require('./e2e-auth-helper.cjs');
const { signInFreshFamily, gotoTab } = require('./e2e-auth-helper.cjs');
const BASE = 'https://moslem04.falahos.my/';
const results = [];
const consoleErrors = [];
@@ -14,7 +14,7 @@ async function main() {
page.on('pageerror', e => consoleErrors.push(e.message));
await signInFreshFamily(page, BASE, 'e2e-business');
const clickTab = async label => { await page.locator('nav button.tab', { hasText: label }).click(); await page.waitForTimeout(500); };
const clickTab = async label => { await gotoTab(page, label); await page.waitForTimeout(500); };
const selectByText = async (locator, text) => {
// Options load async from Supabase now — poll until the matching one shows up.
let val;
+2 -2
View File
@@ -2,7 +2,7 @@
// mismatched bank-mandate fields), and vehicles get correctly suggested Hibah
// (not overkill trust structure) and can actually be covered that way.
const { chromium } = require('playwright');
const { signInFreshFamily } = require('./e2e-auth-helper.cjs');
const { signInFreshFamily, gotoTab } = require('./e2e-auth-helper.cjs');
const BASE = 'https://moslem04.falahos.my/';
const results = [];
const consoleErrors = [];
@@ -15,7 +15,7 @@ async function main() {
page.on('pageerror', e => consoleErrors.push(e.message));
await signInFreshFamily(page, BASE, 'e2e-digital-vehicle');
const clickTab = async label => { await page.locator('nav button.tab', { hasText: label }).click(); await page.waitForTimeout(500); };
const clickTab = async label => { await gotoTab(page, label); await page.waitForTimeout(500); };
const selectByText = async (locator, text) => {
// Options load async from Supabase now — poll until the matching one shows up.
let val;
+89
View File
@@ -0,0 +1,89 @@
// Verifies that firing a member's death trigger automatically invokes the
// notify-emergency-contacts Edge Function (mosque/khairat/police/ambulance
// contacts) alongside the existing heir notification — the one place in
// this app where "auto-send on trigger" is actually the right behavior.
const { chromium } = require('playwright');
const { gotoTab } = require('./e2e-auth-helper.cjs');
const BASE = 'https://moslem04.falahos.my/';
const OWNER = 'nurfalah.e2etest.owner@gmail.com';
const AGENT = 'nurfalah.e2etest.agent@gmail.com';
const PASSWORD = 'TestPassword123!';
const results = [];
function record(name, pass, detail = '') { results.push({ name, pass, detail }); console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`); }
async function signIn(page, email) {
await page.goto(BASE, { waitUntil: 'networkidle' });
await page.locator('.field:has-text("Email") input').fill(email);
await page.locator('.field:has-text("Password") input').fill(PASSWORD);
await page.locator('button.btn-primary', { hasText: 'Sign in' }).click();
await page.waitForTimeout(1500);
}
async function main() {
const familyName = `EmergencyNotify ${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
const browser = await chromium.launch();
const ownerCtx = await browser.newContext({ viewport: { width: 390, height: 844 } });
const ownerPage = await ownerCtx.newPage();
await signIn(ownerPage, OWNER);
await ownerPage.locator('.field:has-text("Family name") input').fill(familyName);
await ownerPage.locator('button.btn-primary', { hasText: 'Create family' }).click();
await ownerPage.waitForTimeout(1200);
await gotoTab(ownerPage, 'Assets');
await ownerPage.waitForTimeout(500);
await ownerPage.locator('.contacts-section .field:has-text("Name") input').fill('Emergency Test Mosque');
await ownerPage.locator('.contacts-section .field:has-text("Category") select').selectOption('mosque');
await ownerPage.locator('.contacts-section .field:has-text("Email") input').fill('mosque-emergency-test@example.com');
await ownerPage.locator('.contacts-section button.btn-secondary', { hasText: 'Add trusted contact' }).click();
await ownerPage.waitForTimeout(1000);
record('Setup: mosque contact with email added', await ownerPage.locator('.contact-row', { hasText: 'Emergency Test Mosque' }).isVisible().catch(() => false));
await gotoTab(ownerPage, 'Manage');
await ownerPage.waitForTimeout(500);
await ownerPage.locator('.field:has-text("Invite by email") input').fill(AGENT);
await ownerPage.locator('.field:has-text("Role") select').selectOption('agent');
await ownerPage.locator('button.btn-primary', { hasText: 'Send invite' }).click();
await ownerPage.waitForTimeout(1000);
const agentCtx = await browser.newContext({ viewport: { width: 390, height: 844 } });
const agentPage = await agentCtx.newPage();
let notifyStatus = null;
agentPage.on('response', r => { if (r.url().includes('functions/v1/notify-emergency-contacts')) notifyStatus = r.status(); });
await signIn(agentPage, AGENT);
const pending = agentPage.locator('.invite-row', { hasText: familyName });
if (await pending.count()) { await pending.locator('button', { hasText: 'Accept' }).click(); await agentPage.waitForTimeout(1000); }
const familyRow = agentPage.locator('.family-row', { hasText: familyName });
if (await familyRow.count()) { await familyRow.click(); await agentPage.waitForTimeout(1000); }
await gotoTab(agentPage, 'Mutawalli');
await agentPage.waitForTimeout(1000);
const memberChip = agentPage.locator('.member-chip', { hasText: 'nurfalah.e2etest.owner' });
record('Mutawalli: owner appears as a member to fire a trigger for', await memberChip.count() === 1);
if (await memberChip.count()) await memberChip.click();
await agentPage.waitForTimeout(500);
const attestorInputs = agentPage.locator('.attestor-row input');
await attestorInputs.nth(0).fill('Witness A');
await agentPage.locator('.attestor-row .confirm-btn').nth(0).click();
await attestorInputs.nth(1).fill('Witness B');
await agentPage.locator('.attestor-row .confirm-btn').nth(1).click();
await agentPage.locator('.field:has-text("Date of death") input').fill('2026-01-01');
await agentPage.locator('.field:has-text("Death certificate") input').fill(`CERT-${Date.now()}`);
await agentPage.waitForTimeout(500);
await agentPage.locator('button.btn-danger-solid').click();
await agentPage.waitForTimeout(4000);
record('Trigger: fires successfully (triggered banner shown)', await agentPage.locator('.triggered-banner').isVisible().catch(() => false));
record('Emergency notify: auto-invoked on fire, returns 200', notifyStatus === 200, `status: ${notifyStatus}`);
record('Emergency notify: status message shown to the mutawalli', await agentPage.locator('.emergency-notify-status', { hasText: 'Notified' }).isVisible().catch(() => false));
await browser.close();
const passCount = results.filter(r => r.pass).length;
const failCount = results.length - passCount;
console.log(`\n${passCount} passed, ${failCount} failed, ${results.length} total`);
if (failCount > 0) results.filter(r => !r.pass).forEach(r => console.log(` - ${r.name}: ${r.detail}`));
process.exit(failCount > 0 ? 1 : 0);
}
main().catch(e => { console.error('SCRIPT ERROR:', e); process.exit(2); });
+103
View File
@@ -0,0 +1,103 @@
// Verifies the Event Board extension to the Neighbourhood board: posting an
// event (date/time/location), RSVP toggle + count, the Announcements/Events
// filter, and that the seeded demo neighbourhood ("Masjid Al-Falah Kariah")
// renders real content for a demo account.
const { chromium } = require('playwright');
const { signInFreshFamily, gotoTab } = require('./e2e-auth-helper.cjs');
const BASE = 'https://moslem04.falahos.my/';
const results = [];
const consoleErrors = [];
function record(name, pass, detail = '') { results.push({ name, pass, detail }); console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`); }
async function main() {
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
page.on('console', m => { if (m.type() === 'error') consoleErrors.push(m.text()); });
page.on('pageerror', e => consoleErrors.push(e.message));
await signInFreshFamily(page, BASE, 'e2e-eventboard');
await gotoTab(page, 'Neighbourhood');
await page.waitForTimeout(600);
const addToggle = await page.locator('.add-toggle').isVisible().catch(() => false);
if (addToggle) await page.locator('.add-toggle').click();
await page.waitForTimeout(300);
await page.locator('.form-card .field:has-text("Name") input').fill(`Event Board Test ${Date.now()}`);
await page.locator('button.btn-primary', { hasText: 'Create & get a join code' }).click();
await page.waitForTimeout(1000);
// Plain announcement — no event fields shown until the toggle is checked
const eventFieldsHiddenByDefault = !(await page.locator('.field:has-text("Date & time")').isVisible().catch(() => false));
record('Neighbourhood: event date/location fields hidden until toggled', eventFieldsHiddenByDefault);
await page.locator('.field:has-text("Title") input').fill('General announcement');
await page.locator('button.btn-primary', { hasText: 'Post announcement' }).click();
await page.waitForTimeout(1000);
record('Neighbourhood: plain announcement posts without an event badge', await page.locator('.post-row:not(.event-row)', { hasText: 'General announcement' }).isVisible().catch(() => false));
// Event post
await page.locator('.field:has-text("Title") input').fill('Community Quran Class');
await page.locator('.event-toggle input').check();
await page.waitForTimeout(200);
const eventFieldsShown = await page.locator('.field:has-text("Date & time")').isVisible().catch(() => false);
record('Neighbourhood: event fields appear once toggled', eventFieldsShown);
await page.locator('.field:has-text("Date & time") input').fill('2026-12-01T18:30');
await page.locator('.field:has-text("Location") input').fill('Masjid Test Hall');
await page.locator('button.btn-primary', { hasText: 'Post event' }).click();
await page.waitForTimeout(1000);
const eventCardVisible = await page.locator('.event-row', { hasText: 'Community Quran Class' }).isVisible().catch(() => false);
record('Event Board: event posts with the 📅 badge and event styling', eventCardVisible);
const eventDetailsVisible = await page.locator('.event-row', { hasText: 'Community Quran Class' }).locator('.event-when, .event-where').count();
record('Event Board: shows date/time and location on the event card', eventDetailsVisible === 2, `${eventDetailsVisible} detail lines`);
// RSVP
const rsvpBtn = page.locator('.event-row', { hasText: 'Community Quran Class' }).locator('.rsvp-btn');
record('Event Board: starts as not going', !(await rsvpBtn.evaluate(el => el.classList.contains('going'))));
await rsvpBtn.click();
await page.waitForTimeout(800);
record('Event Board: RSVP toggles to "Going" with a count of 1', await rsvpBtn.evaluate(el => el.classList.contains('going')) && (await rsvpBtn.textContent()).includes('1'));
await rsvpBtn.click();
await page.waitForTimeout(800);
record('Event Board: RSVP toggles back off (cancel)', !(await rsvpBtn.evaluate(el => el.classList.contains('going'))));
// Filter
await page.locator('.board-filter button', { hasText: 'Events' }).click();
await page.waitForTimeout(300);
const onlyEventsShown = await page.locator('.post-row').count() === 1 && await page.locator('.event-row').isVisible();
record('Event Board: Events filter hides plain announcements', onlyEventsShown);
await page.locator('.board-filter button', { hasText: 'Announcements' }).click();
await page.waitForTimeout(300);
const onlyAnnouncementsShown = await page.locator('.post-row').count() === 1 && !(await page.locator('.event-row').isVisible().catch(() => false));
record('Event Board: Announcements filter hides events', onlyAnnouncementsShown);
// ── Seeded demo neighbourhood ──
const demoPage = await browser.newPage({ viewport: { width: 390, height: 844 } });
demoPage.on('console', m => { if (m.type() === 'error') consoleErrors.push('[demo] ' + m.text()); });
await demoPage.goto(BASE, { waitUntil: 'networkidle' });
await demoPage.locator('.field:has-text("Email") input').fill('nf.demo.ismail@gmail.com');
await demoPage.locator('.field:has-text("Password") input').fill('DemoPassword123!');
await demoPage.locator('button.btn-primary', { hasText: 'Sign in' }).click();
await demoPage.waitForTimeout(1500);
if (await demoPage.locator('.switcher-screen').isVisible().catch(() => false)) {
await demoPage.locator('.family-row').first().click();
await demoPage.waitForTimeout(1000);
}
await gotoTab(demoPage, 'Neighbourhood');
await demoPage.waitForTimeout(800);
record('Demo neighbourhood: Masjid Al-Falah Kariah is visible to the demo owner', await demoPage.locator('.active-card', { hasText: 'Masjid Al-Falah Kariah' }).isVisible().catch(() => false));
const demoPostCount = await demoPage.locator('.post-row').count();
record('Demo neighbourhood: has real seeded content (7 posts)', demoPostCount === 7, `${demoPostCount} posts`);
const demoEventCount = await demoPage.locator('.event-row').count();
record('Demo neighbourhood: has real seeded events (4 events)', demoEventCount === 4, `${demoEventCount} events`);
await demoPage.close();
record('No uncaught JS console errors during full session', consoleErrors.length === 0, consoleErrors.slice(0, 5).join(' || '));
await browser.close();
const passCount = results.filter(r => r.pass).length;
const failCount = results.length - passCount;
console.log(`\n${passCount} passed, ${failCount} failed, ${results.length} total`);
if (failCount > 0) results.filter(r => !r.pass).forEach(r => console.log(` - ${r.name}: ${r.detail}`));
process.exit(failCount > 0 ? 1 : 0);
}
main().catch(e => { console.error('SCRIPT ERROR:', e); process.exit(2); });
+8 -7
View File
@@ -4,6 +4,7 @@
// dashboard, can add an asset — but cannot fire the death trigger (owner-only,
// enforced by RLS, not just hidden in the UI).
const { chromium } = require('playwright');
const { gotoTab } = require('./e2e-auth-helper.cjs');
const BASE = 'https://moslem04.falahos.my/';
const results = [];
function record(name, pass, detail = '') { results.push({ name, pass, detail }); console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`); }
@@ -60,10 +61,10 @@ async function main() {
await ownerPage.locator('.field:has-text("Family name") input').fill(familyName);
await ownerPage.locator('button.btn-primary', { hasText: 'Create family' }).click();
await ownerPage.waitForTimeout(1000);
const onMainApp = await ownerPage.locator('nav button.tab', { hasText: 'Coverage' }).isVisible().catch(() => false);
const onMainApp = await ownerPage.locator('.hub-tab[aria-label="Home"]').isVisible().catch(() => false);
record('Owner: creating a family lands on the main app', onMainApp);
await ownerPage.locator('nav button[aria-label="Family"]').click();
await gotoTab(ownerPage, 'Manage');
await ownerPage.waitForTimeout(300);
await ownerPage.locator('.field:has-text("Invite by email") input').fill(agentEmail);
await ownerPage.locator('.field:has-text("Role") select').selectOption('agent');
@@ -81,10 +82,10 @@ async function main() {
record('Agent: sees pending invite from owner on sign-in', inviteVisible);
await inviteRow.locator('.btn-small', { hasText: 'Accept' }).click();
const agentOnMainApp = await agentPage.locator('nav button[aria-label="Coverage"]').waitFor({ state: 'visible', timeout: 10000 }).then(() => true).catch(() => false);
const agentOnMainApp = await agentPage.locator('.hub-tab[aria-label="Home"]').waitFor({ state: 'visible', timeout: 10000 }).then(() => true).catch(() => false);
record('Agent: accepting invite lands on the main app for that family', agentOnMainApp);
await agentPage.locator('nav button[aria-label="Assets"]').click();
await gotoTab(agentPage, 'Assets');
await agentPage.waitForTimeout(400);
await agentPage.locator('.field:has-text("Description") input').fill('Agent-added asset');
await agentPage.locator('.field:has-text("Estimated value") input').fill('50000');
@@ -93,12 +94,12 @@ async function main() {
record('Agent: can add an asset to the family estate', assetAddedByAgent);
// Owner should see the agent-added asset too (shared, live data — not per-device)
await ownerPage.locator('nav button[aria-label="Assets"]').click();
await gotoTab(ownerPage, 'Assets');
const ownerSeesAgentAsset = await ownerPage.locator('.asset-row', { hasText: 'Agent-added asset' }).waitFor({ state: 'visible', timeout: 10000 }).then(() => true).catch(() => false);
record('Owner: sees the asset the agent just added (shared family data)', ownerSeesAgentAsset);
// ── Agent tries the Death Trigger — should be visibly restricted ──
await agentPage.locator('nav button[aria-label="Trigger"]').click();
await gotoTab(agentPage, 'Trigger');
const agentRestrictionVisible = await agentPage.locator('.agent-restriction').waitFor({ state: 'visible', timeout: 10000 }).then(() => true).catch(() => false);
record('Agent: sees explicit "owner-only to fire" restriction notice', agentRestrictionVisible);
@@ -117,7 +118,7 @@ async function main() {
record('Agent: fire-trigger button stays disabled even with all fields filled (role check)', fireBtnDisabledForAgent);
// ── Owner fires it — should work, enforced by RLS as role=owner ──
await ownerPage.locator('nav button[aria-label="Trigger"]').click();
await gotoTab(ownerPage, 'Trigger');
await ownerPage.waitForTimeout(1000);
const ownerAttestorInputs = ownerPage.locator('.attestor-row input');
const attestorCount = await ownerAttestorInputs.count();
+3 -3
View File
@@ -6,7 +6,7 @@
// proven for Wassiyah/Waqf).
const { chromium } = require('playwright');
const path = require('path');
const { signInFreshFamily } = require('./e2e-auth-helper.cjs');
const { signInFreshFamily, gotoTab } = require('./e2e-auth-helper.cjs');
const BASE = 'https://moslem04.falahos.my/';
const results = [];
const consoleErrors = [];
@@ -19,7 +19,7 @@ async function main() {
page.on('pageerror', e => consoleErrors.push(e.message));
const familyName = await signInFreshFamily(page, BASE, 'e2e-family-tree');
await page.locator('nav button[aria-label="Tree"]').click();
await gotoTab(page, 'Tree');
await page.waitForTimeout(600);
// Add three people: grandfather, father, son
@@ -109,7 +109,7 @@ async function main() {
// ── Isolation: a fresh family (same account) should NOT see this tree ──
const isolationPage = await browser.newPage({ viewport: { width: 390, height: 844 } });
await signInFreshFamily(isolationPage, BASE, 'e2e-family-tree-isolation');
await isolationPage.locator('nav button[aria-label="Tree"]').click();
await gotoTab(isolationPage, 'Tree');
await isolationPage.waitForTimeout(600);
const leakedPerson = await isolationPage.locator('.person-card', { hasText: 'Grandfather Ahmad' }).isVisible().catch(() => false);
record('Family Tree: a different family sees none of this tree (isolation)', !leakedPerson);
+9 -5
View File
@@ -1,7 +1,7 @@
// Targeted E2E for the new fast-path mechanism: Coverage, Hibah asset-link,
// Waqf corpus-link, Nomination, Death Trigger. Run after the general e2e-uat.cjs.
const { chromium } = require('playwright');
const { signInFreshFamily } = require('./e2e-auth-helper.cjs');
const { signInFreshFamily, gotoTab } = require('./e2e-auth-helper.cjs');
const BASE = 'https://moslem04.falahos.my/';
const results = [];
const consoleErrors = [];
@@ -15,12 +15,16 @@ async function main() {
await signInFreshFamily(page, BASE, 'e2e-fastpath');
const clickTab = async label => { await page.locator('nav button.tab', { hasText: label }).click(); await page.waitForTimeout(500); };
const clickTab = async label => { await gotoTab(page, label); await page.waitForTimeout(500); };
// New tabs exist
// New tabs exist and are reachable
for (const label of ['Coverage', 'Nominate', 'Trigger']) {
const visible = await page.locator('nav button.tab', { hasText: label }).isVisible();
record(`Nav: "${label}" tab exists`, visible);
await gotoTab(page, label);
await page.waitForTimeout(300);
const reached = label === 'Coverage'
? await page.locator('.hub-tab.active[aria-label="Home"]').isVisible().catch(() => false)
: await page.locator('.subnav .tab.active', { hasText: label }).isVisible().catch(() => false);
record(`Nav: "${label}" tab exists`, reached);
}
// Add an asset
+106
View File
@@ -0,0 +1,106 @@
// Verifies the second round of competitive-gap closures: the Coverage
// Dashboard's rule-based recommendations engine, the Faraid Calculator's
// madhab selector (including honest Ja'fari gating), and the Wassiyah tab's
// draft will document generator.
const { chromium } = require('playwright');
const { signInFreshFamily, gotoTab } = require('./e2e-auth-helper.cjs');
const BASE = 'https://moslem04.falahos.my/';
const results = [];
const consoleErrors = [];
function record(name, pass, detail = '') { results.push({ name, pass, detail }); console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`); }
async function main() {
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
page.on('console', m => { if (m.type() === 'error') consoleErrors.push(m.text()); });
page.on('pageerror', e => consoleErrors.push(e.message));
await signInFreshFamily(page, BASE, 'e2e-gaps2');
// ── Recommendations engine on Coverage Dashboard ──
// Fresh family, no assets yet: no exposed-asset recommendation, but the
// "no agent assigned" and "no insurance" rules should still fire since
// they don't depend on estateTotal > 0.
await page.waitForTimeout(600);
const recSectionVisible = await page.locator('.recommendations').isVisible().catch(() => false);
record('Coverage: recommendations section renders for a fresh family', recSectionVisible);
const noAgentRec = await page.locator('.rec-row', { hasText: 'No estate agent' }).isVisible().catch(() => false);
record('Coverage: flags missing estate agent/mutawalli', noAgentRec);
// Add an asset to trigger the exposed-asset recommendation
await gotoTab(page, 'Assets');
await page.waitForTimeout(500);
await page.locator('.form-card .field:has-text("Description") input').fill('Savings account');
await page.locator('.form-card .field:has-text("Estimated value") input').fill('50000');
await page.locator('.form-card button.btn-primary', { hasText: 'Add asset' }).click();
await page.waitForTimeout(1000);
await gotoTab(page, 'Coverage');
await page.waitForTimeout(800);
const exposedRec = await page.locator('.rec-row', { hasText: 'exposed to the slow' }).isVisible().catch(() => false);
record('Coverage: flags exposed asset once one is logged', exposedRec);
const wassiyahRec = await page.locator('.rec-row', { hasText: 'No Wassiyah bequests' }).isVisible().catch(() => false);
record('Coverage: flags missing Wassiyah once estate has value', wassiyahRec);
// ── Faraid Calculator: madhab selector ──
await gotoTab(page, 'Faraid');
await page.waitForTimeout(500);
const madhabSelectVisible = await page.locator('.field:has-text("Madhab") select').isVisible().catch(() => false);
record('Faraid: madhab selector is present', madhabSelectVisible);
// Set up: wife only, no other heirs -> sole-heir radd divergence case
await page.locator('.field:has-text("Number of surviving wives") input').fill('1');
await page.waitForTimeout(500);
await page.locator('.field:has-text("Madhab") select').selectOption('shafii');
await page.waitForTimeout(500);
const shafiiShare = await page.locator('.share-row', { hasText: 'Wife' }).locator('.share-frac').textContent();
record("Faraid: Shafi'i excludes spouse from radd (wife keeps 1/4)", shafiiShare.trim() === '1/4', shafiiShare);
const unallocatedNoteVisible = await page.locator('.note-box', { hasText: 'unallocated' }).isVisible().catch(() => false);
record("Faraid: Shafi'i shows unallocated-residue note for sole-heir spouse", unallocatedNoteVisible);
await page.locator('.field:has-text("Madhab") select').selectOption('hanafi');
await page.waitForTimeout(500);
const hanafiShare = await page.locator('.share-row', { hasText: 'Wife' }).locator('.share-frac').textContent();
record('Faraid: Hanafi extends radd to sole-heir spouse (takes whole estate)', hanafiShare.trim() === '1', hanafiShare);
await page.locator('.field:has-text("Madhab") select').selectOption('jaafari');
await page.waitForTimeout(500);
const jaafariGateVisible = await page.locator('.reframe-note', { hasText: "Ja'fari" }).isVisible().catch(() => false);
const sharesHiddenForJaafari = await page.locator('.results').isVisible().catch(() => false);
record("Faraid: Ja'fari is honestly gated as unsupported, not silently computed", jaafariGateVisible && !sharesHiddenForJaafari);
// ── Wassiyah: draft will document generator ──
await page.locator('.field:has-text("Madhab") select').selectOption('shafii'); // reset, avoid cross-test pollution
await gotoTab(page, 'Wassiyah');
await page.waitForTimeout(500);
await page.locator('.field:has-text("Full legal name") input').fill('Ahmad bin Ismail');
await page.locator('.field:has-text("Recipient name") input').fill('Nur Charity Foundation');
await page.locator('.field:has-text("Relation to you") input').fill('charity');
await page.locator('.field:has-text("Description") input').fill('Cash bequest');
await page.locator('.field:has-text("Value") input').first().fill('5000');
await page.locator('button.btn-primary', { hasText: 'Add bequest' }).click();
await page.waitForTimeout(1000);
const [docPage] = await Promise.all([
page.waitForEvent('popup'),
page.locator('button.btn-secondary', { hasText: 'Generate draft will document' }).click()
]);
await docPage.waitForLoadState();
const docText = await docPage.locator('body').innerText();
record('Will document: opens a new tab with the testator name', docText.includes('Ahmad bin Ismail'), docText.slice(0, 100));
record('Will document: watermarked DRAFT / not executed', /draft.*not executed/i.test(docText));
record('Will document: includes the bequest recipient', docText.includes('Nur Charity Foundation'));
record('Will document: includes witness attestation section', /Witness Attestation/i.test(docText));
await docPage.close();
record('No uncaught JS console errors during full session', consoleErrors.length === 0, consoleErrors.join(' || '));
await browser.close();
const passCount = results.filter(r => r.pass).length;
const failCount = results.length - passCount;
console.log(`\n${passCount} passed, ${failCount} failed, ${results.length} total`);
if (failCount > 0) results.filter(r => !r.pass).forEach(r => console.log(` - ${r.name}: ${r.detail}`));
process.exit(failCount > 0 ? 1 : 0);
}
main().catch(e => { console.error('SCRIPT ERROR:', e); process.exit(2); });
+2 -2
View File
@@ -1,6 +1,6 @@
// Verifies every tab has a working (i) info button that opens plain-language help.
const { chromium } = require('playwright');
const { signInFreshFamily } = require('./e2e-auth-helper.cjs');
const { signInFreshFamily, gotoTab } = require('./e2e-auth-helper.cjs');
const BASE = 'https://moslem04.falahos.my/';
const results = [];
const consoleErrors = [];
@@ -17,7 +17,7 @@ async function main() {
await signInFreshFamily(page, BASE, 'e2e-info');
for (const label of TABS) {
await page.locator('nav button.tab', { hasText: label }).click();
await gotoTab(page, label);
await page.waitForTimeout(500);
const infoBtn = page.locator('.module-header .info-btn');
+5 -5
View File
@@ -2,7 +2,7 @@
// per-member Insurance/Takaful policies, family-shared Liabilities, and
// asset-ownership verification (proof document + dual-path confirm).
const { chromium } = require('playwright');
const { signInFreshFamily } = require('./e2e-auth-helper.cjs');
const { signInFreshFamily, gotoTab } = require('./e2e-auth-helper.cjs');
const BASE = 'https://moslem04.falahos.my/';
const results = [];
const consoleErrors = [];
@@ -17,7 +17,7 @@ async function main() {
await signInFreshFamily(page, BASE, 'e2e-insurance');
// ── Insurance & Takaful tab ──
await page.locator('nav button[aria-label="Insurance"]').click();
await gotoTab(page, 'Insurance');
await page.waitForTimeout(600);
await page.locator('.form-card .field:has-text("Type") select').selectOption('takaful');
@@ -44,7 +44,7 @@ async function main() {
record('Insurance: editing a policy succeeds without error', consoleErrors.length === 0, consoleErrors.join(' || '));
// ── Asset ownership verification (Assets tab) ──
await page.locator('nav button[aria-label="Assets"]').click();
await gotoTab(page, 'Assets');
await page.waitForTimeout(600);
await page.locator('.form-card .field:has-text("Description") input').fill('Family sedan');
await page.locator('.form-card .field:has-text("Estimated value") input').fill('45000');
@@ -85,7 +85,7 @@ async function main() {
record('Liabilities: removing a liability removes it from the list', !liabilityRemoved);
// ── Mutawalli dashboard should surface the insurance policy for this member (owner-only family: owner sees their own row) ──
await page.locator('nav button[aria-label="Mutawalli"]').click();
await gotoTab(page, 'Mutawalli');
await page.waitForTimeout(800);
const mutawalliText = await page.locator('.module').innerText().catch(() => '');
const mutawalliGated = mutawalliText.includes('Only the mutawalli');
@@ -94,7 +94,7 @@ async function main() {
// ── Isolation: a second fresh family (same account) must not see this policy/liability/verification data ──
const isolationPage = await browser.newPage({ viewport: { width: 390, height: 844 } });
await signInFreshFamily(isolationPage, BASE, 'e2e-insurance-isolation');
await isolationPage.locator('nav button[aria-label="Insurance"]').click();
await gotoTab(isolationPage, 'Insurance');
await isolationPage.waitForTimeout(600);
const leakedPolicy = await isolationPage.locator('.policy-row', { hasText: 'Etiqa Takaful' }).isVisible().catch(() => false);
record('Isolation: a different family sees none of this insurance data', !leakedPolicy);
+113
View File
@@ -0,0 +1,113 @@
// Verifies the whole-app jurisdiction setting (MY/SG/UK), its downstream
// effects on Wassiyah/will document/Zakat currency, and the professional
// review request workflow against the live backend.
const { chromium } = require('playwright');
const { signInFreshFamily, gotoTab } = require('./e2e-auth-helper.cjs');
const BASE = 'https://moslem04.falahos.my/';
const results = [];
const consoleErrors = [];
function record(name, pass, detail = '') { results.push({ name, pass, detail }); console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`); }
async function main() {
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
page.on('console', m => { if (m.type() === 'error') consoleErrors.push(m.text()); });
page.on('pageerror', e => consoleErrors.push(e.message));
await signInFreshFamily(page, BASE, 'e2e-jurisdiction');
// ── Settings: jurisdiction selector, owner can change ──
await gotoTab(page, 'Settings');
await page.waitForTimeout(600);
const jurisdictionSelectVisible = await page.locator('.jurisdiction-setting select').isVisible().catch(() => false);
record('Settings: jurisdiction selector visible for owner', jurisdictionSelectVisible);
await page.locator('.jurisdiction-setting select').selectOption('SG');
await page.waitForTimeout(1000);
const savedBadge = await page.locator('.jurisdiction-setting .saved').isVisible().catch(() => false);
record('Settings: changing jurisdiction shows a saved confirmation', savedBadge);
// ── Zakat: currency defaults follow the family jurisdiction (SGD) ──
await gotoTab(page, 'Zakat');
await page.waitForTimeout(800);
const sgdLabelVisible = await page.locator('.field:has-text("Cash")').textContent();
record('Zakat: currency label reflects Singapore jurisdiction (SGD)', sgdLabelVisible.includes('SGD'), sgdLabelVisible);
const nisabValue = await page.locator('.field:has-text("Nisab") input').inputValue();
record('Zakat: Nisab default follows Singapore jurisdiction (8000)', nisabValue === '8000', nisabValue);
// Log an asset first so the one-third cap isn't zero (a fresh family with
// no assets has cap=0, which would make any bequest below "exceed" it).
await gotoTab(page, 'Assets');
await page.waitForTimeout(500);
await page.locator('.form-card .field:has-text("Description") input').fill('Savings');
await page.locator('.form-card .field:has-text("Estimated value") input').fill('30000');
await page.locator('.form-card button.btn-primary', { hasText: 'Add asset' }).click();
await page.waitForTimeout(1000);
// ── Wassiyah: jurisdiction dropdown includes Singapore, defaults from family setting ──
await gotoTab(page, 'Wassiyah');
await page.waitForTimeout(800);
const wassiyahJurisdiction = await page.locator('.field:has-text("Jurisdiction") select').inputValue();
record('Wassiyah: jurisdiction defaults to the family setting (SG)', wassiyahJurisdiction === 'SG', wassiyahJurisdiction);
const sgOptionVisible = await page.locator('.field:has-text("Jurisdiction") select option[value="SG"]').count();
record('Wassiyah: Singapore is a selectable jurisdiction option', sgOptionVisible === 1);
// ── Draft will document reflects Singapore-specific legal notes ──
await page.locator('.field:has-text("Full legal name") input').fill('Siti binte Rahman');
await page.locator('.field:has-text("Recipient name") input').fill('Local Mosque Fund');
await page.locator('.field:has-text("Relation to you") input').fill('charity');
await page.locator('.field:has-text("Description") input').fill('Cash gift');
await page.locator('.field:has-text("Value") input').first().fill('3000');
await page.locator('button.btn-primary', { hasText: 'Add bequest' }).click();
await page.waitForTimeout(1000);
const [docPage] = await Promise.all([
page.waitForEvent('popup'),
page.locator('button.btn-secondary', { hasText: 'Generate draft will document' }).click()
]);
await docPage.waitForLoadState();
const docText = await docPage.locator('body').innerText();
record('Will document: Singapore-specific legal note present (Wills Act 1838 / AMLA)', docText.includes('1838') && docText.includes('AMLA'), docText.slice(0, 50));
await docPage.close();
// ── Professional review request workflow ──
const notRequestedVisible = await page.locator('.review-card', { hasText: "hasn't been reviewed" }).isVisible().catch(() => false);
record('Wassiyah: starts as not-yet-reviewed', notRequestedVisible);
await page.locator('button.btn-secondary', { hasText: 'Request professional review' }).click();
await page.waitForTimeout(1000);
const requestedVisible = await page.locator('.review-card.review-requested').isVisible().catch(() => false);
record('Wassiyah: requesting review updates status', requestedVisible);
await page.locator('.reviewer-row input').fill('Ahmad & Co Solicitors');
await page.locator('.reviewer-row button', { hasText: 'Mark reviewed' }).click();
await page.waitForTimeout(1000);
const reviewedVisible = await page.locator('.review-card.review-reviewed', { hasText: 'Ahmad & Co Solicitors' }).isVisible().catch(() => false);
record('Wassiyah: marking reviewed records the reviewer name', reviewedVisible);
// Review status surfaces on the Mutawalli dashboard (owner-only family — owner sees own row)
await gotoTab(page, 'Mutawalli');
await page.waitForTimeout(800);
const mutawalliReviewBadge = await page.locator('.review-badge.review-reviewed').isVisible().catch(() => false);
const mutawalliGated = (await page.locator('.module').innerText()).includes('Only the mutawalli');
record('Mutawalli: review status surfaces on the dashboard (or family is correctly gated)', mutawalliReviewBadge || mutawalliGated);
// ── Isolation: a different family (same account) is unaffected by this jurisdiction change ──
const isolationPage = await browser.newPage({ viewport: { width: 390, height: 844 } });
await signInFreshFamily(isolationPage, BASE, 'e2e-jurisdiction-isolation');
await gotoTab(isolationPage, 'Settings');
await isolationPage.waitForTimeout(600);
const isolatedJurisdiction = await isolationPage.locator('.jurisdiction-setting select').inputValue();
record('Isolation: a different family defaults to MY, unaffected by the SG change above', isolatedJurisdiction === 'MY', isolatedJurisdiction);
await isolationPage.close();
record('No uncaught JS console errors during full session', consoleErrors.length === 0, consoleErrors.join(' || '));
await browser.close();
const passCount = results.filter(r => r.pass).length;
const failCount = results.length - passCount;
console.log(`\n${passCount} passed, ${failCount} failed, ${results.length} total`);
if (failCount > 0) results.filter(r => !r.pass).forEach(r => console.log(` - ${r.name}: ${r.detail}`));
process.exit(failCount > 0 ? 1 : 0);
}
main().catch(e => { console.error('SCRIPT ERROR:', e); process.exit(2); });
+180
View File
@@ -0,0 +1,180 @@
// Verifies the Khairat/emergency-contacts round and the lifestyle-companion
// tabs (Qibla, Prayer Times, Locate, Quran, Neighbourhood) against the live
// backend. Geolocation-dependent tabs use Playwright's mocked geolocation
// (Kuala Lumpur coordinates) rather than the real device.
const { chromium } = require('playwright');
const { signInFreshFamily, gotoTab } = require('./e2e-auth-helper.cjs');
const BASE = 'https://moslem04.falahos.my/';
const results = [];
const consoleErrors = [];
function record(name, pass, detail = '') { results.push({ name, pass, detail }); console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`); }
async function main() {
const browser = await chromium.launch();
const context = await browser.newContext({
viewport: { width: 390, height: 844 },
geolocation: { latitude: 3.1390, longitude: 101.6869 }, // Kuala Lumpur
permissions: ['geolocation']
});
const page = await context.newPage();
page.on('console', m => { if (m.type() === 'error') consoleErrors.push(m.text()); });
page.on('pageerror', e => consoleErrors.push(e.message));
await signInFreshFamily(page, BASE, 'e2e-khairat');
// ── Khairat ──
await gotoTab(page, 'Khairat');
await page.waitForTimeout(600);
await page.locator('.field:has-text("Scheme name") input').fill('Kariah Khairat Kematian Masjid Al-Falah');
await page.locator('.field:has-text("Organization") input').fill('Masjid Al-Falah');
await page.locator('button.btn-primary', { hasText: 'Add khairat membership' }).click();
await page.waitForTimeout(1000);
const khairatRowVisible = await page.locator('.khairat-row', { hasText: 'Kariah Khairat Kematian' }).isVisible().catch(() => false);
record('Khairat: membership added and listed', khairatRowVisible);
await page.locator('.fund-card .field:has-text("Target amount") input').fill('5000');
await page.locator('.fund-card .field:has-text("Current balance") input').fill('1250');
await page.waitForTimeout(1000);
const fundProgressVisible = await page.locator('.fund-progress-label', { hasText: '25%' }).isVisible().catch(() => false);
record('Khairat: emergency fund progress bar computes correctly (1250/5000 = 25%)', fundProgressVisible);
// ── Trusted contacts with category + email ──
await gotoTab(page, 'Assets');
await page.waitForTimeout(500);
await page.locator('.contacts-section .field:has-text("Name") input').fill('Masjid Al-Falah Office');
await page.locator('.contacts-section .field:has-text("Category") select').selectOption('mosque');
await page.locator('.contacts-section .field:has-text("Email") input').fill('office@example-mosque.test');
await page.locator('.contacts-section button.btn-secondary', { hasText: 'Add trusted contact' }).click();
await page.waitForTimeout(1000);
const contactCategoryVisible = await page.locator('.contact-row', { hasText: 'Masjid Al-Falah Office' }).locator('.contact-category', { hasText: 'mosque' }).isVisible().catch(() => false);
record('Trusted Contacts: category badge shows on the new contact', contactCategoryVisible);
// ── Qibla ──
await gotoTab(page, 'Qibla');
await page.waitForTimeout(500);
await page.locator('button.btn-primary', { hasText: 'Find Qibla direction' }).click();
await page.waitForTimeout(2000);
const bearingVisible = await page.locator('.bearing-readout strong').isVisible().catch(() => false);
const bearingText = bearingVisible ? await page.locator('.bearing-readout strong').textContent() : '';
record('Qibla: computes a bearing from Kuala Lumpur (expect ~292° toward Makkah)', bearingVisible && /29[0-5]°/.test(bearingText), bearingText);
const distanceVisible = await page.locator('.distance-note', { hasText: 'km to Makkah' }).isVisible().catch(() => false);
record('Qibla: shows distance to Makkah', distanceVisible);
// Regression guard: many Android browsers fire plain 'deviceorientation'
// with absolute:false even though alpha is a usable heading — a prior
// version required absolute:true and silently never detected the compass
// on those devices. Simulate exactly that event shape.
const staticNoteBeforeSensor = await page.locator('.static-note').isVisible().catch(() => false);
record('Qibla: shows the no-sensor fallback note before any orientation event', staticNoteBeforeSensor);
await page.evaluate(() => {
const evt = new Event('deviceorientation');
Object.defineProperty(evt, 'alpha', { value: 90 });
Object.defineProperty(evt, 'absolute', { value: false });
window.dispatchEvent(evt);
});
await page.waitForTimeout(500);
const staticNoteAfterSensor = await page.locator('.static-note').isVisible().catch(() => false);
record('Qibla: accepts a non-absolute deviceorientation event as a valid compass reading (Android)', !staticNoteAfterSensor);
// ── Prayer Times ──
await gotoTab(page, 'Prayer Times');
await page.waitForTimeout(500);
await page.locator('button.btn-primary', { hasText: "Calculate today's prayer times" }).click();
await page.waitForTimeout(2000);
const fajrVisible = await page.locator('.time-row', { hasText: 'Fajr' }).locator('.time-value').isVisible().catch(() => false);
const timesInOrder = await page.locator('.times-list').innerText();
record('Prayer Times: computes all 6 times for Kuala Lumpur', fajrVisible && /Fajr[\s\S]*Sunrise[\s\S]*Dhuhr[\s\S]*Asr[\s\S]*Maghrib[\s\S]*Isha/.test(timesInOrder), timesInOrder.replace(/\n/g, ' '));
// ── Locate (mosque + halal search via live OpenStreetMap Overpass) ──
await gotoTab(page, 'Locate');
await page.waitForTimeout(500);
await page.locator('button.btn-primary', { hasText: 'Search nearby' }).click();
await page.waitForTimeout(10000); // live Overpass API call, across mirrors if the first is down
const locateResultOrEmpty = await page.locator('.results-list, .empty').isVisible().catch(() => false);
record('Locate: mosque search completes (real results or honest empty state)', locateResultOrEmpty);
// Halal food specifically: OSM tagging is sparse for this category
// globally, but Kuala Lumpur at this test coordinate is known to have
// real diet:halal=yes data (~30 results) — asserting a non-zero count
// here catches a mirror silently returning an empty-but-"successful"
// response, which looks identical to a legitimate empty search otherwise.
await page.locator('.category-toggle button', { hasText: 'Halal food' }).click();
await page.locator('button.btn-primary', { hasText: 'Search nearby' }).click();
await page.waitForTimeout(10000);
const halalResultCount = await page.locator('.result-row').count();
record('Locate: halal food search returns real results at a known-good test location', halalResultCount > 0, `${halalResultCount} results`);
// ── Quran ──
await gotoTab(page, 'Quran');
await page.waitForTimeout(2000);
const surahListVisible = await page.locator('.surah-row', { hasText: 'Al-Faatiha' }).isVisible().catch(() => false);
record('Quran: Surah list loads from the public API (Al-Faatiha present)', surahListVisible);
if (surahListVisible) {
await page.locator('.surah-row', { hasText: 'Al-Faatiha' }).click();
await page.waitForTimeout(2000);
const ayahVisible = await page.locator('.ayah-row').first().isVisible().catch(() => false);
const arabicVisible = await page.locator('.ayah-arabic').first().isVisible().catch(() => false);
record('Quran: opening a Surah loads Arabic text and translation', ayahVisible && arabicVisible);
}
// ── Neighbourhood board ──
// Neighbourhoods are scoped to the user account, not the family — the
// shared e2e owner account may already belong to one from a prior run,
// in which case the create/join form is behind the "add another" toggle.
await gotoTab(page, 'Neighbourhood');
await page.waitForTimeout(600);
const addToggleVisible = await page.locator('.add-toggle').isVisible().catch(() => false);
if (addToggleVisible) await page.locator('.add-toggle').click();
await page.waitForTimeout(300);
const nName = `Kariah Test ${Date.now()}`;
await page.locator('.form-card .field:has-text("Name") input').fill(nName);
await page.locator('button.btn-primary', { hasText: 'Create & get a join code' }).click();
await page.waitForTimeout(1000);
const joinCodeVisible = await page.locator('.join-code', { hasText: 'Join code:' }).isVisible().catch(() => false);
record('Neighbourhood: creating one generates a join code', joinCodeVisible);
await page.locator('.form-card .field:has-text("Title") input').fill('Friday khutbah reminder');
await page.locator('button.btn-primary', { hasText: 'Post announcement' }).click();
await page.waitForTimeout(1000);
const postVisible = await page.locator('.post-row', { hasText: 'Friday khutbah reminder' }).isVisible().catch(() => false);
record('Neighbourhood: posting an announcement shows it in the board', postVisible);
// Join-by-code isolation: a second account joining via the real code should see the same post
const joinCodeText = await page.locator('.join-code').textContent();
const code = joinCodeText.replace('Join code:', '').trim();
// signInFreshFamily always signs in as the same fixed e2e owner account
// (just a new family) — since neighbourhoods are user-scoped, this "second
// account" already belongs to the neighbourhood created above, so the
// join form is behind the same toggle as before.
const secondContext = await browser.newContext({ viewport: { width: 390, height: 844 } });
const secondPage = await secondContext.newPage();
await signInFreshFamily(secondPage, BASE, 'e2e-khairat-neighbour2');
await gotoTab(secondPage, 'Neighbourhood');
await secondPage.waitForTimeout(600);
const secondAddToggleVisible = await secondPage.locator('.add-toggle').isVisible().catch(() => false);
if (secondAddToggleVisible) await secondPage.locator('.add-toggle').click();
await secondPage.waitForTimeout(300);
await secondPage.locator('.form-card .field:has-text("Join code") input').fill(code);
await secondPage.locator('button.btn-secondary', { hasText: 'Join' }).click();
await secondPage.waitForTimeout(1000);
const secondSeesPost = await secondPage.locator('.post-row', { hasText: 'Friday khutbah reminder' }).isVisible().catch(() => false);
record('Neighbourhood: a second account joining by code sees the same posts', secondSeesPost);
await secondContext.close();
// A 504/429/503 while trying one Overpass mirror before falling through to
// the next is the fallback mechanism working as designed, not a bug — the
// browser still logs the failed fetch as a console error either way, so
// filter those specific expected-noise lines out of this assertion.
const realConsoleErrors = consoleErrors.filter(e => !/overpass.*(429|503|504)|Gateway Timeout|Too Many Requests/i.test(e));
record('No uncaught JS console errors during full session', realConsoleErrors.length === 0, realConsoleErrors.slice(0, 5).join(' || '));
await browser.close();
const passCount = results.filter(r => r.pass).length;
const failCount = results.length - passCount;
console.log(`\n${passCount} passed, ${failCount} failed, ${results.length} total`);
if (failCount > 0) results.filter(r => !r.pass).forEach(r => console.log(` - ${r.name}: ${r.detail}`));
process.exit(failCount > 0 ? 1 : 0);
}
main().catch(e => { console.error('SCRIPT ERROR:', e); process.exit(2); });
+2 -2
View File
@@ -2,7 +2,7 @@
// previous bug: a nonsensical "EPF / Takaful nomination" suggestion pointing at a
// channel value ('nomination') that didn't even exist in the Nomination Registry.
const { chromium } = require('playwright');
const { signInFreshFamily } = require('./e2e-auth-helper.cjs');
const { signInFreshFamily, gotoTab } = require('./e2e-auth-helper.cjs');
const BASE = 'https://moslem04.falahos.my/';
const results = [];
const consoleErrors = [];
@@ -15,7 +15,7 @@ async function main() {
page.on('pageerror', e => consoleErrors.push(e.message));
await signInFreshFamily(page, BASE, 'e2e-other');
const clickTab = async label => { await page.locator('nav button.tab', { hasText: label }).click(); await page.waitForTimeout(500); };
const clickTab = async label => { await gotoTab(page, label); await page.waitForTimeout(500); };
const selectByText = async (locator, text) => {
// Options load async from Supabase now — poll until the matching one shows up.
let val;
+10 -9
View File
@@ -5,6 +5,7 @@
// own trigger; the owner's own separate Wassiyah is not visible to the
// member as "theirs" (proves per-author isolation, not just per-family).
const { chromium } = require('playwright');
const { gotoTab } = require('./e2e-auth-helper.cjs');
const BASE = 'https://moslem04.falahos.my/';
const results = [];
function record(name, pass, detail = '') { results.push({ name, pass, detail }); console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`); }
@@ -34,7 +35,7 @@ async function main() {
await ownerPage.locator('button.btn-primary', { hasText: 'Create family' }).click();
await ownerPage.waitForTimeout(1200);
await ownerPage.locator('nav button[aria-label="Family"]').click();
await gotoTab(ownerPage, 'Manage');
await ownerPage.waitForTimeout(500);
await ownerPage.locator('.field:has-text("Invite by email") input').fill(MEMBER);
await ownerPage.locator('.field:has-text("Role") select').selectOption('member');
@@ -47,7 +48,7 @@ async function main() {
record('Owner: invites both a member and an agent', bothInvited);
// Owner writes their OWN wassiyah bequest (should stay private to owner)
await ownerPage.locator('nav button[aria-label="Wassiyah"]').click();
await gotoTab(ownerPage, 'Wassiyah');
await ownerPage.waitForTimeout(600);
await ownerPage.locator('.form-card .field:has-text("Recipient name") input').fill('Owner Charity');
await ownerPage.locator('.form-card .field:has-text("Relation to you") input').fill('charity');
@@ -64,9 +65,9 @@ async function main() {
const inviteVisible = await inviteRow.waitFor({ state: 'visible', timeout: 10000 }).then(() => true).catch(() => false);
record('Member: sees pending invite', inviteVisible);
await inviteRow.locator('.btn-small', { hasText: 'Accept' }).click();
await memberPage.locator('nav button[aria-label="Coverage"]').waitFor({ state: 'visible', timeout: 10000 });
await memberPage.locator('.hub-tab[aria-label="Home"]').waitFor({ state: 'visible', timeout: 10000 });
await memberPage.locator('nav button[aria-label="Wassiyah"]').click();
await gotoTab(memberPage, 'Wassiyah');
await memberPage.waitForTimeout(600);
const ownerBequestVisibleToMember = await memberPage.locator('.bequest-row', { hasText: 'Owner Charity' }).isVisible().catch(() => false);
record('Member: does NOT see owner\'s private bequest (per-author isolation)', !ownerBequestVisibleToMember);
@@ -88,14 +89,14 @@ async function main() {
const agentInviteVisible = await agentInviteRow.waitFor({ state: 'visible', timeout: 10000 }).then(() => true).catch(() => false);
if (agentInviteVisible) {
await agentInviteRow.locator('.btn-small', { hasText: 'Accept' }).click();
await agentPage.locator('nav button[aria-label="Coverage"]').waitFor({ state: 'visible', timeout: 10000 });
await agentPage.locator('.hub-tab[aria-label="Home"]').waitFor({ state: 'visible', timeout: 10000 });
} else {
// Agent may already belong to many families from prior test runs — switch to this one via Family tab
await agentPage.locator('nav button[aria-label="Family"]').click().catch(() => {});
await gotoTab(agentPage, 'Manage').catch(() => {});
}
record('Agent: accepts mutawalli invite', agentInviteVisible);
await agentPage.locator('nav button[aria-label="Mutawalli"]').click();
await gotoTab(agentPage, 'Mutawalli');
await agentPage.waitForTimeout(800);
const memberChipVisible = await agentPage.locator('.member-chip', { hasText: MEMBER }).isVisible().catch(() => false);
record('Mutawalli dashboard: shows the member in the chip list', memberChipVisible);
@@ -127,8 +128,8 @@ async function main() {
const notifyBtn = agentPage.locator('button.btn-secondary', { hasText: 'Notify heirs' });
if (await notifyBtn.isVisible().catch(() => false)) {
await notifyBtn.click();
await agentPage.locator('.notify-status', { hasText: /Sent|Failed/ }).waitFor({ state: 'visible', timeout: 15000 }).catch(() => {});
const statusText = await agentPage.locator('.notify-status').textContent().catch(() => '');
await agentPage.locator('.heir-notify-status', { hasText: /Sent|Failed/ }).waitFor({ state: 'visible', timeout: 15000 }).catch(() => {});
const statusText = await agentPage.locator('.heir-notify-status').textContent().catch(() => '');
record('Mutawalli: heir notification actually sends via the real SMTP relay', statusText.includes('Sent'), statusText);
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
// Verifies property/land assets are covered through ALL THREE applicable fast-path
// channels: Hibah, Waqf (Family Waqf Designator), and Trust (Nomination Registry).
const { chromium } = require('playwright');
const { signInFreshFamily } = require('./e2e-auth-helper.cjs');
const { signInFreshFamily, gotoTab } = require('./e2e-auth-helper.cjs');
const BASE = 'https://moslem04.falahos.my/';
const results = [];
const consoleErrors = [];
@@ -14,7 +14,7 @@ async function main() {
page.on('pageerror', e => consoleErrors.push(e.message));
await signInFreshFamily(page, BASE, 'e2e-property');
const clickTab = async label => { await page.locator('nav button.tab', { hasText: label }).click(); await page.waitForTimeout(500); };
const clickTab = async label => { await gotoTab(page, label); await page.waitForTimeout(500); };
const selectByText = async (locator, text) => {
// Options load async from Supabase now — poll until the matching one shows up.
let val;
+104
View File
@@ -0,0 +1,104 @@
// Verifies the stickiness round: the Sadaqah tracker (log, streak, family
// privacy boundary), the Family Tree memorial-giving link, and the tree
// completeness hints feeding the Coverage Dashboard's recommendations engine.
const { chromium } = require('playwright');
const { signInFreshFamily, gotoTab } = require('./e2e-auth-helper.cjs');
const BASE = 'https://moslem04.falahos.my/';
const results = [];
const consoleErrors = [];
function record(name, pass, detail = '') { results.push({ name, pass, detail }); console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`); }
async function main() {
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
page.on('console', m => { if (m.type() === 'error') consoleErrors.push(m.text()); });
page.on('pageerror', e => consoleErrors.push(e.message));
await signInFreshFamily(page, BASE, 'e2e-sadaqah');
// ── Sadaqah tracker ──
await gotoTab(page, 'Sadaqah');
await page.waitForTimeout(600);
const zeroStreak = await page.locator('.streak-number').textContent();
record('Sadaqah: starts at 0 streak', zeroStreak.trim() === '0', zeroStreak);
const notYetVisible = await page.locator('.streak-today', { hasText: 'Not yet logged today' }).isVisible().catch(() => false);
record('Sadaqah: shows "not yet logged today" before any entry', notYetVisible);
await page.locator('.field:has-text("Cause") input').fill('Local mosque fund');
await page.locator('.field:has-text("Amount") input').fill('20');
await page.locator('button.btn-primary', { hasText: "Log today's sadaqah" }).click();
await page.waitForTimeout(1200);
const streakAfter = await page.locator('.streak-number').textContent();
record('Sadaqah: logging today brings streak to 1', streakAfter.trim() === '1', streakAfter);
const givenToday = await page.locator('.streak-card.done').isVisible().catch(() => false);
record('Sadaqah: streak card shows "given today" state', givenToday);
const historyRowVisible = await page.locator('.history-row', { hasText: 'Local mosque fund' }).isVisible().catch(() => false);
record('Sadaqah: entry appears in own history with amount', historyRowVisible);
// ── Family Tree: add a deceased person, verify memorial link + dedication ──
await gotoTab(page, 'Tree');
await page.waitForTimeout(600);
await page.locator('.form-card .field:has-text("Full name") input').fill('Grandfather Yusuf');
await page.locator('.form-card .field:has-text("Death date") input').fill('2010-05-01');
await page.locator('.form-card button.btn-primary', { hasText: 'Add person' }).click();
await page.locator('.person-card', { hasText: 'Grandfather Yusuf' }).waitFor({ state: 'visible', timeout: 10000 });
await page.locator('.person-summary', { hasText: 'Grandfather Yusuf' }).click();
await page.waitForTimeout(400);
const memorialButtonVisible = await page.locator('.person-card', { hasText: 'Grandfather Yusuf' }).locator('button', { hasText: 'Give sadaqah in memory of' }).isVisible().catch(() => false);
record('Tree: deceased person shows a "give sadaqah in memory" link', memorialButtonVisible);
await page.locator('.person-card', { hasText: 'Grandfather Yusuf' }).locator('button', { hasText: 'Give sadaqah in memory of' }).click();
await page.waitForTimeout(800);
const jumpedToSadaqah = await page.locator('.hub-tab.active[aria-label="Giving"]').isVisible().catch(() => false)
&& await page.locator('.subnav .tab.active[aria-label="Sadaqah"]').isVisible().catch(() => false);
record('Tree: memorial link navigates to the Sadaqah tab', jumpedToSadaqah);
// Log a dedication and confirm the memorial count appears back on the Tree
await page.locator('.field:has-text("In memory of") select').selectOption({ label: 'Grandfather Yusuf' });
await page.locator('.field:has-text("Cause") input').fill('In his memory');
await page.locator('button.btn-primary', { hasText: "Log today's sadaqah" }).click();
await page.waitForTimeout(1200);
await gotoTab(page, 'Tree');
await page.waitForTimeout(600);
await page.locator('.person-summary', { hasText: 'Grandfather Yusuf' }).click();
await page.waitForTimeout(600);
const memorialCountVisible = await page.locator('.memorial-count', { hasText: 'sadaqah given in their memory' }).isVisible().catch(() => false);
record('Tree: memorial sadaqah count appears on the person card', memorialCountVisible);
// ── Coverage Dashboard: tree completeness hints ──
// Add a second, incomplete person (no birth date, no photo, no relationship) to trigger hints.
await page.locator('.form-card .field:has-text("Full name") input').fill('Cousin Zaid');
await page.locator('.form-card button.btn-primary', { hasText: 'Add person' }).click();
await page.waitForTimeout(1000);
await gotoTab(page, 'Coverage');
await page.waitForTimeout(800);
const birthDateHint = await page.locator('.rec-row', { hasText: 'missing a birth date' }).isVisible().catch(() => false);
record('Coverage: recommendations flag people missing a birth date', birthDateHint);
const orphanHint = await page.locator('.rec-row', { hasText: 'no relationships linked' }).isVisible().catch(() => false);
record('Coverage: recommendations flag people with no relationships linked', orphanHint);
// ── Isolation: a different family sees none of this ──
const isolationPage = await browser.newPage({ viewport: { width: 390, height: 844 } });
await signInFreshFamily(isolationPage, BASE, 'e2e-sadaqah-isolation');
await gotoTab(isolationPage, 'Sadaqah');
await isolationPage.waitForTimeout(600);
const isolatedStreak = await isolationPage.locator('.streak-number').textContent();
record('Isolation: a different family starts with a 0 streak, unaffected by the above', isolatedStreak.trim() === '0', isolatedStreak);
await isolationPage.close();
record('No uncaught JS console errors during full session', consoleErrors.length === 0, consoleErrors.join(' || '));
await browser.close();
const passCount = results.filter(r => r.pass).length;
const failCount = results.length - passCount;
console.log(`\n${passCount} passed, ${failCount} failed, ${results.length} total`);
if (failCount > 0) results.filter(r => !r.pass).forEach(r => console.log(` - ${r.name}: ${r.detail}`));
process.exit(failCount > 0 ? 1 : 0);
}
main().catch(e => { console.error('SCRIPT ERROR:', e); process.exit(2); });
+4 -3
View File
@@ -6,12 +6,13 @@
// assume an anonymous landing page and need a sign-in prelude added before
// they're valid again — tracked as a follow-up, not done here.
const { chromium } = require('playwright');
const { gotoTab } = require('./e2e-auth-helper.cjs');
const BASE = 'https://moslem04.falahos.my/';
const results = [];
const consoleErrors = [];
function record(name, pass, detail = '') { results.push({ name, pass, detail }); console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`); }
const TABS = ['Coverage', 'Faraid', 'Assets', 'Wassiyah', 'Hibah', 'Family Waqf', 'Nominate', 'Trigger', 'Claims (H2)', 'Family', 'Settings'];
const TABS = ['Coverage', 'Faraid', 'Assets', 'Insurance', 'Zakat', 'Sadaqah', 'Khairat', 'Wassiyah', 'Hibah', 'Family Waqf', 'Nominate', 'Trigger', 'Mutawalli', 'Tree', 'Prayer Times', 'Qibla', 'Locate', 'Quran', 'Neighbourhood', 'Claims (H2)', 'Manage', 'Settings'];
async function main() {
const browser = await chromium.launch();
@@ -31,11 +32,11 @@ async function main() {
await page.locator('.family-row').first().click();
await page.waitForTimeout(1000);
}
const onMainApp = await page.locator('nav button[aria-label="Coverage"]').isVisible().catch(() => false);
const onMainApp = await page.locator('.hub-tab[aria-label="Home"]').isVisible().catch(() => false);
record('Signed-in owner reaches the main app', onMainApp);
for (const label of TABS) {
await page.locator(`nav button[aria-label="${label}"]`).click();
await gotoTab(page, label);
await page.waitForTimeout(500);
const infoBtn = page.locator('.module-header .info-btn');
const hasInfoBtn = await infoBtn.isVisible().catch(() => false);
+86
View File
@@ -0,0 +1,86 @@
// Verifies the Support tab: amount/frequency selection, and that "Support
// Now" actually opens a real, live Polar.sh checkout page (not a dead or
// placeholder link) — one check per frequency, since each maps to a
// different Polar product.
const { chromium } = require('playwright');
const { signInFreshFamily, gotoTab } = require('./e2e-auth-helper.cjs');
const BASE = 'https://moslem04.falahos.my/';
const results = [];
const consoleErrors = [];
function record(name, pass, detail = '') { results.push({ name, pass, detail }); console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`); }
async function main() {
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
page.on('console', m => { if (m.type() === 'error') consoleErrors.push(m.text()); });
page.on('pageerror', e => consoleErrors.push(e.message));
await signInFreshFamily(page, BASE, 'e2e-support');
await gotoTab(page, 'Support');
await page.waitForTimeout(600);
record('Support: intro copy renders', await page.locator('h3', { hasText: 'Support Nur Falah' }).isVisible().catch(() => false));
// Amount presets
await page.locator('.pill', { hasText: '$25' }).click();
await page.waitForTimeout(200);
const presetActive = await page.locator('.pill.active', { hasText: '$25' }).isVisible().catch(() => false);
record('Support: selecting a preset amount highlights it', presetActive);
// Custom amount overrides preset
await page.locator('.custom-input').fill('17');
await page.waitForTimeout(200);
const customActive = await page.locator('.pill-custom.active').isVisible().catch(() => false);
record('Support: entering a custom amount switches selection to custom', customActive);
const btnShowsCustom = await page.locator('.donate-btn', { hasText: '$17' }).isVisible().catch(() => false);
record('Support: button reflects the custom amount', btnShowsCustom);
await page.locator('.custom-input').fill('');
// Each frequency should open a distinct, real, live Polar checkout page
const frequencies = [
{ label: 'One-Time', btnText: 'Support Now' },
{ label: 'Monthly 🌙', btnText: 'Subscribe Monthly' },
{ label: 'Quarterly', btnText: 'Subscribe Quarterly' },
{ label: 'Yearly', btnText: 'Subscribe Yearly' }
];
const openedUrls = new Set();
for (const freq of frequencies) {
await page.locator('.frequency-toggle button', { hasText: freq.label }).click();
await page.waitForTimeout(200);
await page.locator('.pill', { hasText: '$10' }).click();
await page.waitForTimeout(200);
const [popup] = await Promise.all([
page.waitForEvent('popup'),
page.locator('.donate-btn').click()
]);
await popup.waitForLoadState('domcontentloaded').catch(() => {});
await page.waitForTimeout(1500);
const finalUrl = popup.url();
openedUrls.add(finalUrl);
const reachedPolar = /polar\.sh|stripe\.com/i.test(finalUrl);
record(`Support (${freq.label}): opens a real, live checkout page`, reachedPolar, finalUrl);
await popup.close();
}
record('Support: each frequency opens a genuinely distinct checkout session', openedUrls.size === frequencies.length, `${openedUrls.size} distinct URLs`);
// Partnership section
await page.locator('a', { hasText: 'View Partnership Opportunities' }).click();
await page.waitForTimeout(500);
record('Support: partnership section is reachable', await page.locator('h3', { hasText: 'Co-Branding' }).isVisible().catch(() => false));
const emailLinkVisible = await page.locator('a[href^="mailto:info@falahos.my"]').isVisible().catch(() => false);
record('Support: shows the correct VP sales email contact', emailLinkVisible);
const whatsappLinkVisible = await page.locator('a[href="https://wa.me/60132250691"]').isVisible().catch(() => false);
record('Support: shows the correct WhatsApp contact', whatsappLinkVisible);
record('Support: mentions white-labeling', (await page.locator('.partner-tiers').innerText()).toLowerCase().includes('white-label'));
record('No uncaught JS console errors during full session', consoleErrors.length === 0, consoleErrors.slice(0, 5).join(' || '));
await browser.close();
const passCount = results.filter(r => r.pass).length;
const failCount = results.length - passCount;
console.log(`\n${passCount} passed, ${failCount} failed, ${results.length} total`);
if (failCount > 0) results.filter(r => !r.pass).forEach(r => console.log(` - ${r.name}: ${r.detail}`));
process.exit(failCount > 0 ? 1 : 0);
}
main().catch(e => { console.error('SCRIPT ERROR:', e); process.exit(2); });
+2 -2
View File
@@ -1,6 +1,6 @@
// Verifies land parcels specifically get covered via a trust setup in Nomination Registry.
const { chromium } = require('playwright');
const { signInFreshFamily } = require('./e2e-auth-helper.cjs');
const { signInFreshFamily, gotoTab } = require('./e2e-auth-helper.cjs');
const BASE = 'https://moslem04.falahos.my/';
const results = [];
const consoleErrors = [];
@@ -13,7 +13,7 @@ async function main() {
page.on('pageerror', e => consoleErrors.push(e.message));
await signInFreshFamily(page, BASE, 'e2e-trust');
const clickTab = async label => { await page.locator('nav button.tab', { hasText: label }).click(); await page.waitForTimeout(500); };
const clickTab = async label => { await gotoTab(page, label); await page.waitForTimeout(500); };
// Add a Property-type asset (land parcel)
await clickTab('Assets');
+14 -13
View File
@@ -1,7 +1,7 @@
// Full E2E UAT against the live moslem04.falahos.my deployment.
// Simulates real human interaction: clicks, typed input, waits — not just DOM assertions.
const { chromium } = require('playwright');
const { signInFreshFamily } = require('./e2e-auth-helper.cjs');
const { signInFreshFamily, gotoTab } = require('./e2e-auth-helper.cjs');
const BASE = 'https://moslem04.falahos.my/';
const results = [];
@@ -25,15 +25,16 @@ async function main() {
const tabs = ['Faraid', 'Assets', 'Wassiyah', 'Hibah', 'Family Waqf', 'Claims (H2)', 'Settings'];
for (const label of tabs) {
const tabBtn = page.locator('nav button.tab', { hasText: label });
await tabBtn.click();
await gotoTab(page, label);
await page.waitForTimeout(500);
const isActive = await tabBtn.evaluate(el => el.classList.contains('active'));
const isActive = label === 'Settings'
? await page.locator('.settings-btn').evaluate(el => el.classList.contains('active'))
: await page.locator('.subnav .tab', { hasText: label }).evaluate(el => el.classList.contains('active'));
record(`Nav: click "${label}" tab activates it`, isActive);
}
// ── Faraid Calculator: textbook case (wife + daughter + father + mother) ──
await page.locator('nav button.tab', { hasText: 'Faraid' }).click();
await gotoTab(page, 'Faraid');
await page.waitForTimeout(500);
await page.locator('.field:has-text("Number of surviving wives") input').fill('1');
await page.locator('.field:has-text("Daughters") input').fill('1');
@@ -48,7 +49,7 @@ async function main() {
record('Faraid: wife+daughter+father+mother produces textbook shares', hasWife && hasDaughter && hasMother && hasFather, shareRows.join(' | '));
// ── Asset Registry: add an asset ──
await page.locator('nav button.tab', { hasText: 'Assets' }).click();
await gotoTab(page, 'Assets');
await page.waitForTimeout(500);
await page.locator('.field:has-text("Description") input').fill('Terrace house, Shah Alam');
await page.locator('.field:has-text("Estimated value") input').fill('600000');
@@ -70,7 +71,7 @@ async function main() {
record('Asset Registry: second asset accumulates total', total2.includes('750,000'), total2);
// ── Wassiyah Generator: 1/3 meter + heir-exclusion block ──
await page.locator('nav button.tab', { hasText: 'Wassiyah' }).click();
await gotoTab(page, 'Wassiyah');
await page.waitForTimeout(500);
const capText = await page.locator('.meter-row:has-text("One-third limit") strong').textContent();
record('Wassiyah: one-third meter reflects Asset Registry total (750,000/3=250,000)', capText.includes('250,000'), capText);
@@ -110,7 +111,7 @@ async function main() {
record('Wassiyah: acknowledging override enables export', exportEnabledAfterAck);
// ── Hibah Tracker: marad al-mawt guard, blocked-heir path ──
await page.locator('nav button.tab', { hasText: 'Hibah' }).click();
await gotoTab(page, 'Hibah');
await page.waitForTimeout(500);
await page.locator('.field:has-text("Recipient") input').fill('My Daughter');
await page.locator('.field:has-text("Relation to you") input').fill('daughter');
@@ -127,7 +128,7 @@ async function main() {
record('Hibah: flagged + heir beneficiary blocks confirm', guardErrorVisible && confirmDisabled, 'recipient=daughter, flagged=yes');
// ── Family Waqf Designator: open note + beneficiary flow ──
await page.locator('nav button.tab', { hasText: 'Family Waqf' }).click();
await gotoTab(page, 'Family Waqf');
await page.waitForTimeout(500);
const openNoteVisible = await page.locator('.open-note').isVisible();
record('Family Waqf: open fiqh-question note is visible (OPEN-01 transparency)', openNoteVisible);
@@ -145,7 +146,7 @@ async function main() {
await page.waitForTimeout(500);
// ── Horizon 2 Claims: pre-pilot banner + issue + transfer restriction ──
await page.locator('nav button.tab', { hasText: 'Claims (H2)' }).click();
await gotoTab(page, 'Claims (H2)');
await page.waitForTimeout(500);
const pilotBannerVisible = await page.locator('.pilot-banner').isVisible();
const bannerText = await page.locator('.pilot-banner').textContent();
@@ -171,7 +172,7 @@ async function main() {
record('Claims: transfer-within-pool updates status', statusAfterTransfer.trim() === 'transferred', statusAfterTransfer);
// ── Settings: export + delete-all guarded by confirm() ──
await page.locator('nav button.tab', { hasText: 'Settings' }).click();
await gotoTab(page, 'Settings');
await page.waitForTimeout(500);
const exportBtnVisible = await page.locator('button.btn-secondary', { hasText: 'Export local export' }).isVisible();
const deleteBtnVisible = await page.locator('button.btn-danger', { hasText: 'Delete local device data' }).isVisible();
@@ -188,13 +189,13 @@ async function main() {
page.once('dialog', async d => { record('Settings: delete-all is guarded by a confirm() dialog', d.type() === 'confirm'); await d.dismiss(); });
await page.locator('button.btn-danger', { hasText: 'Delete local device data' }).click();
await page.waitForTimeout(500);
await page.locator('nav button.tab', { hasText: 'Assets' }).click();
await gotoTab(page, 'Assets');
await page.waitForTimeout(500);
const dataStillThereAfterDismiss = await page.locator('.asset-row', { hasText: 'Terrace house' }).isVisible();
record('Settings: dismissing delete confirm leaves data intact', dataStillThereAfterDismiss);
// ── Bilingual: language switcher click actually changes visible UI text ──
await page.locator('nav button.tab', { hasText: 'Settings' }).click();
await gotoTab(page, 'Settings');
await page.waitForTimeout(500);
const taglineBefore = await page.locator('.header-tagline').textContent();
await page.locator('.lang-btn', { hasText: 'Bahasa Malaysia' }).click();
+69
View File
@@ -0,0 +1,69 @@
// Verifies the Zakat calculator: entering zakatable wealth computes the
// correct 2.5% due once above Nisab, values autosave and persist across a
// remount, and a fresh family (same account) doesn't see this member's figures.
const { chromium } = require('playwright');
const { signInFreshFamily, gotoTab } = require('./e2e-auth-helper.cjs');
const BASE = 'https://moslem04.falahos.my/';
const results = [];
const consoleErrors = [];
function record(name, pass, detail = '') { results.push({ name, pass, detail }); console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`); }
async function main() {
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
page.on('console', m => { if (m.type() === 'error') consoleErrors.push(m.text()); });
page.on('pageerror', e => consoleErrors.push(e.message));
await signInFreshFamily(page, BASE, 'e2e-zakat');
await gotoTab(page, 'Zakat');
await page.waitForTimeout(600);
// Below nisab: default nisab is 24000, wealth 10000 -> no zakat due
await page.locator('.field:has-text("Cash") input').fill('10000');
await page.waitForTimeout(1000);
const belowNisabText = await page.locator('.result-row.main span').textContent();
record('Zakat: below Nisab shows "no Zakat due"', belowNisabText.includes('Below Nisab'), belowNisabText);
// Above nisab: cash 30000, gold 10000 = 40000 wealth, nisab 24000 -> due = 40000*0.025 = 1000
await page.locator('.field:has-text("Cash") input').fill('30000');
await page.locator('.field:has-text("Gold") input').fill('10000');
await page.waitForTimeout(1000);
const dueText = await page.locator('.result-row.main strong').textContent();
record('Zakat: correct 2.5% computed once above Nisab', dueText.trim() === '1,000', dueText);
const wealthText = await page.locator('.result-row', { hasText: 'Zakatable wealth' }).locator('strong').textContent();
record('Zakat: zakatable wealth sums categories correctly', wealthText.trim() === '40,000', wealthText);
// Deductible liabilities reduce the base
await page.locator('.field:has-text("Deductible liabilities") input').fill('5000');
await page.waitForTimeout(1000);
const wealthAfterDebt = await page.locator('.result-row', { hasText: 'Zakatable wealth' }).locator('strong').textContent();
record('Zakat: deductible liabilities reduce zakatable wealth', wealthAfterDebt.trim() === '35,000', wealthAfterDebt);
// Persistence: reload the tab (remount) and confirm autosave held
await gotoTab(page, 'Coverage');
await page.waitForTimeout(400);
await gotoTab(page, 'Zakat');
await page.waitForTimeout(800);
const cashPersisted = await page.locator('.field:has-text("Cash") input').inputValue();
record('Zakat: autosaved figures persist across a tab remount', cashPersisted === '30000', cashPersisted);
// Isolation: a different family (same account) starts with defaults, not this member's figures
const isolationPage = await browser.newPage({ viewport: { width: 390, height: 844 } });
await signInFreshFamily(isolationPage, BASE, 'e2e-zakat-isolation');
await gotoTab(isolationPage, 'Zakat');
await isolationPage.waitForTimeout(600);
const isolatedCash = await isolationPage.locator('.field:has-text("Cash") input').inputValue();
record('Isolation: a different family does not see this member\'s Zakat figures', isolatedCash === '', isolatedCash);
await isolationPage.close();
record('No uncaught JS console errors during full session', consoleErrors.length === 0, consoleErrors.join(' || '));
await browser.close();
const passCount = results.filter(r => r.pass).length;
const failCount = results.length - passCount;
console.log(`\n${passCount} passed, ${failCount} failed, ${results.length} total`);
if (failCount > 0) results.filter(r => !r.pass).forEach(r => console.log(` - ${r.name}: ${r.detail}`));
process.exit(failCount > 0 ? 1 : 0);
}
main().catch(e => { console.error('SCRIPT ERROR:', e); process.exit(2); });
+123 -26
View File
@@ -13,12 +13,23 @@
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';
import KhairatTracker from './lib/KhairatTracker.svelte';
import QiblaFinder from './lib/QiblaFinder.svelte';
import PrayerTimes from './lib/PrayerTimes.svelte';
import Locators from './lib/Locators.svelte';
import QuranReader from './lib/QuranReader.svelte';
import NeighbourhoodBoard from './lib/NeighbourhoodBoard.svelte';
import SupportTab from './lib/SupportTab.svelte';
let currentLang = $state('en');
lang.subscribe(v => currentLang = v);
@@ -42,13 +53,84 @@
}
});
const tabs = ['Coverage', 'Faraid', 'Assets', 'Insurance', 'Wassiyah', 'Hibah', 'Family Waqf', 'Nominate', 'Trigger', 'Mutawalli', 'Tree', 'Claims (H2)', 'Family', 'Settings'];
const icons = ['🎯', '📊', '📁', '🛡️', '📜', '🎁', '⛲', '📇', '⚡', '🕋', '🌳', '🔗', '👥', '⚙️'];
let activeTab = $state(0);
// Five hubs instead of one 22-item flat tab strip — grouped by what the
// user is actually trying to do (plan the estate, give, manage the
// family, or use it day-to-day), not alphabetically or by build order.
// Research consistently points to 4-5 primary destinations as the
// ceiling for a bottom nav; everything else lives one tap deeper inside
// its hub instead of competing for space in a single scrolling row.
const HUBS = [
{ key: 'home', label: 'Home', icon: '🎯', tabs: [
{ label: 'Coverage', icon: '🎯', component: CoverageDashboard }
] },
{ key: 'estate', label: 'Estate', icon: '📁', tabs: [
{ label: 'Assets', icon: '📁', component: AssetRegistry },
{ label: 'Faraid', icon: '📊', component: FaraidCalculator },
{ label: 'Insurance', icon: '🛡️', component: InsurancePolicies },
{ label: 'Wassiyah', icon: '📜', component: WassiyahGenerator },
{ label: 'Hibah', icon: '🎁', component: HibahTracker },
{ label: 'Family Waqf', icon: '⛲', component: FamilyWaqfDesignator },
{ label: 'Nominate', icon: '📇', component: NominationRegistry },
{ label: 'Claims (H2)', icon: '🔗', component: DigitalClaims }
] },
{ key: 'giving', label: 'Giving', icon: '🤲', tabs: [
{ label: 'Zakat', icon: '🌙', component: ZakatCalculator },
{ label: 'Sadaqah', icon: '🤲', component: SadaqahTracker },
{ label: 'Khairat', icon: '🆘', component: KhairatTracker },
{ label: 'Support', icon: '🌱', component: SupportTab }
] },
{ key: 'family', label: 'Family', icon: '👪', tabs: [
{ label: 'Tree', icon: '🌳', component: FamilyTree },
{ label: 'Trigger', icon: '⚡', component: DeathTrigger },
{ label: 'Mutawalli', icon: '🕋', component: MutawalliDashboard },
{ label: 'Manage', icon: '👥', component: FamilyManagement },
{ label: 'Neighbourhood', icon: '📢', component: NeighbourhoodBoard }
] },
{ key: 'daily', label: 'Daily', icon: '🕌', tabs: [
{ label: 'Prayer Times', icon: '🕌', component: PrayerTimes },
{ label: 'Qibla', icon: '🧭', component: QiblaFinder },
{ label: 'Quran', icon: '📖', component: QuranReader },
{ label: 'Locate', icon: '📍', component: Locators }
] }
];
let activeHubKey = $state('home');
// Remembers which sub-tab was last open in each hub, so switching hubs
// and back doesn't reset your place.
let subIndexByHub = $state(Object.fromEntries(HUBS.map(h => [h.key, 0])));
let showSettings = $state(false);
const activeHub = $derived(HUBS.find(h => h.key === activeHubKey));
const activeSubTab = $derived(activeHub.tabs[subIndexByHub[activeHubKey]] ?? activeHub.tabs[0]);
const CurrentComponent = $derived(activeSubTab.component);
function selectHub(key) {
activeHubKey = key;
showSettings = false;
}
function selectSubTab(index) {
subIndexByHub = { ...subIndexByHub, [activeHubKey]: index };
showSettings = false;
}
// Cross-component navigation (e.g. the Family Tree's "give sadaqah in
// memory of" link) requests a tab by label — resolve it to whichever
// hub actually contains that label.
requestedTab.subscribe(name => {
if (!name) return;
for (const hub of HUBS) {
const idx = hub.tabs.findIndex(t => t.label === name);
if (idx >= 0) { activeHubKey = hub.key; subIndexByHub = { ...subIndexByHub, [hub.key]: idx }; showSettings = false; break; }
}
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;
const tabs = activeHub.tabs;
if (tabs.length < 2) return;
const current = subIndexByHub[activeHubKey] ?? 0;
if (e.key === 'ArrowRight') selectSubTab((current + 1) % tabs.length);
if (e.key === 'ArrowLeft') selectSubTab((current - 1 + tabs.length) % tabs.length);
}
function doExport() {
@@ -79,6 +161,7 @@
{:else}
<div class="app">
<header>
<button class="settings-btn" class:active={showSettings} onclick={() => showSettings = !showSettings} aria-label="Settings">⚙️</button>
<div class="header-brand">
<div class="brand-line brand-line-first">Nur</div>
<div class="brand-line brand-line-second">Falah</div>
@@ -87,30 +170,19 @@
<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>
{#if !showSettings && activeHub.tabs.length > 1}
<nav class="subnav">
{#each activeHub.tabs as t, i}
<button class="tab" class:active={subIndexByHub[activeHubKey] === i} onclick={() => selectSubTab(i)} aria-label={t.label}>
<span class="tab-icon">{t.icon}</span>
<span class="tab-label">{t.label}</span>
</button>
{/each}
</nav>
{/if}
<main>
{#if activeTab === 0}<CoverageDashboard />
{:else if activeTab === 1}<FaraidCalculator />
{:else if activeTab === 2}<AssetRegistry />
{:else if activeTab === 3}<InsurancePolicies />
{:else if activeTab === 4}<WassiyahGenerator />
{:else if activeTab === 5}<HibahTracker />
{:else if activeTab === 6}<FamilyWaqfDesignator />
{:else if activeTab === 7}<NominationRegistry />
{:else if activeTab === 8}<DeathTrigger />
{:else if activeTab === 9}<MutawalliDashboard />
{:else if activeTab === 10}<FamilyTree />
{:else if activeTab === 11}<DigitalClaims />
{:else if activeTab === 12}<FamilyManagement />
{:else if activeTab === 13}
{#if showSettings}
<div class="module">
<div class="module-header">
<h2>Settings</h2>
@@ -123,6 +195,8 @@
</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">
@@ -135,8 +209,19 @@
<button class="btn-secondary" onclick={signOut}>Sign out</button>
<button class="btn-danger" onclick={doDelete}>Delete local device data irreversible</button>
</div>
{:else}
<CurrentComponent />
{/if}
</main>
<nav class="hub-nav">
{#each HUBS as hub}
<button class="hub-tab" class:active={!showSettings && activeHubKey === hub.key} onclick={() => selectHub(hub.key)} aria-label={hub.label}>
<span class="hub-icon">{hub.icon}</span>
<span class="hub-label">{hub.label}</span>
</button>
{/each}
</nav>
</div>
{/if}
@@ -161,9 +246,11 @@
: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; }
.app { max-width: 480px; margin: 0 auto; min-height: 100dvh; display: flex; flex-direction: column; padding-bottom: 88px; }
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; }
.settings-btn { position: absolute; top: 20px; right: 14px; background: none; border: none; font-size: 18px; cursor: pointer; opacity: 0.7; padding: 6px; border-radius: 8px; }
.settings-btn.active { opacity: 1; background: rgba(201,168,76,0.12); }
.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; }
@@ -171,7 +258,9 @@
.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; }
/* Sub-nav — this hub's own tabs, revealed one level below the primary
bottom nav rather than competing with 21 other items for space. */
.subnav { 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; }
@@ -179,6 +268,14 @@
main { flex: 1; padding: 16px; }
/* Primary nav — 5 hubs, fixed to the bottom so every destination stays
in thumb reach, matching the pattern every reference app converges on. */
.hub-nav { display: flex; position: fixed; bottom: 0; left: 50%; transform: translateX(-50%); width: 100%; max-width: 480px; background: rgba(12,17,23,0.97); backdrop-filter: blur(12px); border-top: 1px solid rgba(201,168,76,0.15); z-index: 11; padding: 6px 4px calc(6px + env(safe-area-inset-bottom, 0px)); }
.hub-tab { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 2px; padding: 8px 2px; background: none; border: none; border-radius: 10px; cursor: pointer; color: #8A8478; }
.hub-tab.active { color: #C9A84C; }
.hub-icon { font-size: 20px; }
.hub-label { font-size: 10px; }
: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; }
+14 -6
View File
@@ -23,7 +23,8 @@
let form = $state(emptyForm());
let editingId = $state(null);
let contactForm = $state({ name: '', method: '' });
let contactForm = $state({ name: '', method: '', category: 'family', email: '' });
const CONTACT_CATEGORIES = ['family', 'mosque', 'khairat', 'police', 'ambulance', 'hospital', 'other'];
let liabilityForm = $state(emptyLiabilityForm());
let editingLiabilityId = $state(null);
@@ -74,8 +75,8 @@
async function addContact() {
if (!contactForm.name) return;
await addTrustedContact(familyId, contactForm.name, contactForm.method);
contactForm = { name: '', method: '' };
await addTrustedContact(familyId, contactForm.name, contactForm.method, contactForm.category, contactForm.email);
contactForm = { name: '', method: '', category: 'family', email: '' };
await refresh();
}
@@ -226,14 +227,20 @@
<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>
<p class="note">Notified automatically by email when your mutawalli fires your death trigger — mosque, khairat officer, police, ambulance/hospital, or family. Not an automated death-detection 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>
<label class="field"><span>Category</span>
<select bind:value={contactForm.category}>
{#each CONTACT_CATEGORIES as cat}<option value={cat}>{cat}</option>{/each}
</select>
</label>
<label class="field"><span>Contact method (phone, etc.)</span><input type="text" bind:value={contactForm.method} placeholder="e.g. phone number" /></label>
<label class="field"><span>Email (for automatic notification)</span><input type="email" bind:value={contactForm.email} placeholder="required to auto-notify this contact" /></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>
<div class="contact-row"><span><span class="contact-category">{c.category}</span> {c.name}{c.method}{c.email ? ` · ${c.email}` : ''}</span><button onclick={() => removeContact(c.id)}>✕</button></div>
{/each}
</div>
@@ -332,6 +339,7 @@
.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-category { font-size: 10px; text-transform: uppercase; letter-spacing: 0.3px; color: #C9A84C; background: rgba(201,168,76,0.1); padding: 1px 6px; border-radius: 4px; margin-right: 6px; }
.verify-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 0 0 12px; margin-top: -6px; border-bottom: 1px solid rgba(255,255,255,0.06); }
.verify-badge { font-size: 10.5px; padding: 2px 8px; border-radius: 999px; background: rgba(255,255,255,0.06); color: #8A8478; }
.verify-badge.verified { background: rgba(46,204,113,0.15); color: #2ECC71; }
+66 -6
View File
@@ -4,27 +4,62 @@
// still fall into the slow court queue because no fast-path instrument covers it.
import { onMount } from 'svelte';
import { computeCoverage, suggestedChannel } from './nonprobate.js';
import { activeFamilyId } from './family.js';
import { listAssets, listHibahGifts, listNominations, listAllWaqfForFamily } from './db.js';
import { buildRecommendations } from './recommendations.js';
import { activeFamilyId, listFamilyMembers } from './family.js';
import { session } from './auth.js';
import {
listAssets, listHibahGifts, listNominations, listAllWaqfForFamily,
listWassiyahBequests, listInsurancePolicies, listLiabilities, getZakatRecord,
getMemberTrigger, listMemberAttestors, listPeople, listRelationships
} from './db.js';
import Disclaimer from './Disclaimer.svelte';
import InfoPanel from './InfoPanel.svelte';
let familyId = $state(null);
activeFamilyId.subscribe(v => familyId = v);
let memberId = $state(null);
session.subscribe(v => memberId = v?.user?.id ?? null);
let coverage = $state({ rows: [], total: 0, fastTotal: 0, exposedTotal: 0, fastPercent: 0 });
let recommendations = $state([]);
async function refresh() {
if (!familyId) return;
const [assets, gifts, nominations, waqfDesignations] = await Promise.all([
listAssets(familyId), listHibahGifts(familyId), listNominations(familyId), listAllWaqfForFamily(familyId)
if (!familyId || !memberId) return;
const [assets, gifts, nominations, waqfDesignations, wassiyah, insurance, liabilities, zakat, members, trigger, people, relationships] = await Promise.all([
listAssets(familyId), listHibahGifts(familyId), listNominations(familyId), listAllWaqfForFamily(familyId),
listWassiyahBequests(familyId, memberId), listInsurancePolicies(familyId, memberId), listLiabilities(familyId),
getZakatRecord(familyId, memberId), listFamilyMembers(familyId), getMemberTrigger(memberId, familyId),
listPeople(familyId), listRelationships(familyId)
]);
const waqfCorpusIds = waqfDesignations.map(w => w.corpus_asset_id).filter(Boolean);
coverage = computeCoverage({ assets, gifts, nominations, waqfCorpusIds });
const attestors = trigger ? await listMemberAttestors(memberId, familyId) : [];
const myWaqf = waqfDesignations.find(w => w.author_id === memberId);
const hasCashOrGold = assets.some(a => a.type === 'Cash / Bank' || a.type === 'Jewelry / valuables');
const linkedPersonIds = new Set(relationships.flatMap(r => [r.personAId, r.personBId]));
recommendations = buildRecommendations({
exposedTotal: coverage.exposedTotal,
exposedCount: coverage.rows.filter(r => !r.fast).length,
unverifiedCount: assets.filter(a => !a.verified).length,
wassiyahCount: wassiyah.length,
estateTotal: coverage.total,
insuranceCount: insurance.length,
zakatConfigured: !!zakat && Number(zakat.nisabThreshold) > 0,
hasCashOrGold,
unlinkedLiabilityCount: liabilities.filter(l => !l.linkedAssetId).length,
confirmedAttestorCount: attestors.filter(a => a.confirmed).length,
hasAgent: members.some(m => m.role === 'agent' && m.status === 'active'),
waqfConfigured: !!myWaqf,
missingBirthDateCount: people.filter(p => !p.birthDate).length,
missingPhotoCount: people.filter(p => !p.photoPath).length,
orphanPersonCount: people.filter(p => !linkedPersonIds.has(p.id)).length
});
}
onMount(refresh);
$effect(() => { familyId; refresh(); });
$effect(() => { familyId; memberId; refresh(); });
const CHANNEL_LABELS = {
hibah: 'Hibah — lifetime gift, completed',
@@ -85,6 +120,21 @@
</div>
{/if}
{#if recommendations.length > 0}
<div class="recommendations">
<h3>Recommended next steps</h3>
{#each recommendations as rec}
<div class="rec-row rec-{rec.severity}">
<span class="rec-dot"></span>
<div class="rec-body">
<p>{rec.text}</p>
<span class="rec-tab">Go to: {rec.tab}</span>
</div>
</div>
{/each}
</div>
{/if}
<div class="asset-list">
{#each coverage.rows as r (r.asset.id)}
{@const suggestion = !r.fast ? suggestedChannel(r.asset.type) : null}
@@ -140,4 +190,14 @@
.status-dot { width: 10px; height: 10px; border-radius: 50%; background: #EF4444; margin-top: 4px; flex-shrink: 0; }
.status-dot.on { background: #2ECC71; }
.empty { font-size: 13px; color: #8A8478; text-align: center; padding: 20px 0; }
.recommendations { margin-bottom: 20px; }
.recommendations h3 { font-size: 14px; color: #C9A84C; margin-bottom: 10px; font-family: 'DM Serif Display', serif; }
.rec-row { display: flex; gap: 10px; padding: 10px 0; border-bottom: 1px solid rgba(255,255,255,0.06); }
.rec-dot { width: 8px; height: 8px; border-radius: 50%; margin-top: 5px; flex-shrink: 0; }
.rec-high .rec-dot { background: #EF4444; }
.rec-medium .rec-dot { background: #C9A84C; }
.rec-low .rec-dot { background: #8A8478; }
.rec-body p { font-size: 12.5px; color: #E8E4DC; line-height: 1.5; margin-bottom: 3px; }
.rec-tab { font-size: 10.5px; color: #8A8478; text-transform: uppercase; letter-spacing: 0.3px; }
</style>
+21 -1
View File
@@ -9,8 +9,9 @@
import {
listPeople, addPerson, updatePerson, removePerson,
uploadPersonPhoto, removePersonPhoto, getPersonPhotoUrl,
listRelationships, addRelationship, removeRelationship
listRelationships, addRelationship, removeRelationship, getMemorialSadaqahCount
} from './db.js';
import { requestedTab } from './nav.js';
import InfoPanel from './InfoPanel.svelte';
import Disclaimer from './Disclaimer.svelte';
@@ -20,6 +21,7 @@
let people = $state([]);
let relationships = $state([]);
let photoUrls = $state({}); // personId -> signed url
let memorialCounts = $state({}); // personId -> count
let error = $state('');
let form = $state({ fullName: '', gender: '', birthDate: '', deathDate: '', notes: '' });
@@ -39,6 +41,14 @@
people.filter(p => p.photoPath).map(async p => [p.id, await getPersonPhotoUrl(p.photoPath)])
);
photoUrls = Object.fromEntries(entries);
const memorialEntries = await Promise.all(
people.filter(p => p.deathDate).map(async p => [p.id, await getMemorialSadaqahCount(p.id)])
);
memorialCounts = Object.fromEntries(memorialEntries);
}
function giveInMemory() {
requestedTab.set('Sadaqah');
}
onMount(refresh);
@@ -239,6 +249,14 @@
{#if expandedId === p.id}
<div class="person-detail">
{#if p.notes}<p class="notes">{p.notes}</p>{/if}
{#if p.deathDate}
<div class="memorial-row">
{#if memorialCounts[p.id] > 0}
<span class="memorial-count">{memorialCounts[p.id]} sadaqah given in their memory</span>
{/if}
<button class="btn-small" onclick={giveInMemory}>Give sadaqah in memory of {p.fullName}</button>
</div>
{/if}
<div class="photo-controls">
<label class="upload-btn">
{uploadingFor === p.id ? 'Uploading…' : (photoUrls[p.id] ? 'Replace photo' : 'Upload photo')}
@@ -328,6 +346,8 @@
.muted { color: #8A8478; font-size: 11px; }
.person-detail { padding: 0 12px 12px; }
.notes { font-size: 12px; color: #B8B2A6; margin-bottom: 10px; }
.memorial-row { display: flex; flex-direction: column; gap: 6px; margin-bottom: 10px; }
.memorial-count { font-size: 11px; color: #C9A84C; }
.photo-controls { display: flex; gap: 8px; margin-bottom: 10px; align-items: center; }
.upload-btn { position: relative; background: rgba(201,168,76,0.15); color: #C9A84C; border-radius: 8px; padding: 8px 12px; font-size: 12px; cursor: pointer; font-weight: 600; }
.upload-btn input[type=file] { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
+21 -2
View File
@@ -1,8 +1,10 @@
<script>
import { calculateFaraid, applyEstateValue } from './calc/faraid.js';
import { calculateFaraid, applyEstateValue, SUPPORTED_MADHABS, MADHAB_LABELS } from './calc/faraid.js';
import Disclaimer from './Disclaimer.svelte';
import InfoPanel from './InfoPanel.svelte';
const MADHAB_OPTIONS = [...SUPPORTED_MADHABS, 'jaafari'];
let madhab = $state('shafii');
let deceasedGender = $state('male');
let estateValue = $state(0);
let spouseCount = $state(0);
@@ -23,7 +25,8 @@
const r = calculateFaraid({
deceasedGender, spouseCount, sons, daughters, father, mother,
fullBrothers, fullSisters, paternalBrothers, paternalSisters, maternalSiblings
});
}, madhab);
if (r.unsupported) return r;
return applyEstateValue(r, estateValue || 0);
});
</script>
@@ -36,6 +39,7 @@
what="This works out the fixed Islamic inheritance shares — who in your family is legally entitled to what, according to the Quran. Think of it as 'if this asset ends up going through the normal court process, here's how it would be split.'"
how="Answer simple questions about who in your family is still alive: spouse, children, parents, siblings. The calculator does the math instantly — no need to know any religious terminology."
fields={[
{ label: 'Madhab', hint: 'The school of jurisprudence to apply. Most calculations are identical across schools — this only affects the rare case of a spouse being the sole heir with no other relative at all.' },
{ label: 'Deceased\'s gender', hint: 'Whose estate is being calculated.' },
{ label: 'Estate value', hint: 'Optional — enter a number if you want to see actual amounts, not just fractions.' },
{ label: 'Wives / husband, sons, daughters, father, mother', hint: 'Tick or enter a count for whoever is still alive.' },
@@ -43,6 +47,16 @@
]}
/>
</div>
<label class="field">
<span>Madhab (school of jurisprudence)</span>
<select bind:value={madhab}>
{#each MADHAB_OPTIONS as m}<option value={m}>{MADHAB_LABELS[m]}</option>{/each}
</select>
</label>
{#if madhab === 'jaafari'}
<div class="reframe-note">Ja'fari (Shia) inheritance uses a fundamentally different classification system, not a variant of the Sunni rules this engine models — it is not calculated here to avoid showing you a wrong number. Please consult a Ja'fari-qualified scholar or a dedicated Shia inheritance tool.</div>
{/if}
<div class="reframe-note">This calculator is informational — it shows what would apply to whatever is <em>left</em> in your estate. It is not itself a fast-path mechanism: anything still in your estate at death goes through the conventional Faraid/probate process. Check the Coverage Dashboard and use Hibah/Waqf/Nomination to move assets out of this calculation entirely.</div>
<p class="sub">Answer simple questions about surviving family — no fiqh terminology needed.</p>
@@ -84,6 +98,7 @@
{/if}
{/if}
{#if !result.unsupported}
<div class="results">
<h3>Shares</h3>
{#each result.shares as s}
@@ -103,7 +118,11 @@
{#if result.isUmariyyatayn}
<p class="note-box">Umariyyatayn / gharrawain ruling applied: the mother's share is one-third of the remainder after the spouse's share, not one-third of the whole estate.</p>
{/if}
{#if result.unallocatedResidue > 0}
<p class="note-box">{(result.unallocatedResidue * 100).toFixed(1)}% of the estate is unallocated under {MADHAB_LABELS[madhab]} fiqh — the spouse is excluded from radd in this school, so this remainder is not distributed to a private heir (classically: held for the public treasury / Bayt al-Mal). Consult a local scholar/authority on how this is handled in your jurisdiction.</p>
{/if}
</div>
{/if}
<Disclaimer />
</div>
+58
View File
@@ -0,0 +1,58 @@
<script>
// Whole-app jurisdiction setting, owner-only to change (enforced by RLS on
// nf_families, not just this UI). Drives Wassiyah legal notes/will document
// format and Zakat currency defaults elsewhere in the app.
import { onMount } from 'svelte';
import { activeFamilyId, listMyFamilies, getFamilyJurisdiction, setFamilyJurisdiction } from './family.js';
const JURISDICTIONS = [
{ value: 'MY', label: 'Malaysia' },
{ value: 'SG', label: 'Singapore' },
{ value: 'UK', label: 'United Kingdom' }
];
let familyId = $state(null);
activeFamilyId.subscribe(v => familyId = v);
let jurisdiction = $state('MY');
let isOwner = $state(false);
let saved = $state(false);
async function refresh() {
if (!familyId) return;
const [j, families] = await Promise.all([getFamilyJurisdiction(familyId), listMyFamilies()]);
jurisdiction = j;
isOwner = families.find(f => f.id === familyId)?.role === 'owner';
}
onMount(refresh);
$effect(() => { familyId; refresh(); });
async function save() {
await setFamilyJurisdiction(familyId, jurisdiction);
saved = true;
setTimeout(() => saved = false, 2000);
}
</script>
<div class="jurisdiction-setting">
<span class="label">Jurisdiction</span>
{#if isOwner}
<select bind:value={jurisdiction} onchange={save}>
{#each JURISDICTIONS as j}<option value={j.value}>{j.label}</option>{/each}
</select>
{#if saved}<span class="saved">Saved</span>{/if}
{:else}
<span class="readonly">{JURISDICTIONS.find(j => j.value === jurisdiction)?.label || jurisdiction} (only the head of family can change this)</span>
{/if}
<p class="note">Drives the legal notes shown in Wassiyah, the draft will document format, and Zakat currency defaults.</p>
</div>
<style>
.jurisdiction-setting { margin-bottom: 16px; }
.label { display: block; font-size: 12px; color: #B8B2A6; margin-bottom: 6px; }
select { 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; width: 100%; }
.readonly { font-size: 13.5px; color: #E8E4DC; }
.saved { font-size: 11px; color: #2ECC71; margin-left: 8px; }
.note { font-size: 11px; color: #8A8478; margin-top: 6px; line-height: 1.4; }
</style>
+140
View File
@@ -0,0 +1,140 @@
<script>
import { onMount } from 'svelte';
import { activeFamilyId } from './family.js';
import { session } from './auth.js';
import { listKhairatMemberships, addKhairatMembership, removeKhairatMembership, getEmergencyFund, upsertEmergencyFund } from './db.js';
import Disclaimer from './Disclaimer.svelte';
import InfoPanel from './InfoPanel.svelte';
let familyId = $state(null);
activeFamilyId.subscribe(v => familyId = v);
// Live off the session store, not a one-time snapshot — same fix as every
// other per-member module in this app: this component remounts on tab
// switch, and a stale null captured once at mount would silently break
// every write until the next remount.
let memberId = $state(null);
session.subscribe(v => memberId = v?.user?.id ?? null);
let memberships = $state([]);
let fund = $state({ targetAmount: '', currentBalance: '0', notes: '' });
let form = $state(emptyForm());
let fundSaveTimer;
function emptyForm() {
return { schemeName: '', organization: '', membershipNumber: '', contactPhone: '', contactEmail: '', notes: '' };
}
async function refresh() {
if (!familyId || !memberId) return;
memberships = (await listKhairatMemberships(familyId)).filter(k => k.memberId === memberId);
const f = await getEmergencyFund(familyId);
fund = f ? { targetAmount: String(f.targetAmount ?? ''), currentBalance: String(f.currentBalance ?? '0'), notes: f.notes || '' } : emptyForm();
}
onMount(refresh);
$effect(() => { familyId; memberId; refresh(); });
async function addMembership() {
if (!form.schemeName) return;
await addKhairatMembership(familyId, memberId, memberId, form);
form = emptyForm();
await refresh();
}
async function remove(id) {
await removeKhairatMembership(id);
await refresh();
}
function scheduleFundSave() {
clearTimeout(fundSaveTimer);
fundSaveTimer = setTimeout(() => { upsertEmergencyFund(familyId, fund); }, 500);
}
const fundProgress = $derived.by(() => {
const target = Number(fund.targetAmount) || 0;
const current = Number(fund.currentBalance) || 0;
if (target <= 0) return 0;
return Math.min(100, Math.round((current / target) * 100));
});
</script>
<div class="module">
<div class="module-header">
<h2>Khairat</h2>
<InfoPanel
title="Khairat"
what="Khairat is a mosque or community mutual-aid fund — most families should belong to one, and it's separate from your own insurance or Takaful. This tab records which scheme(s) you belong to so your mutawalli knows who to contact, plus a simple shared emergency fund your family can track together."
how="Log the mosque or organization running your khairat scheme, your membership number, and how to reach them. The emergency fund below is shared by the whole family — set a target and update the balance as it changes."
fields={[
{ label: 'Scheme name', hint: 'What the khairat scheme is called, e.g. \"Kariah Khairat Kematian\".' },
{ label: 'Organization', hint: 'The mosque or body that runs it.' },
{ label: 'Emergency fund', hint: 'A shared family reserve for urgent costs — separate from any khairat scheme.' }
]}
/>
</div>
<p class="sub">Your khairat scheme membership, plus a shared family emergency fund.</p>
<div class="form-card">
<label class="field"><span>Scheme name</span><input type="text" bind:value={form.schemeName} placeholder="e.g. Kariah Khairat Kematian" /></label>
<label class="field"><span>Organization / mosque</span><input type="text" bind:value={form.organization} /></label>
<label class="field"><span>Membership number</span><input type="text" bind:value={form.membershipNumber} /></label>
<label class="field"><span>Contact phone</span><input type="text" bind:value={form.contactPhone} /></label>
<label class="field"><span>Contact email</span><input type="email" bind:value={form.contactEmail} /></label>
<label class="field"><span>Notes</span><input type="text" bind:value={form.notes} /></label>
<button class="btn-primary" onclick={addMembership}>Add khairat membership</button>
</div>
<div class="list">
{#each memberships as k (k.id)}
<div class="khairat-row">
<div class="khairat-info">
<strong>{k.schemeName}</strong>
<span class="muted">{k.organization || '—'}{k.membershipNumber ? ` · #${k.membershipNumber}` : ''}</span>
<span class="muted">{k.contactPhone || ''}{k.contactPhone && k.contactEmail ? ' · ' : ''}{k.contactEmail || ''}</span>
</div>
<button onclick={() => remove(k.id)} aria-label="Remove"></button>
</div>
{:else}
<p class="empty">No khairat membership logged yet — most families should have at least one.</p>
{/each}
</div>
<div class="fund-section">
<h3>Family emergency fund</h3>
<div class="fund-card">
<label class="field"><span>Target amount</span><input type="number" min="0" bind:value={fund.targetAmount} oninput={scheduleFundSave} /></label>
<label class="field"><span>Current balance</span><input type="number" min="0" bind:value={fund.currentBalance} oninput={scheduleFundSave} /></label>
<label class="field"><span>Notes</span><input type="text" bind:value={fund.notes} oninput={scheduleFundSave} /></label>
{#if Number(fund.targetAmount) > 0}
<div class="fund-bar"><div class="fund-fill" style="width: {fundProgress}%"></div></div>
<p class="fund-progress-label">{fundProgress}% of target</p>
{/if}
</div>
</div>
<Disclaimer text="This app records intent and contact details only — it does not hold, move, or manage real money. Fund figures are self-reported by your family." />
</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; }
.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 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; width: 100%; }
.btn-primary { width: 100%; padding: 12px; border-radius: 8px; border: none; font-weight: 600; cursor: pointer; background: #C9A84C; color: #070A0D; }
.list { margin-bottom: 12px; }
.khairat-row { display: flex; align-items: center; gap: 10px; padding: 12px 0; border-bottom: 1px solid rgba(255,255,255,0.06); }
.khairat-info { display: flex; flex-direction: column; flex: 1; gap: 2px; }
.muted { color: #8A8478; font-size: 11.5px; }
.khairat-row button { background: none; border: none; color: #8A8478; cursor: pointer; padding: 4px 6px; }
.empty { font-size: 13px; color: #8A8478; text-align: center; padding: 20px 0; }
.fund-section { margin-top: 24px; border-top: 1px solid rgba(201,168,76,0.15); padding-top: 16px; }
.fund-section h3 { font-size: 15px; color: #C9A84C; margin-bottom: 10px; font-family: 'DM Serif Display', serif; }
.fund-card { background: rgba(255,255,255,0.03); border-radius: 12px; padding: 14px; }
.fund-bar { height: 8px; background: rgba(255,255,255,0.08); border-radius: 4px; overflow: hidden; margin: 8px 0 4px; }
.fund-fill { height: 100%; background: #2ECC71; transition: width 0.3s; }
.fund-progress-label { font-size: 11px; color: #8A8478; }
</style>
+103
View File
@@ -0,0 +1,103 @@
<script>
import { CATEGORIES, categoryLabel, searchNearby, directionsUrl } from './overpass.js';
import Disclaimer from './Disclaimer.svelte';
import InfoPanel from './InfoPanel.svelte';
let category = $state('mosque');
let status = $state('idle'); // idle | locating | searching | ready | error
let errorMsg = $state('');
let results = $state([]);
function search() {
status = 'locating';
errorMsg = '';
results = [];
if (!navigator.geolocation) {
status = 'error'; errorMsg = 'Geolocation is not available on this device/browser.';
return;
}
navigator.geolocation.getCurrentPosition(
async (pos) => {
status = 'searching';
try {
results = await searchNearby(category, pos.coords.latitude, pos.coords.longitude);
status = 'ready';
} catch (e) {
status = 'error'; errorMsg = 'Could not reach the location search service. ' + e.message;
}
},
(err) => { status = 'error'; errorMsg = err.message || 'Could not get your location.'; },
{ enableHighAccuracy: true, timeout: 10000 }
);
}
</script>
<div class="module">
<div class="module-header">
<h2>Locate</h2>
<InfoPanel
title="Locate"
what="Find nearby mosques, halal food, or cemeteries using OpenStreetMap's free community-mapped data — searched live from your location, never a fixed or fabricated list."
how="Pick a category, tap search, and allow location access. Results are sorted by distance. An empty result means nothing tagged nearby in OpenStreetMap yet — not that nothing exists."
fields={[]}
/>
</div>
<p class="sub">Real, live-searched results from OpenStreetMap — not a curated directory.</p>
<div class="category-toggle">
{#each CATEGORIES as c}
<button class:active={category === c} onclick={() => { category = c; results = []; status = 'idle'; }}>{categoryLabel(c)}</button>
{/each}
</div>
{#if status === 'idle'}
<button class="btn-primary" onclick={search}>Search nearby</button>
{:else if status === 'locating'}
<p class="status-text">Getting your location…</p>
{:else if status === 'searching'}
<p class="status-text">Searching OpenStreetMap…</p>
{:else if status === 'error'}
<p class="error-text">{errorMsg}</p>
<button class="btn-primary" onclick={search}>Try again</button>
{:else if status === 'ready'}
{#if results.length === 0}
<p class="empty">Nothing found nearby in OpenStreetMap's {categoryLabel(category).toLowerCase()} data. It may not be mapped yet in your area.</p>
{:else}
<div class="results-list">
{#each results as r (r.id)}
<a class="result-row" href={directionsUrl(r)} target="_blank" rel="noopener">
<div class="result-info">
<strong>{r.name}</strong>
{#if r.address}<span class="muted">{r.address}</span>{/if}
</div>
<span class="result-distance">{r.distanceKm < 1 ? `${Math.round(r.distanceKm * 1000)} m` : `${r.distanceKm.toFixed(1)} km`}</span>
</a>
{/each}
</div>
{/if}
<button class="btn-secondary" onclick={search}>Search again</button>
{/if}
<Disclaimer text="Data from OpenStreetMap contributors (ODbL license), not verified by Nur Falah. Confirm details (halal certification, opening hours, burial availability) directly before relying on them." />
</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; }
.category-toggle { display: flex; gap: 6px; margin-bottom: 16px; flex-wrap: wrap; }
.category-toggle button { flex: 1; min-width: 90px; padding: 10px 8px; border-radius: 8px; border: 1px solid rgba(201,168,76,0.2); background: rgba(255,255,255,0.03); color: #B8B2A6; font-size: 12px; cursor: pointer; }
.category-toggle button.active { background: rgba(201,168,76,0.15); color: #C9A84C; border-color: rgba(201,168,76,0.4); }
.btn-primary { width: 100%; padding: 14px; border-radius: 8px; border: none; font-weight: 600; cursor: pointer; background: #C9A84C; color: #070A0D; }
.btn-secondary { width: 100%; padding: 12px; border-radius: 8px; border: 1px solid rgba(201,168,76,0.3); font-weight: 600; cursor: pointer; background: rgba(255,255,255,0.05); color: #C9A84C; margin-top: 14px; }
.status-text { font-size: 13px; color: #8A8478; text-align: center; padding: 30px 0; }
.error-text { font-size: 13px; color: #EF4444; margin-bottom: 12px; }
.empty { font-size: 13px; color: #8A8478; text-align: center; padding: 20px 0; }
.results-list { display: flex; flex-direction: column; gap: 2px; }
.result-row { display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 12px; border-radius: 10px; background: rgba(255,255,255,0.03); text-decoration: none; margin-bottom: 6px; }
.result-info { display: flex; flex-direction: column; gap: 2px; }
.result-info strong { color: #E8E4DC; font-size: 13.5px; }
.muted { color: #8A8478; font-size: 11px; }
.result-distance { color: #C9A84C; font-size: 12px; font-weight: 600; white-space: nowrap; }
</style>
+37 -5
View File
@@ -7,10 +7,10 @@
import { activeFamilyId, listFamilyMembers, listMyFamilies } from './family.js';
import { currentUser } from './auth.js';
import {
listAllWassiyahForFamily, listAllWaqfForFamily, listAllInsuranceForFamily,
listAllWassiyahForFamily, listAllWaqfForFamily, listAllInsuranceForFamily, listAllWassiyahSettingsForFamily,
getMemberTrigger, upsertMemberTriggerSetup, fireMemberTrigger,
listMemberAttestors, addMemberAttestor, updateMemberAttestorName, setMemberAttestorConfirmed,
notifyHeirs
notifyHeirs, notifyEmergencyContacts
} from './db.js';
import InfoPanel from './InfoPanel.svelte';
import Disclaimer from './Disclaimer.svelte';
@@ -24,6 +24,7 @@
let wassiyahByAuthor = $state({});
let waqfByAuthor = $state({});
let insuranceByMember = $state({});
let wassiyahSettingsByAuthor = $state({});
let trigger = $state(null);
let attestors = $state([]);
@@ -32,11 +33,12 @@
let deathCertRef = $state('');
let error = $state('');
let notifyStatus = $state('');
let emergencyNotifyStatus = $state('');
async function refresh() {
if (!familyId) return;
const [allMembers, myFamilies, wassiyah, waqf, insurance] = await Promise.all([
listFamilyMembers(familyId), listMyFamilies(), listAllWassiyahForFamily(familyId), listAllWaqfForFamily(familyId), listAllInsuranceForFamily(familyId)
const [allMembers, myFamilies, wassiyah, waqf, insurance, wassiyahSettings] = await Promise.all([
listFamilyMembers(familyId), listMyFamilies(), listAllWassiyahForFamily(familyId), listAllWaqfForFamily(familyId), listAllInsuranceForFamily(familyId), listAllWassiyahSettingsForFamily(familyId)
]);
myRole = myFamilies.find(f => f.id === familyId)?.role;
members = allMembers.filter(m => m.status === 'active');
@@ -54,6 +56,9 @@
const insuranceGrouped = {};
for (const p of insurance) (insuranceGrouped[p.member_id] ??= []).push(p);
insuranceByMember = insuranceGrouped;
const settingsGrouped = {};
for (const s of wassiyahSettings) settingsGrouped[s.author_id] = s;
wassiyahSettingsByAuthor = settingsGrouped;
if (!selectedMemberId && members.length) selectedMemberId = members[0].user_id;
if (selectedMemberId) await loadMemberTrigger(selectedMemberId);
@@ -103,6 +108,12 @@
await upsertMemberTriggerSetup(selectedMemberId, familyId, { executorName, dateOfDeath, deathCertRef });
await fireMemberTrigger(selectedMemberId, familyId, currentUser()?.id);
trigger = await getMemberTrigger(selectedMemberId, familyId);
// Auto-notify emergency contacts (mosque, khairat, police, ambulance/
// hospital) the moment the trigger fires — this is the one event
// where "auto-send" is actually appropriate, not optional. Best-effort:
// a failure here shouldn't make the trigger fire itself look failed.
try { emergencyNotifyStatus = await notifyEmergencyContacts(selectedMemberId, familyId, null).then(r => `Notified ${r.sent} emergency contact(s).`); }
catch (e) { emergencyNotifyStatus = 'Emergency contact notification failed: ' + e.message; }
} catch (e) {
error = e.message;
}
@@ -118,6 +129,16 @@
}
}
async function resendEmergencyNotifications() {
emergencyNotifyStatus = 'Sending…';
try {
const r = await notifyEmergencyContacts(selectedMemberId, familyId, null);
emergencyNotifyStatus = `Notified ${r.sent} emergency contact(s).`;
} catch (e) {
emergencyNotifyStatus = 'Failed: ' + e.message;
}
}
function selectedEmail() {
return members.find(m => m.user_id === selectedMemberId)?.invited_email || '';
}
@@ -150,6 +171,12 @@
<h3>{selectedEmail()}'s documents</h3>
<div class="doc-block">
<span class="doc-label">Wassiyah bequests</span>
{#if wassiyahSettingsByAuthor[selectedMemberId]}
{@const rs = wassiyahSettingsByAuthor[selectedMemberId].review_status}
<div class="review-badge review-{rs}">
{rs === 'reviewed' ? `Reviewed by ${wassiyahSettingsByAuthor[selectedMemberId].reviewer_name}` : rs === 'requested' ? 'Professional review requested' : 'Not yet reviewed'}
</div>
{/if}
{#each wassiyahByAuthor[selectedMemberId] || [] as b}
<div class="doc-row">{b.recipient} ({b.relation || '—'}) — {Number(b.value).toLocaleString()}{b.recipient_email ? ` · ${b.recipient_email}` : ''}</div>
{:else}<div class="doc-empty">None recorded</div>{/each}
@@ -176,7 +203,9 @@
{#if trigger?.triggered}
<div class="triggered-banner">Triggered {trigger.triggered_at?.slice(0, 10)}.</div>
<button class="btn-secondary" onclick={sendHeirNotifications}>Notify heirs by email</button>
{#if notifyStatus}<p class="notify-status">{notifyStatus}</p>{/if}
{#if notifyStatus}<p class="notify-status heir-notify-status">{notifyStatus}</p>{/if}
<button class="btn-secondary" onclick={resendEmergencyNotifications}>Notify emergency contacts</button>
{#if emergencyNotifyStatus}<p class="notify-status emergency-notify-status">{emergencyNotifyStatus}</p>{/if}
{:else}
<label class="field"><span>Executor name</span><input type="text" bind:value={executorName} oninput={e => saveField('executor_name', e.target.value)} /></label>
<div class="attestors">
@@ -214,6 +243,9 @@
.doc-label { display: block; font-size: 10.5px; text-transform: uppercase; color: #8A8478; margin-bottom: 4px; }
.doc-row { font-size: 12.5px; color: #E8E4DC; padding: 3px 0; }
.doc-empty { font-size: 12px; color: #8A8478; font-style: italic; }
.review-badge { display: inline-block; font-size: 10px; padding: 3px 8px; border-radius: 999px; margin-bottom: 6px; background: rgba(255,255,255,0.06); color: #8A8478; }
.review-badge.review-requested { background: rgba(201,168,76,0.15); color: #C9A84C; }
.review-badge.review-reviewed { background: rgba(46,204,113,0.15); color: #2ECC71; }
.trigger-card { background: rgba(255,255,255,0.03); border-radius: 12px; padding: 14px; }
.trigger-card.triggered { background: rgba(239,68,68,0.06); }
.trigger-card h3 { font-size: 14px; color: #C9A84C; margin-bottom: 10px; }
+257
View File
@@ -0,0 +1,257 @@
<script>
import { onMount } from 'svelte';
import { session } from './auth.js';
import {
createNeighbourhood, joinNeighbourhoodByCode, listMyNeighbourhoods, leaveNeighbourhood,
listNeighbourhoodPosts, addNeighbourhoodPost, removeNeighbourhoodPost,
listEventRsvps, rsvpToEvent, cancelRsvp
} from './db.js';
import Disclaimer from './Disclaimer.svelte';
import InfoPanel from './InfoPanel.svelte';
let userId = $state(null);
let userEmail = $state(null);
session.subscribe(v => { userId = v?.user?.id ?? null; userEmail = v?.user?.email ?? null; });
let neighbourhoods = $state([]);
let activeId = $state(null);
let posts = $state([]);
let createName = $state('');
let joinCode = $state('');
let postForm = $state({ title: '', body: '', isEvent: false, eventDate: '', eventLocation: '' });
let error = $state('');
let showAddForm = $state(false);
let boardFilter = $state('all'); // all | announcements | events
let rsvps = $state([]); // { post_id, user_id }[]
async function refreshNeighbourhoods() {
if (!userId) return;
neighbourhoods = await listMyNeighbourhoods(userId);
if (!activeId && neighbourhoods.length) activeId = neighbourhoods[0].id;
if (activeId) await refreshPosts();
}
async function refreshPosts() {
if (!activeId) return;
posts = await listNeighbourhoodPosts(activeId);
const eventIds = posts.filter(p => p.is_event).map(p => p.id);
rsvps = await listEventRsvps(eventIds);
}
function rsvpCount(postId) { return rsvps.filter(r => r.post_id === postId).length; }
function isGoing(postId) { return rsvps.some(r => r.post_id === postId && r.user_id === userId); }
async function toggleRsvp(postId) {
if (isGoing(postId)) await cancelRsvp(postId, userId);
else await rsvpToEvent(postId, userId);
await refreshPosts();
}
onMount(refreshNeighbourhoods);
$effect(() => { userId; refreshNeighbourhoods(); });
async function doCreate() {
error = '';
if (!createName.trim()) return;
try {
const n = await createNeighbourhood(createName.trim(), userId);
createName = '';
activeId = n.id;
showAddForm = false;
await refreshNeighbourhoods();
} catch (e) { error = e.message; }
}
async function doJoin() {
error = '';
if (!joinCode.trim()) return;
try {
const n = await joinNeighbourhoodByCode(joinCode.trim(), userId, userEmail);
joinCode = '';
activeId = n.id;
showAddForm = false;
await refreshNeighbourhoods();
} catch (e) { error = e.message; }
}
async function doLeave(id) {
await leaveNeighbourhood(id, userId);
if (activeId === id) activeId = null;
await refreshNeighbourhoods();
}
function selectNeighbourhood(id) {
activeId = id;
refreshPosts();
}
async function doPost() {
if (!postForm.title.trim() || !activeId) return;
if (postForm.isEvent && !postForm.eventDate) return;
await addNeighbourhoodPost(activeId, userId, userEmail, postForm.title.trim(), postForm.body, postForm.isEvent ? { eventDate: postForm.eventDate, eventLocation: postForm.eventLocation } : null);
postForm = { title: '', body: '', isEvent: false, eventDate: '', eventLocation: '' };
await refreshPosts();
}
async function doRemovePost(id) {
await removeNeighbourhoodPost(id);
await refreshPosts();
}
const activeNeighbourhood = $derived(neighbourhoods.find(n => n.id === activeId));
const visiblePosts = $derived.by(() => {
let list = posts;
if (boardFilter === 'announcements') list = list.filter(p => !p.is_event);
if (boardFilter === 'events') list = list.filter(p => p.is_event);
// Upcoming events sort by date ascending (soonest first); everything
// else stays newest-first, matching the query order from the server.
return [...list].sort((a, b) => {
const aUpcoming = a.is_event && a.event_date && new Date(a.event_date) >= new Date();
const bUpcoming = b.is_event && b.event_date && new Date(b.event_date) >= new Date();
if (aUpcoming && bUpcoming) return new Date(a.event_date) - new Date(b.event_date);
if (aUpcoming !== bUpcoming) return aUpcoming ? -1 : 1;
return new Date(b.created_at) - new Date(a.created_at);
});
});
function formatEventDate(iso) {
if (!iso) return '';
const d = new Date(iso);
return d.toLocaleString(undefined, { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
}
</script>
<div class="module">
<div class="module-header">
<h2>Neighbourhood</h2>
<InfoPanel
title="Neighbourhood"
what="A community board and event listing for your mosque or local community — separate from your estate-planning family. Join with a code from your congregation, or start one and share the code."
how="Create a neighbourhood to get a join code to share, or enter a code someone gave you. Post a plain announcement, or check 'This is an event' to add a date, time, and location — members can tap 'I'm going' to RSVP."
fields={[
{ label: 'Event toggle', hint: 'Turns a post into a dated, located event others can RSVP to — otherwise it\'s a plain announcement.' },
{ label: 'RSVP', hint: 'A one-tap headcount, visible to everyone in the neighbourhood — not private.' }
]}
/>
</div>
<p class="sub">A community board, independent of your estate-planning family.</p>
{#if neighbourhoods.length > 1}
<div class="neighbourhood-tabs">
{#each neighbourhoods as n}
<button class:active={activeId === n.id} onclick={() => selectNeighbourhood(n.id)}>{n.name}</button>
{/each}
</div>
{/if}
{#if activeNeighbourhood}
<div class="active-card">
<div>
<strong>{activeNeighbourhood.name}</strong>
<span class="join-code">Join code: {activeNeighbourhood.joinCode}</span>
</div>
<button class="btn-small-danger" onclick={() => doLeave(activeNeighbourhood.id)}>Leave</button>
</div>
<div class="form-card">
<label class="field"><span>Title</span><input type="text" bind:value={postForm.title} placeholder={postForm.isEvent ? 'e.g. Ramadan Iftar Potluck' : 'e.g. Friday khutbah reminder'} /></label>
<label class="field"><span>Details</span><input type="text" bind:value={postForm.body} /></label>
<label class="event-toggle"><input type="checkbox" bind:checked={postForm.isEvent} /><span>This is an event (with date, time &amp; location)</span></label>
{#if postForm.isEvent}
<label class="field"><span>Date &amp; time</span><input type="datetime-local" bind:value={postForm.eventDate} /></label>
<label class="field"><span>Location</span><input type="text" bind:value={postForm.eventLocation} placeholder="e.g. Masjid Al-Falah, main hall" /></label>
{/if}
<button class="btn-primary" onclick={doPost}>{postForm.isEvent ? 'Post event' : 'Post announcement'}</button>
</div>
<div class="board-filter">
<button class:active={boardFilter === 'all'} onclick={() => boardFilter = 'all'}>All</button>
<button class:active={boardFilter === 'events'} onclick={() => boardFilter = 'events'}>📅 Events</button>
<button class:active={boardFilter === 'announcements'} onclick={() => boardFilter = 'announcements'}>📣 Announcements</button>
</div>
<div class="post-list">
{#each visiblePosts as p (p.id)}
<div class="post-row" class:event-row={p.is_event}>
<div class="post-header">
<strong>{p.is_event ? '📅 ' : ''}{p.title}</strong>
{#if p.author_id === userId}<button class="post-remove" onclick={() => doRemovePost(p.id)}>✕</button>{/if}
</div>
{#if p.is_event}
<div class="event-details">
<span class="event-when">🕐 {formatEventDate(p.event_date)}</span>
{#if p.event_location}<span class="event-where">📍 {p.event_location}</span>{/if}
</div>
{/if}
{#if p.body}<p class="post-body">{p.body}</p>{/if}
<span class="post-meta">{p.author_name || 'Member'} · {p.created_at?.slice(0, 10)}</span>
{#if p.is_event}
<button class="rsvp-btn" class:going={isGoing(p.id)} onclick={() => toggleRsvp(p.id)}>
{isGoing(p.id) ? `✓ Going` : 'I\'m going'} {rsvpCount(p.id) > 0 ? `(${rsvpCount(p.id)})` : ''}
</button>
{/if}
</div>
{:else}
<p class="empty">{boardFilter === 'events' ? 'No events posted yet.' : boardFilter === 'announcements' ? 'No announcements yet.' : 'Nothing posted yet.'}</p>
{/each}
</div>
<button class="btn-secondary add-toggle" onclick={() => showAddForm = !showAddForm}>{showAddForm ? 'Cancel' : '+ Join or start another neighbourhood'}</button>
{/if}
{#if showAddForm || !activeNeighbourhood}
<div class="form-card">
<h3>Start a neighbourhood</h3>
<label class="field"><span>Name</span><input type="text" bind:value={createName} placeholder="e.g. Masjid Al-Falah Kariah" /></label>
<button class="btn-primary" onclick={doCreate}>Create &amp; get a join code</button>
</div>
<div class="form-card">
<h3>Join with a code</h3>
<label class="field"><span>Join code</span><input type="text" bind:value={joinCode} placeholder="6-character code" /></label>
<button class="btn-secondary" onclick={doJoin}>Join</button>
</div>
{#if error}<p class="error-text">{error}</p>{/if}
{/if}
<Disclaimer text="No moderation tooling yet — anyone with the join code can post. Keep codes within a community you trust." />
</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; }
.neighbourhood-tabs { display: flex; gap: 6px; margin-bottom: 14px; flex-wrap: wrap; }
.neighbourhood-tabs button { padding: 8px 12px; border-radius: 20px; border: 1px solid rgba(201,168,76,0.2); background: rgba(255,255,255,0.04); color: #B8B2A6; font-size: 12px; cursor: pointer; }
.neighbourhood-tabs button.active { background: rgba(201,168,76,0.15); color: #C9A84C; border-color: rgba(201,168,76,0.4); }
.active-card { display: flex; justify-content: space-between; align-items: center; background: rgba(255,255,255,0.03); border-radius: 12px; padding: 14px; margin-bottom: 16px; }
.active-card strong { display: block; color: #E8E4DC; font-size: 14px; }
.join-code { font-size: 11px; color: #C9A84C; font-family: ui-monospace, monospace; }
.btn-small-danger { background: none; border: 1px solid rgba(239,68,68,0.3); color: #EF4444; font-size: 11px; padding: 5px 10px; border-radius: 6px; cursor: pointer; }
.form-card { background: rgba(255,255,255,0.03); border-radius: 12px; padding: 14px; margin-bottom: 16px; }
.form-card h3 { font-size: 14px; color: #C9A84C; margin-bottom: 10px; }
.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; }
.field span { font-size: 12px; color: #B8B2A6; }
.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; width: 100%; }
.btn-primary { width: 100%; padding: 12px; border-radius: 8px; border: none; font-weight: 600; cursor: pointer; background: #C9A84C; color: #070A0D; }
.btn-secondary { width: 100%; padding: 12px; border-radius: 8px; border: 1px solid rgba(201,168,76,0.3); font-weight: 600; cursor: pointer; background: rgba(255,255,255,0.05); color: #C9A84C; }
.error-text { font-size: 12px; color: #EF4444; }
.post-list { display: flex; flex-direction: column; gap: 10px; }
.post-row { background: rgba(255,255,255,0.03); border-radius: 10px; padding: 12px; }
.post-header { display: flex; justify-content: space-between; align-items: flex-start; }
.post-header strong { color: #E8E4DC; font-size: 13.5px; }
.post-remove { background: none; border: none; color: #8A8478; cursor: pointer; }
.post-body { font-size: 12.5px; color: #B8B2A6; margin: 6px 0; line-height: 1.5; }
.post-meta { font-size: 10.5px; color: #8A8478; }
.empty { font-size: 13px; color: #8A8478; text-align: center; padding: 20px 0; }
.add-toggle { margin-top: 16px; }
.event-toggle { display: flex; align-items: center; gap: 8px; font-size: 12.5px; color: #B8B2A6; margin-bottom: 12px; cursor: pointer; }
.board-filter { display: flex; gap: 6px; margin-bottom: 14px; }
.board-filter button { flex: 1; padding: 8px 6px; border-radius: 8px; border: 1px solid rgba(201,168,76,0.2); background: rgba(255,255,255,0.03); color: #B8B2A6; font-size: 11.5px; cursor: pointer; }
.board-filter button.active { background: rgba(201,168,76,0.15); color: #C9A84C; border-color: rgba(201,168,76,0.4); }
.event-row { border: 1px solid rgba(46,204,113,0.2); }
.event-details { display: flex; flex-direction: column; gap: 3px; margin: 6px 0; }
.event-when, .event-where { font-size: 11.5px; color: #2ECC71; }
.rsvp-btn { margin-top: 8px; padding: 7px 14px; border-radius: 8px; border: 1px solid rgba(201,168,76,0.3); background: rgba(255,255,255,0.04); color: #C9A84C; font-size: 12px; font-weight: 600; cursor: pointer; }
.rsvp-btn.going { background: rgba(46,204,113,0.15); color: #2ECC71; border-color: rgba(46,204,113,0.4); }
</style>
+111
View File
@@ -0,0 +1,111 @@
<script>
import { calculatePrayerTimes, formatClock } from './calc/prayerTimes.js';
import Disclaimer from './Disclaimer.svelte';
import InfoPanel from './InfoPanel.svelte';
const NAMES = { fajr: 'Fajr', sunrise: 'Sunrise', dhuhr: 'Dhuhr', asr: 'Asr', maghrib: 'Maghrib', isha: 'Isha' };
let status = $state('idle'); // idle | locating | ready | error
let errorMsg = $state('');
let times = $state(null);
let asrShadowFactor = $state(1);
let nextPrayer = $state(null);
function computeNext(t) {
const now = new Date();
const nowMinutes = now.getHours() * 60 + now.getMinutes();
const order = ['fajr', 'dhuhr', 'asr', 'maghrib', 'isha'];
for (const name of order) {
const v = t[name];
if (!v) continue;
if (v.hours * 60 + v.minutes > nowMinutes) return name;
}
return order[0]; // after Isha, next is tomorrow's Fajr
}
function findTimes() {
status = 'locating';
errorMsg = '';
if (!navigator.geolocation) {
status = 'error'; errorMsg = 'Geolocation is not available on this device/browser.';
return;
}
navigator.geolocation.getCurrentPosition(
(pos) => {
times = calculatePrayerTimes({
latitude: pos.coords.latitude, longitude: pos.coords.longitude, date: new Date(),
timezoneOffsetHours: -new Date().getTimezoneOffset() / 60, asrShadowFactor
});
nextPrayer = computeNext(times);
status = 'ready';
},
(err) => { status = 'error'; errorMsg = err.message || 'Could not get your location.'; },
{ enableHighAccuracy: true, timeout: 10000 }
);
}
function changeAsr(factor) {
asrShadowFactor = factor;
if (times) findTimes();
}
</script>
<div class="module">
<div class="module-header">
<h2>Prayer Times</h2>
<InfoPanel
title="Prayer Times"
what="Today's five daily prayer times plus sunrise, calculated from your device's location using standard solar-position astronomy — not fetched from an external service."
how="Tap to calculate. If precision matters for your area, verify against your local mosque's published times — this uses a single-pass calculation (MWL angles: 18°/17°) that can be a few minutes off from locally-adjusted conventions."
fields={[
{ label: 'Asr method', hint: 'Shafii/majority (shadow factor 1) or Hanafi (factor 2) — changes when Asr starts.' }
]}
/>
</div>
<p class="sub">Calculated from your location — not looked up from a directory.</p>
{#if status === 'idle'}
<button class="btn-primary" onclick={findTimes}>Calculate today's prayer times</button>
{:else if status === 'locating'}
<p class="status-text">Getting your location…</p>
{:else if status === 'error'}
<p class="error-text">{errorMsg}</p>
<button class="btn-primary" onclick={findTimes}>Try again</button>
{:else if status === 'ready' && times}
<div class="asr-toggle">
<button class:active={asrShadowFactor === 1} onclick={() => changeAsr(1)}>Shafi'i</button>
<button class:active={asrShadowFactor === 2} onclick={() => changeAsr(2)}>Hanafi</button>
</div>
<div class="times-list">
{#each Object.entries(NAMES) as [key, label]}
<div class="time-row" class:next={key === nextPrayer}>
<span class="time-label">{label}</span>
<span class="time-value">{times[key] ? formatClock(times[key]) : '—'}</span>
</div>
{/each}
</div>
<button class="btn-secondary" onclick={findTimes}>Refresh</button>
{/if}
<Disclaimer text="Single-pass astronomical calculation, not a substitute for your local mosque's published times where precision matters." />
</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: 18px; }
.btn-primary { width: 100%; padding: 14px; border-radius: 8px; border: none; font-weight: 600; cursor: pointer; background: #C9A84C; color: #070A0D; }
.btn-secondary { width: 100%; padding: 12px; border-radius: 8px; border: 1px solid rgba(201,168,76,0.3); font-weight: 600; cursor: pointer; background: rgba(255,255,255,0.05); color: #C9A84C; margin-top: 14px; }
.status-text { font-size: 13px; color: #8A8478; text-align: center; padding: 30px 0; }
.error-text { font-size: 13px; color: #EF4444; margin-bottom: 12px; }
.asr-toggle { display: flex; gap: 8px; margin-bottom: 14px; }
.asr-toggle button { flex: 1; padding: 8px; border-radius: 8px; border: 1px solid rgba(201,168,76,0.2); background: rgba(255,255,255,0.03); color: #B8B2A6; font-size: 12px; cursor: pointer; }
.asr-toggle button.active { background: rgba(201,168,76,0.15); color: #C9A84C; border-color: rgba(201,168,76,0.4); }
.times-list { background: rgba(255,255,255,0.03); border-radius: 12px; padding: 6px 16px; }
.time-row { display: flex; justify-content: space-between; padding: 12px 0; border-bottom: 1px solid rgba(255,255,255,0.06); }
.time-row:last-child { border-bottom: none; }
.time-row.next .time-label, .time-row.next .time-value { color: #2ECC71; font-weight: 700; }
.time-label { font-size: 14px; color: #E8E4DC; }
.time-value { font-size: 14px; color: #C9A84C; font-variant-numeric: tabular-nums; }
</style>
+157
View File
@@ -0,0 +1,157 @@
<script>
// Qibla direction — pure client-side great-circle bearing calculation from
// the device's GPS to the Kaaba. No API, no key, no external service:
// this is closed-form spherical trigonometry, not a lookup.
import Disclaimer from './Disclaimer.svelte';
import InfoPanel from './InfoPanel.svelte';
const KAABA_LAT = 21.4225;
const KAABA_LON = 39.8262;
let status = $state('idle'); // idle | locating | ready | error
let errorMsg = $state('');
let bearing = $state(null); // degrees from true north, 0-360
let distanceKm = $state(null);
let compassHeading = $state(null); // device heading, if sensor available
let compassSupported = $state(false);
function toRad(deg) { return (deg * Math.PI) / 180; }
function toDeg(rad) { return (rad * 180) / Math.PI; }
function computeQibla(lat, lon) {
const phi1 = toRad(lat), phi2 = toRad(KAABA_LAT);
const dLambda = toRad(KAABA_LON - lon);
const y = Math.sin(dLambda) * Math.cos(phi2);
const x = Math.cos(phi1) * Math.sin(phi2) - Math.sin(phi1) * Math.cos(phi2) * Math.cos(dLambda);
let theta = toDeg(Math.atan2(y, x));
bearing = (theta + 360) % 360;
// Haversine distance
const R = 6371;
const dPhi = toRad(KAABA_LAT - lat);
const a = Math.sin(dPhi / 2) ** 2 + Math.cos(phi1) * Math.cos(phi2) * Math.sin(dLambda / 2) ** 2;
distanceKm = Math.round(R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)));
}
let listenersAttached = false;
function handleOrientation(e) {
let heading = e.webkitCompassHeading;
if (heading == null && e.alpha != null) {
// Prefer a true geomagnetic reading when the browser provides one
// (absolute: true), but don't require it — many Android browsers
// never set absolute:true even though alpha is still a usable
// heading, so requiring it strictly left the compass permanently
// "undetected" on those devices.
heading = 360 - e.alpha;
}
if (heading != null) { compassHeading = heading; compassSupported = true; }
}
function attachOrientationListeners() {
if (listenersAttached) return;
listenersAttached = true;
window.addEventListener('deviceorientationabsolute', handleOrientation, true);
window.addEventListener('deviceorientation', handleOrientation, true);
}
async function findQibla() {
status = 'locating';
errorMsg = '';
compassSupported = false;
compassHeading = null;
// Request device-orientation permission (iOS 13+) synchronously here,
// inside this click handler — Safari expires the user-activation grant
// almost immediately, so requesting it *after* the async geolocation
// round-trip (which can take several seconds) reliably fails silently
// with no prompt ever shown. Registering listeners for everyone else
// immediately too, so no early orientation events are missed while
// still waiting on the GPS fix.
if (typeof DeviceOrientationEvent !== 'undefined') {
if (typeof DeviceOrientationEvent.requestPermission === 'function') {
try {
const perm = await DeviceOrientationEvent.requestPermission();
if (perm === 'granted') attachOrientationListeners();
} catch { /* denied or unsupported — static bearing still shown */ }
} else {
attachOrientationListeners();
}
}
if (!navigator.geolocation) {
status = 'error'; errorMsg = 'Geolocation is not available on this device/browser.';
return;
}
navigator.geolocation.getCurrentPosition(
(pos) => { computeQibla(pos.coords.latitude, pos.coords.longitude); status = 'ready'; },
(err) => { status = 'error'; errorMsg = err.message || 'Could not get your location.'; },
{ enableHighAccuracy: true, timeout: 10000 }
);
}
const needleRotation = $derived(compassSupported && compassHeading != null && bearing != null ? bearing - compassHeading : bearing);
</script>
<div class="module">
<div class="module-header">
<h2>Qibla</h2>
<InfoPanel
title="Qibla"
what="The direction to face for prayer, calculated as the great-circle bearing from your current location to the Kaaba in Makkah — computed on your device, not looked up from a database."
how="Tap to find your direction. If your device has a compass sensor and grants permission, the arrow rotates live as you turn; otherwise you'll see the bearing in degrees from true north to align with a separate compass."
fields={[]}
/>
</div>
<p class="sub">Great-circle bearing to the Kaaba, calculated from your device's location.</p>
{#if status === 'idle'}
<button class="btn-primary" onclick={findQibla}>Find Qibla direction</button>
{:else if status === 'locating'}
<p class="status-text">Getting your location…</p>
{:else if status === 'error'}
<p class="error-text">{errorMsg}</p>
<button class="btn-primary" onclick={findQibla}>Try again</button>
{:else if status === 'ready'}
<div class="compass-card">
<div class="compass-rose">
<div class="needle" style="transform: rotate({needleRotation}deg)">
<div class="needle-tip"></div>
</div>
<span class="compass-n">N</span>
</div>
<div class="bearing-readout">
<strong>{Math.round(bearing)}°</strong>
<span>from true north</span>
</div>
{#if !compassSupported}
<p class="static-note">No live compass sensor detected — hold a compass app or physical compass and align it to {Math.round(bearing)}°.</p>
{/if}
<p class="distance-note">~{distanceKm?.toLocaleString()} km to Makkah</p>
</div>
<button class="btn-secondary" onclick={findQibla}>Refresh location</button>
{/if}
<Disclaimer text="A calculated bearing, not a certified qibla marker. Verify against a known qibla direction (e.g. your local mosque) where precision matters." />
</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: 18px; }
.btn-primary { width: 100%; padding: 14px; border-radius: 8px; border: none; font-weight: 600; cursor: pointer; background: #C9A84C; color: #070A0D; }
.btn-secondary { width: 100%; padding: 12px; border-radius: 8px; border: 1px solid rgba(201,168,76,0.3); font-weight: 600; cursor: pointer; background: rgba(255,255,255,0.05); color: #C9A84C; margin-top: 14px; }
.status-text { font-size: 13px; color: #8A8478; text-align: center; padding: 30px 0; }
.error-text { font-size: 13px; color: #EF4444; margin-bottom: 12px; }
.compass-card { display: flex; flex-direction: column; align-items: center; padding: 20px; background: rgba(255,255,255,0.03); border-radius: 16px; }
.compass-rose { position: relative; width: 200px; height: 200px; border-radius: 50%; border: 2px solid rgba(201,168,76,0.3); display: flex; align-items: center; justify-content: center; margin-bottom: 16px; }
.needle { position: absolute; width: 4px; height: 90px; background: linear-gradient(#C9A84C, transparent); top: 10px; left: 50%; margin-left: -2px; transform-origin: bottom center; transition: transform 0.2s ease-out; }
.needle-tip { position: absolute; top: -14px; left: -9px; font-size: 20px; color: #C9A84C; }
.compass-n { position: absolute; top: 8px; font-size: 11px; color: #8A8478; }
.bearing-readout { display: flex; flex-direction: column; align-items: center; margin-bottom: 8px; }
.bearing-readout strong { font-family: 'DM Serif Display', serif; font-size: 32px; color: #C9A84C; }
.bearing-readout span { font-size: 11px; color: #8A8478; }
.static-note { font-size: 12px; color: #B8B2A6; text-align: center; margin-bottom: 8px; line-height: 1.5; }
.distance-note { font-size: 11px; color: #8A8478; }
</style>
+133
View File
@@ -0,0 +1,133 @@
<script>
// Quran text via the free, keyless alquran.cloud public API — Uthmani
// Arabic script paired with a Sahih International English translation.
// No caching/storage of the text in this app's own database; fetched
// fresh from the API each time, same as the locators.
import { onMount } from 'svelte';
import Disclaimer from './Disclaimer.svelte';
import InfoPanel from './InfoPanel.svelte';
const API_BASE = 'https://api.alquran.cloud/v1';
let surahs = $state([]);
let listStatus = $state('loading'); // loading | ready | error
let selectedSurah = $state(null);
let ayahs = $state([]);
let readerStatus = $state('idle'); // idle | loading | ready | error
let search = $state('');
onMount(async () => {
try {
const res = await fetch(`${API_BASE}/surah`);
if (!res.ok) throw new Error(`API returned ${res.status}`);
const json = await res.json();
surahs = json.data || [];
listStatus = 'ready';
} catch (e) {
listStatus = 'error';
}
});
async function openSurah(surah) {
selectedSurah = surah;
readerStatus = 'loading';
ayahs = [];
try {
const res = await fetch(`${API_BASE}/surah/${surah.number}/editions/quran-uthmani,en.sahih`);
if (!res.ok) throw new Error(`API returned ${res.status}`);
const json = await res.json();
const [arabic, translation] = json.data;
ayahs = arabic.ayahs.map((a, i) => ({ number: a.numberInSurah, arabic: a.text, translation: translation.ayahs[i]?.text || '' }));
readerStatus = 'ready';
} catch (e) {
readerStatus = 'error';
}
}
function backToList() { selectedSurah = null; ayahs = []; readerStatus = 'idle'; }
const filteredSurahs = $derived(
search.trim()
? surahs.filter(s => s.englishName.toLowerCase().includes(search.toLowerCase()) || s.englishNameTranslation.toLowerCase().includes(search.toLowerCase()) || String(s.number) === search.trim())
: surahs
);
</script>
<div class="module">
<div class="module-header">
<h2>Quran</h2>
<InfoPanel
title="Quran"
what="The full Quran text — Uthmani Arabic script alongside a Sahih International English translation — fetched from a free public API, not stored in this app."
how="Pick a Surah from the list to read it. Nothing here is saved locally between sessions; it's fetched fresh each time you open a Surah."
fields={[]}
/>
</div>
{#if !selectedSurah}
<p class="sub">114 Surahs — tap one to read.</p>
<input class="search-box" type="text" bind:value={search} placeholder="Search by name or number…" />
{#if listStatus === 'loading'}
<p class="status-text">Loading Surah list…</p>
{:else if listStatus === 'error'}
<p class="error-text">Could not reach the Quran text service. Check your connection and reload this tab.</p>
{:else}
<div class="surah-list">
{#each filteredSurahs as s (s.number)}
<button class="surah-row" onclick={() => openSurah(s)}>
<span class="surah-number">{s.number}</span>
<div class="surah-info">
<strong>{s.englishName}</strong>
<span class="muted">{s.englishNameTranslation} · {s.numberOfAyahs} ayahs · {s.revelationType}</span>
</div>
<span class="surah-arabic">{s.name}</span>
</button>
{/each}
</div>
{/if}
{:else}
<button class="back-btn" onclick={backToList}> All Surahs</button>
<h3 class="surah-title">{selectedSurah.englishName} <span class="muted">{selectedSurah.englishNameTranslation}</span></h3>
{#if readerStatus === 'loading'}
<p class="status-text">Loading…</p>
{:else if readerStatus === 'error'}
<p class="error-text">Could not load this Surah. Check your connection and try again.</p>
{:else}
<div class="ayah-list">
{#each ayahs as a (a.number)}
<div class="ayah-row">
<span class="ayah-number">{a.number}</span>
<p class="ayah-arabic">{a.arabic}</p>
<p class="ayah-translation">{a.translation}</p>
</div>
{/each}
</div>
{/if}
{/if}
<Disclaimer text="Text and translation sourced from a third-party public API (alquran.cloud) — for recitation guidance and tajweed, consult a qualified teacher." />
</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: 12px; }
.search-box { width: 100%; 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; margin-bottom: 14px; }
.status-text { font-size: 13px; color: #8A8478; text-align: center; padding: 30px 0; }
.error-text { font-size: 13px; color: #EF4444; text-align: center; padding: 20px 0; }
.surah-list { display: flex; flex-direction: column; gap: 2px; }
.surah-row { display: flex; align-items: center; gap: 12px; width: 100%; padding: 12px; border-radius: 10px; background: rgba(255,255,255,0.03); border: none; text-align: left; cursor: pointer; margin-bottom: 4px; }
.surah-number { width: 26px; height: 26px; border-radius: 50%; background: rgba(201,168,76,0.15); color: #C9A84C; font-size: 11px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.surah-info { display: flex; flex-direction: column; gap: 2px; flex: 1; }
.surah-info strong { color: #E8E4DC; font-size: 13.5px; }
.muted { color: #8A8478; font-size: 11px; }
.surah-arabic { color: #C9A84C; font-size: 15px; }
.back-btn { background: none; border: none; color: #C9A84C; font-size: 13px; cursor: pointer; padding: 0; margin-bottom: 14px; }
.surah-title { font-family: 'DM Serif Display', serif; font-size: 19px; color: #E8E4DC; margin-bottom: 16px; }
.ayah-list { display: flex; flex-direction: column; gap: 18px; }
.ayah-row { padding-bottom: 16px; border-bottom: 1px solid rgba(255,255,255,0.06); }
.ayah-number { display: inline-block; font-size: 10px; color: #8A8478; background: rgba(255,255,255,0.05); padding: 2px 7px; border-radius: 999px; margin-bottom: 8px; }
.ayah-arabic { font-size: 22px; color: #E8E4DC; text-align: right; line-height: 2; margin-bottom: 8px; direction: rtl; }
.ayah-translation { font-size: 13px; color: #B8B2A6; line-height: 1.6; }
</style>
+162
View File
@@ -0,0 +1,162 @@
<script>
import { onMount } from 'svelte';
import { activeFamilyId, listFamilyMembers } from './family.js';
import { session } from './auth.js';
import { listSadaqahLog, addSadaqahEntry, removeSadaqahEntry, getFamilySadaqahStreaks, listPeople } from './db.js';
import Disclaimer from './Disclaimer.svelte';
import InfoPanel from './InfoPanel.svelte';
let familyId = $state(null);
activeFamilyId.subscribe(v => familyId = v);
// Live off the session store, not a one-time snapshot — same fix as every
// other per-member module in this app: this component remounts on tab
// switch, and a stale null captured once at mount would silently break
// every write until the next remount.
let memberId = $state(null);
session.subscribe(v => memberId = v?.user?.id ?? null);
let log = $state([]);
let familyStreaks = $state([]);
let members = $state([]);
let deceasedPeople = $state([]);
let form = $state(emptyForm());
function emptyForm() {
return { cause: '', amount: '', inMemoryOfPersonId: '', notes: '' };
}
function todayStr() { return new Date().toISOString().slice(0, 10); }
async function refresh() {
if (!familyId || !memberId) return;
const [entries, streaks, fam, people] = await Promise.all([
listSadaqahLog(familyId, memberId), getFamilySadaqahStreaks(familyId), listFamilyMembers(familyId), listPeople(familyId)
]);
log = entries;
familyStreaks = streaks;
members = fam;
deceasedPeople = people.filter(p => p.deathDate);
}
onMount(refresh);
$effect(() => { familyId; memberId; refresh(); });
async function addEntry() {
await addSadaqahEntry(familyId, memberId, { ...form, logDate: todayStr() });
form = emptyForm();
await refresh();
}
async function remove(id) {
await removeSadaqahEntry(id);
await refresh();
}
const myStreak = $derived(familyStreaks.find(s => s.memberId === memberId));
const gaveToday = $derived(log.some(e => e.logDate === todayStr()));
const familyStreakRows = $derived(familyStreaks.map(s => ({
...s, email: members.find(m => m.user_id === s.memberId)?.invited_email || 'Member'
})).filter(r => r.currentStreak > 0 || r.memberId === memberId));
function personName(id) { return deceasedPeople.find(p => p.id === id)?.fullName || ''; }
</script>
<div class="module">
<div class="module-header">
<h2>Sadaqah</h2>
<InfoPanel
title="Sadaqah"
what="A simple journal for daily charity — following the Prophetic practice of giving something every morning and evening. This does not move any money; it's a record of what you already gave elsewhere (your e-wallet, a mosque, a charity), and an optional way to dedicate a gift in memory of someone in your family tree."
how="Log a quick entry after you give — cause, amount if you want to track it, and who it's in memory of if it's a dedication. Your entries stay private; family members only ever see whether you gave today, never amounts or causes."
fields={[
{ label: 'Cause', hint: 'What or who it went to — a mosque, a relief fund, a family member in need.' },
{ label: 'Amount', hint: 'Optional — only you can see this.' },
{ label: 'In memory of', hint: 'Optional — dedicate today\'s giving to a deceased family member from your Tree.' }
]}
/>
</div>
<p class="sub">A private daily giving journal — not a payment tool. Log what you already gave.</p>
<div class="streak-card" class:done={gaveToday}>
<div class="streak-number">{myStreak?.currentStreak || 0}</div>
<div class="streak-label">day{(myStreak?.currentStreak || 0) === 1 ? '' : 's'} in a row</div>
{#if gaveToday}<div class="streak-today">Given today ✓</div>{:else}<div class="streak-today muted">Not yet logged today</div>{/if}
</div>
<div class="form-card">
<label class="field"><span>Cause</span><input type="text" bind:value={form.cause} placeholder="e.g. mosque, relief fund, a relative in need" /></label>
<label class="field"><span>Amount (private, optional)</span><input type="number" min="0" bind:value={form.amount} /></label>
{#if deceasedPeople.length}
<label class="field"><span>In memory of (optional)</span>
<select bind:value={form.inMemoryOfPersonId}>
<option value="">— none —</option>
{#each deceasedPeople as p}<option value={p.id}>{p.fullName}</option>{/each}
</select>
</label>
{/if}
<label class="field"><span>Notes (optional)</span><input type="text" bind:value={form.notes} /></label>
<button class="btn-primary" onclick={addEntry}>Log today's sadaqah</button>
</div>
{#if familyStreakRows.length > 1}
<div class="family-streaks">
<h3>Family</h3>
{#each familyStreakRows as r}
<div class="family-row">
<span>{r.email}</span>
<span class="family-streak-val">{r.currentStreak} day{r.currentStreak === 1 ? '' : 's'}{r.gaveToday ? ' · today ✓' : ''}</span>
</div>
{/each}
<p class="privacy-note">Streaks only — amounts and causes are never shared, even with family.</p>
</div>
{/if}
<div class="history">
<h3>Your history</h3>
{#each log as e (e.id)}
<div class="history-row">
<div class="history-info">
<span class="history-date">{e.logDate}</span>
<span class="history-cause">{e.cause || '—'}{e.inMemoryOfPersonId ? ` · in memory of ${personName(e.inMemoryOfPersonId)}` : ''}</span>
</div>
{#if e.amount}<span class="history-amount">{Number(e.amount).toLocaleString()}</span>{/if}
<button onclick={() => remove(e.id)} aria-label="Remove"></button>
</div>
{:else}
<p class="empty">No entries yet — log your first one above.</p>
{/each}
</div>
<Disclaimer text="This app does not process payments or transfer money. Log what you've already given through your own bank, e-wallet, or charity of choice." />
</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: 18px; }
.streak-card { text-align: center; border-radius: 16px; padding: 22px; margin-bottom: 18px; border: 1px solid rgba(255,255,255,0.08); background: rgba(255,255,255,0.03); }
.streak-card.done { background: rgba(46,204,113,0.08); border-color: rgba(46,204,113,0.3); }
.streak-number { font-family: 'DM Serif Display', serif; font-size: 44px; color: #C9A84C; }
.streak-card.done .streak-number { color: #2ECC71; }
.streak-label { font-size: 12px; color: #B8B2A6; margin-top: 2px; }
.streak-today { font-size: 12px; color: #2ECC71; margin-top: 8px; font-weight: 600; }
.streak-today.muted { color: #8A8478; font-weight: 400; }
.form-card { background: rgba(255,255,255,0.03); border-radius: 12px; padding: 14px; margin-bottom: 18px; }
.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; width: 100%; }
.btn-primary { width: 100%; padding: 12px; border-radius: 8px; border: none; font-weight: 600; cursor: pointer; background: #C9A84C; color: #070A0D; }
.family-streaks { background: rgba(255,255,255,0.03); border-radius: 12px; padding: 14px; margin-bottom: 18px; }
.family-streaks h3 { font-size: 14px; color: #C9A84C; margin-bottom: 8px; }
.family-row { display: flex; justify-content: space-between; padding: 6px 0; font-size: 13px; color: #E8E4DC; border-bottom: 1px solid rgba(255,255,255,0.06); }
.family-streak-val { color: #8A8478; font-size: 12px; }
.privacy-note { font-size: 10.5px; color: #8A8478; margin-top: 8px; font-style: italic; }
.history h3 { font-size: 14px; color: #C9A84C; margin-bottom: 8px; }
.history-row { display: flex; align-items: center; gap: 10px; padding: 10px 0; border-bottom: 1px solid rgba(255,255,255,0.06); }
.history-info { display: flex; flex-direction: column; flex: 1; gap: 2px; }
.history-date { font-size: 11px; color: #8A8478; }
.history-cause { font-size: 13px; color: #E8E4DC; }
.history-amount { color: #2ECC71; font-weight: 600; font-size: 13px; }
.history-row button { background: none; border: none; color: #8A8478; cursor: pointer; }
.empty { font-size: 13px; color: #8A8478; text-align: center; padding: 20px 0; }
</style>
+180
View File
@@ -0,0 +1,180 @@
<script>
// Support Nur Falah — donations to keep the app itself running (distinct
// from the personal Sadaqah tracker, which logs giving elsewhere), plus
// co-branding/white-label partnership contact. Checkout is handled
// entirely by Polar.sh (merchant of record) via hosted checkout links —
// this app never touches payment details itself, same principle as the
// rest of the app's "log intent, don't move money" design.
import Disclaimer from './Disclaimer.svelte';
import InfoPanel from './InfoPanel.svelte';
const CHECKOUT_LINKS = {
onetime: 'https://buy.polar.sh/polar_cl_QHgvTOWQTCZsL6XIx3PDM6qf3pFbBHCJasN3d3bOoOI',
monthly: 'https://buy.polar.sh/polar_cl_VXqJqNXPYgMThCzD0MVxtuZyrwCjiFZ5EuNZc269ji3',
quarterly: 'https://buy.polar.sh/polar_cl_0cY6cJs2FYtw6wzqI8mTglG6zDGsf7qE50Wgr31bdl1',
yearly: 'https://buy.polar.sh/polar_cl_s6zsaewtnkDqaLtgufNuWlRX4cGbWxyHP6Fjm10SPcB'
};
const FREQUENCIES = [
{ key: 'onetime', label: 'One-Time' },
{ key: 'monthly', label: 'Monthly 🌙' },
{ key: 'quarterly', label: 'Quarterly' },
{ key: 'yearly', label: 'Yearly' }
];
const PRESETS = [5, 10, 25, 50];
let frequency = $state('onetime');
let amount = $state(10);
let customAmount = $state('');
const effectiveAmount = $derived.by(() => {
const c = parseFloat(customAmount);
return customAmount && !isNaN(c) && c > 0 ? c : amount;
});
function selectPreset(v) {
amount = v;
customAmount = '';
}
function supportNow() {
const cents = Math.round(effectiveAmount * 100);
const url = `${CHECKOUT_LINKS[frequency]}?amount=${cents}`;
window.open(url, '_blank', 'noopener');
}
const WHATSAPP_URL = 'https://wa.me/60132250691';
const PARTNER_EMAIL = 'info@falahos.my';
</script>
<div class="module">
<div class="module-header">
<h2>Support</h2>
<InfoPanel
title="Support Nur Falah"
what="Nur Falah is free forever — sadaqah jariyah for everyone who uses it. This tab is how you can support the app itself (servers, development, accuracy), separate from the personal Sadaqah tracker elsewhere in the app, which logs your own giving to causes you choose."
how="Pick an amount and how often, then tap Support Now — you'll be taken to our payment partner (Polar.sh) to complete it securely. Nur Falah never sees or stores your payment details."
fields={[]}
/>
</div>
<div class="support-intro">
<div class="support-icon">🌱</div>
<h3>Support Nur Falah</h3>
<p>This app is free forever — sadaqah jariyah for all. Your support keeps the servers running, the features growing, and the calculations accurate. Every contribution goes directly to app development.</p>
</div>
<div class="door-card">
<div class="door-accent-bar"></div>
<div class="door-content">
<div class="amount-pills">
{#each PRESETS as p}
<button class="pill" class:active={!customAmount && amount === p} onclick={() => selectPreset(p)}>${p}</button>
{/each}
<button class="pill pill-custom" class:active={!!customAmount}>Custom</button>
</div>
<input type="number" min="1" step="1" placeholder="Enter amount (USD)" class="custom-input" bind:value={customAmount} />
<div class="frequency-toggle">
{#each FREQUENCIES as f}
<button class:active={frequency === f.key} onclick={() => frequency = f.key}>{f.label}</button>
{/each}
</div>
<div class="door-actions">
<button class="primary donate-btn" onclick={supportNow}>
{frequency === 'onetime' ? `Support Now — $${effectiveAmount}` : `Subscribe ${FREQUENCIES.find(f => f.key === frequency).label} $${effectiveAmount}`}
</button>
</div>
</div>
</div>
<div class="partner-link">
<p>🏛️ Represent a bank, eWallet, or institution? <a href="#partners" onclick={(e) => { e.preventDefault(); document.getElementById('partners-section')?.scrollIntoView({ behavior: 'smooth' }); }}>View Partnership Opportunities →</a></p>
</div>
<div id="partners-section" class="partners-section">
<div class="hero-badge">🤝 PARTNERSHIP</div>
<h3>Co-Branding &amp; White-Label</h3>
<p class="partners-intro">Nur Falah is built to be a trusted estate-planning and Muslim-lifestyle companion. We're open to co-branded partnerships with banks, Takaful/insurance operators, and private institutions — and to white-labeling the entire app under your own brand.</p>
<div class="partner-tiers">
<div class="tier-card">
<span class="tier-icon">🏦</span>
<div>
<strong>Co-Branding</strong>
<p>A branded version of Nur Falah for your customers — your logo, your domain, our estate-planning and lifestyle engine underneath.</p>
</div>
</div>
<div class="tier-card">
<span class="tier-icon">🏷️</span>
<div>
<strong>White-Label</strong>
<p>License the full app under your own brand end-to-end — reach out for terms and technical details.</p>
</div>
</div>
</div>
<div class="contact-card">
<p class="contact-label">Speak to our VP of Sales</p>
<a class="contact-row" href="mailto:{PARTNER_EMAIL}?subject=Nur Falah Partnership Inquiry">
<span class="contact-icon">✉️</span>
<span>{PARTNER_EMAIL}</span>
</a>
<a class="contact-row" href={WHATSAPP_URL} target="_blank" rel="noopener">
<span class="contact-icon">💬</span>
<span>WhatsApp +60 13-225 0691</span>
</a>
</div>
</div>
<p class="footer-tagline">Nur Falah — Free forever, sadaqah jariyah for all.</p>
<Disclaimer text="Checkout is handled entirely by Polar.sh, our payment partner and merchant of record — Nur Falah never processes or stores your card or payment details." />
</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; }
h3 { font-family: 'DM Serif Display', serif; font-size: 19px; color: #E8E4DC; margin-bottom: 8px; }
.support-intro { text-align: center; padding: 8px 0 20px; }
.support-icon { font-size: 36px; margin-bottom: 8px; }
.support-intro p { font-size: 13px; color: #B8B2A6; line-height: 1.6; max-width: 320px; margin: 0 auto; }
.door-card { position: relative; background: rgba(255,255,255,0.03); border-radius: 16px; overflow: hidden; margin-bottom: 16px; border: 1px solid rgba(201,168,76,0.15); }
.door-accent-bar { height: 4px; background: linear-gradient(90deg, #C9A84C, #2ECC71); }
.door-content { padding: 18px; }
.amount-pills { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 10px; }
.pill { padding: 10px 16px; border-radius: 999px; border: 1px solid rgba(201,168,76,0.25); background: rgba(255,255,255,0.04); color: #B8B2A6; font-size: 14px; font-weight: 600; cursor: pointer; }
.pill.active { background: rgba(201,168,76,0.18); color: #C9A84C; border-color: rgba(201,168,76,0.5); }
.custom-input { width: 100%; 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; margin-bottom: 16px; }
.frequency-toggle { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 18px; }
.frequency-toggle button { flex: 1; min-width: 80px; padding: 9px 8px; border-radius: 8px; border: 1px solid rgba(201,168,76,0.2); background: rgba(255,255,255,0.03); color: #B8B2A6; font-size: 12.5px; cursor: pointer; }
.frequency-toggle button.active { background: rgba(201,168,76,0.15); color: #C9A84C; border-color: rgba(201,168,76,0.4); font-weight: 600; }
.door-actions .donate-btn { width: 100%; padding: 14px; border-radius: 10px; border: none; font-weight: 700; font-size: 14px; cursor: pointer; background: linear-gradient(90deg, #C9A84C, #dfc06b); color: #070A0D; }
.partner-link { text-align: center; margin-bottom: 24px; }
.partner-link p { font-size: 12.5px; color: #8A8478; }
.partner-link a { color: #C9A84C; font-weight: 600; text-decoration: none; }
.partners-section { border-top: 1px solid rgba(201,168,76,0.15); padding-top: 20px; margin-bottom: 20px; }
.hero-badge { display: inline-block; font-size: 10px; letter-spacing: 0.6px; background: rgba(46,204,113,0.15); color: #2ECC71; padding: 4px 10px; border-radius: 999px; margin-bottom: 10px; font-weight: 700; }
.partners-intro { font-size: 12.5px; color: #B8B2A6; line-height: 1.6; margin-bottom: 16px; }
.partner-tiers { display: flex; flex-direction: column; gap: 10px; margin-bottom: 18px; }
.tier-card { display: flex; gap: 12px; background: rgba(255,255,255,0.03); border-radius: 12px; padding: 14px; }
.tier-icon { font-size: 22px; flex-shrink: 0; }
.tier-card strong { display: block; font-size: 13.5px; color: #E8E4DC; margin-bottom: 4px; }
.tier-card p { font-size: 12px; color: #8A8478; line-height: 1.5; }
.contact-card { background: rgba(201,168,76,0.06); border: 1px solid rgba(201,168,76,0.2); border-radius: 12px; padding: 16px; }
.contact-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.4px; color: #8A8478; margin-bottom: 10px; }
.contact-row { display: flex; align-items: center; gap: 10px; padding: 8px 0; color: #E8E4DC; text-decoration: none; font-size: 13.5px; }
.contact-icon { font-size: 16px; }
.footer-tagline { text-align: center; font-size: 11px; color: #8A8478; margin-bottom: 16px; }
</style>
+78 -8
View File
@@ -1,9 +1,10 @@
<script>
import { onMount } from 'svelte';
import { oneThirdCap, isQuranicHeirRelation } from './calc/faraid.js';
import { activeFamilyId } from './family.js';
import { activeFamilyId, getFamilyJurisdiction } from './family.js';
import { session } from './auth.js';
import { listAssets, estateTotal, listWassiyahBequests, addWassiyahBequest, removeWassiyahBequest, getWassiyahSettings, upsertWassiyahSettings } from './db.js';
import { listAssets, estateTotal, listWassiyahBequests, addWassiyahBequest, removeWassiyahBequest, getWassiyahSettings, upsertWassiyahSettings, getMemberTrigger, requestWassiyahReview, markWassiyahReviewed } from './db.js';
import { buildWillDocumentHtml, JURISDICTION_LABELS, JURISDICTION_NOTES } from './willDocument.js';
import Disclaimer from './Disclaimer.svelte';
import InfoPanel from './InfoPanel.svelte';
@@ -23,7 +24,11 @@
let form = $state({ recipient: '', relation: '', description: '', value: '', recipientEmail: '' });
let witness1 = $state('');
let witness2 = $state('');
let testatorName = $state('');
let overrideAcknowledged = $state(false);
let reviewStatus = $state('not_requested');
let reviewerName = $state('');
let reviewerNameInput = $state('');
async function refresh() {
if (!familyId || !authorId) return;
@@ -33,9 +38,16 @@
bequests = await listWassiyahBequests(familyId, authorId);
const settings = await getWassiyahSettings(familyId, authorId);
if (settings) {
jurisdiction = settings.jurisdiction || 'UK';
jurisdiction = settings.jurisdiction || jurisdiction;
witness1 = settings.witness1 || '';
witness2 = settings.witness2 || '';
testatorName = settings.testator_name || '';
reviewStatus = settings.review_status || 'not_requested';
reviewerName = settings.reviewer_name || '';
} else {
// No settings row yet — default the jurisdiction to the family's
// whole-app setting rather than a hardcoded guess.
jurisdiction = await getFamilyJurisdiction(familyId);
}
}
@@ -43,7 +55,35 @@
$effect(() => { familyId; authorId; refresh(); });
async function saveSettings() {
await upsertWassiyahSettings(familyId, authorId, { jurisdiction, witness1, witness2 });
await upsertWassiyahSettings(familyId, authorId, { jurisdiction, witness1, witness2, testatorName });
}
async function requestReview() {
await saveSettings();
await requestWassiyahReview(familyId, authorId);
await refresh();
}
async function markReviewed() {
if (!reviewerNameInput.trim()) return;
await markWassiyahReviewed(familyId, authorId, reviewerNameInput.trim());
reviewerNameInput = '';
await refresh();
}
let currentEmail = $state(null);
session.subscribe(v => currentEmail = v?.user?.email ?? null);
async function openDraftWillDocument() {
await saveSettings();
const trigger = await getMemberTrigger(authorId, familyId);
const html = buildWillDocumentHtml({
testatorName, email: currentEmail, jurisdiction, witness1, witness2,
executorName: trigger?.executor_name || '', bequests, estateTotal: total, cap,
generatedDate: new Date().toISOString().slice(0, 10)
});
const blob = new Blob([html], { type: 'text/html' });
const url = URL.createObjectURL(blob);
window.open(url, '_blank');
}
const bequestTotal = $derived(bequests.reduce((s, b) => s + Number(b.value), 0));
@@ -79,9 +119,7 @@
'benefit a person who is already a fixed-share (Faraid) heir, except with the consent',
'of all other heirs after death.',
'',
jurisdiction === 'UK'
? 'UK NOTE: this draft references Wills Act 1837 execution requirements. A will only has\nlegal effect if it is also validly executed under UK law, independent of its faraid-\ncorrectness. Have this reviewed by a UK-qualified solicitor before relying on it.'
: 'MALAYSIA NOTE: Land Office or Shariah Court recognition is not guaranteed by an\napp-generated document alone. State-specific requirements vary across all fourteen states.',
`${JURISDICTION_LABELS[jurisdiction] || jurisdiction} NOTE: ${JURISDICTION_NOTES[jurisdiction] || 'Confirm execution requirements with a locally qualified lawyer before relying on this draft.'}`,
'',
`Witness 1: ${witness1 || '________________________'}`,
`Witness 2: ${witness2 || '________________________'}`,
@@ -125,8 +163,11 @@
<div class="meter-row"><span>Bequeathed so far</span><strong class:danger={exceedsCap}>{bequestTotal.toLocaleString()}</strong></div>
</div>
<label class="field"><span>Full legal name</span><input type="text" bind:value={testatorName} onblur={saveSettings} placeholder="as it should appear on the will document" /></label>
<label class="field"><span>Jurisdiction</span>
<select bind:value={jurisdiction} onchange={saveSettings}><option value="UK">United Kingdom</option><option value="MY">Malaysia</option></select>
<select bind:value={jurisdiction} onchange={saveSettings}>
{#each Object.entries(JURISDICTION_LABELS) as [code, label]}<option value={code}>{label}</option>{/each}
</select>
</label>
<div class="form-card">
@@ -160,6 +201,24 @@
<label class="field"><span>Witness 2</span><input type="text" bind:value={witness2} onblur={saveSettings} /></label>
<button class="btn-primary" disabled={exceedsCap && !overrideAcknowledged} onclick={exportDraft}>Export draft (PDF + text)</button>
<button class="btn-secondary" disabled={exceedsCap && !overrideAcknowledged} onclick={openDraftWillDocument}>Generate draft will document</button>
<p class="will-doc-note">Opens a formatted, statutory-style DRAFT will in a new tab — use your browser's print dialog to save it as a PDF. Clearly watermarked "not executed": a real will still requires physical signing and witnessing to take legal effect.</p>
<div class="review-card review-{reviewStatus}">
<h3>Professional review</h3>
{#if reviewStatus === 'reviewed'}
<p>Reviewed by <strong>{reviewerName}</strong>.</p>
{:else if reviewStatus === 'requested'}
<p>Review requested — visible to your mutawalli/agent. Mark it reviewed once a professional has actually looked at it.</p>
<div class="reviewer-row">
<input type="text" bind:value={reviewerNameInput} placeholder="Reviewer's name" />
<button class="btn-secondary" onclick={markReviewed} disabled={!reviewerNameInput.trim()}>Mark reviewed</button>
</div>
{:else}
<p>This draft hasn't been reviewed by a legal professional yet. This app cannot produce a legally executed will on its own — a professional review is strongly recommended, and required in some jurisdictions before relying on this document.</p>
<button class="btn-secondary" onclick={requestReview}>Request professional review</button>
{/if}
</div>
<Disclaimer />
</div>
@@ -184,6 +243,17 @@
.form-card { background: rgba(255,255,255,0.03); border-radius: 12px; padding: 14px; margin-bottom: 16px; }
.btn-primary { width: 100%; padding: 12px; border-radius: 8px; border: none; font-weight: 600; cursor: pointer; background: #C9A84C; color: #070A0D; }
.btn-primary:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-secondary { width: 100%; padding: 12px; border-radius: 8px; border: 1px solid rgba(201,168,76,0.3); font-weight: 600; cursor: pointer; background: rgba(255,255,255,0.05); color: #C9A84C; margin-top: 10px; }
.btn-secondary:disabled { opacity: 0.4; cursor: not-allowed; }
.will-doc-note { font-size: 11px; color: #8A8478; line-height: 1.5; margin-top: 8px; }
.review-card { background: rgba(255,255,255,0.03); border-radius: 12px; padding: 14px; margin-top: 20px; border: 1px solid rgba(255,255,255,0.08); }
.review-card.review-reviewed { background: rgba(46,204,113,0.06); border-color: rgba(46,204,113,0.25); }
.review-card.review-requested { background: rgba(201,168,76,0.06); border-color: rgba(201,168,76,0.25); }
.review-card h3 { font-size: 13.5px; color: #C9A84C; margin-bottom: 8px; }
.review-card p { font-size: 12px; color: #E8E4DC; line-height: 1.5; margin-bottom: 10px; }
.reviewer-row { display: flex; gap: 8px; }
.reviewer-row input { flex: 1; background: rgba(255,255,255,0.05); border: 1px solid rgba(201,168,76,0.2); border-radius: 8px; padding: 8px 10px; color: #E8E4DC; font-size: 13px; }
.reviewer-row button { width: auto; margin-top: 0; }
.block-error { font-size: 12px; color: #EF4444; line-height: 1.5; }
.bequest-row { display: flex; justify-content: space-between; align-items: center; padding: 10px 0; border-bottom: 1px solid rgba(255,255,255,0.06); font-size: 13px; color: #E8E4DC; }
.muted { color: #8A8478; font-size: 11.5px; }
+108
View File
@@ -0,0 +1,108 @@
<script>
import { onMount } from 'svelte';
import { activeFamilyId, getFamilyJurisdiction } from './family.js';
import { session } from './auth.js';
import { getZakatRecord, upsertZakatRecord } from './db.js';
import { zakatableWealth, zakatDue, meetsNisab } from './calc/zakat.js';
import { CURRENCY_BY_JURISDICTION, DEFAULT_NISAB_BY_JURISDICTION } from './calc/zakat.js';
import Disclaimer from './Disclaimer.svelte';
import InfoPanel from './InfoPanel.svelte';
let familyId = $state(null);
activeFamilyId.subscribe(v => familyId = v);
// Live off the session store, not a one-time snapshot — same fix as every
// other per-member module (Wassiyah, Waqf, Insurance): this component
// remounts on tab switch, and a stale null captured once at mount would
// silently break every write until the next remount.
let memberId = $state(null);
session.subscribe(v => memberId = v?.user?.id ?? null);
let currency = $state('RM');
function emptyFields(jurisdiction) {
return { cash: '', gold: '', silver: '', businessAssets: '', investments: '', otherZakatable: '', deductibleLiabilities: '', nisabThreshold: String(DEFAULT_NISAB_BY_JURISDICTION[jurisdiction] || 24000) };
}
let fields = $state(emptyFields('MY'));
let loaded = $state(false);
async function refresh() {
if (!familyId || !memberId) return;
const jurisdiction = await getFamilyJurisdiction(familyId);
currency = CURRENCY_BY_JURISDICTION[jurisdiction] || 'RM';
const record = await getZakatRecord(familyId, memberId);
fields = record
? { cash: String(record.cash), gold: String(record.gold), silver: String(record.silver), businessAssets: String(record.businessAssets), investments: String(record.investments), otherZakatable: String(record.otherZakatable), deductibleLiabilities: String(record.deductibleLiabilities), nisabThreshold: String(record.nisabThreshold) }
: emptyFields(jurisdiction);
loaded = true;
}
onMount(refresh);
$effect(() => { familyId; memberId; refresh(); });
let saveTimer;
function scheduleSave() {
clearTimeout(saveTimer);
saveTimer = setTimeout(() => { upsertZakatRecord(familyId, memberId, fields); }, 500);
}
const wealth = $derived(zakatableWealth(fields));
const due = $derived(zakatDue(fields));
const meets = $derived(meetsNisab(fields));
</script>
<div class="module">
<div class="module-header">
<h2>Zakat</h2>
<InfoPanel
title="Zakat"
what="Your annual Zakat obligation — 2.5% of qualifying wealth (cash, gold, silver, business assets, investments) held for a full lunar year (haul), once it's above the Nisab threshold. This is a personal obligation, separate from Faraid and Wassiyah, which only apply after death."
how="Enter your zakatable wealth by category, subtract any short-term deductible liabilities, and set today's Nisab value (85g gold or 595g silver equivalent — check a current gold price, this app doesn't fetch it live). Figures autosave as you type."
fields={[
{ label: 'Zakatable wealth', hint: 'Cash, gold, silver, business inventory, investments — anything held for a full lunar year.' },
{ label: 'Deductible liabilities', hint: 'Short-term debts due, subtracted before checking against Nisab.' },
{ label: 'Nisab threshold', hint: 'The minimum wealth level before Zakat is due — varies with the current gold/silver price.' }
]}
/>
</div>
<p class="sub">Your own Zakat calculation — a personal, ongoing obligation, not tied to death or inheritance. Figures in {currency}.</p>
{#if loaded}
<div class="form-card">
<label class="field"><span>Cash &amp; bank balances ({currency})</span><input type="number" min="0" bind:value={fields.cash} oninput={scheduleSave} /></label>
<label class="field"><span>Gold — current market value ({currency})</span><input type="number" min="0" bind:value={fields.gold} oninput={scheduleSave} /></label>
<label class="field"><span>Silver — current market value ({currency})</span><input type="number" min="0" bind:value={fields.silver} oninput={scheduleSave} /></label>
<label class="field"><span>Business assets / inventory ({currency})</span><input type="number" min="0" bind:value={fields.businessAssets} oninput={scheduleSave} /></label>
<label class="field"><span>Investments / shares / digital assets ({currency})</span><input type="number" min="0" bind:value={fields.investments} oninput={scheduleSave} /></label>
<label class="field"><span>Other zakatable wealth ({currency})</span><input type="number" min="0" bind:value={fields.otherZakatable} oninput={scheduleSave} /></label>
<label class="field"><span>Deductible liabilities — due within the year ({currency})</span><input type="number" min="0" bind:value={fields.deductibleLiabilities} oninput={scheduleSave} /></label>
<label class="field"><span>Nisab threshold — today's value ({currency})</span><input type="number" min="0" bind:value={fields.nisabThreshold} oninput={scheduleSave} /></label>
</div>
<div class="result-card" class:due={meets}>
<div class="result-row"><span>Zakatable wealth</span><strong>{wealth.toLocaleString()}</strong></div>
<div class="result-row"><span>Nisab threshold</span><strong>{(Number(fields.nisabThreshold) || 0).toLocaleString()}</strong></div>
<div class="result-row main"><span>{meets ? 'Zakat due (2.5%)' : 'Below Nisab — no Zakat due'}</span><strong>{due.toLocaleString()}</strong></div>
</div>
{/if}
<Disclaimer text="Manual entry only — Nisab is not fetched from a live gold/silver price feed. Confirm today's threshold and consult a scholar for madhab-specific rulings (this uses a single standard 2.5%/85g-gold ruleset)." />
</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; }
.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 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; width: 100%; }
.result-card { background: rgba(201,168,76,0.08); border: 1px solid rgba(201,168,76,0.25); border-radius: 12px; padding: 16px; margin-bottom: 18px; }
.result-card.due { background: rgba(46,204,113,0.08); border-color: rgba(46,204,113,0.3); }
.result-row { display: flex; justify-content: space-between; align-items: baseline; padding: 6px 0; font-size: 13px; color: #B8B2A6; }
.result-row strong { color: #E8E4DC; font-size: 14px; }
.result-row.main { border-top: 1px solid rgba(255,255,255,0.1); margin-top: 6px; padding-top: 12px; }
.result-row.main span { color: #C9A84C; font-weight: 600; }
.result-row.main strong { color: #2ECC71; font-size: 20px; }
</style>
+33 -5
View File
@@ -7,6 +7,18 @@
// blocking for spouse/children/parents/siblings (full, consanguine, uterine).
// Does not yet model grandparents, grandchildren, or extended 'asabah chains —
// tracked as a follow-up, not silently assumed correct for those cases.
//
// Madhab scope: this engine's default rules match the Shafi'i-aligned majority
// position used in Malaysian statutory Faraid application, which Maliki and
// Hanbali also follow on the one point this engine models a real divergence
// on. Hanafi fiqh diverges on radd (see below). Ja'fari (Shia) inheritance
// uses a fundamentally different classification system — not a parameter
// tweak on this engine — and is deliberately NOT computed here; see
// SUPPORTED_MADHABS and the 'jaafari' branch in calculateFaraid.
export const SUPPORTED_MADHABS = ['shafii', 'hanafi', 'maliki', 'hanbali'];
export const MADHAB_LABELS = {
shafii: "Shafi'i", hanafi: 'Hanafi', maliki: 'Maliki', hanbali: 'Hanbali', jaafari: "Ja'fari (Shia)"
};
function gcd(a, b) { return b === 0 ? a : gcd(b, a % b); }
@@ -32,8 +44,12 @@ class Fraction {
* spouseCount, deceasedGender ('male'|'female'),
* sons, daughters, father, mother (bool),
* fullBrothers, fullSisters, paternalBrothers, paternalSisters, maternalSiblings
* @param {string} madhab one of SUPPORTED_MADHABS, or 'jaafari' (returns unsupported: true instead of shares)
*/
export function calculateFaraid(heirs) {
export function calculateFaraid(heirs, madhab = 'shafii') {
if (madhab === 'jaafari') {
return { unsupported: true, madhab, shares: [], awlApplied: false, raddApplied: false, isUmariyyatayn: false };
}
const {
deceasedGender = 'male',
spouseCount = 0,
@@ -189,18 +205,30 @@ export function calculateFaraid(heirs) {
s.fraction = s.fraction.add(bonus);
}
} else {
// no eligible heirs at all besides spouse: spouse takes remainder by radd exception (contested; flagged)
// No eligible heirs at all besides spouse: whether the spouse absorbs the
// remainder by radd is where Hanafi fiqh actually diverges from the
// Shafi'i-aligned majority position this engine otherwise follows.
// Hanafi: spouse included in radd, takes the full remainder.
// Shafi'i/Maliki/Hanbali: spouse excluded from radd — remainder is not
// distributed to any private heir (classically: Bayt al-Mal).
const spouseShare = shares.find(s => s.heir.includes('Wife') || s.heir.includes('Husband'));
if (spouseShare) { spouseShare.fraction = spouseShare.fraction.add(residue); spouseShare.note = (spouseShare.note || '') + ' [radd-to-spouse: minority position, flag for scholarly review]'; }
}
if (madhab === 'hanafi' && spouseShare) {
spouseShare.fraction = spouseShare.fraction.add(residue);
spouseShare.note = (spouseShare.note || '') + ' [Hanafi: radd extends to spouse]';
residue = Fraction.zero();
} else if (spouseShare) {
spouseShare.note = (spouseShare.note || '') + ` [${MADHAB_LABELS[madhab]}: spouse excluded from radd — remainder held for public treasury/Bayt al-Mal, not distributed to a private heir]`;
}
}
}
return {
shares: shares.map(s => ({ ...s, fraction: s.fraction.toString(), fractionValue: s.fraction.toNumber() })),
awlApplied,
raddApplied,
isUmariyyatayn
isUmariyyatayn,
madhab,
unallocatedResidue: residue.toNumber() > 0 ? residue.toNumber() : 0
};
}
+25
View File
@@ -105,5 +105,30 @@ check('Mother + father only, no spouse/children/siblings', {
mother: true, father: true
}, { 'Mother': 1 / 3, 'Father (residuary)': 2 / 3 });
// 12. Madhab divergence — wife is the sole heir (no other heirs at all): Hanafi
// extends radd to the spouse (wife takes 100%); Shafi'i/Maliki/Hanbali exclude
// the spouse from radd (wife keeps her fixed 1/4, remainder unallocated).
{
const hanafi = calculateFaraid({ spouseCount: 1, deceasedGender: 'male' }, 'hanafi');
const wifeHanafi = hanafi.shares.find(s => s.heir.includes('Wife'));
if (wifeHanafi && approx(wifeHanafi.fractionValue, 1) && hanafi.unallocatedResidue === 0) {
pass++; console.log('PASS Madhab: Hanafi extends radd to sole-heir spouse (100%)');
} else { fail++; console.log('FAIL Madhab: Hanafi radd-to-spouse', JSON.stringify(hanafi)); }
const shafii = calculateFaraid({ spouseCount: 1, deceasedGender: 'male' }, 'shafii');
const wifeShafii = shafii.shares.find(s => s.heir.includes('Wife'));
if (wifeShafii && approx(wifeShafii.fractionValue, 1 / 4) && approx(shafii.unallocatedResidue, 3 / 4)) {
pass++; console.log("PASS Madhab: Shafi'i excludes spouse from radd (wife keeps 1/4, 3/4 unallocated)");
} else { fail++; console.log("FAIL Madhab: Shafi'i radd-to-spouse exclusion", JSON.stringify(shafii)); }
}
// 13. Ja'fari is honestly gated as unsupported, not silently computed with Sunni rules.
{
const jaafari = calculateFaraid({ spouseCount: 1, deceasedGender: 'male' }, 'jaafari');
if (jaafari.unsupported === true && jaafari.shares.length === 0) {
pass++; console.log("PASS Madhab: Ja'fari (Shia) is gated as unsupported, not silently miscalculated");
} else { fail++; console.log("FAIL Madhab: Ja'fari gating", JSON.stringify(jaafari)); }
}
console.log(`\n${pass} passed, ${fail} failed`);
if (fail > 0) process.exit(1);
+89
View File
@@ -0,0 +1,89 @@
// Prayer time calculation — the standard astronomical method used across
// open-source Islamic prayer-time tools (single-pass solar position, MWL
// angles by default): computed entirely client-side from geolocation and
// the device's own timezone offset. No API, no key, no external service.
// Single-pass precision is approximate (within a few minutes) — see the
// Disclaimer in PrayerTimes.svelte; this matches the rest of the app's
// "manual entry / calculated, verify locally" philosophy.
const D2R = Math.PI / 180;
const R2D = 180 / Math.PI;
const dsin = d => Math.sin(d * D2R);
const dcos = d => Math.cos(d * D2R);
const dtan = d => Math.tan(d * D2R);
const darcsin = x => Math.asin(x) * R2D;
const darccos = x => Math.acos(x) * R2D;
const darctan2 = (y, x) => Math.atan2(y, x) * R2D;
const fixAngle = a => a - 360 * Math.floor(a / 360);
const fixHour = h => h - 24 * Math.floor(h / 24);
function julianDate(year, month, day) {
if (month <= 2) { year -= 1; month += 12; }
const A = Math.floor(year / 100);
const B = 2 - A + Math.floor(A / 4);
return Math.floor(365.25 * (year + 4716)) + Math.floor(30.6001 * (month + 1)) + day + B - 1524.5;
}
function sunPosition(jd) {
const D = jd - 2451545.0;
const g = fixAngle(357.529 + 0.98560028 * D);
const q = fixAngle(280.459 + 0.98564736 * D);
const L = fixAngle(q + 1.915 * dsin(g) + 0.02 * dsin(2 * g));
const e = 23.439 - 0.00000036 * D;
const RA = darctan2(dcos(e) * dsin(L), dcos(L)) / 15;
const eqt = q / 15 - fixHour(RA);
const decl = darcsin(dsin(e) * dsin(L));
return { declination: decl, equation: eqt };
}
/** Hour angle (in hours from solar noon) at which the sun reaches `angle` degrees below the horizon. */
function angleTime(angle, jd, lat, direction) {
const decl = sunPosition(jd).declination;
const ratio = (-dsin(angle) - dsin(decl) * dsin(lat)) / (dcos(decl) * dcos(lat));
if (ratio > 1 || ratio < -1) return null; // sun never reaches this angle at this latitude/date (polar regions)
const t = darccos(ratio) / 15;
return direction === 'ccw' ? -t : t;
}
function asrOffset(shadowFactor, jd, lat) {
const decl = sunPosition(jd).declination;
const altitude = darctan2(1, shadowFactor + dtan(Math.abs(lat - decl))); // arccot via atan2
return angleTime(-altitude, jd, lat, 'cw');
}
/**
* @param {object} params
* latitude, longitude, date (JS Date), timezoneOffsetHours (device offset from UTC, e.g. -new Date().getTimezoneOffset()/60),
* fajrAngle (default 18, MWL), ishaAngle (default 17, MWL), asrShadowFactor (1 = Shafi'i/majority, 2 = Hanafi)
*/
export function calculatePrayerTimes({ latitude, longitude, date, timezoneOffsetHours, fajrAngle = 18, ishaAngle = 17, asrShadowFactor = 1 }) {
const jd = julianDate(date.getFullYear(), date.getMonth() + 1, date.getDate());
const eqt = sunPosition(jd).equation;
const noon = fixHour(12 - eqt);
const tzAdjust = timezoneOffsetHours - longitude / 15;
const offsets = {
fajr: angleTime(fajrAngle, jd, latitude, 'ccw'),
sunrise: angleTime(0.833, jd, latitude, 'ccw'),
dhuhr: 0,
asr: asrOffset(asrShadowFactor, jd, latitude),
maghrib: angleTime(0.833, jd, latitude, 'cw'),
isha: angleTime(ishaAngle, jd, latitude, 'cw')
};
const result = {};
for (const [name, offset] of Object.entries(offsets)) {
if (offset == null) { result[name] = null; continue; }
const t = fixHour(noon + offset + tzAdjust);
const h = Math.floor(t);
const m = Math.round((t - h) * 60);
result[name] = { hours: h, minutes: m === 60 ? 0 : m };
}
return result;
}
export function formatClock({ hours, minutes }) {
const h12 = hours % 12 === 0 ? 12 : hours % 12;
const ampm = hours < 12 ? 'AM' : 'PM';
return `${h12}:${String(minutes).padStart(2, '0')} ${ampm}`;
}
+31
View File
@@ -0,0 +1,31 @@
// Shared Zakat calculation core — Nisab check + 2.5% (1/40) on qualifying
// wealth held above threshold. Nisab is entered as a value, not a live gold
// price feed — matches the app's "manual entry only" philosophy already
// established for Asset Registry / Faraid.
const ZAKAT_RATE = 0.025;
// Rough starting points, NOT live prices — the app has no gold/silver price
// feed (manual entry only, matching the rest of the app). These exist so a
// new user isn't staring at a blank/zero Nisab field; they must still verify
// today's actual 85g-gold-equivalent value before relying on the result.
export const CURRENCY_BY_JURISDICTION = { MY: 'RM', SG: 'SGD', UK: 'GBP' };
export const DEFAULT_NISAB_BY_JURISDICTION = { MY: 24000, SG: 8000, UK: 5000 };
export function zakatableWealth(fields) {
const gross = (Number(fields.cash) || 0) + (Number(fields.gold) || 0) + (Number(fields.silver) || 0)
+ (Number(fields.businessAssets) || 0) + (Number(fields.investments) || 0) + (Number(fields.otherZakatable) || 0);
const deductible = Number(fields.deductibleLiabilities) || 0;
return Math.max(0, gross - deductible);
}
export function zakatDue(fields) {
const wealth = zakatableWealth(fields);
const nisab = Number(fields.nisabThreshold) || 0;
if (nisab <= 0 || wealth < nisab) return 0;
return wealth * ZAKAT_RATE;
}
export function meetsNisab(fields) {
const nisab = Number(fields.nisabThreshold) || 0;
return nisab > 0 && zakatableWealth(fields) >= nisab;
}
+187 -3
View File
@@ -40,8 +40,8 @@ export async function listTrustedContacts(familyId) {
if (error) throw error;
return data || [];
}
export async function addTrustedContact(familyId, name, method) {
const { error } = await supabase.from('nf_trusted_contacts').insert({ family_id: familyId, name, method });
export async function addTrustedContact(familyId, name, method, category, email) {
const { error } = await supabase.from('nf_trusted_contacts').insert({ family_id: familyId, name, method, category: category || 'family', email: email || null });
if (error) throw error;
}
export async function removeTrustedContact(id) {
@@ -49,6 +49,110 @@ export async function removeTrustedContact(id) {
if (error) throw error;
}
// ── Khairat — per-member scheme membership + a per-family emergency fund. ──
export async function listKhairatMemberships(familyId) {
const { data, error } = await supabase.from('nf_khairat_memberships').select('*').eq('family_id', familyId).order('created_at');
if (error) throw error;
return (data || []).map(k => ({
id: k.id, memberId: k.member_id, schemeName: k.scheme_name, organization: k.organization,
membershipNumber: k.membership_number, contactPhone: k.contact_phone, contactEmail: k.contact_email, notes: k.notes
}));
}
export async function addKhairatMembership(familyId, memberId, createdBy, fields) {
const { error } = await supabase.from('nf_khairat_memberships').insert({
family_id: familyId, member_id: memberId, created_by: createdBy, scheme_name: fields.schemeName,
organization: fields.organization, membership_number: fields.membershipNumber,
contact_phone: fields.contactPhone, contact_email: fields.contactEmail, notes: fields.notes
});
if (error) throw error;
}
export async function removeKhairatMembership(id) {
const { error } = await supabase.from('nf_khairat_memberships').delete().eq('id', id);
if (error) throw error;
}
export async function getEmergencyFund(familyId) {
const { data, error } = await supabase.from('nf_emergency_fund').select('*').eq('family_id', familyId).maybeSingle();
if (error) throw error;
return data ? { targetAmount: data.target_amount, currentBalance: data.current_balance, notes: data.notes } : null;
}
export async function upsertEmergencyFund(familyId, fields) {
const { error } = await supabase.from('nf_emergency_fund').upsert({
family_id: familyId, target_amount: fields.targetAmount ? Number(fields.targetAmount) : null,
current_balance: Number(fields.currentBalance) || 0, notes: fields.notes || null, updated_at: new Date().toISOString()
}, { onConflict: 'family_id' });
if (error) throw error;
}
/** Sends an emergency notice to trusted contacts in the given categories via the notify-emergency-contacts Edge Function. */
export async function notifyEmergencyContacts(memberId, familyId, categories) {
const { data, error } = await supabase.functions.invoke('notify-emergency-contacts', { body: { memberId, familyId, categories } });
if (error) throw error;
return data;
}
// ── Neighbourhood announcement board — join-by-code community, independent
// of the family estate structure. ──
function genJoinCode() {
return Array.from({ length: 6 }, () => 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'[Math.floor(Math.random() * 32)]).join('');
}
export async function createNeighbourhood(name, userId) {
const joinCode = genJoinCode();
const { data, error } = await supabase.from('nf_neighbourhoods').insert({ name, join_code: joinCode, created_by: userId }).select().single();
if (error) throw error;
await supabase.from('nf_neighbourhood_members').insert({ neighbourhood_id: data.id, user_id: userId });
return data;
}
export async function joinNeighbourhoodByCode(joinCode, userId, displayName) {
const { data: n, error: findErr } = await supabase.from('nf_neighbourhoods').select('*').eq('join_code', joinCode.toUpperCase().trim()).maybeSingle();
if (findErr) throw findErr;
if (!n) throw new Error('No neighbourhood found with that code.');
const { error } = await supabase.from('nf_neighbourhood_members').insert({ neighbourhood_id: n.id, user_id: userId, display_name: displayName || null });
if (error) throw error;
return n;
}
export async function listMyNeighbourhoods(userId) {
const { data, error } = await supabase.from('nf_neighbourhood_members').select('neighbourhood_id, nf_neighbourhoods(id, name, join_code)').eq('user_id', userId);
if (error) throw error;
return (data || []).map(r => ({ id: r.neighbourhood_id, name: r.nf_neighbourhoods?.name, joinCode: r.nf_neighbourhoods?.join_code }));
}
export async function leaveNeighbourhood(neighbourhoodId, userId) {
const { error } = await supabase.from('nf_neighbourhood_members').delete().eq('neighbourhood_id', neighbourhoodId).eq('user_id', userId);
if (error) throw error;
}
export async function listNeighbourhoodPosts(neighbourhoodId) {
const { data, error } = await supabase.from('nf_neighbourhood_posts').select('*').eq('neighbourhood_id', neighbourhoodId).order('created_at', { ascending: false });
if (error) throw error;
return data || [];
}
export async function addNeighbourhoodPost(neighbourhoodId, authorId, authorName, title, body, eventFields) {
const { error } = await supabase.from('nf_neighbourhood_posts').insert({
neighbourhood_id: neighbourhoodId, author_id: authorId, author_name: authorName, title, body,
is_event: !!eventFields, event_date: eventFields?.eventDate || null, event_location: eventFields?.eventLocation || null
});
if (error) throw error;
}
export async function removeNeighbourhoodPost(id) {
const { error } = await supabase.from('nf_neighbourhood_posts').delete().eq('id', id);
if (error) throw error;
}
// ── Event Board — RSVPs on event-flagged neighbourhood posts. ──
export async function listEventRsvps(postIds) {
if (!postIds.length) return [];
const { data, error } = await supabase.from('nf_event_rsvps').select('post_id, user_id').in('post_id', postIds);
if (error) throw error;
return data || [];
}
export async function rsvpToEvent(postId, userId) {
const { error } = await supabase.from('nf_event_rsvps').insert({ post_id: postId, user_id: userId });
if (error) throw error;
}
export async function cancelRsvp(postId, userId) {
const { error } = await supabase.from('nf_event_rsvps').delete().eq('post_id', postId).eq('user_id', userId);
if (error) throw error;
}
// ── Hibah ──
export async function listHibahGifts(familyId) {
const { data, error } = await supabase.from('nf_hibah_gifts').select('*').eq('family_id', familyId).order('created_at');
@@ -222,7 +326,7 @@ export async function getWassiyahSettings(familyId, authorId) {
export async function upsertWassiyahSettings(familyId, authorId, fields) {
const { error } = await supabase.from('nf_wassiyah_settings').upsert({
family_id: familyId, author_id: authorId, jurisdiction: fields.jurisdiction, witness1: fields.witness1, witness2: fields.witness2,
updated_at: new Date().toISOString()
testator_name: fields.testatorName, updated_at: new Date().toISOString()
}, { onConflict: 'family_id,author_id' });
if (error) throw error;
}
@@ -234,6 +338,29 @@ export async function listAllWassiyahForFamily(familyId) {
return data || [];
}
// ── Professional review workflow — a structured status, not a marketplace.
// Any family member can request review of their own Wassiyah; anyone (in
// practice the mutawalli/agent, coordinating with an actual lawyer outside
// the app) can mark it reviewed once done.
export async function requestWassiyahReview(familyId, authorId) {
const { error } = await supabase.from('nf_wassiyah_settings').upsert({
family_id: familyId, author_id: authorId, review_status: 'requested', requested_at: new Date().toISOString(), updated_at: new Date().toISOString()
}, { onConflict: 'family_id,author_id' });
if (error) throw error;
}
export async function markWassiyahReviewed(familyId, authorId, reviewerName) {
const { error } = await supabase.from('nf_wassiyah_settings').upsert({
family_id: familyId, author_id: authorId, review_status: 'reviewed', reviewer_name: reviewerName, reviewed_at: new Date().toISOString(), updated_at: new Date().toISOString()
}, { onConflict: 'family_id,author_id' });
if (error) throw error;
}
/** For the mutawalli dashboard: every family member's Wassiyah settings (jurisdiction, review status). */
export async function listAllWassiyahSettingsForFamily(familyId) {
const { data, error } = await supabase.from('nf_wassiyah_settings').select('*').eq('family_id', familyId);
if (error) throw error;
return data || [];
}
// ── Per-member death trigger — the mutawalli/owner fires it for a specific
// member, never the member themselves (enforced by RLS, not just the UI). ──
export async function getMemberTrigger(memberId, familyId) {
@@ -359,6 +486,41 @@ export async function removeRelationship(id) {
if (error) throw error;
}
// ── Sadaqah — private daily journal. Family visibility is limited to
// streak counts via nf_family_sadaqah_streaks (never amounts/causes/notes).
export async function listSadaqahLog(familyId, memberId) {
const { data, error } = await supabase.from('nf_sadaqah_log').select('*').eq('family_id', familyId).eq('member_id', memberId).order('log_date', { ascending: false });
if (error) throw error;
return (data || []).map(s => ({
id: s.id, logDate: s.log_date, cause: s.cause, amount: s.amount,
inMemoryOfPersonId: s.in_memory_of_person_id, notes: s.notes
}));
}
export async function addSadaqahEntry(familyId, memberId, fields) {
const { error } = await supabase.from('nf_sadaqah_log').insert({
family_id: familyId, member_id: memberId, log_date: fields.logDate || new Date().toISOString().slice(0, 10),
cause: fields.cause || null, amount: fields.amount ? Number(fields.amount) : null,
in_memory_of_person_id: fields.inMemoryOfPersonId || null, notes: fields.notes || null
});
if (error) throw error;
}
export async function removeSadaqahEntry(id) {
const { error } = await supabase.from('nf_sadaqah_log').delete().eq('id', id);
if (error) throw error;
}
/** Family-visible streak counts only — no amounts, causes, or notes. */
export async function getFamilySadaqahStreaks(familyId) {
const { data, error } = await supabase.rpc('nf_family_sadaqah_streaks', { p_family_id: familyId });
if (error) throw error;
return (data || []).map(r => ({ memberId: r.member_id, currentStreak: r.current_streak, gaveToday: r.gave_today }));
}
/** Count of sadaqah given in memory of a person — count only, shown on their Tree card. */
export async function getMemorialSadaqahCount(personId) {
const { data, error } = await supabase.rpc('nf_memorial_sadaqah_count', { p_person_id: personId });
if (error) throw error;
return data || 0;
}
// ── Insurance / Takaful — per-member: each family member logs their own
// policies, but any family member (and the mutawalli dashboard) can read them. ──
export async function listInsurancePolicies(familyId, memberId) {
@@ -490,3 +652,25 @@ export async function setAssetVerified(assetId, verifiedBy, verified) {
}).eq('id', assetId);
if (error) throw error;
}
// ── Zakat — per-member, one live record per family/member pair. ──
export async function getZakatRecord(familyId, memberId) {
const { data, error } = await supabase.from('nf_zakat_records').select('*').eq('family_id', familyId).eq('member_id', memberId).maybeSingle();
if (error) throw error;
if (!data) return null;
return {
cash: data.cash, gold: data.gold, silver: data.silver, businessAssets: data.business_assets,
investments: data.investments, otherZakatable: data.other_zakatable,
deductibleLiabilities: data.deductible_liabilities, nisabThreshold: data.nisab_threshold
};
}
export async function upsertZakatRecord(familyId, memberId, fields) {
const { error } = await supabase.from('nf_zakat_records').upsert({
family_id: familyId, member_id: memberId,
cash: Number(fields.cash) || 0, gold: Number(fields.gold) || 0, silver: Number(fields.silver) || 0,
business_assets: Number(fields.businessAssets) || 0, investments: Number(fields.investments) || 0,
other_zakatable: Number(fields.otherZakatable) || 0, deductible_liabilities: Number(fields.deductibleLiabilities) || 0,
nisab_threshold: Number(fields.nisabThreshold) || 0, updated_at: new Date().toISOString()
}, { onConflict: 'family_id,member_id' });
if (error) throw error;
}
+12
View File
@@ -104,6 +104,18 @@ export async function removeMember(membershipId) {
if (error) throw error;
}
/** Whole-app jurisdiction (MY/SG/UK) drives Wassiyah legal notes, will
* document format, and Zakat currency defaults. Owner-only to change. */
export async function getFamilyJurisdiction(familyId) {
const { data, error } = await supabase.from('nf_families').select('jurisdiction').eq('id', familyId).maybeSingle();
if (error) throw error;
return data?.jurisdiction || 'MY';
}
export async function setFamilyJurisdiction(familyId, jurisdiction) {
const { error } = await supabase.from('nf_families').update({ jurisdiction }).eq('id', familyId);
if (error) throw error;
}
export function getActiveFamilyId() {
let id;
activeFamilyId.subscribe(v => id = v)();
+6
View File
@@ -0,0 +1,6 @@
import { writable } from 'svelte/store';
// Cross-component tab navigation — App.svelte owns the actual tab index
// locally (not a store), so this is a one-shot request channel: set the
// tab's display name here, App.svelte jumps to it and clears the request.
export const requestedTab = writable(null);
+99
View File
@@ -0,0 +1,99 @@
// Nearby-place search via the public OpenStreetMap Overpass API — free,
// keyless, real community-sourced data. Deliberately never fabricates
// listings: an empty result means nothing was found nearby, shown as such.
//
// The public Overpass instances are individually flaky under load — the
// same query can return 429 (rate-limited) from one mirror and 200 from
// another a moment later. A single hardcoded endpoint means any one
// mirror having a bad minute breaks the feature outright, so this tries
// a short list of independent public mirrors in order and only reports
// failure once all of them have failed.
// overpass.osm.ch was tried here too, but confirmed to return a "successful"
// 200 with an empty/incomplete result set for queries the other two mirrors
// answer correctly (e.g. diet:halal=yes near Kuala Lumpur: 0 results vs 30) —
// worse than an outright failure, since a 200 short-circuits the fallback
// loop and looks like a legitimate empty search to the user. Dropped.
const OVERPASS_MIRRORS = [
'https://overpass-api.de/api/interpreter',
'https://overpass.kumi.systems/api/interpreter'
];
const QUERIES = {
mosque: tags => `node["amenity"="place_of_worship"]["religion"="muslim"](around:${tags.radius},${tags.lat},${tags.lon});
way["amenity"="place_of_worship"]["religion"="muslim"](around:${tags.radius},${tags.lat},${tags.lon});`,
halal: tags => `node["diet:halal"="yes"](around:${tags.radius},${tags.lat},${tags.lon});
way["diet:halal"="yes"](around:${tags.radius},${tags.lat},${tags.lon});`,
cemetery: tags => `node["landuse"="cemetery"](around:${tags.radius},${tags.lat},${tags.lon});
way["landuse"="cemetery"](around:${tags.radius},${tags.lat},${tags.lon});
node["amenity"="grave_yard"](around:${tags.radius},${tags.lat},${tags.lon});`
};
const LABELS = { mosque: 'Mosque / musalla', halal: 'Halal food', cemetery: 'Muslim cemetery' };
export const CATEGORIES = Object.keys(QUERIES);
export function categoryLabel(cat) { return LABELS[cat] || cat; }
function toRad(d) { return (d * Math.PI) / 180; }
function distanceKm(lat1, lon1, lat2, lon2) {
const R = 6371;
const dLat = toRad(lat2 - lat1), dLon = toRad(lon2 - lon1);
const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
function fetchWithTimeout(url, options, timeoutMs) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
return fetch(url, { ...options, signal: controller.signal }).finally(() => clearTimeout(timer));
}
/** Searches OpenStreetMap for real nearby places in the given category. Returns [] on no results, throws only if every mirror fails. */
export async function searchNearby(category, lat, lon, radiusMeters = 5000) {
const q = QUERIES[category];
if (!q) throw new Error(`Unknown category: ${category}`);
const query = `[out:json][timeout:20];(${q({ lat, lon, radius: radiusMeters })});out center 30;`;
const body = 'data=' + encodeURIComponent(query);
let lastError = null;
let data = null;
for (const mirror of OVERPASS_MIRRORS) {
try {
const res = await fetchWithTimeout(mirror, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body
}, 12000);
if (res.status === 429 || res.status === 504 || res.status === 503) {
// This mirror is overloaded/rate-limited right now — a different
// one may not be, so keep trying rather than failing immediately.
lastError = new Error(`${mirror} returned ${res.status}`);
continue;
}
if (!res.ok) { lastError = new Error(`${mirror} returned ${res.status}`); continue; }
data = await res.json();
break;
} catch (e) {
lastError = e;
}
}
if (!data) throw new Error(`All location-search mirrors are unavailable right now (${lastError?.message || 'unknown error'}). Try again shortly.`);
return (data.elements || [])
.map(el => {
const elLat = el.lat ?? el.center?.lat;
const elLon = el.lon ?? el.center?.lon;
if (elLat == null || elLon == null) return null;
return {
id: el.id,
name: el.tags?.name || categoryLabel(category),
lat: elLat, lon: elLon,
distanceKm: distanceKm(lat, lon, elLat, elLon),
address: [el.tags?.['addr:housenumber'], el.tags?.['addr:street'], el.tags?.['addr:city']].filter(Boolean).join(', ')
};
})
.filter(Boolean)
.sort((a, b) => a.distanceKm - b.distanceKm);
}
export function directionsUrl(place) {
return `https://www.openstreetmap.org/directions?to=${place.lat}%2C${place.lon}`;
}
+60
View File
@@ -0,0 +1,60 @@
// Rule-based guidance engine — turns the app's own data into plain-language
// next steps, the way Legacy Logic's "personalized report" and Rafiq's
// contextual coaching do. Deliberately rule-based against data already
// captured in this app, not a call to an external model: every recommendation
// here traces to a concrete fact (an unverified asset, a missing attestor,
// zero policies logged), not a generated guess.
export function buildRecommendations(data) {
const {
exposedTotal = 0, exposedCount = 0, unverifiedCount = 0, wassiyahCount = 0, estateTotal = 0,
insuranceCount = 0, zakatConfigured = false, hasCashOrGold = false, unlinkedLiabilityCount = 0,
confirmedAttestorCount = 0, hasAgent = false, waqfConfigured = false,
missingBirthDateCount = 0, missingPhotoCount = 0, orphanPersonCount = 0
} = data;
const items = [];
if (exposedTotal > 0) {
items.push({
severity: 'high',
text: `${exposedCount} asset${exposedCount === 1 ? ' is' : 's are'} still exposed to the slow Faraid/probate queue (${exposedTotal.toLocaleString()} total). Cover the highest-value one first — Hibah, Waqf, or a Nomination.`,
tab: 'Coverage'
});
}
if (estateTotal > 0 && wassiyahCount === 0) {
items.push({ severity: 'medium', text: 'No Wassiyah bequests recorded yet. Up to a third of your estate can go to non-heirs or charity — worth setting up even alongside Faraid.', tab: 'Wassiyah' });
}
if (unverifiedCount > 0) {
items.push({ severity: 'medium', text: `${unverifiedCount} asset${unverifiedCount === 1 ? '' : 's'} still unverified — attach proof (title, grant, cover note) and get it confirmed so there's no dispute later.`, tab: 'Assets' });
}
if (unlinkedLiabilityCount > 0) {
items.push({ severity: 'low', text: `${unlinkedLiabilityCount} liabilit${unlinkedLiabilityCount === 1 ? 'y is' : 'ies are'} not linked to the asset securing it — link it so the net estate calculation stays accurate.`, tab: 'Assets' });
}
if (insuranceCount === 0) {
items.push({ severity: 'low', text: 'No life or Takaful policies logged. If you have any, add them — payouts usually go straight to a named beneficiary, outside Faraid entirely.', tab: 'Insurance' });
}
if (hasCashOrGold && !zakatConfigured) {
items.push({ severity: 'low', text: 'You have cash or gold logged but no Zakat calculation set up. Check whether you\'re above Nisab this year.', tab: 'Zakat' });
}
if (confirmedAttestorCount < 2) {
items.push({ severity: 'medium', text: 'Fewer than 2 attestors are confirmed on your death trigger. Without them, your mutawalli/agent can\'t fire the trigger when the time comes.', tab: 'Trigger' });
}
if (!hasAgent) {
items.push({ severity: 'low', text: 'No estate agent/mutawalli assigned to this family yet. Without one, only the owner can execute triggers and confirm assets.', tab: 'Family' });
}
if (estateTotal > 0 && !waqfConfigured) {
items.push({ severity: 'low', text: 'No Waqf designation set up. If any part of your estate is meant as a lasting charitable endowment, this is where to set it aside.', tab: 'Family Waqf' });
}
if (orphanPersonCount > 0) {
items.push({ severity: 'low', text: `${orphanPersonCount} ${orphanPersonCount === 1 ? 'person has' : 'people have'} no relationships linked in your Family Tree — is a parent, child, or spouse missing?`, tab: 'Tree' });
}
if (missingBirthDateCount > 0) {
items.push({ severity: 'low', text: `${missingBirthDateCount} ${missingBirthDateCount === 1 ? 'person is' : 'people are'} missing a birth date in the Family Tree.`, tab: 'Tree' });
}
if (missingPhotoCount > 0) {
items.push({ severity: 'low', text: `${missingPhotoCount} ${missingPhotoCount === 1 ? 'person has' : 'people have'} no photo in the Family Tree.`, tab: 'Tree' });
}
const order = { high: 0, medium: 1, low: 2 };
return items.sort((a, b) => order[a.severity] - order[b.severity]);
}
+100
View File
@@ -0,0 +1,100 @@
// Generates a print-ready DRAFT will document from Wassiyah data — a
// statutory-style layout (declaration/revocation, executor appointment,
// bequest schedule, witness attestation blocks) that a lawyer can review and
// finalize, not a document this app claims is legally executed on its own.
// Deliberately watermarked and disclaimed throughout: a will only takes
// legal effect once physically signed and witnessed per local law (in most
// jurisdictions, two witnesses present simultaneously, neither a
// beneficiary). No PDF library is added — the browser's own print-to-PDF
// keeps this dependency-free and print-perfect, matching the app's existing
// Blob-download pattern for execution packets.
function esc(s) {
return String(s || '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
export const JURISDICTION_LABELS = { MY: 'Malaysia', SG: 'Singapore', UK: 'United Kingdom' };
// Execution requirements genuinely differ by jurisdiction — kept short and
// factual (statute names, witness rules), not legal advice on how they
// apply to any specific estate.
export const JURISDICTION_NOTES = {
MY: "Execution under the Wills Act 1959 applies to non-Muslims and to the discretionary Wassiyah portion for Muslims; the signature must be made or acknowledged in the presence of two witnesses present at the same time, who then also sign. The remainder of the estate is subject to Faraid under the relevant State Islamic law — requirements and the recognized process vary across Malaysia's states, so confirm the current position with a Wasiyyah provider or Syariah-qualified lawyer before relying on this draft.",
SG: "Execution under the Wills Act 1838 (Cap. 352) requires the testator's signature made or acknowledged in the presence of two witnesses present at the same time, who then also sign in the testator's presence. For Muslims, the Administration of Muslim Law Act (AMLA) governs Faraid distribution of the remainder through the Syariah Court, while the Wassiyah (discretionary one-third) portion is still executed as a civil will under the Wills Act — have this draft reviewed by a Singapore-qualified lawyer before relying on it.",
UK: "Execution under the Wills Act 1837 requires the testator's signature made or acknowledged in the presence of two witnesses present at the same time, who then also sign in the testator's presence. A UK Islamic will operates as an ordinary civil will for legal purposes — English/Scottish/Northern Irish succession law does not itself apply Faraid, so the Wassiyah structure here only takes effect through this document being properly executed. Have this draft reviewed by a UK-qualified solicitor before relying on it."
};
export function buildWillDocumentHtml({ testatorName, email, jurisdiction, witness1, witness2, executorName, bequests, estateTotal, cap, generatedDate }) {
const name = testatorName?.trim() || '[FULL LEGAL NAME NOT YET ENTERED]';
const exec = executorName?.trim() || '[EXECUTOR NOT YET NAMED]';
const w1 = witness1?.trim() || '[WITNESS 1 NOT YET NAMED]';
const w2 = witness2?.trim() || '[WITNESS 2 NOT YET NAMED]';
const bequestTotal = (bequests || []).reduce((s, b) => s + Number(b.value || 0), 0);
const bequestRows = (bequests || []).map((b, i) => `
<tr>
<td>${i + 1}</td>
<td>${esc(b.recipient)}${b.relation ? ` (${esc(b.relation)})` : ''}</td>
<td>${esc(b.description || '—')}</td>
<td class="num">${Number(b.value || 0).toLocaleString()}</td>
</tr>`).join('');
return `<!doctype html>
<html><head><meta charset="utf-8"><title>Draft Will ${esc(name)}</title>
<style>
@page { margin: 2.2cm; }
body { font-family: Georgia, 'Times New Roman', serif; color: #111; line-height: 1.6; max-width: 800px; margin: 0 auto; padding: 24px; }
.watermark { text-align: center; background: #fff3f3; border: 2px dashed #c0392b; color: #c0392b; font-weight: bold; padding: 14px; margin-bottom: 24px; letter-spacing: 0.5px; text-transform: uppercase; font-family: Arial, sans-serif; font-size: 13px; }
h1 { text-align: center; font-size: 22px; letter-spacing: 1px; text-transform: uppercase; margin-bottom: 4px; }
.subtitle { text-align: center; font-size: 12px; color: #555; margin-bottom: 28px; font-family: Arial, sans-serif; }
h2 { font-size: 14px; text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 1px solid #999; padding-bottom: 4px; margin-top: 28px; }
p { font-size: 14px; text-align: justify; }
table { width: 100%; border-collapse: collapse; margin: 12px 0; font-size: 13px; }
th, td { border: 1px solid #ccc; padding: 8px; text-align: left; }
th { background: #f4f4f4; font-family: Arial, sans-serif; font-size: 11px; text-transform: uppercase; }
td.num, th.num { text-align: right; }
.signature-block { margin-top: 40px; }
.sig-line { border-top: 1px solid #111; width: 300px; margin-top: 50px; padding-top: 4px; font-size: 12px; font-family: Arial, sans-serif; }
.witness-block { display: flex; gap: 40px; margin-top: 30px; }
.footer-note { margin-top: 40px; font-size: 11px; color: #777; font-family: Arial, sans-serif; border-top: 1px solid #ddd; padding-top: 12px; }
@media print { .no-print { display: none; } }
</style></head>
<body>
<div class="watermark">Draft Not Executed. Requires physical signing and witnessing per local law to take legal effect.</div>
<h1>Last Will &amp; Testament</h1>
<p class="subtitle">(Islamic Wassiyah limited to one-third of the net estate per Shariah)</p>
<h2>1. Declaration</h2>
<p>I, <strong>${esc(name)}</strong>${email ? ` (${esc(email)})` : ''}, being of sound mind, declare this to be my Will, and I hereby revoke all previous wills and testamentary dispositions made by me. This document expresses my Wassiyah the portion of my estate I direct outside the fixed Faraid distribution and does not purport to override any Faraid share owed to my heirs.</p>
<h2>2. Jurisdiction</h2>
<p>This Will is intended to take effect under the laws of <strong>${esc(JURISDICTION_LABELS[jurisdiction] || jurisdiction || '[JURISDICTION NOT YET ENTERED]')}</strong>, in accordance with Shariah principles governing Wassiyah.</p>
<p>${JURISDICTION_NOTES[jurisdiction] || 'Jurisdiction-specific execution requirements were not available for this selection — confirm requirements with a locally qualified lawyer before relying on this draft.'}</p>
<h2>3. Appointment of Executor</h2>
<p>I appoint <strong>${esc(exec)}</strong> as Executor of this Will, to administer my estate, settle my debts, and distribute both the Wassiyah bequests below and the remaining estate according to Faraid.</p>
<h2>4. One-Third Cap Acknowledgement</h2>
<p>My net estate is recorded at approximately <strong>${(estateTotal || 0).toLocaleString()}</strong>. The maximum permissible under Wassiyah (one-third) is approximately <strong>${(cap || 0).toLocaleString()}</strong>. The bequests below total <strong>${bequestTotal.toLocaleString()}</strong>${bequestTotal > (cap || 0) ? ' THIS EXCEEDS THE ONE-THIRD CAP AND REQUIRES HEIR CONSENT TO STAND; REVIEW BEFORE EXECUTION.' : ', within the permitted limit.'} No bequest below is made to a Quranic fixed heir, who already receives a Faraid share.</p>
<h2>5. Schedule of Bequests</h2>
${bequestRows ? `<table><thead><tr><th>#</th><th>Recipient</th><th>Description</th><th class="num">Value</th></tr></thead><tbody>${bequestRows}</tbody></table>` : '<p><em>No bequests recorded.</em></p>'}
<p>The remainder of my estate, after debts, funeral expenses, and the bequests above, is to be distributed among my legal heirs according to Faraid, as determined at the time of my death.</p>
<h2>6. Witness Attestation</h2>
<p>This Will was signed by the testator in the presence of the two witnesses below, present at the same time, who then signed in the presence of the testator and each other. Neither witness should be a beneficiary under this Will.</p>
<div class="witness-block">
<div><div class="sig-line">Witness 1: ${esc(w1)}<br>Signature &amp; date</div></div>
<div><div class="sig-line">Witness 2: ${esc(w2)}<br>Signature &amp; date</div></div>
</div>
<div class="signature-block"><div class="sig-line">Testator signature &amp; date</div></div>
<div class="footer-note">
Generated by Nur Falah on ${esc(generatedDate)} from data entered in the Wassiyah tab. This is a DRAFT ONLY
it has not been reviewed by a lawyer, has not been signed, and has no legal effect until properly executed
under the laws of the stated jurisdiction. Faraid shares shown elsewhere in this app are informational and
not a substitute for a qualified estate-planning consultation.
</div>
</body></html>`;
}
+5 -4
View File
@@ -3,6 +3,7 @@
// checks the family switcher shows exactly one family, and inspects
// Coverage/Assets/Tree for expected content.
const { chromium } = require('playwright');
const { gotoTab } = require('./e2e-auth-helper.cjs');
const BASE = 'https://moslem04.falahos.my/';
const results = [];
function record(name, pass, detail = '') { results.push({ name, pass, detail }); console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`); }
@@ -38,12 +39,12 @@ async function main() {
await page.waitForTimeout(1000);
}
}
const onMainApp = await page.locator('nav button[aria-label="Coverage"]').isVisible({ timeout: 8000 }).catch(() => false);
const onMainApp = await page.locator('.hub-tab[aria-label="Home"]').isVisible({ timeout: 8000 }).catch(() => false);
record(`${fam.name}: owner reaches the main app`, onMainApp);
if (!onMainApp) { await page.close(); continue; }
// Assets
await page.locator('nav button[aria-label="Assets"]').click();
await gotoTab(page, 'Assets');
await page.waitForTimeout(800);
const assetCount = await page.locator('.asset-row').count();
record(`${fam.name}: has ${fam.expectedAssets} assets`, assetCount === fam.expectedAssets, `found ${assetCount}`);
@@ -51,13 +52,13 @@ async function main() {
record(`${fam.name}: estate total is non-zero`, /[1-9]/.test(totalText), totalText.trim());
// Coverage
await page.locator('nav button[aria-label="Coverage"]').click();
await gotoTab(page, 'Coverage');
await page.waitForTimeout(800);
const pctText = await page.locator('.big-percent').textContent().catch(() => '');
record(`${fam.name}: Coverage Dashboard shows a percentage`, /%/.test(pctText), pctText.trim());
// Family Tree
await page.locator('nav button[aria-label="Tree"]').click();
await gotoTab(page, 'Tree');
await page.waitForTimeout(800);
const peopleCount = await page.locator('.person-card').count();
record(`${fam.name}: has ${fam.expectedPeople} people in the tree`, peopleCount === fam.expectedPeople, `found ${peopleCount}`);