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).
This commit is contained in:
2026-08-14 14:25:26 +08:00
parent 7ce1121b94
commit 2549e9de0c
15 changed files with 1339 additions and 24 deletions
+88
View File
@@ -0,0 +1,88 @@
// 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 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 ownerPage.locator('nav button[aria-label="Assets"]').click();
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 ownerPage.locator('nav button[aria-label="Family"]').click();
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 agentPage.locator('nav button[aria-label="Mutawalli"]').click();
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); });
+148
View File
@@ -0,0 +1,148 @@
// 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 } = 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 page.locator('nav button[aria-label="Khairat"]').click();
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 page.locator('nav button[aria-label="Assets"]').click();
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 page.locator('nav button[aria-label="Qibla"]').click();
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);
// ── Prayer Times ──
await page.locator('nav button[aria-label="Prayer Times"]').click();
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 search via live OpenStreetMap Overpass) ──
await page.locator('nav button[aria-label="Locate"]').click();
await page.waitForTimeout(500);
await page.locator('button.btn-primary', { hasText: 'Search nearby' }).click();
await page.waitForTimeout(8000); // live Overpass API call
const locateResultOrEmpty = await page.locator('.results-list, .empty').isVisible().catch(() => false);
record('Locate: mosque search completes (real results or honest empty state)', locateResultOrEmpty);
// ── Quran ──
await page.locator('nav button[aria-label="Quran"]').click();
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 page.locator('nav button[aria-label="Neighbourhood"]').click();
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 secondPage.locator('nav button[aria-label="Neighbourhood"]').click();
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();
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
@@ -127,8 +127,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);
}
}
+24 -12
View File
@@ -23,6 +23,12 @@
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';
let currentLang = $state('en');
lang.subscribe(v => currentLang = v);
@@ -46,8 +52,8 @@
}
});
const tabs = ['Coverage', 'Faraid', 'Assets', 'Insurance', 'Zakat', 'Sadaqah', 'Wassiyah', 'Hibah', 'Family Waqf', 'Nominate', 'Trigger', 'Mutawalli', 'Tree', 'Claims (H2)', 'Family', 'Settings'];
const icons = ['🎯', '📊', '📁', '🛡️', '🌙', '🤲', '📜', '🎁', '⛲', '📇', '⚡', '🕋', '🌳', '🔗', '👥', '⚙️'];
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)', 'Family', 'Settings'];
const icons = ['🎯', '📊', '📁', '🛡️', '🌙', '🤲', '🆘', '📜', '🎁', '⛲', '📇', '⚡', '🕋', '🌳', '🕌', '🧭', '📍', '📖', '📢', '🔗', '👥', '⚙️'];
let activeTab = $state(0);
requestedTab.subscribe(name => {
if (!name) return;
@@ -113,16 +119,22 @@
{:else if activeTab === 3}<InsurancePolicies />
{:else if activeTab === 4}<ZakatCalculator />
{:else if activeTab === 5}<SadaqahTracker />
{:else if activeTab === 6}<WassiyahGenerator />
{:else if activeTab === 7}<HibahTracker />
{:else if activeTab === 8}<FamilyWaqfDesignator />
{:else if activeTab === 9}<NominationRegistry />
{:else if activeTab === 10}<DeathTrigger />
{:else if activeTab === 11}<MutawalliDashboard />
{:else if activeTab === 12}<FamilyTree />
{:else if activeTab === 13}<DigitalClaims />
{:else if activeTab === 14}<FamilyManagement />
{:else if activeTab === 15}
{:else if activeTab === 6}<KhairatTracker />
{:else if activeTab === 7}<WassiyahGenerator />
{:else if activeTab === 8}<HibahTracker />
{:else if activeTab === 9}<FamilyWaqfDesignator />
{:else if activeTab === 10}<NominationRegistry />
{:else if activeTab === 11}<DeathTrigger />
{:else if activeTab === 12}<MutawalliDashboard />
{:else if activeTab === 13}<FamilyTree />
{:else if activeTab === 14}<PrayerTimes />
{:else if activeTab === 15}<QiblaFinder />
{:else if activeTab === 16}<Locators />
{:else if activeTab === 17}<QuranReader />
{:else if activeTab === 18}<NeighbourhoodBoard />
{:else if activeTab === 19}<DigitalClaims />
{:else if activeTab === 20}<FamilyManagement />
{:else if activeTab === 21}
<div class="module">
<div class="module-header">
<h2>Settings</h2>
+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; }
+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>
+21 -2
View File
@@ -10,7 +10,7 @@
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';
@@ -33,6 +33,7 @@
let deathCertRef = $state('');
let error = $state('');
let notifyStatus = $state('');
let emergencyNotifyStatus = $state('');
async function refresh() {
if (!familyId) return;
@@ -107,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;
}
@@ -122,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 || '';
}
@@ -186,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">
+187
View File
@@ -0,0 +1,187 @@
<script>
import { onMount } from 'svelte';
import { session } from './auth.js';
import {
createNeighbourhood, joinNeighbourhoodByCode, listMyNeighbourhoods, leaveNeighbourhood,
listNeighbourhoodPosts, addNeighbourhoodPost, removeNeighbourhoodPost
} 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: '' });
let error = $state('');
let showAddForm = $state(false);
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);
}
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;
await addNeighbourhoodPost(activeId, userId, userEmail, postForm.title.trim(), postForm.body);
postForm = { title: '', body: '' };
await refreshPosts();
}
async function doRemovePost(id) {
await removeNeighbourhoodPost(id);
await refreshPosts();
}
const activeNeighbourhood = $derived(neighbourhoods.find(n => n.id === activeId));
</script>
<div class="module">
<div class="module-header">
<h2>Neighbourhood</h2>
<InfoPanel
title="Neighbourhood"
what="A simple announcement board 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. Once joined, post and read announcements with everyone else in that neighbourhood."
fields={[]}
/>
</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="e.g. Friday khutbah reminder" /></label>
<label class="field"><span>Details</span><input type="text" bind:value={postForm.body} /></label>
<button class="btn-primary" onclick={doPost}>Post announcement</button>
</div>
<div class="post-list">
{#each posts as p (p.id)}
<div class="post-row">
<div class="post-header">
<strong>{p.title}</strong>
{#if p.author_id === userId}<button class="post-remove" onclick={() => doRemovePost(p.id)}>✕</button>{/if}
</div>
{#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>
</div>
{:else}
<p class="empty">No announcements 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; }
</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>
+133
View File
@@ -0,0 +1,133 @@
<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)));
}
function handleOrientation(e) {
const heading = e.webkitCompassHeading ?? (e.absolute && e.alpha != null ? 360 - e.alpha : null);
if (heading != null) { compassHeading = heading; compassSupported = true; }
}
async function findQibla() {
status = 'locating';
errorMsg = '';
if (!navigator.geolocation) {
status = 'error'; errorMsg = 'Geolocation is not available on this device/browser.';
return;
}
navigator.geolocation.getCurrentPosition(
async (pos) => {
computeQibla(pos.coords.latitude, pos.coords.longitude);
status = 'ready';
// Compass heading requires an explicit permission prompt on iOS 13+,
// which must be triggered by this same user gesture.
if (typeof DeviceOrientationEvent !== 'undefined' && typeof DeviceOrientationEvent.requestPermission === 'function') {
try {
const perm = await DeviceOrientationEvent.requestPermission();
if (perm === 'granted') window.addEventListener('deviceorientation', handleOrientation, true);
} catch { /* permission denied or unsupported — static bearing still shown */ }
} else if (typeof DeviceOrientationEvent !== 'undefined') {
window.addEventListener('deviceorientationabsolute', handleOrientation, true);
window.addEventListener('deviceorientation', handleOrientation, true);
}
},
(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>
+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}`;
}
+87 -2
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,91 @@ 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) {
const { error } = await supabase.from('nf_neighbourhood_posts').insert({ neighbourhood_id: neighbourhoodId, author_id: authorId, author_name: authorName, title, body });
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;
}
// ── Hibah ──
export async function listHibahGifts(familyId) {
const { data, error } = await supabase.from('nf_hibah_gifts').select('*').eq('family_id', familyId).order('created_at');
+59
View File
@@ -0,0 +1,59 @@
// 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.
const OVERPASS_URL = 'https://overpass-api.de/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));
}
/** Searches OpenStreetMap for real nearby places in the given category. Returns [] on no results, throws on network/API failure. */
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 res = await fetch(OVERPASS_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'data=' + encodeURIComponent(query)
});
if (!res.ok) throw new Error(`Overpass API returned ${res.status}`);
const data = await res.json();
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}`;
}