feat: add Support tab — donations + co-branding/white-label contact

New tab under the Giving hub, alongside Zakat/Sadaqah/Khairat. Two parts:

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

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

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

Covered by e2e-support.cjs (14/14) — including opening all four
checkout links live and confirming each is a genuinely distinct,
resolving Stripe checkout session, not a dead or placeholder link.
Full regression: all suites pass (one confirmed transient flake on
rerun, unrelated to this change).
This commit is contained in:
2026-08-14 16:24:42 +08:00
parent 9cb0793347
commit 22ad077dab
4 changed files with 270 additions and 2 deletions
+1 -1
View File
@@ -31,7 +31,7 @@ const HUB_OF_TAB = {
'Coverage': 'Home', 'Coverage': 'Home',
'Assets': 'Estate', 'Faraid': 'Estate', 'Insurance': 'Estate', 'Wassiyah': 'Estate', 'Assets': 'Estate', 'Faraid': 'Estate', 'Insurance': 'Estate', 'Wassiyah': 'Estate',
'Hibah': 'Estate', 'Family Waqf': 'Estate', 'Nominate': 'Estate', 'Claims (H2)': 'Estate', 'Hibah': 'Estate', 'Family Waqf': 'Estate', 'Nominate': 'Estate', 'Claims (H2)': 'Estate',
'Zakat': 'Giving', 'Sadaqah': 'Giving', 'Khairat': 'Giving', 'Zakat': 'Giving', 'Sadaqah': 'Giving', 'Khairat': 'Giving', 'Support': 'Giving',
'Tree': 'Family', 'Trigger': 'Family', 'Mutawalli': 'Family', 'Manage': 'Family', 'Neighbourhood': 'Family', 'Tree': 'Family', 'Trigger': 'Family', 'Mutawalli': 'Family', 'Manage': 'Family', 'Neighbourhood': 'Family',
'Prayer Times': 'Daily', 'Qibla': 'Daily', 'Quran': 'Daily', 'Locate': 'Daily' 'Prayer Times': 'Daily', 'Qibla': 'Daily', 'Quran': 'Daily', 'Locate': 'Daily'
}; };
+86
View File
@@ -0,0 +1,86 @@
// Verifies the Support tab: amount/frequency selection, and that "Support
// Now" actually opens a real, live Polar.sh checkout page (not a dead or
// placeholder link) — one check per frequency, since each maps to a
// different Polar product.
const { chromium } = require('playwright');
const { signInFreshFamily, gotoTab } = require('./e2e-auth-helper.cjs');
const BASE = 'https://moslem04.falahos.my/';
const results = [];
const consoleErrors = [];
function record(name, pass, detail = '') { results.push({ name, pass, detail }); console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`); }
async function main() {
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
page.on('console', m => { if (m.type() === 'error') consoleErrors.push(m.text()); });
page.on('pageerror', e => consoleErrors.push(e.message));
await signInFreshFamily(page, BASE, 'e2e-support');
await gotoTab(page, 'Support');
await page.waitForTimeout(600);
record('Support: intro copy renders', await page.locator('h3', { hasText: 'Support Nur Falah' }).isVisible().catch(() => false));
// Amount presets
await page.locator('.pill', { hasText: '$25' }).click();
await page.waitForTimeout(200);
const presetActive = await page.locator('.pill.active', { hasText: '$25' }).isVisible().catch(() => false);
record('Support: selecting a preset amount highlights it', presetActive);
// Custom amount overrides preset
await page.locator('.custom-input').fill('17');
await page.waitForTimeout(200);
const customActive = await page.locator('.pill-custom.active').isVisible().catch(() => false);
record('Support: entering a custom amount switches selection to custom', customActive);
const btnShowsCustom = await page.locator('.donate-btn', { hasText: '$17' }).isVisible().catch(() => false);
record('Support: button reflects the custom amount', btnShowsCustom);
await page.locator('.custom-input').fill('');
// Each frequency should open a distinct, real, live Polar checkout page
const frequencies = [
{ label: 'One-Time', btnText: 'Support Now' },
{ label: 'Monthly 🌙', btnText: 'Subscribe Monthly' },
{ label: 'Quarterly', btnText: 'Subscribe Quarterly' },
{ label: 'Yearly', btnText: 'Subscribe Yearly' }
];
const openedUrls = new Set();
for (const freq of frequencies) {
await page.locator('.frequency-toggle button', { hasText: freq.label }).click();
await page.waitForTimeout(200);
await page.locator('.pill', { hasText: '$10' }).click();
await page.waitForTimeout(200);
const [popup] = await Promise.all([
page.waitForEvent('popup'),
page.locator('.donate-btn').click()
]);
await popup.waitForLoadState('domcontentloaded').catch(() => {});
await page.waitForTimeout(1500);
const finalUrl = popup.url();
openedUrls.add(finalUrl);
const reachedPolar = /polar\.sh|stripe\.com/i.test(finalUrl);
record(`Support (${freq.label}): opens a real, live checkout page`, reachedPolar, finalUrl);
await popup.close();
}
record('Support: each frequency opens a genuinely distinct checkout session', openedUrls.size === frequencies.length, `${openedUrls.size} distinct URLs`);
// Partnership section
await page.locator('a', { hasText: 'View Partnership Opportunities' }).click();
await page.waitForTimeout(500);
record('Support: partnership section is reachable', await page.locator('h3', { hasText: 'Co-Branding' }).isVisible().catch(() => false));
const emailLinkVisible = await page.locator('a[href^="mailto:info@falahos.my"]').isVisible().catch(() => false);
record('Support: shows the correct VP sales email contact', emailLinkVisible);
const whatsappLinkVisible = await page.locator('a[href="https://wa.me/60132250691"]').isVisible().catch(() => false);
record('Support: shows the correct WhatsApp contact', whatsappLinkVisible);
record('Support: mentions white-labeling', (await page.locator('.partner-tiers').innerText()).toLowerCase().includes('white-label'));
record('No uncaught JS console errors during full session', consoleErrors.length === 0, consoleErrors.slice(0, 5).join(' || '));
await browser.close();
const passCount = results.filter(r => r.pass).length;
const failCount = results.length - passCount;
console.log(`\n${passCount} passed, ${failCount} failed, ${results.length} total`);
if (failCount > 0) results.filter(r => !r.pass).forEach(r => console.log(` - ${r.name}: ${r.detail}`));
process.exit(failCount > 0 ? 1 : 0);
}
main().catch(e => { console.error('SCRIPT ERROR:', e); process.exit(2); });
+3 -1
View File
@@ -29,6 +29,7 @@
import Locators from './lib/Locators.svelte'; import Locators from './lib/Locators.svelte';
import QuranReader from './lib/QuranReader.svelte'; import QuranReader from './lib/QuranReader.svelte';
import NeighbourhoodBoard from './lib/NeighbourhoodBoard.svelte'; import NeighbourhoodBoard from './lib/NeighbourhoodBoard.svelte';
import SupportTab from './lib/SupportTab.svelte';
let currentLang = $state('en'); let currentLang = $state('en');
lang.subscribe(v => currentLang = v); lang.subscribe(v => currentLang = v);
@@ -75,7 +76,8 @@
{ key: 'giving', label: 'Giving', icon: '🤲', tabs: [ { key: 'giving', label: 'Giving', icon: '🤲', tabs: [
{ label: 'Zakat', icon: '🌙', component: ZakatCalculator }, { label: 'Zakat', icon: '🌙', component: ZakatCalculator },
{ label: 'Sadaqah', icon: '🤲', component: SadaqahTracker }, { label: 'Sadaqah', icon: '🤲', component: SadaqahTracker },
{ label: 'Khairat', icon: '🆘', component: KhairatTracker } { label: 'Khairat', icon: '🆘', component: KhairatTracker },
{ label: 'Support', icon: '🌱', component: SupportTab }
] }, ] },
{ key: 'family', label: 'Family', icon: '👪', tabs: [ { key: 'family', label: 'Family', icon: '👪', tabs: [
{ label: 'Tree', icon: '🌳', component: FamilyTree }, { label: 'Tree', icon: '🌳', component: FamilyTree },
+180
View File
@@ -0,0 +1,180 @@
<script>
// Support Nur Falah — donations to keep the app itself running (distinct
// from the personal Sadaqah tracker, which logs giving elsewhere), plus
// co-branding/white-label partnership contact. Checkout is handled
// entirely by Polar.sh (merchant of record) via hosted checkout links —
// this app never touches payment details itself, same principle as the
// rest of the app's "log intent, don't move money" design.
import Disclaimer from './Disclaimer.svelte';
import InfoPanel from './InfoPanel.svelte';
const CHECKOUT_LINKS = {
onetime: 'https://buy.polar.sh/polar_cl_QHgvTOWQTCZsL6XIx3PDM6qf3pFbBHCJasN3d3bOoOI',
monthly: 'https://buy.polar.sh/polar_cl_VXqJqNXPYgMThCzD0MVxtuZyrwCjiFZ5EuNZc269ji3',
quarterly: 'https://buy.polar.sh/polar_cl_0cY6cJs2FYtw6wzqI8mTglG6zDGsf7qE50Wgr31bdl1',
yearly: 'https://buy.polar.sh/polar_cl_s6zsaewtnkDqaLtgufNuWlRX4cGbWxyHP6Fjm10SPcB'
};
const FREQUENCIES = [
{ key: 'onetime', label: 'One-Time' },
{ key: 'monthly', label: 'Monthly 🌙' },
{ key: 'quarterly', label: 'Quarterly' },
{ key: 'yearly', label: 'Yearly' }
];
const PRESETS = [5, 10, 25, 50];
let frequency = $state('onetime');
let amount = $state(10);
let customAmount = $state('');
const effectiveAmount = $derived.by(() => {
const c = parseFloat(customAmount);
return customAmount && !isNaN(c) && c > 0 ? c : amount;
});
function selectPreset(v) {
amount = v;
customAmount = '';
}
function supportNow() {
const cents = Math.round(effectiveAmount * 100);
const url = `${CHECKOUT_LINKS[frequency]}?amount=${cents}`;
window.open(url, '_blank', 'noopener');
}
const WHATSAPP_URL = 'https://wa.me/60132250691';
const PARTNER_EMAIL = 'info@falahos.my';
</script>
<div class="module">
<div class="module-header">
<h2>Support</h2>
<InfoPanel
title="Support Nur Falah"
what="Nur Falah is free forever — sadaqah jariyah for everyone who uses it. This tab is how you can support the app itself (servers, development, accuracy), separate from the personal Sadaqah tracker elsewhere in the app, which logs your own giving to causes you choose."
how="Pick an amount and how often, then tap Support Now — you'll be taken to our payment partner (Polar.sh) to complete it securely. Nur Falah never sees or stores your payment details."
fields={[]}
/>
</div>
<div class="support-intro">
<div class="support-icon">🌱</div>
<h3>Support Nur Falah</h3>
<p>This app is free forever — sadaqah jariyah for all. Your support keeps the servers running, the features growing, and the calculations accurate. Every contribution goes directly to app development.</p>
</div>
<div class="door-card">
<div class="door-accent-bar"></div>
<div class="door-content">
<div class="amount-pills">
{#each PRESETS as p}
<button class="pill" class:active={!customAmount && amount === p} onclick={() => selectPreset(p)}>${p}</button>
{/each}
<button class="pill pill-custom" class:active={!!customAmount}>Custom</button>
</div>
<input type="number" min="1" step="1" placeholder="Enter amount (USD)" class="custom-input" bind:value={customAmount} />
<div class="frequency-toggle">
{#each FREQUENCIES as f}
<button class:active={frequency === f.key} onclick={() => frequency = f.key}>{f.label}</button>
{/each}
</div>
<div class="door-actions">
<button class="primary donate-btn" onclick={supportNow}>
{frequency === 'onetime' ? `Support Now — $${effectiveAmount}` : `Subscribe ${FREQUENCIES.find(f => f.key === frequency).label} $${effectiveAmount}`}
</button>
</div>
</div>
</div>
<div class="partner-link">
<p>🏛️ Represent a bank, eWallet, or institution? <a href="#partners" onclick={(e) => { e.preventDefault(); document.getElementById('partners-section')?.scrollIntoView({ behavior: 'smooth' }); }}>View Partnership Opportunities →</a></p>
</div>
<div id="partners-section" class="partners-section">
<div class="hero-badge">🤝 PARTNERSHIP</div>
<h3>Co-Branding &amp; White-Label</h3>
<p class="partners-intro">Nur Falah is built to be a trusted estate-planning and Muslim-lifestyle companion. We're open to co-branded partnerships with banks, Takaful/insurance operators, and private institutions — and to white-labeling the entire app under your own brand.</p>
<div class="partner-tiers">
<div class="tier-card">
<span class="tier-icon">🏦</span>
<div>
<strong>Co-Branding</strong>
<p>A branded version of Nur Falah for your customers — your logo, your domain, our estate-planning and lifestyle engine underneath.</p>
</div>
</div>
<div class="tier-card">
<span class="tier-icon">🏷️</span>
<div>
<strong>White-Label</strong>
<p>License the full app under your own brand end-to-end — reach out for terms and technical details.</p>
</div>
</div>
</div>
<div class="contact-card">
<p class="contact-label">Speak to our VP of Sales</p>
<a class="contact-row" href="mailto:{PARTNER_EMAIL}?subject=Nur Falah Partnership Inquiry">
<span class="contact-icon">✉️</span>
<span>{PARTNER_EMAIL}</span>
</a>
<a class="contact-row" href={WHATSAPP_URL} target="_blank" rel="noopener">
<span class="contact-icon">💬</span>
<span>WhatsApp +60 13-225 0691</span>
</a>
</div>
</div>
<p class="footer-tagline">Nur Falah — Free forever, sadaqah jariyah for all.</p>
<Disclaimer text="Checkout is handled entirely by Polar.sh, our payment partner and merchant of record — Nur Falah never processes or stores your card or payment details." />
</div>
<style>
.module { padding: 4px 0 40px; }
.module-header { display: flex; align-items: center; margin-bottom: 4px; }
h2 { font-family: 'DM Serif Display', serif; font-size: 24px; color: #E8E4DC; margin-bottom: 0; }
h3 { font-family: 'DM Serif Display', serif; font-size: 19px; color: #E8E4DC; margin-bottom: 8px; }
.support-intro { text-align: center; padding: 8px 0 20px; }
.support-icon { font-size: 36px; margin-bottom: 8px; }
.support-intro p { font-size: 13px; color: #B8B2A6; line-height: 1.6; max-width: 320px; margin: 0 auto; }
.door-card { position: relative; background: rgba(255,255,255,0.03); border-radius: 16px; overflow: hidden; margin-bottom: 16px; border: 1px solid rgba(201,168,76,0.15); }
.door-accent-bar { height: 4px; background: linear-gradient(90deg, #C9A84C, #2ECC71); }
.door-content { padding: 18px; }
.amount-pills { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 10px; }
.pill { padding: 10px 16px; border-radius: 999px; border: 1px solid rgba(201,168,76,0.25); background: rgba(255,255,255,0.04); color: #B8B2A6; font-size: 14px; font-weight: 600; cursor: pointer; }
.pill.active { background: rgba(201,168,76,0.18); color: #C9A84C; border-color: rgba(201,168,76,0.5); }
.custom-input { width: 100%; background: rgba(255,255,255,0.05); border: 1px solid rgba(201,168,76,0.2); border-radius: 8px; padding: 10px 12px; color: #E8E4DC; font-size: 14px; margin-bottom: 16px; }
.frequency-toggle { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 18px; }
.frequency-toggle button { flex: 1; min-width: 80px; padding: 9px 8px; border-radius: 8px; border: 1px solid rgba(201,168,76,0.2); background: rgba(255,255,255,0.03); color: #B8B2A6; font-size: 12.5px; cursor: pointer; }
.frequency-toggle button.active { background: rgba(201,168,76,0.15); color: #C9A84C; border-color: rgba(201,168,76,0.4); font-weight: 600; }
.door-actions .donate-btn { width: 100%; padding: 14px; border-radius: 10px; border: none; font-weight: 700; font-size: 14px; cursor: pointer; background: linear-gradient(90deg, #C9A84C, #dfc06b); color: #070A0D; }
.partner-link { text-align: center; margin-bottom: 24px; }
.partner-link p { font-size: 12.5px; color: #8A8478; }
.partner-link a { color: #C9A84C; font-weight: 600; text-decoration: none; }
.partners-section { border-top: 1px solid rgba(201,168,76,0.15); padding-top: 20px; margin-bottom: 20px; }
.hero-badge { display: inline-block; font-size: 10px; letter-spacing: 0.6px; background: rgba(46,204,113,0.15); color: #2ECC71; padding: 4px 10px; border-radius: 999px; margin-bottom: 10px; font-weight: 700; }
.partners-intro { font-size: 12.5px; color: #B8B2A6; line-height: 1.6; margin-bottom: 16px; }
.partner-tiers { display: flex; flex-direction: column; gap: 10px; margin-bottom: 18px; }
.tier-card { display: flex; gap: 12px; background: rgba(255,255,255,0.03); border-radius: 12px; padding: 14px; }
.tier-icon { font-size: 22px; flex-shrink: 0; }
.tier-card strong { display: block; font-size: 13.5px; color: #E8E4DC; margin-bottom: 4px; }
.tier-card p { font-size: 12px; color: #8A8478; line-height: 1.5; }
.contact-card { background: rgba(201,168,76,0.06); border: 1px solid rgba(201,168,76,0.2); border-radius: 12px; padding: 16px; }
.contact-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.4px; color: #8A8478; margin-bottom: 10px; }
.contact-row { display: flex; align-items: center; gap: 10px; padding: 8px 0; color: #E8E4DC; text-decoration: none; font-size: 13.5px; }
.contact-icon { font-size: 16px; }
.footer-tagline { text-align: center; font-size: 11px; color: #8A8478; margin-bottom: 16px; }
</style>