diff --git a/e2e-auth-helper.cjs b/e2e-auth-helper.cjs index c925c29..cc22161 100644 --- a/e2e-auth-helper.cjs +++ b/e2e-auth-helper.cjs @@ -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', + '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 }; diff --git a/e2e-business-check.cjs b/e2e-business-check.cjs index 1857dbb..a02997e 100644 --- a/e2e-business-check.cjs +++ b/e2e-business-check.cjs @@ -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'); diff --git a/e2e-business.cjs b/e2e-business.cjs index eacdb6a..04a4b51 100644 --- a/e2e-business.cjs +++ b/e2e-business.cjs @@ -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; diff --git a/e2e-digital-vehicle.cjs b/e2e-digital-vehicle.cjs index 38be532..e4103cc 100644 --- a/e2e-digital-vehicle.cjs +++ b/e2e-digital-vehicle.cjs @@ -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; diff --git a/e2e-emergency-notify.cjs b/e2e-emergency-notify.cjs index dd7d87b..400ade5 100644 --- a/e2e-emergency-notify.cjs +++ b/e2e-emergency-notify.cjs @@ -3,6 +3,7 @@ // 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'; @@ -29,7 +30,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="Assets"]').click(); + 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'); @@ -38,7 +39,7 @@ async function main() { 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 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'); @@ -55,7 +56,7 @@ async function main() { 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 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); diff --git a/e2e-family-agent.cjs b/e2e-family-agent.cjs index 0acd573..908e1c3 100644 --- a/e2e-family-agent.cjs +++ b/e2e-family-agent.cjs @@ -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(); diff --git a/e2e-family-tree.cjs b/e2e-family-tree.cjs index 4340e9f..5bdb7d1 100644 --- a/e2e-family-tree.cjs +++ b/e2e-family-tree.cjs @@ -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); diff --git a/e2e-fastpath.cjs b/e2e-fastpath.cjs index 6471f16..8474ea6 100644 --- a/e2e-fastpath.cjs +++ b/e2e-fastpath.cjs @@ -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 diff --git a/e2e-gaps-round2.cjs b/e2e-gaps-round2.cjs index 00bcee7..c0f7e1f 100644 --- a/e2e-gaps-round2.cjs +++ b/e2e-gaps-round2.cjs @@ -3,7 +3,7 @@ // madhab selector (including honest Ja'fari gating), and the Wassiyah tab's // draft will document generator. 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 = []; @@ -28,14 +28,14 @@ async function main() { record('Coverage: flags missing estate agent/mutawalli', noAgentRec); // Add an asset to trigger the exposed-asset recommendation - await page.locator('nav button[aria-label="Assets"]').click(); + 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 page.locator('nav button[aria-label="Coverage"]').click(); + 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); @@ -43,7 +43,7 @@ async function main() { record('Coverage: flags missing Wassiyah once estate has value', wassiyahRec); // ── Faraid Calculator: madhab selector ── - await page.locator('nav button[aria-label="Faraid"]').click(); + 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); @@ -72,7 +72,7 @@ async function main() { // ── Wassiyah: draft will document generator ── await page.locator('.field:has-text("Madhab") select').selectOption('shafii'); // reset, avoid cross-test pollution - await page.locator('nav button[aria-label="Wassiyah"]').click(); + 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'); diff --git a/e2e-info.cjs b/e2e-info.cjs index 99694ef..50dc3ab 100644 --- a/e2e-info.cjs +++ b/e2e-info.cjs @@ -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'); diff --git a/e2e-insurance-verification.cjs b/e2e-insurance-verification.cjs index c23f31d..e34083b 100644 --- a/e2e-insurance-verification.cjs +++ b/e2e-insurance-verification.cjs @@ -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); diff --git a/e2e-jurisdiction-review.cjs b/e2e-jurisdiction-review.cjs index 6506e19..c96b1a4 100644 --- a/e2e-jurisdiction-review.cjs +++ b/e2e-jurisdiction-review.cjs @@ -2,7 +2,7 @@ // effects on Wassiyah/will document/Zakat currency, and the professional // review request workflow against the live backend. 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-jurisdiction'); // ── Settings: jurisdiction selector, owner can change ── - await page.locator('nav button[aria-label="Settings"]').click(); + 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); @@ -28,7 +28,7 @@ async function main() { record('Settings: changing jurisdiction shows a saved confirmation', savedBadge); // ── Zakat: currency defaults follow the family jurisdiction (SGD) ── - await page.locator('nav button[aria-label="Zakat"]').click(); + 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); @@ -37,7 +37,7 @@ async function main() { // 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 page.locator('nav button[aria-label="Assets"]').click(); + 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'); @@ -45,7 +45,7 @@ async function main() { await page.waitForTimeout(1000); // ── Wassiyah: jurisdiction dropdown includes Singapore, defaults from family setting ── - await page.locator('nav button[aria-label="Wassiyah"]').click(); + 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); @@ -86,7 +86,7 @@ async function main() { record('Wassiyah: marking reviewed records the reviewer name', reviewedVisible); // Review status surfaces on the Mutawalli dashboard (owner-only family — owner sees own row) - await page.locator('nav button[aria-label="Mutawalli"]').click(); + 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'); @@ -95,7 +95,7 @@ async function main() { // ── 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 isolationPage.locator('nav button[aria-label="Settings"]').click(); + 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); diff --git a/e2e-khairat-lifestyle.cjs b/e2e-khairat-lifestyle.cjs index 4055c85..92f5d74 100644 --- a/e2e-khairat-lifestyle.cjs +++ b/e2e-khairat-lifestyle.cjs @@ -3,7 +3,7 @@ // 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 { signInFreshFamily, gotoTab } = require('./e2e-auth-helper.cjs'); const BASE = 'https://moslem04.falahos.my/'; const results = []; const consoleErrors = []; @@ -23,7 +23,7 @@ async function main() { await signInFreshFamily(page, BASE, 'e2e-khairat'); // ── Khairat ── - await page.locator('nav button[aria-label="Khairat"]').click(); + 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'); @@ -39,7 +39,7 @@ async function main() { 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 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'); @@ -50,7 +50,7 @@ async function main() { record('Trusted Contacts: category badge shows on the new contact', contactCategoryVisible); // ── Qibla ── - await page.locator('nav button[aria-label="Qibla"]').click(); + await gotoTab(page, 'Qibla'); await page.waitForTimeout(500); await page.locator('button.btn-primary', { hasText: 'Find Qibla direction' }).click(); await page.waitForTimeout(2000); @@ -61,7 +61,7 @@ async function main() { record('Qibla: shows distance to Makkah', distanceVisible); // ── Prayer Times ── - await page.locator('nav button[aria-label="Prayer Times"]').click(); + 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); @@ -70,7 +70,7 @@ async function main() { 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 gotoTab(page, 'Locate'); await page.waitForTimeout(500); await page.locator('button.btn-primary', { hasText: 'Search nearby' }).click(); await page.waitForTimeout(8000); // live Overpass API call @@ -78,7 +78,7 @@ async function main() { record('Locate: mosque search completes (real results or honest empty state)', locateResultOrEmpty); // ── Quran ── - await page.locator('nav button[aria-label="Quran"]').click(); + 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); @@ -95,7 +95,7 @@ async function main() { // 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 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(); @@ -124,7 +124,7 @@ async function main() { 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 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(); diff --git a/e2e-other.cjs b/e2e-other.cjs index 2d8e51c..6cad97a 100644 --- a/e2e-other.cjs +++ b/e2e-other.cjs @@ -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; diff --git a/e2e-per-member.cjs b/e2e-per-member.cjs index e87c250..d319d23 100644 --- a/e2e-per-member.cjs +++ b/e2e-per-member.cjs @@ -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); diff --git a/e2e-property.cjs b/e2e-property.cjs index e253455..340faa9 100644 --- a/e2e-property.cjs +++ b/e2e-property.cjs @@ -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; diff --git a/e2e-sadaqah-tree.cjs b/e2e-sadaqah-tree.cjs index ed9f9da..1e74a4e 100644 --- a/e2e-sadaqah-tree.cjs +++ b/e2e-sadaqah-tree.cjs @@ -2,7 +2,7 @@ // 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 } = 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-sadaqah'); // ── Sadaqah tracker ── - await page.locator('nav button[aria-label="Sadaqah"]').click(); + await gotoTab(page, 'Sadaqah'); await page.waitForTimeout(600); const zeroStreak = await page.locator('.streak-number').textContent(); @@ -39,7 +39,7 @@ async function main() { record('Sadaqah: entry appears in own history with amount', historyRowVisible); // ── Family Tree: add a deceased person, verify memorial link + dedication ── - await page.locator('nav button[aria-label="Tree"]').click(); + 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'); @@ -53,7 +53,8 @@ async function main() { 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('nav button.tab.active[aria-label="Sadaqah"]').isVisible().catch(() => false); + 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 @@ -62,7 +63,7 @@ async function main() { await page.locator('button.btn-primary', { hasText: "Log today's sadaqah" }).click(); await page.waitForTimeout(1200); - await page.locator('nav button[aria-label="Tree"]').click(); + await gotoTab(page, 'Tree'); await page.waitForTimeout(600); await page.locator('.person-summary', { hasText: 'Grandfather Yusuf' }).click(); await page.waitForTimeout(600); @@ -75,7 +76,7 @@ async function main() { await page.locator('.form-card button.btn-primary', { hasText: 'Add person' }).click(); await page.waitForTimeout(1000); - await page.locator('nav button[aria-label="Coverage"]').click(); + 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); @@ -85,7 +86,7 @@ async function main() { // ── 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 isolationPage.locator('nav button[aria-label="Sadaqah"]').click(); + 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); diff --git a/e2e-smoke-authed.cjs b/e2e-smoke-authed.cjs index 1b1ccaf..04c5895 100644 --- a/e2e-smoke-authed.cjs +++ b/e2e-smoke-authed.cjs @@ -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); diff --git a/e2e-trust.cjs b/e2e-trust.cjs index 82bf6ef..07a9fea 100644 --- a/e2e-trust.cjs +++ b/e2e-trust.cjs @@ -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'); diff --git a/e2e-uat.cjs b/e2e-uat.cjs index d91b8b3..cf8b59e 100644 --- a/e2e-uat.cjs +++ b/e2e-uat.cjs @@ -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(); diff --git a/e2e-zakat.cjs b/e2e-zakat.cjs index 9809274..8356638 100644 --- a/e2e-zakat.cjs +++ b/e2e-zakat.cjs @@ -2,7 +2,7 @@ // 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 } = 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-zakat'); - await page.locator('nav button[aria-label="Zakat"]').click(); + await gotoTab(page, 'Zakat'); await page.waitForTimeout(600); // Below nisab: default nisab is 24000, wealth 10000 -> no zakat due @@ -41,9 +41,9 @@ async function main() { record('Zakat: deductible liabilities reduce zakatable wealth', wealthAfterDebt.trim() === '35,000', wealthAfterDebt); // Persistence: reload the tab (remount) and confirm autosave held - await page.locator('nav button[aria-label="Coverage"]').click(); + await gotoTab(page, 'Coverage'); await page.waitForTimeout(400); - await page.locator('nav button[aria-label="Zakat"]').click(); + 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); @@ -51,7 +51,7 @@ async function main() { // 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 isolationPage.locator('nav button[aria-label="Zakat"]').click(); + 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); diff --git a/src/App.svelte b/src/App.svelte index e1f335f..3481331 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -52,19 +52,83 @@ } }); - 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); + // 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 } + ] }, + { 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; - const idx = tabs.indexOf(name); - if (idx >= 0) activeTab = idx; + 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() { @@ -95,6 +159,7 @@ {:else}
+
Nur
Falah
@@ -103,38 +168,19 @@
- + {#if !showSettings && activeHub.tabs.length > 1} + + {/if}
- {#if activeTab === 0} - {:else if activeTab === 1} - {:else if activeTab === 2} - {:else if activeTab === 3} - {:else if activeTab === 4} - {:else if activeTab === 5} - {:else if activeTab === 6} - {:else if activeTab === 7} - {:else if activeTab === 8} - {:else if activeTab === 9} - {:else if activeTab === 10} - {:else if activeTab === 11} - {:else if activeTab === 12} - {:else if activeTab === 13} - {:else if activeTab === 14} - {:else if activeTab === 15} - {:else if activeTab === 16} - {:else if activeTab === 17} - {:else if activeTab === 18} - {:else if activeTab === 19} - {:else if activeTab === 20} - {:else if activeTab === 21} + {#if showSettings}

Settings

@@ -161,8 +207,19 @@
+ {:else} + {/if}
+ +
{/if} @@ -187,9 +244,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; } @@ -197,7 +256,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; } @@ -205,6 +266,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; } diff --git a/verify-demo-families.cjs b/verify-demo-families.cjs index d0b9215..308ba00 100644 --- a/verify-demo-families.cjs +++ b/verify-demo-families.cjs @@ -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}`);