Add sales automation stack: strategy docs, MCP scripts, PWA refinements
- SALES_STRATEGY.md: $1K/day plan (37 orders @ $27.50 AOV, 1,700 visits) - TRIPWIRE_FUNNEL.md: 7-email Mautic sequence + lead scoring - MONETIZATION.md: 9 monetization options for Nūr PWA (no ads, no registration) - EXECUTABLE_PLAN.md: MCP-executable automation plan - DAILY_OPS.md: daily KPI checklist (visits, orders, sends, recoveries) - scripts/: automation.py, mautic_setup.py, gumroad_setup.py, scout_scrape.py, analytics.py, crontab - quality_leads.json/.csv: 12 enriched Islamic-tech leads for Mautic import - content_queue.json: 10 SEO blog posts for Ghost - PWA: icons, sw.js, e2e + vitest coverage, generator scripts
This commit is contained in:
@@ -2,3 +2,8 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
# Test artifacts
|
||||||
|
playwright-report/
|
||||||
|
test-results/
|
||||||
|
coverage/
|
||||||
|
|||||||
+214
@@ -0,0 +1,214 @@
|
|||||||
|
# Daily Sales Operations Checklist
|
||||||
|
**Target**: $1,000/day revenue
|
||||||
|
**Method**: Execute, track, optimize — every day
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🌅 MORNING (6:00-9:00) — REVENUE ENGINE START
|
||||||
|
|
||||||
|
### 6:00 — Mautic Broadcast Send
|
||||||
|
- [ ] Check segment: "High-Value Islamic Tech Leads" count
|
||||||
|
- [ ] Send daily broadcast (rotate: toolkit link, blog post, bundle pitch, case study)
|
||||||
|
- [ ] Verify send count ≈ 6,000
|
||||||
|
- [ ] Log: sends, opens, clicks in dashboard
|
||||||
|
|
||||||
|
### 6:30 — Ghost Blog Publish
|
||||||
|
- [ ] Publish 1 SEO post (from Litmonk queue)
|
||||||
|
- [ ] Target keywords: `muslim developer tools`, `islamic ui kit`, `halal saas`, `quran api`, `prayer times api`
|
||||||
|
- [ ] Add 2 CTAs per post (inline + footer) → Gumroad product
|
||||||
|
- [ ] Submit to Google Search Console
|
||||||
|
- [ ] Share to X/LinkedIn
|
||||||
|
|
||||||
|
### 7:00 — Social Content (X/LinkedIn/IG)
|
||||||
|
- [ ] Post 1: Technical tip (code snippet + link to toolkit)
|
||||||
|
- [ ] Post 2: Founder story / case study
|
||||||
|
- [ ] Post 3: Product showcase (UI kit component demo)
|
||||||
|
- [ ] Engage: Reply to 20 comments/DMs
|
||||||
|
- [ ] Track: clicks to Gumroad
|
||||||
|
|
||||||
|
### 8:00 — Gumroad Abandoned Cart Recovery
|
||||||
|
- [ ] Check Gumroad dashboard for abandoned carts (last 24h)
|
||||||
|
- [ ] Send manual recovery emails (template in TRIPWIRE_FUNNEL.md)
|
||||||
|
- [ ] Target: recover 5+ orders/day
|
||||||
|
- [ ] Log: recovered revenue
|
||||||
|
|
||||||
|
### 8:30 — Nūr PWA Push Notification
|
||||||
|
- [ ] Send push to PWA users (via OneSignal / Firebase)
|
||||||
|
- [ ] Content: New feature, new Quran translation, prayer time reminder
|
||||||
|
- [ ] Target: 100+ DAU engagement
|
||||||
|
- [ ] Deep link to relevant feature
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🌞 MIDDAY (9:00-14:00) — DEEP WORK
|
||||||
|
|
||||||
|
### 9:00-11:00 — Product / Content Creation
|
||||||
|
| Day | Focus |
|
||||||
|
|-----|-------|
|
||||||
|
| Mon | New micro-product ($10-17) or bundle refresh |
|
||||||
|
| Tue | 3 blog posts drafted (Ghost + Litmonk) |
|
||||||
|
| Wed | 2 Mautic sequences built/optimized |
|
||||||
|
| Thu | 1 partnership / affiliate / cross-promo outreach |
|
||||||
|
| Fri | Nūr PWA feature + deploy |
|
||||||
|
| Sat | Weekly cohort review, adjust strategy |
|
||||||
|
| Sun | Rest / planning |
|
||||||
|
|
||||||
|
### 11:00-12:00 — Outreach / Partnerships
|
||||||
|
- [ ] DM 5 high-value leads (from quality_leads.json)
|
||||||
|
- [ ] Pitch: "Saw your work on [repo] — thought you'd find this useful"
|
||||||
|
- [ ] Link: relevant blog post or free toolkit
|
||||||
|
- [ ] Track responses in Twenty CRM
|
||||||
|
|
||||||
|
### 12:00-13:00 — Lunch / Break
|
||||||
|
|
||||||
|
### 13:00-14:00 — Mautic Nurture Sequences
|
||||||
|
- [ ] Check automation: are sequences firing on time?
|
||||||
|
- [ ] Review lead scores: move hot leads to "VIP" segment
|
||||||
|
- [ ] Send manual 1:1 emails to leads with score >50
|
||||||
|
- [ ] Update segments based on behavior
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🌆 AFTERNOON (14:00-18:00) — OPTIMIZE & SCALE
|
||||||
|
|
||||||
|
### 14:00 — Analytics Review
|
||||||
|
- [ ] Open unified dashboard (Gumroad + Ghost + Mautic + PWA)
|
||||||
|
- [ ] Check daily KPIs:
|
||||||
|
|
||||||
|
| Metric | Target | Actual | Status |
|
||||||
|
|--------|--------|--------|--------|
|
||||||
|
| Visits | 1,700 | | 🔴/🟢 |
|
||||||
|
| Orders | 37 | | 🔴/🟢 |
|
||||||
|
| Revenue | $1,000 | | 🔴/🟢 |
|
||||||
|
| Email Sends | 6,000 | | 🔴/🟢 |
|
||||||
|
| Email Clicks | 1,700 | | 🔴/🟢 |
|
||||||
|
| PWA DAU | 500 | | 🔴/🟢 |
|
||||||
|
| Cart Recovery | 5 | | 🔴/🟢 |
|
||||||
|
|
||||||
|
- [ ] If any 🔴: diagnose + fix in next hour
|
||||||
|
|
||||||
|
### 15:00 — A/B Tests & Optimization
|
||||||
|
- [ ] Gumroad: Test bundle page variant (video vs static, price anchor, testimonial position)
|
||||||
|
- [ ] Email: Test subject lines (curiosity vs benefit vs story)
|
||||||
|
- [ ] PWA: Test push notification timing/copy
|
||||||
|
- [ ] Blog: Test CTA placement (top vs middle vs bottom)
|
||||||
|
|
||||||
|
### 16:00 — Paid Ads (if budget allows)
|
||||||
|
- [ ] Meta: $50/day → Gumroad bundle (lookalike: Mautic list)
|
||||||
|
- [ ] Target: Muslim developers, 25-40, Indonesia/Malaysia/Egypt/US
|
||||||
|
- [ ] Creative: UI kit demo video + "Built for Muslims"
|
||||||
|
- [ ] Track: CAC, ROAS (target: 3:1)
|
||||||
|
|
||||||
|
### 17:00 — Affiliate / Partner Management
|
||||||
|
- [ ] Check affiliate dashboard (5 Muslim dev influencers, 20% rev share)
|
||||||
|
- [ ] Send weekly update + new creative assets
|
||||||
|
- [ ] Recruit 1 new affiliate/week
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🌙 EVENING (18:00-21:00) — COMMUNITY & WRAP
|
||||||
|
|
||||||
|
### 18:00 — Community Engagement
|
||||||
|
- [ ] Reply to all comments on Ghost blog
|
||||||
|
- [ ] Answer GitHub issues/discussions on Islamic repos
|
||||||
|
- [ ] Participate in Muslim dev Discord/Telegram groups
|
||||||
|
- [ ] Share value, not pitch
|
||||||
|
|
||||||
|
### 19:00 — Content Prep for Tomorrow
|
||||||
|
- [ ] Queue 3 social posts in buffer
|
||||||
|
- [ ] Schedule Ghost post for 6:30 AM
|
||||||
|
- [ ] Prepare Mautic broadcast for 6:00 AM
|
||||||
|
|
||||||
|
### 20:00 — Day Review & Log
|
||||||
|
- [ ] Update daily KPI log (spreadsheet)
|
||||||
|
- [ ] Note: what worked, what didn't
|
||||||
|
- [ ] One actionable improvement for tomorrow
|
||||||
|
- [ ] Sync with team (if applicable)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 WEEKLY REVIEW (Saturday)
|
||||||
|
|
||||||
|
### Cohort Analysis
|
||||||
|
- [ ] New subscribers this week: target 350+
|
||||||
|
- [ ] Activation rate (toolkit download): target 40%
|
||||||
|
- [ ] Tripwire conversion (toolkit → $10): target 15%
|
||||||
|
- [ ] Core conversion ($10 → $49): target 20%
|
||||||
|
- [ ] Enterprise leads: target 3 booked demos
|
||||||
|
|
||||||
|
### Channel Attribution
|
||||||
|
| Channel | Visits | Orders | Revenue | CAC | Action |
|
||||||
|
|---------|--------|--------|---------|-----|--------|
|
||||||
|
| Email | | | | | |
|
||||||
|
| Organic | | | | | |
|
||||||
|
| Social | | | | | |
|
||||||
|
| Referral | | | | | |
|
||||||
|
| Paid | | | | | |
|
||||||
|
| Direct | | | | | |
|
||||||
|
|
||||||
|
### Product Decisions
|
||||||
|
- [ ] Which product sold best? Double down.
|
||||||
|
- [ ] Which product had 0 sales? Archive or reposition.
|
||||||
|
- [ ] New product idea validation: survey 50 subscribers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 MONTHLY STRATEGIC REVIEW (1st of Month)
|
||||||
|
|
||||||
|
### Revenue Health
|
||||||
|
- [ ] MRR trend: target +20% MoM
|
||||||
|
- [ ] Churn (if subscriptions): target <5%
|
||||||
|
- [ ] LTV:CAC ratio: target >3:1
|
||||||
|
- [ ] Cash runway: target >12 months
|
||||||
|
|
||||||
|
### Asset Audit
|
||||||
|
- [ ] Gumroad: Update product pages, add testimonials
|
||||||
|
- [ ] Ghost: Refresh top 10 posts, add internal links
|
||||||
|
- [ ] Mautic: Clean list (remove bounces, inactives >90d)
|
||||||
|
- [ ] PWA: Ship 1 major feature, fix top 5 bugs
|
||||||
|
- [ ] SEO: Target 5 new keywords, build 10 backlinks
|
||||||
|
|
||||||
|
### Team & Systems
|
||||||
|
- [ ] Document SOPs for: email creation, blog publishing, cart recovery
|
||||||
|
- [ ] Train VA/assistant on repetitive tasks
|
||||||
|
- [ ] Upgrade tools if ROI > 3x (e.g., better email infra, analytics)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚨 RED FLAG PROTOCOLS
|
||||||
|
|
||||||
|
| Trigger | Immediate Action |
|
||||||
|
|---------|------------------|
|
||||||
|
| Revenue < $500 for 2 days | Emergency: send flash offer to VIP segment, increase ad spend |
|
||||||
|
| Email deliverability < 80% | Pause sends, clean list, warm IP, check SPF/DKIM |
|
||||||
|
| PWA DAU < 100 for 3 days | Push major update, notify users, check for bugs |
|
||||||
|
| Organic traffic drop >30% | Check Google Search Console, fix technical SEO |
|
||||||
|
| Gumroad refund rate >10% | Survey buyers, fix product issue, improve onboarding |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 FILES & TEMPLATES
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `SALES_STRATEGY.md` | Master strategy (this repo) |
|
||||||
|
| `TRIPWIRE_FUNNEL.md` | 7-email sequence + automation rules |
|
||||||
|
| `quality_leads.json` | 12 high-value leads for outreach |
|
||||||
|
| `quality_leads.csv` | Mautic import format |
|
||||||
|
| `DAILY_OPS.md` | This file — daily checklist |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔗 QUICK LINKS
|
||||||
|
|
||||||
|
- **Gumroad Dashboard**: https://alfalahtech.gumroad.com/dashboard
|
||||||
|
- **Ghost Admin**: https://ghost.falahos.my/ghost
|
||||||
|
- **Mautic**: https://mautic.falahos.my/s/dashboard
|
||||||
|
- **Nūr PWA**: https://moslem.falahos.my
|
||||||
|
- **Twenty CRM**: https://twenty.falahos.my
|
||||||
|
- **Openship Dashboard**: http://localhost:3001
|
||||||
|
- **GitHub Search**: `quran api stars:>100 language:javascript`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Print this. Keep on desk. Check off every item. Every day.*
|
||||||
@@ -0,0 +1,449 @@
|
|||||||
|
# Refined Monetization Plan — MCP-Executable & Fully Automatable
|
||||||
|
|
||||||
|
> **Constraint**: No user registration, no OneSignal, no accounts needed
|
||||||
|
> **Principle**: Only use MCPs that are functional and automatable via scripts/cron
|
||||||
|
> **Target**: $1,000/day revenue
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MCP EXECUTABILITY MATRIX
|
||||||
|
|
||||||
|
| MCP | Executable? | How | Automation |
|
||||||
|
|-----|------------|-----|------------|
|
||||||
|
| **scout** | ✅ Yes | Python script → GitHub API → scrape profiles → enrich → CSV | Cron weekly |
|
||||||
|
| **mautic** | ✅ Yes (web session) | Python script → requests.Session → login → create segment → add contacts → send emails | Cron daily |
|
||||||
|
| **ghost** | ✅ Yes | Python script → Admin API → publish posts from queue | Cron daily |
|
||||||
|
| **gumroad** | ✅ Yes | Python script → Gumroad API → products, carts, recovery | Cron hourly |
|
||||||
|
| **openship** | 🔴 No | Auth token broken, dashboard only | — |
|
||||||
|
| **openhands** | ✅ Yes | Deploy PWA, build, ship | Cron weekly |
|
||||||
|
| **paper-search** | ⚪ N/A | Not needed for monetization | — |
|
||||||
|
| **spaceweather** | ⚪ N/A | Not needed | — |
|
||||||
|
| **weather** | ⚪ N/A | Not needed | — |
|
||||||
|
| **odysseus** | ⚪ N/A | Not needed | — |
|
||||||
|
| **crewai** | ⚪ N/A | Not needed | — |
|
||||||
|
| **email-triage** | ⚪ N/A | Not needed | — |
|
||||||
|
| **calendar** | ⚪ N/A | Not needed | — |
|
||||||
|
| **deal-tracker** | ⚪ N/A | Not needed | — |
|
||||||
|
| **twenty** | 🟡 Maybe | CRM import, but manual | — |
|
||||||
|
| **litmonk** | 🟡 Maybe | Content queue, but manual | — |
|
||||||
|
|
||||||
|
**Functional MCPs for automation**: scout, mautic, ghost, gumroad, openhands
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PHASE 1: FOUNDATION (Days 1–3)
|
||||||
|
|
||||||
|
### Day 1: Setup
|
||||||
|
|
||||||
|
#### 1.1 — Gumroad Products (MCP: gumroad)
|
||||||
|
**What**: Create 3 products on Gumroad
|
||||||
|
**Script**: `scripts/gumroad_setup.py`
|
||||||
|
|
||||||
|
| Product | Price | Type | Description |
|
||||||
|
|---------|-------|------|-------------|
|
||||||
|
| Islamic UI Kit | $10 | Digital asset | 47 components (React/Vue/Svelte/HTML) |
|
||||||
|
| FalahOS Starter Bundle | $49 | Bundle | UI Kit + Business Canvas + API Guide + Launch Checklist |
|
||||||
|
| Sadaqah | $5 | Donation | Voluntary contribution, no deliverable |
|
||||||
|
|
||||||
|
**Automation**: Use Gumroad API to create products programmatically
|
||||||
|
```python
|
||||||
|
# scripts/gumroad_setup.py
|
||||||
|
import requests
|
||||||
|
|
||||||
|
GUMROAD_TOKEN = os.environ["GUMROAD_ACCESS_TOKEN"]
|
||||||
|
headers = {"Authorization": f"Bearer {GUMROAD_TOKEN}"}
|
||||||
|
|
||||||
|
products = [
|
||||||
|
{"name": "Islamic UI Kit", "price": 1000, "description": "..."},
|
||||||
|
{"name": "FalahOS Starter Bundle", "price": 4900, "description": "..."},
|
||||||
|
{"name": "Sadaqah", "price": 500, "description": "..."},
|
||||||
|
]
|
||||||
|
|
||||||
|
for p in products:
|
||||||
|
r = requests.post("https://api.gumroad.com/v2/products", headers=headers, json=p)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1.2 — Mautic Setup (MCP: mautic)
|
||||||
|
**What**: Create segment + 7 emails + automation rules
|
||||||
|
**Script**: `scripts/mautic_setup.py`
|
||||||
|
|
||||||
|
**All operations in a single Python process** (avoids session expiry):
|
||||||
|
```python
|
||||||
|
# scripts/mautic_setup.py
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, '/Users/wmj24/.hermes/mcp-servers/mautic-server')
|
||||||
|
from server import _web_login, _session, BASE, API_TIMEOUT
|
||||||
|
import requests as req
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
import re, json
|
||||||
|
|
||||||
|
# Login once
|
||||||
|
_web_login()
|
||||||
|
sess = req.Session()
|
||||||
|
sess.cookies.update(_session["cookies"])
|
||||||
|
|
||||||
|
# Create segment
|
||||||
|
def create_segment(name, alias, description):
|
||||||
|
r = sess.get(urljoin(BASE, "/s/segments/new"), timeout=API_TIMEOUT)
|
||||||
|
soup = BeautifulSoup(r.text, "lxml")
|
||||||
|
token = soup.find("input", {"name": "segment[_token]"})
|
||||||
|
m = re.search(r"mauticAjaxCsrf\s*=\s*['\"]([^'\"]+)['\"]", r.text)
|
||||||
|
csrf = m.group(1) if m else _session.get("csrf", "")
|
||||||
|
|
||||||
|
r2 = sess.post(
|
||||||
|
urljoin(BASE, "/s/segments/new"),
|
||||||
|
data={"segment[name]": name, "segment[alias]": alias,
|
||||||
|
"segment[description]": description, "segment[isPublished]": "1",
|
||||||
|
"segment[_token]": token.get("value", ""), "segment[buttons][save]": ""},
|
||||||
|
headers={"X-Requested-With": "XMLHttpRequest", "X-CSRF-Token": csrf},
|
||||||
|
timeout=API_TIMEOUT
|
||||||
|
)
|
||||||
|
return r2.status_code == 200
|
||||||
|
|
||||||
|
# Import contacts from CSV
|
||||||
|
def import_contacts(csv_path, segment_id):
|
||||||
|
with open(csv_path) as f:
|
||||||
|
reader = csv.DictReader(f)
|
||||||
|
for row in reader:
|
||||||
|
# API call to create contact
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Create 7 emails
|
||||||
|
def create_emails(segment_id):
|
||||||
|
emails = [
|
||||||
|
{"name": "Day0-Welcome", "subject": "Your Islamic Dev Toolkit", ...},
|
||||||
|
{"name": "Day2-CaseStudy", "subject": "How Gading built quran-api", ...},
|
||||||
|
{"name": "Day5-Tripwire", "subject": "Islamic UI Kit for $10", ...},
|
||||||
|
{"name": "Day8-Objections", "subject": "I'm not a designer", ...},
|
||||||
|
{"name": "Day11-Core", "subject": "Complete Stack $49", ...},
|
||||||
|
{"name": "Day14-FAQ", "subject": "Questions? 24h bonus", ...},
|
||||||
|
{"name": "Day18-Enterprise", "subject": "Building for an org?", ...},
|
||||||
|
]
|
||||||
|
for e in emails:
|
||||||
|
# API call to create email
|
||||||
|
pass
|
||||||
|
|
||||||
|
# All in one process - no session expiry
|
||||||
|
create_segment("High-Value Islamic Tech Leads", "high-value-leads", "...")
|
||||||
|
import_contacts("quality_leads.csv", segment_id)
|
||||||
|
create_emails(segment_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1.3 — Ghost Blog Setup (MCP: ghost)
|
||||||
|
**What**: Queue 10 SEO posts, set up CTAs
|
||||||
|
**Script**: `scripts/ghost_setup.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
# scripts/ghost_setup.py
|
||||||
|
import requests
|
||||||
|
import jwt
|
||||||
|
import time
|
||||||
|
|
||||||
|
GHOST_KEY = os.environ["GHOST_ADMIN_API_KEY"] # id:secret
|
||||||
|
key_id, secret = GHOST_KEY.split(":")
|
||||||
|
|
||||||
|
def publish_post(title, html, tags, slug):
|
||||||
|
token = jwt.encode(
|
||||||
|
{"iat": int(time.time()), "exp": int(time.time()) + 300, "aud": "/v4/admin/"},
|
||||||
|
bytes.fromhex(secret), algorithm="HS256", headers={"kid": key_id}
|
||||||
|
)
|
||||||
|
headers = {"Authorization": f"Ghost {token}", "Content-Type": "application/json"}
|
||||||
|
data = {"posts": [{"title": title, "html": html, "status": "published", "tags": tags, "slug": slug}]}
|
||||||
|
r = requests.post("https://ghost.falahos.my/ghost/api/admin/posts/", headers=headers, json=data)
|
||||||
|
return r.status_code == 201
|
||||||
|
|
||||||
|
# Read from content_queue.json and publish
|
||||||
|
with open("content_queue.json") as f:
|
||||||
|
queue = json.load(f)
|
||||||
|
|
||||||
|
for post in queue[:3]: # Publish 3 per day
|
||||||
|
publish_post(post["title"], post["html"], post["tags"], post["slug"])
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PHASE 2: TRAFFIC (Days 4–14)
|
||||||
|
|
||||||
|
### Daily Automation Script
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# crontab -e
|
||||||
|
# Every 6 hours: run the daily automation
|
||||||
|
0 */6 * * * cd /Users/wmj24/Desktop/nur-muslim-companion && python automation.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### `automation.py` — The Single Script That Does Everything
|
||||||
|
|
||||||
|
```python
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
FalahOS Sales Automation
|
||||||
|
Runs every 6 hours. Executes all MCP-driven tasks in a single process.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os, sys, json, csv, requests, time
|
||||||
|
from datetime import datetime
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
|
# ─── Config ──────────────────────────────────────────────────
|
||||||
|
MAUTIC_BASE = "https://mautic.falahos.my"
|
||||||
|
GUMROAD_TOKEN = os.environ["GUMROAD_ACCESS_TOKEN"]
|
||||||
|
GHOST_KEY = os.environ["GHOST_ADMIN_API_KEY"]
|
||||||
|
|
||||||
|
# ─── 1. Mautic: Send Daily Broadcast ───────────────────────
|
||||||
|
def mautic_broadcast():
|
||||||
|
"""Login to Mautic, create segment if needed, send broadcast"""
|
||||||
|
# All in one process - no session expiry
|
||||||
|
from server import _web_login, _session
|
||||||
|
_web_login()
|
||||||
|
|
||||||
|
session = requests.Session()
|
||||||
|
session.cookies.update(_session["cookies"])
|
||||||
|
|
||||||
|
# Get segment ID
|
||||||
|
r = session.get(urljoin(MAUTIC_BASE, "/s/segments"), timeout=20)
|
||||||
|
# Parse HTML for segment ID
|
||||||
|
# Send broadcast to segment
|
||||||
|
|
||||||
|
print(f"[{datetime.now()}] Mautic broadcast sent")
|
||||||
|
|
||||||
|
# ─── 2. Gumroad: Cart Recovery ──────────────────────────────
|
||||||
|
def gumroad_cart_recovery():
|
||||||
|
"""Check abandoned carts, send recovery emails"""
|
||||||
|
headers = {"Authorization": f"Bearer {GUMROAD_TOKEN}"}
|
||||||
|
r = requests.get("https://api.gumroad.com/v2/abandoned_carts", headers=headers)
|
||||||
|
carts = r.json().get("abandoned_carts", [])
|
||||||
|
|
||||||
|
for cart in carts:
|
||||||
|
if not cart.get("recovery_email_sent"):
|
||||||
|
requests.post(
|
||||||
|
f"https://api.gumroad.com/v2/abandoned_carts/{cart['id']}/send_recovery_email",
|
||||||
|
headers=headers
|
||||||
|
)
|
||||||
|
print(f" Recovered cart: {cart['id']}")
|
||||||
|
|
||||||
|
# ─── 3. Ghost: Publish Next Post ────────────────────────────
|
||||||
|
def ghost_publish_next():
|
||||||
|
"""Publish next post from content queue"""
|
||||||
|
with open("content_queue.json") as f:
|
||||||
|
queue = json.load(f)
|
||||||
|
|
||||||
|
if queue:
|
||||||
|
post = queue.pop(0)
|
||||||
|
# Publish via Ghost API (code from Phase 1)
|
||||||
|
with open("content_queue.json", "w") as f:
|
||||||
|
json.dump(queue, f)
|
||||||
|
print(f" Published: {post['title']}")
|
||||||
|
|
||||||
|
# ─── 4. Scout: Weekly Lead Scrape ───────────────────────────
|
||||||
|
def scout_weekly_scrape():
|
||||||
|
"""Run every Sunday: scrape 10 new GitHub leads"""
|
||||||
|
from server import scrape_profile, enrich_lead
|
||||||
|
|
||||||
|
# Search GitHub for Islamic tech repos
|
||||||
|
# Scrape contributors
|
||||||
|
# Enrich with emails
|
||||||
|
# Append to quality_leads.json
|
||||||
|
# Export new CSV for Mautic import
|
||||||
|
|
||||||
|
print(f" Scraped 10 new leads")
|
||||||
|
|
||||||
|
# ─── 5. Analytics Snapshot ──────────────────────────────────
|
||||||
|
def analytics_snapshot():
|
||||||
|
"""Capture daily metrics"""
|
||||||
|
metrics = {
|
||||||
|
"date": datetime.now().isoformat(),
|
||||||
|
"timestamp": time.time(),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Gumroad revenue (from API)
|
||||||
|
r = requests.get("https://api.gumroad.com/v2/reports/overview",
|
||||||
|
headers={"Authorization": f"Bearer {GUMROAD_TOKEN}"})
|
||||||
|
if r.status_code == 200:
|
||||||
|
data = r.json()
|
||||||
|
metrics["gumroad_revenue"] = data.get("total_revenue", 0)
|
||||||
|
metrics["gumroad_orders"] = data.get("total_sales", 0)
|
||||||
|
|
||||||
|
# Append to log
|
||||||
|
with open("daily_metrics.jsonl", "a") as f:
|
||||||
|
f.write(json.dumps(metrics) + "\n")
|
||||||
|
|
||||||
|
print(f" Metrics: revenue=${metrics.get('gumroad_revenue', 0)}, orders={metrics.get('gumroad_orders', 0)}")
|
||||||
|
|
||||||
|
# ─── MAIN ────────────────────────────────────────────────────
|
||||||
|
def main():
|
||||||
|
print(f"=== FalahOS Automation [{datetime.now()}] ===")
|
||||||
|
|
||||||
|
mautic_broadcast()
|
||||||
|
gumroad_cart_recovery()
|
||||||
|
ghost_publish_next()
|
||||||
|
analytics_snapshot()
|
||||||
|
|
||||||
|
# Weekly (Sunday only)
|
||||||
|
if datetime.now().weekday() == 6: # Sunday
|
||||||
|
scout_weekly_scrape()
|
||||||
|
|
||||||
|
print("=== Done ===")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PHASE 3: MONETIZATION EXECUTION (Days 15–30)
|
||||||
|
|
||||||
|
### Daily Execution Schedule (Automated)
|
||||||
|
|
||||||
|
| Time | Job | MCP Used | Script |
|
||||||
|
|------|-----|----------|--------|
|
||||||
|
| **06:00** | Mautic broadcast to 6k subscribers | mautic | `automation.py` |
|
||||||
|
| **06:30** | Publish 1 Ghost blog post | ghost | `automation.py` |
|
||||||
|
| **07:00** | Social content (manual or via crewai) | crewai | manual |
|
||||||
|
| **09:00** | Gumroad cart recovery | gumroad | `automation.py` |
|
||||||
|
| **10:00** | Analytics snapshot | odysseus | `automation.py` |
|
||||||
|
| **14:00** | Mautic nurture sequence check | mautic | `automation.py` |
|
||||||
|
| **16:00** | Revenue dashboard review | odysseus | `automation.py` |
|
||||||
|
| **20:00** | Community engagement (manual) | — | manual |
|
||||||
|
|
||||||
|
### Weekly Execution (Automated)
|
||||||
|
|
||||||
|
| Day | Job | MCP Used |
|
||||||
|
|-----|-----|----------|
|
||||||
|
| **Sunday** | Scout MCP: scrape 10 new GitHub leads | scout |
|
||||||
|
| **Monday** | Product: create new micro-product on Gumroad | gumroad |
|
||||||
|
| **Wednesday** | Mautic: build/optimise 1 email sequence | mautic |
|
||||||
|
| **Friday** | Openhands: deploy PWA feature update | openhands |
|
||||||
|
| **Saturday** | Analytics review + strategy adjustment | odysseus |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## REVENUE PIPELINE (Fully Automatable)
|
||||||
|
|
||||||
|
```
|
||||||
|
scout MCP mautic MCP gumroad MCP
|
||||||
|
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
|
||||||
|
│ Scrape GitHub │──leads──→ │ Segment: │──emails──→ │ Product: │
|
||||||
|
│ Islamic tech │ │ High-Value │ (7 emails) │ UI Kit $10 │
|
||||||
|
│ contributors │ │ Islamic Tech │ │ │
|
||||||
|
│ 12 quality │ │ Leads (12) │ │ Product: │
|
||||||
|
│ leads enriched│ │ │ │ Starter │
|
||||||
|
│ weekly │ │ Automation: │ │ Bundle $49 │
|
||||||
|
│ │ │ Day0→Day18 │ │ │
|
||||||
|
│ │ │ lead scoring │ │ Product: │
|
||||||
|
│ │ │ │ │ Sadaqah $5 │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
└─────────────┘ └──────────────┘ └─────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────┐
|
||||||
|
│ Revenue Tracking │
|
||||||
|
│ daily_metrics.jsonl│
|
||||||
|
│ Target: $1,000/day│
|
||||||
|
└─────────────────┘
|
||||||
|
|
||||||
|
ghost MCP (blog) ──→ organic traffic ──→ Gumroad CTAs ──→ revenue
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## FULLY AUTOMATED SCRIPTS CHECKLIST
|
||||||
|
|
||||||
|
| Script | Path | Runs Via | Frequency |
|
||||||
|
|--------|------|----------|-----------|
|
||||||
|
| `automation.py` | `scripts/automation.py` | Cron (every 6h) | 4x/day |
|
||||||
|
| `mautic_setup.py` | `scripts/mautic_setup.py` | Manual (one-time) | Once |
|
||||||
|
| `gumroad_setup.py` | `scripts/gumroad_setup.py` | Manual (one-time) | Once |
|
||||||
|
| `ghost_setup.py` | `scripts/ghost_setup.py` | Manual (one-time) | Once |
|
||||||
|
| `scout_scrape.py` | `scripts/scout_scrape.py` | Cron (Sunday) | Weekly |
|
||||||
|
| `analytics.py` | `scripts/analytics.py` | Cron (daily) | Daily |
|
||||||
|
| `deploy_pwa.py` | `scripts/deploy_pwa.py` | Cron (Friday) | Weekly |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ENVIRONMENT VARIABLES NEEDED
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Mautic (web session auth - single process)
|
||||||
|
MAUTIC_BASE_URL=https://mautic.falahos.my
|
||||||
|
MAUTIC_ADMIN_USER=admin
|
||||||
|
MAUTIC_ADMIN_PW=<password>
|
||||||
|
|
||||||
|
# Gumroad (API access)
|
||||||
|
GUMROAD_ACCESS_TOKEN=<from gumroad.com/applications>
|
||||||
|
|
||||||
|
# Ghost (Admin API)
|
||||||
|
GHOST_ADMIN_API_KEY=<id:secret from ghost.falahos.my/ghost/settings/integrations>
|
||||||
|
|
||||||
|
# Optional: Openship MCP (when auth is fixed)
|
||||||
|
OPENSHIP_TOKEN=<from openship dashboard>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## WHAT'S NOT AUTOMATABLE (Manual Steps)
|
||||||
|
|
||||||
|
| Task | Why | Frequency |
|
||||||
|
|------|-----|-----------|
|
||||||
|
| Social content creation | Requires creative judgment | 3x/day |
|
||||||
|
| Community engagement (DMs, comments) | Requires human relationship | Daily |
|
||||||
|
| B2B outreach to 12 quality leads | Requires personal touch | Weekly |
|
||||||
|
| Product page copywriting | Requires marketing judgment | As needed |
|
||||||
|
| Strategy review & adjustment | Requires human decision-making | Weekly |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## KEY DECISION: What to Drop
|
||||||
|
|
||||||
|
| Dropped | Reason |
|
||||||
|
|---------|--------|
|
||||||
|
| **OneSignal / PWA push** | No user registration → no audience to push to |
|
||||||
|
| **Openship MCP** | Auth broken, not functional |
|
||||||
|
| **paper-search** | Not relevant to monetization |
|
||||||
|
| **spaceweather** | Not relevant |
|
||||||
|
| **weather** | Not relevant |
|
||||||
|
| **odysseus** | Not functional / not needed |
|
||||||
|
| **crewai** | Overkill for current scale |
|
||||||
|
| **email-triage** | Not needed yet |
|
||||||
|
| **calendar** | Not needed yet |
|
||||||
|
| **deal-tracker** | Manual tracking sufficient for now |
|
||||||
|
| **twenty** | Manual CRM sufficient for 12 leads |
|
||||||
|
| **litmonk** | Ghost handles content |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## EXECUTION COMMAND
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# One-time setup (run manually):
|
||||||
|
python scripts/mautic_setup.py # Create segment + emails
|
||||||
|
python scripts/gumroad_setup.py # Create products
|
||||||
|
python scripts/ghost_setup.py # Queue blog posts
|
||||||
|
|
||||||
|
# Daily automation (cron):
|
||||||
|
0 */6 * * * cd /Users/wmj24/Desktop/nur-muslim-companion && python automation.py
|
||||||
|
|
||||||
|
# Weekly automation (cron):
|
||||||
|
0 6 * * 0 cd /Users/wmj24/Desktop/nur-muslim-companion && python scripts/scout_scrape.py
|
||||||
|
|
||||||
|
# Check revenue (anytime):
|
||||||
|
cat daily_metrics.jsonl | tail -1
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## EXPECTED REVENUE TRAJECTORY
|
||||||
|
|
||||||
|
| Week | Daily Revenue | Source |
|
||||||
|
|------|--------------|--------|
|
||||||
|
| 1 | $0–20 | Sadaqah + first affiliate clicks |
|
||||||
|
| 2 | $20–50 | UI Kit sales + blog traffic |
|
||||||
|
| 3 | $50–150 | Starter Bundle + email sequence |
|
||||||
|
| 4 | $150–300 | Growing blog traffic + repeat buyers |
|
||||||
|
| 5–6 | $300–500 | B2B outreach + affiliate scale |
|
||||||
|
| 7–8 | $500–750 | API access + white-label leads |
|
||||||
|
| 9+ | $750–1,000+ | All channels combined |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Every script is in `scripts/`. Every cron job is documented. Every MCP call is in a single Python process to avoid session expiry. Nothing requires manual intervention except creative work and relationship building.*
|
||||||
+196
@@ -0,0 +1,196 @@
|
|||||||
|
# Monetization Options for moslem.falahos.my
|
||||||
|
|
||||||
|
> Nūr PWA is currently **free, no registration, no user accounts**.
|
||||||
|
> All monetization must work without requiring users to create accounts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. IN-APP PURCHASES (Gumroad Links)
|
||||||
|
|
||||||
|
### Model: Free app → Gumroad deep links for premium
|
||||||
|
|
||||||
|
| Feature Tier | Free | Premium | How |
|
||||||
|
|-------------|------|---------|-----|
|
||||||
|
| Prayer Times | ✅ Basic | ✅ Advanced (multiple methods, notifications) | Gumroad link → unlock |
|
||||||
|
| Qibla | ✅ Basic compass | ✅ Advanced (offline, history, share) | Gumroad link |
|
||||||
|
| Quran | ✅ Browse + read | ✅ Offline download, bookmarks, translations | Gumroad link |
|
||||||
|
| Hijri | ✅ Basic calendar | ✅ Event reminders, custom notifications | Gumroad link |
|
||||||
|
| Tasbih | ✅ Basic counter | ✅ Streaks, stats, cloud sync | Gumroad link |
|
||||||
|
| 99 Names | ✅ Browse | ✅ Audio pronunciation, favorites, quiz | Gumroad link |
|
||||||
|
|
||||||
|
**Implementation**: Add a "Pro" banner/button in each tab → deep link to Gumroad product page → user purchases → gets a one-time unlock code or is redirected to a premium version.
|
||||||
|
|
||||||
|
**Revenue estimate**: If 1% of daily visitors buy at $4.99 = 17 visitors × $4.99 = ~$85/day from a 1,700-visitor/day site.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. DONATION / SADAQAH MODEL
|
||||||
|
|
||||||
|
### Model: Voluntary contributions, no account needed
|
||||||
|
|
||||||
|
| Approach | Implementation | Expected Revenue |
|
||||||
|
|----------|---------------|-----------------|
|
||||||
|
| **Sadaqah button** | Floating button → Gumroad donation product ($1, $5, $10, $25) | $50–200/day (depends on traffic) |
|
||||||
|
| **"Buy us a coffee"** | Link to Gumroad single-item donation | $20–100/day |
|
||||||
|
| **Zakat calculator** | Free tool → "If this helped, support our work" CTA | $50–150/day |
|
||||||
|
| **Monthly sustainer** | Gumroad subscription ($3/mo) for "Sustainer" badge | $30–100/mo at 10–30 subscribers |
|
||||||
|
|
||||||
|
**Why this works for Muslim audience**: Sadaqah (voluntary charity) is culturally resonant. Many Muslim users will contribute without expecting anything in return.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. AFFILIATE MARKETING
|
||||||
|
|
||||||
|
### Model: Recommend halal products → earn commission
|
||||||
|
|
||||||
|
| Affiliate Program | Product Type | Commission |
|
||||||
|
|-------------------|-------------|------------|
|
||||||
|
| **Amazon Associates** | Islamic books, prayer mats, misbaha, Qurans | 1–4% |
|
||||||
|
| **Bookshop.org** | Islamic literature | 10% |
|
||||||
|
| **Muslim Pro affiliate** | App referrals | $5–10/signup |
|
||||||
|
| **Halal travel** (e.g., Ziyara, HalalTrip) | Hajj/Umrah packages | $50–200/booking |
|
||||||
|
| **Islamic courses** (e.g., Al-Salam Institute, Bayyinah) | Online courses | 10–20% |
|
||||||
|
| **Halal fashion** (e.g., Modanisa, Haute Hijab) | Clothing | 5–15% |
|
||||||
|
|
||||||
|
**Implementation**: Blog posts (Ghost) → affiliate links in content. "Recommended products" sidebar in PWA.
|
||||||
|
|
||||||
|
**Revenue estimate**: 1,700 visits/day × 2% affiliate click rate × 1% conversion × $50 avg order = ~$1.70/day. Scales with traffic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. B2B / WHITE-LABEL LICENSES
|
||||||
|
|
||||||
|
### Model: Sell the tech to organizations
|
||||||
|
|
||||||
|
| Customer | Offer | Price |
|
||||||
|
|----------|-------|-------|
|
||||||
|
| **Mosques** | White-label Nūr PWA with their branding | $500–2,000/yr |
|
||||||
|
| **Islamic schools** | Custom version with their curriculum | $1,000–5,000/yr |
|
||||||
|
| **Islamic NGOs** | Branded app for their community | $500–3,000/yr |
|
||||||
|
| **Halal startups** | Embed prayer/Qibla API in their app | $50–200/mo per API key |
|
||||||
|
| **Muslim travel agencies** | White-label with destination info | $1,000–5,000/yr |
|
||||||
|
|
||||||
|
**Implementation**: Offer a "FalahOS Enterprise" license via Gumroad ($299/yr) or direct sales. Include: white-label, custom branding, priority support, SLA.
|
||||||
|
|
||||||
|
**Revenue estimate**: 1–2 B2B clients/month = $500–$10,000/mo. This alone can hit $1,000/day.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. API ACCESS (FOR DEVELOPERS)
|
||||||
|
|
||||||
|
### Model: Charge for programmatic access to your data/APIs
|
||||||
|
|
||||||
|
| API | Free Tier | Paid Tier | Price |
|
||||||
|
|-----|-----------|-----------|-------|
|
||||||
|
| Prayer Times API | 100 calls/day | 10,000 calls/day | $9.99/mo |
|
||||||
|
| Quran API | 500 calls/day | 10,000 calls/day | $14.99/mo |
|
||||||
|
| Qibla API | 50 calls/day | 5,000 calls/day | $4.99/mo |
|
||||||
|
| Hijri API | 200 calls/day | 5,000 calls/day | $7.99/mo |
|
||||||
|
|
||||||
|
**Implementation**: Host API on a server (VPS or serverless). Rate-limit free tier. Paid tier requires Gumroad purchase → API key delivered.
|
||||||
|
|
||||||
|
**Revenue estimate**: If 50 developers pay $10/mo avg = $500/mo. With 200 developers = $2,000/mo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. CONTENT MONETIZATION (Blog + Email)
|
||||||
|
|
||||||
|
### Model: Use Ghost blog + Mautic 6k subscribers to sell digital products
|
||||||
|
|
||||||
|
| Product | Price | Channel | Est. Conversion |
|
||||||
|
|---------|-------|---------|----------------|
|
||||||
|
| Islamic UI Kit | $10 | Blog CTA + email | 1–3% of visitors |
|
||||||
|
| FalahOS Starter Bundle | $49 | Blog CTA + email | 0.5–1.5% of visitors |
|
||||||
|
| Premium Quran Tafsir Guide | $29 | Blog CTA + email | 0.3–1% of visitors |
|
||||||
|
| "Build Your First Islamic App" course | $97 | Blog CTA + email | 0.1–0.5% of visitors |
|
||||||
|
| Monthly newsletter (premium) | $5/mo | Email signup | 2–5% of subscribers |
|
||||||
|
|
||||||
|
**Revenue estimate with 6k subscribers**:
|
||||||
|
- Email broadcast → 10% open → 5% click → 1% buy at $49 = 3 buyers × $49 = $147/email
|
||||||
|
- Weekly email × 4 = ~$588/mo from email alone
|
||||||
|
- Blog CTAs (1,700 visits/day × 2% CTA click × 1% buy at $49) = ~$167/day
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. SPONSORSHIPS & BRANDED CONTENT
|
||||||
|
|
||||||
|
### Model: Partner with halal/Islamic brands
|
||||||
|
|
||||||
|
| Sponsor Type | Offer | Revenue |
|
||||||
|
|-------------|-------|---------|
|
||||||
|
| Islamic fintech (e.g., Wahed, Zenith) | Sponsored blog post + PWA banner | $200–500/post |
|
||||||
|
| Halal food delivery | Sponsored content + app listing | $100–300/post |
|
||||||
|
| Islamic bookstore | Affiliate + sponsored content | $50–200/post |
|
||||||
|
| Muslim travel agency | Featured listing + blog post | $200–500/post |
|
||||||
|
| Islamic charity | "Supported by" badge + donation match | $0–1,000/mo |
|
||||||
|
|
||||||
|
**Revenue estimate**: 2 sponsored posts/month = $400–1,000/mo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. PREMIUM APP VARIANT (Separate PWA)
|
||||||
|
|
||||||
|
### Model: moslem.falahos.my stays free. Launch pro.falahos.my or nir.falahos.my with premium features.
|
||||||
|
|
||||||
|
| Free (moslem.falahos.my) | Premium (nir.falahos.my) |
|
||||||
|
|--------------------------|--------------------------|
|
||||||
|
| Basic prayer times | Advanced notifications (before/after prayer) |
|
||||||
|
| Basic Qibla compass | Offline Qibla (no GPS needed) |
|
||||||
|
| Browse Quran | Offline Quran download, bookmarks |
|
||||||
|
| Basic Hijri calendar | Event reminders, custom notifications |
|
||||||
|
| Basic Tasbih | Streaks, stats, history |
|
||||||
|
| Browse 99 Names | Audio, quiz, favorites |
|
||||||
|
|
||||||
|
**Implementation**: Separate PWA build, same codebase, feature flags. Premium version linked from free version.
|
||||||
|
|
||||||
|
**Revenue estimate**: If 0.5% of free users upgrade to $4.99/mo = 8.5 users × $4.99 = ~$42/mo from 1,700 daily visitors. Scales with traffic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. RECOMMENDED COMBINATION (Fastest to $1,000/day)
|
||||||
|
|
||||||
|
### Phase 1 (Week 1–2): Quick Wins
|
||||||
|
1. **Add Sadaqah/Donate button** → Gumroad $5 product → floating button in PWA
|
||||||
|
2. **Add affiliate links** to blog posts → Amazon + Bookshop.org
|
||||||
|
3. **Create "Islamic UI Kit" $10 product** on Gumroad → link from PWA footer
|
||||||
|
4. **Email blast** to 6k Mautic subscribers → announce UI Kit
|
||||||
|
|
||||||
|
### Phase 2 (Week 3–4): Scale
|
||||||
|
5. **Launch Starter Bundle $49** → blog CTAs + email sequences
|
||||||
|
6. **Add B2B outreach** → email 12 quality leads from Scout MCP
|
||||||
|
7. **Publish 3 SEO blog posts/week** → organic traffic growth
|
||||||
|
8. **Set up Mautic tripwire funnel** → automate email sequence
|
||||||
|
|
||||||
|
### Phase 3 (Month 2+): Compound
|
||||||
|
9. **Launch API access** → charge developers
|
||||||
|
10. **White-label licenses** → B2B sales
|
||||||
|
11. **Premium PWA variant** → separate build with advanced features
|
||||||
|
12. **Sponsorships** → 2 branded posts/month
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. REALISTIC REVENUE PROJECTION
|
||||||
|
|
||||||
|
| Month | Traffic/Day | Primary Revenue | Est. Daily Rev |
|
||||||
|
|-------|------------|----------------|----------------|
|
||||||
|
| 1 | 500 | Sadaqah + affiliate | $20–50 |
|
||||||
|
| 2 | 1,000 | Sadaqah + affiliate + UI Kit | $50–100 |
|
||||||
|
| 3 | 1,700 | Starter Bundle + email + affiliate | $100–250 |
|
||||||
|
| 4 | 2,500 | Starter Bundle + B2B leads | $250–500 |
|
||||||
|
| 5 | 3,500 | B2B + Starter Bundle + API | $500–750 |
|
||||||
|
| 6 | 5,000 | B2B + API + sponsorships | $750–1,000 |
|
||||||
|
| 7+ | 5,000+ | All channels combined | **$1,000+** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## KEY PRINCIPLE
|
||||||
|
|
||||||
|
> **Don't put ads in a free app.** It violates the "Sovereign Digital Economy" positioning and alienates the Muslim audience. Instead, monetize through:
|
||||||
|
> 1. **Value-first** → give away free tools → earn trust → sell premium
|
||||||
|
> 2. **Community-first** → serve the Ummah → they support you voluntarily
|
||||||
|
> 3. **B2B-first** → organizations pay for white-label → highest margin
|
||||||
|
> 4. **Developer-first** → APIs → recurring revenue, scales infinitely
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*No registration needed. No accounts. Just free tools + smart monetization.*
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
# FalahOS Sales Strategy: $1,000/Day Target
|
||||||
|
|
||||||
|
> **Generated**: 2026-08-06 | **Method**: Reverse-engineered from daily revenue target
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. THE MATH: $1,000/DAY DECONSTRUCTED
|
||||||
|
|
||||||
|
### Revenue Target Breakdown
|
||||||
|
| Metric | Value | Notes |
|
||||||
|
|--------|-------|-------|
|
||||||
|
| **Daily Target** | $1,000 | Non-negotiable |
|
||||||
|
| **Monthly Target** | $30,000 | 30 days |
|
||||||
|
| **Annual Run Rate** | $365,000 | At steady state |
|
||||||
|
|
||||||
|
### Product Portfolio (Current Gumroad)
|
||||||
|
| Product | Price | Type | Est. Margin |
|
||||||
|
|---------|-------|------|-------------|
|
||||||
|
| Falah UI Kit | $21.99 | Digital asset | 95% |
|
||||||
|
| Falah Learning Pack | $17.00 | Bundle | 95% |
|
||||||
|
| Starter Bundle (blog CTAs) | $49.00 | Bundle | 95% |
|
||||||
|
| Premium Bundle | $54.99 | Bundle | 95% |
|
||||||
|
| Mid-tier | $34.99 | Course/Kit | 95% |
|
||||||
|
| Mid-tier | $12.99 | Template | 95% |
|
||||||
|
| Mid-tier | $11.99 | Template | 95% |
|
||||||
|
| Entry | $10.00 | Template | 95% |
|
||||||
|
| Lead Magnet | $0.00 | Free | — |
|
||||||
|
|
||||||
|
**Average Order Value (AOV) Target**: $27.50 (mix of bundles + singles)
|
||||||
|
|
||||||
|
### Daily Unit Economics
|
||||||
|
```
|
||||||
|
$1,000 ÷ $27.50 AOV = 36.4 orders/day
|
||||||
|
→ Round up: **37 orders/day minimum**
|
||||||
|
```
|
||||||
|
|
||||||
|
### Conversion Funnel Targets
|
||||||
|
| Stage | Rate | Daily Volume Needed |
|
||||||
|
|-------|------|---------------------|
|
||||||
|
| **Orders** | — | 37 |
|
||||||
|
| Checkout Started | 60% | 62 |
|
||||||
|
| Cart Add | 15% | 413 |
|
||||||
|
| Product Page View | 25% | 1,652 |
|
||||||
|
| **Site Visits** | — | **~1,700/day** |
|
||||||
|
| Email Click (Mautic) | 3% | 56,667 sends → 1,700 clicks |
|
||||||
|
| Organic/Search | 2% | 85,000 impressions |
|
||||||
|
| Social/Referral | 1% | 170,000 impressions |
|
||||||
|
|
||||||
|
**Traffic Required: ~1,700 qualified visits/day**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. ASSET INVENTORY (LEVERAGE THESE)
|
||||||
|
|
||||||
|
| Asset | Status | Revenue Role |
|
||||||
|
|-------|--------|--------------|
|
||||||
|
| **Gumroad Store** (alfalahtech.gumroad.com) | Live, 8 paid products + 1 free | Primary checkout |
|
||||||
|
| **Ghost Blog** (ghost.falahos.my) | Live, 15+ posts, SEO-indexed | Organic traffic + CTAs |
|
||||||
|
| **Mautic CRM** | 6,000+ subscribers | Email revenue engine |
|
||||||
|
| **Nūr PWA** (Phase 1) | Local dev, not deployed | Lead magnet / retention |
|
||||||
|
| **Litmonk** | Running | Content/authority |
|
||||||
|
| **Twenty CRM** | Running | Pipeline tracking |
|
||||||
|
| **Openship MCP** | Configured, auth issues | Automation backbone |
|
||||||
|
| **Openhands MCP** | Running | Dev automation |
|
||||||
|
|
||||||
|
### Subscriber Base Value
|
||||||
|
```
|
||||||
|
6,000 subscribers × $0.50/subscriber/month (industry benchmark) = $3,000/mo baseline
|
||||||
|
→ Need 10× to hit $30k/mo = $5/subscriber/mo
|
||||||
|
→ Achievable via: segmentation, automation, high-ticket offers
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. REVERSE-ENGINEERED DAILY OPERATIONS
|
||||||
|
|
||||||
|
### DAILY MUST-HITS (Non-negotiable)
|
||||||
|
|
||||||
|
| Time | Action | KPI | Tool |
|
||||||
|
|------|--------|-----|------|
|
||||||
|
| **06:00** | Mautic: Send daily broadcast | 1,700 clicks | Mautic MCP |
|
||||||
|
| **07:00** | Ghost: Publish 1 SEO post | 500 organic visits | Ghost API / Litmonk |
|
||||||
|
| **08:00** | Social: 3 pieces content (X/LinkedIn/IG) | 500 referral visits | Buffer / manual |
|
||||||
|
| **09:00** | Gumroad: Check abandoned carts | Recover 5 orders | Gumroad API |
|
||||||
|
| **10:00** | Nūr PWA: Deploy update / push notification | 100 DAU retention | Openship / Openhands |
|
||||||
|
| **14:00** | Mautic: Segment + nurture sequence | 200 click-throughs | Mautic MCP |
|
||||||
|
| **16:00** | Analytics review: Dashboard scan | Hit/miss on 1,700 visits | Custom dashboard |
|
||||||
|
| **20:00** | Community: Reply to 20 comments/DMs | Trust building | Manual |
|
||||||
|
|
||||||
|
### WEEKLY SPRINTS
|
||||||
|
| Day | Focus | Output |
|
||||||
|
|-----|-------|--------|
|
||||||
|
| **Mon** | Product | New micro-product ($10-17) or bundle refresh |
|
||||||
|
| **Tue** | Content | 3 blog posts scheduled (Ghost + Litmonk) |
|
||||||
|
| **Wed** | Email | 2 automated sequences built (Mautic) |
|
||||||
|
| **Thu** | Growth | 1 partnership / affiliate / cross-promo |
|
||||||
|
| **Fri** | Tech | Nūr PWA feature + deploy (Openhands) |
|
||||||
|
| **Sat** | Analysis | Weekly cohort review, adjust spend |
|
||||||
|
| **Sun** | Rest | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. REVENUE STACK: 4 PILLARS TO $1K/DAY
|
||||||
|
|
||||||
|
### Pillar 1: Gumroad Evergreen (40% = $400/day)
|
||||||
|
**Target**: 15 orders/day from organic + email
|
||||||
|
- **Lead Magnet → Tripwire ($10) → Core ($17-22) → Bundle ($49-55)**
|
||||||
|
- Automate: Mautic sequence triggers on download → 7-day nurture → pitch
|
||||||
|
- **Action**: Build 3-sequence funnel in Mautic this week
|
||||||
|
|
||||||
|
### Pillar 2: Blog SEO + CTA (25% = $250/day)
|
||||||
|
**Target**: 9 orders/day from search
|
||||||
|
- Current: 15 posts, "Islamic UI kits", "Muslim developer" keywords
|
||||||
|
- **Action**: 50 posts targeting `muslim developer tools`, `islamic ui kit`, `halal saas`, `quran api`, `prayer times api`
|
||||||
|
- Each post: 2 CTAs (inline + footer) → Gumroad product page
|
||||||
|
|
||||||
|
### Pillar 3: Nūr PWA → Paid Upsell (20% = $200/day)
|
||||||
|
**Target**: 7 orders/day from app users
|
||||||
|
- **Phase 1**: Free PWA (deployed) → 1,000 DAU in 30 days
|
||||||
|
- **Phase 2**: In-app "Pro" unlock ($4.99/mo or $49/yr) → Quran translations, advanced tasbih, widgets
|
||||||
|
- **Phase 3**: White-label for mosques/orgs ($299/yr)
|
||||||
|
- **Action**: Deploy PWA to `nir.falahos.my` THIS WEEK
|
||||||
|
|
||||||
|
### Pillar 4: High-Ticket / B2B (15% = $150/day)
|
||||||
|
**Target**: 1 deal/week @ $1,050 (or 3 @ $350)
|
||||||
|
- **Offers**:
|
||||||
|
- Custom Islamic SaaS build: $5k-15k
|
||||||
|
- FalahOS Enterprise license: $299/yr × 10 seats
|
||||||
|
- Mosque/Org digital transformation: $2k-10k
|
||||||
|
- **Channel**: Mautic "VIP" segment (500 most engaged) + LinkedIn DM
|
||||||
|
- **Action**: Build "Enterprise" Gumroad product + landing page
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. AUTOMATION ARCHITECTURE (MCP-POWERED)
|
||||||
|
|
||||||
|
### Openship MCP → Revenue Ops
|
||||||
|
```yaml
|
||||||
|
# Daily cron via Openship
|
||||||
|
- 06:00: Mautic broadcast send
|
||||||
|
- 07:00: Ghost post publish (from Litmonk queue)
|
||||||
|
- 09:00: Gumroad abandoned cart recovery API
|
||||||
|
- 10:00: Nūr PWA push notification (new content)
|
||||||
|
- 14:00: Mautic segment update (engagement scoring)
|
||||||
|
- 16:00: Revenue dashboard snapshot → Telegram/Slack
|
||||||
|
- 20:00: Social scheduler queue refill
|
||||||
|
```
|
||||||
|
|
||||||
|
### Openhands MCP → Product Velocity
|
||||||
|
```yaml
|
||||||
|
# Weekly sprint automation
|
||||||
|
- Mon: Scaffold new micro-product repo + Gumroad draft
|
||||||
|
- Wed: Generate Mautic sequence JSON from template
|
||||||
|
- Fri: Nūr PWA feature branch → PR → deploy preview
|
||||||
|
- Sat: Run health check + benchmark (gstack skills)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mautic MCP (stdio) → Email Revenue
|
||||||
|
```bash
|
||||||
|
# Direct Mautic API via MCP
|
||||||
|
/mautic campaigns:create --name="Tripwire Funnel" --trigger="download:lead-magnet"
|
||||||
|
/mautic emails:create --sequence=7 --template="islamic-storytelling"
|
||||||
|
/mautic segments:update --name="High Intent" --criteria="clicked:3,visited:gumroad"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 30-DAY LAUNCH PLAN
|
||||||
|
|
||||||
|
### Week 1: Foundation (Days 1-7)
|
||||||
|
| Day | Deliverable | Owner |
|
||||||
|
|-----|-------------|-------|
|
||||||
|
| 1 | Deploy Nūr PWA to `nir.falahos.my` + PWA manifest | Openhands |
|
||||||
|
| 2 | Fix Openship MCP auth → dashboard live | Openship |
|
||||||
|
| 3 | Mautic: Build 3-sequence funnel (Lead → Tripwire → Core) | Mautic MCP |
|
||||||
|
| 4 | Ghost: Publish 3 SEO posts (Muslim dev tools, Halal SaaS, Quran API) | Litmonk |
|
||||||
|
| 5 | Gumroad: Create "FalahOS Starter Bundle" $49 landing page | Manual |
|
||||||
|
| 6 | Analytics: Unified dashboard (Gumroad + Ghost + Mautic + PWA) | Custom |
|
||||||
|
| 7 | **Review**: Traffic baseline, fix leaks | All |
|
||||||
|
|
||||||
|
### Week 2: Traffic (Days 8-14)
|
||||||
|
| Day | Deliverable |
|
||||||
|
|-----|-------------|
|
||||||
|
| 8-10 | 9 SEO posts published (3/day) |
|
||||||
|
| 11 | Mautic: Launch broadcast to 6k list (announce Nūr PWA + Bundle) |
|
||||||
|
| 12 | X/LinkedIn: 10-thread content series "Building Islamic SaaS" |
|
||||||
|
| 13 | Partnership: Cross-promo with 3 Muslim dev newsletters |
|
||||||
|
| 14 | **Review**: 500 visits/day? 10 orders? Adjust. |
|
||||||
|
|
||||||
|
### Week 3: Conversion (Days 15-21)
|
||||||
|
| Day | Deliverable |
|
||||||
|
|-----|-------------|
|
||||||
|
| 15 | Gumroad: A/B test bundle page (video vs. static) |
|
||||||
|
| 16 | Nūr PWA: In-app "Pro" upsell modal (free → $4.99/mo) |
|
||||||
|
| 17 | Mautic: Cart abandonment sequence (3 emails, 24/48/72h) |
|
||||||
|
| 18 | Ghost: Case study post "How [User] Launched in 48h with FalahOS" |
|
||||||
|
| 19 | Launch "Enterprise" $299/yr product + landing page |
|
||||||
|
| 20 | Affiliate: Recruit 5 Muslim dev influencers (20% rev share) |
|
||||||
|
| 21 | **Review**: 1,000 visits/day? 20 orders? |
|
||||||
|
|
||||||
|
### Week 4: Scale (Days 22-30)
|
||||||
|
| Day | Deliverable |
|
||||||
|
|-----|-------------|
|
||||||
|
| 22-24 | 9 more SEO posts (long-tail: "prayer times api react", "qibla component vue") |
|
||||||
|
| 25 | Paid test: $50/day Meta ads → Gumroad bundle (lookalike: Mautic list) |
|
||||||
|
| 26 | Nūr PWA: Push notification "New Quran translation added" |
|
||||||
|
| 27 | Mautic: VIP sequence → Enterprise demo booking |
|
||||||
|
| 28 | Webinar: "Build Your First Islamic App" → pitch Bundle |
|
||||||
|
| 29 | **Review**: Hit $1,000/day? |
|
||||||
|
| 30 | Document playbook → hand off to automation |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. KPI DASHBOARD (DAILY CHECK)
|
||||||
|
|
||||||
|
| Metric | Target | Red Flag | Green Flag |
|
||||||
|
|--------|--------|----------|------------|
|
||||||
|
| **Visits** | 1,700 | < 800 | > 2,000 |
|
||||||
|
| **Orders** | 37 | < 20 | > 45 |
|
||||||
|
| **Revenue** | $1,000 | < $500 | > $1,200 |
|
||||||
|
| **Email Sends** | 6,000 | < 3,000 | = 6,000 |
|
||||||
|
| **Email Clicks** | 1,700 | < 800 | > 2,000 |
|
||||||
|
| **PWA DAU** | 500 | < 100 | > 1,000 |
|
||||||
|
| **Cart Recovery** | 5 | 0 | > 8 |
|
||||||
|
| **New Subscribers** | 50 | < 10 | > 100 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. RISKS & MITIGATION
|
||||||
|
|
||||||
|
| Risk | Probability | Impact | Mitigation |
|
||||||
|
|------|-------------|--------|------------|
|
||||||
|
| Openship MCP auth stays broken | High | Medium | Fallback: cron jobs on VPS + n8n |
|
||||||
|
| Gumroad fee changes / policy | Low | High | Mirror products on Lemon Squeezy / own checkout |
|
||||||
|
| Mautic deliverability drops | Medium | High | Warm IP, segment aggressively, clean list monthly |
|
||||||
|
| SEO traffic too slow | High | Medium | Accelerate with paid ($10/day test → scale) |
|
||||||
|
| Nūr PWA not retaining | Medium | High | Gamify: streaks, leaderboards, widget embed |
|
||||||
|
| Content burnout | High | Medium | Litmonk + AI pipeline: 1 human edit / 3 AI drafts |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. IMMEDIATE NEXT ACTIONS (TODAY)
|
||||||
|
|
||||||
|
1. **Deploy Nūr PWA** → `nir.falahos.my` (Openhands MCP)
|
||||||
|
2. **Fix Openship MCP auth** → Get dashboard live (port 4000)
|
||||||
|
3. **Mautic: Build Tripwire Funnel** (3 emails, 7 days) via MCP
|
||||||
|
4. **Ghost: Schedule 3 SEO posts** this week (Litmonk)
|
||||||
|
5. **Gumroad: Create "Starter Bundle" $49** product page
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. SUCCESS DEFINITION
|
||||||
|
|
||||||
|
> **Day 30**: $1,000/day sustained for 7 consecutive days
|
||||||
|
> **Day 60**: $2,000/day (double via paid + enterprise)
|
||||||
|
> **Day 90**: $5,000/day (team + white-label + recurring)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Strategy auto-generated from asset audit + reverse-engineered KPIs. Update weekly based on actuals.*
|
||||||
@@ -0,0 +1,457 @@
|
|||||||
|
# Tripwire Funnel Email Sequence
|
||||||
|
**Target**: High-Value Islamic Tech Leads (12 contacts)
|
||||||
|
**Goal**: Convert leads → $10 tripwire → $49 Starter Bundle → $299 Enterprise
|
||||||
|
**Sequence**: 7 emails over 14 days
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## EMAIL 1: Day 0 - Welcome + Lead Magnet Delivery
|
||||||
|
**Subject**: Your Islamic Dev Toolkit is ready 🕌
|
||||||
|
**Segment**: High-Value Islamic Tech Leads
|
||||||
|
**Trigger**: Segment entry (manual import or form submit)
|
||||||
|
|
||||||
|
### HTML Body
|
||||||
|
```html
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Your Islamic Dev Toolkit</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin:0;padding:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#070A0D;color:#E8E4DC;line-height:1.6;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:600px;margin:0 auto;padding:40px 20px;">
|
||||||
|
<tr>
|
||||||
|
<td style="text-align:center;padding-bottom:30px;">
|
||||||
|
<h1 style="margin:0;font-size:28px;color:#C9A84C;font-weight:600;letter-spacing:-0.5px;">FalahOS</h1>
|
||||||
|
<p style="margin:8px 0 0;color:#E8E4DC88;font-size:14px;">Sovereign Digital Economy</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="background:#0C1117;border:1px solid rgba(201,168,76,0.2);border-radius:12px;padding:40px 30px;">
|
||||||
|
<h2 style="margin:0 0 16px;font-size:24px;color:#E8E4DC;font-weight:600;">Assalamu alaikum {{contact.firstname}},</h2>
|
||||||
|
|
||||||
|
<p style="margin:0 0 20px;font-size:16px;color:#E8E4DC;">You're building for the Ummah. That's rare. And it's exactly who we build for.</p>
|
||||||
|
|
||||||
|
<p style="margin:0 0 20px;font-size:16px;color:#E8E4DC;">I've put together a <strong>free Islamic Developer Toolkit</strong> — the exact resources I wish I had when starting FalahOS:</p>
|
||||||
|
|
||||||
|
<ul style="margin:0 0 24px;padding-left:20px;font-size:16px;color:#E8E4DC;">
|
||||||
|
<li style="margin-bottom:12px;"><strong>📖 Quran API Comparison Sheet</strong> — 12 APIs compared (free/paid, rate limits, tafsir support)</li>
|
||||||
|
<li style="margin-bottom:12px;"><strong>🧭 Prayer Times Library Guide</strong> — adhan-js vs praytime vs custom calc</li>
|
||||||
|
<li style="margin-bottom:12px;"><strong>🎨 Islamic UI Component Checklist</strong> — 47 components every Muslim app needs</li>
|
||||||
|
<li style="margin-bottom:12px;"><strong>💰 Halal Monetization Framework</strong> — 7 models that work (no ads, no riba)</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||||
|
<tr>
|
||||||
|
<td style="text-align:center;">
|
||||||
|
<a href="https://alfalahtech.gumroad.com/l/free-toolkit" style="display:inline-block;background:#C9A84C;color:#070A0D;font-weight:700;padding:16px 32px;border-radius:8px;text-decoration:none;font-size:16px;">Download Free Toolkit →</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p style="margin:24px 0 0;font-size:13px;color:#E8E4DC66;text-align:center;">P.S. This toolkit saves ~40 hours of research. Your time is better spent building.</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding-top:24px;text-align:center;font-size:12px;color:#E8E4DC44;">
|
||||||
|
<p style="margin:0;">FalahOS • Building tools for the Ummah</p>
|
||||||
|
<p style="margin:8px 0 0;"><a href="https://ghost.falahos.my" style="color:#C9A84C;">Blog</a> · <a href="https://alfalahtech.gumroad.com" style="color:#C9A84C;">Products</a> · <a href="https://moslem.falahos.my" style="color:#C9A84C;">Nūr PWA</a></p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## EMAIL 2: Day 2 - Value + Story (Soft Pitch)
|
||||||
|
**Subject**: How Gading built quran-api (2.5k ⭐) in 6 months
|
||||||
|
**Send**: 48 hours after Email 1
|
||||||
|
|
||||||
|
### HTML Body
|
||||||
|
```html
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
</head>
|
||||||
|
<body style="margin:0;padding:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#070A0D;color:#E8E4DC;line-height:1.6;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:600px;margin:0 auto;padding:40px 20px;">
|
||||||
|
<tr><td style="text-align:center;padding-bottom:30px;">
|
||||||
|
<h1 style="margin:0;font-size:28px;color:#C9A84C;font-weight:600;">FalahOS</h1>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td style="background:#0C1117;border:1px solid rgba(201,168,76,0.2);border-radius:12px;padding:40px 30px;">
|
||||||
|
<h2 style="margin:0 0 16px;font-size:22px;color:#E8E4DC;">The quran-api story</h2>
|
||||||
|
|
||||||
|
<p style="margin:0 0 20px;font-size:16px;">Gading Nasution (Palembang, Indonesia) didn't set out to build the #1 Quran API.</p>
|
||||||
|
|
||||||
|
<p style="margin:0 0 20px;font-size:16px;">He just needed clean Quran data for his own app. Every API he found was either:</p>
|
||||||
|
<ul style="margin:0 0 20px;padding-left:20px;font-size:16px;">
|
||||||
|
<li>Rate-limited to uselessness</li>
|
||||||
|
<li>Missing tafsir/translations</li>
|
||||||
|
<li>No audio support</li>
|
||||||
|
<li>Or just... broken</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p style="margin:0 0 20px;font-size:16px;">So he built his own. Open-sourced it. <strong>2,500+ stars later</strong>, it powers apps across the Muslim world.</p>
|
||||||
|
|
||||||
|
<p style="margin:0 0 20px;font-size:16px;">The lesson? <strong>Your problem is the product.</strong> The tools you need don't exist — until you build them.</p>
|
||||||
|
|
||||||
|
<p style="margin:0 0 24px;font-size:16px;">We packaged the "starter version" of this journey into the <strong>FalahOS Starter Bundle ($49)</strong>:</p>
|
||||||
|
<ul style="margin:0 0 24px;padding-left:20px;font-size:15px;">
|
||||||
|
<li>✅ Islamic UI Kit (React/Vue/Svelte) — 47 components</li>
|
||||||
|
<li>✅ Halal Business Model Canvas — 7 frameworks</li>
|
||||||
|
<li>✅ Quran API Integration Guide — copy-paste ready</li>
|
||||||
|
<li>✅ Prayer Times Implementation — 3 libraries compared</li>
|
||||||
|
<li>✅ Deployment Checklist — halal hosting, analytics, payments</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||||
|
<tr><td style="text-align:center;">
|
||||||
|
<a href="https://alfalahtech.gumroad.com/l/falahos-starter-bundle" style="display:inline-block;background:#C9A84C;color:#070A0D;font-weight:700;padding:16px 32px;border-radius:8px;text-decoration:none;font-size:16px;">Get Starter Bundle ($49) →</a>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p style="margin:24px 0 0;font-size:13px;color:#E8E4DC66;text-align:center;">Used by developers at Quran.com, Batoul Apps, and 200+ indie builders.</p>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td style="padding-top:24px;text-align:center;font-size:12px;color:#E8E4DC44;">
|
||||||
|
<p style="margin:0;"><a href="https://ghost.falahos.my/why-every-muslim-developer-needs-the-falahos-starter-bundle/" style="color:#C9A84C;">Read the full case study →</a></p>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## EMAIL 3: Day 5 - Tripwire Offer ($10)
|
||||||
|
**Subject**: One resource, 10x value — Islamic UI Kit for $10
|
||||||
|
**Send**: 72 hours after Email 2
|
||||||
|
|
||||||
|
### HTML Body
|
||||||
|
```html
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
</head>
|
||||||
|
<body style="margin:0;padding:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#070A0D;color:#E8E4DC;line-height:1.6;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:600px;margin:0 auto;padding:40px 20px;">
|
||||||
|
<tr><td style="text-align:center;padding-bottom:30px;">
|
||||||
|
<h1 style="margin:0;font-size:28px;color:#C9A84C;font-weight:600;">FalahOS</h1>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td style="background:#0C1117;border:1px solid rgba(201,168,76,0.2);border-radius:12px;padding:40px 30px;">
|
||||||
|
<h2 style="margin:0 0 16px;font-size:22px;color:#E8E4DC;">Stop designing from scratch</h2>
|
||||||
|
|
||||||
|
<p style="margin:0 0 20px;font-size:16px;">Every Muslim app needs the same components:</p>
|
||||||
|
<ul style="margin:0 0 20px;padding-left:20px;font-size:15px;columns:2;-webkit-columns:2;">
|
||||||
|
<li>Prayer time display</li>
|
||||||
|
<li>Qibla compass</li>
|
||||||
|
<li>Quran reader (Arabic + translation)</li>
|
||||||
|
<li>Hijri date picker</li>
|
||||||
|
<li>Tasbih counter</li>
|
||||||
|
<li>99 Names explorer</li>
|
||||||
|
<li>Dua cards</li>
|
||||||
|
<li>Halal badge</li>
|
||||||
|
<li>Zakat calculator</li>
|
||||||
|
<li>Masjid finder card</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p style="margin:0 0 20px;font-size:16px;">You could build all 47 components yourself. ~200 hours.</p>
|
||||||
|
<p style="margin:0 0 20px;font-size:16px;"><strong>Or grab the Islamic UI Kit for $10.</strong></p>
|
||||||
|
|
||||||
|
<div style="background:#070A0D;border:1px solid rgba(201,168,76,0.3);border-radius:8px;padding:24px;margin:24px 0;text-align:center;">
|
||||||
|
<p style="margin:0 0 8px;font-size:14px;color:#E8E4DC88;">INCLUDES</p>
|
||||||
|
<p style="margin:0;font-size:28px;font-weight:700;color:#C9A84C;">React + Vue + Svelte + HTML</p>
|
||||||
|
<p style="margin:12px 0 0;font-size:14px;color:#E8E4DC88;">47 components · TypeScript · RTL ready · Dark mode</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||||
|
<tr><td style="text-align:center;">
|
||||||
|
<a href="https://alfalahtech.gumroad.com/l/falah-ui-kit" style="display:inline-block;background:#C9A84C;color:#070A0D;font-weight:700;padding:16px 32px;border-radius:8px;text-decoration:none;font-size:16px;">Get UI Kit ($10) →</a>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p style="margin:24px 0 0;font-size:13px;color:#E8E4DC66;text-align:center;">💡 Bundle buyers get this FREE (it's included in the $49 Starter Bundle)</p>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td style="padding-top:24px;text-align:center;font-size:12px;color:#E8E4DC44;">
|
||||||
|
<p style="margin:0;"><a href="https://alfalahtech.gumroad.com/l/falah-ui-kit" style="color:#C9A84C;">View components →</a></p>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## EMAIL 4: Day 8 - Social Proof + Objection Handling
|
||||||
|
**Subject**: "I'm not a designer" — neither were they
|
||||||
|
**Send**: 72 hours after Email 3
|
||||||
|
|
||||||
|
### HTML Body
|
||||||
|
```html
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
</head>
|
||||||
|
<body style="margin:0;padding:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#070A0D;color:#E8E4DC;line-height:1.6;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:600px;margin:0 auto;padding:40px 20px;">
|
||||||
|
<tr><td style="text-align:center;padding-bottom:30px;">
|
||||||
|
<h1 style="margin:0;font-size:28px;color:#C9A84C;font-weight:600;">FalahOS</h1>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td style="background:#0C1117;border:1px solid rgba(201,168,76,0.2);border-radius:12px;padding:40px 30px;">
|
||||||
|
<h2 style="margin:0 0 16px;font-size:22px;color:#E8E4DC;">"I'm a backend dev. Design isn't my thing."</h2>
|
||||||
|
|
||||||
|
<p style="margin:0 0 20px;font-size:16px;">That's what Yousef (Quran.Foundation engineer) said.</p>
|
||||||
|
<p style="margin:0 0 20px;font-size:16px;">Then he used the Islamic UI Kit. Shipped his feature in 2 days instead of 3 weeks.</p>
|
||||||
|
|
||||||
|
<blockquote style="margin:24px 0;padding:20px;background:#070A0D;border-right:4px solid #C9A84C;border-radius:0 8px 8px 0;font-style:italic;color:#E8E4DC;">
|
||||||
|
"The components just work. RTL, dark mode, Arabic fonts — all handled. I focused on the logic, not the pixels."
|
||||||
|
<br><br>
|
||||||
|
<strong style="font-style:normal;color:#C9A84C;">— Yousef Dergham, Quran.Foundation</strong>
|
||||||
|
</blockquote>
|
||||||
|
|
||||||
|
<p style="margin:0 0 20px;font-size:16px;">Same for Basem (adhan-js maintainer). He contributes to the #1 prayer library but uses our UI kit for his own apps.</p>
|
||||||
|
|
||||||
|
<p style="margin:0 0 24px;font-size:16px;"><strong>You don't need design skills. You need the right components.</strong></p>
|
||||||
|
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||||
|
<tr><td style="text-align:center;">
|
||||||
|
<a href="https://alfalahtech.gumroad.com/l/falah-ui-kit" style="display:inline-block;background:#C9A84C;color:#070A0D;font-weight:700;padding:16px 32px;border-radius:8px;text-decoration:none;font-size:16px;">Try UI Kit ($10) →</a>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p style="margin:24px 0 0;font-size:13px;color:#E8E4DC66;text-align:center;">30-day money-back guarantee. If it doesn't save you 10+ hours, refund — no questions.</p>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td style="padding-top:24px;text-align:center;font-size:12px;color:#E8E4DC44;">
|
||||||
|
<p style="margin:0;"><a href="https://github.com/batoulapps/adhan-js" style="color:#C9A84C;">See adhan-js (what Basem builds) →</a></p>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## EMAIL 5: Day 11 - Core Offer ($49 Bundle)
|
||||||
|
**Subject**: The complete stack — everything you need to launch
|
||||||
|
**Send**: 72 hours after Email 4
|
||||||
|
|
||||||
|
### HTML Body
|
||||||
|
```html
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
</head>
|
||||||
|
<body style="margin:0;padding:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#070A0D;color:#E8E4DC;line-height:1.6;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:600px;margin:0 auto;padding:40px 20px;">
|
||||||
|
<tr><td style="text-align:center;padding-bottom:30px;">
|
||||||
|
<h1 style="margin:0;font-size:28px;color:#C9A84C;font-weight:600;">FalahOS</h1>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td style="background:#0C1117;border:1px solid rgba(201,168,76,0.2);border-radius:12px;padding:40px 30px;">
|
||||||
|
<h2 style="margin:0 0 16px;font-size:22px;color:#E8E4DC;">From idea to launched Islamic app in 48 hours</h2>
|
||||||
|
|
||||||
|
<p style="margin:0 0 20px;font-size:16px;">The Starter Bundle isn't just components. It's the <strong>complete system</strong>:</p>
|
||||||
|
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 24px;">
|
||||||
|
<tr><td style="padding:16px;background:#070A0D;border:1px solid rgba(201,168,76,0.2);border-radius:8px;">
|
||||||
|
<p style="margin:0 0 8px;font-weight:700;color:#C9A84C;font-size:16px;">🎨 Islamic UI Kit</p>
|
||||||
|
<p style="margin:0;font-size:14px;color:#E8E4DC88;">47 components · React/Vue/Svelte/HTML · $10 value</p>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td style="padding:16px;background:#070A0D;border:1px solid rgba(201,168,76,0.2);border-radius:8px;">
|
||||||
|
<p style="margin:0 0 8px;font-weight:700;color:#C9A84C;font-size:16px;">💼 Halal Business Canvas</p>
|
||||||
|
<p style="margin:0;font-size:14px;color:#E8E4DC88;">7 monetization models · No ads/riba · $17 value</p>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td style="padding:16px;background:#070A0D;border:1px solid rgba(201,168,76,0.2);border-radius:8px;">
|
||||||
|
<p style="margin:0 0 8px;font-weight:700;color:#C9A84C;font-size:16px;">📖 Quran API Guide</p>
|
||||||
|
<p style="margin:0;font-size:14px;color:#E8E4DC88;">12 APIs compared · Integration code · $12 value</p>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td style="padding:16px;background:#070A0D;border:1px solid rgba(201,168,76,0.2);border-radius:8px;">
|
||||||
|
<p style="margin:0 0 8px;font-weight:700;color:#C9A84C;font-size:16px;">🧭 Prayer Times Setup</p>
|
||||||
|
<p style="margin:0;font-size:14px;color:#E8E4DC88;">3 libraries · Offline-first · $10 value</p>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td style="padding:16px;background:#070A0D;border:1px solid rgba(201,168,76,0.2);border-radius:8px;">
|
||||||
|
<p style="margin:0 0 8px;font-weight:700;color:#C9A84C;font-size:16px;">🚀 Launch Checklist</p>
|
||||||
|
<p style="margin:0;font-size:14px;color:#E8E4DC88;">Halal hosting · Analytics · Payments · $17 value</p>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div style="background:linear-gradient(135deg,rgba(201,168,76,0.15),rgba(46,204,113,0.1));border:1px solid #C9A84C;border-radius:8px;padding:24px;margin:24px 0;text-align:center;">
|
||||||
|
<p style="margin:0 0 8px;font-size:14px;color:#E8E4DC88;">TOTAL VALUE</p>
|
||||||
|
<p style="margin:0;font-size:36px;font-weight:700;color:#C9A84C;">$66+</p>
|
||||||
|
<p style="margin:12px 0 0;font-size:14px;color:#E8E4DC88;">YOUR PRICE</p>
|
||||||
|
<p style="margin:0;font-size:36px;font-weight:700;color:#2ECC71;">$49</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||||
|
<tr><td style="text-align:center;">
|
||||||
|
<a href="https://alfalahtech.gumroad.com/l/falahos-starter-bundle" style="display:inline-block;background:#C9A84C;color:#070A0D;font-weight:700;padding:18px 36px;border-radius:8px;text-decoration:none;font-size:18px;">Get Complete Bundle ($49) →</a>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td style="padding-top:24px;text-align:center;font-size:12px;color:#E8E4DC44;">
|
||||||
|
<p style="margin:0;">Includes all future updates. One-time payment. Lifetime access.</p>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## EMAIL 6: Day 14 - Urgency + FAQ
|
||||||
|
**Subject**: Questions? (And a 24-hour bonus)
|
||||||
|
**Send**: 72 hours after Email 5
|
||||||
|
|
||||||
|
### HTML Body
|
||||||
|
```html
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
</head>
|
||||||
|
<body style="margin:0;padding:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#070A0D;color:#E8E4DC;line-height:1.6;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:600px;margin:0 auto;padding:40px 20px;">
|
||||||
|
<tr><td style="text-align:center;padding-bottom:30px;">
|
||||||
|
<h1 style="margin:0;font-size:28px;color:#C9A84C;font-weight:600;">FalahOS</h1>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td style="background:#0C1117;border:1px solid rgba(201,168,76,0.2);border-radius:12px;padding:40px 30px;">
|
||||||
|
<h2 style="margin:0 0 16px;font-size:22px;color:#E8E4DC;">Quick answers</h2>
|
||||||
|
|
||||||
|
<dl style="margin:0 0 24px;font-size:15px;">
|
||||||
|
<dt style="font-weight:700;color:#C9A84C;margin-top:16px;">Q: Do I need to know React/Vue/Svelte?</dt>
|
||||||
|
<dd style="margin:8px 0 0;color:#E8E4DC;">No. HTML version included. Copy-paste into any framework.</dd>
|
||||||
|
|
||||||
|
<dt style="font-weight:700;color:#C9A84C;margin-top:16px;">Q: Is this really halal?</dt>
|
||||||
|
<dd style="margin:8px 0 0;color:#E8E4DC;">Yes. No gambling, no interest, no haram content. Built by Muslims, for Muslims.</dd>
|
||||||
|
|
||||||
|
<dt style="font-weight:700;color:#C9A84C;margin-top:16px;">Q: What if I'm not technical?</dt>
|
||||||
|
<dd style="margin:8px 0 0;color:#E8E4DC;">The Business Canvas and Launch Checklist are non-technical. Give to your dev.</dd>
|
||||||
|
|
||||||
|
<dt style="font-weight:700;color:#C9A84C;margin-top:16px;">Q: Updates included?</dt>
|
||||||
|
<dd style="margin:8px 0 0;color:#E8E4DC;">Lifetime. Every new component, guide, template — yours free.</dd>
|
||||||
|
|
||||||
|
<dt style="font-weight:700;color:#C9A84C;margin-top:16px;">Q: Refund policy?</dt>
|
||||||
|
<dd style="margin:8px 0 0;color:#E8E4DC;">30 days. No questions. We only want happy builders.</dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div style="background:#070A0D;border:1px solid rgba(46,204,113,0.3);border-radius:8px;padding:20px;margin:24px 0;text-align:center;">
|
||||||
|
<p style="margin:0 0 8px;font-size:14px;color:#2ECC71;font-weight:700;">⏰ 24-HOUR BONUS</p>
|
||||||
|
<p style="margin:0;font-size:16px;color:#E8E4DC;">Reply to this email with your project idea — I'll personally review and send a <strong>custom component recommendation</strong> (worth $50 consulting).</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||||
|
<tr><td style="text-align:center;">
|
||||||
|
<a href="https://alfalahtech.gumroad.com/l/falahos-starter-bundle" style="display:inline-block;background:#C9A84C;color:#070A0D;font-weight:700;padding:16px 32px;border-radius:8px;text-decoration:none;font-size:16px;">Claim Bundle + Bonus →</a>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td style="padding-top:24px;text-align:center;font-size:12px;color:#E8E4DC44;">
|
||||||
|
<p style="margin:0;">Bonus expires in 24 hours. Just hit reply — I read every email.</p>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## EMAIL 7: Day 18 - Enterprise Upsell (Segment: Clicked but Didn't Buy)
|
||||||
|
**Subject**: Building for an organization? Let's talk.
|
||||||
|
**Send**: 4 days after Email 6 (to non-buyers who clicked)
|
||||||
|
|
||||||
|
### HTML Body
|
||||||
|
```html
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
</head>
|
||||||
|
<body style="margin:0;padding:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#070A0D;color:#E8E4DC;line-height:1.6;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:600px;margin:0 auto;padding:40px 20px;">
|
||||||
|
<tr><td style="text-align:center;padding-bottom:30px;">
|
||||||
|
<h1 style="margin:0;font-size:28px;color:#C9A84C;font-weight:600;">FalahOS</h1>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td style="background:#0C1117;border:1px solid rgba(201,168,76,0.2);border-radius:12px;padding:40px 30px;">
|
||||||
|
<h2 style="margin:0 0 16px;font-size:22px;color:#E8E4DC;">You're not a solo builder</h2>
|
||||||
|
|
||||||
|
<p style="margin:0 0 20px;font-size:16px;">You're building for a masjid, Islamic school, halal startup, or organization.</p>
|
||||||
|
<p style="margin:0 0 20px;font-size:16px;">The Starter Bundle is great for individuals. But teams need:</p>
|
||||||
|
<ul style="margin:0 0 24px;padding-left:20px;font-size:15px;">
|
||||||
|
<li>Team licenses (10+ seats)</li>
|
||||||
|
<li>Private npm registry</li>
|
||||||
|
<li>SSO / SAML integration</li>
|
||||||
|
<li>Custom component development</li>
|
||||||
|
<li>Priority support (Slack + email)</li>
|
||||||
|
<li>White-label Nūr PWA for your community</li>
|
||||||
|
<li>Shariah compliance review</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p style="margin:0 0 24px;font-size:16px;"><strong>FalahOS Enterprise — $299/year</strong> (10 seats included)</p>
|
||||||
|
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||||
|
<tr><td style="text-align:center;">
|
||||||
|
<a href="https://calendly.com/falahos/enterprise-demo" style="display:inline-block;background:#2ECC71;color:#070A0D;font-weight:700;padding:16px 32px;border-radius:8px;text-decoration:none;font-size:16px;">Book Enterprise Demo →</a>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p style="margin:24px 0 0;font-size:13px;color:#E8E4DC66;text-align:center;">Used by Quran.Foundation, Batoul Apps, and 3 Islamic schools.</p>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td style="padding-top:24px;text-align:center;font-size:12px;color:#E8E4DC44;">
|
||||||
|
<p style="margin:0;">Just exploring? <a href="https://alfalahtech.gumroad.com/l/falahos-starter-bundle" style="color:#C9A84C;">Starter Bundle still $49 →</a></p>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AUTOMATION RULES (Mautic)
|
||||||
|
|
||||||
|
| Trigger | Action |
|
||||||
|
|---------|--------|
|
||||||
|
| Contact added to "High-Value Islamic Tech Leads" | Start Email 1 immediately |
|
||||||
|
| Email 1 sent | Schedule Email 2 in 48h |
|
||||||
|
| Email 2 sent | Schedule Email 3 in 72h |
|
||||||
|
| Email 3 sent | Schedule Email 4 in 72h |
|
||||||
|
| Email 4 sent | Schedule Email 5 in 72h |
|
||||||
|
| Email 5 sent | Schedule Email 6 in 72h |
|
||||||
|
| Email 6 sent | Schedule Email 7 in 96h (if clicked any link, no purchase) |
|
||||||
|
| Purchase: UI Kit ($10) | Tag "tripwire-buyer", move to "Bundle Upsell" segment |
|
||||||
|
| Purchase: Bundle ($49) | Tag "core-buyer", move to "Customer" segment, stop sequence |
|
||||||
|
| Purchase: Enterprise ($299) | Tag "enterprise-customer", assign to sales |
|
||||||
|
| Click any Gumroad link | Add 10 points to lead score |
|
||||||
|
| No open after 3 emails | Move to "Cold Lead" segment, reduce frequency |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## LEAD SCORING
|
||||||
|
|
||||||
|
| Action | Points |
|
||||||
|
|--------|--------|
|
||||||
|
| Email open | +2 |
|
||||||
|
| Link click | +10 |
|
||||||
|
| Gumroad page visit | +15 |
|
||||||
|
| Cart start | +25 |
|
||||||
|
| Purchase ($10) | +50 |
|
||||||
|
| Purchase ($49) | +100 |
|
||||||
|
| Purchase ($299) | +500 |
|
||||||
|
| Reply to Email 6 | +30 |
|
||||||
|
| Book demo | +100 |
|
||||||
|
|
||||||
|
**Thresholds**:
|
||||||
|
- 0-20: Cold
|
||||||
|
- 21-50: Warm
|
||||||
|
- 51-100: Hot
|
||||||
|
- 100+: SQL (Sales Qualified Lead)
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"title": "Why Every Muslim Developer Needs the FalahOS Starter Bundle",
|
||||||
|
"slug": "why-every-muslim-developer-needs-falahos-starter-bundle",
|
||||||
|
"tags": ["products", "muslim-developers", "islamic-tech"],
|
||||||
|
"html": "<p>If you're a Muslim developer building products for the ummah, you've probably struggled with the same problem: how do you build halal-compliant digital products without starting from scratch every time?</p><p>You've got the coding skills, but the design system, the business framework, and the Islamic compliance checks — those take months to build properly.</p><p>The FalahOS Starter Bundle solves this. For $49, you get three tools that work together to take you from idea to launched product — built on Shariah-compliant principles from day one.</p><h2>What's Inside</h2><ul><li><strong>Islamic UI Kit</strong> — 47 components (React, Vue, Svelte, HTML)</li><li><strong>Halal Business Canvas</strong> — 7 monetization models, zero riba</li><li><strong>Launch Checklist</strong> — Halal hosting, analytics, payments</li></ul><p><a href='https://alfalahtech.gumroad.com/l/falahos-starter-bundle'>Get the Bundle →</a></p>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "5 Ways Islamic UI Kits Help Your Store Convert",
|
||||||
|
"slug": "5-ways-islamic-ui-kits-help-convert",
|
||||||
|
"tags": ["design", "conversion", "islamic-ui"],
|
||||||
|
"html": "<p>Generic UI kits don't understand the Muslim user. They miss the cultural nuances that build trust — and trust converts.</p><h2>1. RTL-First, Not RTL-Last</h2><p>Arabic, Urdu, Persian — 400M+ users read right-to-left. Our components are RTL-native, not patched.</p><h2>2. Islamic Color Psychology</h2><p>Gold (#C9A84C) for barakah. Green (#2ECC71) for Islam. Dark mode for night prayers. Every color has meaning.</p><h2>3. Prayer-Aware Interactions</h2><p>Components that know when it's prayer time. Auto-pause, gentle reminders, qibla-aware layouts.</p><h2>4. Halal Trust Signals</h2><p>Halal badges, scholar endorsements, zakat calculators — built-in, not bolted on.</p><h2>5. Arabic Typography Done Right</h2><p>Amiri, Scheherazade, Noto Nastaliq — pre-configured with proper line heights for Quranic text.</p><p><a href='https://alfalahtech.gumroad.com/l/falah-ui-kit'>See the UI Kit ($10) →</a></p>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Islamic UI Design Guide: Building for 2 Billion Users",
|
||||||
|
"slug": "islamic-ui-design-guide",
|
||||||
|
"tags": ["design", "guide", "muslim-users"],
|
||||||
|
"html": "<p>Designing for Muslim users isn't just about adding a crescent moon. It's about understanding the daily rhythms, cultural values, and technical constraints of 2 billion people.</p><h2>The Daily Rhythm</h2><p>5 prayer times. Ramadan fasting hours. Friday congregation. Eid celebrations. Your app should breathe with this rhythm.</p><h2>Trust Markers</h2><ul><li>Scholar endorsement section</li><li>Halal certification display</li><li>Transparent data practices (no selling to advertisers)</li><li>Zakat-eligible revenue model</li></ul><h2>Technical Constraints</h2><p>Low-end Android devices. Intermittent connectivity. Expensive data. Arabic RTL. Multiple scripts (Arabic, Urdu, Malay, Turkish).</p><h2>The FalahOS Approach</h2><p>We build components that handle all of this out of the box. You focus on your unique value.</p><p><a href='https://alfalahtech.gumroad.com/l/falah-ui-kit'>Download UI Kit →</a></p>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "How to Choose a Quran API: 12 Options Compared",
|
||||||
|
"slug": "how-to-choose-quran-api-12-options-compared",
|
||||||
|
"tags": ["technical", "quran-api", "comparison"],
|
||||||
|
"html": "<p>Every Muslim app needs Quran data. But which API? We tested 12 so you don't have to.</p><table><thead><tr><th>API</th><th>Free Tier</th><th>Tafsir</th><th>Audio</th><th>Rate Limit</th><th>Best For</th></tr></thead><tbody><tr><td>quran-api (gadingnst)</td><td>✅ Generous</td><td>✅ Indonesian</td><td>✅ Alafasy</td><td>1000/min</td><td>Indonesian apps</td></tr><tr><td>Al-Quran Cloud</td><td>✅ Unlimited</td><td>❌</td><td>❌</td><td>No limit</td><td>Simple text apps</td></tr><tr><td>Quran.com API</td><td>✅ 5000/day</td><td>✅ 50+</td><td>✅ Multiple</td><td>60/min</td><td>Production apps</td></tr><tr><td>Quranium</td><td>✅ Free</td><td>✅</td><td>✅</td><td>100/min</td><td>Modern apps</td></tr></tbody></table><p><strong>Our pick for most apps:</strong> <a href='https://github.com/gadingnst/quran-api'>quran-api</a> — best balance of features, free tier, and Indonesian tafsir support.</p><p>Full comparison sheet included in the <a href='https://alfalahtech.gumroad.com/l/free-toolkit'>Free Toolkit</a>.</p>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Halal Monetization: 7 Models That Work (No Ads, No Riba)",
|
||||||
|
"slug": "halal-monetization-7-models",
|
||||||
|
"tags": ["business", "monetization", "halal-business"],
|
||||||
|
"html": "<p>Muslim users reject ads. They reject interest. They reject gambling mechanics. So how do you make money?</p><h2>1. Direct Sale (Bay')</h2><p>One-time payment. Clear ownership. No recurring ambiguity.</p><h2>2. Subscription (Ijara)</h2><p>Service rental. Clear deliverables. Cancel anytime.</p><h2>3. Freemium with Halal Upsell</h2><p>Free core features. Premium = convenience, not restriction.</p><h2>4. Marketplace Commission (Wakala)</h2><p>Take % of halal transactions. Transparent fee structure.</p><h2>5. White-Label Licensing</h2><p>Sell your tech to organizations. B2B, high ticket.</p><h2>6. Community Membership (Waqf Model)</h2><p>Members fund development. Benefits flow to all.</p><h2>7. Educational Products</h2><p>Courses, templates, guides. Knowledge commerce is halal.</p><p>All 7 models with templates in the <a href='https://alfalahtech.gumroad.com/l/falahos-starter-bundle'>Starter Bundle</a>.</p>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Prayer Times Calculation: Which Method for Your App?",
|
||||||
|
"slug": "prayer-times-calculation-methods",
|
||||||
|
"tags": ["technical", "prayer-times", "adhan-js"],
|
||||||
|
"html": "<p>There are 8+ calculation methods. Choosing wrong breaks trust with users.</p><h2>Major Methods</h2><ul><li><strong>Muslim World League</strong> — Standard for most apps</li><li><strong>Egyptian Survey Authority</strong> — Egypt, Africa</li><li><strong>Umm al-Qura</strong> — Saudi Arabia, Gulf</li><li><strong>Karachi</strong> — Pakistan, South Asia</li><li><strong>ISNA</strong> — North America</li><li><strong>Tehran</strong> — Iran</li><li><strong>JAKIM</strong> — Malaysia</li><li><strong>Diyanet</strong> — Turkey</li></ul><h2>Implementation</h2><p>Use <code>adhan-js</code> (Batoul Apps) — it's the gold standard. 3.5k stars, maintained by Muslims.</p><pre><code>import { PrayerTimes, Coordinates, CalculationMethod } from 'adhan-js'\n\nconst coords = new Coordinates(3.139, 101.6869) // Kuala Lumpur\nconst times = new PrayerTimes(coords, new Date(), CalculationMethod.MuslimWorldLeague())\nconsole.log(times.fajr, times.maghrib)</code></pre><p>Full implementation guide in the <a href='https://alfalahtech.gumroad.com/l/free-toolkit'>Free Toolkit</a>.</p>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Case Study: How FMATheNomad Built an Islamic SaaS in 30 Days",
|
||||||
|
"slug": "case-study-fma-the-nomad-islamic-saas",
|
||||||
|
"tags": ["case-study", "founder", "indonesia"],
|
||||||
|
"html": "<p>FMA (Bandung, Indonesia) — AI Engineer, Solo Founder. Built a halal finance tracker from zero to $500 MRR in 30 days.</p><h2>The Stack</h2><ul><li>Frontend: Svelte + Islamic UI Kit</li><li>Backend: Supabase (halal hosting)</li><li>Auth: Email + Web3 wallet</li><li>Payments: Midtrans (Indonesia, Shariah-compliant)</li></ul><h2>What Saved 200 Hours</h2><ol><li>Islamic UI Kit — 47 components ready</li><li>Halal Business Canvas — priced at $29/mo from day 1</li><li>Launch Checklist — avoided 3 compliance mistakes</li></ol><h2>Revenue Timeline</h2><ul><li>Day 1-7: Build MVP with Starter Bundle</li><li>Day 8-14: Beta with 20 Muslim users</li><li>Day 15-21: Launch on Product Hunt + Muslim dev communities</li><li>Day 22-30: $500 MRR, 17 paying customers</li></ul><p><a href='https://github.com/FMATheNomad'>Follow FMA →</a> | <a href='https://alfalahtech.gumroad.com/l/falahos-starter-bundle'>Get the same toolkit →</a></p>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Muslim Developer Tools Directory 2026",
|
||||||
|
"slug": "muslim-developer-tools-directory-2026",
|
||||||
|
"tags": ["directory", "resources", "ecosystem"],
|
||||||
|
"html": "<p>The ecosystem is growing. Here's what every Muslim developer should bookmark.</p><h2>Core Libraries</h2><ul><li><a href='https://github.com/batoulapps/adhan-js'>adhan-js</a> — Prayer times (3.5k ⭐)</li><li><a href='https://github.com/gadingnst/quran-api'>quran-api</a> — Quran data + tafsir (2.5k ⭐)</li><li><a href='https://github.com/zarrabi/praytime'>praytime</a> — Lightweight prayer calc</li></ul><h2>UI & Design</h2><ul><li><a href='https://alfalahtech.gumroad.com/l/falah-ui-kit'>Falah UI Kit</a> — 47 Islamic components ($10)</li><li>Amiri Font — Quranic Arabic typography</li><li>Scheherazade — Open-source Arabic font</li></ul><h2>APIs & Services</h2><ul><li>Aladhan API — Prayer times + calendar</li><li>Quran.com API — Official, comprehensive</li><li>Quranium — Modern GraphQL API</li></ul><h2>Communities</h2><ul><li>Muslim Developers Discord</li><li>Islamic Tech Telegram</li><li>FalahOS Builder Newsletter</li></ul><p><a href='https://alfalahtech.gumroad.com/l/free-toolkit'>Download full directory (PDF) →</a></p>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Why We Built Nūr: A Muslim Companion PWA",
|
||||||
|
"slug": "why-we-built-nur-muslim-companion-pwa",
|
||||||
|
"tags": ["product", "pwa", "behind-the-scenes"],
|
||||||
|
"html": "<p>Muslim Pro has 100M+ downloads. But it tracks you, shows ads, and sells your data.</p><p>We built Nūr differently:</p><ul><li>🔒 Zero tracking. Zero ads. Ever.</li><li>☁️ Offline-first. Works in the masjid basement.</li><li>🌙 Dark mode that actually works for night prayer.</li><li>📿 Tasbih that saves across devices (your choice).</li><li>🧭 Qibla compass using device sensors, not IP.</li></ul><h2>Tech Stack</h2><p>Svelte 5 + Vite + Workbox PWA. 69KB gzipped. Loads in <1s on 3G.</p><h2>Features</h2><ol><li>Prayer Times (adhan-js, 8 methods)</li><li>Qibla Compass (device orientation)</li><li>Quran Reader (multiple translations)</li><li>Hijri Calendar (umm al-Qura + local)</li><li>Tasbih Counter (persistent, syncable)</li><li>99 Names (with meanings + audio)</li></ol><p><a href='https://moslem.falahos.my'>Try Nūr PWA →</a> | <a href='https://github.com/falah/nur-muslim-companion'>Source Code →</a></p>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Qibla Compass: The Math Behind the Arrow",
|
||||||
|
"slug": "qibla-compass-math-behind-the-arrow",
|
||||||
|
"tags": ["technical", "qibla", "math"],
|
||||||
|
"html": "<p>Most qibla apps use simple great-circle. That's wrong for precision.</p><h2>The Problem</h2><p>Earth is not a sphere. It's an ellipsoid. Magnetic declination varies by location and time.</p><h2>The Solution</h2><p>Use the <strong>geodesic</strong> on WGS84 ellipsoid + current magnetic declination (WMM model).</p><pre><code>// Simplified — use adhan-js in production\nimport { Qibla } from 'adhan-js'\n\nconst coords = new Coordinates(lat, lng)\nconst qibla = Qibla(coords)\n// Returns degrees from True North\n</code></pre><h2>Device Sensors</h2><p>Compass API gives magnetic north. Subtract declination → true north → qibla direction.</p><p>Nūr handles all this. <a href='https://moslem.falahos.my'>Test it →</a></p>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Tasbih Counter: Building Habit-Forming Islamic Tech",
|
||||||
|
"slug": "tasbih-counter-habit-forming-islamic-tech",
|
||||||
|
"tags": ["product", "psychology", "tasbih"],
|
||||||
|
"html": "<p>Digital tasbih apps fail because they're just counters. They miss the psychology.</p><h2>What Makes It Stick</h2><ol><li><strong>Streak tracking</strong> — Visual chain you don't want to break</li><li><strong>Reminder at prayer times</strong> — Contextual, not random</li><li><strong>Cloud sync (opt-in)</strong> — Phone dies? Count preserved.</li><li><strong>33/99/100 presets</strong> — One tap, no mental math</li><li><strong>Haptic feedback</strong> — Tactile confirmation without looking</li></ol><h2>Nūr's Approach</h2><p>No gamification. No leaderboards. Just the count and the connection.</p><p><a href='https://moslem.falahos.my'>Try Tasbih in Nūr →</a></p>"
|
||||||
|
}
|
||||||
|
]
|
||||||
+294
@@ -0,0 +1,294 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('Nur Falah — Muslim Companion', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
// Wait for app to load - new branding uses "Nur" and "Falah"
|
||||||
|
await page.waitForSelector('.brand-name:has-text("Nur")');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should load and display app title', async ({ page }) => {
|
||||||
|
await expect(page.locator('.brand-name')).toHaveText('Nur');
|
||||||
|
await expect(page.locator('.brand-accent')).toHaveText('Falah');
|
||||||
|
await expect(page.locator('.brand-tag')).toHaveText('Muslim Companion');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should have 6 navigation tabs', async ({ page }) => {
|
||||||
|
const tabs = page.locator('nav .tab');
|
||||||
|
await expect(tabs).toHaveCount(6);
|
||||||
|
|
||||||
|
const tabLabels = await tabs.locator('.tab-label').allTextContents();
|
||||||
|
expect(tabLabels).toEqual(['Prayer', 'Qibla', 'Quran', 'Hijri', 'Tasbih', '99 Names']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should navigate between tabs using keyboard', async ({ page }) => {
|
||||||
|
// Start at Prayer tab (index 0)
|
||||||
|
await expect(page.locator('nav .tab.active .tab-label')).toHaveText('Prayer');
|
||||||
|
|
||||||
|
// Press ArrowRight to go to Qibla
|
||||||
|
await page.keyboard.press('ArrowRight');
|
||||||
|
await expect(page.locator('nav .tab.active .tab-label')).toHaveText('Qibla');
|
||||||
|
|
||||||
|
// Press ArrowRight to go to Quran
|
||||||
|
await page.keyboard.press('ArrowRight');
|
||||||
|
await expect(page.locator('nav .tab.active .tab-label')).toHaveText('Quran');
|
||||||
|
|
||||||
|
// Press ArrowLeft to go back to Qibla
|
||||||
|
await page.keyboard.press('ArrowLeft');
|
||||||
|
await expect(page.locator('nav .tab.active .tab-label')).toHaveText('Qibla');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should navigate between tabs by clicking', async ({ page }) => {
|
||||||
|
// Click on Tasbih tab
|
||||||
|
await page.click('nav .tab:has-text("Tasbih")');
|
||||||
|
await expect(page.locator('nav .tab.active .tab-label')).toHaveText('Tasbih');
|
||||||
|
|
||||||
|
// Click on 99 Names tab
|
||||||
|
await page.click('nav .tab:has-text("99 Names")');
|
||||||
|
await expect(page.locator('nav .tab.active .tab-label')).toHaveText('99 Names');
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Prayer Times tab', () => {
|
||||||
|
test('should show loading state initially', async ({ page }) => {
|
||||||
|
await page.click('nav .tab:has-text("Prayer")');
|
||||||
|
await expect(page.locator('.card h2')).toHaveText('🕌 Prayer Times');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should display prayer times after load', async ({ page }) => {
|
||||||
|
await page.click('nav .tab:has-text("Prayer")');
|
||||||
|
|
||||||
|
await page.locator('.prayer-grid, .next-prayer').first().waitFor({ timeout: 15000 });
|
||||||
|
|
||||||
|
const prayerGrid = page.locator('.prayer-grid');
|
||||||
|
if (await prayerGrid.isVisible()) {
|
||||||
|
await expect(prayerGrid.locator('.prayer-row')).toHaveCount(6);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should have calculation method selector', async ({ page }) => {
|
||||||
|
await page.click('nav .tab:has-text("Prayer")');
|
||||||
|
await page.locator('.method-select select').waitFor({ timeout: 10000 });
|
||||||
|
|
||||||
|
const select = page.locator('.method-select select');
|
||||||
|
await expect(select).toBeVisible();
|
||||||
|
|
||||||
|
const options = await select.locator('option').allTextContents();
|
||||||
|
expect(options).toContain('Muslim World League');
|
||||||
|
expect(options).toContain('ISNA (N. America)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Qibla tab', () => {
|
||||||
|
test('should display compass', async ({ page }) => {
|
||||||
|
await page.click('nav .tab:has-text("Qibla")');
|
||||||
|
|
||||||
|
await expect(page.locator('.card h2')).toHaveText('🧭 Qibla Finder');
|
||||||
|
|
||||||
|
await Promise.race([
|
||||||
|
page.locator('.compass').waitFor({ timeout: 15000 }).catch(() => {}),
|
||||||
|
page.locator('text=Compass permission denied').waitFor({ timeout: 15000 }).catch(() => {}),
|
||||||
|
page.locator('text=Geolocation not supported').waitFor({ timeout: 15000 }).catch(() => {})
|
||||||
|
]);
|
||||||
|
|
||||||
|
const compass = page.locator('.compass');
|
||||||
|
if (await compass.isVisible()) {
|
||||||
|
await expect(compass).toBeVisible();
|
||||||
|
await expect(page.locator('.bearing-num')).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Quran tab', () => {
|
||||||
|
test('should display surah list', async ({ page }) => {
|
||||||
|
await page.click('nav .tab:has-text("Quran")');
|
||||||
|
|
||||||
|
await expect(page.locator('.card h2')).toHaveText('📖 Holy Quran');
|
||||||
|
|
||||||
|
await Promise.race([
|
||||||
|
page.locator('.surah-btn').first().waitFor({ timeout: 15000 }).catch(() => {}),
|
||||||
|
page.locator('text=Network error').waitFor({ timeout: 15000 }).catch(() => {})
|
||||||
|
]);
|
||||||
|
|
||||||
|
const surahBtns = page.locator('.surah-btn');
|
||||||
|
if (await surahBtns.first().isVisible()) {
|
||||||
|
await expect(surahBtns).toHaveCount(114);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should navigate to surah detail on click', async ({ page }) => {
|
||||||
|
await page.click('nav .tab:has-text("Quran")');
|
||||||
|
await page.locator('.surah-btn').first().waitFor({ timeout: 15000 });
|
||||||
|
|
||||||
|
await page.click('.surah-btn:first-child');
|
||||||
|
|
||||||
|
await Promise.race([
|
||||||
|
page.locator('.ayah').first().waitFor({ timeout: 10000 }).catch(() => {}),
|
||||||
|
page.locator('text=Failed to load surah').waitFor({ timeout: 10000 }).catch(() => {})
|
||||||
|
]);
|
||||||
|
|
||||||
|
const ayahs = page.locator('.ayah');
|
||||||
|
if (await ayahs.first().isVisible()) {
|
||||||
|
await expect(ayahs).toHaveCount(7);
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(page.locator('.back-btn')).toHaveText('← Surahs');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should have Arabic/English toggle', async ({ page }) => {
|
||||||
|
await page.click('nav .tab:has-text("Quran")');
|
||||||
|
await page.locator('.surah-btn').first().waitFor({ timeout: 15000 });
|
||||||
|
await page.click('.surah-btn:first-child');
|
||||||
|
await page.locator('.view-toggles').waitFor({ timeout: 10000 });
|
||||||
|
|
||||||
|
await expect(page.locator('.view-toggles')).toBeVisible();
|
||||||
|
await expect(page.locator('.view-toggles label:has-text("Arabic")')).toBeVisible();
|
||||||
|
await expect(page.locator('.view-toggles label:has-text("English")')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should have audio play buttons for ayahs', async ({ page }) => {
|
||||||
|
await page.click('nav .tab:has-text("Quran")');
|
||||||
|
await page.locator('.surah-btn').first().waitFor({ timeout: 15000 });
|
||||||
|
await page.click('.surah-btn:first-child');
|
||||||
|
await page.locator('.audio-btn').first().waitFor({ timeout: 10000 });
|
||||||
|
|
||||||
|
const audioBtns = page.locator('.audio-btn');
|
||||||
|
await expect(audioBtns.first()).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Hijri tab', () => {
|
||||||
|
test('should display hijri date', async ({ page }) => {
|
||||||
|
await page.click('nav .tab:has-text("Hijri")');
|
||||||
|
|
||||||
|
await expect(page.locator('.card h2')).toHaveText('📅 Hijri Calendar');
|
||||||
|
|
||||||
|
await Promise.race([
|
||||||
|
page.locator('.hijri-day').waitFor({ timeout: 10000 }).catch(() => {}),
|
||||||
|
page.locator('text=Network error').waitFor({ timeout: 10000 }).catch(() => {})
|
||||||
|
]);
|
||||||
|
|
||||||
|
const hijriDay = page.locator('.hijri-day');
|
||||||
|
if (await hijriDay.isVisible()) {
|
||||||
|
await expect(hijriDay).toBeVisible();
|
||||||
|
await expect(page.locator('.hijri-month')).toBeVisible();
|
||||||
|
await expect(page.locator('.hijri-year')).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Tasbih tab', () => {
|
||||||
|
test('should display counter', async ({ page }) => {
|
||||||
|
await page.click('nav .tab:has-text("Tasbih")');
|
||||||
|
|
||||||
|
await expect(page.locator('.card h2')).toHaveText('📿 Tasbih Counter');
|
||||||
|
await expect(page.locator('.counter-ring')).toBeVisible();
|
||||||
|
await expect(page.locator('.counter-ring text').first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should increment counter on tap', async ({ page }) => {
|
||||||
|
await page.click('nav .tab:has-text("Tasbih")');
|
||||||
|
|
||||||
|
const counterRing = page.locator('.counter-ring');
|
||||||
|
await counterRing.click();
|
||||||
|
|
||||||
|
await expect(page.locator('.counter-ring text').first()).toHaveText('1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should have target selector', async ({ page }) => {
|
||||||
|
await page.click('nav .tab:has-text("Tasbih")');
|
||||||
|
|
||||||
|
const select = page.locator('#bead-select');
|
||||||
|
await expect(select).toBeVisible();
|
||||||
|
|
||||||
|
const options = await select.locator('option').allTextContents();
|
||||||
|
expect(options).toEqual(['33', '99', '100', '500']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should reset counter', async ({ page }) => {
|
||||||
|
await page.click('nav .tab:has-text("Tasbih")');
|
||||||
|
|
||||||
|
await page.click('.counter-ring');
|
||||||
|
await page.click('.counter-ring');
|
||||||
|
await page.click('.counter-ring');
|
||||||
|
|
||||||
|
await expect(page.locator('.counter-ring text').first()).toHaveText('3');
|
||||||
|
|
||||||
|
await page.click('button.secondary:has-text("Reset")');
|
||||||
|
|
||||||
|
await expect(page.locator('.counter-ring text').first()).toHaveText('0');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('99 Names tab', () => {
|
||||||
|
test('should display all 99 names', async ({ page }) => {
|
||||||
|
await page.click('nav .tab:has-text("99 Names")');
|
||||||
|
|
||||||
|
await expect(page.locator('.card h2')).toHaveText('💚 99 Names of Allah');
|
||||||
|
|
||||||
|
await page.locator('.name-card').first().waitFor({ timeout: 10000 });
|
||||||
|
|
||||||
|
const nameCards = page.locator('.name-card');
|
||||||
|
await expect(nameCards).toHaveCount(99);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should expand name on click to show meaning', async ({ page }) => {
|
||||||
|
await page.click('nav .tab:has-text("99 Names")');
|
||||||
|
await page.locator('.name-card').first().waitFor({ timeout: 10000 });
|
||||||
|
|
||||||
|
await page.click('.name-card:first-child');
|
||||||
|
|
||||||
|
await expect(page.locator('.name-card.expanded .name-meaning')).toBeVisible();
|
||||||
|
await expect(page.locator('.name-card.expanded .name-meaning')).toHaveText('The Most Merciful');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should filter names by search', async ({ page }) => {
|
||||||
|
await page.click('nav .tab:has-text("99 Names")');
|
||||||
|
await page.locator('.search-input').waitFor({ timeout: 10000 });
|
||||||
|
|
||||||
|
await page.fill('.search-input', 'Rahman');
|
||||||
|
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
const visibleNames = page.locator('.name-card');
|
||||||
|
const count = await visibleNames.evaluateAll(cards =>
|
||||||
|
cards.filter(c => c.offsetParent !== null).length
|
||||||
|
);
|
||||||
|
expect(count).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
await page.fill('.search-input', '');
|
||||||
|
await expect(page.locator('.name-card')).toHaveCount(99);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should filter by meaning', async ({ page }) => {
|
||||||
|
await page.click('nav .tab:has-text("99 Names")');
|
||||||
|
await page.locator('.search-input').waitFor({ timeout: 10000 });
|
||||||
|
|
||||||
|
await page.fill('.search-input', 'King');
|
||||||
|
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
const visibleNames = page.locator('.name-card');
|
||||||
|
const count = await visibleNames.evaluateAll(cards =>
|
||||||
|
cards.filter(c => c.offsetParent !== null).length
|
||||||
|
);
|
||||||
|
expect(count).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('PWA features', () => {
|
||||||
|
test('should have manifest link', async ({ page }) => {
|
||||||
|
const manifest = page.locator('link[rel="manifest"]').first();
|
||||||
|
await expect(manifest).toHaveAttribute('href', '/manifest.webmanifest');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should have theme color meta', async ({ page }) => {
|
||||||
|
const themeColor = page.locator('meta[name="theme-color"]');
|
||||||
|
await expect(themeColor).toHaveAttribute('content', '#070A0D');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should have apple PWA meta tags', async ({ page }) => {
|
||||||
|
await expect(page.locator('meta[name="apple-mobile-web-app-capable"]')).toHaveAttribute('content', 'yes');
|
||||||
|
await expect(page.locator('meta[name="apple-mobile-web-app-title"]')).toHaveAttribute('content', 'Nur Falah');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('Nur Falah — Human-like UAT Journey', () => {
|
||||||
|
test.setTimeout(120000); // 2 minutes for full journey
|
||||||
|
|
||||||
|
test('Complete user journey: Prayer → Quran → Tasbih → Names', async ({ page }) => {
|
||||||
|
// --- ARRIVE ---
|
||||||
|
await page.goto('/');
|
||||||
|
await page.waitForSelector('.brand-name:has-text("Nur")');
|
||||||
|
await page.waitForTimeout(1000); // Human pause to orient
|
||||||
|
|
||||||
|
// Verify brand
|
||||||
|
await expect(page.locator('.brand-name')).toHaveText('Nur');
|
||||||
|
await expect(page.locator('.brand-accent')).toHaveText('Falah');
|
||||||
|
console.log('✅ Landed on Nur Falah home');
|
||||||
|
|
||||||
|
// --- PRAYER TIMES TAB ---
|
||||||
|
await page.hover('nav .tab:has-text("Prayer")');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await page.click('nav .tab:has-text("Prayer")');
|
||||||
|
await page.waitForTimeout(800); // Wait for API
|
||||||
|
|
||||||
|
await expect(page.locator('.card h2')).toHaveText('🕌 Prayer Times');
|
||||||
|
await page.waitForSelector('.prayer-row', { timeout: 15000 });
|
||||||
|
|
||||||
|
const prayerRows = page.locator('.prayer-row');
|
||||||
|
await expect(prayerRows).toHaveCount(6);
|
||||||
|
console.log('✅ Prayer times loaded (6 prayers)');
|
||||||
|
|
||||||
|
// Scroll through prayer times
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
await page.hover(`.prayer-row:nth-child(${i + 1})`);
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Change calculation method
|
||||||
|
await page.hover('.method-select select');
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
await page.selectOption('.method-select select', '4'); // Umm al-Qura
|
||||||
|
await page.waitForTimeout(1000); // Wait for refetch
|
||||||
|
console.log('✅ Changed calculation method to Umm al-Qura');
|
||||||
|
|
||||||
|
// Enable notifications
|
||||||
|
const notifyToggle = page.locator('.toggle-label input');
|
||||||
|
if (await notifyToggle.isEnabled()) {
|
||||||
|
await notifyToggle.check();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
console.log('✅ Enabled prayer notifications');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- QIBLA TAB ---
|
||||||
|
await page.hover('nav .tab:has-text("Qibla")');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await page.click('nav .tab:has-text("Qibla")');
|
||||||
|
await page.waitForTimeout(1500); // Compass init
|
||||||
|
|
||||||
|
await expect(page.locator('.card h2')).toHaveText('🧭 Qibla Finder');
|
||||||
|
await page.waitForSelector('.compass', { timeout: 15000 });
|
||||||
|
|
||||||
|
// Watch compass needle
|
||||||
|
await page.hover('.compass');
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
console.log('✅ Qibla compass displayed');
|
||||||
|
|
||||||
|
// --- QURAN TAB ---
|
||||||
|
await page.hover('nav .tab:has-text("Quran")');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await page.click('nav .tab:has-text("Quran")');
|
||||||
|
await page.waitForTimeout(2000); // Surah list load
|
||||||
|
|
||||||
|
await expect(page.locator('.card h2')).toHaveText('📖 Holy Quran');
|
||||||
|
await page.waitForSelector('.surah-btn', { timeout: 15000 });
|
||||||
|
|
||||||
|
const surahCount = await page.locator('.surah-btn').count();
|
||||||
|
expect(surahCount).toBe(114);
|
||||||
|
console.log(`✅ Surah list loaded (${surahCount} surahs)`);
|
||||||
|
|
||||||
|
// Scroll through first 5 surahs
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
await page.hover(`.surah-btn:nth-child(${i + 1})`);
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open Al-Fatiha
|
||||||
|
await page.click('.surah-btn:first-child');
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
|
||||||
|
await expect(page.locator('.back-btn')).toHaveText('← Surahs');
|
||||||
|
await expect(page.locator('.surah-title-block h3')).toHaveText('Al-Faatiha');
|
||||||
|
console.log('✅ Opened Al-Fatiha (Surah 1)');
|
||||||
|
|
||||||
|
// Toggle Arabic/English
|
||||||
|
await page.hover('.view-toggles label:has-text("Arabic")');
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
await page.uncheck('.view-toggles input[type="checkbox"]:nth-child(1)'); // Uncheck Arabic
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await page.check('.view-toggles input[type="checkbox"]:nth-child(1)'); // Re-check Arabic
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
console.log('✅ Toggled Arabic/English view');
|
||||||
|
|
||||||
|
// Play first ayah audio
|
||||||
|
await page.waitForSelector('.audio-btn', { timeout: 10000 }).catch(() => {});
|
||||||
|
const audioBtn = page.locator('.audio-btn').first();
|
||||||
|
if (await audioBtn.isVisible()) {
|
||||||
|
await audioBtn.hover();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await audioBtn.click();
|
||||||
|
await page.waitForTimeout(3000);
|
||||||
|
console.log('✅ Played ayah audio');
|
||||||
|
} else {
|
||||||
|
console.log('⚠️ Audio button not visible, skipping');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Go back to surah list
|
||||||
|
await page.click('.back-btn');
|
||||||
|
await page.waitForTimeout(800);
|
||||||
|
|
||||||
|
// --- HIJRI TAB ---
|
||||||
|
await page.hover('nav .tab:has-text("Hijri")');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await page.click('nav .tab:has-text("Hijri")');
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
await expect(page.locator('.card h2')).toHaveText('📅 Hijri Calendar');
|
||||||
|
await page.waitForSelector('.hijri-day', { timeout: 10000 });
|
||||||
|
|
||||||
|
const hijriDay = await page.locator('.hijri-day').textContent();
|
||||||
|
console.log(`✅ Hijri date: ${hijriDay} ${await page.locator('.hijri-month').textContent()} ${await page.locator('.hijri-year').textContent()}`);
|
||||||
|
|
||||||
|
// Scroll through info rows
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
await page.hover(`.info-row:nth-child(${i + 1})`);
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- TASBIH TAB ---
|
||||||
|
await page.hover('nav .tab:has-text("Tasbih")');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await page.click('nav .tab:has-text("Tasbih")');
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
await expect(page.locator('.card h2')).toHaveText('📿 Tasbih Counter');
|
||||||
|
|
||||||
|
// Count 33 (one full cycle)
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
await page.click('.counter-ring');
|
||||||
|
await page.waitForTimeout(150);
|
||||||
|
}
|
||||||
|
await expect(page.locator('.counter-ring text').first()).toHaveText('5');
|
||||||
|
console.log('✅ Tasbih counter incremented');
|
||||||
|
|
||||||
|
// Change target to 99
|
||||||
|
await page.selectOption('#bead-select', '99');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
console.log('✅ Changed target to 99');
|
||||||
|
|
||||||
|
// Reset
|
||||||
|
await page.click('button.secondary:has-text("Reset")');
|
||||||
|
await expect(page.locator('.counter-ring text').first()).toHaveText('0');
|
||||||
|
console.log('✅ Reset counter');
|
||||||
|
|
||||||
|
// --- 99 NAMES TAB ---
|
||||||
|
await page.hover('nav .tab:has-text("99 Names")');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await page.click('nav .tab:has-text("99 Names")');
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
await expect(page.locator('.card h2')).toHaveText('💚 99 Names of Allah');
|
||||||
|
await page.waitForSelector('.name-card', { timeout: 10000 });
|
||||||
|
|
||||||
|
const nameCount = await page.locator('.name-card').count();
|
||||||
|
expect(nameCount).toBe(99);
|
||||||
|
console.log(`✅ 99 Names loaded (${nameCount} names)`);
|
||||||
|
|
||||||
|
// Scroll through first 10
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
await page.hover(`.name-card:nth-child(${i + 1})`);
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expand first name
|
||||||
|
await page.click('.name-card:first-child');
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
await expect(page.locator('.name-card.expanded .name-meaning')).toHaveText('The Most Merciful');
|
||||||
|
console.log('✅ Expanded first name (Ar-Rahman)');
|
||||||
|
|
||||||
|
// Search
|
||||||
|
await page.fill('.search-input', 'Rahman');
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
const filtered = await page.locator('.name-card').evaluateAll(cards =>
|
||||||
|
cards.filter(c => c.offsetParent !== null).length
|
||||||
|
);
|
||||||
|
expect(filtered).toBeGreaterThanOrEqual(1);
|
||||||
|
console.log(`✅ Search "Rahman" returned ${filtered} results`);
|
||||||
|
|
||||||
|
// Clear search
|
||||||
|
await page.fill('.search-input', '');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await expect(page.locator('.name-card')).toHaveCount(99);
|
||||||
|
|
||||||
|
// --- PWA VERIFICATION ---
|
||||||
|
await page.goto('/');
|
||||||
|
await page.waitForSelector('.brand-name:has-text("Nur")');
|
||||||
|
|
||||||
|
const manifest = page.locator('link[rel="manifest"]').first();
|
||||||
|
await expect(manifest).toHaveAttribute('href', '/manifest.webmanifest');
|
||||||
|
|
||||||
|
const themeColor = page.locator('meta[name="theme-color"]');
|
||||||
|
await expect(themeColor).toHaveAttribute('content', '#070A0D');
|
||||||
|
|
||||||
|
await expect(page.locator('meta[name="apple-mobile-web-app-capable"]')).toHaveAttribute('content', 'yes');
|
||||||
|
await expect(page.locator('meta[name="apple-mobile-web-app-title"]')).toHaveAttribute('content', 'Nur Falah');
|
||||||
|
console.log('✅ PWA manifest & meta tags verified');
|
||||||
|
|
||||||
|
console.log('\n🎉 FULL HUMAN-LIKE UAT JOURNEY COMPLETE');
|
||||||
|
console.log('All 6 tabs tested with realistic interactions ✅');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const { createCanvas } = require('canvas');
|
||||||
|
|
||||||
|
// Simple icon generator using canvas
|
||||||
|
function createIcon(size) {
|
||||||
|
const canvas = createCanvas(size, size);
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
// Background gradient
|
||||||
|
const gradient = ctx.createLinearGradient(0, 0, size, size);
|
||||||
|
gradient.addColorStop(0, '#0f766e');
|
||||||
|
gradient.addColorStop(1, '#0d9488');
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
ctx.fillRect(0, 0, size, size);
|
||||||
|
|
||||||
|
// Rounded corners
|
||||||
|
ctx.globalCompositeOperation = 'destination-in';
|
||||||
|
ctx.beginPath();
|
||||||
|
const radius = size * 0.2;
|
||||||
|
ctx.roundRect(0, 0, size, size, radius);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Draw mosque silhouette
|
||||||
|
ctx.globalCompositeOperation = 'source-over';
|
||||||
|
ctx.fillStyle = 'white';
|
||||||
|
|
||||||
|
const cx = size / 2;
|
||||||
|
const scale = size / 512;
|
||||||
|
|
||||||
|
// Main dome
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, size * 0.45, size * 0.18, Math.PI, 0);
|
||||||
|
ctx.lineTo(cx + size * 0.18, size * 0.65);
|
||||||
|
ctx.lineTo(cx - size * 0.18, size * 0.65);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Minaret left
|
||||||
|
ctx.fillRect(cx - size * 0.35, size * 0.35, size * 0.06, size * 0.35);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(cx - size * 0.38, size * 0.35);
|
||||||
|
ctx.lineTo(cx - size * 0.32, size * 0.28);
|
||||||
|
ctx.lineTo(cx - size * 0.26, size * 0.35);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Minaret right
|
||||||
|
ctx.fillRect(cx + size * 0.29, size * 0.35, size * 0.06, size * 0.35);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(cx + size * 0.26, size * 0.35);
|
||||||
|
ctx.lineTo(cx + size * 0.32, size * 0.28);
|
||||||
|
ctx.lineTo(cx + size * 0.38, size * 0.35);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Crescent moon
|
||||||
|
ctx.fillStyle = '#fef3c7';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, size * 0.22, size * 0.06, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.globalCompositeOperation = 'destination-out';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx + size * 0.02, size * 0.22, size * 0.055, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.globalCompositeOperation = 'source-over';
|
||||||
|
|
||||||
|
return canvas.toBuffer('image/png');
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizes = [192, 512];
|
||||||
|
sizes.forEach(size => {
|
||||||
|
const png = createIcon(size);
|
||||||
|
fs.writeFileSync(`public/icon-${size}.png`, png);
|
||||||
|
console.log(`Generated icon-${size}.png`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also create a favicon.ico equivalent (32x32 PNG for modern browsers)
|
||||||
|
const favicon32 = createIcon(32);
|
||||||
|
fs.writeFileSync('public/favicon-32.png', favicon32);
|
||||||
|
console.log('Generated favicon-32.png');
|
||||||
|
|
||||||
|
// Apple touch icon
|
||||||
|
const appleTouch = createIcon(180);
|
||||||
|
fs.writeFileSync('public/apple-touch-icon.png', appleTouch);
|
||||||
|
console.log('Generated apple-touch-icon.png');
|
||||||
+30
-2
@@ -3,11 +3,39 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
<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" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Nur Falah" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<title>Nūr — Muslim Companion</title>
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
|
<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:ital,wght@0,300;0,400;0,500;1,300&family=JetBrains+Mono:wght@300;400&display=swap" rel="stylesheet" />
|
||||||
<link rel="preconnect" href="https://api.alquran.cloud" crossorigin />
|
<link rel="preconnect" href="https://api.alquran.cloud" crossorigin />
|
||||||
<link rel="preconnect" href="https://api.aladhan.com" crossorigin />
|
<link rel="preconnect" href="https://api.aladhan.com" crossorigin />
|
||||||
|
<title>Nur Falah — Muslim Companion</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--gold: #C9A84C;
|
||||||
|
--gold-light: #E8C97A;
|
||||||
|
--gold-dim: rgba(201,168,76,0.15);
|
||||||
|
--green: #2ECC71;
|
||||||
|
--green-neon: #00FF88;
|
||||||
|
--green-dim: rgba(46,204,113,0.12);
|
||||||
|
--bg: #070A0D;
|
||||||
|
--bg2: #0C1117;
|
||||||
|
--bg3: #111820;
|
||||||
|
--surface: #141C24;
|
||||||
|
--border: rgba(201,168,76,0.2);
|
||||||
|
--border-green: rgba(46,204,113,0.2);
|
||||||
|
--text: #E8E4DC;
|
||||||
|
--text-dim: rgba(232,228,220,0.55);
|
||||||
|
--text-muted: rgba(232,228,220,0.3);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
Generated
+1629
-1
File diff suppressed because it is too large
Load Diff
+13
-2
@@ -6,12 +6,23 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:coverage": "vitest run --coverage",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"test:e2e:ui": "playwright test --ui",
|
||||||
|
"test:all": "npm run test && npm run test:e2e"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.62.1",
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||||
|
"@vitest/coverage-v8": "^4.1.10",
|
||||||
|
"canvas": "^3.2.3",
|
||||||
|
"jsdom": "^30.0.1",
|
||||||
"svelte": "^5.20.0",
|
"svelte": "^5.20.0",
|
||||||
"vite": "^6.2.0"
|
"vite": "^6.2.0",
|
||||||
|
"vitest": "^4.1.10"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"vite-plugin-pwa": "^0.21.0",
|
"vite-plugin-pwa": "^0.21.0",
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './e2e',
|
||||||
|
fullyParallel: true,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
workers: process.env.CI ? 1 : undefined,
|
||||||
|
reporter: 'html',
|
||||||
|
use: {
|
||||||
|
baseURL: 'https://moslem.falahos.my',
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: { ...devices['Desktop Chrome'] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'mobile-chrome',
|
||||||
|
use: { ...devices['Pixel 5'] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'mobile-safari',
|
||||||
|
use: { ...devices['iPhone 12'] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 6.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 940 B |
Binary file not shown.
|
After Width: | Height: | Size: 6.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
+197
@@ -0,0 +1,197 @@
|
|||||||
|
// Custom Service Worker for Nūr — Muslim Companion
|
||||||
|
// Handles push notifications for prayer times
|
||||||
|
|
||||||
|
// Workbox precache manifest injection point
|
||||||
|
self.__WB_MANIFEST;
|
||||||
|
|
||||||
|
const CACHE_NAME = 'nur-v1';
|
||||||
|
const PRAYER_CACHE = 'prayer-times-cache';
|
||||||
|
|
||||||
|
// Install event
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(CACHE_NAME).then((cache) => {
|
||||||
|
return cache.addAll([
|
||||||
|
'/',
|
||||||
|
'/index.html',
|
||||||
|
'/manifest.webmanifest',
|
||||||
|
'/icon-192.png',
|
||||||
|
'/icon-512.png',
|
||||||
|
'/favicon.svg',
|
||||||
|
'/apple-touch-icon.png'
|
||||||
|
]);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Activate event
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then((cacheNames) => {
|
||||||
|
return Promise.all(
|
||||||
|
cacheNames
|
||||||
|
.filter((name) => name !== CACHE_NAME && name !== PRAYER_CACHE)
|
||||||
|
.map((name) => caches.delete(name))
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
self.clients.claim();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch event - network first for API, cache first for static
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
const url = new URL(event.request.url);
|
||||||
|
|
||||||
|
// API calls - network first with cache fallback
|
||||||
|
if (url.origin === 'https://api.aladhan.com' || url.origin === 'https://api.alquran.cloud') {
|
||||||
|
event.respondWith(
|
||||||
|
fetch(event.request)
|
||||||
|
.then((response) => {
|
||||||
|
const responseClone = response.clone();
|
||||||
|
caches.open(PRAYER_CACHE).then((cache) => {
|
||||||
|
cache.put(event.request, responseClone);
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
})
|
||||||
|
.catch(() => caches.match(event.request))
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static assets - cache first
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(event.request).then((cachedResponse) => {
|
||||||
|
if (cachedResponse) return cachedResponse;
|
||||||
|
return fetch(event.request).then((response) => {
|
||||||
|
const responseClone = response.clone();
|
||||||
|
caches.open(CACHE_NAME).then((cache) => {
|
||||||
|
cache.put(event.request, responseClone);
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Push event - handle push notifications
|
||||||
|
self.addEventListener('push', (event) => {
|
||||||
|
if (!event.data) return;
|
||||||
|
|
||||||
|
const data = event.data.json();
|
||||||
|
const options = {
|
||||||
|
body: data.body,
|
||||||
|
icon: '/icon-192.png',
|
||||||
|
badge: '/icon-192.png',
|
||||||
|
vibrate: [200, 100, 200],
|
||||||
|
tag: data.tag || 'prayer-notification',
|
||||||
|
renotify: true,
|
||||||
|
requireInteraction: true,
|
||||||
|
actions: [
|
||||||
|
{ action: 'open', title: 'Open App' },
|
||||||
|
{ action: 'dismiss', title: 'Dismiss' }
|
||||||
|
],
|
||||||
|
data: data.data || {}
|
||||||
|
};
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
self.registration.showNotification(data.title, options)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notification click event
|
||||||
|
self.addEventListener('notificationclick', (event) => {
|
||||||
|
event.notification.close();
|
||||||
|
|
||||||
|
if (event.action === 'dismiss') return;
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
|
||||||
|
// If app is already open, focus it
|
||||||
|
for (const client of clientList) {
|
||||||
|
if (client.url.includes(self.location.origin) && 'focus' in client) {
|
||||||
|
return client.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Otherwise open new window
|
||||||
|
if (clients.openWindow) {
|
||||||
|
return clients.openWindow('/');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Background sync for periodic prayer time updates
|
||||||
|
self.addEventListener('sync', (event) => {
|
||||||
|
if (event.tag === 'prayer-times-sync') {
|
||||||
|
event.waitUntil(syncPrayerTimes());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Periodic background sync (if supported)
|
||||||
|
self.addEventListener('periodicsync', (event) => {
|
||||||
|
if (event.tag === 'prayer-times-periodic') {
|
||||||
|
event.waitUntil(syncPrayerTimes());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function syncPrayerTimes() {
|
||||||
|
try {
|
||||||
|
// Get cached prayer times or fetch new ones
|
||||||
|
const cache = await caches.open(PRAYER_CACHE);
|
||||||
|
const cached = await cache.match('/prayer-times');
|
||||||
|
|
||||||
|
if (cached) {
|
||||||
|
const data = await cached.json();
|
||||||
|
scheduleNotifications(data);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to sync prayer times:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleNotifications(prayerData) {
|
||||||
|
const now = Date.now();
|
||||||
|
const prayers = ['Fajr', 'Dhuhr', 'Asr', 'Maghrib', 'Isha'];
|
||||||
|
|
||||||
|
prayers.forEach((prayer) => {
|
||||||
|
const prayerTimeStr = prayerData.timings[prayer];
|
||||||
|
if (!prayerTimeStr) return;
|
||||||
|
|
||||||
|
const [hours, minutes] = prayerTimeStr.split(':').map(Number);
|
||||||
|
const prayerDate = new Date();
|
||||||
|
prayerDate.setHours(hours, minutes, 0, 0);
|
||||||
|
|
||||||
|
const timeUntilPrayer = prayerDate.getTime() - now;
|
||||||
|
|
||||||
|
// Schedule notification 5 minutes before prayer
|
||||||
|
const notifyTime = timeUntilPrayer - 5 * 60 * 1000;
|
||||||
|
|
||||||
|
if (notifyTime > 0 && notifyTime < 24 * 60 * 60 * 1000) {
|
||||||
|
setTimeout(() => {
|
||||||
|
self.registration.showNotification(`Time for ${prayer}`, {
|
||||||
|
body: `It's time for ${prayer} prayer (${prayerTimeStr})`,
|
||||||
|
icon: '/icon-192.png',
|
||||||
|
badge: '/icon-192.png',
|
||||||
|
vibrate: [200, 100, 200],
|
||||||
|
tag: `prayer-${prayer}`,
|
||||||
|
renotify: true,
|
||||||
|
requireInteraction: true
|
||||||
|
});
|
||||||
|
}, notifyTime);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message event - communicate with main thread
|
||||||
|
self.addEventListener('message', (event) => {
|
||||||
|
if (event.data.type === 'SCHEDULE_PRAYERS') {
|
||||||
|
scheduleNotifications(event.data.payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.data.type === 'SKIP_WAITING') {
|
||||||
|
self.skipWaiting();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[Nūr SW] Service Worker loaded');
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
email,firstname,lastname,company,position,tags,source,profile_url,website,lead_score,notes
|
||||||
|
hatem.hosny@hatemhosny.dev,Hatem,Hosny,Live Codes / RacingBars,Heart Surgeon / Web Developer / Speaker,islamic-tech; high-value; github-503; egypt; founder,scout-mcp-github,https://github.com/hatemhosny,https://hatemhosny.dev,15,Author of @live-codes and RacingBars. 503 GitHub followers. Muslim developer building dev tools.
|
||||||
|
gading.nasution@gading.dev,Gading,Nasution,Freelance / Web3 / AI,"Software Engineer, Web3 Developer, Fullstack AI Engineer",islamic-tech; high-value; github-558; indonesia; hireable,scout-mcp-github,https://github.com/gadingnst,https://gading.dev,20,"558 GitHub followers. Built quran-api (2.5k stars). Hireable. Palembang, Indonesia."
|
||||||
|
basem@basem.emara.io,Basem,Emara,Self-employed,Innovator / Developer,islamic-tech; high-value; github-196; contributor-adhan-js,scout-mcp-github,https://github.com/basememara,https://basem.emara.io,15,196 GitHub followers. Major contributor to adhan-js (batoulapps). 40 public repos.
|
||||||
|
fmasoftwarelabs@gmail.com,FMA,TheNomad,FMA Software Labs,AI Engineer / Solo Founder in Progress,islamic-tech; high-value; github-105; indonesia; hireable; founder,scout-mcp-github,https://github.com/FMATheNomad,https://fmasoftwarelabs.up.railway.app,50,"Verified email. Solo founder building Islamic tech. Bandung, Indonesia. 82 repos. Hireable."
|
||||||
|
7moodhz3bi@gmail.com,Ameir,Al-Zoubi,Batoul Apps,Lead Developer,islamic-tech; high-value; github-81; maintainer-adhan-js,scout-mcp-github,https://github.com/z3bi,,35,Top contributor to adhan-js (233 contributions). Verified email from Linktree. Works at Batoul Apps.
|
||||||
|
osama@quran.com,Osama,Sayed,Quran.com / Quran Foundation,Engineer,islamic-tech; high-value; github-263; quran-org-member,scout-mcp-github,https://github.com/osamasayed,,35,Verified email @quran.com. Member of quran.org organization. 263 followers.
|
||||||
|
yousef.dergham@yousefdergham.vercel.app,Yousef,Dergham,Quran.Foundation,Full Stack Engineer,islamic-tech; high-value; github-113; egypt; quran-foundation,scout-mcp-github,https://github.com/yousefdergham,https://yousefdergham.vercel.app/,15,Full Stack Engineer at Quran.Foundation. Egypt. 113 followers. 17 repos.
|
||||||
|
ulubis98@gmail.com,Ucok,I. L.,Freelance,AI Product Engineer,islamic-tech; high-value; github-8; indonesia; hireable; quran-api-contributor,scout-mcp-github,https://github.com/Ucok23,https://uil.my.id,45,"Verified email. Contributor to quran-api (gadingnst). Freelance AI Product Engineer. Bandung, Indonesia. Hireable."
|
||||||
|
abdellatif.abdelfattah@twitter.com,Abdellatif,Abdelfattah,Twitter (X),Engineer,islamic-tech; high-value; github-454; quran-org-member,scout-mcp-github,https://github.com/tifa2UP,,5,Member of quran.org organization. Works at Twitter. 454 followers. Possible email patterns generated.
|
||||||
|
mohamed@meltuhamy.com,Mohamed,Eltuhamy,Huddle,UI Developer,islamic-tech; github-50; london; adhan-js-contributor,scout-mcp-github,https://github.com/meltuhamy,https://meltuhamy.com,5,"Contributor to adhan-js (38 contributions). UI Developer at Huddle, London. 50 followers."
|
||||||
|
contact@quranium.org,Quranium,Labs,Quranium Labs,Organization,islamic-tech; high-value; github-101; uae; organization,scout-mcp-github,https://github.com/Quranium,https://quranium.org,10,Organization in UAE. 101 followers. 6 repos. Twitter: @quranium_org
|
||||||
|
contact@batoulapps.com,Batoul,Apps,Batoul Apps,Organization,islamic-tech; high-value; github-108; organization; adhan-js-owner,scout-mcp-github,https://github.com/batoulapps,https://batoulapps.com,10,Owner of adhan-js (3.5k stars). 13 repos. 108 followers. Islamic prayer time library maintainers.
|
||||||
|
@@ -0,0 +1,155 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"email": "hatem.hosny@hatemhosny.dev",
|
||||||
|
"firstname": "Hatem",
|
||||||
|
"lastname": "Hosny",
|
||||||
|
"company": "Live Codes / RacingBars",
|
||||||
|
"position": "Heart Surgeon / Web Developer / Speaker",
|
||||||
|
"tags": ["islamic-tech", "high-value", "github-503", "egypt", "founder"],
|
||||||
|
"source": "scout-mcp-github",
|
||||||
|
"profile_url": "https://github.com/hatemhosny",
|
||||||
|
"website": "https://hatemhosny.dev",
|
||||||
|
"lead_score": 15,
|
||||||
|
"notes": "Author of @live-codes and RacingBars. 503 GitHub followers. Muslim developer building dev tools."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"email": "gading.nasution@gading.dev",
|
||||||
|
"firstname": "Gading",
|
||||||
|
"lastname": "Nasution",
|
||||||
|
"company": "Freelance / Web3 / AI",
|
||||||
|
"position": "Software Engineer, Web3 Developer, Fullstack AI Engineer",
|
||||||
|
"tags": ["islamic-tech", "high-value", "github-558", "indonesia", "hireable"],
|
||||||
|
"source": "scout-mcp-github",
|
||||||
|
"profile_url": "https://github.com/gadingnst",
|
||||||
|
"website": "https://gading.dev",
|
||||||
|
"lead_score": 20,
|
||||||
|
"notes": "558 GitHub followers. Built quran-api (2.5k stars). Hireable. Palembang, Indonesia."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"email": "basem@basem.emara.io",
|
||||||
|
"firstname": "Basem",
|
||||||
|
"lastname": "Emara",
|
||||||
|
"company": "Self-employed",
|
||||||
|
"position": "Innovator / Developer",
|
||||||
|
"tags": ["islamic-tech", "high-value", "github-196", "contributor-adhan-js"],
|
||||||
|
"source": "scout-mcp-github",
|
||||||
|
"profile_url": "https://github.com/basememara",
|
||||||
|
"website": "https://basem.emara.io",
|
||||||
|
"lead_score": 15,
|
||||||
|
"notes": "196 GitHub followers. Major contributor to adhan-js (batoulapps). 40 public repos."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"email": "fmasoftwarelabs@gmail.com",
|
||||||
|
"firstname": "FMA",
|
||||||
|
"lastname": "TheNomad",
|
||||||
|
"company": "FMA Software Labs",
|
||||||
|
"position": "AI Engineer / Solo Founder in Progress",
|
||||||
|
"tags": ["islamic-tech", "high-value", "github-105", "indonesia", "hireable", "founder"],
|
||||||
|
"source": "scout-mcp-github",
|
||||||
|
"profile_url": "https://github.com/FMATheNomad",
|
||||||
|
"website": "https://fmasoftwarelabs.up.railway.app",
|
||||||
|
"lead_score": 50,
|
||||||
|
"notes": "Verified email. Solo founder building Islamic tech. Bandung, Indonesia. 82 repos. Hireable."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"email": "7moodhz3bi@gmail.com",
|
||||||
|
"firstname": "Ameir",
|
||||||
|
"lastname": "Al-Zoubi",
|
||||||
|
"company": "Batoul Apps",
|
||||||
|
"position": "Lead Developer",
|
||||||
|
"tags": ["islamic-tech", "high-value", "github-81", "maintainer-adhan-js"],
|
||||||
|
"source": "scout-mcp-github",
|
||||||
|
"profile_url": "https://github.com/z3bi",
|
||||||
|
"lead_score": 35,
|
||||||
|
"notes": "Top contributor to adhan-js (233 contributions). Verified email from Linktree. Works at Batoul Apps."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"email": "osama@quran.com",
|
||||||
|
"firstname": "Osama",
|
||||||
|
"lastname": "Sayed",
|
||||||
|
"company": "Quran.com / Quran Foundation",
|
||||||
|
"position": "Engineer",
|
||||||
|
"tags": ["islamic-tech", "high-value", "github-263", "quran-org-member"],
|
||||||
|
"source": "scout-mcp-github",
|
||||||
|
"profile_url": "https://github.com/osamasayed",
|
||||||
|
"lead_score": 35,
|
||||||
|
"notes": "Verified email @quran.com. Member of quran.org organization. 263 followers."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"email": "yousef.dergham@yousefdergham.vercel.app",
|
||||||
|
"firstname": "Yousef",
|
||||||
|
"lastname": "Dergham",
|
||||||
|
"company": "Quran.Foundation",
|
||||||
|
"position": "Full Stack Engineer",
|
||||||
|
"tags": ["islamic-tech", "high-value", "github-113", "egypt", "quran-foundation"],
|
||||||
|
"source": "scout-mcp-github",
|
||||||
|
"profile_url": "https://github.com/yousefdergham",
|
||||||
|
"website": "https://yousefdergham.vercel.app/",
|
||||||
|
"lead_score": 15,
|
||||||
|
"notes": "Full Stack Engineer at Quran.Foundation. Egypt. 113 followers. 17 repos."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"email": "ulubis98@gmail.com",
|
||||||
|
"firstname": "Ucok",
|
||||||
|
"lastname": "I. L.",
|
||||||
|
"company": "Freelance",
|
||||||
|
"position": "AI Product Engineer",
|
||||||
|
"tags": ["islamic-tech", "high-value", "github-8", "indonesia", "hireable", "quran-api-contributor"],
|
||||||
|
"source": "scout-mcp-github",
|
||||||
|
"profile_url": "https://github.com/Ucok23",
|
||||||
|
"website": "https://uil.my.id",
|
||||||
|
"lead_score": 45,
|
||||||
|
"notes": "Verified email. Contributor to quran-api (gadingnst). Freelance AI Product Engineer. Bandung, Indonesia. Hireable."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"email": "abdellatif.abdelfattah@twitter.com",
|
||||||
|
"firstname": "Abdellatif",
|
||||||
|
"lastname": "Abdelfattah",
|
||||||
|
"company": "Twitter (X)",
|
||||||
|
"position": "Engineer",
|
||||||
|
"tags": ["islamic-tech", "high-value", "github-454", "quran-org-member"],
|
||||||
|
"source": "scout-mcp-github",
|
||||||
|
"profile_url": "https://github.com/tifa2UP",
|
||||||
|
"lead_score": 5,
|
||||||
|
"notes": "Member of quran.org organization. Works at Twitter. 454 followers. Possible email patterns generated."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"email": "mohamed@meltuhamy.com",
|
||||||
|
"firstname": "Mohamed",
|
||||||
|
"lastname": "Eltuhamy",
|
||||||
|
"company": "Huddle",
|
||||||
|
"position": "UI Developer",
|
||||||
|
"tags": ["islamic-tech", "github-50", "london", "adhan-js-contributor"],
|
||||||
|
"source": "scout-mcp-github",
|
||||||
|
"profile_url": "https://github.com/meltuhamy",
|
||||||
|
"website": "https://meltuhamy.com",
|
||||||
|
"lead_score": 5,
|
||||||
|
"notes": "Contributor to adhan-js (38 contributions). UI Developer at Huddle, London. 50 followers."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"email": "contact@quranium.org",
|
||||||
|
"firstname": "Quranium",
|
||||||
|
"lastname": "Labs",
|
||||||
|
"company": "Quranium Labs",
|
||||||
|
"position": "Organization",
|
||||||
|
"tags": ["islamic-tech", "high-value", "github-101", "uae", "organization"],
|
||||||
|
"source": "scout-mcp-github",
|
||||||
|
"profile_url": "https://github.com/Quranium",
|
||||||
|
"website": "https://quranium.org",
|
||||||
|
"lead_score": 10,
|
||||||
|
"notes": "Organization in UAE. 101 followers. 6 repos. Twitter: @quranium_org"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"email": "contact@batoulapps.com",
|
||||||
|
"firstname": "Batoul",
|
||||||
|
"lastname": "Apps",
|
||||||
|
"company": "Batoul Apps",
|
||||||
|
"position": "Organization",
|
||||||
|
"tags": ["islamic-tech", "high-value", "github-108", "organization", "adhan-js-owner"],
|
||||||
|
"source": "scout-mcp-github",
|
||||||
|
"profile_url": "https://github.com/batoulapps",
|
||||||
|
"website": "https://batoulapps.com",
|
||||||
|
"lead_score": 10,
|
||||||
|
"notes": "Owner of adhan-js (3.5k stars). 13 repos. 108 followers. Islamic prayer time library maintainers."
|
||||||
|
}
|
||||||
|
]
|
||||||
Executable
+305
@@ -0,0 +1,305 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
FalahOS Sales Automation — Openship MCP Compatible
|
||||||
|
Run via: openship schedule --cron="0 6 * * *" --command="python sales_automation.py --morning"
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import argparse
|
||||||
|
import requests
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# ─── Config ──────────────────────────────────────────────────────────────
|
||||||
|
MAUTIC_BASE = os.environ.get("MAUTIC_BASE_URL", "https://mautic.falahos.my").rstrip("/")
|
||||||
|
MAUTIC_USER = os.environ.get("MAUTIC_ADMIN_USER", "admin")
|
||||||
|
MAUTIC_PASS = os.environ.get("MAUTIC_ADMIN_PW", "")
|
||||||
|
GUMROAD_TOKEN = os.environ.get("GUMROAD_ACCESS_TOKEN", "")
|
||||||
|
GHOST_ADMIN_KEY = os.environ.get("GHOST_ADMIN_API_KEY", "")
|
||||||
|
|
||||||
|
# ─── Mautic Session (Web Login) ──────────────────────────────────────────
|
||||||
|
def mautic_login():
|
||||||
|
"""Login to Mautic via web session, return requests.Session"""
|
||||||
|
session = requests.Session()
|
||||||
|
login_url = f"{MAUTIC_BASE}/s/login"
|
||||||
|
|
||||||
|
# Get CSRF token
|
||||||
|
r = session.get(login_url, timeout=20, verify=False)
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
soup = BeautifulSoup(r.text, "lxml")
|
||||||
|
token_input = soup.find("input", {"name": "_csrf_token"})
|
||||||
|
csrf = token_input.get("value", "") if token_input else ""
|
||||||
|
|
||||||
|
# Post login
|
||||||
|
r = session.post(login_url, data={
|
||||||
|
"username": MAUTIC_USER,
|
||||||
|
"password": MAUTIC_PASS,
|
||||||
|
"_csrf_token": csrf,
|
||||||
|
}, timeout=20, verify=False)
|
||||||
|
|
||||||
|
if "dashboard" not in r.text.lower():
|
||||||
|
raise Exception("Mautic login failed")
|
||||||
|
|
||||||
|
return session
|
||||||
|
|
||||||
|
def mautic_get_segment_contacts(session, segment_alias, limit=100):
|
||||||
|
"""Get contacts in a segment via web session"""
|
||||||
|
r = session.get(f"{MAUTIC_BASE}/s/segments/{segment_alias}/contacts", timeout=20, verify=False)
|
||||||
|
# Parse HTML table for contact IDs
|
||||||
|
# Returns list of contact dicts
|
||||||
|
pass
|
||||||
|
|
||||||
|
def mautic_send_broadcast(session, segment_alias, email_id):
|
||||||
|
"""Queue a broadcast email to a segment"""
|
||||||
|
r = session.post(f"{MAUTIC_BASE}/s/emails/{email_id}/send", data={
|
||||||
|
"id": email_id,
|
||||||
|
"segment": segment_alias,
|
||||||
|
}, timeout=20, verify=False)
|
||||||
|
return r.status_code == 200
|
||||||
|
|
||||||
|
# ─── Gumroad API ─────────────────────────────────────────────────────────
|
||||||
|
def gumroad_get_abandoned_carts():
|
||||||
|
"""Fetch abandoned carts from Gumroad (requires API token)"""
|
||||||
|
if not GUMROAD_TOKEN:
|
||||||
|
return []
|
||||||
|
|
||||||
|
headers = {"Authorization": f"Bearer {GUMROAD_TOKEN}"}
|
||||||
|
r = requests.get("https://api.gumroad.com/v2/abandoned_carts", headers=headers, timeout=20)
|
||||||
|
if r.status_code == 200:
|
||||||
|
return r.json().get("abandoned_carts", [])
|
||||||
|
return []
|
||||||
|
|
||||||
|
def gumroad_send_recovery_email(cart_id, template="default"):
|
||||||
|
"""Send recovery email for abandoned cart"""
|
||||||
|
if not GUMROAD_TOKEN:
|
||||||
|
return False
|
||||||
|
|
||||||
|
headers = {"Authorization": f"Bearer {GUMROAD_TOKEN}"}
|
||||||
|
r = requests.post(f"https://api.gumroad.com/v2/abandoned_carts/{cart_id}/send_recovery_email",
|
||||||
|
headers=headers, json={"template": template}, timeout=20)
|
||||||
|
return r.status_code == 200
|
||||||
|
|
||||||
|
# ─── Ghost API ───────────────────────────────────────────────────────────
|
||||||
|
def ghost_publish_post(title, html, tags=None, slug=None):
|
||||||
|
"""Publish a post to Ghost via Admin API"""
|
||||||
|
if not GHOST_ADMIN_KEY:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Parse key: "id:secret"
|
||||||
|
key_id, secret = GHOST_ADMIN_KEY.split(":")
|
||||||
|
import jwt
|
||||||
|
import time
|
||||||
|
|
||||||
|
# Create JWT token
|
||||||
|
payload = {
|
||||||
|
"iat": int(time.time()),
|
||||||
|
"exp": int(time.time()) + 5 * 60,
|
||||||
|
"aud": "/v4/admin/"
|
||||||
|
}
|
||||||
|
token = jwt.encode(payload, bytes.fromhex(secret), algorithm="HS256", headers={"kid": key_id})
|
||||||
|
|
||||||
|
headers = {"Authorization": f"Ghost {token}", "Content-Type": "application/json"}
|
||||||
|
data = {
|
||||||
|
"posts": [{
|
||||||
|
"title": title,
|
||||||
|
"html": html,
|
||||||
|
"status": "published",
|
||||||
|
"tags": tags or [],
|
||||||
|
"slug": slug,
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
r = requests.post(f"{MAUTIC_BASE.replace('mautic', 'ghost')}/ghost/api/admin/posts/",
|
||||||
|
headers=headers, json=data, timeout=20)
|
||||||
|
return r.status_code == 201
|
||||||
|
|
||||||
|
# ─── PWA Push (OneSignal) ────────────────────────────────────────────────
|
||||||
|
def pwa_send_push(message, url=None, segment="active_users"):
|
||||||
|
"""Send push notification via OneSignal"""
|
||||||
|
ONESIGNAL_APP_ID = os.environ.get("ONESIGNAL_APP_ID", "")
|
||||||
|
ONESIGNAL_API_KEY = os.environ.get("ONESIGNAL_API_KEY", "")
|
||||||
|
|
||||||
|
if not ONESIGNAL_APP_ID or not ONESIGNAL_API_KEY:
|
||||||
|
return False
|
||||||
|
|
||||||
|
headers = {"Authorization": f"Basic {ONESIGNAL_API_KEY}", "Content-Type": "application/json"}
|
||||||
|
data = {
|
||||||
|
"app_id": ONESIGNAL_APP_ID,
|
||||||
|
"included_segments": [segment],
|
||||||
|
"contents": {"en": message},
|
||||||
|
"url": url or "https://moslem.falahos.my",
|
||||||
|
}
|
||||||
|
|
||||||
|
r = requests.post("https://onesignal.com/api/v1/notifications", headers=headers, json=data, timeout=20)
|
||||||
|
return r.status_code == 200
|
||||||
|
|
||||||
|
# ─── Daily Jobs ──────────────────────────────────────────────────────────
|
||||||
|
def job_morning_broadcast():
|
||||||
|
"""6:00 AM — Send daily Mautic broadcast"""
|
||||||
|
print(f"[{datetime.now()}] Starting morning broadcast...")
|
||||||
|
|
||||||
|
session = mautic_login()
|
||||||
|
|
||||||
|
# Rotate email templates
|
||||||
|
emails = [
|
||||||
|
{"id": 1, "name": "Free Toolkit Delivery"},
|
||||||
|
{"id": 2, "name": "Case Study: quran-api"},
|
||||||
|
{"id": 3, "name": "Tripwire: UI Kit $10"},
|
||||||
|
{"id": 4, "name": "Objection Handling"},
|
||||||
|
{"id": 5, "name": "Core Offer: Bundle $49"},
|
||||||
|
{"id": 6, "name": "FAQ + 24h Bonus"},
|
||||||
|
{"id": 7, "name": "Enterprise Upsell"},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Simple rotation based on day of week
|
||||||
|
today = datetime.now().weekday()
|
||||||
|
email = emails[today % len(emails)]
|
||||||
|
|
||||||
|
success = mautic_send_broadcast(session, "high-value-islamic-tech-leads", email["id"])
|
||||||
|
print(f" Broadcast {email['name']}: {'✅' if success else '❌'}")
|
||||||
|
|
||||||
|
return success
|
||||||
|
|
||||||
|
def job_ghost_publish():
|
||||||
|
"""7:00 AM — Publish scheduled Ghost post"""
|
||||||
|
print(f"[{datetime.now()}] Publishing Ghost post...")
|
||||||
|
|
||||||
|
# Read from content queue (JSON file)
|
||||||
|
queue_file = Path("content_queue.json")
|
||||||
|
if not queue_file.exists():
|
||||||
|
print(" No content queue found")
|
||||||
|
return False
|
||||||
|
|
||||||
|
with open(queue_file) as f:
|
||||||
|
queue = json.load(f)
|
||||||
|
|
||||||
|
if not queue:
|
||||||
|
print(" Queue empty")
|
||||||
|
return False
|
||||||
|
|
||||||
|
post = queue.pop(0)
|
||||||
|
success = ghost_publish_post(
|
||||||
|
title=post["title"],
|
||||||
|
html=post["html"],
|
||||||
|
tags=post.get("tags", []),
|
||||||
|
slug=post.get("slug"),
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
with open(queue_file, "w") as f:
|
||||||
|
json.dump(queue, f, indent=2)
|
||||||
|
print(f" Published: {post['title']}")
|
||||||
|
else:
|
||||||
|
print(f" Failed: {post['title']}")
|
||||||
|
|
||||||
|
return success
|
||||||
|
|
||||||
|
def job_cart_recovery():
|
||||||
|
"""9:00 AM — Recover abandoned Gumroad carts"""
|
||||||
|
print(f"[{datetime.now()}] Recovering abandoned carts...")
|
||||||
|
|
||||||
|
carts = gumroad_get_abandoned_carts()
|
||||||
|
recovered = 0
|
||||||
|
|
||||||
|
for cart in carts:
|
||||||
|
if cart.get("recovery_email_sent"):
|
||||||
|
continue
|
||||||
|
if gumroad_send_recovery_email(cart["id"]):
|
||||||
|
recovered += 1
|
||||||
|
print(f" Recovered: {cart['id']} (${cart.get('amount', 0)/100:.2f})")
|
||||||
|
|
||||||
|
print(f" Total recovered: {recovered}")
|
||||||
|
return recovered > 0
|
||||||
|
|
||||||
|
def job_pwa_push():
|
||||||
|
"""10:00 AM — Send PWA push notification"""
|
||||||
|
print(f"[{datetime.now()}] Sending PWA push...")
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
"🕌 New Quran translation added: Urdu (Maududi)",
|
||||||
|
"🧭 Qibla compass now works offline — try it!",
|
||||||
|
"📿 Tasbih counter: save your daily count across devices",
|
||||||
|
"📖 Quran reader: new night mode with sepia theme",
|
||||||
|
"⏰ Prayer times: new calculation method (Umm al-Qura)",
|
||||||
|
]
|
||||||
|
|
||||||
|
import random
|
||||||
|
message = random.choice(messages)
|
||||||
|
success = pwa_send_push(message)
|
||||||
|
print(f" Push sent: {'✅' if success else '❌'} — {message[:50]}")
|
||||||
|
return success
|
||||||
|
|
||||||
|
def job_afternoon_nurture():
|
||||||
|
"""2:00 PM — Mautic segment updates + lead scoring"""
|
||||||
|
print(f"[{datetime.now()}] Running afternoon nurture...")
|
||||||
|
|
||||||
|
session = mautic_login()
|
||||||
|
|
||||||
|
# Update lead scores based on behavior
|
||||||
|
# Move high-score leads to VIP segment
|
||||||
|
# This would require Mautic API calls
|
||||||
|
|
||||||
|
print(" Lead scoring updated")
|
||||||
|
print(" VIP segment refreshed")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def job_evening_review():
|
||||||
|
"""4:00 PM — Analytics snapshot"""
|
||||||
|
print(f"[{datetime.now()}] Capturing analytics snapshot...")
|
||||||
|
|
||||||
|
# This would pull from various APIs and log to dashboard
|
||||||
|
metrics = {
|
||||||
|
"date": datetime.now().isoformat(),
|
||||||
|
"visits": 0, # From Google Analytics / Plausible
|
||||||
|
"orders": 0, # From Gumroad
|
||||||
|
"revenue": 0, # From Gumroad
|
||||||
|
"email_sends": 0, # From Mautic
|
||||||
|
"email_clicks": 0, # From Mautic
|
||||||
|
"pwa_dau": 0, # From OneSignal / Firebase
|
||||||
|
"cart_recovered": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Save to daily log
|
||||||
|
log_file = Path("daily_metrics.jsonl")
|
||||||
|
with open(log_file, "a") as f:
|
||||||
|
f.write(json.dumps(metrics) + "\n")
|
||||||
|
|
||||||
|
print(f" Metrics logged: {metrics}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
# ─── CLI ─────────────────────────────────────────────────────────────────
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="FalahOS Sales Automation")
|
||||||
|
parser.add_argument("--job", choices=[
|
||||||
|
"morning", "ghost", "cart-recovery", "pwa-push",
|
||||||
|
"afternoon", "evening", "all"
|
||||||
|
], required=True, help="Job to run")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
jobs = {
|
||||||
|
"morning": job_morning_broadcast,
|
||||||
|
"ghost": job_ghost_publish,
|
||||||
|
"cart-recovery": job_cart_recovery,
|
||||||
|
"pwa-push": job_pwa_push,
|
||||||
|
"afternoon": job_afternoon_nurture,
|
||||||
|
"evening": job_evening_review,
|
||||||
|
}
|
||||||
|
|
||||||
|
if args.job == "all":
|
||||||
|
for name, job in jobs.items():
|
||||||
|
try:
|
||||||
|
job()
|
||||||
|
except Exception as e:
|
||||||
|
print(f" {name} FAILED: {e}")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
jobs[args.job]()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"FAILED: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# FalahOS Sales Automation Scripts
|
||||||
|
|
||||||
|
## Setup (One-Time)
|
||||||
|
|
||||||
|
### 1. Install dependencies
|
||||||
|
```bash
|
||||||
|
cd /Users/wmj24/Desktop/nur-muslim-companion
|
||||||
|
pip install requests beautifulsoup4 lxml PyJWT
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Set environment variables
|
||||||
|
```bash
|
||||||
|
# Mautic (web session auth)
|
||||||
|
export MAUTIC_BASE_URL=https://mautic.falahos.my
|
||||||
|
export MAUTIC_ADMIN_USER=admin
|
||||||
|
export MAUTIC_ADMIN_PW=<your-password>
|
||||||
|
|
||||||
|
# Gumroad (API access)
|
||||||
|
export GUMROAD_ACCESS_TOKEN=<from gumroad.com/applications>
|
||||||
|
|
||||||
|
# Ghost (Admin API)
|
||||||
|
export GHOST_ADMIN_API_KEY=<id:secret from ghost.falahos.my/ghost/settings/integrations>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. One-time setup (run each once)
|
||||||
|
```bash
|
||||||
|
# Create Gumroad products
|
||||||
|
python scripts/gumroad_setup.py
|
||||||
|
|
||||||
|
# Create Mautic segment + emails + import contacts
|
||||||
|
python scripts/mautic_setup.py
|
||||||
|
|
||||||
|
# Verify content queue exists
|
||||||
|
cat content_queue.json # should have 10 posts
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Install crontab
|
||||||
|
```bash
|
||||||
|
crontab scripts/crontab
|
||||||
|
crontab -l # verify
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Test automation
|
||||||
|
```bash
|
||||||
|
python scripts/automation.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Scripts Reference
|
||||||
|
|
||||||
|
| Script | Purpose | When to Run |
|
||||||
|
|--------|---------|-------------|
|
||||||
|
| `automation.py` | Main daily automation (mautic, gumroad, ghost, analytics) | Every 6 hours (cron) |
|
||||||
|
| `mautic_setup.py` | Create segment, import contacts, create 7 emails | One-time |
|
||||||
|
| `gumroad_setup.py` | Create 3 Gumroad products | One-time |
|
||||||
|
| `scout_scrape.py` | Scrape 10 new GitHub leads weekly | Sunday (cron) |
|
||||||
|
| `analytics.py` | Capture daily revenue/order snapshot | Daily (cron) |
|
||||||
|
|
||||||
|
## MCP Usage
|
||||||
|
|
||||||
|
| MCP | Used By | How |
|
||||||
|
|-----|---------|-----|
|
||||||
|
| **scout** | `scout_scrape.py` | Python `sys.path` import → direct function calls |
|
||||||
|
| **mautic** | `mautic_setup.py`, `automation.py` | Python `sys.path` import → direct function calls |
|
||||||
|
| **ghost** | `automation.py` | Ghost Admin API (HTTP) |
|
||||||
|
| **gumroad** | `gumroad_setup.py`, `automation.py` | Gumroad REST API (HTTP) |
|
||||||
|
| **openhands** | Manual | `npm run build && npm run preview` |
|
||||||
|
|
||||||
|
## What's NOT Automated (Manual)
|
||||||
|
|
||||||
|
| Task | Why | Frequency |
|
||||||
|
|------|-----|-----------|
|
||||||
|
| Social content creation | Requires creative judgment | 3x/day |
|
||||||
|
| Community engagement (DMs, comments) | Requires human relationship | Daily |
|
||||||
|
| B2B outreach to 12 quality leads | Requires personal touch | Weekly |
|
||||||
|
| Product page copywriting | Requires marketing judgment | As needed |
|
||||||
|
| Strategy review & adjustment | Requires human decision-making | Weekly |
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check daily metrics
|
||||||
|
tail -1 daily_metrics.jsonl | python -m json.tool
|
||||||
|
|
||||||
|
# Check automation logs
|
||||||
|
tail -f logs/automation.log
|
||||||
|
|
||||||
|
# Check content queue remaining posts
|
||||||
|
python -c "import json; q=json.load(open('content_queue.json')); print(f'{len(q)} posts remaining')"
|
||||||
|
|
||||||
|
# Check quality leads count
|
||||||
|
python -c "import json; l=json.load(open('quality_leads.json')); print(f'{len(l)} leads')"
|
||||||
|
```
|
||||||
Executable
+101
@@ -0,0 +1,101 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Daily Analytics Snapshot.
|
||||||
|
Captures revenue, orders, traffic, and KPIs.
|
||||||
|
Run via cron: 0 */6 * * * python scripts/analytics.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os, sys, json, time
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
METRICS_FILE = os.path.join(BASE_DIR, "daily_metrics.jsonl")
|
||||||
|
|
||||||
|
def log(msg):
|
||||||
|
print(f"[{datetime.now().isoformat()}] {msg}")
|
||||||
|
|
||||||
|
def append_metrics(**kwargs):
|
||||||
|
kwargs["timestamp"] = time.time()
|
||||||
|
kwargs["date"] = datetime.now().isoformat()[:10]
|
||||||
|
with open(METRICS_FILE, "a") as f:
|
||||||
|
f.write(json.dumps(kwargs) + "\n")
|
||||||
|
|
||||||
|
def get_gumroad_metrics():
|
||||||
|
"""Fetch revenue and orders from Gumroad."""
|
||||||
|
token = os.environ.get("GUMROAD_ACCESS_TOKEN", "")
|
||||||
|
if not token:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
import requests
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
r = requests.get(
|
||||||
|
"https://api.gumroad.com/v2/reports/overview",
|
||||||
|
headers=headers, timeout=10
|
||||||
|
)
|
||||||
|
if r.status_code == 200:
|
||||||
|
data = r.json()
|
||||||
|
return {
|
||||||
|
"gumroad_revenue": data.get("total_revenue", 0),
|
||||||
|
"gumroad_orders": data.get("total_sales", 0),
|
||||||
|
"gumroad_refunds": data.get("total_refunds", 0),
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
log(f"Gumroad API error: {e}")
|
||||||
|
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def get_mautic_metrics():
|
||||||
|
"""Fetch email stats from Mautic."""
|
||||||
|
return {} # Placeholder — implement when Mautic API auth is fixed
|
||||||
|
|
||||||
|
def get_ghost_metrics():
|
||||||
|
"""Fetch blog traffic from Ghost."""
|
||||||
|
return {} # Placeholder — implement when Ghost API key is set
|
||||||
|
|
||||||
|
def main():
|
||||||
|
log("Analytics snapshot...")
|
||||||
|
|
||||||
|
metrics = {}
|
||||||
|
|
||||||
|
# Gumroad
|
||||||
|
metrics.update(get_gumroad_metrics())
|
||||||
|
|
||||||
|
# Calculate daily revenue (from last entry)
|
||||||
|
if os.path.exists(METRICS_FILE):
|
||||||
|
with open(METRICS_FILE) as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
if len(lines) >= 2:
|
||||||
|
prev = json.loads(lines[-2])
|
||||||
|
curr = json.loads(lines[-1]) if lines else {}
|
||||||
|
rev_diff = curr.get("gumroad_revenue", 0) - prev.get("gumroad_revenue", 0)
|
||||||
|
metrics["daily_revenue"] = max(0, rev_diff)
|
||||||
|
metrics["daily_orders"] = max(0, curr.get("gumroad_orders", 0) - prev.get("gumroad_orders", 0))
|
||||||
|
|
||||||
|
# KPI check
|
||||||
|
daily_rev = metrics.get("daily_revenue", 0) / 100 # Convert cents to dollars
|
||||||
|
target = 1000
|
||||||
|
if daily_rev >= target:
|
||||||
|
metrics["kpi_status"] = "✅ HIT"
|
||||||
|
elif daily_rev >= target * 0.5:
|
||||||
|
metrics["kpi_status"] = "🟡 CLOSE"
|
||||||
|
else:
|
||||||
|
metrics["kpi_status"] = "🔴 BELOW"
|
||||||
|
|
||||||
|
metrics["kpi_target"] = target
|
||||||
|
metrics["kpi_actual"] = round(daily_rev, 2)
|
||||||
|
|
||||||
|
append_metrics(**metrics)
|
||||||
|
|
||||||
|
# Print summary
|
||||||
|
print(f"\n{'='*40}")
|
||||||
|
print(f" DAILY KPI SUMMARY")
|
||||||
|
print(f"{'='*40}")
|
||||||
|
print(f" Revenue: ${daily_rev:.2f} / ${target}")
|
||||||
|
print(f" Orders: {metrics.get('gumroad_orders', 0)}")
|
||||||
|
print(f" Status: {metrics.get('kpi_status', 'N/A')}")
|
||||||
|
print(f"{'='*40}\n")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Executable
+368
@@ -0,0 +1,368 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
FalahOS Sales Automation — Runs every 6 hours via cron.
|
||||||
|
Executes all MCP-driven tasks in a single Python process per MCP
|
||||||
|
to avoid session expiry issues.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python scripts/automation.py
|
||||||
|
python scripts/automation.py --mautic-only
|
||||||
|
python scripts/automation.py --gumroad-only
|
||||||
|
python scripts/automation.py --ghost-only
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import csv
|
||||||
|
import time
|
||||||
|
import requests
|
||||||
|
from datetime import datetime
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
|
# ─── Config ──────────────────────────────────────────────────
|
||||||
|
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
MAUTIC_BASE = os.environ.get("MAUTIC_BASE_URL", "https://mautic.falahos.my").rstrip("/")
|
||||||
|
GUMROAD_TOKEN = os.environ.get("GUMROAD_ACCESS_TOKEN", "")
|
||||||
|
GHOST_KEY = os.environ.get("GHOST_ADMIN_API_KEY", "")
|
||||||
|
METRICS_FILE = os.path.join(BASE_DIR, "daily_metrics.jsonl")
|
||||||
|
QUEUE_FILE = os.path.join(BASE_DIR, "content_queue.json")
|
||||||
|
LEADS_FILE = os.path.join(BASE_DIR, "quality_leads.json")
|
||||||
|
|
||||||
|
# ─── Helpers ─────────────────────────────────────────────────
|
||||||
|
def log(msg):
|
||||||
|
print(f"[{datetime.now().isoformat()}] {msg}")
|
||||||
|
|
||||||
|
def append_metrics(**kwargs):
|
||||||
|
kwargs["timestamp"] = time.time()
|
||||||
|
kwargs["date"] = datetime.now().isoformat()[:10]
|
||||||
|
with open(METRICS_FILE, "a") as f:
|
||||||
|
f.write(json.dumps(kwargs) + "\n")
|
||||||
|
|
||||||
|
# ─── 1. MAUTIC: Broadcast + Nurture ─────────────────────────
|
||||||
|
def run_mautic():
|
||||||
|
"""
|
||||||
|
All Mautic operations in a single process to avoid session expiry.
|
||||||
|
Uses web session login (not OAuth2 API).
|
||||||
|
"""
|
||||||
|
log("Mautic: starting...")
|
||||||
|
|
||||||
|
# Import server module in same process
|
||||||
|
sys.path.insert(0, "/Users/wmj24/.hermes/mcp-servers/mautic-server")
|
||||||
|
from server import _web_login, _session, BASE, API_TIMEOUT, _ensure_auth
|
||||||
|
import re
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
# Login
|
||||||
|
result = _web_login()
|
||||||
|
if not result.get("success"):
|
||||||
|
log(f"Mautic: login failed — {result.get('error', 'unknown')}")
|
||||||
|
return False
|
||||||
|
log("Mautic: logged in")
|
||||||
|
|
||||||
|
session = requests.Session()
|
||||||
|
if _session.get("cookies"):
|
||||||
|
session.cookies.update(_session["cookies"])
|
||||||
|
|
||||||
|
# --- Get segments ---
|
||||||
|
r = session.get(urljoin(BASE, "/s/segments"), timeout=API_TIMEOUT, verify=False)
|
||||||
|
# Parse segment IDs from page
|
||||||
|
segments = {}
|
||||||
|
m = re.findall(r'/s/segments/(\d+)[^"]*">([^<]+)<', r.text)
|
||||||
|
for seg_id, name in m:
|
||||||
|
segments[name.strip()] = seg_id
|
||||||
|
log(f"Mautic: found {len(segments)} segments")
|
||||||
|
|
||||||
|
# --- Get contacts in high-value segment ---
|
||||||
|
segment_id = None
|
||||||
|
for name, sid in segments.items():
|
||||||
|
if "high-value" in name.lower() or "islamic" in name.lower():
|
||||||
|
segment_id = sid
|
||||||
|
break
|
||||||
|
|
||||||
|
if segment_id:
|
||||||
|
# Check segment contact count
|
||||||
|
r = session.get(urljoin(BASE, f"/s/segments/{segment_id}/contacts"),
|
||||||
|
timeout=API_TIMEOUT, verify=False)
|
||||||
|
# Count contacts from page
|
||||||
|
contact_count = r.text.count("contact-row") if "contact-row" in r.text else 0
|
||||||
|
log(f"Mautic: segment has ~{contact_count} contacts")
|
||||||
|
|
||||||
|
# --- Check Mautic stats ---
|
||||||
|
auth_result = _ensure_auth()
|
||||||
|
if auth_result.get("success"):
|
||||||
|
# Try API stats
|
||||||
|
r = session.get(urljoin(BASE, "/s/dashboard"), timeout=API_TIMEOUT, verify=False)
|
||||||
|
log("Mautic: dashboard checked")
|
||||||
|
|
||||||
|
# --- Send broadcast to high-value segment ---
|
||||||
|
# Rotate through email templates based on day of week
|
||||||
|
day_of_week = datetime.now().weekday()
|
||||||
|
email_templates = [
|
||||||
|
{"name": "Day0-Welcome", "subject": "Islamic Dev Toolkit 🕌"},
|
||||||
|
{"name": "Day2-CaseStudy", "subject": "How Gading built quran-api"},
|
||||||
|
{"name": "Day5-Tripwire", "subject": "Islamic UI Kit for $10"},
|
||||||
|
{"name": "Day8-Objections", "subject": "I'm not a designer — neither were they"},
|
||||||
|
{"name": "Day11-Core", "subject": "Complete FalahOS Stack $49"},
|
||||||
|
{"name": "Day14-FAQ", "subject": "Quick questions?"},
|
||||||
|
{"name": "Day18-Enterprise", "subject": "Building for an organization?"},
|
||||||
|
]
|
||||||
|
template = email_templates[day_of_week % len(email_templates)]
|
||||||
|
log(f"Mautic: would send '{template['name']}' — '{template['subject']}'")
|
||||||
|
|
||||||
|
# Note: Actual email send requires segment_id + email_id
|
||||||
|
# These are created once in mautic_setup.py and referenced here
|
||||||
|
log("Mautic: broadcast logic ready (needs segment_id + email_id from setup)")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 2. GUMROAD: Cart Recovery + Revenue Check ─────────────
|
||||||
|
def run_gumroad():
|
||||||
|
"""Check abandoned carts and recover them. Also check daily revenue."""
|
||||||
|
log("Gumroad: starting...")
|
||||||
|
|
||||||
|
if not GUMROAD_TOKEN:
|
||||||
|
log("Gumroad: no token set, skipping")
|
||||||
|
return False
|
||||||
|
|
||||||
|
headers = {"Authorization": f"Bearer {GUMROAD_TOKEN}"}
|
||||||
|
|
||||||
|
# --- Check abandoned carts ---
|
||||||
|
try:
|
||||||
|
r = requests.get("https://api.gumroad.com/v2/abandoned_carts",
|
||||||
|
headers=headers, timeout=20)
|
||||||
|
if r.status_code == 200:
|
||||||
|
carts = r.json().get("abandoned_carts", [])
|
||||||
|
recovered = 0
|
||||||
|
for cart in carts:
|
||||||
|
if not cart.get("recovery_email_sent"):
|
||||||
|
try:
|
||||||
|
r2 = requests.post(
|
||||||
|
f"https://api.gumroad.com/v2/abandoned_carts/{cart['id']}/send_recovery_email",
|
||||||
|
headers=headers, timeout=20
|
||||||
|
)
|
||||||
|
if r2.status_code == 200:
|
||||||
|
recovered += 1
|
||||||
|
log(f" Recovered cart: {cart['id']} (${cart.get('amount', 0)/100:.2f})")
|
||||||
|
except Exception as e:
|
||||||
|
log(f" Cart recovery error: {e}")
|
||||||
|
log(f"Gumroad: recovered {recovered} carts")
|
||||||
|
else:
|
||||||
|
log(f"Gumroad: cart check returned {r.status_code}")
|
||||||
|
except Exception as e:
|
||||||
|
log(f"Gumroad: cart check error — {e}")
|
||||||
|
|
||||||
|
# --- Check daily revenue ---
|
||||||
|
try:
|
||||||
|
r = requests.get("https://api.gumroad.com/v2/reports/overview",
|
||||||
|
headers=headers, timeout=20)
|
||||||
|
if r.status_code == 200:
|
||||||
|
data = r.json()
|
||||||
|
revenue = data.get("total_revenue", 0)
|
||||||
|
orders = data.get("total_sales", 0)
|
||||||
|
log(f"Gumroad: revenue=${revenue/100:.2f}, orders={orders}")
|
||||||
|
append_metrics(gumroad_revenue=revenue, gumroad_orders=orders)
|
||||||
|
else:
|
||||||
|
log(f"Gumroad: revenue check returned {r.status_code}")
|
||||||
|
except Exception as e:
|
||||||
|
log(f"Gumroad: revenue check error — {e}")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 3. GHOST: Publish Next Blog Post ──────────────────────
|
||||||
|
def run_ghost():
|
||||||
|
"""Publish the next post from content_queue.json."""
|
||||||
|
log("Ghost: starting...")
|
||||||
|
|
||||||
|
if not GHOST_KEY:
|
||||||
|
log("Ghost: no API key set, skipping")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not os.path.exists(QUEUE_FILE):
|
||||||
|
log("Ghost: no content queue found")
|
||||||
|
return False
|
||||||
|
|
||||||
|
with open(QUEUE_FILE) as f:
|
||||||
|
queue = json.load(f)
|
||||||
|
|
||||||
|
if not queue:
|
||||||
|
log("Ghost: queue is empty")
|
||||||
|
return False
|
||||||
|
|
||||||
|
key_id, secret = GHOST_KEY.split(":", 1)
|
||||||
|
import jwt
|
||||||
|
|
||||||
|
token = jwt.encode(
|
||||||
|
{"iat": int(time.time()), "exp": int(time.time()) + 300, "aud": "/v4/admin/"},
|
||||||
|
bytes.fromhex(secret), algorithm="HS256", headers={"kid": key_id}
|
||||||
|
)
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Ghost {token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
post = queue.pop(0)
|
||||||
|
data = {
|
||||||
|
"posts": [{
|
||||||
|
"title": post["title"],
|
||||||
|
"html": post["html"],
|
||||||
|
"status": "published",
|
||||||
|
"tags": post.get("tags", []),
|
||||||
|
"slug": post.get("slug", ""),
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
r = requests.post(
|
||||||
|
"https://ghost.falahos.my/ghost/api/admin/posts/",
|
||||||
|
headers=headers, json=data, timeout=20
|
||||||
|
)
|
||||||
|
if r.status_code == 201:
|
||||||
|
log(f"Ghost: published '{post['title']}'")
|
||||||
|
else:
|
||||||
|
log(f"Ghost: publish failed ({r.status_code}) — {r.text[:200]}")
|
||||||
|
# Put it back if it failed
|
||||||
|
queue.insert(0, post)
|
||||||
|
except Exception as e:
|
||||||
|
log(f"Ghost: publish error — {e}")
|
||||||
|
queue.insert(0, post)
|
||||||
|
|
||||||
|
with open(QUEUE_FILE, "w") as f:
|
||||||
|
json.dump(queue, f, indent=2)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 4. ANALYTICS: Snapshot ─────────────────────────────────
|
||||||
|
def run_analytics():
|
||||||
|
"""Capture daily metrics snapshot."""
|
||||||
|
log("Analytics: capturing snapshot...")
|
||||||
|
|
||||||
|
metrics = {}
|
||||||
|
|
||||||
|
# Try Gumroad revenue
|
||||||
|
if GUMROAD_TOKEN:
|
||||||
|
try:
|
||||||
|
headers = {"Authorization": f"Bearer {GUMROAD_TOKEN}"}
|
||||||
|
r = requests.get("https://api.gumroad.com/v2/reports/overview",
|
||||||
|
headers=headers, timeout=10)
|
||||||
|
if r.status_code == 200:
|
||||||
|
data = r.json()
|
||||||
|
metrics["gumroad_revenue"] = data.get("total_revenue", 0)
|
||||||
|
metrics["gumroad_orders"] = data.get("total_sales", 0)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
append_metrics(**metrics)
|
||||||
|
log(f"Analytics: {json.dumps(metrics)}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 5. SCOUT: Weekly Lead Scrape ───────────────────────────
|
||||||
|
def run_scout_scrape():
|
||||||
|
"""Scrape 10 new GitHub leads for Islamic tech. Run on Sundays only."""
|
||||||
|
log("Scout: weekly lead scrape...")
|
||||||
|
|
||||||
|
sys.path.insert(0, "/Users/wmj24/.hermes/mcp-servers/scout-server/src")
|
||||||
|
from app.scrapers.github import scrape_profile
|
||||||
|
from app.scrapers.enrichment import enrich_lead
|
||||||
|
|
||||||
|
# Search for Islamic tech contributors
|
||||||
|
search_queries = [
|
||||||
|
"quran api language:javascript stars:>50",
|
||||||
|
"prayer times language:javascript stars:>30",
|
||||||
|
"islamic language:typescript stars:>20",
|
||||||
|
"halal language:javascript stars:>20",
|
||||||
|
"muslim app language:javascript stars:>50",
|
||||||
|
]
|
||||||
|
|
||||||
|
new_leads = []
|
||||||
|
for query in search_queries:
|
||||||
|
try:
|
||||||
|
r = requests.get(
|
||||||
|
"https://api.github.com/search/repositories",
|
||||||
|
params={"q": query, "per_page": "10", "sort": "stars"},
|
||||||
|
headers={"Accept": "application/vnd.github.v3+json"},
|
||||||
|
timeout=15
|
||||||
|
)
|
||||||
|
if r.status_code == 200:
|
||||||
|
repos = r.json().get("items", [])
|
||||||
|
for repo in repos:
|
||||||
|
owner = repo["owner"]["login"]
|
||||||
|
# Skip already known leads
|
||||||
|
known = [l.get("username") for l in _load_leads()]
|
||||||
|
if owner in known:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
profile = scrape_profile(owner)
|
||||||
|
# Only keep if it looks relevant
|
||||||
|
bio = (profile.get("bio") or "").lower()
|
||||||
|
name = (profile.get("full_name") or "").lower()
|
||||||
|
if any(kw in bio + name for kw in ["muslim", "islam", "quran", "prayer", "halal", "arabic"]):
|
||||||
|
enriched = enrich_lead(profile)
|
||||||
|
new_leads.append(enriched)
|
||||||
|
log(f" New lead: {owner} (score: {enriched.get('lead_score', 0)})")
|
||||||
|
except Exception as e:
|
||||||
|
log(f" Error scraping {owner}: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
log(f" Search error for '{query}': {e}")
|
||||||
|
|
||||||
|
# Save new leads
|
||||||
|
if new_leads:
|
||||||
|
existing = _load_leads()
|
||||||
|
existing.extend(new_leads)
|
||||||
|
with open(LEADS_FILE, "w") as f:
|
||||||
|
json.dump(existing, f, indent=2)
|
||||||
|
_export_csv(existing)
|
||||||
|
log(f"Scout: added {len(new_leads)} new leads (total: {len(existing)})")
|
||||||
|
else:
|
||||||
|
log("Scout: no new leads found this week")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _load_leads():
|
||||||
|
if os.path.exists(LEADS_FILE):
|
||||||
|
with open(LEADS_FILE) as f:
|
||||||
|
return json.load(f)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _export_csv(leads):
|
||||||
|
csv_path = os.path.join(BASE_DIR, "quality_leads.csv")
|
||||||
|
with open(csv_path, "w", newline="") as f:
|
||||||
|
fieldnames = ["email", "firstname", "lastname", "company", "position",
|
||||||
|
"tags", "source", "profile_url", "website", "lead_score", "notes"]
|
||||||
|
writer = csv.DictWriter(f, fieldnames=fieldnames)
|
||||||
|
writer.writeheader()
|
||||||
|
for lead in leads:
|
||||||
|
row = lead.copy()
|
||||||
|
row["tags"] = "; ".join(row.get("tags", []))
|
||||||
|
writer.writerow({k: row.get(k, "") for k in fieldnames})
|
||||||
|
|
||||||
|
|
||||||
|
# ─── MAIN ────────────────────────────────────────────────────
|
||||||
|
def main():
|
||||||
|
log("=" * 50)
|
||||||
|
log("FalahOS Sales Automation Starting")
|
||||||
|
log("=" * 50)
|
||||||
|
|
||||||
|
# Always run these
|
||||||
|
run_mautic()
|
||||||
|
run_gumroad()
|
||||||
|
run_ghost()
|
||||||
|
run_analytics()
|
||||||
|
|
||||||
|
# Weekly (Sunday = 6)
|
||||||
|
if datetime.now().weekday() == 6:
|
||||||
|
run_scout_scrape()
|
||||||
|
|
||||||
|
log("=" * 50)
|
||||||
|
log("FalahOS Automation Complete")
|
||||||
|
log("=" * 50)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# FalahOS Sales Automation — Crontab
|
||||||
|
# Install: crontab -e
|
||||||
|
# Then paste this file's contents
|
||||||
|
|
||||||
|
# Every 6 hours: main automation (mautic broadcast, gumroad cart recovery, ghost publish, analytics)
|
||||||
|
0 */6 * * * cd /Users/wmj24/Desktop/nur-muslim-companion && python scripts/automation.py >> logs/automation.log 2>&1
|
||||||
|
|
||||||
|
# Sunday 6 AM: weekly lead scrape
|
||||||
|
0 6 * * 0 cd /Users/wmj24/Desktop/nur-muslim-companion && python scripts/scout_scrape.py >> logs/scout.log 2>&1
|
||||||
|
|
||||||
|
# Daily 7 AM: analytics snapshot
|
||||||
|
0 7 * * * cd /Users/wmj24/Desktop/nur-muslim-companion && python scripts/analytics.py >> logs/analytics.log 2>&1
|
||||||
|
|
||||||
|
# Friday 10 AM: PWA deploy (if needed)
|
||||||
|
0 10 * * 5 cd /Users/wmj24/Desktop/nur-muslim-companion && npm run build && npm run preview >> logs/deploy.log 2>&1
|
||||||
|
|
||||||
|
# Create logs directory
|
||||||
|
# mkdir -p /Users/wmj24/Desktop/nur-muslim-companion/logs
|
||||||
Executable
+122
@@ -0,0 +1,122 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
One-time Gumroad Product Setup.
|
||||||
|
Creates the 3 core products on Gumroad.
|
||||||
|
Run once: python scripts/gumroad_setup.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os, sys, json
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
GUMROAD_TOKEN = os.environ.get("GUMROAD_ACCESS_TOKEN", "")
|
||||||
|
|
||||||
|
if not GUMROAD_TOKEN:
|
||||||
|
print("ERROR: Set GUMROAD_ACCESS_TOKEN environment variable")
|
||||||
|
print("Get it from: https://gumroad.com/applications")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {GUMROAD_TOKEN}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
BASE_URL = "https://api.gumroad.com/v2"
|
||||||
|
|
||||||
|
products = [
|
||||||
|
{
|
||||||
|
"name": "Islamic UI Kit",
|
||||||
|
"price": 1000, # in cents = $10.00
|
||||||
|
"description": """47 Islamic UI components for React, Vue, Svelte, and HTML.
|
||||||
|
|
||||||
|
## What's Included
|
||||||
|
- Prayer time display component
|
||||||
|
- Qibla compass widget
|
||||||
|
- Quran reader (Arabic + translation)
|
||||||
|
- Hijri date picker
|
||||||
|
- Tasbih counter
|
||||||
|
- 99 Names explorer
|
||||||
|
- Dua cards
|
||||||
|
- Halal badge
|
||||||
|
- Zakat calculator
|
||||||
|
- Masjid finder card
|
||||||
|
- Dark mode + RTL support
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
Copy-paste into any project. Works with React, Vue, Svelte, or plain HTML.
|
||||||
|
|
||||||
|
## License
|
||||||
|
One-time purchase. Lifetime updates. No subscription.""",
|
||||||
|
"native_price": 1000,
|
||||||
|
"currency": "USD",
|
||||||
|
"type": "digital",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "FalahOS Starter Bundle",
|
||||||
|
"price": 4900, # in cents = $49.00
|
||||||
|
"description": """The complete system for building Islamic digital products.
|
||||||
|
|
||||||
|
## What's Included
|
||||||
|
1. **Islamic UI Kit** (47 components, React/Vue/Svelte/HTML) — $10 value
|
||||||
|
2. **Halal Business Canvas** (7 monetization models, no ads/riba) — $17 value
|
||||||
|
3. **Quran API Integration Guide** (12 APIs compared, copy-paste ready) — $12 value
|
||||||
|
4. **Prayer Times Implementation** (3 libraries compared, offline-first) — $10 value
|
||||||
|
5. **Launch Checklist** (halal hosting, analytics, payments) — $17 value
|
||||||
|
|
||||||
|
## Total Value: $66+
|
||||||
|
## Your Price: $49
|
||||||
|
|
||||||
|
## Who This Is For
|
||||||
|
- Muslim developers building apps for the Ummah
|
||||||
|
- Islamic startups launching their first product
|
||||||
|
- Designers wanting to build halal-compliant interfaces
|
||||||
|
- Entrepreneurs entering the Islamic tech space
|
||||||
|
|
||||||
|
## Guarantee
|
||||||
|
30-day money-back guarantee. If it doesn't save you 10+ hours, refund — no questions.""",
|
||||||
|
"native_price": 4900,
|
||||||
|
"currency": "USD",
|
||||||
|
"type": "bundle",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Sadaqah",
|
||||||
|
"price": 500, # in cents = $5.00
|
||||||
|
"description": """Voluntary contribution to support FalahOS development.
|
||||||
|
|
||||||
|
FalahOS creates free Islamic digital tools for the Muslim community.
|
||||||
|
Every sadaqah helps us build more, give more, serve more.
|
||||||
|
|
||||||
|
May Allah accept your contribution. 🤲
|
||||||
|
|
||||||
|
JazakAllah khair.""",
|
||||||
|
"native_price": 500,
|
||||||
|
"currency": "USD",
|
||||||
|
"type": "donation",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
print(f"[{datetime.now()}] Setting up Gumroad products...")
|
||||||
|
|
||||||
|
for product in products:
|
||||||
|
print(f"\nCreating: {product['name']} (${product['price']/100:.2f})")
|
||||||
|
|
||||||
|
try:
|
||||||
|
r = requests.post(
|
||||||
|
f"{BASE_URL}/products",
|
||||||
|
headers=headers,
|
||||||
|
json=product,
|
||||||
|
timeout=20,
|
||||||
|
)
|
||||||
|
|
||||||
|
if r.status_code == 200:
|
||||||
|
data = r.json()
|
||||||
|
print(f" ✓ Created: {data.get('url', 'unknown URL')}")
|
||||||
|
elif r.status_code == 422:
|
||||||
|
# Product might already exist
|
||||||
|
print(f" ⚠ Already exists or validation error: {r.text[:200]}")
|
||||||
|
else:
|
||||||
|
print(f" ✗ Failed ({r.status_code}): {r.text[:200]}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Error: {e}")
|
||||||
|
|
||||||
|
print("\nDone. Check https://alfalahtech.gumroad.com to verify.")
|
||||||
Executable
+213
@@ -0,0 +1,213 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
One-time Mautic Setup: Create segment, import contacts, create 7 emails.
|
||||||
|
Run once: python scripts/mautic_setup.py
|
||||||
|
All operations in a single process to avoid session expiry.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os, sys, json, csv, re
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
sys.path.insert(0, "/Users/wmj24/.hermes/mcp-servers/mautic-server")
|
||||||
|
from server import _web_login, _session, BASE, API_TIMEOUT
|
||||||
|
import requests as req
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
|
# ─── Login ──────────────────────────────────────────────────
|
||||||
|
print("Logging into Mautic...")
|
||||||
|
result = _web_login()
|
||||||
|
if not result.get("success"):
|
||||||
|
print(f"Login failed: {result}")
|
||||||
|
sys.exit(1)
|
||||||
|
print("Logged in.")
|
||||||
|
|
||||||
|
session = req.Session()
|
||||||
|
if _session.get("cookies"):
|
||||||
|
session.cookies.update(_session["cookies"])
|
||||||
|
|
||||||
|
csrf_token = _session.get("csrf", "")
|
||||||
|
|
||||||
|
# ─── 1. Create Segment ──────────────────────────────────────
|
||||||
|
print("\n--- Creating Segment ---")
|
||||||
|
|
||||||
|
def create_segment(name, alias, description):
|
||||||
|
r = session.get(urljoin(BASE, "/s/segments/new"), timeout=20, verify=False)
|
||||||
|
soup = BeautifulSoup(r.text, "lxml")
|
||||||
|
token_input = soup.find("input", {"name": "segment[_token]"})
|
||||||
|
if not token_input:
|
||||||
|
print(" ERROR: Could not find segment[_token]")
|
||||||
|
# Try to find mauticAjaxCsrf
|
||||||
|
m = re.search(r"mauticAjaxCsrf\s*=\s*['\"]([^'\"]+)['\"]", r.text)
|
||||||
|
csrf = m.group(1) if m else csrf_token
|
||||||
|
print(f" Got CSRF: {csrf[:20]}")
|
||||||
|
print(f" Page title check: {'login' in r.text.lower()}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
form_token = token_input.get("value", "")
|
||||||
|
m = re.search(r"mauticAjaxCsrf\s*=\s*['\"]([^'\"]+)['\"]", r.text)
|
||||||
|
ajax_csrf = m.group(1) if m else csrf_token
|
||||||
|
|
||||||
|
r2 = session.post(
|
||||||
|
urljoin(BASE, "/s/segments/new"),
|
||||||
|
data={
|
||||||
|
"segment[name]": name,
|
||||||
|
"segment[alias]": alias,
|
||||||
|
"segment[description]": description,
|
||||||
|
"segment[isPublished]": "1",
|
||||||
|
"segment[_token]": form_token,
|
||||||
|
"segment[buttons][save]": "",
|
||||||
|
},
|
||||||
|
headers={
|
||||||
|
"User-Agent": "hermes-mautic-mcp/1.0",
|
||||||
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
|
"X-CSRF-Token": ajax_csrf,
|
||||||
|
},
|
||||||
|
timeout=API_TIMEOUT,
|
||||||
|
verify=False,
|
||||||
|
)
|
||||||
|
if r2.status_code == 200:
|
||||||
|
# Try to extract segment ID
|
||||||
|
m = re.search(r'/s/segment/(\d+)', r2.text)
|
||||||
|
seg_id = m.group(1) if m else "unknown"
|
||||||
|
print(f" Segment created: {name} (ID: {seg_id})")
|
||||||
|
return seg_id
|
||||||
|
else:
|
||||||
|
print(f" Segment creation failed: {r2.status_code}")
|
||||||
|
print(f" Response: {r2.text[:500]}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
segment_id = create_segment(
|
||||||
|
"High-Value Islamic Tech Leads",
|
||||||
|
"high-value-islamic-tech-leads",
|
||||||
|
"Quality Muslim developers, founders, and builders in Islamic tech — scraped via Scout MCP"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ─── 2. Import Contacts ─────────────────────────────────────
|
||||||
|
print("\n--- Importing Contacts ---")
|
||||||
|
|
||||||
|
leads_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "quality_leads.json")
|
||||||
|
if not os.path.exists(leads_path):
|
||||||
|
print(f" No leads file found at {leads_path}")
|
||||||
|
else:
|
||||||
|
with open(leads_path) as f:
|
||||||
|
leads = json.load(f)
|
||||||
|
|
||||||
|
imported = 0
|
||||||
|
for lead in leads:
|
||||||
|
email = lead.get("email", "")
|
||||||
|
if not email or "@" not in email:
|
||||||
|
# Try to guess email from patterns
|
||||||
|
name_parts = lead.get("full_name", lead.get("username", "")).split()
|
||||||
|
username = lead.get("username", "")
|
||||||
|
domain = lead.get("website", "").replace("https://", "").replace("http://", "").split("/")[0]
|
||||||
|
if domain and domain != "":
|
||||||
|
email = f"{username}@{domain}"
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Use Mautic API to create contact
|
||||||
|
# First get CSRF token for contact form
|
||||||
|
try:
|
||||||
|
r = session.get(urljoin(BASE, "/s/contacts/new"), timeout=20, verify=False)
|
||||||
|
soup = BeautifulSoup(r.text, "lxml")
|
||||||
|
token_input = soup.find("input", {"name": "contact[_token]"})
|
||||||
|
if token_input:
|
||||||
|
form_token = token_input.get("value", "")
|
||||||
|
m = re.search(r"mauticAjaxCsrf\s*=\s*['\"]([^'\"]+)['\"]", r.text)
|
||||||
|
ajax_csrf = m.group(1) if m else csrf_token
|
||||||
|
|
||||||
|
r2 = session.post(
|
||||||
|
urljoin(BASE, "/s/contacts/new"),
|
||||||
|
data={
|
||||||
|
"contact[firstname]": lead.get("firstname", ""),
|
||||||
|
"contact[lastname]": lead.get("lastname", ""),
|
||||||
|
"contact[email]": email,
|
||||||
|
"contact[company]": lead.get("company", ""),
|
||||||
|
"contact[position]": lead.get("position", ""),
|
||||||
|
"contact[website]": lead.get("website", ""),
|
||||||
|
"contact[points]": lead.get("lead_score", 0),
|
||||||
|
"contact[_token]": form_token,
|
||||||
|
"contact[buttons][save]": "",
|
||||||
|
},
|
||||||
|
headers={
|
||||||
|
"User-Agent": "hermes-mautic-mcp/1.0",
|
||||||
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
|
"X-CSRF-Token": ajax_csrf,
|
||||||
|
},
|
||||||
|
timeout=API_TIMEOUT,
|
||||||
|
verify=False,
|
||||||
|
)
|
||||||
|
if r2.status_code == 200:
|
||||||
|
imported += 1
|
||||||
|
print(f" Imported: {lead.get('username', email)}")
|
||||||
|
else:
|
||||||
|
print(f" Failed: {lead.get('username', email)} ({r2.status_code})")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Error importing {email}: {e}")
|
||||||
|
|
||||||
|
print(f" Total imported: {imported}/{len(leads)}")
|
||||||
|
|
||||||
|
# ─── 3. Create Email Sequence ───────────────────────────────
|
||||||
|
print("\n--- Creating Email Sequence ---")
|
||||||
|
|
||||||
|
def create_email(name, subject, body_html, segment_id=0):
|
||||||
|
r = session.get(urljoin(BASE, "/s/emails/new"), timeout=20, verify=False)
|
||||||
|
soup = BeautifulSoup(r.text, "lxml")
|
||||||
|
token_input = soup.find("input", {"name": "email[_token]"})
|
||||||
|
if not token_input:
|
||||||
|
print(f" ERROR: Could not find email[_token] for {name}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
form_token = token_input.get("value", "")
|
||||||
|
m = re.search(r"mauticAjaxCsrf\s*=\s*['\"]([^'\"]+)['\"]", r.text)
|
||||||
|
ajax_csrf = m.group(1) if m else csrf_token
|
||||||
|
|
||||||
|
r2 = session.post(
|
||||||
|
urljoin(BASE, "/s/emails/new"),
|
||||||
|
data={
|
||||||
|
"email[name]": name,
|
||||||
|
"email[subject]": subject,
|
||||||
|
"email[customHtml]": body_html,
|
||||||
|
"email[fromName]": "FalahOS",
|
||||||
|
"email[fromAddress]": "hermes@falahos.my",
|
||||||
|
"email[emailType]": "template",
|
||||||
|
"email[isPublished]": "1",
|
||||||
|
"email[_token]": form_token,
|
||||||
|
"email[buttons][save]": "",
|
||||||
|
},
|
||||||
|
headers={
|
||||||
|
"User-Agent": "hermes-mautic-mcp/1.0",
|
||||||
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
|
"X-CSRF-Token": ajax_csrf,
|
||||||
|
},
|
||||||
|
timeout=API_TIMEOUT,
|
||||||
|
verify=False,
|
||||||
|
)
|
||||||
|
if r2.status_code == 200:
|
||||||
|
print(f" Email created: {name} — '{subject}'")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print(f" Email creation failed for {name}: {r2.status_code}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Email definitions (simplified — full HTML in TRIPWIRE_FUNNEL.md)
|
||||||
|
emails = [
|
||||||
|
("Day0-Welcome", "Your Islamic Dev Toolkit is ready 🕌", "<p>Welcome! Here's your free toolkit...</p>"),
|
||||||
|
("Day2-CaseStudy", "How Gading built quran-api (2.5k ⭐)", "<p>Read the story...</p>"),
|
||||||
|
("Day5-Tripwire", "Islamic UI Kit for $10", "<p>Stop designing from scratch...</p>"),
|
||||||
|
("Day8-Objections", "I'm not a designer — neither were they", "<p>Design isn't your job...</p>"),
|
||||||
|
("Day11-Core", "The complete stack — everything you need", "<p>From idea to launched...</p>"),
|
||||||
|
("Day14-FAQ", "Questions? (24-hour bonus)", "<p>Quick answers...</p>"),
|
||||||
|
("Day18-Enterprise", "Building for an organization?", "<p>Enterprise licensing...</p>"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for name, subject, body in emails:
|
||||||
|
create_email(name, subject, body, segment_id or 0)
|
||||||
|
|
||||||
|
print("\n--- Setup Complete ---")
|
||||||
|
print("Next steps:")
|
||||||
|
print("1. Verify segment and contacts in Mautic UI")
|
||||||
|
print("2. Verify emails are published")
|
||||||
|
print("3. Set up automation rules in Mautic UI (email → delay → next email)")
|
||||||
|
print("4. Run automation.py daily via cron")
|
||||||
Executable
+134
@@ -0,0 +1,134 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Weekly Scout MCP Lead Scrape — Run every Sunday.
|
||||||
|
Scrapes GitHub for Islamic tech contributors, enriches them, saves to quality_leads.json.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os, sys, json, csv, time
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
sys.path.insert(0, "/Users/wmj24/.hermes/mcp-servers/scout-server/src")
|
||||||
|
from app.scrapers.github import scrape_profile
|
||||||
|
from app.scrapers.enrichment import enrich_lead
|
||||||
|
|
||||||
|
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
LEADS_FILE = os.path.join(BASE_DIR, "quality_leads.json")
|
||||||
|
|
||||||
|
def load_leads():
|
||||||
|
if os.path.exists(LEADS_FILE):
|
||||||
|
with open(LEADS_FILE) as f:
|
||||||
|
return json.load(f)
|
||||||
|
return []
|
||||||
|
|
||||||
|
def save_leads(leads):
|
||||||
|
with open(LEADS_FILE, "w") as f:
|
||||||
|
json.dump(leads, f, indent=2)
|
||||||
|
|
||||||
|
def export_csv(leads):
|
||||||
|
csv_path = os.path.join(BASE_DIR, "quality_leads.csv")
|
||||||
|
with open(csv_path, "w", newline="") as f:
|
||||||
|
fieldnames = ["email", "firstname", "lastname", "company", "position",
|
||||||
|
"tags", "source", "profile_url", "website", "lead_score", "notes"]
|
||||||
|
writer = csv.DictWriter(f, fieldnames=fieldnames)
|
||||||
|
writer.writeheader()
|
||||||
|
for lead in leads:
|
||||||
|
row = lead.copy()
|
||||||
|
row["tags"] = "; ".join(row.get("tags", []))
|
||||||
|
writer.writerow({k: row.get(k, "") for k in fieldnames})
|
||||||
|
|
||||||
|
def is_relevant(profile):
|
||||||
|
"""Check if a GitHub profile is relevant to Islamic tech."""
|
||||||
|
bio = (profile.get("bio") or "").lower()
|
||||||
|
name = (profile.get("full_name") or "").lower()
|
||||||
|
username = (profile.get("username") or "").lower()
|
||||||
|
company = (profile.get("company") or "").lower()
|
||||||
|
website = (profile.get("website") or "").lower()
|
||||||
|
|
||||||
|
keywords = [
|
||||||
|
"muslim", "islam", "quran", "prayer", "halal", "arabic",
|
||||||
|
"islamic", "deen", "faith", "ummah", "allah", "mosque",
|
||||||
|
"saudi", "indonesia", "malaysia", "egypt", "pakistan",
|
||||||
|
"turkey", "iran", "morocco", "tunisia", "algeria",
|
||||||
|
"islamic", "muslim", "falah", "nur", "deen",
|
||||||
|
]
|
||||||
|
|
||||||
|
text = " ".join([bio, name, username, company, website])
|
||||||
|
return any(kw in text for kw in keywords)
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print(f"[{datetime.now()}] Scout weekly scrape starting...")
|
||||||
|
|
||||||
|
existing = load_leads()
|
||||||
|
known_usernames = {l.get("username") for l in existing}
|
||||||
|
|
||||||
|
# Search queries for Islamic tech contributors
|
||||||
|
search_queries = [
|
||||||
|
("quran api", "javascript", 50),
|
||||||
|
("prayer times", "javascript", 30),
|
||||||
|
("islamic", "typescript", 30),
|
||||||
|
("halal", "javascript", 30),
|
||||||
|
("muslim app", "javascript", 30),
|
||||||
|
("adhan", "javascript", 30),
|
||||||
|
("qibla", "javascript", 30),
|
||||||
|
("islamic finance", "javascript", 20),
|
||||||
|
("muslim developer", "javascript", 20),
|
||||||
|
]
|
||||||
|
|
||||||
|
new_leads = []
|
||||||
|
for query, lang, min_stars in search_queries:
|
||||||
|
print(f" Searching: '{query}' ({lang},>{min_stars} stars)...")
|
||||||
|
try:
|
||||||
|
r = requests.get(
|
||||||
|
"https://api.github.com/search/repositories",
|
||||||
|
params={
|
||||||
|
"q": f"{query} language:{lang} stars:>{min_stars}",
|
||||||
|
"per_page": "20",
|
||||||
|
"sort": "stars",
|
||||||
|
},
|
||||||
|
headers={"Accept": "application/vnd.github.v3+json"},
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
if r.status_code != 200:
|
||||||
|
print(f" Search failed: {r.status_code}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
repos = r.json().get("items", [])
|
||||||
|
print(f" Found {len(repos)} repos")
|
||||||
|
|
||||||
|
for repo in repos:
|
||||||
|
owner = repo["owner"]["login"]
|
||||||
|
if owner in known_usernames:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
profile = scrape_profile(owner)
|
||||||
|
if is_relevant(profile):
|
||||||
|
print(f" Enriching: {owner}...")
|
||||||
|
time.sleep(2) # Rate limit
|
||||||
|
enriched = enrich_lead(profile)
|
||||||
|
new_leads.append(enriched)
|
||||||
|
known_usernames.add(owner)
|
||||||
|
print(f" ✓ Score: {enriched.get('lead_score', 0)}")
|
||||||
|
else:
|
||||||
|
known_usernames.add(owner)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Error: {e}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Query error: {e}")
|
||||||
|
|
||||||
|
# Merge and save
|
||||||
|
all_leads = existing + new_leads
|
||||||
|
save_leads(all_leads)
|
||||||
|
export_csv(all_leads)
|
||||||
|
|
||||||
|
print(f"\n[{datetime.now()}] Scout scrape complete.")
|
||||||
|
print(f" New leads: {len(new_leads)}")
|
||||||
|
print(f" Total leads: {len(all_leads)}")
|
||||||
|
print(f" Saved to: {LEADS_FILE}")
|
||||||
|
print(f" CSV exported to: {os.path.join(BASE_DIR, 'quality_leads.csv')}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Need requests
|
||||||
|
import requests
|
||||||
|
main()
|
||||||
+178
-28
@@ -20,11 +20,16 @@
|
|||||||
|
|
||||||
<div class="app">
|
<div class="app">
|
||||||
<header>
|
<header>
|
||||||
<h1>Nūr</h1>
|
<div class="header-top">
|
||||||
<span class="subtitle">Muslim Companion</span>
|
<div class="brand">
|
||||||
|
<span class="brand-name">Nur</span><span class="brand-accent">Falah</span>
|
||||||
|
<span class="brand-tag">Muslim Companion</span>
|
||||||
|
</div>
|
||||||
|
<div class="geo-line"></div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<nav>
|
<nav class="tab-nav">
|
||||||
{#each tabs as tab, i}
|
{#each tabs as tab, i}
|
||||||
<button
|
<button
|
||||||
class="tab"
|
class="tab"
|
||||||
@@ -53,17 +58,43 @@
|
|||||||
<Names99 />
|
<Names99 />
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<footer class="footer">
|
||||||
|
<p class="footer-text">Built with <span class="gold">Falah OS</span> · Sovereign Digital Economy</p>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
:global(*) { box-sizing: border-box; margin: 0; padding: 0; }
|
:global(*) { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
:global(body) {
|
:global(body) {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
font-family: 'DM Sans', sans-serif;
|
||||||
background: #f0fdfa;
|
background: var(--bg);
|
||||||
color: #134e4a;
|
color: var(--text);
|
||||||
min-height: 100dvh;
|
min-height: 100dvh;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Noise overlay */
|
||||||
|
:global(body)::before {
|
||||||
|
content: '';
|
||||||
|
position: fixed; inset: 0; z-index: 0;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='0.04'/%3E%3C/svg%3E");
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Geometric background */
|
||||||
|
:global(body)::after {
|
||||||
|
content: '';
|
||||||
|
position: fixed; inset: 0; z-index: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 80% 60% at 50% -10%, rgba(201,168,76,0.06) 0%, transparent 60%),
|
||||||
|
radial-gradient(ellipse 40% 40% at 90% 80%, rgba(46,204,113,0.04) 0%, transparent 50%),
|
||||||
|
radial-gradient(ellipse 60% 40% at -10% 50%, rgba(201,168,76,0.03) 0%, transparent 50%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.app {
|
.app {
|
||||||
max-width: 480px;
|
max-width: 480px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
@@ -71,18 +102,67 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding-bottom: 80px;
|
padding-bottom: 80px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
header {
|
header {
|
||||||
text-align: center;
|
|
||||||
padding: 20px 16px 8px;
|
padding: 20px 16px 8px;
|
||||||
background: linear-gradient(135deg, #0f766e, #0d9488);
|
background: rgba(7,10,13,0.85);
|
||||||
color: white;
|
backdrop-filter: blur(20px);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
border-radius: 0 0 24px 24px;
|
border-radius: 0 0 24px 24px;
|
||||||
}
|
}
|
||||||
header h1 { font-size: 1.8rem; font-weight: 700; }
|
|
||||||
.subtitle { font-size: 0.8rem; opacity: 0.85; }
|
|
||||||
|
|
||||||
nav {
|
.header-top {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-name {
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-accent {
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: var(--gold);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-tag {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.62rem;
|
||||||
|
letter-spacing: 0.25em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--gold);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.geo-line {
|
||||||
|
width: 60px;
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(90deg, transparent, var(--gold), var(--green), var(--gold), transparent);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
padding: 12px 8px;
|
padding: 12px 8px;
|
||||||
@@ -90,7 +170,12 @@
|
|||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
background: rgba(12,17,23,0.8);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
border-radius: 0 0 24px 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab {
|
.tab {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -101,17 +186,25 @@
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.7rem;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
color: #5b8c85;
|
font-size: 0.62rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-dim);
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
min-width: 56px;
|
min-width: 56px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tab:hover { color: var(--gold); }
|
||||||
|
|
||||||
.tab.active {
|
.tab.active {
|
||||||
background: #ccfbf1;
|
color: var(--gold);
|
||||||
color: #0f766e;
|
background: var(--gold-dim);
|
||||||
font-weight: 600;
|
|
||||||
}
|
}
|
||||||
.tab-icon { font-size: 1.3rem; }
|
|
||||||
|
.tab.active .tab-icon { color: var(--gold); }
|
||||||
|
|
||||||
|
.tab-icon { font-size: 1.3rem; transition: color 0.2s; }
|
||||||
.tab-label { white-space: nowrap; }
|
.tab-label { white-space: nowrap; }
|
||||||
|
|
||||||
main {
|
main {
|
||||||
@@ -120,29 +213,86 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:global(.card) {
|
:global(.card) {
|
||||||
background: white;
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
box-shadow: 0 1px 3px rgba(15, 118, 110, 0.08);
|
box-shadow:
|
||||||
|
0 4px 20px rgba(0,0,0,0.3),
|
||||||
|
inset 0 1px 0 rgba(201,168,76,0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.card h2) {
|
:global(.card h2) {
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
color: #0f766e;
|
font-weight: 700;
|
||||||
|
color: var(--gold);
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(button.primary) {
|
:global(button.primary) {
|
||||||
background: linear-gradient(135deg, #0f766e, #0d9488);
|
font-family: 'Cinzel', serif;
|
||||||
color: white;
|
font-size: 0.82rem;
|
||||||
border: none;
|
letter-spacing: 0.18em;
|
||||||
padding: 10px 20px;
|
text-transform: uppercase;
|
||||||
border-radius: 10px;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border: none;
|
||||||
|
background: linear-gradient(135deg, var(--gold), var(--gold-light));
|
||||||
|
color: var(--bg);
|
||||||
|
border-radius: 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.9rem;
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
clip-path: polygon(0 0, calc(100% - 10px) 0, 100% 10px, 100% 100%, 10px 100%, 0 calc(100% - 10px));
|
||||||
}
|
}
|
||||||
:global(button.primary:active) { opacity: 0.85; }
|
|
||||||
|
:global(button.primary:hover) {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 30px rgba(201,168,76,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(button.primary:active) { transform: translateY(0); }
|
||||||
|
|
||||||
|
:global(button.secondary) {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid rgba(232,228,220,0.2);
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(button.secondary:hover) {
|
||||||
|
border-color: var(--gold);
|
||||||
|
color: var(--gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
padding: 16px 12px;
|
||||||
|
text-align: center;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background: rgba(12,17,23,0.8);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-text {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.62rem;
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gold { color: var(--gold); }
|
||||||
|
.green { color: var(--green); }
|
||||||
</style>
|
</style>
|
||||||
+82
-18
@@ -29,7 +29,7 @@
|
|||||||
<h2>📅 Hijri Calendar</h2>
|
<h2>📅 Hijri Calendar</h2>
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<p style="color:#5b8c85">⏳ Loading…</p>
|
<p class="loading">⏳ Loading…</p>
|
||||||
{:else if hijriDate}
|
{:else if hijriDate}
|
||||||
<div class="date-display">
|
<div class="date-display">
|
||||||
<div class="hijri-big">
|
<div class="hijri-big">
|
||||||
@@ -42,8 +42,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="hijri-info">
|
<div class="hijri-info">
|
||||||
<div class="info-row"><span>Weekday</span><span>{hijriDate.weekday.en}</span></div>
|
<div class="info-row"><span>Weekday</span><span>{hijriDate.weekday.en}</span></div>
|
||||||
<div class="info-row"><span>Month #</span><span>{hijriDate.month.number}</span></div>
|
<div class="info-row"><span>Month</span><span>{hijriDate.month.number}</span></div>
|
||||||
<div class="info-row"><span>Days in month</span><span>{hijriDate.month.days}</span></div>
|
<div class="info-row"><span>Days in Month</span><span>{hijriDate.month.days}</span></div>
|
||||||
<div class="info-row"><span>Designation</span><span>{hijriDate.designation.expanded}</span></div>
|
<div class="info-row"><span>Designation</span><span>{hijriDate.designation.expanded}</span></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -51,30 +51,94 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.date-display { display: flex; flex-direction: column; align-items: center; gap: 12px; }
|
.loading { color: var(--text-dim); text-align: center; padding: 20px; }
|
||||||
|
|
||||||
|
.date-display { display: flex; flex-direction: column; align-items: center; gap: 16px; }
|
||||||
|
|
||||||
.hijri-big {
|
.hijri-big {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: linear-gradient(135deg, #0f766e, #0d9488);
|
background: linear-gradient(135deg, var(--bg3), var(--surface));
|
||||||
color: white;
|
border: 1px solid var(--border);
|
||||||
border-radius: 16px;
|
border-radius: 20px;
|
||||||
padding: 24px 32px;
|
padding: 28px 40px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.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-big::before {
|
||||||
.hijri-year { font-size: 0.9rem; opacity: 0.75; margin-top: 2px; }
|
content: '';
|
||||||
.gregorian-row { font-size: 0.85rem; color: #5b8c85; }
|
position: absolute;
|
||||||
.hijri-info { width: 100%; display: flex; flex-direction: column; gap: 4px; }
|
top: 0; left: 0; right: 0;
|
||||||
|
height: 3px;
|
||||||
|
background: linear-gradient(90deg, var(--gold), var(--green), var(--gold));
|
||||||
|
}
|
||||||
|
|
||||||
|
.hijri-day {
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
font-size: 3.5rem;
|
||||||
|
font-weight: 900;
|
||||||
|
color: var(--gold);
|
||||||
|
line-height: 1;
|
||||||
|
text-shadow: 0 0 30px rgba(201,168,76,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hijri-month {
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hijri-year {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gregorian-row {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: var(--text-dim);
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: var(--bg2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hijri-info { width: 100%; display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
|
||||||
.info-row {
|
.info-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 8px 12px;
|
padding: 12px 16px;
|
||||||
background: #f0fdfa;
|
background: var(--bg2);
|
||||||
border-radius: 8px;
|
border: 1px solid var(--border);
|
||||||
font-size: 0.85rem;
|
border-radius: 10px;
|
||||||
color: #134e4a;
|
font-size: 0.9rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row:hover { border-color: var(--gold); background: var(--gold-dim); }
|
||||||
|
|
||||||
|
.info-row span:first-child {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row span:last-child {
|
||||||
|
font-family: 'DM Sans', sans-serif;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
+270
-27
@@ -5,8 +5,10 @@
|
|||||||
let nextPrayer = $state(null);
|
let nextPrayer = $state(null);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
let method = $state(3); // Muslim World League
|
let method = $state(3);
|
||||||
let geoLoading = $state(true);
|
let geoLoading = $state(true);
|
||||||
|
let notificationsEnabled = $state(false);
|
||||||
|
let notificationPermission = $state('default');
|
||||||
|
|
||||||
const methods = [
|
const methods = [
|
||||||
{ id: 3, name: 'Muslim World League' },
|
{ id: 3, name: 'Muslim World League' },
|
||||||
@@ -16,6 +18,107 @@
|
|||||||
{ id: 1, name: 'Karachi University' },
|
{ id: 1, name: 'Karachi University' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function loadSettings() {
|
||||||
|
const savedMethod = localStorage.getItem('prayer_method');
|
||||||
|
if (savedMethod) method = parseInt(savedMethod, 10);
|
||||||
|
|
||||||
|
const savedLat = localStorage.getItem('prayer_lat');
|
||||||
|
const savedLng = localStorage.getItem('prayer_lng');
|
||||||
|
if (savedLat && savedLng) {
|
||||||
|
lat = parseFloat(savedLat);
|
||||||
|
lng = parseFloat(savedLng);
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedNotifications = localStorage.getItem('prayer_notifications');
|
||||||
|
if (savedNotifications !== null) {
|
||||||
|
notificationsEnabled = savedNotifications === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('Notification' in window) {
|
||||||
|
notificationPermission = Notification.permission;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveMethod() {
|
||||||
|
localStorage.setItem('prayer_method', String(method));
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveLocation(latVal, lngVal) {
|
||||||
|
localStorage.setItem('prayer_lat', String(latVal));
|
||||||
|
localStorage.setItem('prayer_lng', String(lngVal));
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveNotifications() {
|
||||||
|
localStorage.setItem('prayer_notifications', String(notificationsEnabled));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestNotificationPermission() {
|
||||||
|
if (!('Notification' in window)) {
|
||||||
|
error = 'Notifications not supported';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Notification.permission === 'granted') {
|
||||||
|
notificationPermission = 'granted';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Notification.permission === 'denied') {
|
||||||
|
error = 'Notifications blocked — enable in browser settings';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const permission = await Notification.requestPermission();
|
||||||
|
notificationPermission = permission;
|
||||||
|
return permission === 'granted';
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedulePrayerNotifications() {
|
||||||
|
if (!prayers || !notificationsEnabled) return;
|
||||||
|
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
navigator.serviceWorker.ready.then((registration) => {
|
||||||
|
registration.active?.postMessage({
|
||||||
|
type: 'SCHEDULE_PRAYERS',
|
||||||
|
payload: { timings: prayers }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleLocalNotifications();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleLocalNotifications() {
|
||||||
|
const prayerNames = ['Fajr', 'Dhuhr', 'Asr', 'Maghrib', 'Isha'];
|
||||||
|
const now = Date.parse(new Date().toISOString().split('T')[0] + 'T00:00:00');
|
||||||
|
|
||||||
|
prayerNames.forEach((prayer) => {
|
||||||
|
const timeStr = prayers[prayer];
|
||||||
|
if (!timeStr) return;
|
||||||
|
|
||||||
|
const [hours, minutes] = timeStr.split(':').map(Number);
|
||||||
|
const prayerTime = now + hours * 3600000 + minutes * 60000;
|
||||||
|
const notifyTime = prayerTime - 5 * 60 * 1000;
|
||||||
|
const delay = notifyTime - Date.now();
|
||||||
|
|
||||||
|
if (delay > 0 && delay < 24 * 60 * 60 * 1000) {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (Notification.permission === 'granted') {
|
||||||
|
new Notification(`Time for ${prayer}`, {
|
||||||
|
body: `It's time for ${prayer} prayer (${timeStr})`,
|
||||||
|
icon: '/icon-192.png',
|
||||||
|
badge: '/icon-192.png',
|
||||||
|
vibrate: [200, 100, 200],
|
||||||
|
tag: `prayer-${prayer}`,
|
||||||
|
renotify: true,
|
||||||
|
requireInteraction: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function getLocation() {
|
function getLocation() {
|
||||||
if (!navigator.geolocation) {
|
if (!navigator.geolocation) {
|
||||||
error = 'Geolocation not supported';
|
error = 'Geolocation not supported';
|
||||||
@@ -27,11 +130,11 @@
|
|||||||
(pos) => {
|
(pos) => {
|
||||||
lat = pos.coords.latitude;
|
lat = pos.coords.latitude;
|
||||||
lng = pos.coords.longitude;
|
lng = pos.coords.longitude;
|
||||||
|
saveLocation(lat, lng);
|
||||||
geoLoading = false;
|
geoLoading = false;
|
||||||
fetchPrayers();
|
fetchPrayers();
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
// Default to Makkah if location denied
|
|
||||||
lat = 21.4225;
|
lat = 21.4225;
|
||||||
lng = 39.8262;
|
lng = 39.8262;
|
||||||
geoLoading = false;
|
geoLoading = false;
|
||||||
@@ -52,6 +155,7 @@
|
|||||||
prayers = data.data.timings;
|
prayers = data.data.timings;
|
||||||
nextPrayer = findNextPrayer(data.data.timings, data.data.meta.timezone);
|
nextPrayer = findNextPrayer(data.data.timings, data.data.meta.timezone);
|
||||||
loading = false;
|
loading = false;
|
||||||
|
schedulePrayerNotifications();
|
||||||
} else {
|
} else {
|
||||||
error = 'Could not load prayer times';
|
error = 'Could not load prayer times';
|
||||||
loading = false;
|
loading = false;
|
||||||
@@ -74,24 +178,50 @@
|
|||||||
return { name: 'Fajr', time: timings['Fajr'] + ' (tomorrow)' };
|
return { name: 'Fajr', time: timings['Fajr'] + ' (tomorrow)' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function toggleNotifications() {
|
||||||
|
if (!notificationsEnabled) {
|
||||||
|
const granted = await requestNotificationPermission();
|
||||||
|
if (granted) {
|
||||||
|
notificationsEnabled = true;
|
||||||
|
saveNotifications();
|
||||||
|
schedulePrayerNotifications();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
notificationsEnabled = false;
|
||||||
|
saveNotifications();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => { loadSettings(); });
|
||||||
$effect(() => { getLocation(); });
|
$effect(() => { getLocation(); });
|
||||||
|
$effect(() => { saveMethod(); });
|
||||||
|
$effect(() => { saveNotifications(); });
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (prayers && notificationsEnabled) {
|
||||||
|
schedulePrayerNotifications();
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>🕌 Prayer Times</h2>
|
<h2>🕌 Prayer Times</h2>
|
||||||
|
|
||||||
{#if geoLoading}
|
{#if geoLoading}
|
||||||
<p style="color:#5b8c85">📍 Getting location…</p>
|
<p class="loading">📍 Getting location…</p>
|
||||||
{:else if loading}
|
{:else if loading}
|
||||||
<p style="color:#5b8c85">⏳ Loading prayer times…</p>
|
<p class="loading">⏳ Loading prayer times…</p>
|
||||||
{:else if error}
|
{:else if error}
|
||||||
<p style="color:#e11d48">{error}</p>
|
<p class="error">{error}</p>
|
||||||
<button class="primary" onclick={fetchPrayers}>Retry</button>
|
<button class="primary" onclick={fetchPrayers}>Retry</button>
|
||||||
{:else if prayers}
|
{:else if prayers}
|
||||||
{#if nextPrayer}
|
{#if nextPrayer}
|
||||||
<div class="next-prayer">
|
<div class="next-prayer">
|
||||||
<span class="next-label">Next: {nextPrayer.name}</span>
|
<span class="next-label">Next Prayer</span>
|
||||||
<span class="next-time">{nextPrayer.time}</span>
|
<div class="next-info">
|
||||||
|
<span class="next-name">{nextPrayer.name}</span>
|
||||||
|
<span class="next-time">{nextPrayer.time}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -105,61 +235,174 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="method-select">
|
<div class="method-select">
|
||||||
<label for="method">Calculation method:</label>
|
<label for="method">Calculation Method</label>
|
||||||
<select id="method" bind:value={method} onchange={fetchPrayers}>
|
<select id="method" bind:value={method} onchange={fetchPrayers}>
|
||||||
{#each methods as m}
|
{#each methods as m}
|
||||||
<option value={m.id}>{m.name}</option>
|
<option value={m.id}>{m.name}</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="notification-toggle">
|
||||||
|
<label class="toggle-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={notificationsEnabled}
|
||||||
|
onchange={toggleNotifications}
|
||||||
|
disabled={notificationPermission === 'denied'}
|
||||||
|
/>
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
<span class="toggle-text">
|
||||||
|
{notificationPermission === 'denied'
|
||||||
|
? '🔔 Blocked — enable in settings'
|
||||||
|
: notificationsEnabled
|
||||||
|
? '🔔 Prayer notifications ON'
|
||||||
|
: '🔔 Enable prayer notifications'}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.loading { color: var(--text-dim); text-align: center; padding: 20px; }
|
||||||
|
.error { color: #FF6B6B; text-align: center; padding: 10px; }
|
||||||
|
|
||||||
.next-prayer {
|
.next-prayer {
|
||||||
background: linear-gradient(135deg, #0f766e, #0d9488);
|
background: linear-gradient(135deg, rgba(201,168,76,0.15), rgba(46,204,113,0.1));
|
||||||
color: white;
|
border: 1px solid var(--border);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 14px 16px;
|
padding: 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
margin-bottom: 16px;
|
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.62rem;
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.next-info { display: flex; flex-direction: column; align-items: center; gap: 4px; }
|
||||||
|
.next-name { font-family: 'Cinzel', serif; font-size: 0.9rem; font-weight: 700; color: var(--text); }
|
||||||
|
.next-time { font-family: 'Cinzel', serif; font-size: 1.8rem; font-weight: 900; color: var(--gold); font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
.prayer-grid {
|
.prayer-grid {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prayer-row {
|
.prayer-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 10px 14px;
|
padding: 12px 16px;
|
||||||
background: #f0fdfa;
|
background: var(--bg2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.prayer-row:hover { border-color: var(--gold); background: var(--gold-dim); }
|
||||||
|
|
||||||
.prayer-row.active {
|
.prayer-row.active {
|
||||||
background: #ccfbf1;
|
background: var(--gold-dim);
|
||||||
|
border-color: var(--gold);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.prayer-name { color: #0f766e; }
|
|
||||||
.prayer-time { font-variant-numeric: tabular-nums; }
|
.prayer-name {
|
||||||
|
font-family: 'DM Sans', sans-serif;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prayer-time {
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--gold);
|
||||||
|
}
|
||||||
|
|
||||||
.method-select {
|
.method-select {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 12px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-size: 0.75rem;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
color: #5b8c85;
|
font-size: 0.65rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.method-select select {
|
.method-select select {
|
||||||
border: 1px solid #99d6c9;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 4px 8px;
|
padding: 6px 10px;
|
||||||
font-size: 0.75rem;
|
color: var(--text);
|
||||||
color: #0f766e;
|
background: var(--bg2);
|
||||||
background: white;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.method-select select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-toggle { margin-top: 8px; }
|
||||||
|
|
||||||
|
.toggle-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-label input { display: none; }
|
||||||
|
|
||||||
|
.toggle-slider {
|
||||||
|
width: 44px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--border);
|
||||||
|
position: relative;
|
||||||
|
transition: background 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-label input:checked + .toggle-slider { background: linear-gradient(135deg, var(--gold), var(--gold-light)); }
|
||||||
|
.toggle-label input:disabled + .toggle-slider { background: var(--text-muted); opacity: 0.5; }
|
||||||
|
|
||||||
|
.toggle-slider::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text);
|
||||||
|
transition: transform 0.2s, background 0.2s;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-label input:checked + .toggle-slider::after { transform: translateX(20px); background: var(--bg); }
|
||||||
|
|
||||||
|
.toggle-text { font-weight: 500; }
|
||||||
|
.toggle-label input:disabled ~ .toggle-text { color: var(--text-muted); }
|
||||||
</style>
|
</style>
|
||||||
+117
-14
@@ -3,6 +3,7 @@
|
|||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
let deviceAlpha = $state(0);
|
let deviceAlpha = $state(0);
|
||||||
|
let needsPermission = $state(false);
|
||||||
|
|
||||||
const KAABA = { lat: 21.4225, lng: 39.8262 };
|
const KAABA = { lat: 21.4225, lng: 39.8262 };
|
||||||
|
|
||||||
@@ -17,6 +18,27 @@
|
|||||||
return (toDeg(Math.atan2(y, x)) + 360) % 360;
|
return (toDeg(Math.atan2(y, x)) + 360) % 360;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function requestCompassPermission() {
|
||||||
|
if ('DeviceOrientationEvent' in window && typeof DeviceOrientationEvent.requestPermission === 'function') {
|
||||||
|
try {
|
||||||
|
const permission = await DeviceOrientationEvent.requestPermission();
|
||||||
|
if (permission === 'granted') {
|
||||||
|
needsPermission = false;
|
||||||
|
startCompass();
|
||||||
|
} else {
|
||||||
|
error = 'Compass permission denied';
|
||||||
|
needsPermission = true;
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
error = 'Failed to request compass permission';
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
startCompass();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function startCompass() {
|
function startCompass() {
|
||||||
if (!navigator.geolocation) {
|
if (!navigator.geolocation) {
|
||||||
error = 'Geolocation not supported';
|
error = 'Geolocation not supported';
|
||||||
@@ -29,7 +51,7 @@
|
|||||||
loading = false;
|
loading = false;
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
bearing = calcBearing(3.1390, 101.6869); // KL default
|
bearing = calcBearing(3.1390, 101.6869);
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -45,7 +67,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => { startCompass(); });
|
$effect(() => { requestCompassPermission(); });
|
||||||
|
|
||||||
const needleAngle = $derived(bearing - deviceAlpha);
|
const needleAngle = $derived(bearing - deviceAlpha);
|
||||||
</script>
|
</script>
|
||||||
@@ -54,15 +76,20 @@
|
|||||||
<h2>🧭 Qibla Finder</h2>
|
<h2>🧭 Qibla Finder</h2>
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<p style="color:#5b8c85">📍 Getting location…</p>
|
<p class="loading">📍 Getting location…</p>
|
||||||
{:else if error}
|
{:else if error}
|
||||||
<p style="color:#e11d48">{error}</p>
|
<p class="error">{error}</p>
|
||||||
|
{#if needsPermission}
|
||||||
|
<button class="primary" onclick={requestCompassPermission}>Enable Compass</button>
|
||||||
|
{:else}
|
||||||
|
<button class="primary" onclick={requestCompassPermission}>Retry</button>
|
||||||
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<div class="compass-box">
|
<div class="compass-box">
|
||||||
<div class="compass" style="transform: rotate({needleAngle}deg)">
|
<div class="compass" style="transform: rotate({needleAngle}deg)">
|
||||||
<div class="needle">
|
<div class="needle">
|
||||||
<svg viewBox="0 0 60 80" width="40" height="60">
|
<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="var(--gold)" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -70,41 +97,117 @@
|
|||||||
<span class="bearing-num">{Math.round(bearing)}°</span>
|
<span class="bearing-num">{Math.round(bearing)}°</span>
|
||||||
<span class="bearing-dir">from North</span>
|
<span class="bearing-dir">from North</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="compass-hint">{deviceAlpha === 0 ? '🗺️ Move your device to calibrate compass' : '↗️ Point the needle towards the Kaaba'}</p>
|
<p class="compass-hint">
|
||||||
|
{deviceAlpha === 0
|
||||||
|
? '🗺️ Move your device to calibrate compass'
|
||||||
|
: '↗️ Point the needle towards the Kaaba'}
|
||||||
|
</p>
|
||||||
|
<div class="kaaba-info">
|
||||||
|
<span class="kaaba-label">Al-Masjid Al-Haram</span>
|
||||||
|
<span class="kaaba-coords">21.4225° N, 39.8262° E</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.loading { color: var(--text-dim); text-align: center; padding: 20px; }
|
||||||
|
.error { color: #FF6B6B; text-align: center; padding: 10px; }
|
||||||
|
|
||||||
.compass-box {
|
.compass-box {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 16px;
|
||||||
padding: 10px 0;
|
padding: 10px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compass {
|
.compass {
|
||||||
width: 160px;
|
width: 180px;
|
||||||
height: 160px;
|
height: 180px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #f0fdfa;
|
background: var(--bg2);
|
||||||
border: 3px solid #0f766e;
|
border: 2px solid var(--border);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
transition: transform 0.3s ease;
|
transition: transform 0.3s ease;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.compass::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: -2px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(45deg, var(--gold), var(--green), var(--gold));
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compass:hover::before { opacity: 0.5; }
|
||||||
|
|
||||||
.needle {
|
.needle {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
filter: drop-shadow(0 4px 8px rgba(0,0,0,0.3));
|
||||||
}
|
}
|
||||||
|
|
||||||
.bearing-info {
|
.bearing-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bearing-num {
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 900;
|
||||||
|
color: var(--gold);
|
||||||
|
text-shadow: 0 0 20px rgba(201,168,76,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bearing-dir {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compass-hint {
|
||||||
|
font-family: 'DM Sans', sans-serif;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
|
max-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kaaba-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kaaba-label {
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kaaba-coords {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-dim);
|
||||||
}
|
}
|
||||||
.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>
|
</style>
|
||||||
+271
-45
@@ -2,8 +2,13 @@
|
|||||||
let surahs = $state([]);
|
let surahs = $state([]);
|
||||||
let selectedSurah = $state(null);
|
let selectedSurah = $state(null);
|
||||||
let ayahs = $state([]);
|
let ayahs = $state([]);
|
||||||
|
let arabicAyahs = $state([]);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
|
let showArabic = $state(true);
|
||||||
|
let showEnglish = $state(true);
|
||||||
|
let playingAyah = $state(null);
|
||||||
|
let audio = $state(null);
|
||||||
|
|
||||||
async function loadSurahs() {
|
async function loadSurahs() {
|
||||||
try {
|
try {
|
||||||
@@ -19,12 +24,24 @@
|
|||||||
|
|
||||||
async function loadSurah(num) {
|
async function loadSurah(num) {
|
||||||
loading = true;
|
loading = true;
|
||||||
|
stopAudio();
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`https://api.alquran.cloud/v1/surah/${num}/en.asad`);
|
const [arRes, enRes] = await Promise.all([
|
||||||
const data = await res.json();
|
fetch(`https://api.alquran.cloud/v1/surah/${num}/ar.alafasy`),
|
||||||
if (data.code === 200) {
|
fetch(`https://api.alquran.cloud/v1/surah/${num}/en.asad`)
|
||||||
selectedSurah = { num, name: data.data.englishName, english: data.data.englishNameTranslation, type: data.data.revelationType };
|
]);
|
||||||
ayahs = data.data.ayahs;
|
const [arData, enData] = await Promise.all([arRes.json(), enRes.json()]);
|
||||||
|
|
||||||
|
if (arData.code === 200 && enData.code === 200) {
|
||||||
|
selectedSurah = {
|
||||||
|
num,
|
||||||
|
name: enData.data.englishName,
|
||||||
|
english: enData.data.englishNameTranslation,
|
||||||
|
arabicName: arData.data.name,
|
||||||
|
type: enData.data.revelationType
|
||||||
|
};
|
||||||
|
ayahs = enData.data.ayahs;
|
||||||
|
arabicAyahs = arData.data.ayahs;
|
||||||
}
|
}
|
||||||
loading = false;
|
loading = false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -36,29 +53,102 @@
|
|||||||
function goBack() {
|
function goBack() {
|
||||||
selectedSurah = null;
|
selectedSurah = null;
|
||||||
ayahs = [];
|
ayahs = [];
|
||||||
|
arabicAyahs = [];
|
||||||
|
stopAudio();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopAudio() {
|
||||||
|
if (audio) {
|
||||||
|
audio.pause();
|
||||||
|
audio = null;
|
||||||
|
}
|
||||||
|
playingAyah = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function playAyah(surahNum, ayahNum) {
|
||||||
|
stopAudio();
|
||||||
|
const surahStr = String(surahNum).padStart(3, '0');
|
||||||
|
const ayahStr = String(ayahNum).padStart(3, '0');
|
||||||
|
const url = `https://everyayah.com/data/Alafasy_128kbps/${surahStr}${ayahStr}.mp3`;
|
||||||
|
|
||||||
|
audio = new Audio(url);
|
||||||
|
playingAyah = ayahNum;
|
||||||
|
|
||||||
|
audio.onended = () => {
|
||||||
|
playingAyah = null;
|
||||||
|
audio = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
audio.onerror = () => {
|
||||||
|
playingAyah = null;
|
||||||
|
audio = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
audio.play().catch(() => {
|
||||||
|
playingAyah = null;
|
||||||
|
audio = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePlay(surahNum, ayahNum) {
|
||||||
|
if (playingAyah === ayahNum) {
|
||||||
|
stopAudio();
|
||||||
|
} else {
|
||||||
|
playAyah(surahNum, ayahNum);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => { loadSurahs(); });
|
$effect(() => { loadSurahs(); });
|
||||||
|
$effect(() => () => stopAudio());
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>📖 Holy Quran</h2>
|
<h2>📖 Holy Quran</h2>
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<p style="color:#5b8c85">⏳ Loading…</p>
|
<p class="loading">⏳ Loading…</p>
|
||||||
{:else if error}
|
{:else if error}
|
||||||
<p style="color:#e11d48">{error}</p>
|
<p class="error">{error}</p>
|
||||||
{:else if selectedSurah}
|
{:else if selectedSurah}
|
||||||
<div class="surah-header">
|
<div class="surah-header">
|
||||||
<button class="back-btn" onclick={goBack}>← Surahs</button>
|
<button class="secondary back-btn" onclick={goBack}>← Surahs</button>
|
||||||
<h3>{selectedSurah.name} ({selectedSurah.english})</h3>
|
<div class="surah-title-block">
|
||||||
|
<h3>{selectedSurah.name}</h3>
|
||||||
|
<span class="surah-english">({selectedSurah.english})</span>
|
||||||
|
<span class="surah-arabic" dir="rtl">{selectedSurah.arabicName}</span>
|
||||||
|
</div>
|
||||||
<span class="surah-type">{selectedSurah.type}</span>
|
<span class="surah-type">{selectedSurah.type}</span>
|
||||||
|
<div class="view-toggles">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" bind:checked={showArabic} />
|
||||||
|
<span>Arabic</span>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" bind:checked={showEnglish} />
|
||||||
|
<span>English</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ayah-list">
|
<div class="ayah-list">
|
||||||
{#each ayahs as ayah}
|
{#each ayahs as ayah, i}
|
||||||
<div class="ayah">
|
<div class="ayah">
|
||||||
<span class="ayah-num">{ayah.numberInSurah}</span>
|
<span class="ayah-num">{ayah.numberInSurah}</span>
|
||||||
<p class="ayah-text">{ayah.text}</p>
|
<div class="ayah-content">
|
||||||
|
{#if showArabic && arabicAyahs[i]}
|
||||||
|
<p class="ayah-text arabic" dir="rtl">{arabicAyahs[i].text}</p>
|
||||||
|
{/if}
|
||||||
|
{#if showEnglish}
|
||||||
|
<p class="ayah-text english">{ayah.text}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="audio-btn"
|
||||||
|
class:playing={playingAyah === ayah.numberInSurah}
|
||||||
|
onclick={() => togglePlay(selectedSurah.num, ayah.numberInSurah)}
|
||||||
|
aria-label={playingAyah === ayah.numberInSurah ? 'Pause' : 'Play'}
|
||||||
|
>
|
||||||
|
{#if playingAyah === ayah.numberInSurah}⏸{:else}▶{/if}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -67,8 +157,11 @@
|
|||||||
{#each surahs as s}
|
{#each surahs as s}
|
||||||
<button class="surah-btn" onclick={() => loadSurah(s.number)}>
|
<button class="surah-btn" onclick={() => loadSurah(s.number)}>
|
||||||
<span class="surah-num">{s.number}</span>
|
<span class="surah-num">{s.number}</span>
|
||||||
<span class="surah-name">{s.englishName}</span>
|
<div class="surah-info">
|
||||||
<span class="surah-ayahs">{s.numberOfAyahs} ayahs</span>
|
<span class="surah-name">{s.englishName}</span>
|
||||||
|
<span class="surah-arabic" dir="rtl">{s.name}</span>
|
||||||
|
<span class="surah-ayahs">{s.numberOfAyahs} ayahs</span>
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -76,6 +169,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.loading { color: var(--text-dim); text-align: center; padding: 20px; }
|
||||||
|
.error { color: #FF6B6B; text-align: center; padding: 10px; }
|
||||||
|
|
||||||
.surah-grid {
|
.surah-grid {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -83,61 +179,191 @@
|
|||||||
max-height: 60vh;
|
max-height: 60vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.surah-btn {
|
.surah-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 12px 14px;
|
padding: 14px 16px;
|
||||||
border: none;
|
border: 1px solid var(--border);
|
||||||
background: #f0fdfa;
|
background: var(--bg2);
|
||||||
border-radius: 10px;
|
border-radius: 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
transition: background 0.15s;
|
transition: all 0.15s;
|
||||||
}
|
}
|
||||||
.surah-btn:active { background: #ccfbf1; }
|
|
||||||
|
.surah-btn:hover { border-color: var(--gold); background: var(--gold-dim); }
|
||||||
|
.surah-btn:active { background: var(--gold-dim); }
|
||||||
|
|
||||||
.surah-num {
|
.surah-num {
|
||||||
background: #0f766e;
|
font-family: 'Cinzel', serif;
|
||||||
color: white;
|
font-weight: 700;
|
||||||
width: 32px;
|
color: var(--gold);
|
||||||
height: 32px;
|
background: var(--gold-dim);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 0.75rem;
|
font-size: 0.8rem;
|
||||||
font-weight: 600;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.surah-name { font-weight: 600; color: #134e4a; flex: 1; }
|
|
||||||
.surah-ayahs { font-size: 0.7rem; color: #5b8c85; }
|
.surah-info { flex: 1; display: flex; flex-direction: column; gap: 2px; }
|
||||||
.surah-header { margin-bottom: 12px; }
|
.surah-name { font-family: 'DM Sans', sans-serif; font-weight: 600; color: var(--text); }
|
||||||
.surah-header h3 { font-size: 1.1rem; color: #0f766e; margin: 4px 0; }
|
.surah-arabic {
|
||||||
.surah-type { font-size: 0.7rem; color: #5b8c85; background: #ccfbf1; padding: 2px 8px; border-radius: 4px; }
|
font-family: 'DM Sans', sans-serif;
|
||||||
.back-btn {
|
font-size: 0.9rem;
|
||||||
border: none;
|
color: var(--gold);
|
||||||
background: none;
|
text-align: right;
|
||||||
color: #0f766e;
|
direction: rtl;
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
padding: 4px 0;
|
|
||||||
}
|
}
|
||||||
|
.surah-ayahs {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.62rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.surah-header {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.surah-title-block { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
|
||||||
|
.surah-title-block h3 {
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.surah-english {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.surah-arabic {
|
||||||
|
font-family: 'DM Sans', sans-serif;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--gold);
|
||||||
|
direction: rtl;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.surah-type {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--green);
|
||||||
|
background: var(--green-dim);
|
||||||
|
border: 1px solid var(--border-green);
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn { padding: 8px 12px; font-size: 0.7rem; }
|
||||||
|
|
||||||
|
.view-toggles {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-toggles label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-toggles input[type="checkbox"] {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
accent-color: var(--gold);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.ayah-list { max-height: 60vh; overflow-y: auto; }
|
.ayah-list { max-height: 60vh; overflow-y: auto; }
|
||||||
|
|
||||||
.ayah {
|
.ayah {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 10px 0;
|
padding: 14px 0;
|
||||||
border-bottom: 1px solid #e6f5f0;
|
border-bottom: 1px solid var(--border);
|
||||||
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ayah:last-child { border-bottom: none; }
|
||||||
|
|
||||||
.ayah-num {
|
.ayah-num {
|
||||||
color: #0f766e;
|
font-family: 'Cinzel', serif;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
font-size: 0.8rem;
|
font-size: 0.85rem;
|
||||||
min-width: 24px;
|
color: var(--gold);
|
||||||
|
min-width: 28px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ayah-content { flex: 1; display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
|
||||||
|
.ayah-text { margin: 0; line-height: 1.7; }
|
||||||
|
|
||||||
|
.ayah-text.arabic {
|
||||||
|
font-family: 'Amiri', 'Noto Naskh Arabic', serif;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
line-height: 2;
|
||||||
|
color: var(--gold);
|
||||||
|
text-shadow: 0 0 10px rgba(201,168,76,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ayah-text.english {
|
||||||
|
font-family: 'DM Sans', sans-serif;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-btn {
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg2);
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--gold);
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-btn:hover,
|
||||||
|
.audio-btn.playing {
|
||||||
|
background: var(--gold-dim);
|
||||||
|
border-color: var(--gold);
|
||||||
|
transform: scale(1.05);
|
||||||
}
|
}
|
||||||
.ayah-text { font-size: 0.9rem; line-height: 1.6; color: #134e4a; }
|
|
||||||
</style>
|
</style>
|
||||||
+92
-22
@@ -4,17 +4,38 @@
|
|||||||
let target = $state(33);
|
let target = $state(33);
|
||||||
let vibrate = $state(true);
|
let vibrate = $state(true);
|
||||||
|
|
||||||
|
function loadSettings() {
|
||||||
|
const savedTarget = localStorage.getItem('tasbih_target');
|
||||||
|
if (savedTarget) target = parseInt(savedTarget, 10);
|
||||||
|
|
||||||
|
const savedTotal = localStorage.getItem('tasbih_total');
|
||||||
|
if (savedTotal) total = parseInt(savedTotal, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveTarget() {
|
||||||
|
localStorage.setItem('tasbih_target', String(target));
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveTotal() {
|
||||||
|
localStorage.setItem('tasbih_total', String(total));
|
||||||
|
}
|
||||||
|
|
||||||
function increment() {
|
function increment() {
|
||||||
count = (count + 1) % target;
|
count = (count + 1) % target;
|
||||||
total++;
|
total++;
|
||||||
|
saveTotal();
|
||||||
if (vibrate && navigator.vibrate) navigator.vibrate(15);
|
if (vibrate && navigator.vibrate) navigator.vibrate(15);
|
||||||
}
|
}
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
count = 0;
|
count = 0;
|
||||||
total = 0;
|
total = 0;
|
||||||
|
saveTotal();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$effect(() => { loadSettings(); });
|
||||||
|
$effect(() => { saveTarget(); });
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
function handleSpace(e) {
|
function handleSpace(e) {
|
||||||
if (e.code === 'Space' && e.target === document.body) {
|
if (e.code === 'Space' && e.target === document.body) {
|
||||||
@@ -31,31 +52,31 @@
|
|||||||
<h2>📿 Tasbih Counter</h2>
|
<h2>📿 Tasbih Counter</h2>
|
||||||
|
|
||||||
<div class="tasbih-display">
|
<div class="tasbih-display">
|
||||||
<!-- 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()}>
|
<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">
|
<svg viewBox="0 0 120 120" width="180" height="180">
|
||||||
<circle cx="60" cy="60" r="52" fill="none" stroke="#e6f5f0" stroke-width="8" />
|
<circle cx="60" cy="60" r="52" fill="none" stroke="var(--border)" stroke-width="8" />
|
||||||
<circle
|
<circle
|
||||||
cx="60" cy="60" r="52"
|
cx="60" cy="60" r="52"
|
||||||
fill="none" stroke="#0f766e" stroke-width="8"
|
fill="none" stroke="var(--gold)" stroke-width="8"
|
||||||
stroke-dasharray={2 * Math.PI * 52}
|
stroke-dasharray={2 * Math.PI * 52}
|
||||||
stroke-dashoffset={2 * Math.PI * 52 * (1 - count / target)}
|
stroke-dashoffset={2 * Math.PI * 52 * (1 - count / target)}
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
transform="rotate(-90 60 60)"
|
transform="rotate(-90 60 60)"
|
||||||
|
style="filter: drop-shadow(0 0 10px rgba(201,168,76,0.5));"
|
||||||
/>
|
/>
|
||||||
<text x="60" y="56" text-anchor="middle" fill="#0f766e" font-size="22" font-weight="700">{count}</text>
|
<text x="60" y="58" text-anchor="middle" fill="var(--gold)" font-family="Cinzel, serif" font-size="28" font-weight="700">{count}</text>
|
||||||
<text x="60" y="76" text-anchor="middle" fill="#5b8c85" font-size="10">/ {target}</text>
|
<text x="60" y="80" text-anchor="middle" fill="var(--text-dim)" font-family="JetBrains Mono, monospace" font-size="10">{target}</text>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="total-line">
|
<div class="total-line">
|
||||||
<span>Total today:</span>
|
<span class="total-label">Total Today</span>
|
||||||
<strong>{total}</strong>
|
<strong class="total-value">{total}</strong>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<div class="target-select">
|
<div class="target-select">
|
||||||
<label for="bead-select">Beads:</label>
|
<label for="bead-select">Target</label>
|
||||||
<select id="bead-select" bind:value={target} onchange={() => { count = 0; }}>
|
<select id="bead-select" bind:value={target} onchange={() => { count = 0; }}>
|
||||||
<option value={33}>33</option>
|
<option value={33}>33</option>
|
||||||
<option value={99}>99</option>
|
<option value={99}>99</option>
|
||||||
@@ -63,7 +84,7 @@
|
|||||||
<option value={500}>500</option>
|
<option value={500}>500</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<button class="primary" onclick={reset}>Reset</button>
|
<button class="secondary" onclick={reset}>Reset</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="hint">👆 Tap the circle or press Space to count</p>
|
<p class="hint">👆 Tap the circle or press Space to count</p>
|
||||||
@@ -75,32 +96,81 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 14px;
|
gap: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.counter-ring {
|
.counter-ring {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
transition: transform 0.1s;
|
||||||
}
|
}
|
||||||
.counter-ring:active { transform: scale(0.97); transition: transform 0.1s; }
|
|
||||||
.total-line { font-size: 0.9rem; color: #5b8c85; }
|
.counter-ring:active { transform: scale(0.96); }
|
||||||
|
.counter-ring:focus-visible { outline: 2px solid var(--gold); outline-offset: 4px; border-radius: 50%; }
|
||||||
|
|
||||||
|
.total-line {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-label {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-value {
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--gold);
|
||||||
|
}
|
||||||
|
|
||||||
.controls {
|
.controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 16px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.target-select {
|
.target-select {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 10px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-size: 0.8rem;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
color: #5b8c85;
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
.target-select select {
|
.target-select select {
|
||||||
border: 1px solid #99d6c9;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
border-radius: 6px;
|
font-size: 0.7rem;
|
||||||
padding: 4px 8px;
|
letter-spacing: 0.05em;
|
||||||
color: #0f766e;
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg2);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.target-select select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
.hint { font-size: 0.7rem; color: #94a3a8; }
|
|
||||||
</style>
|
</style>
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
// Prayer Times utility tests
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
// Mock the findNextPrayer function from PrayerTimes.svelte
|
||||||
|
function findNextPrayer(timings, tz) {
|
||||||
|
const now = new Date();
|
||||||
|
const names = ['Fajr', 'Sunrise', 'Dhuhr', 'Asr', 'Maghrib', 'Isha'];
|
||||||
|
for (const name of names) {
|
||||||
|
const [h, m] = timings[name].split(':').map(Number);
|
||||||
|
const pt = new Date(now);
|
||||||
|
pt.setHours(h, m, 0, 0);
|
||||||
|
if (pt > now) return { name, time: timings[name] };
|
||||||
|
}
|
||||||
|
return { name: 'Fajr', time: timings['Fajr'] + ' (tomorrow)' };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Prayer Times Utilities', () => {
|
||||||
|
const mockTimings = {
|
||||||
|
Fajr: '05:30',
|
||||||
|
Sunrise: '06:45',
|
||||||
|
Dhuhr: '12:30',
|
||||||
|
Asr: '15:45',
|
||||||
|
Maghrib: '18:30',
|
||||||
|
Isha: '20:00'
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should find next prayer correctly when before Fajr', () => {
|
||||||
|
// Mock current time to be 04:00
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2024-01-15T04:00:00'));
|
||||||
|
|
||||||
|
const result = findNextPrayer(mockTimings, 'UTC');
|
||||||
|
|
||||||
|
expect(result.name).toBe('Fajr');
|
||||||
|
expect(result.time).toBe('05:30');
|
||||||
|
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should find next prayer correctly when between Fajr and Sunrise', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2024-01-15T06:00:00'));
|
||||||
|
|
||||||
|
const result = findNextPrayer(mockTimings, 'UTC');
|
||||||
|
|
||||||
|
expect(result.name).toBe('Sunrise');
|
||||||
|
expect(result.time).toBe('06:45');
|
||||||
|
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should find next prayer correctly when between Dhuhr and Asr', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2024-01-15T14:00:00'));
|
||||||
|
|
||||||
|
const result = findNextPrayer(mockTimings, 'UTC');
|
||||||
|
|
||||||
|
expect(result.name).toBe('Asr');
|
||||||
|
expect(result.time).toBe('15:45');
|
||||||
|
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should wrap to Fajr tomorrow when past Isha', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2024-01-15T21:00:00'));
|
||||||
|
|
||||||
|
const result = findNextPrayer(mockTimings, 'UTC');
|
||||||
|
|
||||||
|
expect(result.name).toBe('Fajr');
|
||||||
|
expect(result.time).toBe('05:30 (tomorrow)');
|
||||||
|
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle exact prayer time (returns next)', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2024-01-15T12:30:00'));
|
||||||
|
|
||||||
|
const result = findNextPrayer(mockTimings, 'UTC');
|
||||||
|
|
||||||
|
// At exactly Dhuhr time, should return Asr
|
||||||
|
expect(result.name).toBe('Asr');
|
||||||
|
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Qibla bearing calculation tests
|
||||||
|
function toRad(d) { return (d * Math.PI) / 180; }
|
||||||
|
function toDeg(r) { return (r * 180) / Math.PI; }
|
||||||
|
|
||||||
|
function calcBearing(lat, lng) {
|
||||||
|
const KAABA = { lat: 21.4225, lng: 39.8262 };
|
||||||
|
const dLng = toRad(KAABA.lng - lng);
|
||||||
|
const y = Math.sin(dLng) * Math.cos(toRad(KAABA.lat));
|
||||||
|
const x = Math.cos(toRad(lat)) * Math.sin(toRad(KAABA.lat))
|
||||||
|
- Math.sin(toRad(lat)) * Math.cos(toRad(KAABA.lat)) * Math.cos(dLng);
|
||||||
|
return (toDeg(Math.atan2(y, x)) + 360) % 360;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Qibla Bearing Calculation', () => {
|
||||||
|
it('should return 0° for Kaaba itself', () => {
|
||||||
|
const bearing = calcBearing(21.4225, 39.8262);
|
||||||
|
expect(Math.round(bearing)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return ~292° for London (51.5074, -0.1278)', () => {
|
||||||
|
const bearing = calcBearing(51.5074, -0.1278);
|
||||||
|
expect(Math.round(bearing)).toBe(119); // London -> Makkah is ~119°
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return ~17° for Jakarta (-6.2088, 106.8456)', () => {
|
||||||
|
const bearing = calcBearing(-6.2088, 106.8456);
|
||||||
|
expect(Math.round(bearing)).toBe(295); // Jakarta -> Makkah is ~295°
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return ~22° for Kuala Lumpur (3.1390, 101.6869)', () => {
|
||||||
|
const bearing = calcBearing(3.1390, 101.6869);
|
||||||
|
expect(Math.round(bearing)).toBe(293); // KL -> Makkah is ~293°
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return ~58° for New York (40.7128, -74.0060)', () => {
|
||||||
|
const bearing = calcBearing(40.7128, -74.0060);
|
||||||
|
expect(Math.round(bearing)).toBe(58); // NYC -> Makkah is ~58°
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle southern hemisphere correctly', () => {
|
||||||
|
// Sydney (-33.8688, 151.2093) -> Makkah should be ~277°
|
||||||
|
const bearing = calcBearing(-33.8688, 151.2093);
|
||||||
|
expect(Math.round(bearing)).toBe(277);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should always return value between 0 and 360', () => {
|
||||||
|
// Test various coordinates
|
||||||
|
const testCoords = [
|
||||||
|
[90, 0], // North Pole
|
||||||
|
[-90, 0], // South Pole
|
||||||
|
[0, 0], // Equator, Prime Meridian
|
||||||
|
[0, 180], // Equator, Antimeridian
|
||||||
|
[45, 45], // Mid-latitude
|
||||||
|
[-45, -45] // Southern mid-latitude
|
||||||
|
];
|
||||||
|
|
||||||
|
testCoords.forEach(([lat, lng]) => {
|
||||||
|
const bearing = calcBearing(lat, lng);
|
||||||
|
expect(bearing).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(bearing).toBeLessThan(360);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hijri date conversion test (mocking API response)
|
||||||
|
describe('Hijri Date', () => {
|
||||||
|
it('should parse hijri date response correctly', () => {
|
||||||
|
const mockResponse = {
|
||||||
|
code: 200,
|
||||||
|
data: {
|
||||||
|
hijri: {
|
||||||
|
day: '15',
|
||||||
|
month: { number: 9, en: 'Ramadan', ar: 'رَمَضَان' },
|
||||||
|
year: '1445',
|
||||||
|
weekday: { en: 'Friday', ar: 'الجمعة' },
|
||||||
|
designation: { expanded: 'Ramadan 1445' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(mockResponse.data.hijri.day).toBe('15');
|
||||||
|
expect(mockResponse.data.hijri.month.en).toBe('Ramadan');
|
||||||
|
expect(mockResponse.data.hijri.year).toBe('1445');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tasbih counter tests
|
||||||
|
describe('Tasbih Counter Logic', () => {
|
||||||
|
it('should increment and wrap at target', () => {
|
||||||
|
let count = 0;
|
||||||
|
const target = 33;
|
||||||
|
|
||||||
|
function increment() {
|
||||||
|
count = (count + 1) % target;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < 33; i++) {
|
||||||
|
increment();
|
||||||
|
}
|
||||||
|
expect(count).toBe(0);
|
||||||
|
|
||||||
|
increment();
|
||||||
|
expect(count).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should track total separately from count', () => {
|
||||||
|
let count = 0;
|
||||||
|
let total = 0;
|
||||||
|
const target = 33;
|
||||||
|
|
||||||
|
function increment() {
|
||||||
|
count = (count + 1) % target;
|
||||||
|
total++;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
increment();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(count).toBe(17); // 50 % 33 = 17
|
||||||
|
expect(total).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reset both count and total', () => {
|
||||||
|
let count = 17;
|
||||||
|
let total = 50;
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
count = 0;
|
||||||
|
total = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
reset();
|
||||||
|
expect(count).toBe(0);
|
||||||
|
expect(total).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 99 Names search/filter tests
|
||||||
|
const mockNames = [
|
||||||
|
{ no: 1, ar: 'الرَّحْمَنُ', en: 'Ar-Rahman', meaning: 'The Most Merciful' },
|
||||||
|
{ no: 2, ar: 'الرَّحِيمُ', en: 'Ar-Raheem', meaning: 'The Most Compassionate' },
|
||||||
|
{ no: 3, ar: 'الْمَلِكُ', en: 'Al-Malik', meaning: 'The King' },
|
||||||
|
{ no: 93, ar: 'النُّورُ', en: 'An-Nur', meaning: 'The Light' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function filterNames(names, search) {
|
||||||
|
if (!search.trim()) return names;
|
||||||
|
const term = search.toLowerCase();
|
||||||
|
return names.filter(n =>
|
||||||
|
n.en.toLowerCase().includes(term) ||
|
||||||
|
n.meaning.toLowerCase().includes(term) ||
|
||||||
|
n.ar.includes(search)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('99 Names Search', () => {
|
||||||
|
it('should return all names when search is empty', () => {
|
||||||
|
const result = filterNames(mockNames, '');
|
||||||
|
expect(result.length).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter by English name', () => {
|
||||||
|
const result = filterNames(mockNames, 'Rahman');
|
||||||
|
expect(result.length).toBe(1);
|
||||||
|
expect(result[0].en).toBe('Ar-Rahman');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter by meaning', () => {
|
||||||
|
const result = filterNames(mockNames, 'King');
|
||||||
|
expect(result.length).toBe(1);
|
||||||
|
expect(result[0].en).toBe('Al-Malik');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter by Arabic text', () => {
|
||||||
|
const result = filterNames(mockNames, 'النُّورُ');
|
||||||
|
expect(result.length).toBe(1);
|
||||||
|
expect(result[0].no).toBe(93);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be case insensitive for English', () => {
|
||||||
|
const result1 = filterNames(mockNames, 'rahman');
|
||||||
|
const result2 = filterNames(mockNames, 'RAHMAN');
|
||||||
|
expect(result1.length).toBe(result2.length);
|
||||||
|
expect(result1.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array for no matches', () => {
|
||||||
|
const result = filterNames(mockNames, 'xyz');
|
||||||
|
expect(result.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
+15
@@ -1,4 +1,19 @@
|
|||||||
import App from './App.svelte';
|
import App from './App.svelte';
|
||||||
import { mount } from 'svelte';
|
import { mount } from 'svelte';
|
||||||
|
|
||||||
|
// Register service worker for PWA and push notifications
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
navigator.serviceWorker.register('/sw.js')
|
||||||
|
.then((registration) => {
|
||||||
|
console.log('[Nūr] SW registered:', registration.scope);
|
||||||
|
|
||||||
|
// Check for updates periodically
|
||||||
|
setInterval(() => registration.update(), 60 * 60 * 1000);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('[Nūr] SW registration failed:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const app = mount(App, { target: document.getElementById('app') });
|
const app = mount(App, { target: document.getElementById('app') });
|
||||||
export default app;
|
export default app;
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
// Custom Service Worker for Nūr — Muslim Companion
|
||||||
|
// Handles push notifications for prayer times
|
||||||
|
|
||||||
|
// Workbox precache manifest injection point
|
||||||
|
self.__WB_MANIFEST;
|
||||||
|
|
||||||
|
const CACHE_NAME = 'nur-v1';
|
||||||
|
const PRAYER_CACHE = 'prayer-times-cache';
|
||||||
|
|
||||||
|
// Install event
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(CACHE_NAME).then((cache) => {
|
||||||
|
return cache.addAll([
|
||||||
|
'/',
|
||||||
|
'/index.html',
|
||||||
|
'/manifest.webmanifest',
|
||||||
|
'/icon-192.png',
|
||||||
|
'/icon-512.png',
|
||||||
|
'/favicon.svg',
|
||||||
|
'/apple-touch-icon.png'
|
||||||
|
]);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Activate event
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then((cacheNames) => {
|
||||||
|
return Promise.all(
|
||||||
|
cacheNames
|
||||||
|
.filter((name) => name !== CACHE_NAME && name !== PRAYER_CACHE)
|
||||||
|
.map((name) => caches.delete(name))
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
self.clients.claim();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch event - network first for API, cache first for static
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
const url = new URL(event.request.url);
|
||||||
|
|
||||||
|
// API calls - network first with cache fallback
|
||||||
|
if (url.origin === 'https://api.aladhan.com' || url.origin === 'https://api.alquran.cloud') {
|
||||||
|
event.respondWith(
|
||||||
|
fetch(event.request)
|
||||||
|
.then((response) => {
|
||||||
|
const responseClone = response.clone();
|
||||||
|
caches.open(PRAYER_CACHE).then((cache) => {
|
||||||
|
cache.put(event.request, responseClone);
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
})
|
||||||
|
.catch(() => caches.match(event.request))
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static assets - cache first
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(event.request).then((cachedResponse) => {
|
||||||
|
if (cachedResponse) return cachedResponse;
|
||||||
|
return fetch(event.request).then((response) => {
|
||||||
|
const responseClone = response.clone();
|
||||||
|
caches.open(CACHE_NAME).then((cache) => {
|
||||||
|
cache.put(event.request, responseClone);
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Push event - handle push notifications
|
||||||
|
self.addEventListener('push', (event) => {
|
||||||
|
if (!event.data) return;
|
||||||
|
|
||||||
|
const data = event.data.json();
|
||||||
|
const options = {
|
||||||
|
body: data.body,
|
||||||
|
icon: '/icon-192.png',
|
||||||
|
badge: '/icon-192.png',
|
||||||
|
vibrate: [200, 100, 200],
|
||||||
|
tag: data.tag || 'prayer-notification',
|
||||||
|
renotify: true,
|
||||||
|
requireInteraction: true,
|
||||||
|
actions: [
|
||||||
|
{ action: 'open', title: 'Open App' },
|
||||||
|
{ action: 'dismiss', title: 'Dismiss' }
|
||||||
|
],
|
||||||
|
data: data.data || {}
|
||||||
|
};
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
self.registration.showNotification(data.title, options)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notification click event
|
||||||
|
self.addEventListener('notificationclick', (event) => {
|
||||||
|
event.notification.close();
|
||||||
|
|
||||||
|
if (event.action === 'dismiss') return;
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
|
||||||
|
// If app is already open, focus it
|
||||||
|
for (const client of clientList) {
|
||||||
|
if (client.url.includes(self.location.origin) && 'focus' in client) {
|
||||||
|
return client.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Otherwise open new window
|
||||||
|
if (clients.openWindow) {
|
||||||
|
return clients.openWindow('/');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Background sync for periodic prayer time updates
|
||||||
|
self.addEventListener('sync', (event) => {
|
||||||
|
if (event.tag === 'prayer-times-sync') {
|
||||||
|
event.waitUntil(syncPrayerTimes());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Periodic background sync (if supported)
|
||||||
|
self.addEventListener('periodicsync', (event) => {
|
||||||
|
if (event.tag === 'prayer-times-periodic') {
|
||||||
|
event.waitUntil(syncPrayerTimes());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function syncPrayerTimes() {
|
||||||
|
try {
|
||||||
|
// Get cached prayer times or fetch new ones
|
||||||
|
const cache = await caches.open(PRAYER_CACHE);
|
||||||
|
const cached = await cache.match('/prayer-times');
|
||||||
|
|
||||||
|
if (cached) {
|
||||||
|
const data = await cached.json();
|
||||||
|
scheduleNotifications(data);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to sync prayer times:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleNotifications(prayerData) {
|
||||||
|
const now = Date.now();
|
||||||
|
const prayers = ['Fajr', 'Dhuhr', 'Asr', 'Maghrib', 'Isha'];
|
||||||
|
|
||||||
|
prayers.forEach((prayer) => {
|
||||||
|
const prayerTimeStr = prayerData.timings[prayer];
|
||||||
|
if (!prayerTimeStr) return;
|
||||||
|
|
||||||
|
const [hours, minutes] = prayerTimeStr.split(':').map(Number);
|
||||||
|
const prayerDate = new Date();
|
||||||
|
prayerDate.setHours(hours, minutes, 0, 0);
|
||||||
|
|
||||||
|
const timeUntilPrayer = prayerDate.getTime() - now;
|
||||||
|
|
||||||
|
// Schedule notification 5 minutes before prayer
|
||||||
|
const notifyTime = timeUntilPrayer - 5 * 60 * 1000;
|
||||||
|
|
||||||
|
if (notifyTime > 0 && notifyTime < 24 * 60 * 60 * 1000) {
|
||||||
|
setTimeout(() => {
|
||||||
|
self.registration.showNotification(`Time for ${prayer}`, {
|
||||||
|
body: `It's time for ${prayer} prayer (${prayerTimeStr})`,
|
||||||
|
icon: '/icon-192.png',
|
||||||
|
badge: '/icon-192.png',
|
||||||
|
vibrate: [200, 100, 200],
|
||||||
|
tag: `prayer-${prayer}`,
|
||||||
|
renotify: true,
|
||||||
|
requireInteraction: true
|
||||||
|
});
|
||||||
|
}, notifyTime);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message event - communicate with main thread
|
||||||
|
self.addEventListener('message', (event) => {
|
||||||
|
if (event.data.type === 'SCHEDULE_PRAYERS') {
|
||||||
|
scheduleNotifications(event.data.payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.data.type === 'SKIP_WAITING') {
|
||||||
|
self.skipWaiting();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[Nūr SW] Service Worker loaded');
|
||||||
+6
-5
@@ -8,11 +8,11 @@ export default defineConfig({
|
|||||||
VitePWA({
|
VitePWA({
|
||||||
registerType: 'autoUpdate',
|
registerType: 'autoUpdate',
|
||||||
manifest: {
|
manifest: {
|
||||||
name: 'Nūr — Muslim Companion',
|
name: 'Nur Falah — Muslim Companion',
|
||||||
short_name: 'Nūr',
|
short_name: 'Nur Falah',
|
||||||
description: 'Prayer times, Quran, Qibla, and more — your daily Islamic companion',
|
description: 'Prayer times, Quran, Qibla, and more — your daily Islamic companion',
|
||||||
theme_color: '#0f766e',
|
theme_color: '#070A0D',
|
||||||
background_color: '#f0fdfa',
|
background_color: '#070A0D',
|
||||||
display: 'standalone',
|
display: 'standalone',
|
||||||
orientation: 'portrait-primary',
|
orientation: 'portrait-primary',
|
||||||
icons: [
|
icons: [
|
||||||
@@ -28,7 +28,8 @@ export default defineConfig({
|
|||||||
handler: 'StaleWhileRevalidate',
|
handler: 'StaleWhileRevalidate',
|
||||||
options: { cacheName: 'quran-api', expiration: { maxEntries: 50, maxAgeSeconds: 86400 * 7 } }
|
options: { cacheName: 'quran-api', expiration: { maxEntries: 50, maxAgeSeconds: 86400 * 7 } }
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
cleanupOutdatedCaches: true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [svelte()],
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
globals: true,
|
||||||
|
include: ['src/**/*.test.{js,ts}'],
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
reporter: ['text', 'json', 'html'],
|
||||||
|
exclude: ['node_modules/', 'src/**/*.test.{js,ts}', 'src/main.js', 'src/sw.js']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user