feat: add Bir Nur (Well of Light) waqf dashboard + reframe Support with bank trustee giving model

- New BirNur.svelte: waqf configuration based on companions' model
- Reframed Support.svelte with dual-track: bank trustee giving + app support
- Updated App.svelte to add Waqf tab (tab 14) between WorshipTracker and Support
- Model inspired by Uthman's Well of Rumah and Abdur Rahman bin Auf's productive endowment
This commit is contained in:
2026-08-08 16:54:51 +08:00
parent b4e9e85f89
commit feb5f1f03b
28 changed files with 6360 additions and 405 deletions
+136
View File
@@ -0,0 +1,136 @@
# Nūr — Muslim Companion · Deployment Guide
**Target:** `moslem02.falahos.my`
**CDN:** Cloudflare (proxied / orange-cloud)
**Server:** Linux with Nginx
**Stack:** Svelte 5 + Vite PWA (static SPA)
---
## 1. Build
```bash
# Install dependencies (one-time)
npm ci
# Build for production
npm run build
```
Output goes to `dist/`. This includes:
- `index.html` — SPA entry (must not be cached long)
- `assets/index-<hash>.js` / `.css` — hashed, cacheable forever
- `sw.js` — Workbox service worker (must not be cached)
- `manifest.webmanifest` — PWA manifest
- Icons (`icon-192.png`, `icon-512.png`)
- Optional: `workbox-<hash>.js`
---
## 2. Upload to Server
Choose one method.
### SCP (manual)
```bash
scp -r dist/* user@moslem02.falahos.my:/var/www/nur-muslim-companion/
```
### Rsync (incremental, recommended)
```bash
rsync -avz --delete dist/ user@moslem02.falahos.my:/var/www/nur-muslim-companion/
```
---
## 3. Nginx
### Copy the config
```bash
scp deploy/nginx.conf user@moslem02.falahos.my:/etc/nginx/sites-available/nur-muslim-companion
```
### Enable and test
```bash
sudo ln -sf /etc/nginx/sites-available/nur-muslim-companion /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
```
### Verify
```bash
curl -sI https://moslem02.falahos.my/ | grep -i "cache-control"
# Should show: cache-control: no-cache, must-revalidate
```
---
## 4. Cloudflare Configuration
These settings are in your Cloudflare dashboard for `moslem02.falahos.my`:
| Setting | Value | Reason |
|---------|-------|--------|
| **Proxy status** | Proxied (orange cloud) | CDN caching, DDoS protection, SSL |
| **SSL/TLS** | Full (strict) | End-to-end encryption; requires a valid origin cert |
| **Always Use HTTPS** | On | Redirect HTTP → HTTPS |
| **Auto Minify** | Off | Service worker integrity; Vite already minifies |
| **Brotli** | On (default) | Better compression than gzip |
| **Cache Level** | Standard | Respects origin `Cache-Control` |
| **Edge Cache TTL** | Respect Existing Headers | Our nginx config sets correct policies |
| **Security Level** | Medium | Default; raise if under attack |
### Origin Certificate
Since SSL/TLS is set to **Full (strict)**, the origin server (your VPS) needs a valid certificate. Generate one in Cloudflare Dashboard → SSL/TLS → Origin Server → Create Certificate. Install it on the VPS and point nginx to it.
**If you use Cloudflare's edge certificates only (Flexible SSL), the nginx config can stay HTTP-only on port 80.** The `deploy/nginx.conf` in this repo listens on port 80 — this is safe because Cloudflare proxies all traffic; your VPS never speaks cleartext to the internet.
---
## 5. Service Worker & Cache Invalidation
The PWA uses `registerType: 'autoUpdate'`:
1. **Always re-deploy `sw.js` with `no-cache`**`deploy/nginx.conf` already does this.
2. When `sw.js` changes, Workbox detects the update, installs the new version, and the PWA updates automatically on next page load or tab switch.
3. Static assets (`/assets/*`) use content-hashed filenames — old cache entries are harmless and evicted naturally.
### Force-refresh after deploy
```bash
# Clear Cloudflare cache for the whole zone
curl -X POST "https://api.cloudflare.com/client/v4/zones/<ZONE_ID>/purge_cache" \
-H "Authorization: Bearer <API_TOKEN>" \
-H "Content-Type: application/json" \
--data '{"purge_everything":true}'
```
Or use Cloudflare Dashboard → Caching → Purge Everything.
---
## 6. Verification Checklist
- [ ] `curl -I https://moslem02.falahos.my/` returns 200
- [ ] `curl -I https://moslem02.falahos.my/assets/index-*.js` has `cache-control: public, immutable, max-age=31536000`
- [ ] `curl -I https://moslem02.falahos.my/sw.js` has `cache-control: no-cache, no-store, must-revalidate` and `service-worker-allowed: /`
- [ ] Open https://moslem02.falahos.my/ in Chrome → DevTools → Application → Service Workers shows "activated"
- [ ] App installs as PWA (install prompt or Add to Home Screen)
- [ ] Install prompt appears on mobile (Chrome Android / Safari iOS)
---
## 7. Rollback
```bash
# Deploy previous build
rsync -avz --delete path/to/previous-build/ user@moslem02.falahos.my:/var/www/nur-muslim-companion/
# Purge Cloudflare cache
```
+264
View File
@@ -0,0 +1,264 @@
{
"name": "Polar.sh → CRM Sync",
"nodes": [
{
"id": "webhook-trigger",
"name": "Polar Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [250, 300],
"webhookId": "polar-crm-sync",
"authentication": "none",
"properties": {
"httpMethod": "POST",
"path": "polar-webhook",
"responseMode": "onReceived",
"responseData": "",
"options": {}
},
"notes": "SETUP: 1) Deploy this workflow. 2) Copy the webhook URL. 3) Go to Polar.sh Dashboard → Settings → Webhooks → Add Endpoint. 4) Paste URL. 5) Select events: checkout.created, checkout.succeeded"
},
{
"id": "switch-crm",
"name": "Route to CRM",
"type": "n8n-nodes-base.switch",
"typeVersion": 2,
"position": [450, 300],
"properties": {
"dataType": "string",
"value1": "",
"value2": "",
"outputType": "route",
"rules": [
{
"value": "",
"outputKey": "Mautic"
},
{
"value": "",
"outputKey": "Twenty CRM"
}
]
},
"notes": "SET ME: Change routing based on which CRM you're using. Currently routes to Mautic (output 1). Switch to Twenty by changing routing logic."
},
{
"id": "code-parse",
"name": "Parse Polar Payload",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [650, 150],
"properties": {
"language": "javaScript",
"code": "const payload = $input.first().json;\nconst eventType = payload.type || '';\nconst checkout = payload.data || {};\n\nconst email = checkout.customer?.email || checkout.customerEmail || '';\nconst name = checkout.customer?.name || checkout.customerName || '';\nconst amount = checkout.amount || 0;\nconst currency = checkout.currency || 'usd';\nconst productName = checkout.product?.name || checkout.productName || '';\nconst metadata = checkout.product?.metadata || checkout.metadata || {};\nconst donationType = metadata.donationType || metadata.type || '';\nconst timestamp = checkout.createdAt || new Date().toISOString();\n\nlet donationLabel = '';\nif (donationType === 'sadaqah' || productName.toLowerCase().includes('sadaqah')) {\n donationLabel = 'Sadaqah Jariyah';\n} else if (donationType === 'zakat' || productName.toLowerCase().includes('zakat')) {\n donationLabel = 'Zakat (Fi Sabilillah)';\n} else if (donationType === 'hibah' || productName.toLowerCase().includes('hibah')) {\n donationLabel = 'Hibah (Gift)';\n} else {\n donationLabel = 'Donation';\n}\n\nconst amountDisplay = currency === 'myr'\n ? `RM${(amount / 100).toFixed(2)}`\n : `${(amount / 100).toFixed(2)} ${currency.toUpperCase()}`;\n\nreturn {\n email,\n name,\n amount,\n amountDisplay,\n currency,\n donationLabel,\n donationType,\n productName,\n timestamp\n};"
},
"notes": "Extracts fields from Polar.sh webhook payload."
},
{
"id": "mautic-upsert",
"name": "Mautic - Upsert Contact",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [850, 100],
"credentials": {
"httpRequest": {
"id": "SET_ME_MauticCredId",
"name": "Mautic API"
}
},
"properties": {
"method": "POST",
"url": "https://mautic.falahos.my/api/contacts/new",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendBody": true,
"bodyParameters": {
"parameters": [
{
"name": "email",
"value": "={{ $json.email }}"
},
{
"name": "firstname",
"value": "={{ $json.name.split(' ')[0] }}"
},
{
"name": "lastname",
"value": "={{ $json.name.split(' ').slice(1).join(' ') }}"
},
{
"name": "tags",
"value": "={{ 'polar-donor,' + $json.donationLabel.toLowerCase().replace(' ', '-') }}"
}
]
},
"options": {}
},
"notes": "Creates/updates contact in Mautic CRM. Mautic is running at mautic.falahos.my. API credentials: admin / FalahMautic2026!"
},
{
"id": "mautic-note",
"name": "Mautic - Add Donation Note",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [850, 300],
"credentials": {
"httpRequest": {
"id": "SET_ME_MauticCredId",
"name": "Mautic API"
}
},
"properties": {
"method": "POST",
"url": "https://mautic.falahos.my/api/notes/new",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendBody": true,
"bodyParameters": {
"parameters": [
{
"name": "text",
"value": "={{ '💚 ' + $json.donationLabel + ': ' + $json.amountDisplay + ' via Polar.sh' }}"
},
{
"name": "type",
"value": "donation"
}
]
},
"options": {}
},
"notes": "Adds a timeline note in Mautic recording the donation. The contact ID from the upsert response is used as the parent."
},
{
"id": "twenty-upsert",
"name": "Twenty CRM - Upsert Contact",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [850, 500],
"credentials": {
"httpRequest": {
"id": "SET_ME_TwentyCredId",
"name": "Twenty CRM API"
}
},
"properties": {
"method": "POST",
"url": "SET_ME_TwentyURL/rest/contacts",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendBody": true,
"bodyParameters": {
"parameters": [
{ "name": "email", "value": "={{ $json.email }}" },
{ "name": "name", "value": "={{ $json.name }}" },
{
"name": "position",
"value": "={{ $json.donationLabel + ' Donor' }}"
}
]
},
"options": {}
},
"notes": "SET ME: Configure Twenty CRM API URL and API key. Currently disabled. Activate when Twenty is set up."
},
{
"id": "twenty-note",
"name": "Twenty CRM - Log Donation Note",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [850, 700],
"credentials": {
"httpRequest": {
"id": "SET_ME_TwentyCredId",
"name": "Twenty CRM API"
}
},
"properties": {
"method": "POST",
"url": "SET_ME_TwentyURL/rest/activities",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendBody": true,
"bodyParameters": {
"parameters": [
{
"name": "title",
"value": "={{ $json.donationLabel }}"
},
{
"name": "body",
"value": "={{ 'Donation of ' + $json.amountDisplay + ' via Polar.sh' }}"
},
{
"name": "type",
"value": "Note"
}
]
},
"options": {}
},
"notes": "SET ME: Configure when Twenty CRM is set up. Logs donation as an activity note."
},
{
"id": "done",
"name": "Done",
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [1050, 300],
"properties": {}
}
],
"connections": {
"Polar Webhook": {
"main": [
[
{ "node": "Parse Polar Payload", "type": "main", "index": 0 }
]
]
},
"Parse Polar Payload": {
"main": [
[
{ "node": "Mautic - Upsert Contact", "type": "main", "index": 0 },
{ "node": "Mautic - Add Donation Note", "type": "main", "index": 0 },
{ "node": "Twenty CRM - Upsert Contact", "type": "main", "index": 0 },
{ "node": "Twenty CRM - Log Donation Note", "type": "main", "index": 0 }
]
]
},
"Mautic - Upsert Contact": {
"main": [
[
{ "node": "Done", "type": "main", "index": 0 }
]
]
},
"Mautic - Add Donation Note": {
"main": [
[
{ "node": "Done", "type": "main", "index": 0 }
]
]
},
"Twenty CRM - Upsert Contact": {
"main": [
[
{ "node": "Done", "type": "main", "index": 0 }
]
]
},
"Twenty CRM - Log Donation Note": {
"main": [
[
{ "node": "Done", "type": "main", "index": 0 }
]
]
}
},
"settings": {
"timezone": "Asia/Kuala_Lumpur"
},
"staticData": null,
"tags": ["polar", "crm", "donations", "nur-falah"]
}
+232
View File
@@ -0,0 +1,232 @@
{
"name": "Polar.sh → Twenty CRM Sync",
"nodes": [
{
"id": "webhook-trigger",
"name": "Polar Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [250, 300],
"webhookId": "polar-twenty-sync",
"authentication": "none",
"properties": {
"httpMethod": "POST",
"path": "polar-webhook",
"responseMode": "onReceived",
"responseData": "",
"options": {}
},
"notes": "SET ME: Configure this webhook URL in Polar.sh dashboard under Webhooks → Add endpoint. Polar sends checkout.created, checkout.updated, checkout.succeeded events."
},
{
"id": "code-parse",
"name": "Parse Polar Payload",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [450, 300],
"properties": {
"language": "javaScript",
"code": "// Parse Polar.sh webhook payload\n// Polar sends: type, data (containing checkout object)\n// Checkout object has: customer (email, name, billingAddress), product (name, metadata), amount, currency\n\nconst payload = $input.first().json;\nconst eventType = payload.type || '';\nconst checkout = payload.data || {};\n\n// Extract fields from Polar payload\nconst email = checkout.customer?.email || checkout.customerEmail || '';\nconst name = checkout.customer?.name || checkout.customerName || '';\nconst amount = checkout.amount || 0;\nconst currency = checkout.currency || 'usd';\nconst productName = checkout.product?.name || checkout.productName || '';\nconst metadata = checkout.product?.metadata || checkout.metadata || {};\nconst donationType = metadata.donationType || metadata.type || '';\nconst timestamp = checkout.createdAt || new Date().toISOString();\nconst polarCheckoutId = checkout.id || '';\n\n// Map donation type\nlet donationLabel = '';\nif (donationType === 'sadaqah' || productName.toLowerCase().includes('sadaqah')) {\n donationLabel = 'Sadaqah';\n} else if (donationType === 'zakat' || productName.toLowerCase().includes('zakat')) {\n donationLabel = 'Zakat';\n} else if (donationType === 'hibah' || productName.toLowerCase().includes('hibah')) {\n donationLabel = 'Hibah';\n} else {\n donationLabel = 'Donation';\n}\n\n// Format amount to display value (cents to main unit)\nconst amountDisplay = currency === 'myr' ?\n `RM${(amount / 100).toFixed(2)}` :\n `${(amount / 100).toFixed(2)} ${currency.toUpperCase()}`;\n\nreturn {\n email,\n name,\n amount,\n amountDisplay,\n currency,\n donationLabel,\n donationType,\n productName,\n polarCheckoutId,\n timestamp\n};"
},
"notes": "Extracts and normalises Polar.sh webhook fields. Metadata fields are set in Polar product configuration."
},
{
"id": "http-twenty",
"name": "Twenty CRM - Upsert Contact",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [650, 300],
"credentials": {
"httpRequest": {
"id": "SET_ME_TwentyCRMApiKeyId",
"name": "Twenty CRM API Key"
}
},
"properties": {
"method": "POST",
"url": "SET_ME_TwentyCRMAPIUrl/rest/contacts",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendBody": true,
"bodyParameters": {
"parameters": [
{
"name": "email",
"value": "={{ $json.email }}"
},
{
"name": "name",
"value": "={{ $json.name }}"
},
{
"name": "city",
"value": ""
},
{
"name": "phone",
"value": ""
},
{
"name": "position",
"value": "={{ $json.donationLabel }}"
},
{
"name": "linkedinLink",
"value": ""
},
{
"name": "xLink",
"value": ""
},
{
"name": "introduction",
"value": ""
}
]
},
"options": {
"timeout": 10000,
"allowUnauthorizedCerts": false,
"redirect": {},
"response": {
"response": {
"responseFormat": "json"
}
}
},
"headers": {
"parameters": [
{
"name": "Content-Type",
"value": "application/json"
}
]
}
},
"notes": "SET ME: Replace the URL placeholder with your Twenty CRM instance URL (e.g., https://your-workspace.twenty.com). Configure the header auth credential with your Twenty CRM API key."
},
{
"id": "set-activity",
"name": "Twenty CRM - Log Note Activity",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [650, 480],
"credentials": {
"httpRequest": {
"id": "SET_ME_TwentyCRMApiKeyId",
"name": "Twenty CRM API Key"
}
},
"properties": {
"method": "POST",
"url": "SET_ME_TwentyCRMAPIUrl/rest/activityTargets",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendBody": true,
"bodyParameters": {
"parameters": [
{
"name": "title",
"value": "={{ $json.donationLabel + ' Donation - ' + $json.amountDisplay }}"
},
{
"name": "body",
"value": "={{ 'Donation from ' + $json.name + ' (' + $json.email + ')\\n' + 'Type: ' + $json.donationLabel + '\\n' + 'Amount: ' + $json.amountDisplay + '\\n' + 'Checkout ID: ' + $json.polarCheckoutId + '\\n' + 'Date: ' + $json.timestamp }}"
},
{
"name": "type",
"value": "Note"
},
{
"name": "visibility",
"value": "workspace"
}
]
},
"options": {
"timeout": 10000,
"allowUnauthorizedCerts": false,
"redirect": {},
"response": {
"responseFormat": "json"
}
},
"headers": {
"parameters": [
{
"name": "Content-Type",
"value": "application/json"
}
]
}
},
"notes": "SET ME: Same credential and URL base as above. This node logs a note activity for the contact."
},
{
"id": "noop-complete",
"name": "Done",
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [850, 300],
"properties": {}
}
],
"connections": {
"Polar Webhook": {
"main": [
[
{
"node": "Parse Polar Payload",
"type": "main",
"index": 0
}
]
]
},
"Parse Polar Payload": {
"main": [
[
{
"node": "Twenty CRM - Upsert Contact",
"type": "main",
"index": 0
},
{
"node": "Twenty CRM - Log Note Activity",
"type": "main",
"index": 0
}
]
]
},
"Twenty CRM - Upsert Contact": {
"main": [
[
{
"node": "Done",
"type": "main",
"index": 0
}
]
]
},
"Twenty CRM - Log Note Activity": {
"main": [
[
{
"node": "Done",
"type": "main",
"index": 0
}
]
]
}
},
"pinData": {},
"settings": {
"executionOrder": "v1"
},
"staticData": null,
"tags": [],
"versionId": "1.0.0"
}
+127
View File
@@ -0,0 +1,127 @@
# ──────────────────────────────────────────────
# Nūr — Muslim Companion · Nginx SPA Config
# Target: moslem02.falahos.my (Cloudflare proxied)
# ──────────────────────────────────────────────
upstream backend_prayer {
# Placeholder for future API upstream (e.g. aladhan.com proxy)
keepalive 32;
}
server {
listen 80;
listen [::]:80;
server_name moslem02.falahos.my;
# ── Cloudflare real-ip ─────────────────────
# Cloudflare sends visitor IP via CF-Connecting-IP header.
# Uncomment and populate with current CF IP ranges:
# https://www.cloudflare.com/ips-v4 / ips-v6
# real_ip_header CF-Connecting-IP;
# real_ip_recursive on;
# set_real_ip_from 173.245.48.0/20;
# set_real_ip_from 103.21.244.0/22;
# … (keep current by syncing from cloudflare.com/ips-v4)
# ── Static root ────────────────────────────
root /var/www/nur-muslim-companion;
index index.html;
error_page 404 =200 /index.html;
# ── Gzip ───────────────────────────────────
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 5;
gzip_min_length 512;
gzip_types
text/html
text/plain
text/css
text/javascript
application/javascript
application/json
application/manifest+json
image/svg+xml
image/x-icon
font/woff2;
# ── Security headers ───────────────────────
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "0" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# HSTS — only enable once TLS is confirmed working via Cloudflare
# add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
# ── SPA fallback ───────────────────────────
# All routes serve index.html; actual 404s are impossible.
# index.html is NOT cached so updates reach clients immediately.
location / {
try_files $uri $uri/ /index.html;
# index.html: no-cache (must always fetch fresh version)
add_header Cache-Control "no-cache, must-revalidate" always;
}
# ── Hashed static assets (immutable) ───────
# Vite emits content-hashed filenames: assets/index-abc123.js
# These can be cached forever on CDN and browsers alike.
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable, max-age=31536000" always;
access_log off;
}
# ── Favicon ────────────────────────────────
location = /favicon.svg {
expires 7d;
add_header Cache-Control "public, max-age=604800" always;
access_log off;
}
# ── PWA manifest ───────────────────────────
location = /manifest.webmanifest {
expires 1d;
add_header Cache-Control "public, max-age=86400" always;
add_header Content-Type "application/manifest+json";
}
# ── Service Worker ─────────────────────────
# Service-worker script must NOT be cached and must be served
# from its own scope (root). Cloudflare bypasses cache for sw.js.
location /sw.js {
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
add_header Service-Worker-Allowed "/";
expires off;
access_log off;
}
# ── PWA app shell (workbox precached) ──────
# workbox-*.js, worker-*.js are versioned hashed files:
location ~* \.(js|css|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable, max-age=31536000" always;
}
# ── Icon files ─────────────────────────────
location ~* \.(png|ico)$ {
expires 1y;
add_header Cache-Control "public, immutable, max-age=31536000" always;
access_log off;
}
# ── Deny hidden files ──────────────────────
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
# ── Deny node_modules & src ────────────────
location ~ ^/(node_modules|src)/ {
deny all;
access_log off;
}
}