From feb5f1f03b9c311264bafabb22f7fe6edd3a5925 Mon Sep 17 00:00:00 2001 From: Wmj Ismail Date: Sat, 8 Aug 2026 16:54:51 +0800 Subject: [PATCH] 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 --- .gitignore | 3 + deploy/DEPLOY.md | 136 +++++ deploy/n8n-polar-to-crm.json | 264 +++++++++ deploy/n8n-polar-to-twenty.json | 232 ++++++++ deploy/nginx.conf | 127 ++++ index.html | 18 +- netlify.toml | 8 + package-lock.json | 42 -- public/icon-192.png | Bin 0 -> 1393 bytes public/icon-512.png | Bin 0 -> 4163 bytes src/App.svelte | 245 +++++++- src/lib/BirNur.svelte | 989 +++++++++++++++++++++++++++++++ src/lib/DuaLibrary.svelte | 470 +++++++++++++++ src/lib/HalalFoodFinder.svelte | 534 ++++++++++++++--- src/lib/HifdhTracker.svelte | 484 +++++++++++++++ src/lib/Hijri.svelte | 52 +- src/lib/IngredientScanner.svelte | 238 ++++---- src/lib/IslamicCalendar.svelte | 338 +++++++++++ src/lib/MosqueFinder.svelte | 328 ++++++++++ src/lib/Names99.svelte | 51 +- src/lib/PrayerTimes.svelte | 251 +++++++- src/lib/Qibla.svelte | 35 +- src/lib/Quran.svelte | 407 +++++++++++-- src/lib/Support.svelte | 583 ++++++++++++++++++ src/lib/Tasbih.svelte | 41 +- src/lib/WorshipTracker.svelte | 407 +++++++++++++ src/lib/ZakatCalc.svelte | 474 +++++++++++++++ vite.config.js | 8 +- 28 files changed, 6360 insertions(+), 405 deletions(-) create mode 100644 deploy/DEPLOY.md create mode 100644 deploy/n8n-polar-to-crm.json create mode 100644 deploy/n8n-polar-to-twenty.json create mode 100644 deploy/nginx.conf create mode 100644 netlify.toml create mode 100644 public/icon-192.png create mode 100644 public/icon-512.png create mode 100644 src/lib/BirNur.svelte create mode 100644 src/lib/DuaLibrary.svelte create mode 100644 src/lib/HifdhTracker.svelte create mode 100644 src/lib/IslamicCalendar.svelte create mode 100644 src/lib/MosqueFinder.svelte create mode 100644 src/lib/Support.svelte create mode 100644 src/lib/WorshipTracker.svelte create mode 100644 src/lib/ZakatCalc.svelte diff --git a/.gitignore b/.gitignore index 9451024..b1dc291 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,6 @@ node_modules/ dist/ .DS_Store *.log + +# Local Netlify folder +.netlify diff --git a/deploy/DEPLOY.md b/deploy/DEPLOY.md new file mode 100644 index 0000000..9f89d38 --- /dev/null +++ b/deploy/DEPLOY.md @@ -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-.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-.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//purge_cache" \ + -H "Authorization: Bearer " \ + -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 +``` diff --git a/deploy/n8n-polar-to-crm.json b/deploy/n8n-polar-to-crm.json new file mode 100644 index 0000000..f04de67 --- /dev/null +++ b/deploy/n8n-polar-to-crm.json @@ -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"] +} diff --git a/deploy/n8n-polar-to-twenty.json b/deploy/n8n-polar-to-twenty.json new file mode 100644 index 0000000..b03d2b2 --- /dev/null +++ b/deploy/n8n-polar-to-twenty.json @@ -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" +} diff --git a/deploy/nginx.conf b/deploy/nginx.conf new file mode 100644 index 0000000..39e372a --- /dev/null +++ b/deploy/nginx.conf @@ -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; + } +} diff --git a/index.html b/index.html index d7c1431..9a8a0fb 100644 --- a/index.html +++ b/index.html @@ -3,11 +3,25 @@ - + - Nūr — Muslim Companion + Nur Falah — Muslim Companion + + + +
diff --git a/netlify.toml b/netlify.toml new file mode 100644 index 0000000..5b93df5 --- /dev/null +++ b/netlify.toml @@ -0,0 +1,8 @@ +[build] +command = "npm run build" +publish = "dist" + +[[redirects]] + from = "/*" + to = "/index.html" + status = 200 diff --git a/package-lock.json b/package-lock.json index 048909d..ff58eed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1972,9 +1972,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2184,9 +2181,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2200,9 +2194,6 @@ "cpu": [ "arm" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2216,9 +2207,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2232,9 +2220,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2248,9 +2233,6 @@ "cpu": [ "loong64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2264,9 +2246,6 @@ "cpu": [ "loong64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2280,9 +2259,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2296,9 +2272,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2312,9 +2285,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2328,9 +2298,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2344,9 +2311,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2360,9 +2324,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2376,9 +2337,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/public/icon-192.png b/public/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..441f4610449ae43ba0b6730cef8397bc945216ba GIT binary patch literal 1393 zcmb`HYgE#A7{`BqDZmQJGR@3O+N?8^R5Wc0q0k|hN@ka3npUPx$WqDJy!`)Zp$?m# z)F{JS>9lofHfXx71fzAGf@U|%P}3BHh0`X5@E>e%_GYiP7tiH8qL z<}I5C03d{KVr(^6nI-U8bKd%bg9Bi0P&gxW8&9MixSF|tqus`6U#&=luvU%~TI&U* z7V8$<$|TONFW-uOp1Hrb@%d5d={q0m)nh8y@WwqfFW4}5Q;HvGa72^~XsK8?f6T&a z2;T*cCxbBZf0MOZjRUO}HjWO#*~ZDSm;`~T3xwGl#b}hAXM@4-)-zE~A+Hp&^ENUQ zgub~V$ZoBXa$A>y^+}ynScTkRNbBO%e8*Kt+2QjU=7w#18%*ylRB$=73I~y*%Vva$5z=vfLswIhOX8rQJC~ z>?D~o$b0DdK`vRC_StSHXXH3R)-27sW0}L|RhS!N- z-e(GQV)6#8%I`r{ZtpKW?19RKXlc`~o$zaNO5O5MF7OZE{QVv~di7J_Nu(T6Cpgk4 zycasjxFrvCXP;0;p#uQNj-S1_f?;y0&ue)oM0;i`c~@Qn+?jTLOnPCvQ+-D9$SA7G zi40b?1J4!S@SB`JJe2b#-q3rG=stqdrxm3qn8Y2KzV1ji306fc0GY~^*iLbYwVOIt z#Fbqn_pcapUW{%(;W7- zu;f%TE=cdHCv&_KPdDSrb%D@uEvkn8W{dVLy@COFqK8fuIaR1ib<+Ix+Q zRNeVd1(B+*FOvvdtfB*D{*eM&o+1N^$VvOb`_?5DyZ`+M+i~!4!y@hQ-B;WwrD4dPYxjTFZr_1yGhG01TX6DQ?dH&7yjU8=ILGh`5Pmv;4 zE~wn?_7UHBepj7y)#k0-@9$3@~AzQX_@-Jce;&Y=Jgx{UT z0To1TDqFXliSse)zHa3!4)qZ`XY}&PSLAc)vjkd5-*>U~>l>`O zyn);hiq#S6?THSq2HSD_iA8J14ITUrsHN?g04;>Bi(%z%p=6zy|;*d5Z$goV@f&ej5Q`TLcCy50&J;d{X^{eS>C{y**|$un3N^gZxN=g!GFiV z_WhUX&ci)cR7F1EX7N7S*F*g$(9E?!U@xe~(+z9b#3mHXV>K;f4i;!-2H=hY-5nb& z))Rp#-hDlv8Dpp#Uj(S$x}JSL&`Af0+`ndK1x1$$_(s9dp#DZ*HYB=&W)E}phH^3< zL5FAFL%o%g0TH>&{XCnJHe%=ZQbe{?VcNrFHurPj9ywZ29=(#L@Ii?0IV0+8i8sN= z8g}2QZ75#siZ!Qz*pZpI^U{1C!NlOD_fXiij$@EFsvM!>Q36BQ&m-p+dcoE0x(_~o z4A-sU>N8D|2T$aM;&vRveje8K<3Vt5XboQdaP9*oQaFcl>Xq6&Bnt{TRN)U+XlVOn z_(ouOl-0l9H3r;k#WTn-7h@^JKBmk%Y_O!m9N)nZ{i?JSyL!Q#RvlHJbPa?1-iF$0 z@v<_=yj{z>GjLqCBi%s_{LfX++9{t}0i%Kz)#dUkYcb{pjPb8i$SPFC z4IP!Pl%n`2-iAny{)Epk6Ds(bVrI)E&Ab?pWUHJz%3n*)fM!-QyJXwg8wNt`NM@Bp zIqy3S8f$-Qx58Pu0%EfxDgDhExvn6%m7a868(hx@&dy4abYRwMIxy#l7P9oGr(?i! zaBEDH{xTiK4?4IsW{m!F7=t|vd<%v8%nBNW@7P87X`{lY!_dh{M1Q#~ArN$v{Lz6O zvfhOVA!JaS3#9cd6tA(jNLM9x`XG2DBn1G<&EWlxXoEgnG{m6WPm!Vn1CT(4Oh2_vndi;Bkz2LT!VI2@u~=N{6{89k25?AiYPMX!uF; zChi!yuJIsCp1*MB2TRhRIbG$<-zW)y-M3p=nVuXT05=_)j~B^^+*Wr)K__Q<8k-D& zi%>Bo--yJ?%P~?hi;qK-@CV; z1+ll~o)L1Hn7PoJhVNv!#V=`#t?B7hOod7Me}Bu`-V!%2V4r`>TmG~Md1wu6qPE~p z4>IkIMIrZj(A@`o?`UH4cdLQ8;K7h~E2c>T!S>vjkDjNb9okT4*?Y;Z1;N(~`n-SG z!1?Ks8f>>N{PoSZsnr`woZ?EE2%PjDx|~{hcQ)+$X&j!<>C5DfxXbRPsr!cROQl%{ z7gr*<8^>@jJet{kLy5Q(Zf#a_;SZO5;`S^<;Fh=HveAAyh$5LX4+V2@2H3?}-i|gr zO0<4>ez@Wc;9GtF@qSjnl7%SJN1nT?PG3FnDYJAs6w>6vyiH}JbZ{s5-%RiM-V0G2 zkM>OD2LwjcbhJ)I!EWDBIi;s^CITnyv$Z!5y&|`WCcFAvDLrfjlO!#t>(aMJZEUJg z3JL&E;TUHq>JPJlpK^AMbf|#pfX^&ns zHH}R|&FNeBg}E)Pj&|R1nj~TTejeaCBpVpJL=&3EPZ|8!!FN>EtG`*5>kq_wj!|5* z-({j-aYxArRe!+|;U^7-9gZ7Pjq2~HIq$?VSl7pDC{KIHo{&dkW&Xz+zk&Oop<;f- zc7!jSZ4I$WMUyKFq>D2q+8l!3oCHo>{{o=In^ z$}`6jv=wjukY55RogV3@kD2plRc1W%M1GeKrYqvdvWm9-5qTU}q&F}@TjONJfFz?- z6AJ(IYyFDUw{0fU$Y}R01ODpo$mAV~4zzFZ%klr?)!g<&*0(R$bXb`(ZkGRnCUM}wW-{Y4 zTQ*@RSp#GhHkhg~v3bdnB0EXG^y)jFK_f7APSKP~*;r;y8yzZ>XfR&f37ez7ITxBe z^JsWrRl{8_su`AU4X-!l3(rxSf&Lr?0m^YucwV#tn}o8{D=}htR2)=4-P9>bY801- zoCdr$0DdkRyN(gtpYGYQ01Mn7|0E|Zjt-dtSo+OFWx+rMB)iZ{dZ%FoTB^L_kgC;o zk1UzeX#)zJCSLvUfF}u#`Ewqn^sCbV|D3TtUhFguyf@V+oEgEtHkBn*CIgNJfKyn- z4H92J^1rd^xh5n580{IpAT;okq7Kbc6uz)RLU&4E`NNzGi3!m^O}(nEcB`u)mC)7= zEhd(#zXJRjBcepOiVbF!=51D1L`+Y;`jgk=o(Qspt9}?0$`04kxS+{zUrd_YYg2xWfLE{x;*8L)0 z_hA9J*UyF7%E_C))1Q6)qK4#Uj*EFmS7O(FIqV|l!UNW<;6sDbGe0QYx@}0z{a>mYDk%qdUlMH%mSD;#E*e0_4c6Z^_xNn`2##5wJMNaFVG0`Hs)*YaHm*p=t zVKV!}7JOJWnG`=4;2#v20HL5)0G^<7K*iI^(I|n54HORK=!97MV4 zG#~|k!r-4(qtLN@3Bq1S1{Tdj*!3Oq%>%L}2+o5fj*$k&t@a|OKJPUK$tbpWsP`yn zrX*9ezZg5BP`t(7TH&nE&cL8-!5p#kz``;ssQS4PH)^*%bpc66D}(j>W3Pc8hU4#e8R{ zW|79qWU=lucqEy$g*g`(J&9md*W=D$KbAcybX(LAJ6lC3xuWtb(8MR(HgEiGCfKjQ z@X>&OR8t6k^%NM?^6YCZLFKSj+!B%oVhbx6m~>5IMl1@a6PK5v3o8-vQh%m)(nA}V z!$XNg@!T@lbbB`PD?M_28f2jaEZr{$tp1$-8SEC@gtI$zpRKHtb$a>_E;jdPc9kmZj= rP~a1jX8wJ~{Ii( - +
-

Nūr

- Muslim Companion +
+
Nur
+
Falah
+
+ MUSLIM COMPANION +
+
@@ -65,12 +104,35 @@ + :global(button.secondary) { + background: transparent; + color: #E8E4DC; + border: 1px solid rgba(232,228,220,0.3); + padding: 10px 20px; + clip-path: polygon(6px 0,100% 0,100% calc(100% - 6px),calc(100% - 6px) 100%,0 100%,0 6px); + font-family: 'JetBrains Mono', monospace; + font-weight: 400; + cursor: pointer; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.5px; + } + :global(button.secondary:active) { opacity: 0.7; } + :global(select) { + background: #111820; + color: #E8E4DC; + border: 1px solid rgba(201,168,76,0.2); + border-radius: 6px; + padding: 6px 10px; + font-family: 'DM Sans', sans-serif; + font-size: 0.8rem; + outline: none; + } + :global(input), :global(textarea) { + background: #111820; + color: #E8E4DC; + border: 1px solid rgba(201,168,76,0.2); + border-radius: 8px; + padding: 10px 14px; + font-family: 'DM Sans', sans-serif; + font-size: 0.85rem; + outline: none; + } + :global(input:focus), :global(textarea:focus) { + border-color: #C9A84C; + box-shadow: 0 0 0 2px rgba(201,168,76,0.1); + } + :global(input::placeholder), :global(textarea::placeholder) { + color: rgba(232,228,220,0.3); + } + + .support-float { + display: flex; + align-items: center; + gap: 6px; + margin: 10px auto 0; + padding: 6px 16px; + background: rgba(201,168,76,0.1); + border: 1px solid rgba(201,168,76,0.25); + border-radius: 20px; + color: #C9A84C; + font-family: 'JetBrains Mono', monospace; + font-size: 0.7rem; + font-weight: 400; + cursor: pointer; + transition: all 0.2s; + text-transform: uppercase; + letter-spacing: 1px; + position: relative; + } + .support-float:hover { + background: rgba(201,168,76,0.18); + border-color: #C9A84C; + } + .support-float-icon { font-size: 1rem; } + .support-float-label { white-space: nowrap; } + .support-dot { + position: absolute; + top: -2px; + right: -2px; + width: 8px; + height: 8px; + background: #2ECC71; + border-radius: 50%; + border: 2px solid #070A0D; + } + \ No newline at end of file diff --git a/src/lib/BirNur.svelte b/src/lib/BirNur.svelte new file mode 100644 index 0000000..ccf0530 --- /dev/null +++ b/src/lib/BirNur.svelte @@ -0,0 +1,989 @@ + + +
+ {#if step === 'intro'} + +
+
+

Bir Nur

+

بِئْر نُور — Well of Light

+
+

+ "When a person dies, their deeds are cut off except for three: ongoing charity (sadaqah jariyah), knowledge from which people benefit, and a righteous child who prays for them." + — Prophet Muhammad ﷺ (Muslim) +

+
+ + +
showStory = !showStory} onkeydown={(e) => e.key === 'Enter' && (showStory = !showStory)} role="button" tabindex="0"> + {showStory ? '▼' : '▶'} The story behind Bir Nur +
+ + {#if showStory} +
+

Uthman ibn Affan and the Well of Rumah

+

When the Muslims arrived in Madinah, the only sweet water was the Well of Rumah, owned by a Jewish man who sold it. The Prophet ﷺ said: "Whoever buys the well of Rumah and donates it (as waqf), will have Paradise."

+

Uthman ibn Affan ﷺ bought it for 35,000 dirhams and made it waqf — free water for all, Muslim and non-Muslim, human and animal. One payment, perpetual benefit.

+
+

Abdur Rahman bin Auf and the Productive Garden

+

He ﷺ was the wealthiest of the companions. He didn't hoard — he endowed orchards and gardens whose fruits fed the poor year after year. He once sold land for 40,000 dinars and gave every single coin away. His wealth only grew.

+

His waqf was productive — the garden kept growing, the charity kept flowing. This is the model of waqf istithmari (productive endowment).

+
+ {/if} + + +
+

How Bir Nur Works

+
+
+
1
+

Dig Your Well

+

Contribute any amount — RM 1 or RM 100,000. Your contribution is your well.

+
+
+
+
2
+

Water Flows Forever

+

Your principal is never spent. It is invested in Shariah-compliant assets. The returns are the water.

+
+
+
+
3
+

Quench Thirst Perpetually

+

The returns serve orphans, students, the sick, and the needy. Your reward continues as long as water flows.

+
+
+
+ + +
+
🏛️
+
+ Managed by a regulated bank trustee +

Your waqf funds are held by a licensed Islamic bank — Shariah audited, BNM-regulated, transparent reporting.

+
+
+ + +
+

Your One Gift, Forever Giving

+
+
+ Your contribution + {currencyRM(amountRM)} +
+
+ Annual returns (≈5%) + {currencyRM(annualReturn)} +
+
+ Given to charity in 10 years + {currencyRM(tenYearReturn)} +
+
+ Given in 50 years + {currencyRM(fiftyYearReturn)} +
+
+

Your RM {amountRM.toFixed(0)} is never touched. It grows. It gives. Forever.

+ +
+ RM 1 + RM 10K +
+
+ + + + {:else if step === 'configure'} + +
+ +

🌴 Dig Your Well

+

Choose your well type, name it, and decide where its water flows.

+ + +
+

Name Your Well

+ +

Like the companions named their endowments — this becomes your legacy.

+
+ + +
+

Choose Your Well Type

+
+ {#each WELL_TYPES as w} + + {/each} +
+ {#if well} +
+

{well.desc}

+

Benefits: {well.beneficiaries.join(' · ')}

+
+ {/if} +
+ + +
+

Your Contribution (RM)

+
+ {#each [10, 50, 100, 500, 1000] as amt} + + {/each} + +
+ {#if customAmount !== ''} + + {/if} +
+ Your well: {currencyRM(amountRM)} + {currencyRM(annualReturn)} per year in charity + {currencyRM(tenYearReturn)} over 10 years +
+
+ + +
+
+

Where the Water Flows

+ +
+

Allocate the annual returns across causes. Total must equal 100%.

+
+ {#each CAUSES as cause} +
+ {cause.icon} {cause.label} +
+ + {allocation[cause.id] || 0}% + +
+
+ {/each} +
+
+ Total: {totalAlloc}% {allocValid ? '✅' : '❌ Must equal 100%'} +
+
+ + +
+

Your Details

+ + +
+ + +

No payment taken yet. We'll contact you when our bank trustee partner is live.

+
+ + {:else if step === 'registered'} + +
+
+

Your Well is Registered 🌱

+

+ Your {waqfName || 'General Well of Light'} of {currencyRM(amountRM)} ({WELL_TYPES.find(w => w.id === selectedWell)?.icon} {WELL_TYPES.find(w => w.id === selectedWell)?.title}) is noted. +

+

We'll email {donorEmail} as soon as our bank trustee partnership is live. You'll be among the first to dig a Bir Nur well.

+ + +
+
+ 📊 Your Well Preview + COMING SOON +
+
+
+ Well Structure: {currencyRM(amountRM)} — Intact + Growing +
+
+
+ Year 1 Water Drawn: {currencyRM(annualReturn)} +
+
+

Your water will flow to:

+ {#each CAUSES as c} + {#if (allocation[c.id] || 0) > 0} +
+ {c.icon} {c.label} + {allocation[c.id]}% + {currencyRM(annualReturn * (allocation[c.id] / 100))} +
+ {/if} + {/each} +
+

This continues year after year. Your reward never stops. 🤲

+
+ + + + + + +
+ {/if} +
+ + diff --git a/src/lib/DuaLibrary.svelte b/src/lib/DuaLibrary.svelte new file mode 100644 index 0000000..7f66e0e --- /dev/null +++ b/src/lib/DuaLibrary.svelte @@ -0,0 +1,470 @@ + + +
+
+

🤲 Dua & Sunnah

+ + +
+ +
+ + +
+ + {#each categories as cat} + + {/each} +
+ + + {#if favoriteCount > 0} +
+ + {favoriteCount} saved +
+ {/if} + + +
{filtered.length} dua{filtered.length !== 1 ? 's' : ''}
+ + {#if filtered.length === 0} +
+
🔍
+

No duas found

+

Try a different search or category

+
+ {:else} +
+ {#each filtered as dua} +
+
expandedDua = expandedDua === dua.id ? null : dua.id} + onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); expandedDua = expandedDua === dua.id ? null : dua.id; } }} + aria-label="Toggle dua details" + > +
+ {dua.title} +
+ {dua.category} + +
+
+
{dua.arabic}
+
+ + {#if expandedDua === dua.id} +
+
+ Transliteration +

{dua.transliteration}

+
+
+ Translation +

{dua.translation}

+
+
+ Reference +

{dua.reference}

+
+ {#if dua.benefit} +
+ Benefit +

{dua.benefit}

+
+ {/if} +
+ {/if} +
+ {/each} +
+ {/if} +
+
+ + diff --git a/src/lib/HalalFoodFinder.svelte b/src/lib/HalalFoodFinder.svelte index 484c30f..751c1ce 100644 --- a/src/lib/HalalFoodFinder.svelte +++ b/src/lib/HalalFoodFinder.svelte @@ -13,6 +13,11 @@ let mapReady = $state(false); let cuisineFilter = $state('all'); + // Global mode state + let globalMode = $state(false); + let globalRestaurants = $state([]); + let searching = $state(false); + const radiusOptions = [1, 5, 10, 25, 50]; const restaurants = [ @@ -36,31 +41,261 @@ { id: 18, name: 'Boathouse', lat: 3.1615, lng: 101.6970, cuisine: 'Seafood', certifications: [], address: 'Taman Tasik Titiwangsa, 53200 KL', rating: 4.0, priceRange: '$$$', phone: '+603-4022 7888', muslimOwned: false, userReviews: [{ rating: 3, comment: 'Seafood restaurant by the lake. No halal cert. Serves alcohol.', date: '2026-07-18' }] }, ]; - const allCuisines = $derived([...new Set(restaurants.map(r => r.cuisine))].sort()); - const cuisineList = $derived(['all', ...allCuisines]); - function haversineDistance(lat1, lng1, lat2, lng2) { const R = 6371; const dLat = (lat2 - lat1) * Math.PI / 180; const dLng = (lng2 - lng1) * Math.PI / 180; const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLng / 2) ** 2; return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); } - const enriched = $derived(restaurants.map(r => ({ ...r, distance: lat !== null ? haversineDistance(lat, lng, r.lat, r.lng) : 0, confidence: calculateConfidence(r), certBadge: certBadgeColor(r), certStatus: certStatusLabel(r) }))); + // --- OSM confidence helpers --- + + /** Compute halal confidence (0-100) from OSM tags */ + function osmConfidence(tags) { + let score = 0; + if (tags['diet:halal'] === 'yes') score += 60; + else if (tags['diet:halal'] === 'only') score += 70; + if (tags.halal === 'yes') score += 50; + if (tags['halal:certification'] || tags.certification) score += 40; + if (tags['diet:halal'] === 'no') score -= 80; + if (tags['diet:halal'] === 'limited') score -= 30; + if (tags['diet:meat'] === 'halal') score += 20; + if (tags['diet:chicken'] === 'halal') score += 10; + if (tags['diet:beef'] === 'halal') score += 10; + // If no explicit halal tag but it's a known halal-cuisine type + if (tags.cuisine) { + const c = tags.cuisine.toLowerCase(); + if (c.includes('halal') || c.includes('arab') || c.includes('indian') || c.includes('malaysian') || c.includes('indonesian') || c.includes('mamak') || c.includes('middle eastern') || c.includes('turkish') || c.includes('pakistani')) { + score += 10; + } + } + if (tags['diet:pork'] === 'no') score += 10; + if (tags['diet:alcohol'] === 'no') score += 10; + if (tags['self_service'] === 'yes') score -= 10; + if (tags.outdoor_seating === 'yes') {} // neutral + // Cap + return Math.max(0, Math.min(100, score)); + } + + function osmConfidenceLabel(score) { + if (score >= 80) return '✅ High'; + if (score >= 50) return '🟡 Medium'; + if (score >= 30) return '⚠ Low'; + return '❓ Unknown'; + } + + function osmConfidenceColor(score) { + if (score >= 80) return '#2ECC71'; + if (score >= 50) return '#C9A84C'; + return '#9ca3af'; + } + + /** Build address string from OSM addr:* tags */ + function osmAddress(tags) { + const parts = []; + if (tags['addr:housenumber']) parts.push(tags['addr:housenumber']); + if (tags['addr:street']) parts.push(tags['addr:street']); + if (tags['addr:city']) parts.push(tags['addr:city']); + if (tags['addr:postcode']) parts.push(tags['addr:postcode']); + if (tags['addr:country']) parts.push(tags['addr:country']); + return parts.length > 0 ? parts.join(', ') : (tags['addr:full'] || 'Address not available'); + } + + /** Build cuisine string from OSM cuisine tag */ + function osmCuisine(tags) { + const c = tags.cuisine; + if (!c) return 'Restaurant'; + return c.split(';').map(s => s.trim()).filter(Boolean).join(', '); + } + + // --- Overpass fetch --- + + async function fetchGlobalRestaurants() { + if (lat === null || lng === null) return; + searching = true; + error = ''; + const radius_m = radius * 1000; + const search_lat = lat; + const search_lng = lng; + + const queries = [ + // Primary: restaurants with explicit halal tag + `[out:json];(node["amenity"="restaurant"]["diet:halal"="yes"](around:${radius_m},${search_lat},${search_lng});way["amenity"="restaurant"]["diet:halal"="yes"](around:${radius_m},${search_lat},${search_lng});node["amenity"="fast_food"]["diet:halal"="yes"](around:${radius_m},${search_lat},${search_lng});way["amenity"="fast_food"]["diet:halal"="yes"](around:${radius_m},${search_lat},${search_lng}););out body center;`, + // Secondary: restaurants with general halal tag + `[out:json];(node["amenity"="restaurant"]["halal"](around:${radius_m},${search_lat},${search_lng});way["amenity"="restaurant"]["halal"](around:${radius_m},${search_lat},${search_lng});node["amenity"="fast_food"]["halal"](around:${radius_m},${search_lat},${search_lng});way["amenity"="fast_food"]["halal"](around:${radius_m},${search_lat},${search_lng}););out body center;`, + // Tertiary: diet:halal present in any value + `[out:json];(node["amenity"="restaurant"]["diet:halal"](around:${radius_m},${search_lat},${search_lng});way["amenity"="restaurant"]["diet:halal"](around:${radius_m},${search_lat},${search_lng});node["amenity"="fast_food"]["diet:halal"](around:${radius_m},${search_lat},${search_lng});way["amenity"="fast_food"]["diet:halal"](around:${radius_m},${search_lat},${search_lng}););out body center;`, + // Fallback: all restaurants + `[out:json];(node["amenity"="restaurant"](around:${radius_m},${search_lat},${search_lng});way["amenity"="restaurant"](around:${radius_m},${search_lat},${search_lng});node["amenity"="fast_food"](around:${radius_m},${search_lat},${search_lng});way["amenity"="fast_food"](around:${radius_m},${search_lat},${search_lng}););out body center;` + ]; + + const seen = new Set(); + + for (const query of queries) { + try { + const res = await fetch('https://overpass-api.de/api/interpreter', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ data: query }) + }); + if (!res.ok) continue; + const data = await res.json(); + if (data.elements && data.elements.length > 0) { + const parsed = data.elements + .filter(el => el.tags) + .map(el => { + const t = el.tags || {}; + let osmLat, osmLng; + if (el.type === 'node') { + osmLat = el.lat; + osmLng = el.lon; + } else if (el.center) { + osmLat = el.center.lat; + osmLng = el.center.lon; + } else { + return null; + } + const uniqueId = `${el.type}-${el.id}`; + if (seen.has(uniqueId)) return null; + seen.add(uniqueId); + + const name = t.name || t['name:en'] || t.operator || 'Halal Restaurant'; + const cuisine = osmCuisine(t); + const address = osmAddress(t); + const distance = haversineDistance(lat, lng, osmLat, osmLng); + const confScore = osmConfidence(t); + + // Collect halal indicators for detail view + const halalIndicators = []; + if (t['diet:halal'] === 'yes') halalIndicators.push({ label: 'Diet: Halal', value: 'Yes', good: true }); + if (t['diet:halal'] === 'only') halalIndicators.push({ label: 'Diet: Halal', value: 'Only halal', good: true }); + if (t.halal === 'yes') halalIndicators.push({ label: 'Halal listed', value: 'Yes', good: true }); + if (t['halal:certification']) halalIndicators.push({ label: 'Certification', value: t['halal:certification'], good: true }); + if (t.certification) halalIndicators.push({ label: 'Certification', value: t.certification, good: true }); + if (t['diet:meat'] === 'halal') halalIndicators.push({ label: 'Meat: Halal', value: 'Yes', good: true }); + if (t['diet:chicken'] === 'halal') halalIndicators.push({ label: 'Chicken: Halal', value: 'Yes', good: true }); + if (t['diet:beef'] === 'halal') halalIndicators.push({ label: 'Beef: Halal', value: 'Yes', good: true }); + if (t['diet:pork'] === 'no') halalIndicators.push({ label: 'No Pork', value: 'Yes', good: true }); + if (t['diet:alcohol'] === 'no') halalIndicators.push({ label: 'No Alcohol', value: 'Yes', good: true }); + if (t['self_service'] === 'yes') halalIndicators.push({ label: 'Self-service', value: 'Yes', good: false }); + if (t['diet:halal'] === 'no') halalIndicators.push({ label: 'Diet: Halal', value: 'No', good: false }); + if (t['diet:halal'] === 'limited') halalIndicators.push({ label: 'Diet: Halal', value: 'Limited', good: false }); + + // Collect other OSM extras + const extras = []; + if (t.phone) extras.push({ label: 'Phone', value: t.phone }); + if (t.website) extras.push({ label: 'Website', value: t.website }); + if (t.opening_hours) extras.push({ label: 'Hours', value: t.opening_hours }); + if (t.wheelchair) extras.push({ label: 'Wheelchair', value: t.wheelchair === 'yes' ? 'Accessible' : t.wheelchair === 'no' ? 'Not accessible' : t.wheelchair }); + if (t.dietary_restrictions) extras.push({ label: 'Dietary', value: t.dietary_restrictions }); + if (t['takeaway'] === 'yes') extras.push({ label: 'Takeaway', value: 'Yes' }); + if (t['delivery'] === 'yes') extras.push({ label: 'Delivery', value: 'Yes' }); + if (t['capacity']) extras.push({ label: 'Capacity', value: t.capacity + ' pax' }); + + return { uniqueId, name, cuisine, address, distance, confScore, osmLat, osmLng, osmTags: t, halalIndicators, extras }; + }) + .filter(r => r !== null); + globalRestaurants = parsed; + if (globalRestaurants.length > 0) break; + } + } catch (e) { + continue; + } + } + searching = false; + } + + // --- Mode toggle --- + + function toggleGlobal() { + globalMode = !globalMode; + if (globalMode && lat !== null) { + fetchGlobalRestaurants(); + if (viewMode === 'map') setTimeout(() => initMap(), 100); + } + if (!globalMode && viewMode === 'map') { + setTimeout(() => initMap(), 100); + } + } + + // --- Derived data --- + + const enrichedLocal = $derived(restaurants.map(r => ({ + ...r, + distance: lat !== null ? haversineDistance(lat, lng, r.lat, r.lng) : 0, + confidence: calculateConfidence(r), + certBadge: certBadgeColor(r), + certStatus: certStatusLabel(r), + _source: 'local' + }))); + + const enrichedGlobal = $derived(globalRestaurants.map(r => ({ + id: r.uniqueId, + name: r.name, + cuisine: r.cuisine, + address: r.address, + distance: r.distance, + lat: r.osmLat, + lng: r.osmLng, + confidence: r.confScore, + confScore: r.confScore, + halalIndicators: r.halalIndicators, + extras: r.extras, + osmTags: r.osmTags, + phone: r.osmTags?.phone || '', + certifications: r.halalIndicators.filter(i => i.good && i.label === 'Certification').length > 0 ? ['osm-halal'] : [], + priceRange: '', + rating: null, + muslimOwned: null, + userReviews: [], + certBadge: [osmConfidenceColor(r.confScore), '#070A0D'], + certStatus: osmConfidenceLabel(r.confScore), + _source: 'global' + }))); + + // Choose active set based on mode + const activeRestaurants = $derived(globalMode ? enrichedGlobal : enrichedLocal); + + const allCuisines = $derived([...new Set(activeRestaurants.map(r => r.cuisine))].sort()); + const cuisineList = $derived(['all', ...allCuisines]); const filtered = $derived( - enriched - .filter(r => { if (activeFilter === 'certified') return r.certifications.some(c => (getCert(c)?.trustLevel || 0) >= 4); if (activeFilter === 'muslim-owned') return r.muslimOwned; if (activeFilter === 'not-halal') return r.confidence < 30; return true; }) + activeRestaurants + .filter(r => { + if (activeFilter === 'certified') { + if (globalMode) return r.confidence >= 80; + return r.certifications.some(c => (getCert(c)?.trustLevel || 0) >= 4); + } + if (activeFilter === 'muslim-owned') return !globalMode && r.muslimOwned; + if (activeFilter === 'not-halal') return r.confidence < 30; + return true; + }) .filter(r => cuisineFilter === 'all' || r.cuisine.toLowerCase() === cuisineFilter.toLowerCase()) - .filter(r => { if (!searchQuery.trim()) return true; const q = searchQuery.toLowerCase(); return r.name.toLowerCase().includes(q) || r.cuisine.toLowerCase().includes(q); }) + .filter(r => { + if (!searchQuery.trim()) return true; + const q = searchQuery.toLowerCase(); + return r.name.toLowerCase().includes(q) || r.cuisine.toLowerCase().includes(q); + }) .filter(r => r.distance <= radius) .sort((a, b) => a.distance - b.distance) ); + // --- Location --- + function getLocation() { - if (!navigator.geolocation) { lat = 3.1390; lng = 101.6869; loading = false; return; } - navigator.geolocation.getCurrentPosition(pos => { lat = pos.coords.latitude; lng = pos.coords.longitude; loading = false; initMap(); }, () => { lat = 3.1390; lng = 101.6869; loading = false; initMap(); }); + if (!navigator.geolocation) { lat = 3.1390; lng = 101.6869; loading = false; fetchGlobalRestaurants(); return; } + navigator.geolocation.getCurrentPosition(pos => { + lat = pos.coords.latitude; lng = pos.coords.longitude; loading = false; + if (globalMode) fetchGlobalRestaurants(); + if (viewMode === 'map') setTimeout(() => initMap(), 100); + }, () => { + lat = 3.1390; lng = 101.6869; loading = false; + if (globalMode) fetchGlobalRestaurants(); + if (viewMode === 'map') setTimeout(() => initMap(), 100); + }); } + // --- Map --- + let mapInstance = null; let markersLayer = null; function initMap() { @@ -74,8 +309,8 @@ function createMap() { if (mapInstance) return; const el = document.getElementById('halal-map'); if (!el) return; mapInstance = L.map(el).setView([lat, lng], 14); - L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap', maxZoom: 19 }).addTo(mapInstance); - L.marker([lat, lng], { icon: L.divIcon({ className: 'user-marker', html: '
', iconSize: [14,14] }) }).addTo(mapInstance).bindPopup('📍 You are here'); + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap contributors', maxZoom: 19 }).addTo(mapInstance); + L.marker([lat, lng], { icon: L.divIcon({ className: 'user-marker', html: '
', iconSize: [14,14] }) }).addTo(mapInstance).bindPopup('📍 You are here'); updateMapMarkers(); mapReady = true; } @@ -83,27 +318,45 @@ if (!mapInstance) return; if (markersLayer) mapInstance.removeLayer(markersLayer); markersLayer = L.layerGroup(); filtered.forEach(r => { - const color = r.confidence >= 80 ? '#16a34a' : r.confidence >= 50 ? '#d97706' : '#9ca3af'; - L.marker([r.lat, r.lng], { icon: L.divIcon({ className: 'resto-marker', html: `
🍽️
`, iconSize: [28,28] }) }).bindPopup(`${r.name}
${r.cuisine} · ${r.distance.toFixed(1)} km
${r.certStatus}`).addTo(markersLayer); + const rstLat = r.lat || r.osmLat; + const rstLng = r.lng || r.osmLng; + if (!rstLat || !rstLng) return; + const color = r.confidence >= 80 ? '#2ECC71' : r.confidence >= 50 ? '#C9A84C' : '#9ca3af'; + const popupContent = `${r.name}
${r.cuisine} · ${r.distance.toFixed(1)} km
${r.certStatus}`; + L.marker([rstLat, rstLng], { icon: L.divIcon({ className: 'resto-marker', html: `
🍽️
`, iconSize: [28,28] }) }).bindPopup(popupContent).addTo(markersLayer); }); markersLayer.addTo(mapInstance); } function toggleView() { viewMode = viewMode === 'list' ? 'map' : 'list'; if (viewMode === 'map') setTimeout(() => initMap(), 100); } + // --- Effects --- + $effect(() => { getLocation(); }); - $effect(() => { if (viewMode === 'map' && lat !== null) updateMapMarkers(); }); + $effect(() => { + if (viewMode === 'map' && lat !== null) updateMapMarkers(); + }); + // Re-fetch when radius changes in global mode + $effect(() => { + if (radius !== undefined && lat !== null && !loading && globalMode) { + fetchGlobalRestaurants(); + } + });

🍽️ Halal Food Finder

{#if loading} -

📍 Getting your location…

+

📍 Getting your location…

{:else if error}

{error}

{:else}
+
+ + +
@@ -111,98 +364,201 @@
Radius:
{#each radiusOptions as r}{/each}
- - + {#if globalMode} + + {:else} + + + {/if}
-
{#each cuisineList as c}{/each}
-
-
{filtered.length} restaurant{filtered.length !== 1 ? 's' : ''} within {radius}km
- {#if viewMode === 'list'} - {#if filtered.length === 0} -

😕 No restaurants found

Try increasing radius or changing filters

- {:else} -
- {#each filtered as resto (resto.id)} -
- - {#if expandedId === resto.id} -
-

📍 {resto.address}

- {#if resto.phone}

📞 {resto.phone}

{/if} - {#if resto.certifications.length > 0} -
Certifications:{#each resto.certifications as certId}{@const cert = getCert(certId)}{#if cert}
{'⭐'.repeat(cert.trustLevel)}{cert.name}({cert.country})
{/if}{/each}
- {:else if resto.muslimOwned} -

👤 Muslim-owned establishment. Awaiting or not yet pursuing halal certification.

- {:else} -

⚠ No halal certification found. Exercise caution.

- {/if} - {#if resto.userReviews.length > 0} -
Community Reviews ({resto.userReviews.length}):{#each resto.userReviews as review}
{'⭐'.repeat(review.rating)}{review.comment}{review.date}
{/each}
- {/if} -
- {/if} -
- {/each} -
+ {#if cuisineList.length > 0} +
{#each cuisineList as c}{/each}
{/if} +
+ {#if globalMode && searching} +

🔍 Searching OpenStreetMap for halal restaurants…

{:else} -
+
+ {filtered.length} restaurant{filtered.length !== 1 ? 's' : ''} within {radius}km + {#if globalMode} + · from OpenStreetMap + {/if} +
+ {#if viewMode === 'list'} + {#if filtered.length === 0} +

😕 No restaurants found

Try increasing radius or changing filters

+ {:else} +
+ {#each filtered as r (r.id || r.uniqueId)} +
+ + {#if expandedId === (r.id || r.uniqueId)} +
+

📍 {r.address}

+ {#if r.phone} +

📞 {r.phone}

+ {/if} + {#if r._source === 'local' && r.certifications} +
+ 📜 Certifications + {#if r.certifications.length > 0} + {#each r.certifications as cid} + {@const cert = getCert(cid)} + {#if cert} +
+ + {cert.name} ({cert.country}) + Trust: {'🟢'.repeat(cert.trustLevel)}{'⚪'.repeat(5 - cert.trustLevel)} +
+ {/if} + {/each} + {:else} +

No formal halal certification

+ {/if} +
+ {/if} + {#if r._source === 'global' && r.halalIndicators && r.halalIndicators.length > 0} +
+ 📊 Halal Indicators (OSM Tags) +
+ {#each r.halalIndicators as ind} +
+ {ind.good ? '✅' : '⚠️'} + {ind.label}: + {ind.value} +
+ {/each} +
+
+ {/if} + {#if r._source === 'local' && r.muslimOwned} +
👤 Muslim-owned establishment
+ {/if} + {#if r._source === 'global' && r.extras && r.extras.length > 0} +
+ ℹ️ More Info +
+ {#each r.extras as ext} +
+ {ext.label}: + {ext.value} +
+ {/each} +
+
+ {/if} + {#if r._source === 'local' && r.userReviews && r.userReviews.length > 0} +
+ 💬 Reviews + {#each r.userReviews as rev} +
+ {'⭐'.repeat(rev.rating)} + {rev.comment} + {rev.date} +
+ {/each} +
+ {/if} +
+ {/if} +
+ {/each} +
+ {/if} + {:else} +
+ {#if globalMode} +

Powered by OpenStreetMap · Data © OSM contributors

+ {/if} + {/if} {/if} {/if}
diff --git a/src/lib/HifdhTracker.svelte b/src/lib/HifdhTracker.svelte new file mode 100644 index 0000000..3d028ba --- /dev/null +++ b/src/lib/HifdhTracker.svelte @@ -0,0 +1,484 @@ + + +
+
+
+
+ {memorizedCount} + Memorized +
+
+ {learningCount} + Learning +
+
+ {reviewingCount} + Reviewing +
+
+ {streak.count} + Day Streak +
+
+
+ +
+
+ + + + +
+ {percentMemorized}% + Memorized +
+
+
+ {memorizedCount}/114 + surahs completed +
+
+ +
+ +
+ + + + + +
+
+ +
+ {#each filteredSurahs as s (s.n)} + {@const status = progress[s.n] || 'not_started'} + + {/each} +
+
+ + diff --git a/src/lib/Hijri.svelte b/src/lib/Hijri.svelte index 95d7e97..89bd46b 100644 --- a/src/lib/Hijri.svelte +++ b/src/lib/Hijri.svelte @@ -29,7 +29,7 @@

📅 Hijri Calendar

{#if loading} -

⏳ Loading…

+

⏳ Loading…

{:else if hijriDate}
@@ -56,25 +56,55 @@ display: flex; flex-direction: column; align-items: center; - background: linear-gradient(135deg, #0f766e, #0d9488); - color: white; + background: linear-gradient(135deg, rgba(201,168,76,0.1), rgba(201,168,76,0.05)); + border: 1px solid rgba(201,168,76,0.2); + border-top: 2px solid #C9A84C; border-radius: 16px; padding: 24px 32px; width: 100%; text-align: center; } - .hijri-day { font-size: 2.8rem; font-weight: 700; line-height: 1; } - .hijri-month { font-size: 1.2rem; opacity: 0.9; margin-top: 4px; } - .hijri-year { font-size: 0.9rem; opacity: 0.75; margin-top: 2px; } - .gregorian-row { font-size: 0.85rem; color: #5b8c85; } + .hijri-day { + font-family: 'Cinzel', serif; + font-size: 2.8rem; + font-weight: 900; + color: #C9A84C; + line-height: 1; + } + .hijri-month { + font-family: 'Cinzel', serif; + font-size: 1.2rem; + font-weight: 600; + color: #E8E4DC; + margin-top: 4px; + } + .hijri-year { + font-family: 'JetBrains Mono', monospace; + font-size: 0.9rem; + font-weight: 300; + color: rgba(232,228,220,0.55); + margin-top: 2px; + } + .gregorian-row { + font-family: 'JetBrains Mono', monospace; + font-size: 0.8rem; + font-weight: 300; + color: rgba(232,228,220,0.55); + letter-spacing: 0.5px; + } .hijri-info { width: 100%; display: flex; flex-direction: column; gap: 4px; } .info-row { display: flex; justify-content: space-between; padding: 8px 12px; - background: #f0fdfa; + background: #111820; + border: 1px solid rgba(201,168,76,0.1); border-radius: 8px; - font-size: 0.85rem; - color: #134e4a; + font-family: 'DM Sans', sans-serif; + font-size: 0.8rem; + color: rgba(232,228,220,0.55); } - + .info-row span:last-child { + color: #E8E4DC; + } + \ No newline at end of file diff --git a/src/lib/IngredientScanner.svelte b/src/lib/IngredientScanner.svelte index 70a0b97..502d38a 100644 --- a/src/lib/IngredientScanner.svelte +++ b/src/lib/IngredientScanner.svelte @@ -37,9 +37,9 @@ function statusColor(status) { if (status === 'haram') return '#e11d48'; - if (status === 'mushbooh') return '#d97706'; - if (status === 'halal') return '#16a34a'; - return '#94a3a8'; + if (status === 'mushbooh') return '#C9A84C'; + if (status === 'halal') return '#2ECC71'; + return 'rgba(232,228,220,0.3)'; } function statusEmoji(status) { @@ -50,10 +50,10 @@ } function statusBg(status) { - if (status === 'haram') return '#fef2f2'; - if (status === 'mushbooh') return '#fffbeb'; - if (status === 'halal') return '#f0fdf4'; - return '#f8fafc'; + if (status === 'haram') return 'rgba(225,29,72,0.1)'; + if (status === 'mushbooh') return 'rgba(201,168,76,0.1)'; + if (status === 'halal') return 'rgba(46,204,113,0.1)'; + return '#0C1117'; } const summary = $derived(() => { @@ -123,51 +123,52 @@ {#if scanMode === 'paste'}
- - {#if ingredientText} - - {/if} + +
{/if} {#if scanMode === 'camera'}
- {#if cameraState === 'idle' || cameraState === 'starting'} -
-

📷 {cameraState === 'starting' ? 'Starting camera…' : 'Tap to start camera'}

- {#if cameraState === 'idle'}{/if} -
- {:else if cameraState === 'active'} - -
- - -
+ {#if cameraState === 'starting'} +
⏳ Starting camera…
{:else if cameraState === 'error'} -

❌ {cameraError}

+
{cameraError}
+ {:else if cameraState === 'active'} + +
+ + +
+ {:else} +
+

📷 Point camera at ingredient list

+ +
{/if}
{/if} @@ -175,117 +176,132 @@ e.g.: Wheat Flour, E120, E471, Sugar, Vegetable Oil, E322…" {#if scanned}
- -
0} class:mushbooh={summary.haram === 0 && summary.mushbooh > 0} class:halal={summary.haram === 0 && summary.mushbooh === 0 && summary.halal > 0}> - {summary.verdict} -
- {#if summary.haram > 0}🚫 {summary.haram} Haram{/if} - {#if summary.mushbooh > 0}⚠ {summary.mushbooh} Doubtful{/if} - {#if summary.halal > 0}✅ {summary.halal} Halal{/if} - {#if summary.unknown > 0}❓ {summary.unknown} Unknown{/if} -
-
- - -
- {#each results as item (item.id)} -
-
- {statusEmoji(item.ingredient?.status || 'unknown')} - - {item.raw} - {#if item.eNumber}({item.eNumber}){/if} - -
- {#if item.ingredient} -
-

Category: {item.ingredient.category}

-

{item.ingredient.explanation}

-

📚 {item.ingredient.scholarlyNote}

-
- {:else} -

Not found in our database. Check JAKIM portal or MUI.

- {/if} + {#if results.length > 0} + {@const s = summary()} +
0} class:mushbooh={s.mushbooh > 0 && s.haram === 0} class:halal={s.haram === 0 && s.mushbooh === 0 && s.halal > 0}> +
{s.verdict}
+
+ {s.haram} 🚫 + {s.mushbooh} ⚠️ + {s.halal} ✅ + {s.unknown} ❓
- {/each} -
+
+ +
+ {#each results as r (r.id)} +
+
+ {statusEmoji(r.ingredient?.status)} +
+ {r.raw}{#if r.eNumber} {r.eNumber}{/if} + {#if r.ingredient} +
Category: {r.ingredient.category}
+
{r.ingredient.explanation}
+ {#if r.ingredient.scholarlyNote} +
{r.ingredient.scholarlyNote}
+ {/if} + {:else} +
+ ❓ Not in database. Check FDA GRAS list or consult a scholar. +
+ {/if} +
+
+
+ {/each} +
+ {:else} +

No ingredients parsed. Please enter ingredients to scan.

+ {/if}
{/if} {#if history.length > 0}
- 📜 Recent Scans ({history.length}) + 📜 Scan History ({history.length})
- {#each history as h} + {#each history as h (h.timestamp)}
+ Scanned {h.results} items {new Date(h.timestamp).toLocaleString()} - {h.results} ingredient{h.results !== 1 ? 's' : ''}
{/each} -
+
{/if}
+ .history-item { display: flex; justify-content: space-between; padding: 6px 8px; font-family: 'DM Sans', sans-serif; font-size: 0.7rem; color: rgba(232,228,220,0.55); border-bottom: 1px solid rgba(201,168,76,0.08); } + .history-date { font-family: 'JetBrains Mono', monospace; font-size: 0.6rem; color: rgba(232,228,220,0.3); } + .clear-btn { margin-top: 8px; padding: 4px 12px; background: none; border: 1px solid rgba(201,168,76,0.15); border-radius: 8px; font-family: 'JetBrains Mono', monospace; font-size: 0.6rem; color: rgba(232,228,220,0.3); cursor: pointer; } + \ No newline at end of file diff --git a/src/lib/IslamicCalendar.svelte b/src/lib/IslamicCalendar.svelte new file mode 100644 index 0000000..17ef45d --- /dev/null +++ b/src/lib/IslamicCalendar.svelte @@ -0,0 +1,338 @@ + + +
+

📅 Islamic Events

+ + {#if loading} +

⏳ Loading…

+ {:else if error} +
Could not load calendar data. Check your connection.
+ {:else if hijriDate} + +
+
+ {hijriDate.day} + {hijriDate.month.en} {hijriDate.year} + {gregorianToday} +
+
+ + +
+ {#each upcomingEvents as event (event.name)} +
+ {event.icon} +
+ {event.name} + {event.hijriDateStr} + {#if event.gregDateStr} + {formatGreg(event.gregDateStr)} + {/if} +
+ + {daysLabel(event.daysUntil)} + +
+ {/each} +
+ {/if} +
+ + diff --git a/src/lib/MosqueFinder.svelte b/src/lib/MosqueFinder.svelte new file mode 100644 index 0000000..a207f26 --- /dev/null +++ b/src/lib/MosqueFinder.svelte @@ -0,0 +1,328 @@ + + +
+

🕌 Mosque Finder

+ {#if loading} +

📍 Getting your location…

+ {:else if error} +

{error}

+ + {:else} +
+
+ + +
+
+ Radius: +
+ {#each radiusOptions as r} + + {/each} +
+
+
+ {#if searching} +

🔍 Searching for mosques…

+ {:else} +
{filtered.length} mosque{filtered.length !== 1 ? 's' : ''} within {radius}km
+ {#if viewMode === 'list'} + {#if filtered.length === 0} +

🕌 No mosques found

Try increasing radius or search area

+ {:else} +
+ {#each filtered as m, i} +
+ + {#if expandedId === m.id} +
+
📍 {m.address}
+ 🗺️ Open in Google Maps ({m.osmLat.toFixed(4)}, {m.osmLng.toFixed(4)}) + {#if m.extras.length > 0} +
+ {#each m.extras as e} +
+ {e.label} + {e.value} +
+ {/each} +
+ {/if} +
+ {/if} +
+ {/each} +
+ {/if} + {:else} +
+ {/if} + {/if} + {/if} +
+ + diff --git a/src/lib/Names99.svelte b/src/lib/Names99.svelte index 59cdae1..50b4dae 100644 --- a/src/lib/Names99.svelte +++ b/src/lib/Names99.svelte @@ -122,19 +122,13 @@ />
- {#each filtered as n} - {/each} @@ -145,14 +139,17 @@ .search-input { width: 100%; padding: 10px 14px; - border: 1px solid #99d6c9; + background: #111820; + border: 1px solid rgba(201,168,76,0.2); border-radius: 10px; + font-family: 'DM Sans', sans-serif; font-size: 0.9rem; margin-bottom: 12px; - color: #134e4a; + color: #E8E4DC; outline: none; } - .search-input:focus { border-color: #0f766e; } + .search-input:focus { border-color: #C9A84C; box-shadow: 0 0 0 2px rgba(201,168,76,0.1); } + .search-input::placeholder { color: rgba(232,228,220,0.3); } .names-grid { display: flex; flex-direction: column; @@ -164,36 +161,38 @@ display: flex; gap: 12px; padding: 10px 12px; - border: none; - background: #f0fdfa; + border: 1px solid rgba(201,168,76,0.1); + background: #111820; border-radius: 10px; cursor: pointer; text-align: left; align-items: center; - transition: background 0.15s; + transition: border-color 0.15s, background 0.15s; } - .name-card:active { background: #ccfbf1; } + .name-card:hover { border-color: #C9A84C; } + .name-card:active { background: rgba(201,168,76,0.08); } .name-no { - background: #0f766e; - color: white; + background: #C9A84C; + color: #070A0D; width: 28px; height: 28px; border-radius: 50%; display: flex; align-items: center; justify-content: center; + font-family: 'Cinzel', serif; font-size: 0.7rem; - font-weight: 600; + font-weight: 900; flex-shrink: 0; } .name-body { flex: 1; display: flex; flex-direction: column; gap: 2px; } .name-ar { font-size: 1rem; - color: #134e4a; + color: #C9A84C; font-weight: 600; direction: rtl; text-align: right; } - .name-en { font-size: 0.8rem; color: #0f766e; font-weight: 500; } - .name-meaning { font-size: 0.75rem; color: #5b8c85; margin-top: 2px; } - + .name-en { font-size: 0.82rem; color: #E8E4DC; font-weight: 500; } + .name-meaning { font-size: 0.75rem; color: rgba(232,228,220,0.55); margin-top: 2px; } + \ No newline at end of file diff --git a/src/lib/PrayerTimes.svelte b/src/lib/PrayerTimes.svelte index 7839e3d..c45b3bd 100644 --- a/src/lib/PrayerTimes.svelte +++ b/src/lib/PrayerTimes.svelte @@ -7,6 +7,89 @@ let error = $state(''); let method = $state(3); // Muslim World League let geoLoading = $state(true); + let notificationsEnabled = $state(false); + let adhanEnabled = $state(false); + let notificationPermission = $state('default'); + let timeoutIds = []; + let audio = null; + + const prayerOrder = ['Fajr', 'Sunrise', 'Dhuhr', 'Asr', 'Maghrib', 'Isha']; + const notifPrayerNames = ['Fajr', 'Dhuhr', 'Asr', 'Maghrib', 'Isha']; + + function clearTimeouts() { + for (const id of timeoutIds) clearTimeout(id); + timeoutIds = []; + } + + function stopAdhan() { + if (audio) { audio.pause(); audio.currentTime = 0; } + } + + function scheduleNotifications() { + clearTimeouts(); + if (!prayers || !notificationsEnabled || notificationPermission !== 'granted') return; + + for (const name of notifPrayerNames) { + const [h, m] = prayers[name].split(':').map(Number); + const prayerTime = new Date(); + prayerTime.setHours(h, m, 0, 0); + if (prayerTime <= new Date()) prayerTime.setDate(prayerTime.getDate() + 1); + + const msUntil = Math.max(0, prayerTime.getTime() - Date.now()); + const id = setTimeout(() => fireNotification(name), msUntil); + timeoutIds.push(id); + } + } + + function fireNotification(name) { + const idx = notifPrayerNames.indexOf(name); + let nextName, nextTime; + if (idx === -1 || idx >= notifPrayerNames.length - 1) { + nextName = 'Fajr'; + nextTime = prayers['Fajr']; + } else { + nextName = notifPrayerNames[idx + 1]; + nextTime = prayers[nextName]; + } + + try { + const n = new Notification('🕌 Time for ' + name, { + body: "It's time for " + name + ' prayer. ' + nextName + ' is at ' + nextTime, + icon: '/icon-192.png', + badge: '/favicon.svg', + requireInteraction: true, + }); + if (adhanEnabled) { + playAdhan(); + setTimeout(stopAdhan, 30000); + n.addEventListener('click', stopAdhan, { once: true }); + } + } catch (_) { /* notification may fail silently */ } + } + + function playAdhan() { + if (!audio) { + audio = new Audio('https://www.masjidalkhair.com/wp-content/uploads/2015/10/adhan.mp3'); + audio.preload = 'auto'; + } + audio.currentTime = 0; + audio.play().catch(() => {}); + } + + async function handleNotifToggle() { + notificationsEnabled = !notificationsEnabled; + if (!notificationsEnabled) { + clearTimeouts(); + stopAdhan(); + return; + } + if (!('Notification' in window)) { notificationPermission = 'denied'; return; } + if (Notification.permission === 'denied') { notificationPermission = 'denied'; return; } + if (Notification.permission === 'granted') { notificationPermission = 'granted'; scheduleNotifications(); return; } + const result = await Notification.requestPermission(); + notificationPermission = result; + if (result === 'granted') scheduleNotifications(); + } const methods = [ { id: 3, name: 'Muslim World League' }, @@ -75,15 +158,25 @@ } $effect(() => { getLocation(); }); + + // Re-schedule when prayers, notification state, or permission changes + $effect(() => { + if (prayers && notificationsEnabled && notificationPermission === 'granted') { + scheduleNotifications(); + } else if (timeoutIds.length > 0) { + clearTimeouts(); + } + return () => { clearTimeouts(); if (audio) { audio.pause(); audio.src = ''; } }; + });

🕌 Prayer Times

{#if geoLoading} -

📍 Getting location…

+

📍 Getting location…

{:else if loading} -

⏳ Loading prayer times…

+

⏳ Loading prayer times…

{:else if error}

{error}

@@ -112,13 +205,37 @@ {/each}
+ +
+
+ 🔔 Prayer Notifications + +
+ {#if notificationsEnabled} +
+ 🔊 Adhan Audio + +
+ {/if} + {#if notificationPermission === 'denied'} +

Notifications blocked. Enable in browser site settings (lock icon next to URL).

+ {:else if notificationPermission === 'granted'} +

✓ Prayer notifications active

+ {/if} +
{/if}
+ .notification-settings { + background: #111820; + border: 1px solid rgba(201,168,76,0.2); + border-radius: 12px; + padding: 12px 16px; + margin-top: 14px; + } + .toggle-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 4px 0; + } + .toggle-row + .toggle-row { + margin-top: 4px; + } + .toggle-label { + font-family: 'JetBrains Mono', monospace; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.5px; + color: rgba(232,228,220,0.55); + } + .toggle-switch { + position: relative; + display: inline-block; + width: 40px; + height: 22px; + flex-shrink: 0; + } + .toggle-switch input { + opacity: 0; + width: 0; + height: 0; + } + .toggle-slider { + position: absolute; + cursor: pointer; + inset: 0; + background: #2a2a2a; + border-radius: 22px; + transition: background 0.25s; + } + .toggle-slider::before { + content: ''; + position: absolute; + height: 16px; + width: 16px; + left: 3px; + bottom: 3px; + background: #E8E4DC; + border-radius: 50%; + transition: transform 0.25s; + } + .toggle-switch input:checked + .toggle-slider { + background: #C9A84C; + } + .toggle-switch input:checked + .toggle-slider::before { + transform: translateX(18px); + } + .hint-text { + margin: 8px 0 0 0; + font-family: 'DM Sans', sans-serif; + font-size: 0.7rem; + color: rgba(232,228,220,0.45); + } + \ No newline at end of file diff --git a/src/lib/Qibla.svelte b/src/lib/Qibla.svelte index d0bb283..5e76a18 100644 --- a/src/lib/Qibla.svelte +++ b/src/lib/Qibla.svelte @@ -54,7 +54,7 @@

🧭 Qibla Finder

{#if loading} -

📍 Getting location…

+

📍 Getting location…

{:else if error}

{error}

{:else} @@ -62,7 +62,7 @@
- +
@@ -87,8 +87,9 @@ width: 160px; height: 160px; border-radius: 50%; - background: #f0fdfa; - border: 3px solid #0f766e; + background: #0C1117; + border: 3px solid #C9A84C; + box-shadow: 0 0 20px rgba(201,168,76,0.15), inset 0 0 20px rgba(201,168,76,0.05); display: flex; align-items: center; justify-content: center; @@ -104,7 +105,25 @@ flex-direction: column; align-items: center; } - .bearing-num { font-size: 2rem; font-weight: 700; color: #0f766e; } - .bearing-dir { font-size: 0.8rem; color: #5b8c85; } - .compass-hint { font-size: 0.75rem; color: #94a3a8; text-align: center; } - + .bearing-num { + font-family: 'Cinzel', serif; + font-size: 2rem; + font-weight: 900; + color: #C9A84C; + text-shadow: 0 0 12px rgba(201,168,76,0.3); + } + .bearing-dir { + font-family: 'JetBrains Mono', monospace; + font-size: 0.8rem; + font-weight: 300; + color: rgba(232,228,220,0.55); + text-transform: uppercase; + letter-spacing: 1px; + } + .compass-hint { + font-family: 'DM Sans', sans-serif; + font-size: 0.75rem; + color: rgba(232,228,220,0.3); + text-align: center; + } + \ No newline at end of file diff --git a/src/lib/Quran.svelte b/src/lib/Quran.svelte index 08fe843..688c209 100644 --- a/src/lib/Quran.svelte +++ b/src/lib/Quran.svelte @@ -2,9 +2,18 @@ let surahs = $state([]); let selectedSurah = $state(null); let ayahs = $state([]); + let arabicAyahs = $state([]); let loading = $state(true); let error = $state(''); + let showArabic = $state(true); + let showTranslation = $state(true); + let autoPlay = $state(false); + + let currentAudioIndex = $state(-1); + let isPlaying = $state(false); + let audioElement = $state(null); + async function loadSurahs() { try { const res = await fetch('https://api.alquran.cloud/v1/surah'); @@ -19,12 +28,25 @@ async function loadSurah(num) { loading = true; + error = ''; + stopAudio(); try { - const res = await fetch(`https://api.alquran.cloud/v1/surah/${num}/en.asad`); - const data = await res.json(); - if (data.code === 200) { - selectedSurah = { num, name: data.data.englishName, english: data.data.englishNameTranslation, type: data.data.revelationType }; - ayahs = data.data.ayahs; + const [enRes, arRes] = await Promise.all([ + fetch(`https://api.alquran.cloud/v1/surah/${num}/en.asad`), + fetch(`https://api.alquran.cloud/v1/surah/${num}/ar.alafasy`) + ]); + const [enData, arData] = await Promise.all([enRes.json(), arRes.json()]); + if (enData.code === 200 && arData.code === 200) { + selectedSurah = { + num, + name: enData.data.englishName, + english: enData.data.englishNameTranslation, + type: enData.data.revelationType + }; + ayahs = enData.data.ayahs; + arabicAyahs = arData.data.ayahs; + } else { + error = 'Failed to load surah data'; } loading = false; } catch (e) { @@ -33,9 +55,65 @@ } } + function playAyah(index) { + if (audioElement) { + audioElement.pause(); + audioElement = null; + } + if (!arabicAyahs[index]?.audio) return; + const audio = new Audio(arabicAyahs[index].audio); + audioElement = audio; + currentAudioIndex = index; + audio.play().then(() => { + isPlaying = true; + }).catch(() => { + isPlaying = false; + }); + audio.onended = () => { + if (autoPlay && currentAudioIndex < arabicAyahs.length - 1) { + playAyah(currentAudioIndex + 1); + } else { + isPlaying = false; + currentAudioIndex = -1; + audioElement = null; + } + }; + } + + function pauseAudio() { + if (audioElement) { + audioElement.pause(); + isPlaying = false; + } + } + + function toggleAudio(index) { + if (currentAudioIndex === index && isPlaying) { + pauseAudio(); + } else { + playAyah(index); + } + } + + function stopAudio() { + if (audioElement) { + audioElement.pause(); + audioElement = null; + } + currentAudioIndex = -1; + isPlaying = false; + } + function goBack() { + stopAudio(); selectedSurah = null; ayahs = []; + arabicAyahs = []; + } + + function currentAyahLabel() { + if (currentAudioIndex < 0 || !selectedSurah) return ''; + return `${selectedSurah.name} — Ayah ${currentAudioIndex + 1}`; } $effect(() => { loadSurahs(); }); @@ -45,7 +123,7 @@

📖 Holy Quran

{#if loading} -

⏳ Loading…

+

⏳ Loading…

{:else if error}

{error}

{:else if selectedSurah} @@ -54,21 +132,69 @@

{selectedSurah.name} ({selectedSurah.english})

{selectedSurah.type}
+ +
+ + + +
+
- {#each ayahs as ayah} -
- {ayah.numberInSurah} -

{ayah.text}

+ {#each ayahs as ayah, i} +
+
+ {ayah.numberInSurah} + +
+ {#if showArabic && arabicAyahs[i]} +

{arabicAyahs[i].text}

+ {/if} + {#if showTranslation} +

{ayah.text}

+ {/if}
{/each}
+ + {#if currentAudioIndex >= 0} +
+ + {currentAyahLabel()} + +
+ {/if} {:else}
- {#each surahs as s} - {/each}
@@ -79,65 +205,234 @@ .surah-grid { display: flex; flex-direction: column; - gap: 4px; - max-height: 60vh; - overflow-y: auto; + gap: 6px; } - .surah-btn { + .surah-card { display: flex; align-items: center; gap: 12px; padding: 12px 14px; - border: none; - background: #f0fdfa; + background: #111820; + border: 1px solid rgba(201,168,76,0.2); border-radius: 10px; cursor: pointer; text-align: left; + transition: border-color 0.15s; + } + .surah-card:hover { + border-color: #C9A84C; + } + .surah-num { + background: #C9A84C; + color: #070A0D; + width: 28px; + height: 28px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-family: 'Cinzel', serif; + font-size: 0.75rem; + font-weight: 900; + flex-shrink: 0; + } + .surah-name { + flex: 1; + font-family: 'DM Sans', sans-serif; font-size: 0.9rem; + font-weight: 500; + color: #E8E4DC; + } + .surah-arabic { + font-size: 1.1rem; + color: #C9A84C; + direction: rtl; + text-align: right; + } + .surah-header { + text-align: center; + margin-bottom: 16px; + } + .back-btn { + background: none; + border: 1px solid rgba(201,168,76,0.3); + color: #C9A84C; + padding: 6px 14px; + border-radius: 8px; + font-family: 'JetBrains Mono', monospace; + font-size: 0.75rem; + cursor: pointer; + margin-bottom: 8px; + } + .surah-header h3 { + font-family: 'Cinzel', serif; + font-size: 1.1rem; + font-weight: 600; + color: #C9A84C; + margin: 8px 0 4px; + } + .surah-type { + font-family: 'JetBrains Mono', monospace; + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 1px; + color: #2ECC71; + border: 1px solid rgba(46,204,113,0.2); + padding: 2px 8px; + border-radius: 4px; + } + .ayah-list { + display: flex; + flex-direction: column; + gap: 8px; + } + .ayah { + padding: 12px; + background: #111820; + border: 1px solid rgba(201,168,76,0.1); + border-radius: 10px; + transition: border-color 0.2s, background 0.2s; + } + .ayah.ayah-active { + border-color: #C9A84C; + background: rgba(201,168,76,0.06); + } + .ayah-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; + } + .ayah-num { + display: inline-block; + background: rgba(201,168,76,0.15); + color: #C9A84C; + width: 22px; + height: 22px; + border-radius: 50%; + text-align: center; + line-height: 22px; + font-family: 'JetBrains Mono', monospace; + font-size: 0.65rem; + font-weight: 400; + } + .play-btn { + background: none; + border: 1px solid rgba(201,168,76,0.3); + color: #C9A84C; + width: 24px; + height: 24px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + padding: 0; + font-size: 0.5rem; transition: background 0.15s; } - .surah-btn:active { background: #ccfbf1; } - .surah-num { - background: #0f766e; - color: white; + .play-btn:hover { + background: rgba(201,168,76,0.15); + } + .play-icon { + line-height: 1; + } + .ayah-arabic { + font-family: 'Amiri', 'Noto Naskh Arabic', serif; + font-size: 1.35rem; + line-height: 2; + color: #C9A84C; + direction: rtl; + text-align: right; + margin: 0 0 6px; + word-spacing: 0.1em; + } + .ayah-text { + font-size: 0.9rem; + line-height: 1.7; + color: rgba(232,228,220,0.85); + } + .view-toggles { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-bottom: 14px; + padding: 10px 14px; + background: #111820; + border: 1px solid rgba(201,168,76,0.12); + border-radius: 10px; + } + .toggle-label { + display: flex; + align-items: center; + gap: 6px; + font-family: 'DM Sans', sans-serif; + font-size: 0.78rem; + color: rgba(232,228,220,0.75); + cursor: pointer; + user-select: none; + } + .toggle-label input[type="checkbox"] { + accent-color: #C9A84C; + width: 14px; + height: 14px; + cursor: pointer; + } + .audio-player { + display: flex; + align-items: center; + gap: 10px; + position: sticky; + bottom: 0; + margin-top: 12px; + padding: 10px 14px; + background: #111820; + border: 1px solid rgba(201,168,76,0.3); + border-top: 2px solid #C9A84C; + border-radius: 10px 10px 0 0; + } + .player-btn { + background: none; + border: 1px solid #C9A84C; + color: #C9A84C; width: 32px; height: 32px; border-radius: 50%; display: flex; align-items: center; justify-content: center; - font-size: 0.75rem; - font-weight: 600; - flex-shrink: 0; - } - .surah-name { font-weight: 600; color: #134e4a; flex: 1; } - .surah-ayahs { font-size: 0.7rem; color: #5b8c85; } - .surah-header { margin-bottom: 12px; } - .surah-header h3 { font-size: 1.1rem; color: #0f766e; margin: 4px 0; } - .surah-type { font-size: 0.7rem; color: #5b8c85; background: #ccfbf1; padding: 2px 8px; border-radius: 4px; } - .back-btn { - border: none; - background: none; - color: #0f766e; - font-weight: 600; cursor: pointer; - font-size: 0.85rem; - padding: 4px 0; - } - .ayah-list { max-height: 60vh; overflow-y: auto; } - .ayah { - display: flex; - gap: 12px; - padding: 10px 0; - border-bottom: 1px solid #e6f5f0; - } - .ayah-num { - color: #0f766e; - font-weight: 600; - font-size: 0.8rem; - min-width: 24px; - text-align: center; + padding: 0; + font-size: 0.65rem; + transition: background 0.15s; flex-shrink: 0; } - .ayah-text { font-size: 0.9rem; line-height: 1.6; color: #134e4a; } + .player-btn:hover { + background: rgba(201,168,76,0.15); + } + .audio-info { + flex: 1; + font-family: 'DM Sans', sans-serif; + font-size: 0.8rem; + color: rgba(232,228,220,0.75); + } + .player-close { + background: none; + border: 1px solid rgba(225,29,72,0.3); + color: #e11d48; + width: 24px; + height: 24px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + padding: 0; + font-size: 0.6rem; + transition: background 0.15s; + flex-shrink: 0; + } + .player-close:hover { + background: rgba(225,29,72,0.15); + } diff --git a/src/lib/Support.svelte b/src/lib/Support.svelte new file mode 100644 index 0000000..fdbf239 --- /dev/null +++ b/src/lib/Support.svelte @@ -0,0 +1,583 @@ + + + + +
+ {#if state === 'completed'} + +
+
+ + + + +
+

Jazakallah khair!

+

May Allah accept your sadaqah and multiply your reward. Ameen.

+ {#if lastDonation} +
+ 🌱 Sadaqah for Nur Falah + {centsToRm(lastDonation.amount)} +
+ {/if} +
+ + +
+ +
+ + {:else if state === 'processing'} + +
+
+

Opening Checkout...

+

Complete your payment in the popup window.

+ + +
+ + {:else} + +
+ + +
+ + {#if activeTab === 'bank-trustee'} + +
+
🏛️
+

Give with Confidence

+

Your zakat, sadaqah, and waqf flow through our regulated bank trustee partner — Shariah audited, fully transparent.

+
+ +
+ + + + +
switchTab(8)} onkeydown={(e) => e.key === 'Enter' && switchTab(8)} role="button" tabindex="0"> +
💰
+

Calculate & Pay Zakat

+

Use our Zakat Calculator then pay directly through our bank trustee.

+
+ 📋 Live nisab rates + 🧾 Tax-relief receipt +
+ Calculate Now → +
+ + +
+
🌱
+

Sadaqah Micro-Giving

+

One-tap giving after each prayer. Coming soon.

+
+ ⏳ In development +
+
+ + +
+
🎁
+

Hibah Planning

+

Islamic gift planning with bank custody. Coming soon.

+
+ ⏳ In development +
+
+
+ + +
+

Why a Bank Trustee?

+
+
+ 🔒 + BNM-regulated + Funds held by a licensed Islamic bank +
+
+ 📜 + Shariah-audited + Bank's Shariah committee approves all investments +
+
+ 📊 + Transparent + Per-user dashboard showing every movement +
+
+ ♾️ + Perpetual + Principal never spent — only returns benefit +
+
+
+ + {:else} + +
+
+
🌱
+

Support Nur Falah

+

This app is free forever — sadaqah jariyah for all. Your support keeps the servers running, the features growing, and the prayer times accurate. Every contribution goes directly to app development.

+
+ +
+
+
+
+ 🌱 +

Sadaqah for Nur Falah

+
+

Your contribution keeps this app running. Every prayer, every ayah recited — you're part of that reward.

+ +
+ {#each presets as amt} + + {/each} + +
+ + {#if !sadaqahAmount && !customAmount} +

Select an amount above

+ {/if} + + {#if customAmount !== '' && !sadaqahAmount} + + {/if} + +
+ + +
+ +
+ +
+
+
+ + + {#if getStats().totalDonors > 0} + {@const stats = getStats()} +
showStats = !showStats} onkeydown={(e) => e.key === 'Enter' && (showStats = !showStats)} role="button" tabindex="0"> +
+ Community Impact + {showStats ? '▲' : '▼'} +
+ {#if showStats} +
+
+ 💚 + {stats.totalDonors} + Total Donors +
+
+ 🌱 + {centsToRm(stats.totalRaised)} + Total Raised +
+
+ {/if} +
+ {/if} + +
+

🙏 Nur Falah is free forever as sadaqah jariyah. If you'd like to give zakat or set up a perpetual waqf, use the tab above.

+
+
+ {/if} + {/if} +
+ + diff --git a/src/lib/Tasbih.svelte b/src/lib/Tasbih.svelte index 7d3236f..132c812 100644 --- a/src/lib/Tasbih.svelte +++ b/src/lib/Tasbih.svelte @@ -34,17 +34,17 @@
e.key === 'Enter' && increment()}> - + - {count} - / {target} + {count} + / {target}
@@ -83,7 +83,18 @@ user-select: none; } .counter-ring:active { transform: scale(0.97); transition: transform 0.1s; } - .total-line { font-size: 0.9rem; color: #5b8c85; } + .total-line { + font-family: 'JetBrains Mono', monospace; + font-size: 0.85rem; + font-weight: 300; + color: rgba(232,228,220,0.55); + } + .total-line strong { + font-family: 'Cinzel', serif; + font-weight: 900; + color: #C9A84C; + font-size: 1.1rem; + } .controls { display: flex; gap: 12px; @@ -93,14 +104,20 @@ display: flex; gap: 6px; align-items: center; - font-size: 0.8rem; - color: #5b8c85; + font-family: 'JetBrains Mono', monospace; + font-size: 0.75rem; + font-weight: 300; + color: rgba(232,228,220,0.55); } .target-select select { - border: 1px solid #99d6c9; + background: #111820; + color: #E8E4DC; + border: 1px solid rgba(201,168,76,0.2); border-radius: 6px; - padding: 4px 8px; - color: #0f766e; + padding: 6px 10px; + font-family: 'DM Sans', sans-serif; + font-size: 0.8rem; + outline: none; } - .hint { font-size: 0.7rem; color: #94a3a8; } - + .hint { font-family: 'DM Sans', sans-serif; font-size: 0.7rem; color: rgba(232,228,220,0.3); } + \ No newline at end of file diff --git a/src/lib/WorshipTracker.svelte b/src/lib/WorshipTracker.svelte new file mode 100644 index 0000000..c091f51 --- /dev/null +++ b/src/lib/WorshipTracker.svelte @@ -0,0 +1,407 @@ + + +
+

📊 Worship Tracker

+ +
+
{storageData.streak > 0 ? '🔥' : '🌱'}
+
{storageData.streak}
+
day streak
+
{streakMessage}
+
🏆 Best: {storageData.bestStreak} days
+
+ +
+
Today's Progress
+
+
+
+
{completedCount} / {worshipItems.length}
+
+ +
+ {#each worshipItems as item} + {@const checked = isChecked(item.id)} + + {/each} +
+ +
+ +
+ {#each getLast7Days() as day} +
+ {new Date(day + 'T12:00:00').toLocaleDateString('en', { weekday: 'short' })} + {new Date(day + 'T12:00:00').getDate()} +
+ {/each} +
+
+ +
+ +
+ {#each getMonthDays() as day} + {#if day} +
+ {new Date(day + 'T12:00:00').getDate()} +
+ {:else} +
+ {/if} + {/each} +
+
+ + {#if !showResetConfirm} + + {:else} +
+

Reset all tracking data? (Best streak preserved)

+
+ + +
+
+ {/if} +
+ + diff --git a/src/lib/ZakatCalc.svelte b/src/lib/ZakatCalc.svelte new file mode 100644 index 0000000..a548d55 --- /dev/null +++ b/src/lib/ZakatCalc.svelte @@ -0,0 +1,474 @@ + + +
+

💰 Zakat Calculator

+ + +
+
+ Gold + + {#if loadingPrices} + + {:else} + {currency(goldPrice ?? 0)}/g + {/if} + +
+
+
+ Silver + + {#if loadingPrices} + + {:else} + {currency(silverPrice ?? 0)}/g + {/if} + +
+ +
+ + {#if pricesError} +
Using fallback prices — could not fetch live rates
+ {/if} + + +
+
+ Gold Nisab (85g) + {loadingPrices ? '⟳' : currency(goldNisab)} +
+
+ Silver Nisab (595g) + {loadingPrices ? '⟳' : currency(silverNisab)} +
+
+ Effective Nisab + {loadingPrices ? '⟳' : currency(effectiveNisab)} +
+
+ + +
+
+ +
+ + g +
+
{currency(goldValue)}
+
+ +
+ +
+ + g +
+
{currency(silverValue)}
+
+ +
+ +
+ $ + +
+
+ +
+ +
+ $ + +
+
+ +
+ +
+ $ + +
+
+
+ + +
+ Total Wealth + {currency(totalWealth)} +
+ + + {#if goldPrice !== null && silverPrice !== null} +
+ {#if zakatDue} +
+
Zakat Due
+
{currency(zakatAmount)}
+
+ {gramsStr(goldG)} gold @ {currency(goldPrice)}/g = {currency(goldValue)} + {gramsStr(silverG)} silver @ {currency(silverPrice)}/g = {currency(silverValue)} + Cash: {currency(cash)} + Stocks/Business: {currency(stocks)} + Other Assets: {currency(other)} + Zakat is {ZAKAT_RATE * 100}% of {currency(totalWealth)} +
+ {:else} +
📋
+
Below Nisab
+
{currency(shortfall)}
+
You are {currency(shortfall)} short of the nisab threshold of {currency(effectiveNisab)}. Zakat is not due yet.
+
+ Current wealth: {currency(totalWealth)} + Nisab: {currency(effectiveNisab)} +
+ {/if} +
+ {/if} +
+ + diff --git a/vite.config.js b/vite.config.js index b5678e0..7face2d 100644 --- a/vite.config.js +++ b/vite.config.js @@ -8,11 +8,11 @@ export default defineConfig({ VitePWA({ registerType: 'autoUpdate', manifest: { - name: 'Nūr — Muslim Companion', - short_name: 'Nūr', + name: 'Nur Falah — Muslim Companion', + short_name: 'Nur Falah', description: 'Prayer times, Quran, Qibla, and more — your daily Islamic companion', - theme_color: '#0f766e', - background_color: '#f0fdfa', + theme_color: '#070A0D', + background_color: '#070A0D', display: 'standalone', orientation: 'portrait-primary', icons: [