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:
@@ -2,3 +2,6 @@ node_modules/
|
||||
dist/
|
||||
.DS_Store
|
||||
*.log
|
||||
|
||||
# Local Netlify folder
|
||||
.netlify
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+16
-2
@@ -3,11 +3,25 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#0f766e" />
|
||||
<meta name="theme-color" content="#070A0D" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>Nūr — Muslim Companion</title>
|
||||
<title>Nur Falah — Muslim Companion</title>
|
||||
<link rel="preconnect" href="https://api.alquran.cloud" crossorigin />
|
||||
<link rel="preconnect" href="https://api.aladhan.com" crossorigin />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600;900&family=DM+Sans:opsz,wght@9..40,300;9..40,400;9..40,500&family=JetBrains+Mono:wght@300;400&family=Amiri:wght@400;700&family=Noto+Naskh+Arabic:wght@400;700&display=swap" rel="stylesheet" />
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #070A0D;
|
||||
color: #E8E4DC;
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
min-height: 100dvh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
[build]
|
||||
command = "npm run build"
|
||||
publish = "dist"
|
||||
|
||||
[[redirects]]
|
||||
from = "/*"
|
||||
to = "/index.html"
|
||||
status = 200
|
||||
Generated
-42
@@ -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": [
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.1 KiB |
+217
-28
@@ -7,24 +7,47 @@
|
||||
import Names99 from './lib/Names99.svelte';
|
||||
import HalalFoodFinder from './lib/HalalFoodFinder.svelte';
|
||||
import IngredientScanner from './lib/IngredientScanner.svelte';
|
||||
import ZakatCalc from './lib/ZakatCalc.svelte';
|
||||
import IslamicCalendar from './lib/IslamicCalendar.svelte';
|
||||
import MosqueFinder from './lib/MosqueFinder.svelte';
|
||||
import DuaLibrary from './lib/DuaLibrary.svelte';
|
||||
import HifdhTracker from './lib/HifdhTracker.svelte';
|
||||
import WorshipTracker from './lib/WorshipTracker.svelte';
|
||||
import Support, { getSupportStats } from './lib/Support.svelte';
|
||||
import BirNur from './lib/BirNur.svelte';
|
||||
|
||||
const tabs = ['Prayer', 'Qibla', 'Quran', 'Hijri', 'Tasbih', 'Names', 'Food', 'Scan'];
|
||||
const icons = ['🕌', '🧭', '📖', '📅', '📿', '💚', '🍽️', '🔍'];
|
||||
const tabs = ['Prayer', 'Qibla', 'Quran', 'Hijri', 'Tasbih', 'Names', 'Food', 'Scan', 'Zakat', 'Events', 'Mosques', 'Dua', 'Hifdh', 'Track', 'Waqf', 'Support'];
|
||||
const icons = ['🕌', '🧭', '📖', '📅', '📿', '💚', '🍽️', '🔍', '💰', '🗓️', '🕌', '🤲', '📖', '📊', '⛲', '🤲'];
|
||||
const premiumTabs = new Set([6, 7]);
|
||||
let activeTab = $state(0);
|
||||
|
||||
function handleSwitchTab(e) {
|
||||
activeTab = e.detail;
|
||||
}
|
||||
|
||||
function handleKeydown(e) {
|
||||
if (e.key === 'ArrowRight') activeTab = (activeTab + 1) % tabs.length;
|
||||
if (e.key === 'ArrowLeft') activeTab = (activeTab - 1 + tabs.length) % tabs.length;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
<svelte:window onkeydown={handleKeydown} onswitch-tab={handleSwitchTab} />
|
||||
|
||||
<div class="app">
|
||||
<header>
|
||||
<h1>Nūr</h1>
|
||||
<span class="subtitle">Muslim Companion</span>
|
||||
<div class="header-brand">
|
||||
<div class="brand-line brand-line-first">Nur</div>
|
||||
<div class="brand-line brand-line-second">Falah</div>
|
||||
</div>
|
||||
<span class="header-tagline">MUSLIM COMPANION</span>
|
||||
<div class="header-divider"></div>
|
||||
<button class="support-float" onclick={() => activeTab = 15} aria-label="Support">
|
||||
<span class="support-float-icon">🤲</span>
|
||||
<span class="support-float-label">Support</span>
|
||||
{#if getSupportStats().totalDonors > 0}
|
||||
<span class="support-dot"></span>
|
||||
{/if}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<nav>
|
||||
@@ -58,6 +81,22 @@
|
||||
<HalalFoodFinder />
|
||||
{:else if activeTab === 7}
|
||||
<IngredientScanner />
|
||||
{:else if activeTab === 8}
|
||||
<ZakatCalc />
|
||||
{:else if activeTab === 9}
|
||||
<IslamicCalendar />
|
||||
{:else if activeTab === 10}
|
||||
<MosqueFinder />
|
||||
{:else if activeTab === 11}
|
||||
<DuaLibrary />
|
||||
{:else if activeTab === 12}
|
||||
<HifdhTracker />
|
||||
{:else if activeTab === 13}
|
||||
<WorshipTracker />
|
||||
{:else if activeTab === 14}
|
||||
<BirNur />
|
||||
{:else if activeTab === 15}
|
||||
<Support />
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
@@ -65,12 +104,35 @@
|
||||
<style>
|
||||
:global(*) { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:global(body) {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #f0fdfa;
|
||||
color: #134e4a;
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
background: #070A0D;
|
||||
color: #E8E4DC;
|
||||
min-height: 100dvh;
|
||||
overflow-x: hidden;
|
||||
position: relative;
|
||||
}
|
||||
:global(body::before) {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: 0; left: 0;
|
||||
width: 100%; height: 100%;
|
||||
background:
|
||||
radial-gradient(ellipse 80% 50% at 50% -20%, rgba(201,168,76,0.08) 0%, transparent 70%),
|
||||
radial-gradient(ellipse 60% 40% at 20% 80%, rgba(46,204,113,0.05) 0%, transparent 60%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
:global(body::after) {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: 0; left: 0;
|
||||
width: 100%; height: 100%;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.03'/%3E%3C/svg%3E");
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
:global(#app) { position: relative; z-index: 2; }
|
||||
|
||||
.app {
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
@@ -79,15 +141,55 @@
|
||||
flex-direction: column;
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
header {
|
||||
text-align: center;
|
||||
padding: 20px 16px 8px;
|
||||
background: linear-gradient(135deg, #0f766e, #0d9488);
|
||||
color: white;
|
||||
border-radius: 0 0 24px 24px;
|
||||
padding: 28px 16px 16px;
|
||||
background: rgba(12, 17, 23, 0.95);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid rgba(201,168,76,0.15);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
.header-brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
font-family: 'Cinzel', serif;
|
||||
line-height: 1;
|
||||
}
|
||||
.brand-line-first {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 400;
|
||||
color: #E8E4DC;
|
||||
letter-spacing: 8px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.brand-line-second {
|
||||
font-size: 2rem;
|
||||
font-weight: 900;
|
||||
color: #C9A84C;
|
||||
letter-spacing: 12px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.header-tagline {
|
||||
display: block;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 300;
|
||||
color: rgba(232,228,220,0.55);
|
||||
letter-spacing: 4px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.header-divider {
|
||||
width: 60%;
|
||||
height: 1px;
|
||||
margin: 12px auto 0;
|
||||
background: linear-gradient(90deg, transparent, #C9A84C, #2ECC71, #C9A84C, transparent);
|
||||
}
|
||||
header h1 { font-size: 1.8rem; font-weight: 700; }
|
||||
.subtitle { font-size: 0.8rem; opacity: 0.85; }
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
@@ -97,6 +199,7 @@
|
||||
-webkit-overflow-scrolling: touch;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
background: #0C1117;
|
||||
}
|
||||
.tab {
|
||||
display: flex;
|
||||
@@ -108,17 +211,21 @@
|
||||
background: transparent;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
font-size: 0.7rem;
|
||||
color: #5b8c85;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 300;
|
||||
text-transform: uppercase;
|
||||
color: rgba(232,228,220,0.3);
|
||||
letter-spacing: 0.5px;
|
||||
transition: all 0.2s;
|
||||
min-width: 56px;
|
||||
}
|
||||
.tab.active {
|
||||
background: #ccfbf1;
|
||||
color: #0f766e;
|
||||
font-weight: 600;
|
||||
background: rgba(201,168,76,0.15);
|
||||
color: #C9A84C;
|
||||
font-weight: 400;
|
||||
}
|
||||
.tab-icon { font-size: 1.3rem; }
|
||||
.tab-icon { font-size: 1.2rem; }
|
||||
.tab-label { white-space: nowrap; }
|
||||
|
||||
main {
|
||||
@@ -127,29 +234,111 @@
|
||||
}
|
||||
|
||||
:global(.card) {
|
||||
background: white;
|
||||
background: #141C24;
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
margin-bottom: 12px;
|
||||
box-shadow: 0 1px 3px rgba(15, 118, 110, 0.08);
|
||||
border: 1px solid rgba(201,168,76,0.2);
|
||||
}
|
||||
:global(.card h2) {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 1rem;
|
||||
color: #0f766e;
|
||||
font-weight: 600;
|
||||
color: #C9A84C;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
:global(button.primary) {
|
||||
background: linear-gradient(135deg, #0f766e, #0d9488);
|
||||
color: white;
|
||||
background: linear-gradient(135deg, #C9A84C, #E8C97A);
|
||||
color: #070A0D;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 10px;
|
||||
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: 600;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
:global(button.primary:active) { opacity: 0.85; }
|
||||
</style>
|
||||
: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;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,989 @@
|
||||
<script>
|
||||
// ── Bir Nur — Well of Light ──
|
||||
// Based on the waqf of Abdur Rahman bin Auf & the Well of Rumah of Uthman ibn Affan
|
||||
|
||||
const WELL_TYPES = [
|
||||
{
|
||||
id: 'date-palm',
|
||||
icon: '🌴',
|
||||
title: 'Date Palm Well',
|
||||
subtitle: 'Based on Abdur Rahman\'s orchard',
|
||||
desc: 'Invested in agricultural sukuk — growing food for the hungry, creating farm livelihoods.',
|
||||
color: '#2ECC71',
|
||||
beneficiaries: ['Orphans food', 'Farm families', 'Community iftar']
|
||||
},
|
||||
{
|
||||
id: 'water',
|
||||
icon: '💧',
|
||||
title: 'Water Well',
|
||||
subtitle: 'Inspired by Bir Rumah of Uthman',
|
||||
desc: 'Funds clean water projects — wells, filtration, sanitation. For every human and animal.',
|
||||
color: '#3B82F6',
|
||||
beneficiaries: ['Clean drinking water', 'Sanitation', 'Animal troughs']
|
||||
},
|
||||
{
|
||||
id: 'knowledge',
|
||||
icon: '📖',
|
||||
title: 'Knowledge Well',
|
||||
subtitle: 'The legacy of Masjid Nabawi',
|
||||
desc: 'Scholarships for Quran schools, vocational training, Islamic education.',
|
||||
color: '#C9A84C',
|
||||
beneficiaries: ['Quran students', 'Vocational training', 'Madrasah support']
|
||||
},
|
||||
{
|
||||
id: 'healing',
|
||||
icon: '🏥',
|
||||
title: 'Healing Well',
|
||||
subtitle: 'The Islamic bimaristan tradition',
|
||||
desc: 'Healthcare infrastructure — clinics, medicine, maternal health for underserved communities.',
|
||||
color: '#EF4444',
|
||||
beneficiaries: ['Medical clinics', 'Medicine for poor', 'Maternal health']
|
||||
},
|
||||
{
|
||||
id: 'shelter',
|
||||
icon: '🏠',
|
||||
title: 'Shelter Well',
|
||||
subtitle: 'Riba-free housing for all',
|
||||
desc: 'Shariah-compliant affordable housing, shelter for homeless families.',
|
||||
color: '#F59E0B',
|
||||
beneficiaries: ['Affordable housing', 'Emergency shelter', 'Home repairs']
|
||||
},
|
||||
{
|
||||
id: 'general',
|
||||
icon: '🤲',
|
||||
title: 'General Well',
|
||||
subtitle: 'Following Abdur Rahman\'s universal charity',
|
||||
desc: 'Diversified across all causes — maximum flexibility to meet urgent needs.',
|
||||
color: '#8B5CF6',
|
||||
beneficiaries: ['All causes balanced', 'Emergency response', 'Where most needed']
|
||||
}
|
||||
];
|
||||
|
||||
const CAUSES = [
|
||||
{ id: 'orphans', label: 'Orphans & Children', icon: '👶', default: 25 },
|
||||
{ id: 'education', label: 'Education & Quran', icon: '📖', default: 20 },
|
||||
{ id: 'water', label: 'Clean Water', icon: '💧', default: 15 },
|
||||
{ id: 'healthcare', label: 'Healthcare', icon: '🏥', default: 15 },
|
||||
{ id: 'masajid', label: 'Masajid & Community', icon: '🕌', default: 15 },
|
||||
{ id: 'debt', label: 'Debt Relief (Gharimin)', icon: '🤲', default: 10 }
|
||||
];
|
||||
|
||||
let step = $state('intro'); // intro | configure | preview | registered
|
||||
let selectedWell = $state('general');
|
||||
let waqfName = $state('');
|
||||
let waqfAmount = $state(100);
|
||||
let customAmount = $state('');
|
||||
let donorName = $state('');
|
||||
let donorEmail = $state('');
|
||||
let allocation = $state({});
|
||||
let registered = $state(false);
|
||||
let showPreview = $state(false);
|
||||
|
||||
// Historical toggle
|
||||
let showStory = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
// Initialize default allocations
|
||||
if (Object.keys(allocation).length === 0) {
|
||||
const alloc = {};
|
||||
CAUSES.forEach(c => { alloc[c.id] = c.default; });
|
||||
allocation = alloc;
|
||||
}
|
||||
});
|
||||
|
||||
// Derived
|
||||
const well = $derived(WELL_TYPES.find(w => w.id === selectedWell));
|
||||
const totalAlloc = $derived(Object.values(allocation).reduce((a, b) => a + b, 0));
|
||||
const allocValid = $derived(totalAlloc === 100);
|
||||
const amountRM = $derived(customAmount ? parseFloat(customAmount) || 0 : waqfAmount);
|
||||
|
||||
// Simulated projections
|
||||
const annualReturn = $derived(amountRM * 0.05);
|
||||
const tenYearReturn = $derived(amountRM * 0.05 * 10);
|
||||
const fiftyYearReturn = $derived(amountRM * 1.05 ** 50 - amountRM);
|
||||
|
||||
function setWell(id) { selectedWell = id; }
|
||||
function goToConfig() { step = 'configure'; }
|
||||
function goToPreview() { step = 'preview'; }
|
||||
|
||||
function adjustAllocation(causeId, delta) {
|
||||
const current = allocation[causeId] || 0;
|
||||
const newVal = Math.max(0, Math.min(100, current + delta));
|
||||
allocation = { ...allocation, [causeId]: newVal };
|
||||
}
|
||||
|
||||
function autoBalance() {
|
||||
const even = Math.floor(100 / CAUSES.length);
|
||||
const remainder = 100 - even * CAUSES.length;
|
||||
const alloc = {};
|
||||
CAUSES.forEach((c, i) => { alloc[c.id] = even + (i < remainder ? 1 : 0); });
|
||||
allocation = alloc;
|
||||
}
|
||||
|
||||
function registerInterest() {
|
||||
if (!donorEmail) return;
|
||||
try {
|
||||
const prefs = JSON.parse(localStorage.getItem('birnur-interest') || '[]');
|
||||
prefs.push({
|
||||
waqfName: waqfName || `Sadaqah Jariyah for ${donorName || 'Anonymous'}`,
|
||||
amount: amountRM,
|
||||
wellType: selectedWell,
|
||||
donorName,
|
||||
donorEmail,
|
||||
allocation,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
localStorage.setItem('birnur-interest', JSON.stringify(prefs));
|
||||
registered = true;
|
||||
step = 'registered';
|
||||
} catch { step = 'registered'; }
|
||||
}
|
||||
|
||||
function currencyRM(n) {
|
||||
return 'RM ' + n.toFixed(2);
|
||||
}
|
||||
|
||||
function formatYear(years) {
|
||||
return years + ' year' + (years !== 1 ? 's' : '');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="birnur">
|
||||
{#if step === 'intro'}
|
||||
<!-- INTRO: The Story of Bir Nur -->
|
||||
<div class="hero-section">
|
||||
<div class="hero-icon">⛲</div>
|
||||
<h1 class="hero-title">Bir Nur</h1>
|
||||
<p class="hero-subtitle">بِئْر نُور — <em>Well of Light</em></p>
|
||||
<div class="hero-divider"></div>
|
||||
<p class="hero-quote">
|
||||
<em>"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."</em>
|
||||
<span class="quote-source">— Prophet Muhammad ﷺ (Muslim)</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Historical Scroll -->
|
||||
<div class="story-toggle" onclick={() => showStory = !showStory} onkeydown={(e) => e.key === 'Enter' && (showStory = !showStory)} role="button" tabindex="0">
|
||||
<span>{showStory ? '▼' : '▶'} The story behind Bir Nur</span>
|
||||
</div>
|
||||
|
||||
{#if showStory}
|
||||
<div class="story-card">
|
||||
<p><strong>Uthman ibn Affan and the Well of Rumah</strong></p>
|
||||
<p>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: <em>"Whoever buys the well of Rumah and donates it (as waqf), will have Paradise."</em></p>
|
||||
<p>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.</p>
|
||||
<div class="story-divider"></div>
|
||||
<p><strong>Abdur Rahman bin Auf and the Productive Garden</strong></p>
|
||||
<p>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.</p>
|
||||
<p>His waqf was productive — the garden kept growing, the charity kept flowing. This is the model of <em>waqf istithmari</em> (productive endowment).</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- How It Works -->
|
||||
<div class="how-section">
|
||||
<h2>How Bir Nur Works</h2>
|
||||
<div class="steps">
|
||||
<div class="step-card">
|
||||
<div class="step-num">1</div>
|
||||
<h3>Dig Your Well</h3>
|
||||
<p>Contribute any amount — RM 1 or RM 100,000. Your contribution is your well.</p>
|
||||
</div>
|
||||
<div class="step-arrow">→</div>
|
||||
<div class="step-card">
|
||||
<div class="step-num">2</div>
|
||||
<h3>Water Flows Forever</h3>
|
||||
<p>Your principal is never spent. It is invested in Shariah-compliant assets. The returns are the water.</p>
|
||||
</div>
|
||||
<div class="step-arrow">→</div>
|
||||
<div class="step-card">
|
||||
<div class="step-num">3</div>
|
||||
<h3>Quench Thirst Perpetually</h3>
|
||||
<p>The returns serve orphans, students, the sick, and the needy. Your reward continues as long as water flows.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Trustee Badge -->
|
||||
<div class="trustee-badge">
|
||||
<div class="trustee-icon">🏛️</div>
|
||||
<div class="trustee-text">
|
||||
<strong>Managed by a regulated bank trustee</strong>
|
||||
<p>Your waqf funds are held by a licensed Islamic bank — Shariah audited, BNM-regulated, transparent reporting.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Projected Impact -->
|
||||
<div class="impact-preview">
|
||||
<h2>Your One Gift, Forever Giving</h2>
|
||||
<div class="impact-grid">
|
||||
<div class="impact-item">
|
||||
<span class="impact-label">Your contribution</span>
|
||||
<span class="impact-value">{currencyRM(amountRM)}</span>
|
||||
</div>
|
||||
<div class="impact-item">
|
||||
<span class="impact-label">Annual returns (≈5%)</span>
|
||||
<span class="impact-value highlight">{currencyRM(annualReturn)}</span>
|
||||
</div>
|
||||
<div class="impact-item">
|
||||
<span class="impact-label">Given to charity in 10 years</span>
|
||||
<span class="impact-value highlight">{currencyRM(tenYearReturn)}</span>
|
||||
</div>
|
||||
<div class="impact-item">
|
||||
<span class="impact-label">Given in 50 years</span>
|
||||
<span class="impact-value highlight lot">{currencyRM(fiftyYearReturn)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="impact-note">Your RM {amountRM.toFixed(0)} is never touched. It grows. It gives. Forever.</p>
|
||||
<input type="range" min="1" max="10000" step="1" bind:value={waqfAmount} class="amount-slider" />
|
||||
<div class="amount-labels">
|
||||
<span>RM 1</span>
|
||||
<span>RM 10K</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="primary cta" onclick={goToConfig}>Configure Your Well</button>
|
||||
|
||||
{:else if step === 'configure'}
|
||||
<!-- CONFIGURATION -->
|
||||
<div class="config-section">
|
||||
<button class="back-btn" onclick={() => step = 'intro'}>← Back</button>
|
||||
<h2>🌴 Dig Your Well</h2>
|
||||
<p class="config-subtitle">Choose your well type, name it, and decide where its water flows.</p>
|
||||
|
||||
<!-- Step 1: Name Your Waqf -->
|
||||
<div class="config-block">
|
||||
<h3>Name Your Well</h3>
|
||||
<input type="text" bind:value={waqfName} placeholder="e.g. Sadaqah Jariyah for my mother" class="config-input" />
|
||||
<p class="hint">Like the companions named their endowments — this becomes your legacy.</p>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Well Type -->
|
||||
<div class="config-block">
|
||||
<h3>Choose Your Well Type</h3>
|
||||
<div class="well-grid">
|
||||
{#each WELL_TYPES as w}
|
||||
<button
|
||||
class="well-card"
|
||||
class:active={selectedWell === w.id}
|
||||
style="--well-color: {w.color}"
|
||||
onclick={() => setWell(w.id)}
|
||||
>
|
||||
<span class="well-icon">{w.icon}</span>
|
||||
<span class="well-name">{w.title}</span>
|
||||
<span class="well-subtitle">{w.subtitle}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if well}
|
||||
<div class="well-detail" style="border-color: {well.color}">
|
||||
<p>{well.desc}</p>
|
||||
<p class="well-beneficiaries">Benefits: {well.beneficiaries.join(' · ')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Amount -->
|
||||
<div class="config-block">
|
||||
<h3>Your Contribution (RM)</h3>
|
||||
<div class="amount-presets">
|
||||
{#each [10, 50, 100, 500, 1000] as amt}
|
||||
<button
|
||||
class="pill"
|
||||
class:active={!customAmount && waqfAmount === amt}
|
||||
onclick={() => { waqfAmount = amt; customAmount = ''; }}
|
||||
>RM {amt}</button>
|
||||
{/each}
|
||||
<button
|
||||
class="pill pill-custom"
|
||||
class:active={!!customAmount}
|
||||
onclick={() => customAmount = '100'}
|
||||
>Custom</button>
|
||||
</div>
|
||||
{#if customAmount !== ''}
|
||||
<input type="number" min="1" bind:value={customAmount} placeholder="Enter amount" class="config-input" />
|
||||
{/if}
|
||||
<div class="impact-mini">
|
||||
<span>Your well: <strong>{currencyRM(amountRM)}</strong></span>
|
||||
<span>→ <strong>{currencyRM(annualReturn)}</strong> per year in charity</span>
|
||||
<span>→ <strong>{currencyRM(tenYearReturn)}</strong> over 10 years</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 4: Allocation -->
|
||||
<div class="config-block">
|
||||
<div class="alloc-header">
|
||||
<h3>Where the Water Flows</h3>
|
||||
<button class="balance-btn" onclick={autoBalance} disabled={allocValid}>Auto-Balance</button>
|
||||
</div>
|
||||
<p class="hint">Allocate the annual returns across causes. Total must equal 100%.</p>
|
||||
<div class="alloc-grid">
|
||||
{#each CAUSES as cause}
|
||||
<div class="alloc-row">
|
||||
<span class="alloc-label">{cause.icon} {cause.label}</span>
|
||||
<div class="alloc-controls">
|
||||
<button class="alloc-btn" onclick={() => adjustAllocation(cause.id, -5)} disabled={(allocation[cause.id] || 0) <= 0}>−</button>
|
||||
<span class="alloc-value" class:zero={(allocation[cause.id] || 0) === 0}>{allocation[cause.id] || 0}%</span>
|
||||
<button class="alloc-btn" onclick={() => adjustAllocation(cause.id, 5)} disabled={(allocation[cause.id] || 0) >= 100}>+</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="alloc-total" class:valid={allocValid} class:invalid={!allocValid}>
|
||||
Total: {totalAlloc}% {allocValid ? '✅' : '❌ Must equal 100%'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 5: Your Details -->
|
||||
<div class="config-block">
|
||||
<h3>Your Details</h3>
|
||||
<input type="text" bind:value={donorName} placeholder="Your name (optional)" class="config-input" />
|
||||
<input type="email" bind:value={donorEmail} placeholder="Your email for updates" class="config-input" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="primary cta"
|
||||
disabled={!donorEmail || !allocValid || amountRM <= 0}
|
||||
onclick={registerInterest}
|
||||
>
|
||||
{#if registered}
|
||||
✅ Registered — We'll notify you when the bank trustee is live
|
||||
{:else}
|
||||
Register Interest — Be the First to Dig
|
||||
{/if}
|
||||
</button>
|
||||
<p class="disclaimer">No payment taken yet. We'll contact you when our bank trustee partner is live.</p>
|
||||
</div>
|
||||
|
||||
{:else if step === 'registered'}
|
||||
<!-- REGISTERED → Preview Dashboard -->
|
||||
<div class="thank-section">
|
||||
<div class="check-big">⛲</div>
|
||||
<h2>Your Well is Registered 🌱</h2>
|
||||
<p class="thank-text">
|
||||
Your <strong>{waqfName || 'General Well of Light'}</strong> of <strong>{currencyRM(amountRM)}</strong> ({WELL_TYPES.find(w => w.id === selectedWell)?.icon} {WELL_TYPES.find(w => w.id === selectedWell)?.title}) is noted.
|
||||
</p>
|
||||
<p class="thank-text">We'll email <strong>{donorEmail}</strong> as soon as our bank trustee partnership is live. You'll be among the first to dig a Bir Nur well.</p>
|
||||
|
||||
<!-- Preview Dashboard -->
|
||||
<div class="dashboard-preview">
|
||||
<div class="dash-header">
|
||||
<span>📊 Your Well Preview</span>
|
||||
<span class="dash-badge">COMING SOON</span>
|
||||
</div>
|
||||
<div class="dash-well-bar">
|
||||
<div class="dash-well-fill" style="width: 100%"></div>
|
||||
<span class="dash-well-label">Well Structure: {currencyRM(amountRM)} — Intact + Growing</span>
|
||||
</div>
|
||||
<div class="dash-water-bar">
|
||||
<div class="dash-water-fill" style="width: 20%"></div>
|
||||
<span class="dash-water-label">Year 1 Water Drawn: {currencyRM(annualReturn)}</span>
|
||||
</div>
|
||||
<div class="dash-beneficiaries">
|
||||
<p class="dash-benef-title">Your water will flow to:</p>
|
||||
{#each CAUSES as c}
|
||||
{#if (allocation[c.id] || 0) > 0}
|
||||
<div class="dash-benef-row">
|
||||
<span>{c.icon} {c.label}</span>
|
||||
<span>{allocation[c.id]}%</span>
|
||||
<span class="dash-benef-amount">{currencyRM(annualReturn * (allocation[c.id] / 100))}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
<p class="dash-perpetual">This continues year after year. Your reward never stops. 🤲</p>
|
||||
</div>
|
||||
|
||||
<div class="share-section">
|
||||
<p>Share your intention:</p>
|
||||
<button class="share-btn" onclick={() => window.open('https://wa.me/?text=I%27m%20digging%20a%20Bir%20Nur%20(Well%20of%20Light)%20—%20a%20perpetual%20charity%20that%20serves%20orphans%20and%20communities%20forever.%20Join%3A%20moslem.falahos.my', '_blank')}>Share on WhatsApp</button>
|
||||
</div>
|
||||
|
||||
<button class="secondary" onclick={() => {
|
||||
step = 'intro';
|
||||
waqfName = ''; customAmount = ''; donorName = ''; donorEmail = '';
|
||||
}}>Configure Another Well</button>
|
||||
|
||||
<div class="hadith-footer">
|
||||
<p>✨ Your well is registered in this life. Your reward awaits in the next.</p>
|
||||
<p class="hadith-ref">— Inspired by the waqf of Abdur Rahman bin Auf 🌴 and Uthman's Well of Rumah 💧</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.birnur {
|
||||
padding: 8px 0 20px;
|
||||
}
|
||||
|
||||
/* ── Hero ── */
|
||||
.hero-section {
|
||||
text-align: center;
|
||||
padding: 24px 0;
|
||||
}
|
||||
.hero-icon { font-size: 3.5rem; margin-bottom: 8px; }
|
||||
.hero-title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 2rem;
|
||||
font-weight: 900;
|
||||
color: #C9A84C;
|
||||
letter-spacing: 6px;
|
||||
}
|
||||
.hero-subtitle {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.75rem;
|
||||
color: rgba(232,228,220,0.45);
|
||||
letter-spacing: 2px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.hero-divider {
|
||||
width: 40%;
|
||||
height: 1px;
|
||||
margin: 12px auto;
|
||||
background: linear-gradient(90deg, transparent, #C9A84C, transparent);
|
||||
}
|
||||
.hero-quote {
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.7;
|
||||
color: rgba(232,228,220,0.65);
|
||||
font-style: italic;
|
||||
padding: 0 8px;
|
||||
}
|
||||
.quote-source {
|
||||
display: block;
|
||||
font-size: 0.65rem;
|
||||
color: rgba(232,228,220,0.35);
|
||||
margin-top: 4px;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
/* ── Story ── */
|
||||
.story-toggle {
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
cursor: pointer;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.7rem;
|
||||
color: rgba(201,168,76,0.6);
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
user-select: none;
|
||||
}
|
||||
.story-toggle:hover { color: #C9A84C; }
|
||||
.story-card {
|
||||
background: #111820;
|
||||
border: 1px solid rgba(201,168,76,0.2);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.7;
|
||||
color: rgba(232,228,220,0.75);
|
||||
}
|
||||
.story-card p { margin-bottom: 8px; }
|
||||
.story-card strong { color: #E8E4DC; }
|
||||
.story-divider {
|
||||
height: 1px;
|
||||
background: rgba(201,168,76,0.15);
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
/* ── How It Works ── */
|
||||
.how-section { margin-bottom: 20px; }
|
||||
.how-section h2 {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 1rem;
|
||||
color: #C9A84C;
|
||||
text-align: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.step-card {
|
||||
background: #141C24;
|
||||
border: 1px solid rgba(201,168,76,0.12);
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
.step-num {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: rgba(201,168,76,0.15);
|
||||
color: #C9A84C;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: 'Cinzel', serif;
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.step-card h3 {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 0.85rem;
|
||||
color: #E8E4DC;
|
||||
}
|
||||
.step-card p {
|
||||
font-size: 0.72rem;
|
||||
color: rgba(232,228,220,0.55);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.step-arrow { text-align: center; color: rgba(201,168,76,0.3); font-size: 1.2rem; }
|
||||
|
||||
/* ── Trustee Badge ── */
|
||||
.trustee-badge {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
background: rgba(201,168,76,0.06);
|
||||
border: 1px solid rgba(201,168,76,0.2);
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.trustee-icon { font-size: 1.8rem; flex-shrink: 0; }
|
||||
.trustee-text strong {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 0.8rem;
|
||||
color: #C9A84C;
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.trustee-text p {
|
||||
font-size: 0.7rem;
|
||||
color: rgba(232,228,220,0.5);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── Impact Preview ── */
|
||||
.impact-preview {
|
||||
background: #141C24;
|
||||
border: 1px solid rgba(46,204,113,0.2);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.impact-preview h2 {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 0.85rem;
|
||||
color: #2ECC71;
|
||||
text-align: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.impact-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.impact-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid rgba(201,168,76,0.06);
|
||||
}
|
||||
.impact-label {
|
||||
font-size: 0.7rem;
|
||||
color: rgba(232,228,220,0.5);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
.impact-value {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: #E8E4DC;
|
||||
}
|
||||
.impact-value.highlight { color: #2ECC71; }
|
||||
.impact-value.lot { color: #C9A84C; }
|
||||
.impact-note {
|
||||
text-align: center;
|
||||
font-size: 0.65rem;
|
||||
color: rgba(232,228,220,0.4);
|
||||
margin: 8px 0;
|
||||
font-style: italic;
|
||||
}
|
||||
.amount-slider {
|
||||
width: 100%;
|
||||
-webkit-appearance: none;
|
||||
height: 4px;
|
||||
background: #111820;
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
margin: 4px 0;
|
||||
}
|
||||
.amount-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: #C9A84C;
|
||||
cursor: pointer;
|
||||
border: 2px solid #070A0D;
|
||||
}
|
||||
.amount-labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.55rem;
|
||||
color: rgba(232,228,220,0.25);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.cta {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
font-size: 0.85rem;
|
||||
padding: 14px;
|
||||
}
|
||||
.cta:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Configuration ── */
|
||||
.config-section { }
|
||||
.back-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(232,228,220,0.4);
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
padding: 4px 0;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.back-btn:hover { color: #E8E4DC; }
|
||||
.config-section h2 {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 1.2rem;
|
||||
color: #C9A84C;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.config-subtitle {
|
||||
font-size: 0.78rem;
|
||||
color: rgba(232,228,220,0.5);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.config-block {
|
||||
background: #141C24;
|
||||
border: 1px solid rgba(201,168,76,0.12);
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.config-block h3 {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 0.8rem;
|
||||
color: #E8E4DC;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.config-input {
|
||||
width: 100%;
|
||||
margin-bottom: 6px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.hint {
|
||||
font-size: 0.65rem;
|
||||
color: rgba(232,228,220,0.35);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── Well Type Grid ── */
|
||||
.well-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.well-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 10px 6px;
|
||||
background: #111820;
|
||||
border: 1px solid rgba(232,228,220,0.1);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.well-card.active {
|
||||
border-color: var(--well-color);
|
||||
background: rgba(from var(--well-color) r g b / 0.08);
|
||||
box-shadow: 0 0 8px rgba(from var(--well-color) r g b / 0.15);
|
||||
}
|
||||
.well-icon { font-size: 1.5rem; }
|
||||
.well-name {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.65rem;
|
||||
color: #E8E4DC;
|
||||
font-weight: 500;
|
||||
}
|
||||
.well-subtitle {
|
||||
font-size: 0.55rem;
|
||||
color: rgba(232,228,220,0.35);
|
||||
}
|
||||
.well-detail {
|
||||
border-left: 3px solid;
|
||||
padding: 8px 12px;
|
||||
margin-top: 4px;
|
||||
font-size: 0.72rem;
|
||||
color: rgba(232,228,220,0.6);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.well-beneficiaries {
|
||||
margin-top: 4px;
|
||||
color: rgba(232,228,220,0.35);
|
||||
font-size: 0.62rem;
|
||||
}
|
||||
|
||||
/* ── Amount ── */
|
||||
.amount-presets {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.pill {
|
||||
padding: 6px 12px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(232,228,220,0.15);
|
||||
background: transparent;
|
||||
color: #E8E4DC;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.72rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pill.active {
|
||||
background: #C9A84C;
|
||||
color: #070A0D;
|
||||
border-color: #C9A84C;
|
||||
font-weight: 600;
|
||||
}
|
||||
.pill-custom { border-style: dashed; }
|
||||
.impact-mini {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: 0.68rem;
|
||||
color: rgba(232,228,220,0.5);
|
||||
margin-top: 6px;
|
||||
}
|
||||
.impact-mini strong { color: #2ECC71; }
|
||||
|
||||
/* ── Allocation ── */
|
||||
.alloc-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.balance-btn {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.6rem;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid rgba(201,168,76,0.3);
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #C9A84C;
|
||||
cursor: pointer;
|
||||
}
|
||||
.balance-btn:disabled { opacity: 0.3; cursor: not-allowed; }
|
||||
.alloc-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.alloc-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 6px 8px;
|
||||
background: #111820;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.alloc-label { font-size: 0.72rem; color: rgba(232,228,220,0.7); }
|
||||
.alloc-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.alloc-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(201,168,76,0.2);
|
||||
background: transparent;
|
||||
color: #C9A84C;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.alloc-btn:disabled { opacity: 0.2; cursor: not-allowed; }
|
||||
.alloc-value {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
min-width: 32px;
|
||||
text-align: center;
|
||||
color: #E8E4DC;
|
||||
}
|
||||
.alloc-value.zero { color: rgba(232,228,220,0.3); }
|
||||
.alloc-total {
|
||||
text-align: center;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.65rem;
|
||||
padding: 4px;
|
||||
}
|
||||
.alloc-total.valid { color: #2ECC71; }
|
||||
.alloc-total.invalid { color: #EF4444; }
|
||||
|
||||
.disclaimer {
|
||||
text-align: center;
|
||||
font-size: 0.6rem;
|
||||
color: rgba(232,228,220,0.3);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* ── Thank You / Preview ── */
|
||||
.thank-section { text-align: center; }
|
||||
.check-big { font-size: 4rem; margin-bottom: 8px; }
|
||||
.thank-section h2 {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 1.2rem;
|
||||
color: #C9A84C;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.thank-text {
|
||||
font-size: 0.82rem;
|
||||
color: rgba(232,228,220,0.7);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.dashboard-preview {
|
||||
text-align: left;
|
||||
background: #111820;
|
||||
border: 1px solid rgba(46,204,113,0.2);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
.dash-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.7rem;
|
||||
color: rgba(232,228,220,0.6);
|
||||
}
|
||||
.dash-badge {
|
||||
background: #C9A84C;
|
||||
color: #070A0D;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
font-size: 0.55rem;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.dash-well-bar, .dash-water-bar {
|
||||
position: relative;
|
||||
height: 32px;
|
||||
background: #0C1117;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.dash-well-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #C9A84C, #E8C97A);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.dash-water-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #3B82F6, #60A5FA);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.dash-well-label, .dash-water-label {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 10px;
|
||||
transform: translateY(-50%);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.6rem;
|
||||
color: #070A0D;
|
||||
font-weight: 500;
|
||||
}
|
||||
.dash-water-label { color: white; }
|
||||
.dash-beneficiaries { margin-top: 10px; }
|
||||
.dash-benef-title {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.6rem;
|
||||
text-transform: uppercase;
|
||||
color: rgba(232,228,220,0.4);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.dash-benef-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.72rem;
|
||||
color: rgba(232,228,220,0.65);
|
||||
padding: 3px 0;
|
||||
}
|
||||
.dash-benef-amount {
|
||||
font-family: 'Cinzel', serif;
|
||||
color: #2ECC71;
|
||||
}
|
||||
.dash-perpetual {
|
||||
text-align: center;
|
||||
font-size: 0.7rem;
|
||||
color: #2ECC71;
|
||||
margin-top: 10px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.share-section {
|
||||
margin: 16px 0;
|
||||
}
|
||||
.share-section p {
|
||||
font-size: 0.7rem;
|
||||
color: rgba(232,228,220,0.4);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.share-btn {
|
||||
padding: 8px 20px;
|
||||
border: 1px solid rgba(46,204,113,0.3);
|
||||
border-radius: 20px;
|
||||
background: rgba(46,204,113,0.06);
|
||||
color: #2ECC71;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
}
|
||||
|
||||
.hadith-footer {
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
border-top: 1px solid rgba(201,168,76,0.1);
|
||||
font-size: 0.72rem;
|
||||
color: rgba(232,228,220,0.45);
|
||||
line-height: 1.6;
|
||||
font-style: italic;
|
||||
}
|
||||
.hadith-ref {
|
||||
font-size: 0.6rem;
|
||||
color: rgba(232,228,220,0.25);
|
||||
margin-top: 4px;
|
||||
font-style: normal;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,470 @@
|
||||
<script>
|
||||
const categories = [
|
||||
'Morning & Evening',
|
||||
'After Prayer',
|
||||
'Sleeping',
|
||||
'Eating & Drinking',
|
||||
'Travel',
|
||||
'Protection & Wellness',
|
||||
'Forgiveness & Repentance',
|
||||
'Important Occasions'
|
||||
];
|
||||
|
||||
/** @type {Array<{id:string,category:string,title:string,arabic:string,transliteration:string,translation:string,reference:string,benefit?:string}>} */
|
||||
const duas = [
|
||||
// ── Morning & Evening ──
|
||||
{ id: 'm1', category: 'Morning & Evening', title: 'Ayat al-Kursi', arabic: 'ٱللَّهُ لَآ إِلَـٰهَ إِلَّا هُوَ ٱلْحَىُّ ٱلْقَيُّومُ ۚ لَا تَأْخُذُهُۥ سِنَةٌۭ وَلَا نَوْمٌۭ ۚ لَّهُۥ مَا فِى ٱلسَّمَـٰوَٰتِ وَمَا فِى ٱلْأَرْضِ ۗ مَن ذَا ٱلَّذِى يَشْفَعُ عِندَهُۥٓ إِلَّا بِإِذْنِهِۦ ۚ يَعْلَمُ مَا بَيْنَ أَيْدِيهِمْ وَمَا خَلْفَهُمْ ۖ وَلَا يُحِيطُونَ بِشَىْءٍۢ مِّنْ عِلْمِهِۦٓ إِلَّا بِمَا شَآءَ ۚ وَسِعَ كُرْسِيُّهُ ٱلسَّمَـٰوَٰتِ وَٱلْأَرْضَ ۖ وَلَا يَـُٔودُهُۥ حِفْظُهُمَا ۚ وَهُوَ ٱلْعَلِىُّ ٱلْعَظِيمُ', transliteration: 'Allahu la ilaha illa Huwal-Hayyul-Qayyum…', translation: 'Allah — there is no deity except Him, the Ever-Living, the Sustainer of all existence…', reference: 'Quran 2:255', benefit: 'Whoever recites it in the morning will be protected until the evening, and vice versa.' },
|
||||
{ id: 'm2', category: 'Morning & Evening', title: 'Three Quls (Ikhlas, Falaq, Nas)', arabic: 'قُلْ هُوَ ٱللَّهُ أَحَدٌ \nقُلْ أَعُوذُ بِرَبِّ ٱلْفَلَقِ \nقُلْ أَعُوذُ بِرَبِّ ٱلنَّاسِ', transliteration: 'Qul Huwallahu Ahad… Qul A\'udhu bi Rabbil-Falaq… Qul A\'udhu bi Rabbin-Nas…', translation: 'Say: He is Allah, the One… Say: I seek refuge in the Lord of the Daybreak… Say: I seek refuge in the Lord of mankind…', reference: 'Quran 112, 113, 114', benefit: 'Recite 3 times each morning and evening — sufficient as protection.' },
|
||||
{ id: 'm3', category: 'Morning & Evening', title: 'Morning Remembrance', arabic: 'ٱللَّهُمَّ بِكَ أَصْبَحْنَا وَبِكَ أَمْسَيْنَا وَبِكَ نَحْيَا وَبِكَ نَمُوتُ وَإِلَيْكَ النُّشُورُ', transliteration: 'Allahumma bika asbahna, wa bika amsayna, wa bika nahya, wa bika namutu, wa ilayka an-nushur.', translation: 'O Allah, by You we enter the morning, by You we enter the evening, by You we live, by You we die, and to You is the resurrection.', reference: 'Abu Dawud, Tirmidhi' },
|
||||
{ id: 'm4', category: 'Morning & Evening', title: 'Evening Remembrance', arabic: 'ٱللَّهُمَّ بِكَ أَمْسَيْنَا وَبِكَ أَصْبَحْنَا وَبِكَ نَحْيَا وَبِكَ نَمُوتُ وَإِلَيْكَ الْمَصِيرُ', transliteration: 'Allahumma bika amsayna, wa bika asbahna, wa bika nahya, wa bika namutu, wa ilayka al-maseer.', translation: 'O Allah, by You we enter the evening, by You we enter the morning, by You we live, by You we die, and to You is the final return.', reference: 'Abu Dawud, Tirmidhi' },
|
||||
{ id: 'm5', category: 'Morning & Evening', title: 'Well-being in Body', arabic: 'ٱللَّهُمَّ عَافِنِي فِي بَدَنِي، ٱللَّهُمَّ عَافِنِي فِي سَمْعِي، ٱللَّهُمَّ عَافِنِي فِي بَصَرِي، لَا إِلَهَ إِلَّا أَنْتَ', transliteration: 'Allahumma \'afini fi badani, Allahumma \'afini fi sam\'i, Allahumma \'afini fi basari, la ilaha illa anta.', translation: 'O Allah, grant my body well-being. O Allah, grant my hearing well-being. O Allah, grant my sight well-being. There is no god but You.', reference: 'Abu Dawud' },
|
||||
{ id: 'm6', category: 'Morning & Evening', title: 'Protection from Four Things', arabic: 'ٱللَّهُمَّ إِنِّي أَعُوذُ بِكَ مِنَ الْكُفْرِ وَالْفَقْرِ وَعَذَابِ الْقَبْرِ', transliteration: 'Allahumma inni a\'udhu bika minal-kufri wal-faqri wa \'adhabil-qabr.', translation: 'O Allah, I seek refuge in You from disbelief, poverty, and the punishment of the grave.', reference: 'Ahmad, An-Nasa\'i' },
|
||||
|
||||
// ── After Prayer ──
|
||||
{ id: 'p1', category: 'After Prayer', title: 'Astaghfirullah (x3)', arabic: 'أَسْتَغْفِرُ اللّهَ', transliteration: 'Astaghfirullah.', translation: 'I seek forgiveness from Allah.', reference: 'Muslim' },
|
||||
{ id: 'p2', category: 'After Prayer', title: 'Allahumma Antas-Salam', arabic: 'ٱللَّهُمَّ أَنْتَ السَّلَامُ وَمِنْكَ السَّلَامُ تَبَارَكْتَ يَا ذَا الْجَلَالِ وَالْإِكْرَامِ', transliteration: 'Allahumma antas-salam, wa minkas-salam, tabarakta ya dhal-jalali wal-ikram.', translation: 'O Allah, You are Peace and from You is peace. Blessed are You, O Possessor of Majesty and Honor.', reference: 'Muslim' },
|
||||
{ id: 'p3', category: 'After Prayer', title: 'Tasbih (33+34)', arabic: 'سُبْحَانَ اللّه (٣٣) \nالْحَمْدُ لِلّه (٣٣) \nاللّهُ أَكْبَر (٣٤)', transliteration: 'Subhanallah (33x) — Alhamdulillah (33x) — Allahu Akbar (34x)', translation: 'Glory be to Allah. Praise be to Allah. Allah is the Greatest.', reference: 'Muslim', benefit: 'Whoever says these after each prayer, his sins are forgiven even if like the foam of the sea.' },
|
||||
{ id: 'p4', category: 'After Prayer', title: 'La ilaha illallah', arabic: 'لَا إِلَهَ إِلَّا اللّهُ وَحْدَهُ لَا شَرِيكَ لَهُ، لَهُ الْمُلْكُ وَلَهُ الْحَمْدُ وَهُوَ عَلَى كُلِّ شَيْءٍ قَدِيرٌ', transliteration: 'La ilaha illallahu wahdahu la sharika lahu, lahul-mulku wa lahul-hamdu wa huwa \'ala kulli shay\'in qadeer.', translation: 'There is no god but Allah alone, no partner, His is the dominion and His is the praise, and He is over all things Omnipotent.', reference: 'Bukhari, Muslim' },
|
||||
{ id: 'p5', category: 'After Prayer', title: 'Seeking Beneficial Knowledge', arabic: 'ٱللَّهُمَّ إِنِّي أَسْأَلُكَ عِلْماً نَافِعاً، وَرِزْقاً طَيِّباً، وَعَمَلاً مُتَقَبَّلاً', transliteration: 'Allahumma inni as\'aluka \'ilman nafi\'an, wa rizqan tayyiban, wa \'amalan mutaqabbalan.', translation: 'O Allah, I ask You for beneficial knowledge, goodly provision, and accepted deeds.', reference: 'Ibn Majah' },
|
||||
|
||||
// ── Sleeping ──
|
||||
{ id: 's1', category: 'Sleeping', title: 'Before Sleep', arabic: 'بِاسْمِكَ اللَّهُمَّ أَمُوتُ وَأَحْيَا', transliteration: 'Bismika Allahumma amutu wa ahya.', translation: 'In Your name, O Allah, I die and I live.', reference: 'Bukhari' },
|
||||
{ id: 's2', category: 'Sleeping', title: 'After Waking', arabic: 'الْحَمْدُ لِلَّهِ الَّذِي أَحْيَانَا بَعْدَ مَا أَمَاتَنَا وَإِلَيْهِ النُّشُورُ', transliteration: 'Alhamdulillahil-ladhi ahyana ba\'da ma amatana wa ilayhin-nushur.', translation: 'All praise is for Allah who gave us life after having taken it, and to Him is the resurrection.', reference: 'Bukhari' },
|
||||
{ id: 's3', category: 'Sleeping', title: 'Protection from Punishment', arabic: 'اللَّهُمَّ قِنِي عَذَابَ يَوْمِ تَبْعَثُ عِبَادَكَ', transliteration: 'Allahumma qini \'adhaba yawma tab\'athu \'ibadak.', translation: 'O Allah, protect me from Your punishment on the Day You resurrect Your servants.', reference: 'Abu Dawud, Tirmidhi' },
|
||||
{ id: 's4', category: 'Sleeping', title: 'Placing Oneself (before sleep)', arabic: 'بِاسْمِكَ رَبِّ وَضَعْتُ جَنْبِي، وَبِكَ أَرْفَعُهُ، إِنْ أَمْسَكْتَ نَفْسِي فَارْحَمْهَا، وَإِنْ أَرْسَلْتَهَا فَاحْفَظْهَا بِمَا تَحْفَظُ بِهِ عِبَادَكَ الصَّالِحِينَ', transliteration: 'Bismika rabbi wada\'tu janbi, wa bika arfa\'uhu, in amsakta nafsi farhamha, wa in arsaltaha fahfazha bima tahfazu bihi \'ibadakas-salihin.', translation: 'In Your name, my Lord, I place my side. If You take my soul, have mercy on it. If You return it, protect it as You protect Your righteous servants.', reference: 'Bukhari' },
|
||||
{ id: 's5', category: 'Sleeping', title: 'Ayat al-Kursī before Sleep', arabic: 'ٱللَّهُ لَآ إِلَـٰهَ إِلَّا هُوَ ٱلْحَىُّ ٱلْقَيُّومُ…', transliteration: 'Allahu la ilaha illa Huwal-Hayyul-Qayyum…', translation: 'Whoever recites Ayat al-Kursi before sleeping, Allah appoints a guardian over him until morning.', reference: 'Quran 2:255', benefit: 'A protector from Allah remains with you, and no devil approaches until morning.' },
|
||||
|
||||
// ── Eating & Drinking ──
|
||||
{ id: 'e1', category: 'Eating & Drinking', title: 'Before Eating', arabic: 'بِسْمِ اللَّهِ', transliteration: 'Bismillah.', translation: 'In the name of Allah.', reference: 'Bukhari, Muslim' },
|
||||
{ id: 'e2', category: 'Eating & Drinking', title: 'After Eating', arabic: 'الْحَمْدُ لِلَّهِ الَّذِي أَطْعَمَنَا وَسَقَانَا وَجَعَلَنَا مُسْلِمِينَ', transliteration: 'Alhamdulillahil-ladhi at\'amana wa saqana wa ja\'alana muslimin.', translation: 'All praise is for Allah who fed us, gave us drink, and made us Muslim.', reference: 'Abu Dawud, Tirmidhi' },
|
||||
{ id: 'e3', category: 'Eating & Drinking', title: 'Before Drinking', arabic: 'بِسْمِ اللَّهِ', transliteration: 'Bismillah.', translation: 'In the name of Allah.', reference: 'Tirmidhi' },
|
||||
{ id: 'e4', category: 'Eating & Drinking', title: 'After Drinking', arabic: 'الْحَمْدُ لِلَّهِ', transliteration: 'Alhamdulillah.', translation: 'All praise is for Allah.', reference: 'Bukhari' },
|
||||
{ id: 'e5', category: 'Eating & Drinking', title: 'When Forgetting Bismillah', arabic: 'بِسْمِ اللَّهِ أَوَّلَهُ وَآخِرَهُ', transliteration: 'Bismillahi awwalahu wa akhirah.', translation: 'In the name of Allah, its beginning and its end.', reference: 'Abu Dawud, Tirmidhi', benefit: 'Say this if you forget to say Bismillah before eating.' },
|
||||
|
||||
// ── Travel ──
|
||||
{ id: 't1', category: 'Travel', title: 'When Boarding', arabic: 'بِسْمِ اللَّهِ', transliteration: 'Bismillah.', translation: 'In the name of Allah.', reference: 'Muslim' },
|
||||
{ id: 't2', category: 'Travel', title: 'Travel Prayer', arabic: 'اللَّهُمَّ إِنَّا نَسْأَلُكَ فِي سَفَرِنَا هَذَا الْبِرَّ وَالتَّقْوَى، وَمِنَ الْعَمَلِ مَا تَرْضَى', transliteration: 'Allahumma inna nas\'aluka fi safarina hadha al-birra wat-taqwa, wa minal-\'amali ma tarda.', translation: 'O Allah, we ask You in this journey of ours for righteousness, piety, and deeds that please You.', reference: 'Muslim' },
|
||||
{ id: 't3', category: 'Travel', title: 'Returning Home', arabic: 'اللَّهُمَّ إِنِّي أَسْأَلُكَ خَيْرَ الْمَوْلَجِ وَخَيْرَ الْمَخْرَجِ، بِسْمِ اللَّهِ وَلَجْنَا وَبِسْمِ اللَّهِ خَرَجْنَا، وَعَلَى اللَّهِ رَبِّنَا تَوَكَّلْنَا', transliteration: 'Allahumma inni as\'aluka khayral-mawlaji wa khayral-makhraji. Bismillahi walajna wa bismillahi kharajna, wa \'alallahi rabbina tawakkalna.', translation: 'O Allah, I ask You for the best entry and the best exit. In the name of Allah we entered, and in His name we left, and upon Allah our Lord we rely.', reference: 'Abu Dawud' },
|
||||
{ id: 't4', category: 'Travel', title: 'Leaving Home', arabic: 'بِسْمِ اللَّهِ تَوَكَّلْتُ عَلَى اللَّهِ، وَلَا حَوْلَ وَلَا قُوَّةَ إِلَّا بِاللَّهِ', transliteration: 'Bismillahi tawakkaltu \'alallah, wa la hawla wa la quwwata illa billah.', translation: 'In the name of Allah, I rely upon Allah. There is no power nor strength except through Allah.', reference: 'Abu Dawud, Tirmidhi', benefit: 'When you leave home, say this and it will be said: You are guided, sufficient, and protected.' },
|
||||
|
||||
// ── Protection & Wellness ──
|
||||
{ id: 'w1', category: 'Protection & Wellness', title: 'When Sick', arabic: 'اللَّهُمَّ رَبَّ النَّاسِ أَذْهِبِ الْبَأْسَ، وَاشْفِ أَنْتَ الشَّافِي، لَا شِفَاءَ إِلَّا شِفَاؤُكَ، شِفَاءً لَا يُغَادِرُ سَقَماً', transliteration: 'Allahumma rabban-nasi adhhibil-ba\'sa washfi antash-shafi, la shifa\'a illa shifa\'uka, shifa\'an la yughadiru saqaman.', translation: 'O Allah, Lord of mankind, remove the harm and heal, for You are the Healer. There is no cure except Your cure, a cure that leaves no illness.', reference: 'Bukhari' },
|
||||
{ id: 'w2', category: 'Protection & Wellness', title: 'Visiting the Sick', arabic: 'لَا بَأْسَ طَهُورٌ إِنْ شَاءَ اللَّهُ', transliteration: 'La ba\'sa tahurun in sha\'allah.', translation: 'No worry, it is purification, if Allah wills.', reference: 'Bukhari' },
|
||||
{ id: 'w3', category: 'Protection & Wellness', title: 'When Distressed', arabic: 'اللَّهُمَّ رَحْمَتَكَ أَرْجُو، فَلَا تَكِلْنِي إِلَى نَفْسِي طَرْفَةَ عَيْنٍ، وَأَصْلِحْ لِي شَأْنِي كُلَّهُ، لَا إِلَهَ إِلَّا أَنْتَ', transliteration: 'Allahumma rahmataka arju, fala takilni ila nafsi tarfata \'aynin, wa aslih li sha\'ni kullahu, la ilaha illa anta.', translation: 'O Allah, I hope for Your mercy. Do not entrust me to myself for the blink of an eye. Correct all my affairs. There is no god but You.', reference: 'Abu Dawud' },
|
||||
{ id: 'w4', category: 'Protection & Wellness', title: 'Protection from Harm', arabic: 'بِسْمِ اللَّهِ الَّذِي لَا يَضُرُّ مَعَ اسْمِهِ شَيْءٌ فِي الْأَرْضِ وَلَا فِي السَّمَاءِ، وَهُوَ السَّمِيعُ الْعَلِيمُ', transliteration: 'Bismillahil-ladhi la yadurru ma\'asmihi shay\'un fil-ardi wa la fis-sama\'i, wa huwas-sami\'ul-\'alim.', translation: 'In the name of Allah who, with His name, nothing on earth or in heaven can cause harm. He is the All-Hearing, the All-Knowing.', reference: 'Abu Dawud, Tirmidhi', benefit: 'Recite three times morning and evening — no sudden calamity will harm you.' },
|
||||
{ id: 'w5', category: 'Protection & Wellness', title: 'Against Anxiety', arabic: 'اللَّهُمَّ إِنِّي أَعُوذُ بِكَ مِنَ الْهَمِّ وَالْحَزَنِ، وَالْعَجْزِ وَالْكَسَلِ، وَالْبُخْلِ وَالْجُبْنِ، وَضَلَعِ الدَّيْنِ وَغَلَبَةِ الرِّجَالِ', transliteration: 'Allahumma inni a\'udhu bika minal-hammi wal-hazani, wal-\'ajzi wal-kasali, wal-bukhli wal-jubni, wa dhala\'id-dayni wa ghalabati-rijal.', translation: 'O Allah, I seek refuge in You from worry and grief, incapacity and laziness, miserliness and cowardice, the burden of debt and the domination of men.', reference: 'Bukhari' },
|
||||
|
||||
// ── Forgiveness & Repentance ──
|
||||
{ id: 'f1', category: 'Forgiveness & Repentance', title: 'Sayyid al-Istighfar (Chief of Repentance)', arabic: 'اللَّهُمَّ أَنْتَ رَبِّي لَا إِلَهَ إِلَّا أَنْتَ، خَلَقْتَنِي وَأَنَا عَبْدُكَ، وَأَنَا عَلَى عَهْدِكَ وَوَعْدِكَ مَا اسْتَطَعْتُ، أَعُوذُ بِكَ مِنْ شَرِّ مَا صَنَعْتُ، أَبُوءُ لَكَ بِنِعْمَتِكَ عَلَيَّ وَأَبُوءُ بِذَنْبِي فَاغْفِرْ لِي، فَإِنَّهُ لَا يَغْفِرُ الذُّنُوبَ إِلَّا أَنْتَ', transliteration: 'Allahumma anta Rabbi la ilaha illa anta, khalaqtani wa ana \'abduka…', translation: 'O Allah, You are my Lord. There is no god but You. You created me and I am Your servant…', reference: 'Bukhari', benefit: 'Whoever says it with certainty in the evening and dies that night enters Paradise.' },
|
||||
{ id: 'f2', category: 'Forgiveness & Repentance', title: 'Forgiveness for Parents', arabic: 'رَبَّنَا اغْفِرْ لِي وَلِوَالِدَيَّ وَلِلْمُؤْمِنِينَ يَوْمَ يَقُومُ الْحِسَابُ', transliteration: 'Rabbana-ghfir li wa liwalidayya wa lil-mu\'minina yawma yaqumul-hisab.', translation: 'Our Lord, forgive me and my parents and the believers on the Day the account is established.', reference: 'Quran 14:41' },
|
||||
{ id: 'f3', category: 'Forgiveness & Repentance', title: 'Subhanallah wa bihamdih (x100)', arabic: 'سُبْحَانَ اللَّهِ وَبِحَمْدِهِ', transliteration: 'Subhanallahi wa bihamdih.', translation: 'Glory be to Allah and all praise is to Him.', reference: 'Muslim', benefit: 'Whoever says it 100 times in a day, his sins are forgiven even if like the foam of the sea.' },
|
||||
{ id: 'f4', category: 'Forgiveness & Repentance', title: 'Astaghfirullah wa Atubu Ilayh', arabic: 'أَسْتَغْفِرُ اللَّهَ وَأَتُوبُ إِلَيْهِ', transliteration: 'Astaghfirullaha wa atubu ilayh.', translation: 'I seek forgiveness of Allah and repent to Him.', reference: 'Ahmad, Tirmidhi', benefit: 'Whoever persists in this 70 times or 100 times daily, his sins are forgiven even if they were as numerous as the foam of the sea.' },
|
||||
|
||||
// ── Important Occasions ──
|
||||
{ id: 'o1', category: 'Important Occasions', title: 'When Sneezing', arabic: 'الْحَمْدُ لِلَّهِ', transliteration: 'Alhamdulillah.', translation: 'All praise is for Allah.', reference: 'Bukhari' },
|
||||
{ id: 'o2', category: 'Important Occasions', title: 'Reply to a Sneeze', arabic: 'يَرْحَمُكَ اللَّهُ', transliteration: 'Yarhamukallah.', translation: 'May Allah have mercy on you.', reference: 'Bukhari' },
|
||||
{ id: 'o3', category: 'Important Occasions', title: 'Entering the Mosque', arabic: 'اللَّهُمَّ افْتَحْ لِي أَبْوَابَ رَحْمَتِكَ', transliteration: 'Allahumma iftah li abwaba rahmatik.', translation: 'O Allah, open for me the doors of Your mercy.', reference: 'Muslim' },
|
||||
{ id: 'o4', category: 'Important Occasions', title: 'Leaving the Mosque', arabic: 'اللَّهُمَّ إِنِّي أَسْأَلُكَ مِنْ فَضْلِكَ', transliteration: 'Allahumma inni as\'aluka min fadlik.', translation: 'O Allah, I ask You of Your bounty.', reference: 'Muslim' },
|
||||
{ id: 'o5', category: 'Important Occasions', title: 'Seeing the New Moon', arabic: 'اللَّهُمَّ أَهِلَّهُ عَلَيْنَا بِالْيُمْنِ وَالْإِيمَانِ، وَالسَّلَامَةِ وَالْإِسْلَامِ، رَبِّي وَرَبُّكَ اللَّهُ', transliteration: 'Allahumma ahillahu \'alayna bil-yumni wal-iman, was-salamati wal-islam. Rabbi wa rabbukallah.', translation: 'O Allah, bring this new moon upon us with blessings, faith, safety, and submission. My Lord and your Lord is Allah.', reference: 'Tirmidhi' },
|
||||
{ id: 'o6', category: 'Important Occasions', title: 'Wedding Blessing', arabic: 'بَارَكَ اللَّهُ لَكَ وَبَارَكَ عَلَيْكَ وَجَمَعَ بَيْنَكُمَا فِي خَيْرٍ', transliteration: 'Barakallahu laka wa baraka \'alayka wa jama\'a baynakuma fi khayr.', translation: 'May Allah bless you, and shower blessings upon you, and join you together in goodness.', reference: 'Abu Dawud, Tirmidhi' }
|
||||
];
|
||||
|
||||
let activeCategory = $state('All');
|
||||
let searchQuery = $state('');
|
||||
let expandedDua = $state(null);
|
||||
let favorites = $state(loadFavorites());
|
||||
|
||||
function loadFavorites() {
|
||||
try {
|
||||
const saved = localStorage.getItem('dua_favorites');
|
||||
return saved ? JSON.parse(saved) : [];
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
function toggleFavorite(id) {
|
||||
if (favorites.includes(id)) {
|
||||
favorites = favorites.filter(f => f !== id);
|
||||
} else {
|
||||
favorites = [...favorites, id];
|
||||
}
|
||||
localStorage.setItem('dua_favorites', JSON.stringify(favorites));
|
||||
}
|
||||
|
||||
function isFavorited(id) {
|
||||
return favorites.includes(id);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
// persist favorites on change
|
||||
localStorage.setItem('dua_favorites', JSON.stringify(favorites));
|
||||
});
|
||||
|
||||
let filtered = $derived.by(() => {
|
||||
let result = duas;
|
||||
if (activeCategory !== 'All') {
|
||||
result = result.filter(d => d.category === activeCategory);
|
||||
}
|
||||
if (searchQuery.trim()) {
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
result = result.filter(d =>
|
||||
d.title.toLowerCase().includes(q) ||
|
||||
d.arabic.includes(q) ||
|
||||
d.translation.toLowerCase().includes(q) ||
|
||||
d.transliteration.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
let favoriteCount = $derived(favorites.length);
|
||||
</script>
|
||||
|
||||
<div class="dua-library">
|
||||
<div class="card">
|
||||
<h2>🤲 Dua & Sunnah</h2>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="search-wrap">
|
||||
<input
|
||||
type="search"
|
||||
class="search-input"
|
||||
placeholder="Search duas…"
|
||||
bind:value={searchQuery}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Category chips -->
|
||||
<div class="chip-row">
|
||||
<button
|
||||
class="chip"
|
||||
class:active={activeCategory === 'All'}
|
||||
onclick={() => activeCategory = 'All'}
|
||||
>All</button>
|
||||
{#each categories as cat}
|
||||
<button
|
||||
class="chip"
|
||||
class:active={activeCategory === cat}
|
||||
onclick={() => activeCategory = cat}
|
||||
>{cat}</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Favorite count badge -->
|
||||
{#if favoriteCount > 0}
|
||||
<div class="fav-bar">
|
||||
<span class="fav-star-icon">★</span>
|
||||
<span>{favoriteCount} saved</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Results -->
|
||||
<div class="results-count">{filtered.length} dua{filtered.length !== 1 ? 's' : ''}</div>
|
||||
|
||||
{#if filtered.length === 0}
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">🔍</div>
|
||||
<p>No duas found</p>
|
||||
<p class="hint">Try a different search or category</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="dua-list">
|
||||
{#each filtered as dua}
|
||||
<div
|
||||
class="dua-card"
|
||||
class:expanded={expandedDua === dua.id}
|
||||
class:faved={isFavorited(dua.id)}
|
||||
>
|
||||
<div
|
||||
class="dua-main"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => 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"
|
||||
>
|
||||
<div class="dua-header">
|
||||
<span class="dua-title">{dua.title}</span>
|
||||
<div class="dua-meta">
|
||||
<span class="dua-category-tag">{dua.category}</span>
|
||||
<button
|
||||
class="fav-btn"
|
||||
class:faved={isFavorited(dua.id)}
|
||||
onclick={(e) => { e.stopPropagation(); toggleFavorite(dua.id); }}
|
||||
aria-label={isFavorited(dua.id) ? 'Remove from favorites' : 'Add to favorites'}
|
||||
>★</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dua-arabic">{dua.arabic}</div>
|
||||
</div>
|
||||
|
||||
{#if expandedDua === dua.id}
|
||||
<div class="dua-details">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Transliteration</span>
|
||||
<p class="dua-transliteration">{dua.transliteration}</p>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Translation</span>
|
||||
<p class="dua-translation">{dua.translation}</p>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Reference</span>
|
||||
<p class="dua-reference">{dua.reference}</p>
|
||||
</div>
|
||||
{#if dua.benefit}
|
||||
<div class="detail-row">
|
||||
<span class="detail-label benefit-label">Benefit</span>
|
||||
<p class="dua-benefit">{dua.benefit}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.dua-library {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
/* Search */
|
||||
.search-wrap {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid rgba(201,168,76,0.2);
|
||||
border-radius: 10px;
|
||||
font-size: 0.85rem;
|
||||
color: #E8E4DC;
|
||||
background: #111820;
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.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);
|
||||
}
|
||||
|
||||
/* Category chips */
|
||||
.chip-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding-bottom: 4px;
|
||||
margin-bottom: 10px;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.chip-row::-webkit-scrollbar { display: none; }
|
||||
.chip {
|
||||
flex-shrink: 0;
|
||||
padding: 6px 14px;
|
||||
border: 1px solid rgba(201,168,76,0.25);
|
||||
border-radius: 20px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 300;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
background: transparent;
|
||||
color: rgba(232,228,220,0.5);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.chip:hover {
|
||||
border-color: #C9A84C;
|
||||
color: #C9A84C;
|
||||
}
|
||||
.chip.active {
|
||||
background: rgba(201,168,76,0.12);
|
||||
border-color: #C9A84C;
|
||||
color: #C9A84C;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Favorite bar */
|
||||
.fav-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 300;
|
||||
color: #C9A84C;
|
||||
margin-bottom: 8px;
|
||||
padding: 6px 10px;
|
||||
background: rgba(201,168,76,0.06);
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(201,168,76,0.12);
|
||||
}
|
||||
.fav-star-icon { font-size: 0.8rem; }
|
||||
|
||||
.results-count {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 300;
|
||||
color: rgba(232,228,220,0.4);
|
||||
margin-bottom: 8px;
|
||||
padding-left: 2px;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 40px 0;
|
||||
color: rgba(232,228,220,0.55);
|
||||
}
|
||||
.empty-icon { font-size: 2rem; margin-bottom: 8px; }
|
||||
.empty-state p { font-family: 'DM Sans', sans-serif; font-size: 0.85rem; }
|
||||
.empty-state .hint { font-size: 0.72rem; color: rgba(232,228,220,0.3); margin-top: 4px; }
|
||||
|
||||
/* Dua list */
|
||||
.dua-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Dua card */
|
||||
.dua-card {
|
||||
background: #141C24;
|
||||
border: 1px solid rgba(201,168,76,0.2);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.dua-card.expanded {
|
||||
border-color: rgba(201,168,76,0.5);
|
||||
}
|
||||
.dua-card.faved {
|
||||
border-color: rgba(201,168,76,0.35);
|
||||
}
|
||||
|
||||
.dua-main {
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.dua-main:active { opacity: 0.85; }
|
||||
|
||||
.dua-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
.dua-title {
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
font-weight: 500;
|
||||
font-size: 0.85rem;
|
||||
color: #E8E4DC;
|
||||
}
|
||||
.dua-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dua-category-tag {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.55rem;
|
||||
padding: 2px 6px;
|
||||
background: rgba(201,168,76,0.1);
|
||||
color: #C9A84C;
|
||||
border-radius: 6px;
|
||||
white-space: nowrap;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
.fav-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.1rem;
|
||||
cursor: pointer;
|
||||
color: rgba(232,228,220,0.2);
|
||||
transition: color 0.15s, transform 0.15s;
|
||||
padding: 2px;
|
||||
line-height: 1;
|
||||
}
|
||||
.fav-btn:hover { color: #C9A84C; }
|
||||
.fav-btn.faved { color: #C9A84C; }
|
||||
.fav-btn.faved:active { transform: scale(1.2); }
|
||||
|
||||
/* Arabic text */
|
||||
.dua-arabic {
|
||||
font-family: 'Amiri', 'Noto Naskh Arabic', serif;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.6;
|
||||
color: #C9A84C;
|
||||
direction: rtl;
|
||||
text-align: right;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
/* Expanded details */
|
||||
.dua-details {
|
||||
padding: 0 16px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
border-top: 1px solid rgba(201,168,76,0.1);
|
||||
padding-top: 12px;
|
||||
margin-top: -2px;
|
||||
}
|
||||
|
||||
.detail-row { }
|
||||
.detail-label {
|
||||
display: block;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.55rem;
|
||||
font-weight: 400;
|
||||
color: rgba(201,168,76,0.5);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.benefit-label { color: #2ECC71; }
|
||||
|
||||
.dua-transliteration {
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
font-size: 0.78rem;
|
||||
font-style: italic;
|
||||
color: rgba(232,228,220,0.6);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.dua-translation {
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
font-size: 0.82rem;
|
||||
color: #E8E4DC;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.dua-reference {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.7rem;
|
||||
color: #C9A84C;
|
||||
font-weight: 400;
|
||||
}
|
||||
.dua-benefit {
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
font-size: 0.75rem;
|
||||
color: #2ECC71;
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
+445
-89
@@ -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: '<div style="background:#0f766e;width:14px;height:14px;border-radius:50%;border:2px solid white;box-shadow:0 0 8px rgba(0,0,0,0.3)"></div>', iconSize: [14,14] }) }).addTo(mapInstance).bindPopup('📍 You are here');
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors', maxZoom: 19 }).addTo(mapInstance);
|
||||
L.marker([lat, lng], { icon: L.divIcon({ className: 'user-marker', html: '<div style="background:#C9A84C;width:14px;height:14px;border-radius:50%;border:2px solid white;box-shadow:0 0 8px rgba(0,0,0,0.3)"></div>', 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: `<div style="background:${color};color:white;width:28px;height:28px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:12px;border:2px solid white;box-shadow:0 2px 6px rgba(0,0,0,0.3)">🍽️</div>`, iconSize: [28,28] }) }).bindPopup(`<b>${r.name}</b><br>${r.cuisine} · ${r.distance.toFixed(1)} km<br>${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 = `<b>${r.name}</b><br>${r.cuisine} · ${r.distance.toFixed(1)} km<br>${r.certStatus}`;
|
||||
L.marker([rstLat, rstLng], { icon: L.divIcon({ className: 'resto-marker', html: `<div style="background:${color};color:#070A0D;width:28px;height:28px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:12px;border:2px solid white;box-shadow:0 2px 6px rgba(0,0,0,0.3)">🍽️</div>`, 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();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="card">
|
||||
<h2>🍽️ Halal Food Finder</h2>
|
||||
{#if loading}
|
||||
<p style="color:#5b8c85">📍 Getting your location…</p>
|
||||
<p style="color:rgba(232,228,220,0.55)">📍 Getting your location…</p>
|
||||
{:else if error}
|
||||
<p style="color:#e11d48">{error}</p>
|
||||
<button class="primary" onclick={getLocation}>Retry</button>
|
||||
{:else}
|
||||
<div class="controls">
|
||||
<div class="mode-toggle">
|
||||
<button class="mode-pill" class:active={!globalMode} onclick={() => { if (globalMode) toggleGlobal(); }}>📍 Local</button>
|
||||
<button class="mode-pill" class:active={globalMode} onclick={() => { if (!globalMode) toggleGlobal(); }}>🌍 Global</button>
|
||||
</div>
|
||||
<div class="search-row">
|
||||
<input type="text" placeholder="Search restaurants, cuisines..." bind:value={searchQuery} class="search-input" />
|
||||
<button class="view-toggle" onclick={toggleView} title={viewMode === 'list' ? 'Show map' : 'Show list'}>{viewMode === 'list' ? '🗺️' : '📋'}</button>
|
||||
@@ -111,98 +364,201 @@
|
||||
<div class="radius-row"><span class="radius-label">Radius:</span><div class="radius-pills">{#each radiusOptions as r}<button class="radius-pill" class:active={radius === r} onclick={() => radius = r}>{r}km</button>{/each}</div></div>
|
||||
<div class="filter-row">
|
||||
<button class="filter-chip" class:active={activeFilter === 'all'} onclick={() => activeFilter = 'all'}>All</button>
|
||||
<button class="filter-chip" class:active={activeFilter === 'certified'} onclick={() => activeFilter = 'certified'}>✅ Certified</button>
|
||||
<button class="filter-chip" class:active={activeFilter === 'muslim-owned'} onclick={() => activeFilter = 'muslim-owned'}>👤 Muslim-Owned</button>
|
||||
{#if globalMode}
|
||||
<button class="filter-chip" class:active={activeFilter === 'certified'} onclick={() => activeFilter = 'certified'}>✅ High Confidence</button>
|
||||
{:else}
|
||||
<button class="filter-chip" class:active={activeFilter === 'certified'} onclick={() => activeFilter = 'certified'}>✅ Certified</button>
|
||||
<button class="filter-chip" class:active={activeFilter === 'muslim-owned'} onclick={() => activeFilter = 'muslim-owned'}>👤 Muslim-Owned</button>
|
||||
{/if}
|
||||
<button class="filter-chip" class:active={activeFilter === 'not-halal'} onclick={() => activeFilter = 'not-halal'}>⚠ Not Halal</button>
|
||||
</div>
|
||||
<div class="cuisine-row">{#each cuisineList as c}<button class="cuisine-chip" class:active={cuisineFilter === c} onclick={() => cuisineFilter = c}>{c === 'all' ? '🍴 All' : c}</button>{/each}</div>
|
||||
</div>
|
||||
<div class="results-count">{filtered.length} restaurant{filtered.length !== 1 ? 's' : ''} within {radius}km</div>
|
||||
{#if viewMode === 'list'}
|
||||
{#if filtered.length === 0}
|
||||
<div class="empty-state"><p>😕 No restaurants found</p><p class="hint">Try increasing radius or changing filters</p></div>
|
||||
{:else}
|
||||
<div class="restaurant-list">
|
||||
{#each filtered as resto (resto.id)}
|
||||
<div class="resto-card" class:expanded={expandedId === resto.id}>
|
||||
<button class="resto-header" onclick={() => expandedId = expandedId === resto.id ? null : resto.id}>
|
||||
<div class="resto-top">
|
||||
<div class="resto-name-row"><span class="resto-name">{resto.name}</span><span class="cuisine-tag">{resto.cuisine}</span></div>
|
||||
<div class="resto-meta"><span class="distance">{resto.distance.toFixed(1)} km</span><span class="price">{resto.priceRange}</span><span class="stars">{'⭐'.repeat(Math.round(resto.rating))} {resto.rating}</span></div>
|
||||
</div>
|
||||
<div class="resto-badges"><span class="cert-badge" style="background:{resto.certBadge};color:white">{resto.certStatus}</span><span class="confidence-pill" style="background:{confidenceColor(resto.confidence)};color:white">{resto.confidence}%</span></div>
|
||||
<span class="expand-icon">{expandedId === resto.id ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
{#if expandedId === resto.id}
|
||||
<div class="resto-detail">
|
||||
<p class="detail-address">📍 {resto.address}</p>
|
||||
{#if resto.phone}<p class="detail-phone">📞 <a href="tel:{resto.phone}">{resto.phone}</a></p>{/if}
|
||||
{#if resto.certifications.length > 0}
|
||||
<div class="cert-details"><strong>Certifications:</strong>{#each resto.certifications as certId}{@const cert = getCert(certId)}{#if cert}<div class="cert-item"><span class="cert-trust">{'⭐'.repeat(cert.trustLevel)}</span><span>{cert.name}</span><span class="cert-country">({cert.country})</span></div>{/if}{/each}</div>
|
||||
{:else if resto.muslimOwned}
|
||||
<p class="muslim-owned-note">👤 Muslim-owned establishment. Awaiting or not yet pursuing halal certification.</p>
|
||||
{:else}
|
||||
<p class="no-cert-note">⚠ No halal certification found. Exercise caution.</p>
|
||||
{/if}
|
||||
{#if resto.userReviews.length > 0}
|
||||
<div class="reviews"><strong>Community Reviews ({resto.userReviews.length}):</strong>{#each resto.userReviews as review}<div class="review-item"><span class="review-stars">{'⭐'.repeat(review.rating)}</span><span class="review-text">{review.comment}</span><span class="review-date">{review.date}</span></div>{/each}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if cuisineList.length > 0}
|
||||
<div class="cuisine-row">{#each cuisineList as c}<button class="cuisine-chip" class:active={cuisineFilter === c} onclick={() => cuisineFilter = c}>{c === 'all' ? '🍴 All' : c}</button>{/each}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if globalMode && searching}
|
||||
<p style="color:rgba(232,228,220,0.55);text-align:center;padding:20px 0">🔍 Searching OpenStreetMap for halal restaurants…</p>
|
||||
{:else}
|
||||
<div id="halal-map" class="map-container"></div>
|
||||
<div class="results-count">
|
||||
{filtered.length} restaurant{filtered.length !== 1 ? 's' : ''} within {radius}km
|
||||
{#if globalMode}
|
||||
<span class="result-source"> · from OpenStreetMap</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if viewMode === 'list'}
|
||||
{#if filtered.length === 0}
|
||||
<div class="empty-state"><p>😕 No restaurants found</p><p class="hint">Try increasing radius or changing filters</p></div>
|
||||
{:else}
|
||||
<div class="restaurant-list">
|
||||
{#each filtered as r (r.id || r.uniqueId)}
|
||||
<div class="resto-card" class:expanded={expandedId === (r.id || r.uniqueId)}>
|
||||
<button class="resto-header" onclick={() => { const k = r.id || r.uniqueId; expandedId = expandedId === k ? null : k; }}>
|
||||
<div class="resto-top">
|
||||
<div class="resto-name-row">
|
||||
<span class="resto-name">{r.name}</span>
|
||||
<span class="cuisine-tag">{r.cuisine}</span>
|
||||
</div>
|
||||
<div class="resto-meta">
|
||||
<span class="distance">📏 {r.distance.toFixed(1)} km</span>
|
||||
{#if r.priceRange}
|
||||
<span class="price">{r.priceRange}</span>
|
||||
{/if}
|
||||
{#if r.rating}
|
||||
<span class="stars">⭐ {r.rating}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="resto-badges">
|
||||
<span class="cert-badge" style="background:{r.certBadge?.[0] || 'transparent'};color:{r.certBadge?.[1] || '#E8E4DC'}">{r.certStatus}</span>
|
||||
<span class="confidence-pill" style="background:rgba(46,204,113,0.12);color:#2ECC71">{r.confidence}%</span>
|
||||
</div>
|
||||
<span class="expand-icon">{expandedId === (r.id || r.uniqueId) ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
{#if expandedId === (r.id || r.uniqueId)}
|
||||
<div class="resto-detail">
|
||||
<p class="detail-address">📍 {r.address}</p>
|
||||
{#if r.phone}
|
||||
<p class="detail-phone">📞 <a href="tel:{r.phone}">{r.phone}</a></p>
|
||||
{/if}
|
||||
{#if r._source === 'local' && r.certifications}
|
||||
<div class="cert-details">
|
||||
<strong>📜 Certifications</strong>
|
||||
{#if r.certifications.length > 0}
|
||||
{#each r.certifications as cid}
|
||||
{@const cert = getCert(cid)}
|
||||
{#if cert}
|
||||
<div class="cert-item">
|
||||
<span>✅</span>
|
||||
<span><strong>{cert.name}</strong> ({cert.country})</span>
|
||||
<span class="cert-trust">Trust: {'🟢'.repeat(cert.trustLevel)}{'⚪'.repeat(5 - cert.trustLevel)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{:else}
|
||||
<p class="no-cert-note">No formal halal certification</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if r._source === 'global' && r.halalIndicators && r.halalIndicators.length > 0}
|
||||
<div class="osm-details">
|
||||
<strong>📊 Halal Indicators (OSM Tags)</strong>
|
||||
<div class="osm-tags-grid">
|
||||
{#each r.halalIndicators as ind}
|
||||
<div class="osm-tag-item">
|
||||
<span class="osm-tag-icon">{ind.good ? '✅' : '⚠️'}</span>
|
||||
<span class="osm-tag-label">{ind.label}:</span>
|
||||
<span class="osm-tag-value">{ind.value}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if r._source === 'local' && r.muslimOwned}
|
||||
<div class="muslim-owned-note">👤 Muslim-owned establishment</div>
|
||||
{/if}
|
||||
{#if r._source === 'global' && r.extras && r.extras.length > 0}
|
||||
<div class="osm-extras">
|
||||
<strong>ℹ️ More Info</strong>
|
||||
<div class="osm-tags-grid">
|
||||
{#each r.extras as ext}
|
||||
<div class="osm-tag-item">
|
||||
<span class="osm-tag-label">{ext.label}:</span>
|
||||
<span class="osm-tag-value">{ext.value}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if r._source === 'local' && r.userReviews && r.userReviews.length > 0}
|
||||
<div class="reviews">
|
||||
<strong>💬 Reviews</strong>
|
||||
{#each r.userReviews as rev}
|
||||
<div class="review-item">
|
||||
<span class="review-stars">{'⭐'.repeat(rev.rating)}</span>
|
||||
<span class="review-text">{rev.comment}</span>
|
||||
<span class="review-date">{rev.date}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div id="halal-map" class="map-container"></div>
|
||||
{#if globalMode}
|
||||
<p class="osm-credit">Powered by <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">OpenStreetMap</a> · Data © OSM contributors</p>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.controls { display: flex; flex-direction: column; gap: 10px; margin-bottom: 12px; }
|
||||
.mode-toggle { display: flex; gap: 0; background: #111820; border: 1px solid rgba(201,168,76,0.2); border-radius: 24px; overflow: hidden; width: fit-content; }
|
||||
.mode-pill { padding: 6px 16px; border: none; font-family: 'DM Sans', sans-serif; font-size: 0.75rem; background: transparent; color: rgba(232,228,220,0.45); cursor: pointer; transition: all 0.15s; font-weight: 500; }
|
||||
.mode-pill.active { background: #C9A84C; color: #070A0D; font-weight: 600; }
|
||||
.mode-pill:first-child { border-radius: 24px 0 0 24px; }
|
||||
.mode-pill:last-child { border-radius: 0 24px 24px 0; }
|
||||
.search-row { display: flex; gap: 8px; }
|
||||
.search-input { flex: 1; padding: 10px 14px; border: 1px solid #99d6c9; border-radius: 10px; font-size: 0.9rem; color: #134e4a; background: #f0fdfa; outline: none; }
|
||||
.search-input:focus { border-color: #0f766e; box-shadow: 0 0 0 2px rgba(15, 118, 110, 0.15); }
|
||||
.view-toggle { width: 44px; height: 44px; border: 1px solid #99d6c9; border-radius: 10px; background: white; font-size: 1.2rem; cursor: pointer; display: flex; align-items: center; justify-content: center; }
|
||||
.search-input { flex: 1; padding: 10px 14px; border: 1px solid rgba(201,168,76,0.2); border-radius: 10px; font-size: 0.9rem; color: #E8E4DC; background: #111820; font-family: 'DM Sans', sans-serif; outline: none; }
|
||||
.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); }
|
||||
.view-toggle { width: 44px; height: 44px; border: 1px solid rgba(201,168,76,0.2); border-radius: 10px; background: #111820; font-size: 1.2rem; cursor: pointer; display: flex; align-items: center; justify-content: center; }
|
||||
.radius-row { display: flex; align-items: center; gap: 8px; }
|
||||
.radius-label { font-size: 0.75rem; color: #5b8c85; font-weight: 600; }
|
||||
.radius-label { font-family: 'JetBrains Mono', monospace; font-size: 0.7rem; font-weight: 300; color: rgba(232,228,220,0.55); text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.radius-pills { display: flex; gap: 4px; }
|
||||
.radius-pill { padding: 4px 10px; border: 1px solid #99d6c9; border-radius: 16px; font-size: 0.7rem; background: white; color: #0f766e; cursor: pointer; transition: all 0.15s; }
|
||||
.radius-pill.active { background: #0f766e; color: white; border-color: #0f766e; }
|
||||
.radius-pill { padding: 4px 10px; border: 1px solid rgba(201,168,76,0.2); border-radius: 16px; font-family: 'JetBrains Mono', monospace; font-size: 0.65rem; background: transparent; color: rgba(232,228,220,0.55); cursor: pointer; transition: all 0.15s; }
|
||||
.radius-pill.active { background: #C9A84C; color: #070A0D; border-color: #C9A84C; }
|
||||
.filter-row { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.filter-chip { padding: 5px 12px; border: 1px solid #99d6c9; border-radius: 16px; font-size: 0.7rem; background: white; color: #0f766e; cursor: pointer; transition: all 0.15s; }
|
||||
.filter-chip.active { background: #ccfbf1; border-color: #0f766e; font-weight: 600; }
|
||||
.filter-chip { padding: 5px 12px; border: 1px solid rgba(201,168,76,0.2); border-radius: 16px; font-family: 'DM Sans', sans-serif; font-size: 0.7rem; background: transparent; color: rgba(232,228,220,0.55); cursor: pointer; transition: all 0.15s; }
|
||||
.filter-chip.active { background: rgba(201,168,76,0.15); border-color: #C9A84C; color: #C9A84C; font-weight: 600; }
|
||||
.cuisine-row { display: flex; gap: 4px; flex-wrap: wrap; }
|
||||
.cuisine-chip { padding: 3px 8px; border: 1px solid #e2e8f0; border-radius: 12px; font-size: 0.65rem; background: #f8fafc; color: #64748b; cursor: pointer; transition: all 0.15s; }
|
||||
.cuisine-chip.active { background: #0f766e; color: white; border-color: #0f766e; }
|
||||
.results-count { font-size: 0.75rem; color: #5b8c85; margin-bottom: 10px; padding-left: 4px; }
|
||||
.empty-state { text-align: center; padding: 30px 0; color: #5b8c85; }
|
||||
.empty-state .hint { font-size: 0.75rem; color: #94a3a8; margin-top: 6px; }
|
||||
.cuisine-chip { padding: 3px 8px; border: 1px solid rgba(201,168,76,0.1); border-radius: 12px; font-family: 'DM Sans', sans-serif; font-size: 0.6rem; background: transparent; color: rgba(232,228,220,0.3); cursor: pointer; transition: all 0.15s; }
|
||||
.cuisine-chip.active { background: rgba(46,204,113,0.12); color: #2ECC71; border-color: rgba(46,204,113,0.2); }
|
||||
.results-count { font-family: 'JetBrains Mono', monospace; font-size: 0.7rem; font-weight: 300; color: rgba(232,228,220,0.55); margin-bottom: 10px; padding-left: 4px; }
|
||||
.result-source { font-family: 'DM Sans', sans-serif; font-size: 0.65rem; color: rgba(232,228,220,0.3); }
|
||||
.empty-state { text-align: center; padding: 30px 0; color: rgba(232,228,220,0.55); }
|
||||
.empty-state .hint { font-family: 'DM Sans', sans-serif; font-size: 0.75rem; color: rgba(232,228,220,0.3); margin-top: 6px; }
|
||||
.restaurant-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.resto-card { background: #f0fdfa; border-radius: 12px; overflow: hidden; }
|
||||
.resto-card.expanded { box-shadow: 0 2px 8px rgba(15, 118, 110, 0.1); }
|
||||
.resto-card { background: #111820; border: 1px solid rgba(201,168,76,0.2); border-radius: 12px; overflow: hidden; }
|
||||
.resto-card.expanded { border-color: rgba(201,168,76,0.4); }
|
||||
.resto-header { width: 100%; display: flex; align-items: flex-start; justify-content: space-between; padding: 12px; border: none; background: transparent; cursor: pointer; text-align: left; gap: 8px; }
|
||||
.resto-top { flex: 1; min-width: 0; }
|
||||
.resto-name-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 4px; }
|
||||
.resto-name { font-weight: 600; color: #134e4a; font-size: 0.9rem; }
|
||||
.cuisine-tag { font-size: 0.65rem; padding: 2px 8px; background: #ccfbf1; color: #0f766e; border-radius: 8px; font-weight: 500; white-space: nowrap; }
|
||||
.resto-meta { display: flex; gap: 10px; font-size: 0.7rem; color: #5b8c85; align-items: center; }
|
||||
.distance { font-weight: 600; } .price { color: #0f766e; } .stars { font-size: 0.65rem; }
|
||||
.resto-name { font-family: 'DM Sans', sans-serif; font-weight: 600; color: #E8E4DC; font-size: 0.9rem; }
|
||||
.cuisine-tag { font-family: 'JetBrains Mono', monospace; font-size: 0.6rem; padding: 2px 8px; background: rgba(46,204,113,0.12); color: #2ECC71; border-radius: 8px; font-weight: 400; letter-spacing: 0.3px; white-space: nowrap; }
|
||||
.resto-meta { display: flex; gap: 10px; font-family: 'JetBrains Mono', monospace; font-size: 0.65rem; color: rgba(232,228,220,0.55); align-items: center; }
|
||||
.distance { font-weight: 400; } .price { color: rgba(232,228,220,0.55); } .stars { font-size: 0.6rem; }
|
||||
.resto-badges { display: flex; flex-direction: column; gap: 4px; align-items: flex-end; flex-shrink: 0; }
|
||||
.cert-badge { font-size: 0.6rem; padding: 2px 6px; border-radius: 6px; white-space: nowrap; max-width: 140px; overflow: hidden; text-overflow: ellipsis; }
|
||||
.confidence-pill { font-size: 0.6rem; padding: 2px 6px; border-radius: 6px; font-weight: 600; }
|
||||
.expand-icon { font-size: 0.7rem; color: #94a3a8; padding-top: 2px; }
|
||||
.resto-detail { padding: 0 12px 12px; border-top: 1px solid #ccfbf1; margin-top: 4px; font-size: 0.78rem; color: #134e4a; }
|
||||
.detail-address { margin-bottom: 4px; } .detail-phone { margin-bottom: 8px; } .detail-phone a { color: #0f766e; text-decoration: none; }
|
||||
.cert-details { margin-top: 8px; padding: 10px; background: white; border-radius: 8px; }
|
||||
.cert-details strong { display: block; margin-bottom: 6px; font-size: 0.75rem; color: #0f766e; }
|
||||
.cert-item { display: flex; gap: 6px; align-items: center; font-size: 0.72rem; margin-bottom: 3px; }
|
||||
.cert-trust { font-size: 0.6rem; } .cert-country { color: #94a3a8; font-size: 0.65rem; }
|
||||
.muslim-owned-note { margin-top: 8px; padding: 8px 10px; background: #fef3c7; border-radius: 8px; color: #92400e; font-size: 0.72rem; }
|
||||
.no-cert-note { margin-top: 8px; padding: 8px 10px; background: #fee2e2; border-radius: 8px; color: #991b1b; font-size: 0.72rem; }
|
||||
.reviews { margin-top: 10px; } .reviews strong { display: block; margin-bottom: 6px; font-size: 0.72rem; color: #0f766e; }
|
||||
.review-item { padding: 8px; background: white; border-radius: 8px; margin-bottom: 4px; font-size: 0.7rem; }
|
||||
.review-stars { font-size: 0.65rem; } .review-text { display: block; margin: 2px 0; } .review-date { font-size: 0.6rem; color: #94a3a8; }
|
||||
.map-container { width: 100%; height: 400px; border-radius: 12px; overflow: hidden; border: 1px solid #99d6c9; }
|
||||
.cert-badge { font-family: 'JetBrains Mono', monospace; font-size: 0.55rem; padding: 2px 6px; border-radius: 6px; white-space: nowrap; max-width: 140px; overflow: hidden; text-overflow: ellipsis; }
|
||||
.confidence-pill { font-family: 'JetBrains Mono', monospace; font-size: 0.55rem; padding: 2px 6px; border-radius: 6px; font-weight: 400; }
|
||||
.expand-icon { font-size: 0.7rem; color: rgba(232,228,220,0.3); padding-top: 2px; }
|
||||
.resto-detail { padding: 0 12px 12px; border-top: 1px solid rgba(201,168,76,0.1); margin-top: 4px; font-family: 'DM Sans', sans-serif; font-size: 0.78rem; color: #E8E4DC; }
|
||||
.detail-address { margin-bottom: 4px; } .detail-phone { margin-bottom: 8px; } .detail-phone a { color: #C9A84C; text-decoration: none; }
|
||||
.cert-details { margin-top: 8px; padding: 10px; background: #0C1117; border: 1px solid rgba(201,168,76,0.1); border-radius: 8px; }
|
||||
.cert-details strong { display: block; margin-bottom: 6px; font-family: 'JetBrains Mono', monospace; font-size: 0.7rem; color: #C9A84C; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.cert-item { display: flex; gap: 6px; align-items: center; font-size: 0.72rem; }
|
||||
.cert-trust { font-size: 0.55rem; }
|
||||
.muslim-owned-note { margin-top: 8px; padding: 8px 10px; background: rgba(46,204,113,0.1); border: 1px solid rgba(46,204,113,0.2); border-radius: 8px; color: #2ECC71; font-family: 'JetBrains Mono', monospace; font-size: 0.68rem; }
|
||||
.no-cert-note { margin-top: 8px; padding: 8px 10px; background: rgba(201,168,76,0.08); border: 1px solid rgba(201,168,76,0.15); border-radius: 8px; color: #C9A84C; font-family: 'JetBrains Mono', monospace; font-size: 0.68rem; }
|
||||
.reviews { margin-top: 10px; } .reviews strong { display: block; margin-bottom: 6px; font-family: 'JetBrains Mono', monospace; font-size: 0.7rem; color: #C9A84C; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.review-item { padding: 8px; background: #0C1117; border: 1px solid rgba(201,168,76,0.08); border-radius: 8px; margin-bottom: 4px; font-size: 0.7rem; }
|
||||
.review-stars { font-size: 0.6rem; } .review-text { display: block; margin: 2px 0; color: rgba(232,228,220,0.8); } .review-date { font-family: 'JetBrains Mono', monospace; font-size: 0.55rem; color: rgba(232,228,220,0.3); }
|
||||
.map-container { width: 100%; height: 400px; border-radius: 12px; overflow: hidden; border: 1px solid rgba(201,168,76,0.2); }
|
||||
.osm-credit { text-align: center; font-family: 'DM Sans', sans-serif; font-size: 0.65rem; color: rgba(232,228,220,0.3); margin-top: 6px; }
|
||||
.osm-credit a { color: #C9A84C; text-decoration: none; }
|
||||
.osm-details { margin-top: 8px; padding: 10px; background: #0C1117; border: 1px solid rgba(201,168,76,0.1); border-radius: 8px; }
|
||||
.osm-details strong { display: block; margin-bottom: 6px; font-family: 'JetBrains Mono', monospace; font-size: 0.7rem; color: #C9A84C; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.osm-extras { margin-top: 8px; padding: 10px; background: #0C1117; border: 1px solid rgba(201,168,76,0.1); border-radius: 8px; }
|
||||
.osm-extras strong { display: block; margin-bottom: 6px; font-family: 'JetBrains Mono', monospace; font-size: 0.7rem; color: #C9A84C; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.osm-tags-grid { display: flex; flex-direction: column; gap: 4px; }
|
||||
.osm-tag-item { display: flex; gap: 6px; align-items: center; font-size: 0.72rem; color: #E8E4DC; }
|
||||
.osm-tag-icon { font-size: 0.6rem; }
|
||||
.osm-tag-label { font-family: 'JetBrains Mono', monospace; font-size: 0.65rem; color: rgba(232,228,220,0.55); }
|
||||
.osm-tag-value { font-family: 'DM Sans', sans-serif; font-size: 0.72rem; color: #E8E4DC; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,484 @@
|
||||
<script>
|
||||
const SURAHS = [
|
||||
{ n:1, en:'Al-Fatihah', ar:'الفاتحة', j:1, v:7, r:'Meccan' },
|
||||
{ n:2, en:'Al-Baqarah', ar:'البقرة', j:1, v:286, r:'Medinan' },
|
||||
{ n:3, en:'Aal-e-Imran', ar:'آل عمران', j:3, v:200, r:'Medinan' },
|
||||
{ n:4, en:'An-Nisa', ar:'النساء', j:4, v:176, r:'Medinan' },
|
||||
{ n:5, en:'Al-Maidah', ar:'المائدة', j:6, v:120, r:'Medinan' },
|
||||
{ n:6, en:'Al-Anam', ar:'الأنعام', j:7, v:165, r:'Meccan' },
|
||||
{ n:7, en:'Al-Araf', ar:'الأعراف', j:8, v:206, r:'Meccan' },
|
||||
{ n:8, en:'Al-Anfal', ar:'الأنفال', j:9, v:75, r:'Medinan' },
|
||||
{ n:9, en:'At-Tawbah', ar:'التوبة', j:10, v:129, r:'Medinan' },
|
||||
{ n:10, en:'Yunus', ar:'يونس', j:11, v:109, r:'Meccan' },
|
||||
{ n:11, en:'Hud', ar:'هود', j:11, v:123, r:'Meccan' },
|
||||
{ n:12, en:'Yusuf', ar:'يوسف', j:12, v:111, r:'Meccan' },
|
||||
{ n:13, en:'Ar-Rad', ar:'الرعد', j:13, v:43, r:'Medinan' },
|
||||
{ n:14, en:'Ibrahim', ar:'إبراهيم', j:13, v:52, r:'Meccan' },
|
||||
{ n:15, en:'Al-Hijr', ar:'الحجر', j:14, v:99, r:'Meccan' },
|
||||
{ n:16, en:'An-Nahl', ar:'النحل', j:14, v:128, r:'Meccan' },
|
||||
{ n:17, en:'Al-Isra', ar:'الإسراء', j:15, v:111, r:'Meccan' },
|
||||
{ n:18, en:'Al-Kahf', ar:'الكهف', j:15, v:110, r:'Meccan' },
|
||||
{ n:19, en:'Maryam', ar:'مريم', j:16, v:98, r:'Meccan' },
|
||||
{ n:20, en:'Ta-Ha', ar:'طه', j:16, v:135, r:'Meccan' },
|
||||
{ n:21, en:'Al-Anbiya', ar:'الأنبياء', j:17, v:112, r:'Meccan' },
|
||||
{ n:22, en:'Al-Hajj', ar:'الحج', j:17, v:78, r:'Medinan' },
|
||||
{ n:23, en:'Al-Muminun', ar:'المؤمنون', j:18, v:118, r:'Meccan' },
|
||||
{ n:24, en:'An-Nur', ar:'النور', j:18, v:64, r:'Medinan' },
|
||||
{ n:25, en:'Al-Furqan', ar:'الفرقان', j:18, v:77, r:'Meccan' },
|
||||
{ n:26, en:'Ash-Shuara', ar:'الشعراء', j:19, v:227, r:'Meccan' },
|
||||
{ n:27, en:'An-Naml', ar:'النمل', j:19, v:93, r:'Meccan' },
|
||||
{ n:28, en:'Al-Qasas', ar:'القصص', j:20, v:88, r:'Meccan' },
|
||||
{ n:29, en:'Al-Ankabut', ar:'العنكبوت', j:20, v:69, r:'Meccan' },
|
||||
{ n:30, en:'Ar-Rum', ar:'الروم', j:21, v:60, r:'Meccan' },
|
||||
{ n:31, en:'Luqman', ar:'لقمان', j:21, v:34, r:'Meccan' },
|
||||
{ n:32, en:'As-Sajdah', ar:'السجدة', j:21, v:30, r:'Meccan' },
|
||||
{ n:33, en:'Al-Ahzab', ar:'الأحزاب', j:21, v:73, r:'Medinan' },
|
||||
{ n:34, en:'Saba', ar:'سبأ', j:22, v:54, r:'Meccan' },
|
||||
{ n:35, en:'Fatir', ar:'فاطر', j:22, v:45, r:'Meccan' },
|
||||
{ n:36, en:'Ya-Sin', ar:'يس', j:22, v:83, r:'Meccan' },
|
||||
{ n:37, en:'As-Saffat', ar:'الصافات', j:23, v:182, r:'Meccan' },
|
||||
{ n:38, en:'Sad', ar:'ص', j:23, v:88, r:'Meccan' },
|
||||
{ n:39, en:'Az-Zumar', ar:'الزمر', j:23, v:75, r:'Meccan' },
|
||||
{ n:40, en:'Ghafir', ar:'غافر', j:24, v:85, r:'Meccan' },
|
||||
{ n:41, en:'Fussilat', ar:'فصلت', j:24, v:54, r:'Meccan' },
|
||||
{ n:42, en:'Ash-Shura', ar:'الشورى', j:25, v:53, r:'Meccan' },
|
||||
{ n:43, en:'Az-Zukhruf', ar:'الزخرف', j:25, v:89, r:'Meccan' },
|
||||
{ n:44, en:'Ad-Dukhan', ar:'الدخان', j:25, v:59, r:'Meccan' },
|
||||
{ n:45, en:'Al-Jathiyah', ar:'الجاثية', j:25, v:37, r:'Meccan' },
|
||||
{ n:46, en:'Al-Ahqaf', ar:'الأحقاف', j:26, v:35, r:'Meccan' },
|
||||
{ n:47, en:'Muhammad', ar:'محمد', j:26, v:38, r:'Medinan' },
|
||||
{ n:48, en:'Al-Fath', ar:'الفتح', j:26, v:29, r:'Medinan' },
|
||||
{ n:49, en:'Al-Hujurat', ar:'الحجرات', j:26, v:18, r:'Medinan' },
|
||||
{ n:50, en:'Qaf', ar:'ق', j:26, v:45, r:'Meccan' },
|
||||
{ n:51, en:'Adh-Dhariyat', ar:'الذاريات', j:26, v:60, r:'Meccan' },
|
||||
{ n:52, en:'At-Tur', ar:'الطور', j:27, v:49, r:'Meccan' },
|
||||
{ n:53, en:'An-Najm', ar:'النجم', j:27, v:62, r:'Meccan' },
|
||||
{ n:54, en:'Al-Qamar', ar:'القمر', j:27, v:55, r:'Meccan' },
|
||||
{ n:55, en:'Ar-Rahman', ar:'الرحمن', j:27, v:78, r:'Medinan' },
|
||||
{ n:56, en:'Al-Waqiah', ar:'الواقعة', j:27, v:96, r:'Meccan' },
|
||||
{ n:57, en:'Al-Hadid', ar:'الحديد', j:27, v:29, r:'Medinan' },
|
||||
{ n:58, en:'Al-Mujadilah', ar:'المجادلة', j:28, v:22, r:'Medinan' },
|
||||
{ n:59, en:'Al-Hashr', ar:'الحشر', j:28, v:24, r:'Medinan' },
|
||||
{ n:60, en:'Al-Mumtahanah', ar:'الممتحنة', j:28, v:13, r:'Medinan' },
|
||||
{ n:61, en:'As-Saff', ar:'الصف', j:28, v:14, r:'Medinan' },
|
||||
{ n:62, en:'Al-Jumuah', ar:'الجمعة', j:28, v:11, r:'Medinan' },
|
||||
{ n:63, en:'Al-Munafiqun', ar:'المنافقون', j:28, v:11, r:'Medinan' },
|
||||
{ n:64, en:'At-Taghabun', ar:'التغابن', j:28, v:18, r:'Medinan' },
|
||||
{ n:65, en:'At-Talaq', ar:'الطلاق', j:28, v:12, r:'Medinan' },
|
||||
{ n:66, en:'At-Tahrim', ar:'التحريم', j:28, v:12, r:'Medinan' },
|
||||
{ n:67, en:'Al-Mulk', ar:'الملك', j:29, v:30, r:'Meccan' },
|
||||
{ n:68, en:'Al-Qalam', ar:'القلم', j:29, v:52, r:'Meccan' },
|
||||
{ n:69, en:'Al-Haqqah', ar:'الحاقة', j:29, v:52, r:'Meccan' },
|
||||
{ n:70, en:'Al-Maarij', ar:'المعارج', j:29, v:44, r:'Meccan' },
|
||||
{ n:71, en:'Nuh', ar:'نوح', j:29, v:28, r:'Meccan' },
|
||||
{ n:72, en:'Al-Jinn', ar:'الجن', j:29, v:28, r:'Meccan' },
|
||||
{ n:73, en:'Al-Muzzammil', ar:'المزمل', j:29, v:20, r:'Meccan' },
|
||||
{ n:74, en:'Al-Muddaththir', ar:'المدثر', j:29, v:56, r:'Meccan' },
|
||||
{ n:75, en:'Al-Qiyamah', ar:'القيامة', j:29, v:40, r:'Meccan' },
|
||||
{ n:76, en:'Al-Insan', ar:'الإنسان', j:29, v:31, r:'Medinan' },
|
||||
{ n:77, en:'Al-Mursalat', ar:'المرسلات', j:29, v:50, r:'Meccan' },
|
||||
{ n:78, en:'An-Naba', ar:'النبأ', j:30, v:40, r:'Meccan' },
|
||||
{ n:79, en:'An-Naziat', ar:'النازعات', j:30, v:46, r:'Meccan' },
|
||||
{ n:80, en:'Abasa', ar:'عبس', j:30, v:42, r:'Meccan' },
|
||||
{ n:81, en:'At-Takwir', ar:'التكوير', j:30, v:29, r:'Meccan' },
|
||||
{ n:82, en:'Al-Infitar', ar:'الإنفطار', j:30, v:19, r:'Meccan' },
|
||||
{ n:83, en:'Al-Mutaffifin', ar:'المطففين', j:30, v:36, r:'Meccan' },
|
||||
{ n:84, en:'Al-Inshiqaq', ar:'الإنشقاق', j:30, v:25, r:'Meccan' },
|
||||
{ n:85, en:'Al-Buruj', ar:'البروج', j:30, v:22, r:'Meccan' },
|
||||
{ n:86, en:'At-Tariq', ar:'الطارق', j:30, v:17, r:'Meccan' },
|
||||
{ n:87, en:'Al-Ala', ar:'الأعلى', j:30, v:19, r:'Meccan' },
|
||||
{ n:88, en:'Al-Ghashiyah', ar:'الغاشية', j:30, v:26, r:'Meccan' },
|
||||
{ n:89, en:'Al-Fajr', ar:'الفجر', j:30, v:30, r:'Meccan' },
|
||||
{ n:90, en:'Al-Balad', ar:'البلد', j:30, v:20, r:'Meccan' },
|
||||
{ n:91, en:'Ash-Shams', ar:'الشمس', j:30, v:15, r:'Meccan' },
|
||||
{ n:92, en:'Al-Layl', ar:'الليل', j:30, v:21, r:'Meccan' },
|
||||
{ n:93, en:'Ad-Duhaa', ar:'الضحى', j:30, v:11, r:'Meccan' },
|
||||
{ n:94, en:'Ash-Sharh', ar:'الشرح', j:30, v:8, r:'Meccan' },
|
||||
{ n:95, en:'At-Tin', ar:'التين', j:30, v:8, r:'Meccan' },
|
||||
{ n:96, en:'Al-Alaq', ar:'العلق', j:30, v:19, r:'Meccan' },
|
||||
{ n:97, en:'Al-Qadr', ar:'القدر', j:30, v:5, r:'Meccan' },
|
||||
{ n:98, en:'Al-Bayyinah', ar:'البينة', j:30, v:8, r:'Medinan' },
|
||||
{ n:99, en:'Az-Zalzalah', ar:'الزلزلة', j:30, v:8, r:'Medinan' },
|
||||
{ n:100, en:'Al-Adiyat', ar:'العاديات', j:30, v:11, r:'Meccan' },
|
||||
{ n:101, en:'Al-Qariah', ar:'القارعة', j:30, v:11, r:'Meccan' },
|
||||
{ n:102, en:'At-Takathur', ar:'التكاثر', j:30, v:8, r:'Meccan' },
|
||||
{ n:103, en:'Al-Asr', ar:'العصر', j:30, v:3, r:'Meccan' },
|
||||
{ n:104, en:'Al-Humazah', ar:'الهمزة', j:30, v:9, r:'Meccan' },
|
||||
{ n:105, en:'Al-Fil', ar:'الفيل', j:30, v:5, r:'Meccan' },
|
||||
{ n:106, en:'Quraysh', ar:'قريش', j:30, v:4, r:'Meccan' },
|
||||
{ n:107, en:'Al-Maun', ar:'الماعون', j:30, v:7, r:'Meccan' },
|
||||
{ n:108, en:'Al-Kawthar', ar:'الكوثر', j:30, v:3, r:'Meccan' },
|
||||
{ n:109, en:'Al-Kafirun', ar:'الكافرون', j:30, v:6, r:'Meccan' },
|
||||
{ n:110, en:'An-Nasr', ar:'النصر', j:30, v:3, r:'Medinan' },
|
||||
{ n:111, en:'Al-Masad', ar:'المسد', j:30, v:5, r:'Meccan' },
|
||||
{ n:112, en:'Al-Ikhlas', ar:'الإخلاص', j:30, v:4, r:'Meccan' },
|
||||
{ n:113, en:'Al-Falaq', ar:'الفلق', j:30, v:5, r:'Meccan' },
|
||||
{ n:114, en:'An-Nas', ar:'الناس', j:30, v:6, r:'Meccan' },
|
||||
];
|
||||
|
||||
const STATUSES = ['not_started', 'learning', 'memorized', 'reviewing'];
|
||||
const STATUS_LABELS = { not_started:'Not Started', learning:'Learning', memorized:'Memorized', reviewing:'Reviewing' };
|
||||
const STATUS_COLORS = { not_started:'#9ca3af', learning:'#C9A84C', memorized:'#2ECC71', reviewing:'#60A5FA' };
|
||||
const STATUS_NEXT = { not_started:'learning', learning:'memorized', memorized:'reviewing', reviewing:'not_started' };
|
||||
|
||||
function loadProgress() {
|
||||
try {
|
||||
const raw = localStorage.getItem('nur-hifdh');
|
||||
return raw ? JSON.parse(raw) : {};
|
||||
} catch { return {}; }
|
||||
}
|
||||
|
||||
function loadStreak() {
|
||||
try {
|
||||
const raw = localStorage.getItem('nur-hifdh-streak');
|
||||
return raw ? JSON.parse(raw) : { lastDate: '', count: 0 };
|
||||
} catch { return { lastDate: '', count: 0 }; }
|
||||
}
|
||||
|
||||
let progress = $state(loadProgress());
|
||||
let filter = $state('all');
|
||||
let query = $state('');
|
||||
let streak = $state(loadStreak());
|
||||
|
||||
const filteredSurahs = $derived.by(() => {
|
||||
let list = SURAHS;
|
||||
if (filter !== 'all') {
|
||||
list = list.filter(s => (progress[s.n] || 'not_started') === filter);
|
||||
}
|
||||
if (query.trim()) {
|
||||
const q = query.toLowerCase();
|
||||
list = list.filter(s => s.en.toLowerCase().includes(q) || s.ar.includes(q));
|
||||
}
|
||||
return list;
|
||||
});
|
||||
|
||||
const memorizedCount = $derived(SURAHS.filter(s => (progress[s.n] || 'not_started') === 'memorized').length);
|
||||
const learningCount = $derived(SURAHS.filter(s => (progress[s.n] || 'not_started') === 'learning').length);
|
||||
const reviewingCount = $derived(SURAHS.filter(s => (progress[s.n] || 'not_started') === 'reviewing').length);
|
||||
const percentMemorized = $derived(Math.round((memorizedCount / 114) * 100));
|
||||
|
||||
function cycleStatus(n) {
|
||||
const current = progress[n] || 'not_started';
|
||||
progress = { ...progress, [n]: STATUS_NEXT[current] };
|
||||
localStorage.setItem('nur-hifdh', JSON.stringify(progress));
|
||||
}
|
||||
|
||||
function updateStreak() {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
if (streak.lastDate === today) return;
|
||||
const yesterday = new Date(Date.now() - 864e5).toISOString().slice(0, 10);
|
||||
const newCount = streak.lastDate === yesterday ? streak.count + 1 : 1;
|
||||
streak = { lastDate: today, count: newCount };
|
||||
localStorage.setItem('nur-hifdh-streak', JSON.stringify(streak));
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const hasProgress = SURAHS.some(s => (progress[s.n] || 'not_started') !== 'not_started');
|
||||
if (hasProgress) updateStreak();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="hifdh">
|
||||
<div class="card stats-card">
|
||||
<div class="stats-row">
|
||||
<div class="stat">
|
||||
<span class="stat-num" style="color:#2ECC71">{memorizedCount}</span>
|
||||
<span class="stat-label">Memorized</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-num" style="color:#C9A84C">{learningCount}</span>
|
||||
<span class="stat-label">Learning</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-num" style="color:#60A5FA">{reviewingCount}</span>
|
||||
<span class="stat-label">Reviewing</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-num" style="color:#9ca3af">{streak.count}</span>
|
||||
<span class="stat-label">Day Streak</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card ring-card">
|
||||
<div class="ring-container">
|
||||
<svg viewBox="0 0 120 120" class="progress-ring">
|
||||
<circle cx="60" cy="60" r="52" fill="none" stroke="#1A2430" stroke-width="10" />
|
||||
<circle cx="60" cy="60" r="52" fill="none" stroke="#2ECC71" stroke-width="10"
|
||||
stroke-linecap="round" stroke-dasharray="326.7"
|
||||
stroke-dashoffset={326.7 - (326.7 * percentMemorized / 100)}
|
||||
transform="rotate(-90 60 60)" />
|
||||
</svg>
|
||||
<div class="ring-text">
|
||||
<span class="ring-pct">{percentMemorized}%</span>
|
||||
<span class="ring-label">Memorized</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ring-fact">
|
||||
<span class="fact-num">{memorizedCount}/114</span>
|
||||
<span class="fact-label">surahs completed</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card search-card">
|
||||
<input
|
||||
type="text"
|
||||
class="search-input"
|
||||
placeholder="Search surah..."
|
||||
bind:value={query}
|
||||
/>
|
||||
<div class="filter-chips">
|
||||
<button class="chip" class:chip-active={filter === 'all'} onclick={() => filter = 'all'}>All</button>
|
||||
<button class="chip" style:border-color="#2ECC71" class:chip-active={filter === 'memorized'} onclick={() => filter = 'memorized'}>Memorized</button>
|
||||
<button class="chip" style:border-color="#C9A84C" class:chip-active={filter === 'learning'} onclick={() => filter = 'learning'}>Learning</button>
|
||||
<button class="chip" style:border-color="#60A5FA" class:chip-active={filter === 'reviewing'} onclick={() => filter = 'reviewing'}>Reviewing</button>
|
||||
<button class="chip" style:border-color="#9ca3af" class:chip-active={filter === 'not_started'} onclick={() => filter = 'not_started'}>Not Started</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="surah-list">
|
||||
{#each filteredSurahs as s (s.n)}
|
||||
{@const status = progress[s.n] || 'not_started'}
|
||||
<button class="surah-item" onclick={() => cycleStatus(s.n)}>
|
||||
<div class="surah-left">
|
||||
<span class="surah-num">{s.n}</span>
|
||||
<div class="surah-names">
|
||||
<span class="surah-en">{s.en}</span>
|
||||
<span class="surah-ar">{s.ar}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="surah-right">
|
||||
<span class="surah-juz">Juz {s.j}</span>
|
||||
<span class="surah-verse-count">{s.v} verses</span>
|
||||
<span class="status-badge" style:background={STATUS_COLORS[status]}>{STATUS_LABELS[status]}</span>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.hifdh {
|
||||
padding: 8px 0 16px;
|
||||
}
|
||||
|
||||
.stats-card {
|
||||
background: #141C24;
|
||||
border: 1px solid rgba(201,168,76,0.2);
|
||||
border-radius: 16px;
|
||||
padding: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.stats-row {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
gap: 4px;
|
||||
}
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
.stat-num {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
.stat-label {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.55rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: rgba(232,228,220,0.5);
|
||||
}
|
||||
|
||||
.ring-card {
|
||||
background: #141C24;
|
||||
border: 1px solid rgba(201,168,76,0.2);
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.ring-container {
|
||||
position: relative;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.progress-ring { width: 100%; height: 100%; }
|
||||
.ring-text {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.ring-pct {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 1.8rem;
|
||||
font-weight: 700;
|
||||
color: #2ECC71;
|
||||
line-height: 1;
|
||||
}
|
||||
.ring-label {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.55rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: rgba(232,228,220,0.5);
|
||||
}
|
||||
.ring-fact {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.fact-num {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #E8E4DC;
|
||||
line-height: 1;
|
||||
}
|
||||
.fact-label {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.6rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: rgba(232,228,220,0.4);
|
||||
}
|
||||
|
||||
.search-card {
|
||||
background: #141C24;
|
||||
border: 1px solid rgba(201,168,76,0.2);
|
||||
border-radius: 16px;
|
||||
padding: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.search-input {
|
||||
width: 100%;
|
||||
background: #111820;
|
||||
color: #E8E4DC;
|
||||
border: 1px solid rgba(201,168,76,0.2);
|
||||
border-radius: 10px;
|
||||
padding: 10px 14px;
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
font-size: 0.85rem;
|
||||
outline: none;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.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); }
|
||||
.filter-chips {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.chip {
|
||||
padding: 4px 12px;
|
||||
border: 1px solid rgba(232,228,220,0.2);
|
||||
background: transparent;
|
||||
color: rgba(232,228,220,0.6);
|
||||
border-radius: 20px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.6rem;
|
||||
cursor: pointer;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.chip-active, .chip:hover {
|
||||
background: rgba(201,168,76,0.15);
|
||||
color: #C9A84C;
|
||||
border-color: #C9A84C;
|
||||
}
|
||||
|
||||
.surah-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.surah-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #141C24;
|
||||
border: 1px solid rgba(201,168,76,0.15);
|
||||
border-radius: 14px;
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
.surah-item:active {
|
||||
background: #1A2430;
|
||||
border-color: rgba(201,168,76,0.3);
|
||||
}
|
||||
.surah-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.surah-num {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
color: #C9A84C;
|
||||
width: 30px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.surah-names {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.surah-en {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: #E8E4DC;
|
||||
}
|
||||
.surah-ar {
|
||||
font-family: 'Helvetica Neue', sans-serif;
|
||||
font-size: 0.7rem;
|
||||
color: rgba(232,228,220,0.5);
|
||||
direction: rtl;
|
||||
}
|
||||
.surah-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
}
|
||||
.surah-juz {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.55rem;
|
||||
color: rgba(232,228,220,0.35);
|
||||
}
|
||||
.surah-verse-count {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.55rem;
|
||||
color: rgba(232,228,220,0.35);
|
||||
}
|
||||
.status-badge {
|
||||
padding: 2px 10px;
|
||||
border-radius: 10px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.55rem;
|
||||
font-weight: 500;
|
||||
color: #070A0D;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
</style>
|
||||
+41
-11
@@ -29,7 +29,7 @@
|
||||
<h2>📅 Hijri Calendar</h2>
|
||||
|
||||
{#if loading}
|
||||
<p style="color:#5b8c85">⏳ Loading…</p>
|
||||
<p style="color:rgba(232,228,220,0.55)">⏳ Loading…</p>
|
||||
{:else if hijriDate}
|
||||
<div class="date-display">
|
||||
<div class="hijri-big">
|
||||
@@ -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);
|
||||
}
|
||||
</style>
|
||||
.info-row span:last-child {
|
||||
color: #E8E4DC;
|
||||
}
|
||||
</style>
|
||||
+127
-111
@@ -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'}
|
||||
<div class="paste-area">
|
||||
<textarea
|
||||
placeholder="Paste ingredient list here…
|
||||
e.g.: Wheat Flour, E120, E471, Sugar, Vegetable Oil, E322…"
|
||||
bind:value={ingredientText}
|
||||
rows="5"
|
||||
class="ingredient-input"
|
||||
rows="4"
|
||||
placeholder="Paste ingredients list here… e.g. Sugar, E120, Gelatin, Soy Lecithin"
|
||||
bind:value={ingredientText}
|
||||
></textarea>
|
||||
<div class="paste-actions">
|
||||
<button class="primary" onclick={parseIngredients} disabled={!ingredientText.trim()}>🔍 Analyze</button>
|
||||
{#if ingredientText}
|
||||
<button class="secondary" onclick={clearResults}>Clear</button>
|
||||
{/if}
|
||||
<button class="primary" onclick={parseIngredients} disabled={!ingredientText.trim()}>🔍 Scan</button>
|
||||
<button class="secondary" onclick={clearResults}>Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Search E-Number -->
|
||||
<div class="quick-search">
|
||||
<p class="quick-label">Quick E-number lookup:</p>
|
||||
<div class="quick-label">Quick search E-numbers:</div>
|
||||
<div class="quick-chips">
|
||||
{#each ['E120','E322','E422','E441','E471','E542','E621','E631','E635','E904','E920'] as code}
|
||||
{@const found = findByENumber(code)}
|
||||
<button class="quick-chip" class:haram={found?.status === 'haram'} class:mushbooh={found?.status === 'mushbooh'} onclick={() => { ingredientText = code; parseIngredients(); }}>
|
||||
{code}
|
||||
<span class="quick-hint">{found?.status === 'haram' ? ' 🚫' : found?.status === 'mushbooh' ? ' ⚠' : ''}</span>
|
||||
</button>
|
||||
{/each}
|
||||
<button class="quick-chip haram" onclick={() => { ingredientText += (ingredientText ? ', ' : '') + 'E120'; }} title="Carmine (cochineal) — insects">E120</button>
|
||||
<button class="quick-chip haram" onclick={() => { ingredientText += (ingredientText ? ', ' : '') + 'E441'; }} title="Gelatin — animal source">E441</button>
|
||||
<button class="quick-chip haram" onclick={() => { ingredientText += (ingredientText ? ', ' : '') + 'E542'; }} title="Bone phosphate">E542</button>
|
||||
<button class="quick-chip mushbooh" onclick={() => { ingredientText += (ingredientText ? ', ' : '') + 'E471'; }} title="Mono/diglycerides — may be plant or animal">E471</button>
|
||||
<button class="quick-chip mushbooh" onclick={() => { ingredientText += (ingredientText ? ', ' : '') + 'E472'; }} title="Fatty acid esters — source uncertain">E472</button>
|
||||
<button class="quick-chip" onclick={() => { ingredientText += (ingredientText ? ', ' : '') + 'E322'; }} title="Lecithin — usually soy">E322</button>
|
||||
<button class="quick-chip" onclick={() => { ingredientText += (ingredientText ? ', ' : '') + 'E330'; }} title="Citric acid">E330</button>
|
||||
<button class="quick-chip" onclick={() => { ingredientText += (ingredientText ? ', ' : '') + 'E415'; }} title="Xanthan gum">E415</button>
|
||||
</div>
|
||||
<div class="quick-hint" style="color:rgba(232,228,220,0.3)">🟥 Haram | 🟨 Doubtful | Default = likely halal</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Camera Mode -->
|
||||
{#if scanMode === 'camera'}
|
||||
<div class="camera-area">
|
||||
{#if cameraState === 'idle' || cameraState === 'starting'}
|
||||
<div class="camera-placeholder">
|
||||
<p>📷 {cameraState === 'starting' ? 'Starting camera…' : 'Tap to start camera'}</p>
|
||||
{#if cameraState === 'idle'}<button class="primary" onclick={startCamera}>Start Camera</button>{/if}
|
||||
</div>
|
||||
{:else if cameraState === 'active'}
|
||||
<video id="scanner-video" autoplay playsinline class="camera-feed"></video>
|
||||
<div class="camera-controls">
|
||||
<button class="primary" onclick={captureFrame}>📸 Capture</button>
|
||||
<button class="secondary" onclick={() => switchMode('paste')}>Back to Paste</button>
|
||||
</div>
|
||||
{#if cameraState === 'starting'}
|
||||
<div class="camera-placeholder">⏳ Starting camera…</div>
|
||||
{:else if cameraState === 'error'}
|
||||
<div class="camera-error"><p>❌ {cameraError}</p><button class="primary" onclick={() => switchMode('paste')}>Use Paste Mode</button></div>
|
||||
<div class="camera-error">{cameraError}</div>
|
||||
{:else if cameraState === 'active'}
|
||||
<video id="scanner-video" class="camera-feed" autoplay playsinline muted></video>
|
||||
<div class="camera-controls">
|
||||
<button class="primary" onclick={captureFrame}>📸 Capture & Scan</button>
|
||||
<button class="secondary" onclick={stopCamera}>Cancel</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="camera-placeholder">
|
||||
<p>📷 Point camera at ingredient list</p>
|
||||
<button class="primary" onclick={startCamera}>Start Camera</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -175,117 +176,132 @@ e.g.: Wheat Flour, E120, E471, Sugar, Vegetable Oil, E322…"
|
||||
<!-- Results -->
|
||||
{#if scanned}
|
||||
<div class="scan-results">
|
||||
<!-- Summary Card -->
|
||||
<div class="summary-bar" class:haram={summary.haram > 0} class:mushbooh={summary.haram === 0 && summary.mushbooh > 0} class:halal={summary.haram === 0 && summary.mushbooh === 0 && summary.halal > 0}>
|
||||
<span class="verdict">{summary.verdict}</span>
|
||||
<div class="summary-counts">
|
||||
{#if summary.haram > 0}<span class="count haram">🚫 {summary.haram} Haram</span>{/if}
|
||||
{#if summary.mushbooh > 0}<span class="count mushbooh">⚠ {summary.mushbooh} Doubtful</span>{/if}
|
||||
{#if summary.halal > 0}<span class="count halal">✅ {summary.halal} Halal</span>{/if}
|
||||
{#if summary.unknown > 0}<span class="count unknown">❓ {summary.unknown} Unknown</span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Per-ingredient results -->
|
||||
<div class="result-list">
|
||||
{#each results as item (item.id)}
|
||||
<div class="result-item" style="border-left: 4px solid {statusColor(item.ingredient?.status || 'unknown')}">
|
||||
<div class="result-header">
|
||||
<span class="result-status">{statusEmoji(item.ingredient?.status || 'unknown')}</span>
|
||||
<span class="result-name">
|
||||
{item.raw}
|
||||
{#if item.eNumber}<code class="e-code">({item.eNumber})</code>{/if}
|
||||
</span>
|
||||
</div>
|
||||
{#if item.ingredient}
|
||||
<div class="result-body">
|
||||
<p class="result-category"><strong>Category:</strong> {item.ingredient.category}</p>
|
||||
<p class="result-explanation">{item.ingredient.explanation}</p>
|
||||
<p class="result-scholarly">📚 <em>{item.ingredient.scholarlyNote}</em></p>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="result-unknown">Not found in our database. <a href="https://www.halal.gov.my" target="_blank">Check JAKIM portal</a> or <a href="https://halalmui.org" target="_blank">MUI</a>.</p>
|
||||
{/if}
|
||||
{#if results.length > 0}
|
||||
{@const s = summary()}
|
||||
<div class="summary-bar" class:haram={s.haram > 0} class:mushbooh={s.mushbooh > 0 && s.haram === 0} class:halal={s.haram === 0 && s.mushbooh === 0 && s.halal > 0}>
|
||||
<div class="verdict">{s.verdict}</div>
|
||||
<div class="summary-counts">
|
||||
<span class="count haram">{s.haram} 🚫</span>
|
||||
<span class="count mushbooh">{s.mushbooh} ⚠️</span>
|
||||
<span class="count halal">{s.halal} ✅</span>
|
||||
<span class="count unknown">{s.unknown} ❓</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="result-list">
|
||||
{#each results as r (r.id)}
|
||||
<div class="result-item" style="background:{statusBg(r.ingredient?.status)};">
|
||||
<div class="result-header">
|
||||
<span class="result-status" style="color:{statusColor(r.ingredient?.status)}">{statusEmoji(r.ingredient?.status)}</span>
|
||||
<div class="result-body">
|
||||
<span class="result-name">{r.raw}{#if r.eNumber} <span class="e-code">{r.eNumber}</span>{/if}</span>
|
||||
{#if r.ingredient}
|
||||
<div class="result-category">Category: {r.ingredient.category}</div>
|
||||
<div class="result-explanation">{r.ingredient.explanation}</div>
|
||||
{#if r.ingredient.scholarlyNote}
|
||||
<div class="result-scholarly">{r.ingredient.scholarlyNote}</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="result-unknown">
|
||||
❓ Not in database. <a href="https://www.ecfr.gov/current/title-21/chapter-I/subchapter-B/part-184" target="_blank" style="color:#C9A84C">Check FDA GRAS list</a> or consult a scholar.
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p style="color:rgba(232,228,220,0.55)">No ingredients parsed. Please enter ingredients to scan.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- History -->
|
||||
{#if history.length > 0}
|
||||
<details class="history-section">
|
||||
<summary>📜 Recent Scans ({history.length})</summary>
|
||||
<summary>📜 Scan History ({history.length})</summary>
|
||||
<div class="history-list">
|
||||
{#each history as h}
|
||||
{#each history as h (h.timestamp)}
|
||||
<div class="history-item">
|
||||
<span>Scanned {h.results} items</span>
|
||||
<span class="history-date">{new Date(h.timestamp).toLocaleString()}</span>
|
||||
<span class="history-count">{h.results} ingredient{h.results !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
{/each}
|
||||
<button class="clear-btn" onclick={clearHistory}>Clear history</button>
|
||||
</div>
|
||||
<button class="clear-btn" onclick={clearHistory}>Clear History</button>
|
||||
</details>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.mode-tabs { display: flex; gap: 4px; margin-bottom: 16px; background: #f0fdfa; border-radius: 12px; padding: 4px; }
|
||||
.mode-tab { flex: 1; padding: 10px; border: none; border-radius: 10px; font-size: 0.82rem; cursor: pointer; background: transparent; color: #5b8c85; transition: all 0.2s; }
|
||||
.mode-tab.active { background: #0f766e; color: white; font-weight: 600; box-shadow: 0 1px 4px rgba(15, 118, 110, 0.2); }
|
||||
.mode-tabs { display: flex; gap: 4px; margin-bottom: 16px; background: #111820; border: 1px solid rgba(201,168,76,0.2); border-radius: 12px; padding: 4px; }
|
||||
.mode-tab { flex: 1; padding: 10px; border: none; border-radius: 10px; font-family: 'JetBrains Mono', monospace; font-size: 0.72rem; font-weight: 300; text-transform: uppercase; letter-spacing: 0.5px; cursor: pointer; background: transparent; color: rgba(232,228,220,0.55); transition: all 0.2s; }
|
||||
.mode-tab.active { background: rgba(201,168,76,0.15); color: #C9A84C; font-weight: 400; }
|
||||
|
||||
.paste-area { margin-bottom: 12px; }
|
||||
.ingredient-input { width: 100%; padding: 12px; border: 1px solid #99d6c9; border-radius: 10px; font-size: 0.85rem; color: #134e4a; background: #f0fdfa; outline: none; resize: vertical; font-family: inherit; }
|
||||
.ingredient-input:focus { border-color: #0f766e; box-shadow: 0 0 0 2px rgba(15, 118, 110, 0.15); }
|
||||
.ingredient-input { width: 100%; padding: 12px; border: 1px solid rgba(201,168,76,0.2); border-radius: 10px; font-family: 'DM Sans', sans-serif; font-size: 0.85rem; color: #E8E4DC; background: #111820; outline: none; resize: vertical; }
|
||||
.ingredient-input:focus { border-color: #C9A84C; box-shadow: 0 0 0 2px rgba(201,168,76,0.1); }
|
||||
.ingredient-input::placeholder { color: rgba(232,228,220,0.3); }
|
||||
.paste-actions { display: flex; gap: 8px; margin-top: 8px; }
|
||||
.primary { padding: 10px 20px; background: #0f766e; color: white; border: none; border-radius: 10px; font-size: 0.85rem; cursor: pointer; font-weight: 600; }
|
||||
.primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.secondary { padding: 10px 20px; background: white; color: #0f766e; border: 1px solid #99d6c9; border-radius: 10px; font-size: 0.85rem; cursor: pointer; }
|
||||
.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.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.quick-search { margin-bottom: 12px; }
|
||||
.quick-label { font-size: 0.72rem; color: #64748b; margin-bottom: 6px; }
|
||||
.quick-label { font-family: 'JetBrains Mono', monospace; font-size: 0.65rem; font-weight: 300; color: rgba(232,228,220,0.55); margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.quick-chips { display: flex; gap: 4px; flex-wrap: wrap; }
|
||||
.quick-chip { padding: 4px 10px; border: 1px solid #e2e8f0; border-radius: 14px; font-size: 0.68rem; background: white; color: #0f766e; cursor: pointer; font-family: monospace; transition: all 0.15s; }
|
||||
.quick-chip.haram { border-color: #fecaca; background: #fef2f2; color: #991b1b; }
|
||||
.quick-chip.mushbooh { border-color: #fde68a; background: #fffbeb; color: #92400e; }
|
||||
.quick-hint { font-size: 0.6rem; }
|
||||
.quick-chip { padding: 4px 10px; border: 1px solid rgba(201,168,76,0.15); border-radius: 14px; font-family: 'JetBrains Mono', monospace; font-size: 0.65rem; background: transparent; color: rgba(232,228,220,0.55); cursor: pointer; transition: all 0.15s; }
|
||||
.quick-chip.haram { border-color: rgba(225,29,72,0.3); background: rgba(225,29,72,0.08); color: #e11d48; }
|
||||
.quick-chip.mushbooh { border-color: rgba(201,168,76,0.3); background: rgba(201,168,76,0.08); color: #C9A84C; }
|
||||
.quick-hint { font-family: 'DM Sans', sans-serif; font-size: 0.6rem; margin-top: 4px; }
|
||||
|
||||
.camera-area { margin-bottom: 12px; }
|
||||
.camera-placeholder { text-align: center; padding: 40px; background: #f0fdfa; border-radius: 12px; color: #5b8c85; border: 2px dashed #99d6c9; }
|
||||
.camera-placeholder { text-align: center; padding: 40px; background: #111820; border: 2px dashed rgba(201,168,76,0.2); border-radius: 12px; color: rgba(232,228,220,0.55); }
|
||||
.camera-feed { width: 100%; max-height: 300px; border-radius: 12px; object-fit: cover; background: #000; }
|
||||
.camera-controls { display: flex; gap: 8px; margin-top: 8px; }
|
||||
.camera-error { text-align: center; padding: 20px; background: #fef2f2; border-radius: 12px; color: #991b1b; font-size: 0.82rem; }
|
||||
.camera-error { text-align: center; padding: 20px; background: rgba(225,29,72,0.1); border: 1px solid rgba(225,29,72,0.2); border-radius: 12px; color: #e11d48; font-size: 0.82rem; }
|
||||
|
||||
.scan-results { margin-top: 16px; }
|
||||
.summary-bar { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; border-radius: 12px; margin-bottom: 12px; flex-wrap: wrap; gap: 8px; }
|
||||
.summary-bar.haram { background: #fef2f2; border: 1px solid #fecaca; }
|
||||
.summary-bar.mushbooh { background: #fffbeb; border: 1px solid #fde68a; }
|
||||
.summary-bar.halal { background: #f0fdf4; border: 1px solid #bbf7d0; }
|
||||
.verdict { font-weight: 700; font-size: 0.95rem; color: #134e4a; }
|
||||
.summary-bar.haram { background: rgba(225,29,72,0.1); border: 1px solid rgba(225,29,72,0.2); }
|
||||
.summary-bar.mushbooh { background: rgba(201,168,76,0.1); border: 1px solid rgba(201,168,76,0.2); }
|
||||
.summary-bar.halal { background: rgba(46,204,113,0.08); border: 1px solid rgba(46,204,113,0.2); }
|
||||
.verdict { font-family: 'Cinzel', serif; font-weight: 900; font-size: 0.9rem; color: #E8E4DC; }
|
||||
.summary-counts { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.count { font-size: 0.7rem; padding: 3px 8px; border-radius: 8px; font-weight: 600; }
|
||||
.count.haram { background: #fee2e2; color: #991b1b; }
|
||||
.count.mushbooh { background: #fef3c7; color: #92400e; }
|
||||
.count.halal { background: #dcfce7; color: #166534; }
|
||||
.count.unknown { background: #f1f5f9; color: #475569; }
|
||||
.count { font-family: 'JetBrains Mono', monospace; font-size: 0.65rem; padding: 3px 8px; border-radius: 8px; font-weight: 400; }
|
||||
.count.haram { background: rgba(225,29,72,0.15); color: #e11d48; }
|
||||
.count.mushbooh { background: rgba(201,168,76,0.15); color: #C9A84C; }
|
||||
.count.halal { background: rgba(46,204,113,0.15); color: #2ECC71; }
|
||||
.count.unknown { background: rgba(232,228,220,0.08); color: rgba(232,228,220,0.3); }
|
||||
|
||||
.result-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.result-item { padding: 12px; background: white; border-radius: 10px; }
|
||||
.result-header { display: flex; align-items: flex-start; gap: 8px; margin-bottom: 8px; }
|
||||
.result-item { padding: 12px; border: 1px solid rgba(201,168,76,0.1); border-radius: 10px; }
|
||||
.result-header { display: flex; align-items: flex-start; gap: 8px; }
|
||||
.result-status { font-size: 1.1rem; flex-shrink: 0; }
|
||||
.result-name { font-weight: 600; color: #134e4a; font-size: 0.85rem; }
|
||||
.e-code { font-family: monospace; font-size: 0.72rem; background: #f1f5f9; padding: 1px 5px; border-radius: 4px; color: #64748b; margin-left: 4px; }
|
||||
.result-body { margin-top: 4px; padding-left: 26px; }
|
||||
.result-category { font-size: 0.7rem; color: #64748b; margin-bottom: 4px; }
|
||||
.result-explanation { font-size: 0.75rem; color: #334155; margin-bottom: 4px; line-height: 1.5; }
|
||||
.result-scholarly { font-size: 0.7rem; color: #64748b; line-height: 1.4; border-left: 2px solid #e2e8f0; padding-left: 8px; }
|
||||
.result-unknown { font-size: 0.72rem; color: #64748b; margin-left: 26px; }
|
||||
.result-unknown a { color: #0f766e; }
|
||||
.result-body { flex: 1; }
|
||||
.result-name { font-family: 'DM Sans', sans-serif; font-weight: 600; color: #E8E4DC; font-size: 0.85rem; }
|
||||
.e-code { font-family: 'JetBrains Mono', monospace; font-size: 0.72rem; background: rgba(201,168,76,0.08); padding: 1px 5px; border-radius: 4px; color: #C9A84C; margin-left: 4px; }
|
||||
.result-category { font-family: 'JetBrains Mono', monospace; font-size: 0.65rem; font-weight: 300; color: rgba(232,228,220,0.55); margin-top: 4px; margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.3px; }
|
||||
.result-explanation { font-size: 0.75rem; color: rgba(232,228,220,0.8); margin-bottom: 4px; line-height: 1.5; font-family: 'DM Sans', sans-serif; }
|
||||
.result-scholarly { font-family: 'DM Sans', sans-serif; font-size: 0.7rem; color: rgba(232,228,220,0.55); line-height: 1.4; border-left: 2px solid rgba(201,168,76,0.2); padding-left: 8px; }
|
||||
.result-unknown { font-family: 'DM Sans', sans-serif; font-size: 0.72rem; color: rgba(232,228,220,0.55); }
|
||||
.result-unknown a { color: #C9A84C; }
|
||||
|
||||
.history-section { margin-top: 16px; }
|
||||
.history-section summary { font-size: 0.75rem; color: #5b8c85; cursor: pointer; font-weight: 600; }
|
||||
.history-section summary { font-family: 'JetBrains Mono', monospace; font-size: 0.7rem; font-weight: 300; color: rgba(232,228,220,0.55); cursor: pointer; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.history-list { margin-top: 8px; }
|
||||
.history-item { display: flex; justify-content: space-between; padding: 6px 8px; font-size: 0.7rem; color: #64748b; border-bottom: 1px solid #f1f5f9; }
|
||||
.history-date { color: #94a3a8; }
|
||||
.clear-btn { margin-top: 8px; padding: 4px 12px; background: none; border: 1px solid #e2e8f0; border-radius: 8px; font-size: 0.65rem; color: #94a3a8; cursor: pointer; }
|
||||
</style>
|
||||
.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; }
|
||||
</style>
|
||||
@@ -0,0 +1,338 @@
|
||||
<script>
|
||||
/**
|
||||
* Islamic Calendar Events — embedded event database + FREE AlAdhan API
|
||||
* Shows upcoming Islamic events sorted by Hijri date proximity.
|
||||
*/
|
||||
const EVENTS = [
|
||||
{ name: 'Islamic New Year', month: 1, day: 1, icon: '🌙' },
|
||||
{ name: 'Day of Ashura', month: 1, day: 10, icon: '🤲' },
|
||||
{ name: "Mawlid al-Nabi", month: 3, day: 12, icon: '🕊️' },
|
||||
{ name: "Isra' and Mi'raj", month: 7, day: 27, icon: '🕌' },
|
||||
{ name: "Nisfu Sha'ban", month: 8, day: 15, icon: '🌙' },
|
||||
{ name: 'Ramadan', month: 9, day: 1, icon: '🌙' },
|
||||
{ name: 'Laylat al-Qadr', month: 9, day: 27, icon: '✨' },
|
||||
{ name: 'Eid al-Fitr', month: 10, day: 1, icon: '🎉' },
|
||||
{ name: 'Day of Arafah', month: 12, day: 9, icon: '🕋' },
|
||||
{ name: 'Eid al-Adha', month: 12, day: 10, icon: '🐑' },
|
||||
];
|
||||
|
||||
const MONTH_NAMES = {
|
||||
1: 'Muharram', 2: 'Safar', 3: "Rabi' al-Awwal", 4: "Rabi' al-Thani",
|
||||
5: 'Jumada al-Awwal', 6: 'Jumada al-Thani', 7: 'Rajab', 8: "Sha'ban",
|
||||
9: 'Ramadan', 10: 'Shawwal', 11: "Dhu al-Qi'dah", 12: 'Dhu al-Hijjah',
|
||||
};
|
||||
|
||||
// Standard Hijri month lengths (29/30 alternation)
|
||||
const MONTH_DAYS = [30, 29, 30, 29, 30, 29, 30, 29, 30, 29, 30, 29];
|
||||
const HIJRI_YEAR_LENGTH = 354;
|
||||
|
||||
let hijriDate = $state(null);
|
||||
let gregorianToday = $state('');
|
||||
let loading = $state(true);
|
||||
let error = $state(false);
|
||||
let gregorianDates = $state({}); // "month-day" -> "DD-MM-YYYY"
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
const today = new Date();
|
||||
const dd = String(today.getDate()).padStart(2, '0');
|
||||
const mm = String(today.getMonth() + 1).padStart(2, '0');
|
||||
const yyyy = today.getFullYear();
|
||||
gregorianToday = `${dd}/${mm}/${yyyy}`;
|
||||
|
||||
// Step 1: Get current Hijri date
|
||||
const hijriRes = await fetch(`https://api.aladhan.com/v1/gToH/${dd}-${mm}-${yyyy}`);
|
||||
const hijriData = await hijriRes.json();
|
||||
if (hijriData.code !== 200) { error = true; loading = false; return; }
|
||||
|
||||
hijriDate = hijriData.data.hijri;
|
||||
const hijriYear = hijriDate.year;
|
||||
|
||||
// Step 2: Batch-fetch Gregorian equivalents for every event month
|
||||
const eventMonths = [...new Set(EVENTS.map(e => e.month))];
|
||||
const results = await Promise.allSettled(
|
||||
eventMonths.map(m =>
|
||||
fetch(`https://api.aladhan.com/v1/hToGCalendar/${m}/${hijriYear}`)
|
||||
.then(r => r.json())
|
||||
)
|
||||
);
|
||||
|
||||
const dateMap = {};
|
||||
results.forEach((result, idx) => {
|
||||
if (result.status === 'fulfilled' && result.value?.code === 200) {
|
||||
const month = eventMonths[idx];
|
||||
result.value.data.forEach(entry => {
|
||||
const hDay = parseInt(entry.hijri.date.split('-')[0], 10);
|
||||
dateMap[`${month}-${hDay}`] = entry.gregorian.date; // "DD-MM-YYYY"
|
||||
});
|
||||
}
|
||||
});
|
||||
gregorianDates = dateMap;
|
||||
loading = false;
|
||||
} catch {
|
||||
error = true;
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => { loadData(); });
|
||||
|
||||
const currentDay = $derived(hijriDate ? parseInt(hijriDate.day, 10) : 0);
|
||||
const currentMonth = $derived(hijriDate ? parseInt(hijriDate.month.number, 10) : 0);
|
||||
const currentYear = $derived(hijriDate ? parseInt(hijriDate.year, 10) : 0);
|
||||
|
||||
function dayOfYear(month, day) {
|
||||
let total = 0;
|
||||
for (let i = 0; i < month - 1; i++) total += MONTH_DAYS[i];
|
||||
return total + day;
|
||||
}
|
||||
|
||||
function calcDaysUntil(eventMonth, eventDay) {
|
||||
if (!hijriDate) return null;
|
||||
const cur = dayOfYear(currentMonth, currentDay);
|
||||
const ev = dayOfYear(eventMonth, eventDay);
|
||||
return ev >= cur ? ev - cur : HIJRI_YEAR_LENGTH - cur + ev;
|
||||
}
|
||||
|
||||
const upcomingEvents = $derived.by(() => {
|
||||
if (!hijriDate) return [];
|
||||
const curDOY = dayOfYear(currentMonth, currentDay);
|
||||
return EVENTS.map(e => {
|
||||
const du = calcDaysUntil(e.month, e.day);
|
||||
const evDOY = dayOfYear(e.month, e.day);
|
||||
const eventYear = evDOY >= curDOY ? currentYear : currentYear + 1;
|
||||
const key = `${e.month}-${e.day}`;
|
||||
return {
|
||||
...e,
|
||||
daysUntil: du,
|
||||
isToday: du === 0,
|
||||
hijriDateStr: `${e.day} ${MONTH_NAMES[e.month]} ${eventYear}`,
|
||||
gregDateStr: gregorianDates[key] || null,
|
||||
};
|
||||
}).sort((a, b) => a.daysUntil - b.daysUntil);
|
||||
});
|
||||
|
||||
function formatGreg(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const parts = dateStr.split('-'); // DD-MM-YYYY
|
||||
if (parts.length !== 3) return dateStr;
|
||||
const [d, m, y] = parts;
|
||||
const names = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
return `${names[parseInt(m, 10) - 1]} ${parseInt(d, 10)}, ${y}`;
|
||||
}
|
||||
|
||||
function daysLabel(du) {
|
||||
if (du === 0) return 'Today!';
|
||||
if (du === 1) return 'Tomorrow';
|
||||
return `${du} days`;
|
||||
}
|
||||
|
||||
function urgencyClass(du) {
|
||||
if (du === 0) return 'urgent-today';
|
||||
if (du <= 7) return 'urgent-soon';
|
||||
if (du <= 30) return 'urgent-close';
|
||||
return '';
|
||||
}
|
||||
|
||||
function badgeClass(du) {
|
||||
if (du === 0) return 'badge-today';
|
||||
if (du <= 7) return 'badge-soon';
|
||||
if (du <= 30) return 'badge-close';
|
||||
return 'badge-far';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="card">
|
||||
<h2>📅 Islamic Events</h2>
|
||||
|
||||
{#if loading}
|
||||
<p class="loading-text">⏳ Loading…</p>
|
||||
{:else if error}
|
||||
<div class="error-note">Could not load calendar data. Check your connection.</div>
|
||||
{:else if hijriDate}
|
||||
<!-- Current Hijri Date banner -->
|
||||
<div class="current-date-banner">
|
||||
<div class="hijri-big-block">
|
||||
<span class="hijri-big-day">{hijriDate.day}</span>
|
||||
<span class="hijri-big-month">{hijriDate.month.en} {hijriDate.year}</span>
|
||||
<span class="hijri-big-greg">{gregorianToday}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Events list -->
|
||||
<div class="events-list">
|
||||
{#each upcomingEvents as event (event.name)}
|
||||
<div class="event-card {urgencyClass(event.daysUntil)}">
|
||||
<span class="event-icon">{event.icon}</span>
|
||||
<div class="event-body">
|
||||
<span class="event-name">{event.name}</span>
|
||||
<span class="event-hijri">{event.hijriDateStr}</span>
|
||||
{#if event.gregDateStr}
|
||||
<span class="event-greg">{formatGreg(event.gregDateStr)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="event-badge {badgeClass(event.daysUntil)}">
|
||||
{daysLabel(event.daysUntil)}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.loading-text {
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
font-size: 0.85rem;
|
||||
color: rgba(232, 228, 220, 0.55);
|
||||
}
|
||||
.error-note {
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
font-size: 0.8rem;
|
||||
color: #e74c3c;
|
||||
background: rgba(231, 76, 60, 0.08);
|
||||
border: 1px solid rgba(231, 76, 60, 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
/* ── Current Date Banner ── */
|
||||
.current-date-banner {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.hijri-big-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
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: 20px 32px;
|
||||
text-align: center;
|
||||
gap: 2px;
|
||||
}
|
||||
.hijri-big-day {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 2.4rem;
|
||||
font-weight: 900;
|
||||
color: #c9a84c;
|
||||
line-height: 1;
|
||||
}
|
||||
.hijri-big-month {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: #e8e4dc;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.hijri-big-greg {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 300;
|
||||
color: rgba(232, 228, 220, 0.55);
|
||||
margin-top: 2px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* ── Events List ── */
|
||||
.events-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.event-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: #111820;
|
||||
border: 1px solid rgba(201, 168, 76, 0.15);
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
border-left: 3px solid rgba(201, 168, 76, 0.3);
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
/* Urgency colour-coded left borders */
|
||||
.event-card.urgent-today {
|
||||
border-left-color: #e74c3c;
|
||||
background: rgba(231, 76, 60, 0.06);
|
||||
}
|
||||
.event-card.urgent-soon {
|
||||
border-left-color: #f39c12;
|
||||
background: rgba(243, 156, 18, 0.05);
|
||||
}
|
||||
.event-card.urgent-close {
|
||||
border-left-color: #2ecc71;
|
||||
}
|
||||
|
||||
.event-icon {
|
||||
font-size: 1.3rem;
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.event-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
min-width: 0;
|
||||
}
|
||||
.event-name {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: #e8e4dc;
|
||||
}
|
||||
.event-hijri {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 300;
|
||||
color: rgba(232, 228, 220, 0.55);
|
||||
}
|
||||
.event-greg {
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 300;
|
||||
color: rgba(232, 228, 220, 0.4);
|
||||
}
|
||||
|
||||
/* ── Countdown Badges ── */
|
||||
.event-badge {
|
||||
flex-shrink: 0;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.badge-today {
|
||||
background: rgba(231, 76, 60, 0.15);
|
||||
color: #e74c3c;
|
||||
border: 1px solid rgba(231, 76, 60, 0.3);
|
||||
}
|
||||
.badge-soon {
|
||||
background: rgba(243, 156, 18, 0.13);
|
||||
color: #f39c12;
|
||||
border: 1px solid rgba(243, 156, 18, 0.25);
|
||||
}
|
||||
.badge-close {
|
||||
background: rgba(46, 204, 113, 0.12);
|
||||
color: #2ecc71;
|
||||
border: 1px solid rgba(46, 204, 113, 0.25);
|
||||
}
|
||||
.badge-far {
|
||||
background: rgba(201, 168, 76, 0.08);
|
||||
color: rgba(232, 228, 220, 0.45);
|
||||
border: 1px solid rgba(201, 168, 76, 0.15);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,328 @@
|
||||
<script>
|
||||
let lat = $state(null);
|
||||
let lng = $state(null);
|
||||
let loading = $state(true);
|
||||
let searching = $state(false);
|
||||
let error = $state('');
|
||||
let searchQuery = $state('');
|
||||
let radius = $state(10);
|
||||
let viewMode = $state('list');
|
||||
let mapReady = $state(false);
|
||||
let mosques = $state([]);
|
||||
let expandedId = $state(null);
|
||||
|
||||
const radiusOptions = [1, 5, 10, 25, 50];
|
||||
|
||||
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(mosques.map(m => ({
|
||||
...m,
|
||||
distance: lat !== null ? haversineDistance(lat, lng, m.osmLat, m.osmLng) : 0
|
||||
})));
|
||||
|
||||
const filtered = $derived(
|
||||
enriched
|
||||
.filter(m => {
|
||||
if (!searchQuery.trim()) return true;
|
||||
const q = searchQuery.toLowerCase();
|
||||
return m.name.toLowerCase().includes(q) || m.address.toLowerCase().includes(q);
|
||||
})
|
||||
.filter(m => m.distance <= radius)
|
||||
.sort((a, b) => a.distance - b.distance)
|
||||
);
|
||||
|
||||
async function fetchMosques() {
|
||||
searching = true;
|
||||
error = '';
|
||||
const radius_m = radius * 1000;
|
||||
const search_lat = lat;
|
||||
const search_lng = lng;
|
||||
|
||||
const queries = [
|
||||
`[out:json];(node["amenity"="place_of_worship"]["religion"="muslim"](around:${radius_m},${search_lat},${search_lng});way["amenity"="place_of_worship"]["religion"="muslim"](around:${radius_m},${search_lat},${search_lng}););out body center;`,
|
||||
`[out:json];(node["building"="mosque"](around:${radius_m},${search_lat},${search_lng});way["building"="mosque"](around:${radius_m},${search_lat},${search_lng}););out body center;`,
|
||||
`[out:json];node["amenity"="place_of_worship"]["religion"="muslim"](around:${radius_m},${search_lat},${search_lng});out body;`
|
||||
];
|
||||
|
||||
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) {
|
||||
mosques = 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 name = t.name || t['name:en'] || '';
|
||||
const address = t['addr:full'] || t['addr:street'] || t['addr:city'] || '';
|
||||
const extras = [];
|
||||
if (t.operator) extras.push({ label: 'Operator', value: t.operator });
|
||||
if (t.capacity) extras.push({ label: 'Capacity', value: t.capacity + ' pax' });
|
||||
if (t['capacity:men']) extras.push({ label: 'Men capacity', value: t['capacity:men'] + ' pax' });
|
||||
if (t['capacity:women']) extras.push({ label: 'Women capacity', value: t['capacity:women'] + ' pax' });
|
||||
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['toilets:wheelchair']) extras.push({ label: 'Accessible toilets', value: t['toilets:wheelchair'] === 'yes' ? 'Yes' : 'No' });
|
||||
if (t.wudu) extras.push({ label: 'Wudu facilities', value: t.wudu === 'yes' ? 'Available' : t.wudu === 'no' ? 'Not available' : t.wudu });
|
||||
if (t['parking:capacity']) extras.push({ label: 'Parking', value: t['parking:capacity'] + ' spots' });
|
||||
return { id: `${el.type}-${el.id}`, name: name || 'Unnamed Mosque', address: address || 'Address not available', osmLat, osmLng, extras };
|
||||
})
|
||||
.filter(m => m !== null);
|
||||
if (mosques.length > 0) break;
|
||||
}
|
||||
} catch (e) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
searching = false;
|
||||
}
|
||||
|
||||
function getLocation() {
|
||||
if (!navigator.geolocation) {
|
||||
lat = 3.1390;
|
||||
lng = 101.6869;
|
||||
loading = false;
|
||||
fetchMosques();
|
||||
return;
|
||||
}
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
pos => {
|
||||
lat = pos.coords.latitude;
|
||||
lng = pos.coords.longitude;
|
||||
loading = false;
|
||||
fetchMosques();
|
||||
if (viewMode === 'map') setTimeout(() => initMap(), 100);
|
||||
},
|
||||
() => {
|
||||
lat = 3.1390;
|
||||
lng = 101.6869;
|
||||
loading = false;
|
||||
fetchMosques();
|
||||
if (viewMode === 'map') setTimeout(() => initMap(), 100);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
let mapInstance = null;
|
||||
let markersLayer = null;
|
||||
|
||||
function initMap() {
|
||||
if (viewMode !== 'map' || mapReady) return;
|
||||
if (typeof L === 'undefined') {
|
||||
const css = document.createElement('link');
|
||||
css.rel = 'stylesheet';
|
||||
css.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
|
||||
document.head.appendChild(css);
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js';
|
||||
script.onload = () => createMap();
|
||||
document.head.appendChild(script);
|
||||
} else {
|
||||
createMap();
|
||||
}
|
||||
}
|
||||
|
||||
function createMap() {
|
||||
if (mapInstance) return;
|
||||
const el = document.getElementById('mosque-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: '<div style="background:#C9A84C;width:14px;height:14px;border-radius:50%;border:2px solid white;box-shadow:0 0 8px rgba(0,0,0,0.3)"></div>',
|
||||
iconSize: [14, 14]
|
||||
})
|
||||
}).addTo(mapInstance).bindPopup('📍 You are here');
|
||||
updateMapMarkers();
|
||||
mapReady = true;
|
||||
}
|
||||
|
||||
function updateMapMarkers() {
|
||||
if (!mapInstance) return;
|
||||
if (markersLayer) mapInstance.removeLayer(markersLayer);
|
||||
markersLayer = L.layerGroup();
|
||||
filtered.forEach(m => {
|
||||
L.marker([m.osmLat, m.osmLng], {
|
||||
icon: L.divIcon({
|
||||
className: 'mosque-marker',
|
||||
html: `<div style="background:#2ECC71;color:#070A0D;width:28px;height:28px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:14px;border:2px solid white;box-shadow:0 2px 6px rgba(0,0,0,0.3)">🕌</div>`,
|
||||
iconSize: [28, 28]
|
||||
})
|
||||
}).bindPopup(`<b>${m.name}</b><br>${m.address}<br>${m.distance.toFixed(1)} km`).addTo(markersLayer);
|
||||
});
|
||||
markersLayer.addTo(mapInstance);
|
||||
}
|
||||
|
||||
function toggleView() {
|
||||
viewMode = viewMode === 'list' ? 'map' : 'list';
|
||||
if (viewMode === 'map') setTimeout(() => initMap(), 100);
|
||||
}
|
||||
|
||||
function retryFetch() {
|
||||
error = '';
|
||||
fetchMosques();
|
||||
}
|
||||
|
||||
$effect(() => { getLocation(); });
|
||||
$effect(() => {
|
||||
if (radius !== undefined && lat !== null && !loading) {
|
||||
fetchMosques();
|
||||
}
|
||||
});
|
||||
$effect(() => {
|
||||
if (viewMode === 'map' && lat !== null) updateMapMarkers();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="card">
|
||||
<h2>🕌 Mosque Finder</h2>
|
||||
{#if loading}
|
||||
<p style="color:rgba(232,228,220,0.55)">📍 Getting your location…</p>
|
||||
{:else if error}
|
||||
<p style="color:#e11d48">{error}</p>
|
||||
<button class="primary" onclick={getLocation}>Retry</button>
|
||||
{:else}
|
||||
<div class="controls">
|
||||
<div class="search-row">
|
||||
<input type="text" placeholder="Search mosques..." bind:value={searchQuery} class="search-input" />
|
||||
<button class="view-toggle" onclick={toggleView} title={viewMode === 'list' ? 'Show map' : 'Show list'}>{viewMode === 'list' ? '🗺️' : '📋'}</button>
|
||||
</div>
|
||||
<div class="radius-row">
|
||||
<span class="radius-label">Radius:</span>
|
||||
<div class="radius-pills">
|
||||
{#each radiusOptions as r}
|
||||
<button class="radius-pill" class:active={radius === r} onclick={() => radius = r}>{r}km</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{#if searching}
|
||||
<p style="color:rgba(232,228,220,0.55);text-align:center;padding:20px 0">🔍 Searching for mosques…</p>
|
||||
{:else}
|
||||
<div class="results-count">{filtered.length} mosque{filtered.length !== 1 ? 's' : ''} within {radius}km</div>
|
||||
{#if viewMode === 'list'}
|
||||
{#if filtered.length === 0}
|
||||
<div class="empty-state"><p>🕌 No mosques found</p><p class="hint">Try increasing radius or search area</p></div>
|
||||
{:else}
|
||||
<div class="mosque-list">
|
||||
{#each filtered as m, i}
|
||||
<div class="mosque-card" class:expanded={expandedId === m.id}>
|
||||
<button class="mosque-header" onclick={() => expandedId = expandedId === m.id ? null : m.id}>
|
||||
<div class="mosque-top">
|
||||
<div class="mosque-name-row">
|
||||
<span class="mosque-name">{m.name}</span>
|
||||
{#if m.extras.some(e => e.label === 'Capacity')}
|
||||
<span class="capacity-tag">🕌 {m.extras.find(e => e.label === 'Capacity')?.value}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="mosque-meta">
|
||||
<span class="distance">{m.distance.toFixed(1)} km</span>
|
||||
{#if i < 3}
|
||||
<span class="proximity-badge">Nearest</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mosque-badges">
|
||||
{#if m.extras.some(e => e.label === 'Wudu facilities')}
|
||||
<span class="amenity-badge amenity-water">🚿 Wudu</span>
|
||||
{/if}
|
||||
{#if m.extras.some(e => e.label === 'Wheelchair' && e.value === 'Accessible')}
|
||||
<span class="amenity-badge amenity-accessible">♿ Accessible</span>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="expand-icon">{expandedId === m.id ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
{#if expandedId === m.id}
|
||||
<div class="mosque-detail">
|
||||
<div class="detail-address">📍 {m.address}</div>
|
||||
<a href="https://www.google.com/maps?q={m.osmLat},{m.osmLng}" target="_blank" rel="noopener" class="map-link">🗺️ Open in Google Maps ({m.osmLat.toFixed(4)}, {m.osmLng.toFixed(4)})</a>
|
||||
{#if m.extras.length > 0}
|
||||
<div class="extras-grid">
|
||||
{#each m.extras as e}
|
||||
<div class="extra-item">
|
||||
<span class="extra-label">{e.label}</span>
|
||||
<span class="extra-value">{e.value}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div id="mosque-map" class="map-container"></div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.controls { display: flex; flex-direction: column; gap: 10px; margin-bottom: 12px; }
|
||||
.search-row { display: flex; gap: 8px; }
|
||||
.search-input { flex: 1; padding: 10px 14px; border: 1px solid rgba(201,168,76,0.2); border-radius: 10px; font-size: 0.9rem; color: #E8E4DC; background: #111820; font-family: 'DM Sans', sans-serif; outline: none; }
|
||||
.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); }
|
||||
.view-toggle { width: 44px; height: 44px; border: 1px solid rgba(201,168,76,0.2); border-radius: 10px; background: #111820; font-size: 1.2rem; cursor: pointer; display: flex; align-items: center; justify-content: center; }
|
||||
.radius-row { display: flex; align-items: center; gap: 8px; }
|
||||
.radius-label { font-family: 'JetBrains Mono', monospace; font-size: 0.7rem; font-weight: 300; color: rgba(232,228,220,0.55); text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.radius-pills { display: flex; gap: 4px; }
|
||||
.radius-pill { padding: 4px 10px; border: 1px solid rgba(201,168,76,0.2); border-radius: 16px; font-family: 'JetBrains Mono', monospace; font-size: 0.65rem; background: transparent; color: rgba(232,228,220,0.55); cursor: pointer; transition: all 0.15s; }
|
||||
.radius-pill.active { background: #C9A84C; color: #070A0D; border-color: #C9A84C; }
|
||||
.results-count { font-family: 'JetBrains Mono', monospace; font-size: 0.7rem; font-weight: 300; color: rgba(232,228,220,0.55); margin-bottom: 10px; padding-left: 4px; }
|
||||
.empty-state { text-align: center; padding: 30px 0; color: rgba(232,228,220,0.55); }
|
||||
.empty-state .hint { font-family: 'DM Sans', sans-serif; font-size: 0.75rem; color: rgba(232,228,220,0.3); margin-top: 6px; }
|
||||
.mosque-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.mosque-card { background: #111820; border: 1px solid rgba(201,168,76,0.2); border-radius: 12px; overflow: hidden; }
|
||||
.mosque-card.expanded { border-color: rgba(46,204,113,0.3); }
|
||||
.mosque-header { width: 100%; display: flex; align-items: flex-start; justify-content: space-between; padding: 12px; border: none; background: transparent; cursor: pointer; text-align: left; gap: 8px; }
|
||||
.mosque-top { flex: 1; min-width: 0; }
|
||||
.mosque-name-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 4px; }
|
||||
.mosque-name { font-family: 'DM Sans', sans-serif; font-weight: 600; color: #E8E4DC; font-size: 0.9rem; }
|
||||
.capacity-tag { font-family: 'JetBrains Mono', monospace; font-size: 0.6rem; padding: 2px 8px; background: rgba(46,204,113,0.12); color: #2ECC71; border-radius: 8px; font-weight: 400; letter-spacing: 0.3px; white-space: nowrap; }
|
||||
.mosque-meta { display: flex; gap: 10px; font-family: 'JetBrains Mono', monospace; font-size: 0.65rem; color: rgba(232,228,220,0.55); align-items: center; }
|
||||
.distance { font-weight: 400; }
|
||||
.proximity-badge { font-family: 'JetBrains Mono', monospace; font-size: 0.55rem; padding: 2px 6px; background: rgba(46,204,113,0.15); color: #2ECC71; border-radius: 6px; }
|
||||
.mosque-badges { display: flex; flex-direction: column; gap: 4px; align-items: flex-end; flex-shrink: 0; }
|
||||
.amenity-badge { font-family: 'JetBrains Mono', monospace; font-size: 0.55rem; padding: 2px 6px; border-radius: 6px; white-space: nowrap; }
|
||||
.amenity-water { background: rgba(46,204,113,0.1); color: #2ECC71; }
|
||||
.amenity-accessible { background: rgba(201,168,76,0.1); color: #C9A84C; }
|
||||
.expand-icon { font-size: 0.7rem; color: rgba(232,228,220,0.3); padding-top: 2px; }
|
||||
.mosque-detail { padding: 0 12px 12px; border-top: 1px solid rgba(46,204,113,0.15); margin-top: 4px; font-family: 'DM Sans', sans-serif; font-size: 0.78rem; color: #E8E4DC; }
|
||||
.detail-address { margin-bottom: 10px; color: rgba(232,228,220,0.7); }
|
||||
.extras-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
|
||||
.extra-item { padding: 6px 8px; background: #0C1117; border: 1px solid rgba(201,168,76,0.1); border-radius: 8px; }
|
||||
.extra-label { display: block; font-family: 'JetBrains Mono', monospace; font-size: 0.6rem; color: rgba(232,228,220,0.4); text-transform: uppercase; letter-spacing: 0.3px; margin-bottom: 2px; }
|
||||
.extra-value { display: block; font-family: 'DM Sans', sans-serif; font-size: 0.72rem; color: #E8E4DC; word-break: break-all; }
|
||||
.map-container { width: 100%; height: 400px; border-radius: 12px; overflow: hidden; border: 1px solid rgba(201,168,76,0.2); }
|
||||
</style>
|
||||
+25
-26
@@ -122,19 +122,13 @@
|
||||
/>
|
||||
|
||||
<div class="names-grid">
|
||||
{#each filtered as n}
|
||||
<button
|
||||
class="name-card"
|
||||
class:expanded={expanded === n.no}
|
||||
onclick={() => expanded = expanded === n.no ? null : n.no}
|
||||
>
|
||||
<span class="name-no">{n.no}</span>
|
||||
{#each filtered as name (name.no)}
|
||||
<button class="name-card" onclick={() => expanded = expanded === name.no ? null : name.no}>
|
||||
<span class="name-no">{name.no}</span>
|
||||
<div class="name-body">
|
||||
<span class="name-ar">{n.ar}</span>
|
||||
<span class="name-en">{n.en}</span>
|
||||
{#if expanded === n.no}
|
||||
<span class="name-meaning">{n.meaning}</span>
|
||||
{/if}
|
||||
<span class="name-ar">{name.ar}</span>
|
||||
<span class="name-en">{name.en}</span>
|
||||
<span class="name-meaning">{name.meaning}</span>
|
||||
</div>
|
||||
</button>
|
||||
{/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; }
|
||||
</style>
|
||||
.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; }
|
||||
</style>
|
||||
+235
-16
@@ -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 = ''; } };
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="card">
|
||||
<h2>🕌 Prayer Times</h2>
|
||||
|
||||
{#if geoLoading}
|
||||
<p style="color:#5b8c85">📍 Getting location…</p>
|
||||
<p style="color:rgba(232,228,220,0.55)">📍 Getting location…</p>
|
||||
{:else if loading}
|
||||
<p style="color:#5b8c85">⏳ Loading prayer times…</p>
|
||||
<p style="color:rgba(232,228,220,0.55)">⏳ Loading prayer times…</p>
|
||||
{:else if error}
|
||||
<p style="color:#e11d48">{error}</p>
|
||||
<button class="primary" onclick={fetchPrayers}>Retry</button>
|
||||
@@ -112,13 +205,37 @@
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="notification-settings">
|
||||
<div class="toggle-row">
|
||||
<span class="toggle-label">🔔 Prayer Notifications</span>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" checked={notificationsEnabled} onchange={handleNotifToggle} />
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
{#if notificationsEnabled}
|
||||
<div class="toggle-row">
|
||||
<span class="toggle-label">🔊 Adhan Audio</span>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" bind:checked={adhanEnabled} />
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
{#if notificationPermission === 'denied'}
|
||||
<p class="hint-text">Notifications blocked. Enable in browser site settings (lock icon next to URL).</p>
|
||||
{:else if notificationPermission === 'granted'}
|
||||
<p class="hint-text">✓ Prayer notifications active</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.next-prayer {
|
||||
background: linear-gradient(135deg, #0f766e, #0d9488);
|
||||
color: white;
|
||||
background: linear-gradient(135deg, #C9A84C, #E8C97A);
|
||||
color: #070A0D;
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
@@ -126,8 +243,20 @@
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.next-label { font-size: 0.8rem; opacity: 0.9; }
|
||||
.next-time { font-size: 1.6rem; font-weight: 700; }
|
||||
.next-label {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 400;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.next-time {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 1.6rem;
|
||||
font-weight: 900;
|
||||
color: #070A0D;
|
||||
}
|
||||
.prayer-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -138,28 +267,118 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
background: #f0fdfa;
|
||||
background: #111820;
|
||||
border: 1px solid rgba(201,168,76,0.2);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.prayer-row.active {
|
||||
background: #ccfbf1;
|
||||
background: rgba(201,168,76,0.15);
|
||||
border-color: #C9A84C;
|
||||
font-weight: 600;
|
||||
}
|
||||
.prayer-name {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: rgba(232,228,220,0.55);
|
||||
}
|
||||
.prayer-row.active .prayer-name {
|
||||
color: #C9A84C;
|
||||
}
|
||||
.prayer-time {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 400;
|
||||
color: #E8E4DC;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.prayer-row.active .prayer-time {
|
||||
color: #C9A84C;
|
||||
font-weight: 600;
|
||||
}
|
||||
.prayer-name { color: #0f766e; }
|
||||
.prayer-time { font-variant-numeric: tabular-nums; }
|
||||
.method-select {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
font-size: 0.75rem;
|
||||
color: #5b8c85;
|
||||
color: rgba(232,228,220,0.55);
|
||||
}
|
||||
.method-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;
|
||||
padding: 6px 10px;
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
font-size: 0.75rem;
|
||||
color: #0f766e;
|
||||
background: white;
|
||||
outline: none;
|
||||
}
|
||||
</style>
|
||||
.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);
|
||||
}
|
||||
</style>
|
||||
+27
-8
@@ -54,7 +54,7 @@
|
||||
<h2>🧭 Qibla Finder</h2>
|
||||
|
||||
{#if loading}
|
||||
<p style="color:#5b8c85">📍 Getting location…</p>
|
||||
<p style="color:rgba(232,228,220,0.55)">📍 Getting location…</p>
|
||||
{:else if error}
|
||||
<p style="color:#e11d48">{error}</p>
|
||||
{:else}
|
||||
@@ -62,7 +62,7 @@
|
||||
<div class="compass" style="transform: rotate({needleAngle}deg)">
|
||||
<div class="needle">
|
||||
<svg viewBox="0 0 60 80" width="40" height="60">
|
||||
<polygon points="30,0 0,80 30,60 60,80" fill="#0f766e" />
|
||||
<polygon points="30,0 0,80 30,60 60,80" fill="#C9A84C" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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; }
|
||||
</style>
|
||||
.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;
|
||||
}
|
||||
</style>
|
||||
+351
-56
@@ -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 @@
|
||||
<h2>📖 Holy Quran</h2>
|
||||
|
||||
{#if loading}
|
||||
<p style="color:#5b8c85">⏳ Loading…</p>
|
||||
<p style="color:rgba(232,228,220,0.55)">⏳ Loading…</p>
|
||||
{:else if error}
|
||||
<p style="color:#e11d48">{error}</p>
|
||||
{:else if selectedSurah}
|
||||
@@ -54,21 +132,69 @@
|
||||
<h3>{selectedSurah.name} ({selectedSurah.english})</h3>
|
||||
<span class="surah-type">{selectedSurah.type}</span>
|
||||
</div>
|
||||
|
||||
<div class="view-toggles">
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" bind:checked={showArabic} />
|
||||
<span>Show Arabic</span>
|
||||
</label>
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" bind:checked={showTranslation} />
|
||||
<span>Show Translation</span>
|
||||
</label>
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" bind:checked={autoPlay} />
|
||||
<span>Auto-play audio</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="ayah-list">
|
||||
{#each ayahs as ayah}
|
||||
<div class="ayah">
|
||||
<span class="ayah-num">{ayah.numberInSurah}</span>
|
||||
<p class="ayah-text">{ayah.text}</p>
|
||||
{#each ayahs as ayah, i}
|
||||
<div class="ayah" class:ayah-active={currentAudioIndex === i}>
|
||||
<div class="ayah-header">
|
||||
<span class="ayah-num">{ayah.numberInSurah}</span>
|
||||
<button
|
||||
class="play-btn"
|
||||
onclick={() => toggleAudio(i)}
|
||||
aria-label="Play ayah {ayah.numberInSurah}"
|
||||
>
|
||||
{#if currentAudioIndex === i && isPlaying}
|
||||
<span class="play-icon">❚❚</span>
|
||||
{:else}
|
||||
<span class="play-icon">▶</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{#if showArabic && arabicAyahs[i]}
|
||||
<p class="ayah-arabic">{arabicAyahs[i].text}</p>
|
||||
{/if}
|
||||
{#if showTranslation}
|
||||
<p class="ayah-text">{ayah.text}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if currentAudioIndex >= 0}
|
||||
<div class="audio-player">
|
||||
<button class="player-btn" onclick={() => toggleAudio(currentAudioIndex)}>
|
||||
{#if isPlaying}
|
||||
<span>❚❚</span>
|
||||
{:else}
|
||||
<span>▶</span>
|
||||
{/if}
|
||||
</button>
|
||||
<span class="audio-info">{currentAyahLabel()}</span>
|
||||
<button class="player-close" onclick={stopAudio}>✕</button>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="surah-grid">
|
||||
{#each surahs as s}
|
||||
<button class="surah-btn" onclick={() => loadSurah(s.number)}>
|
||||
<span class="surah-num">{s.number}</span>
|
||||
<span class="surah-name">{s.englishName}</span>
|
||||
<span class="surah-ayahs">{s.numberOfAyahs} ayahs</span>
|
||||
{#each surahs as surah}
|
||||
<button class="surah-card" onclick={() => loadSurah(surah.number)}>
|
||||
<span class="surah-num">{surah.number}</span>
|
||||
<span class="surah-name">{surah.englishName}</span>
|
||||
<span class="surah-arabic">{surah.name}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -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);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,583 @@
|
||||
<script>
|
||||
// ── Polar.sh Product IDs (replace with actual IDs) ──
|
||||
const SADAQAH_PRODUCT_ID = 'replace-with-actual-id';
|
||||
const SADAQAH_MONTHLY_PRODUCT_ID = 'replace-with-actual-id';
|
||||
|
||||
// ── localStorage key ──
|
||||
const STORAGE_KEY = 'nur-falah-donations';
|
||||
|
||||
let activeTab = $state('bank-trustee'); // 'bank-trustee' | 'app-support'
|
||||
let sadaqahAmount = $state(0);
|
||||
let customAmount = $state('');
|
||||
let state = $state('idle'); // 'idle' | 'processing' | 'completed'
|
||||
let lastDonation = $state(null);
|
||||
let showStats = $state(false);
|
||||
let isMonthly = $state(false);
|
||||
|
||||
// ── Helpers ──
|
||||
function centsToRm(cents) {
|
||||
return 'RM' + (cents / 100).toFixed(2);
|
||||
}
|
||||
|
||||
function getDonations() {
|
||||
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]'); }
|
||||
catch { return []; }
|
||||
}
|
||||
|
||||
function saveDonation(record) {
|
||||
const donations = getDonations();
|
||||
donations.push(record);
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(donations));
|
||||
}
|
||||
|
||||
function getAmount() {
|
||||
if (customAmount) {
|
||||
const val = parseFloat(customAmount);
|
||||
if (isNaN(val) || val <= 0) return 0;
|
||||
return Math.round(val * 100);
|
||||
}
|
||||
return sadaqahAmount;
|
||||
}
|
||||
|
||||
function handleSupport() {
|
||||
const amount = getAmount();
|
||||
if (amount <= 0) return;
|
||||
const pid = isMonthly ? SADAQAH_MONTHLY_PRODUCT_ID : SADAQAH_PRODUCT_ID;
|
||||
const url = `https://checkout.polar.sh/pay/${pid}?amount=${amount}`;
|
||||
state = 'processing';
|
||||
const donation = { type: 'sadaqah', amount, currency: 'MYR', timestamp: Date.now() };
|
||||
saveDonation(donation);
|
||||
lastDonation = donation;
|
||||
window.open(url, '_blank', 'width=600,height=700');
|
||||
}
|
||||
|
||||
function confirmComplete() { state = 'completed'; }
|
||||
function resetState() { state = 'idle'; sadaqahAmount = 0; customAmount = ''; }
|
||||
|
||||
function switchTab(tabId) {
|
||||
window.dispatchEvent(new CustomEvent('switch-tab', { detail: tabId }));
|
||||
}
|
||||
|
||||
function getStats() {
|
||||
const donations = getDonations();
|
||||
const totalDonors = new Set(donations.map(d => d.email || d.timestamp)).size;
|
||||
const totalRaised = donations.reduce((sum, d) => sum + d.amount, 0);
|
||||
return { totalDonors, totalRaised, count: donations.length };
|
||||
}
|
||||
|
||||
// Sadaqah amounts in cents
|
||||
const presets = [500, 1000, 2500, 5000]; // RM5, RM10, RM25, RM50
|
||||
</script>
|
||||
|
||||
<script module>
|
||||
const STORAGE_KEY = 'nur-falah-donations';
|
||||
export function getSupportStats() {
|
||||
try {
|
||||
const donations = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
|
||||
const totalDonors = new Set(donations.map(d => d.email || d.timestamp)).size;
|
||||
const totalRaised = donations.reduce((sum, d) => sum + d.amount, 0);
|
||||
return { totalDonors, totalRaised, lastDonation: donations.at(-1) || null };
|
||||
} catch { return { totalDonors: 0, totalRaised: 0, lastDonation: null }; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="support">
|
||||
{#if state === 'completed'}
|
||||
<!-- Thank-You Screen -->
|
||||
<div class="thank-you">
|
||||
<div class="checkmark-circle">
|
||||
<svg viewBox="0 0 52 52" class="checkmark">
|
||||
<circle cx="26" cy="26" r="25" fill="none" class="checkmark-circle-bg"/>
|
||||
<path fill="none" d="M14 27l7 7 16-16" class="checkmark-check"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="thank-title">Jazakallah khair!</h1>
|
||||
<p class="thank-subtitle">May Allah accept your sadaqah and multiply your reward. Ameen.</p>
|
||||
{#if lastDonation}
|
||||
<div class="receipt">
|
||||
<span class="receipt-label">🌱 Sadaqah for Nur Falah</span>
|
||||
<span class="receipt-amount">{centsToRm(lastDonation.amount)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="thank-actions">
|
||||
<button class="share-btn" onclick={() => window.open('https://wa.me/?text=I%20just%20supported%20Nur%20Falah%20Muslim%20Companion!%20May%20Allah%20bless%20this%20effort.', '_blank')}>Share on WhatsApp</button>
|
||||
<button class="share-btn share-x" onclick={() => window.open('https://twitter.com/intent/tweet?text=I%20just%20supported%20Nur%20Falah%20Muslim%20Companion!%20May%20Allah%20bless%20this%20effort.', '_blank')}>Share on X</button>
|
||||
</div>
|
||||
<button class="primary" onclick={resetState}>Go Back</button>
|
||||
</div>
|
||||
|
||||
{:else if state === 'processing'}
|
||||
<!-- Processing -->
|
||||
<div class="processing-screen">
|
||||
<div class="processing-spinner"></div>
|
||||
<h2>Opening Checkout...</h2>
|
||||
<p>Complete your payment in the popup window.</p>
|
||||
<button class="primary" onclick={confirmComplete}>I've Completed My Payment</button>
|
||||
<button class="secondary" onclick={resetState}>Cancel</button>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<!-- Tabs -->
|
||||
<div class="tab-bar">
|
||||
<button class="tab-btn" class:active={activeTab === 'bank-trustee'} onclick={() => activeTab = 'bank-trustee'}>
|
||||
⛲ Give Through Our Trustee
|
||||
</button>
|
||||
<button class="tab-btn" class:active={activeTab === 'app-support'} onclick={() => activeTab = 'app-support'}>
|
||||
🌱 Support the App
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if activeTab === 'bank-trustee'}
|
||||
<!-- BANK TRUSTEE GIVING -->
|
||||
<div class="trustee-hero">
|
||||
<div class="trustee-icon-big">🏛️</div>
|
||||
<h2>Give with Confidence</h2>
|
||||
<p class="trustee-sub">Your zakat, sadaqah, and waqf flow through our regulated bank trustee partner — Shariah audited, fully transparent.</p>
|
||||
</div>
|
||||
|
||||
<div class="giving-grid">
|
||||
<!-- Waqf — Primary -->
|
||||
<div class="giving-card featured" onclick={() => switchTab(14)} onkeydown={(e) => e.key === 'Enter' && switchTab(14)} role="button" tabindex="0">
|
||||
<div class="giving-icon">⛲</div>
|
||||
<h3>Bir Nur — Well of Light</h3>
|
||||
<p>Endow a perpetual waqf. Inspired by Uthman's Well of Rumah and Abdur Rahman bin Auf's garden.</p>
|
||||
<div class="giving-meta">
|
||||
<span>🌴 Your principal grows</span>
|
||||
<span>💧 Returns serve forever</span>
|
||||
</div>
|
||||
<span class="giving-cta">Dig Your Well →</span>
|
||||
</div>
|
||||
|
||||
<!-- Zakat -->
|
||||
<div class="giving-card" onclick={() => switchTab(8)} onkeydown={(e) => e.key === 'Enter' && switchTab(8)} role="button" tabindex="0">
|
||||
<div class="giving-icon">💰</div>
|
||||
<h3>Calculate & Pay Zakat</h3>
|
||||
<p>Use our Zakat Calculator then pay directly through our bank trustee.</p>
|
||||
<div class="giving-meta">
|
||||
<span>📋 Live nisab rates</span>
|
||||
<span>🧾 Tax-relief receipt</span>
|
||||
</div>
|
||||
<span class="giving-cta">Calculate Now →</span>
|
||||
</div>
|
||||
|
||||
<!-- Sadaqah (coming soon) -->
|
||||
<div class="giving-card muted">
|
||||
<div class="giving-icon">🌱</div>
|
||||
<h3>Sadaqah Micro-Giving</h3>
|
||||
<p>One-tap giving after each prayer. Coming soon.</p>
|
||||
<div class="giving-meta">
|
||||
<span>⏳ In development</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hibah (coming soon) -->
|
||||
<div class="giving-card muted">
|
||||
<div class="giving-icon">🎁</div>
|
||||
<h3>Hibah Planning</h3>
|
||||
<p>Islamic gift planning with bank custody. Coming soon.</p>
|
||||
<div class="giving-meta">
|
||||
<span>⏳ In development</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- How it works -->
|
||||
<div class="how-works">
|
||||
<h3>Why a Bank Trustee?</h3>
|
||||
<div class="why-grid">
|
||||
<div class="why-item">
|
||||
<span class="why-icon">🔒</span>
|
||||
<span class="why-label">BNM-regulated</span>
|
||||
<span class="why-desc">Funds held by a licensed Islamic bank</span>
|
||||
</div>
|
||||
<div class="why-item">
|
||||
<span class="why-icon">📜</span>
|
||||
<span class="why-label">Shariah-audited</span>
|
||||
<span class="why-desc">Bank's Shariah committee approves all investments</span>
|
||||
</div>
|
||||
<div class="why-item">
|
||||
<span class="why-icon">📊</span>
|
||||
<span class="why-label">Transparent</span>
|
||||
<span class="why-desc">Per-user dashboard showing every movement</span>
|
||||
</div>
|
||||
<div class="why-item">
|
||||
<span class="why-icon">♾️</span>
|
||||
<span class="why-label">Perpetual</span>
|
||||
<span class="why-desc">Principal never spent — only returns benefit</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<!-- APP SUPPORT → Polar.sh -->
|
||||
<div class="app-support-section">
|
||||
<div class="app-support-intro">
|
||||
<div class="app-support-icon">🌱</div>
|
||||
<h2>Support Nur Falah</h2>
|
||||
<p>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.</p>
|
||||
</div>
|
||||
|
||||
<div class="door-card">
|
||||
<div class="door-accent-bar"></div>
|
||||
<div class="door-content">
|
||||
<div class="door-header">
|
||||
<span class="door-icon-large">🌱</span>
|
||||
<h2 class="door-title">Sadaqah for Nur Falah</h2>
|
||||
</div>
|
||||
<p class="door-body">Your contribution keeps this app running. Every prayer, every ayah recited — you're part of that reward.</p>
|
||||
|
||||
<div class="amount-pills">
|
||||
{#each presets as amt}
|
||||
<button
|
||||
class="pill"
|
||||
class:active={sadaqahAmount === amt && !customAmount}
|
||||
onclick={() => { sadaqahAmount = amt; customAmount = ''; }}
|
||||
>{centsToRm(amt)}</button>
|
||||
{/each}
|
||||
<button
|
||||
class="pill pill-custom"
|
||||
class:active={!!customAmount}
|
||||
onclick={() => customAmount = '10'}
|
||||
>Custom</button>
|
||||
</div>
|
||||
|
||||
{#if !sadaqahAmount && !customAmount}
|
||||
<p class="hint">Select an amount above</p>
|
||||
{/if}
|
||||
|
||||
{#if customAmount !== '' && !sadaqahAmount}
|
||||
<input type="number" min="1" step="1" placeholder="Enter amount (RM)" class="custom-input" bind:value={customAmount} />
|
||||
{/if}
|
||||
|
||||
<div class="frequency-toggle">
|
||||
<button class="freq-btn" class:active={!isMonthly} onclick={() => isMonthly = false}>One-Time</button>
|
||||
<button class="freq-btn" class:active={isMonthly} onclick={() => isMonthly = true}>Monthly 🌙</button>
|
||||
</div>
|
||||
|
||||
<div class="door-actions">
|
||||
<button class="primary donate-btn" disabled={getAmount() <= 0} onclick={handleSupport}>
|
||||
{isMonthly ? 'Subscribe Monthly 🌙' : 'Support Now'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
{#if getStats().totalDonors > 0}
|
||||
{@const stats = getStats()}
|
||||
<div class="stats-section" onclick={() => showStats = !showStats} onkeydown={(e) => e.key === 'Enter' && (showStats = !showStats)} role="button" tabindex="0">
|
||||
<div class="stats-header">
|
||||
<span class="stats-title">Community Impact</span>
|
||||
<span class="stats-toggle">{showStats ? '▲' : '▼'}</span>
|
||||
</div>
|
||||
{#if showStats}
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item">
|
||||
<span class="stat-icon">💚</span>
|
||||
<span class="stat-value">{stats.totalDonors}</span>
|
||||
<span class="stat-label">Total Donors</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-icon">🌱</span>
|
||||
<span class="stat-value">{centsToRm(stats.totalRaised)}</span>
|
||||
<span class="stat-label">Total Raised</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="bank-trustee-note">
|
||||
<p>🙏 <em>Nur Falah is free forever as sadaqah jariyah. If you'd like to give zakat or set up a perpetual waqf, use the <button class="link-btn" onclick={() => activeTab = 'bank-trustee'}>Give Through Our Trustee</button> tab above.</em></p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.support { padding: 8px 0 20px; min-height: 60vh; }
|
||||
|
||||
/* ── Tab Bar ── */
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 16px;
|
||||
background: #111820;
|
||||
border-radius: 12px;
|
||||
padding: 4px;
|
||||
}
|
||||
.tab-btn {
|
||||
flex: 1;
|
||||
padding: 10px 8px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: rgba(232,228,220,0.5);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 300;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.tab-btn.active {
|
||||
background: rgba(201,168,76,0.12);
|
||||
color: #C9A84C;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ── Trustee Hero ── */
|
||||
.trustee-hero {
|
||||
text-align: center;
|
||||
padding: 16px 0;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.trustee-icon-big { font-size: 2.5rem; margin-bottom: 6px; }
|
||||
.trustee-hero h2 {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 1.1rem;
|
||||
color: #C9A84C;
|
||||
}
|
||||
.trustee-sub {
|
||||
font-size: 0.78rem;
|
||||
color: rgba(232,228,220,0.5);
|
||||
max-width: 300px;
|
||||
margin: 6px auto 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── Giving Grid ── */
|
||||
.giving-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.giving-card {
|
||||
background: #141C24;
|
||||
border: 1px solid rgba(201,168,76,0.12);
|
||||
border-radius: 14px;
|
||||
padding: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.giving-card.featured {
|
||||
border-color: rgba(46,204,113,0.3);
|
||||
background: linear-gradient(135deg, rgba(46,204,113,0.04), #141C24);
|
||||
}
|
||||
.giving-card.featured::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 4px;
|
||||
height: 100%;
|
||||
background: #2ECC71;
|
||||
border-radius: 0 2px 2px 0;
|
||||
}
|
||||
.giving-card:hover {
|
||||
border-color: rgba(201,168,76,0.3);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.giving-card.muted { opacity: 0.5; cursor: default; }
|
||||
.giving-card.muted:hover { transform: none; border-color: rgba(201,168,76,0.12); }
|
||||
.giving-icon { font-size: 1.6rem; margin-bottom: 4px; }
|
||||
.giving-card h3 {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 0.85rem;
|
||||
color: #E8E4DC;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.giving-card p {
|
||||
font-size: 0.72rem;
|
||||
color: rgba(232,228,220,0.55);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.giving-meta { display: flex; flex-direction: column; gap: 1px; font-size: 0.6rem; color: rgba(232,228,220,0.3); margin-bottom: 6px; }
|
||||
.giving-cta {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.65rem;
|
||||
color: #2ECC71;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* ── Why Bank Trustee ── */
|
||||
.how-works { margin-bottom: 16px; }
|
||||
.how-works h3 {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 0.8rem;
|
||||
color: #E8E4DC;
|
||||
text-align: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.why-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px;
|
||||
}
|
||||
.why-item {
|
||||
background: #111820;
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.why-icon { font-size: 1.2rem; }
|
||||
.why-label {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.65rem;
|
||||
color: #E8E4DC;
|
||||
font-weight: 500;
|
||||
}
|
||||
.why-desc {
|
||||
font-size: 0.6rem;
|
||||
color: rgba(232,228,220,0.35);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* ── App Support ── */
|
||||
.app-support-section {}
|
||||
.app-support-intro { text-align: center; padding: 12px 0; }
|
||||
.app-support-icon { font-size: 2.5rem; }
|
||||
.app-support-intro h2 {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 1.1rem;
|
||||
color: #C9A84C;
|
||||
margin: 4px 0;
|
||||
}
|
||||
.app-support-intro p {
|
||||
font-size: 0.78rem;
|
||||
color: rgba(232,228,220,0.55);
|
||||
line-height: 1.6;
|
||||
max-width: 320px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.door-card {
|
||||
background: #141C24;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(46,204,113,0.25);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.door-accent-bar { height: 4px; background: #2ECC71; }
|
||||
.door-content { padding: 20px; }
|
||||
.door-header { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
|
||||
.door-icon-large { font-size: 1.8rem; flex-shrink: 0; }
|
||||
.door-title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: #2ECC71;
|
||||
margin: 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.door-body {
|
||||
color: rgba(232,228,220,0.7);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.amount-pills { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 10px; }
|
||||
.pill {
|
||||
padding: 8px 14px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(232,228,220,0.2);
|
||||
background: transparent;
|
||||
color: #E8E4DC;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.pill.active { background: #2ECC71; color: #070A0D; border-color: #2ECC71; font-weight: 600; }
|
||||
.pill-custom { border-style: dashed; }
|
||||
.hint { color: rgba(232,228,220,0.35); font-size: 0.72rem; font-family: 'JetBrains Mono', monospace; margin-bottom: 8px; }
|
||||
.custom-input { width: 100%; margin-bottom: 10px; box-sizing: border-box; }
|
||||
|
||||
.frequency-toggle { display: flex; gap: 6px; margin-bottom: 12px; justify-content: center; }
|
||||
.freq-btn {
|
||||
padding: 6px 18px;
|
||||
border: 1px solid rgba(201,168,76,0.3);
|
||||
border-radius: 20px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
background: transparent;
|
||||
color: rgba(232,228,220,0.6);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.freq-btn.active { background: #2ECC71; color: #070A0D; border-color: #2ECC71; font-weight: 600; }
|
||||
|
||||
.door-actions { display: flex; gap: 8px; margin-top: 4px; }
|
||||
.door-actions :global(.primary) { flex: 1; }
|
||||
.donate-btn:disabled { opacity: 0.3; cursor: not-allowed; }
|
||||
|
||||
.bank-trustee-note {
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
font-size: 0.72rem;
|
||||
color: rgba(232,228,220,0.4);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.link-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #C9A84C;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
padding: 0;
|
||||
}
|
||||
.link-btn:hover { color: #E8C97A; }
|
||||
|
||||
/* ── Processing ── */
|
||||
.processing-screen { text-align: center; padding: 60px 20px; display: flex; flex-direction: column; align-items: center; gap: 16px; }
|
||||
.processing-spinner { width: 48px; height: 48px; border: 3px solid rgba(201,168,76,0.2); border-top-color: #C9A84C; border-radius: 50%; animation: spin 0.8s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.processing-screen h2 { font-family: 'Cinzel', serif; color: #C9A84C; font-size: 1.1rem; }
|
||||
.processing-screen p { color: rgba(232,228,220,0.6); font-size: 0.85rem; max-width: 280px; }
|
||||
.processing-screen :global(.secondary) { background: transparent; border: none; color: rgba(232,228,220,0.4); font-family: 'JetBrains Mono', monospace; font-size: 0.75rem; cursor: pointer; text-decoration: underline; text-underline-offset: 3px; }
|
||||
|
||||
/* ── Thank You ── */
|
||||
.thank-you { text-align: center; padding: 48px 20px; display: flex; flex-direction: column; align-items: center; gap: 16px; }
|
||||
.checkmark-circle { width: 72px; height: 72px; animation: popIn 0.4s cubic-bezier(0.34,1.56,0.64,1); }
|
||||
@keyframes popIn { 0% { transform: scale(0); opacity: 0; } 100% { transform: scale(1); opacity: 1; } }
|
||||
.checkmark-circle-bg { stroke: #2ECC71; stroke-width: 2; stroke-dasharray: 166; stroke-dashoffset: 166; animation: circleAnim 0.6s ease forwards; }
|
||||
@keyframes circleAnim { to { stroke-dashoffset: 0; } }
|
||||
.checkmark-check { stroke: #2ECC71; stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; stroke-dasharray: 48; stroke-dashoffset: 48; animation: checkAnim 0.4s 0.3s ease forwards; }
|
||||
@keyframes checkAnim { to { stroke-dashoffset: 0; } }
|
||||
.thank-title { font-family: 'Cinzel', serif; font-size: 1.6rem; color: #C9A84C; margin: 0; }
|
||||
.thank-subtitle { color: rgba(232,228,220,0.75); font-size: 0.9rem; line-height: 1.6; max-width: 320px; }
|
||||
.receipt { display: flex; justify-content: space-between; align-items: center; background: rgba(201,168,76,0.08); border: 1px solid rgba(201,168,76,0.2); border-radius: 12px; padding: 12px 18px; width: 100%; max-width: 320px; }
|
||||
.receipt-label { font-family: 'Cinzel', serif; font-size: 0.8rem; color: #E8E4DC; }
|
||||
.receipt-amount { font-family: 'JetBrains Mono', monospace; font-size: 1rem; font-weight: 600; color: #C9A84C; }
|
||||
.thank-actions { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; }
|
||||
.share-btn { background: transparent; border: 1px solid rgba(232,228,220,0.25); color: #E8E4DC; padding: 8px 16px; border-radius: 8px; font-family: 'DM Sans', sans-serif; font-size: 0.8rem; cursor: pointer; transition: all 0.2s; }
|
||||
.share-btn:hover { border-color: #C9A84C; color: #C9A84C; }
|
||||
.share-x { border-color: rgba(29,161,242,0.3); color: #1DA1F2; }
|
||||
|
||||
/* ── Stats ── */
|
||||
.stats-section { background: #141C24; border-radius: 16px; border: 1px solid rgba(201,168,76,0.15); overflow: hidden; cursor: pointer; margin-bottom: 12px; }
|
||||
.stats-header { display: flex; justify-content: space-between; align-items: center; padding: 14px 18px; }
|
||||
.stats-title { font-family: 'Cinzel', serif; font-size: 0.85rem; color: #C9A84C; font-weight: 600; }
|
||||
.stats-toggle { color: rgba(232,228,220,0.35); font-size: 0.75rem; }
|
||||
.stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; background: rgba(201,168,76,0.08); border-top: 1px solid rgba(201,168,76,0.1); }
|
||||
.stat-item { background: #141C24; padding: 14px; text-align: center; display: flex; flex-direction: column; align-items: center; gap: 4px; }
|
||||
.stat-icon { font-size: 1.2rem; }
|
||||
.stat-value { font-family: 'Cinzel', serif; font-size: 1.3rem; font-weight: 700; color: #E8E4DC; }
|
||||
.stat-label { font-family: 'JetBrains Mono', monospace; font-size: 0.6rem; color: rgba(232,228,220,0.5); text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
</style>
|
||||
+29
-12
@@ -34,17 +34,17 @@
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div class="counter-ring" onclick={increment} role="button" tabindex="0" onkeydown={(e) => e.key === 'Enter' && increment()}>
|
||||
<svg viewBox="0 0 120 120" width="160" height="160">
|
||||
<circle cx="60" cy="60" r="52" fill="none" stroke="#e6f5f0" stroke-width="8" />
|
||||
<circle cx="60" cy="60" r="52" fill="none" stroke="rgba(201,168,76,0.1)" stroke-width="8" />
|
||||
<circle
|
||||
cx="60" cy="60" r="52"
|
||||
fill="none" stroke="#0f766e" stroke-width="8"
|
||||
fill="none" stroke="#C9A84C" stroke-width="8"
|
||||
stroke-dasharray={2 * Math.PI * 52}
|
||||
stroke-dashoffset={2 * Math.PI * 52 * (1 - count / target)}
|
||||
stroke-linecap="round"
|
||||
transform="rotate(-90 60 60)"
|
||||
/>
|
||||
<text x="60" y="56" text-anchor="middle" fill="#0f766e" font-size="22" font-weight="700">{count}</text>
|
||||
<text x="60" y="76" text-anchor="middle" fill="#5b8c85" font-size="10">/ {target}</text>
|
||||
<text x="60" y="56" text-anchor="middle" fill="#C9A84C" font-size="22" font-weight="900" font-family="Cinzel, serif">{count}</text>
|
||||
<text x="60" y="76" text-anchor="middle" fill="rgba(232,228,220,0.55)" font-size="10" font-family="Cinzel, serif">/ {target}</text>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
@@ -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; }
|
||||
</style>
|
||||
.hint { font-family: 'DM Sans', sans-serif; font-size: 0.7rem; color: rgba(232,228,220,0.3); }
|
||||
</style>
|
||||
@@ -0,0 +1,407 @@
|
||||
<script>
|
||||
let storageData = $state(loadData());
|
||||
let today = $state(new Date().toISOString().split('T')[0]);
|
||||
let showResetConfirm = $state(false);
|
||||
|
||||
const worshipItems = [
|
||||
{ id: 'fajr', label: 'Fajr prayed', icon: '🕌' },
|
||||
{ id: 'dhuhr', label: 'Dhuhr prayed', icon: '🕌' },
|
||||
{ id: 'asr', label: 'Asr prayed', icon: '🕌' },
|
||||
{ id: 'maghrib', label: 'Maghrib prayed', icon: '🕌' },
|
||||
{ id: 'isha', label: 'Isha prayed', icon: '🕌' },
|
||||
{ id: 'sunnah', label: 'Sunnah prayers', icon: '📿' },
|
||||
{ id: 'morning_adhkar', label: 'Morning Adhkar', icon: '🤲' },
|
||||
{ id: 'evening_adhkar', label: 'Evening Adhkar', icon: '🤲' },
|
||||
{ id: 'quran', label: 'Read Quran (1+ page)', icon: '📖' },
|
||||
{ id: 'tasbih', label: 'Tasbih (33x+)', icon: '📿' },
|
||||
{ id: 'charity', label: 'Charity given', icon: '💝' },
|
||||
{ id: 'dua', label: 'Made dua', icon: '🤲' },
|
||||
];
|
||||
|
||||
function loadData() {
|
||||
try {
|
||||
const raw = localStorage.getItem('nur-worship');
|
||||
if (raw) return JSON.parse(raw);
|
||||
} catch (_) {}
|
||||
return { history: {}, streak: 0, bestStreak: 0, lastDate: null };
|
||||
}
|
||||
|
||||
function saveData() {
|
||||
try {
|
||||
localStorage.setItem('nur-worship', JSON.stringify(storageData));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function getTodayEntries() {
|
||||
if (!storageData.history[today]) {
|
||||
storageData.history[today] = {};
|
||||
}
|
||||
return storageData.history[today];
|
||||
}
|
||||
|
||||
function toggleItem(id) {
|
||||
const entries = getTodayEntries();
|
||||
entries[id] = !entries[id];
|
||||
recalcStreak();
|
||||
saveData();
|
||||
storageData = loadData(); // trigger reactivity
|
||||
}
|
||||
|
||||
function isChecked(id) {
|
||||
const entries = storageData.history[today];
|
||||
return entries ? !!entries[id] : false;
|
||||
}
|
||||
|
||||
const completedCount = $derived.by(() => {
|
||||
const entries = storageData.history[today];
|
||||
if (!entries) return 0;
|
||||
return worshipItems.filter(i => entries[i.id]).length;
|
||||
});
|
||||
|
||||
const isDayComplete = $derived(completedCount >= 6);
|
||||
|
||||
const streakMessage = $derived.by(() => {
|
||||
const s = storageData.streak;
|
||||
if (s === 0) return 'Start your journey today! 🌱';
|
||||
if (s >= 365) return '🌟 A full year! Masha\'Allah!';
|
||||
if (s >= 30) return '🔥 Month of consistency! Masha\'Allah!';
|
||||
if (s >= 7) return '🔥 One week strong! Keep going!';
|
||||
return `🔥 ${s} day${s > 1 ? 's' : ''} streak!`;
|
||||
});
|
||||
|
||||
function recalcStreak() {
|
||||
const dates = Object.keys(storageData.history).sort().reverse();
|
||||
let streak = 0;
|
||||
const todayStr = new Date().toISOString().split('T')[0];
|
||||
let checkDate = new Date(todayStr);
|
||||
|
||||
for (const dateStr of dates) {
|
||||
const entries = storageData.history[dateStr];
|
||||
if (!entries) continue;
|
||||
const count = worshipItems.filter(i => entries[i.id]).length;
|
||||
const complete = count >= 6;
|
||||
const d = new Date(dateStr);
|
||||
const diff = Math.round((checkDate - d) / 86400000);
|
||||
|
||||
if (diff <= 1 && complete) {
|
||||
streak++;
|
||||
checkDate = d;
|
||||
} else if (diff > 1) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
storageData.streak = streak;
|
||||
if (streak > storageData.bestStreak) {
|
||||
storageData.bestStreak = streak;
|
||||
}
|
||||
storageData.lastDate = todayStr;
|
||||
}
|
||||
|
||||
function resetData() {
|
||||
storageData = { history: {}, streak: 0, bestStreak: storageData.bestStreak, lastDate: null };
|
||||
saveData();
|
||||
showResetConfirm = false;
|
||||
}
|
||||
|
||||
function getDayColor(dateStr) {
|
||||
const entries = storageData.history[dateStr];
|
||||
if (!entries) return 'transparent';
|
||||
const count = worshipItems.filter(i => entries[i.id]).length;
|
||||
if (count >= 10) return '#2ECC71';
|
||||
if (count >= 6) return '#C9A84C';
|
||||
if (count >= 3) return 'rgba(201,168,76,0.3)';
|
||||
return 'rgba(232,228,220,0.1)';
|
||||
}
|
||||
|
||||
function getLast7Days() {
|
||||
const days = [];
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - i);
|
||||
const str = d.toISOString().split('T')[0];
|
||||
days.push(str);
|
||||
}
|
||||
return days;
|
||||
}
|
||||
|
||||
function getMonthDays() {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = now.getMonth();
|
||||
const firstDay = new Date(year, month, 1).getDay();
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||
const days = [];
|
||||
for (let i = 0; i < firstDay; i++) days.push(null);
|
||||
for (let i = 1; i <= daysInMonth; i++) {
|
||||
const d = new Date(year, month, i);
|
||||
days.push(d.toISOString().split('T')[0]);
|
||||
}
|
||||
return days;
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
const d = new Date(dateStr + 'T12:00:00');
|
||||
return d.toLocaleDateString('en', { weekday: 'short', month: 'short', day: 'numeric' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="card">
|
||||
<h2>📊 Worship Tracker</h2>
|
||||
|
||||
<div class="streak-hero">
|
||||
<div class="streak-fire">{storageData.streak > 0 ? '🔥' : '🌱'}</div>
|
||||
<div class="streak-count">{storageData.streak}</div>
|
||||
<div class="streak-label">day streak</div>
|
||||
<div class="streak-msg">{streakMessage}</div>
|
||||
<div class="best-streak">🏆 Best: {storageData.bestStreak} days</div>
|
||||
</div>
|
||||
|
||||
<div class="today-progress">
|
||||
<div class="progress-label">Today's Progress</div>
|
||||
<div class="progress-bar-bg">
|
||||
<div class="progress-bar-fill" style="width: {(completedCount / worshipItems.length) * 100}%"></div>
|
||||
</div>
|
||||
<div class="progress-count">{completedCount} / {worshipItems.length}</div>
|
||||
</div>
|
||||
|
||||
<div class="items-grid">
|
||||
{#each worshipItems as item}
|
||||
{@const checked = isChecked(item.id)}
|
||||
<button class="worship-item" class:checked onclick={() => toggleItem(item.id)}>
|
||||
<span class="item-icon">{item.icon}</span>
|
||||
<span class="item-label">{item.label}</span>
|
||||
<span class="item-check">{checked ? '✅' : '⬜'}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="week-section">
|
||||
<div class="section-label">Last 7 Days</div>
|
||||
<div class="week-grid">
|
||||
{#each getLast7Days() as day}
|
||||
<div class="day-cell" style="background: {getDayColor(day)}" title="{formatDate(day)}: {(() => { const e = storageData.history[day]; if (!e) return 'No data'; const c = worshipItems.filter(i => e[i.id]).length; return c + '/12 completed'; })()}">
|
||||
<span class="day-name">{new Date(day + 'T12:00:00').toLocaleDateString('en', { weekday: 'short' })}</span>
|
||||
<span class="day-num">{new Date(day + 'T12:00:00').getDate()}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="month-section">
|
||||
<div class="section-label">{new Date().toLocaleDateString('en', { month: 'long', year: 'numeric' })}</div>
|
||||
<div class="month-grid">
|
||||
{#each getMonthDays() as day}
|
||||
{#if day}
|
||||
<div class="month-cell" style="background: {getDayColor(day)}" title="{formatDate(day)}: {(() => { const e = storageData.history[day]; if (!e) return 'No data'; const c = worshipItems.filter(i => e[i.id]).length; return c + '/12 completed'; })()}">
|
||||
{new Date(day + 'T12:00:00').getDate()}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="month-cell empty"></div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if !showResetConfirm}
|
||||
<button class="secondary reset-btn" onclick={() => showResetConfirm = true}>Reset Progress</button>
|
||||
{:else}
|
||||
<div class="reset-confirm">
|
||||
<p>Reset all tracking data? (Best streak preserved)</p>
|
||||
<div class="reset-actions">
|
||||
<button class="primary" onclick={resetData}>Yes, Reset</button>
|
||||
<button class="secondary" onclick={() => showResetConfirm = false}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.streak-hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 20px;
|
||||
background: #111820;
|
||||
border: 1px solid rgba(201,168,76,0.2);
|
||||
border-radius: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.streak-fire { font-size: 2.5rem; }
|
||||
.streak-count {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 3.5rem;
|
||||
font-weight: 900;
|
||||
color: #C9A84C;
|
||||
line-height: 1;
|
||||
}
|
||||
.streak-label {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 2px;
|
||||
color: rgba(232,228,220,0.55);
|
||||
}
|
||||
.streak-msg {
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
font-size: 0.85rem;
|
||||
color: rgba(232,228,220,0.75);
|
||||
margin-top: 4px;
|
||||
}
|
||||
.best-streak {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.65rem;
|
||||
color: rgba(232,228,220,0.4);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.today-progress {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.progress-label {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: rgba(232,228,220,0.55);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.progress-bar-bg {
|
||||
height: 8px;
|
||||
background: #111820;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(201,168,76,0.15);
|
||||
}
|
||||
.progress-bar-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #C9A84C, #2ECC71);
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
.progress-count {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.7rem;
|
||||
color: rgba(232,228,220,0.4);
|
||||
text-align: right;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.items-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.worship-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
background: #111820;
|
||||
border: 1px solid rgba(201,168,76,0.12);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: all 0.2s;
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
font-size: 0.85rem;
|
||||
color: rgba(232,228,220,0.7);
|
||||
}
|
||||
.worship-item:hover {
|
||||
border-color: rgba(201,168,76,0.3);
|
||||
}
|
||||
.worship-item.checked {
|
||||
border-color: rgba(46,204,113,0.3);
|
||||
background: rgba(46,204,113,0.06);
|
||||
color: #E8E4DC;
|
||||
}
|
||||
.item-icon { font-size: 1rem; }
|
||||
.item-label { flex: 1; }
|
||||
.item-check { font-size: 1rem; }
|
||||
|
||||
.section-label {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: rgba(232,228,220,0.55);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.week-section, .month-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.week-grid {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
justify-content: center;
|
||||
}
|
||||
.day-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 8px 6px;
|
||||
border-radius: 8px;
|
||||
min-width: 44px;
|
||||
border: 1px solid rgba(201,168,76,0.1);
|
||||
}
|
||||
.day-name {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.55rem;
|
||||
text-transform: uppercase;
|
||||
color: rgba(232,228,220,0.4);
|
||||
}
|
||||
.day-num {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.8rem;
|
||||
color: rgba(232,228,220,0.7);
|
||||
}
|
||||
|
||||
.month-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 4px;
|
||||
}
|
||||
.month-cell {
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.65rem;
|
||||
color: rgba(232,228,220,0.6);
|
||||
border: 1px solid rgba(201,168,76,0.08);
|
||||
}
|
||||
.month-cell.empty {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.reset-btn {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
text-align: center;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
.reset-confirm {
|
||||
padding: 12px;
|
||||
background: rgba(225,29,72,0.1);
|
||||
border: 1px solid rgba(225,29,72,0.2);
|
||||
border-radius: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
.reset-confirm p {
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
font-size: 0.8rem;
|
||||
color: rgba(232,228,220,0.7);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.reset-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,474 @@
|
||||
<script>
|
||||
const OZ_TO_G = 31.1035;
|
||||
const GOLD_NISAB_G = 85;
|
||||
const SILVER_NISAB_G = 595;
|
||||
const ZAKAT_RATE = 0.025;
|
||||
const FALLBACK_GOLD_PRICE = 85;
|
||||
const FALLBACK_SILVER_PRICE = 0.85;
|
||||
|
||||
let goldPrice = $state(null);
|
||||
let silverPrice = $state(null);
|
||||
let pricesError = $state(false);
|
||||
let loadingPrices = $state(true);
|
||||
|
||||
let goldG = $state(0);
|
||||
let silverG = $state(0);
|
||||
let cash = $state(0);
|
||||
let stocks = $state(0);
|
||||
let other = $state(0);
|
||||
|
||||
async function fetchPrices() {
|
||||
loadingPrices = true;
|
||||
pricesError = false;
|
||||
try {
|
||||
const [goldRes, silverRes] = await Promise.all([
|
||||
fetch('https://api.metals.live/v1/spot/gold'),
|
||||
fetch('https://api.metals.live/v1/spot/silver'),
|
||||
]);
|
||||
if (!goldRes.ok || !silverRes.ok) throw new Error('API returned non-200');
|
||||
const goldData = await goldRes.json();
|
||||
const silverData = await silverRes.json();
|
||||
// metals.live returns [{"timestamp": ..., "price": ..., "currency": "USD"}] or similar
|
||||
const goldSpot = Array.isArray(goldData) ? goldData[0]?.price ?? null : null;
|
||||
const silverSpot = Array.isArray(silverData) ? silverData[0]?.price ?? null : null;
|
||||
if (goldSpot === null || silverSpot === null) throw new Error('Unexpected API response shape');
|
||||
// API returns USD/oz — convert to USD/g
|
||||
goldPrice = goldSpot / OZ_TO_G;
|
||||
silverPrice = silverSpot / OZ_TO_G;
|
||||
} catch {
|
||||
pricesError = true;
|
||||
goldPrice = FALLBACK_GOLD_PRICE;
|
||||
silverPrice = FALLBACK_SILVER_PRICE;
|
||||
} finally {
|
||||
loadingPrices = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => { fetchPrices(); });
|
||||
|
||||
const goldValue = $derived(goldG * (goldPrice ?? 0));
|
||||
const silverValue = $derived(silverG * (silverPrice ?? 0));
|
||||
const totalWealth = $derived(goldValue + silverValue + cash + stocks + other);
|
||||
|
||||
const goldNisab = $derived(GOLD_NISAB_G * (goldPrice ?? 0));
|
||||
const silverNisab = $derived(SILVER_NISAB_G * (silverPrice ?? 0));
|
||||
const effectiveNisab = $derived(Math.min(goldNisab, silverNisab));
|
||||
|
||||
const zakatDue = $derived(totalWealth >= effectiveNisab);
|
||||
const zakatAmount = $derived(totalWealth * ZAKAT_RATE);
|
||||
const shortfall = $derived(zakatDue ? 0 : effectiveNisab - totalWealth);
|
||||
|
||||
function currency(n) {
|
||||
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(n);
|
||||
}
|
||||
|
||||
function gramsStr(g) {
|
||||
return g.toFixed(1) + ' g';
|
||||
}
|
||||
|
||||
function resetFields() {
|
||||
goldG = 0;
|
||||
silverG = 0;
|
||||
cash = 0;
|
||||
stocks = 0;
|
||||
other = 0;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="card">
|
||||
<h2>💰 Zakat Calculator</h2>
|
||||
|
||||
<!-- Live Prices -->
|
||||
<div class="prices-bar">
|
||||
<div class="price-item">
|
||||
<span class="price-label">Gold</span>
|
||||
<span class="price-value gold">
|
||||
{#if loadingPrices}
|
||||
<span class="price-loading">⟳</span>
|
||||
{:else}
|
||||
{currency(goldPrice ?? 0)}<span class="price-unit">/g</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
<div class="price-divider"></div>
|
||||
<div class="price-item">
|
||||
<span class="price-label">Silver</span>
|
||||
<span class="price-value silver">
|
||||
{#if loadingPrices}
|
||||
<span class="price-loading">⟳</span>
|
||||
{:else}
|
||||
{currency(silverPrice ?? 0)}<span class="price-unit">/g</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
<button class="refresh-btn" onclick={fetchPrices} aria-label="Refresh prices">↻</button>
|
||||
</div>
|
||||
|
||||
{#if pricesError}
|
||||
<div class="api-warning">Using fallback prices — could not fetch live rates</div>
|
||||
{/if}
|
||||
|
||||
<!-- Nisab Thresholds -->
|
||||
<div class="nisab-bar">
|
||||
<div class="nisab-item">
|
||||
<span class="nisab-label">Gold Nisab (85g)</span>
|
||||
<span class="nisab-value">{loadingPrices ? '⟳' : currency(goldNisab)}</span>
|
||||
</div>
|
||||
<div class="nisab-item">
|
||||
<span class="nisab-label">Silver Nisab (595g)</span>
|
||||
<span class="nisab-value">{loadingPrices ? '⟳' : currency(silverNisab)}</span>
|
||||
</div>
|
||||
<div class="nisab-effective">
|
||||
<span class="nisab-label">Effective Nisab</span>
|
||||
<span class="nisab-value effective">{loadingPrices ? '⟳' : currency(effectiveNisab)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Input Fields -->
|
||||
<div class="fields-grid">
|
||||
<div class="field-group">
|
||||
<label class="field-label" for="zakat-gold">Gold <span class="field-unit">(grams)</span></label>
|
||||
<div class="field-input-wrap">
|
||||
<input id="zakat-gold" type="number" min="0" step="0.1" bind:value={goldG} placeholder="0.0" />
|
||||
<span class="field-suffix">g</span>
|
||||
</div>
|
||||
<div class="field-value-preview">{currency(goldValue)}</div>
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<label class="field-label" for="zakat-silver">Silver <span class="field-unit">(grams)</span></label>
|
||||
<div class="field-input-wrap">
|
||||
<input id="zakat-silver" type="number" min="0" step="0.1" bind:value={silverG} placeholder="0.0" />
|
||||
<span class="field-suffix">g</span>
|
||||
</div>
|
||||
<div class="field-value-preview">{currency(silverValue)}</div>
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<label class="field-label" for="zakat-cash">Cash <span class="field-unit">(USD)</span></label>
|
||||
<div class="field-input-wrap">
|
||||
<span class="field-prefix">$</span>
|
||||
<input id="zakat-cash" type="number" min="0" step="0.01" bind:value={cash} placeholder="0.00" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<label class="field-label" for="zakat-stocks">Stocks & Business <span class="field-unit">(USD)</span></label>
|
||||
<div class="field-input-wrap">
|
||||
<span class="field-prefix">$</span>
|
||||
<input id="zakat-stocks" type="number" min="0" step="0.01" bind:value={stocks} placeholder="0.00" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<label class="field-label" for="zakat-other">Other Assets <span class="field-unit">(USD)</span></label>
|
||||
<div class="field-input-wrap">
|
||||
<span class="field-prefix">$</span>
|
||||
<input id="zakat-other" type="number" min="0" step="0.01" bind:value={other} placeholder="0.00" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Total -->
|
||||
<div class="total-row">
|
||||
<span class="total-label">Total Wealth</span>
|
||||
<span class="total-value">{currency(totalWealth)}</span>
|
||||
</div>
|
||||
|
||||
<!-- Result -->
|
||||
{#if goldPrice !== null && silverPrice !== null}
|
||||
<div class="result-card" class:due={zakatDue} class:below={!zakatDue}>
|
||||
{#if zakatDue}
|
||||
<div class="result-status-icon">✅</div>
|
||||
<div class="result-status-label">Zakat Due</div>
|
||||
<div class="result-amount">{currency(zakatAmount)}</div>
|
||||
<div class="result-breakdown">
|
||||
<span>{gramsStr(goldG)} gold @ {currency(goldPrice)}/g = {currency(goldValue)}</span>
|
||||
<span>{gramsStr(silverG)} silver @ {currency(silverPrice)}/g = {currency(silverValue)}</span>
|
||||
<span>Cash: {currency(cash)}</span>
|
||||
<span>Stocks/Business: {currency(stocks)}</span>
|
||||
<span>Other Assets: {currency(other)}</span>
|
||||
<span class="result-note">Zakat is {ZAKAT_RATE * 100}% of {currency(totalWealth)}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="result-status-icon">📋</div>
|
||||
<div class="result-status-label">Below Nisab</div>
|
||||
<div class="result-amount amber">{currency(shortfall)}</div>
|
||||
<div class="result-note">You are {currency(shortfall)} short of the nisab threshold of {currency(effectiveNisab)}. Zakat is not due yet.</div>
|
||||
<div class="result-breakdown">
|
||||
<span>Current wealth: {currency(totalWealth)}</span>
|
||||
<span>Nisab: {currency(effectiveNisab)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.prices-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #111820;
|
||||
border: 1px solid rgba(201,168,76,0.2);
|
||||
border-radius: 12px;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 8px;
|
||||
gap: 12px;
|
||||
}
|
||||
.price-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.price-label {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 300;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: rgba(232,228,220,0.55);
|
||||
}
|
||||
.price-value {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.price-value.gold { color: #C9A84C; }
|
||||
.price-value.silver { color: #94A3B8; }
|
||||
.price-unit {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 300;
|
||||
color: rgba(232,228,220,0.4);
|
||||
margin-left: 2px;
|
||||
}
|
||||
.price-loading {
|
||||
color: rgba(232,228,220,0.3);
|
||||
animation: spin 1s linear infinite;
|
||||
display: inline-block;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.price-divider {
|
||||
width: 1px;
|
||||
height: 32px;
|
||||
background: rgba(201,168,76,0.15);
|
||||
}
|
||||
.refresh-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid rgba(201,168,76,0.2);
|
||||
border-radius: 50%;
|
||||
background: #141C24;
|
||||
color: #C9A84C;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.refresh-btn:active { background: rgba(201,168,76,0.15); transform: rotate(180deg); }
|
||||
|
||||
.api-warning {
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
font-size: 0.7rem;
|
||||
color: #C9A84C;
|
||||
background: rgba(201,168,76,0.08);
|
||||
border: 1px solid rgba(201,168,76,0.2);
|
||||
border-radius: 8px;
|
||||
padding: 6px 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.nisab-bar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.nisab-item, .nisab-effective {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
background: #111820;
|
||||
border: 1px solid rgba(201,168,76,0.15);
|
||||
border-radius: 10px;
|
||||
padding: 10px 8px;
|
||||
gap: 4px;
|
||||
}
|
||||
.nisab-effective {
|
||||
background: rgba(201,168,76,0.06);
|
||||
border-color: rgba(201,168,76,0.3);
|
||||
}
|
||||
.nisab-label {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 300;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
color: rgba(232,228,220,0.55);
|
||||
text-align: center;
|
||||
}
|
||||
.nisab-value {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
color: #E8E4DC;
|
||||
}
|
||||
.nisab-value.effective { color: #C9A84C; }
|
||||
|
||||
.fields-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.field-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.field-label {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 300;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
color: rgba(232,228,220,0.55);
|
||||
}
|
||||
.field-unit {
|
||||
font-weight: 300;
|
||||
color: rgba(232,228,220,0.3);
|
||||
}
|
||||
.field-input-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #111820;
|
||||
border: 1px solid rgba(201,168,76,0.2);
|
||||
border-radius: 8px;
|
||||
padding: 0 10px;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.field-input-wrap:focus-within {
|
||||
border-color: #C9A84C;
|
||||
box-shadow: 0 0 0 2px rgba(201,168,76,0.1);
|
||||
}
|
||||
.field-prefix {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.85rem;
|
||||
color: rgba(232,228,220,0.55);
|
||||
padding-right: 4px;
|
||||
}
|
||||
.field-suffix {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.75rem;
|
||||
color: rgba(232,228,220,0.4);
|
||||
padding-left: 4px;
|
||||
}
|
||||
.field-input-wrap input {
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 10px 0;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.9rem;
|
||||
color: #E8E4DC;
|
||||
outline: none;
|
||||
}
|
||||
.field-input-wrap input:focus { box-shadow: none; border-color: transparent; }
|
||||
.field-input-wrap input::placeholder { color: rgba(232,228,220,0.2); }
|
||||
/* hide number spinners */
|
||||
.field-input-wrap input::-webkit-outer-spin-button,
|
||||
.field-input-wrap input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
|
||||
.field-input-wrap input[type=number] { -moz-appearance: textfield; }
|
||||
.field-value-preview {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 400;
|
||||
color: rgba(232,228,220,0.45);
|
||||
text-align: right;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.total-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: #111820;
|
||||
border: 1px solid rgba(201,168,76,0.2);
|
||||
border-radius: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.total-label {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 300;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: rgba(232,228,220,0.55);
|
||||
}
|
||||
.total-value {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: #E8E4DC;
|
||||
}
|
||||
|
||||
.result-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid;
|
||||
gap: 4px;
|
||||
}
|
||||
.result-card.due {
|
||||
background: rgba(46,204,113,0.08);
|
||||
border-color: rgba(46,204,113,0.25);
|
||||
}
|
||||
.result-card.below {
|
||||
background: rgba(201,168,76,0.06);
|
||||
border-color: rgba(201,168,76,0.2);
|
||||
}
|
||||
.result-status-icon { font-size: 1.8rem; }
|
||||
.result-status-label {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 300;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: rgba(232,228,220,0.55);
|
||||
margin-top: 4px;
|
||||
}
|
||||
.result-amount {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 2rem;
|
||||
font-weight: 900;
|
||||
color: #2ECC71;
|
||||
margin: 4px 0;
|
||||
}
|
||||
.result-amount.amber { color: #C9A84C; }
|
||||
.result-note {
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
font-size: 0.75rem;
|
||||
color: rgba(232,228,220,0.55);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.result-breakdown {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid rgba(201,168,76,0.1);
|
||||
width: 100%;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.62rem;
|
||||
color: rgba(232,228,220,0.45);
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
</style>
|
||||
+4
-4
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user