21 Commits

Author SHA1 Message Date
FalahMobile 93e26d749d fix: require JWT auth on daily-chat endpoint, simplify Dockerfile Prisma generate
Build & Deploy Mobile / build-and-deploy (push) Failing after 10s
The /api/chat/daily route accepted a client-supplied userId with no
auth check. Require a verified Bearer JWT and derive the user from
the token instead. Also drop the manual openssl install and
--schema flag from the Dockerfile now that a second `prisma generate`
runs in the runner stage, and add a standard .dockerignore.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwWLS2FYnE9ofqDF9hQFxA
2026-07-09 12:11:56 +08:00
FalahMobile f3bb801715 test: point E2E test target to newest Netlify staging draft deploy 2026-07-08 19:32:58 +08:00
FalahMobile e2f8d7be66 fix(prisma): redirect SQLite file target to writeable /tmp path in serverless contexts 2026-07-08 19:28:55 +08:00
FalahMobile 8861e9143d ci(netlify): explicitly include SQLite dev.db in serverless function packages 2026-07-08 19:25:57 +08:00
FalahMobile 0874133bc1 ci(prisma): generate rhel-openssl binary targets for Netlify serverless execution 2026-07-08 19:20:57 +08:00
FalahMobile 5ef605d550 ci(netlify): set up database generation and seeding for serverless deployment 2026-07-08 19:15:41 +08:00
FalahMobile 0cacc4fa1a feat(falahmobile): retheme app to premium dark spatial UI, implement waqf module, and resolve testing suite config 2026-07-08 18:07:07 +08:00
FalahMobile 3eb89a5665 fix update runner label 2026-07-07 05:49:41 +08:00
FalahMobile d7b2d19359 fix: production readiness bugfix batch
- Security: JWT fallback removed, auth checks on unprotected endpoints
- Build: tsconfig excludes tests, ESLint flat config fixed
- Money: Cashout deducts balance in transaction, UI refreshes
- Core Chat: Nur page wired to send/receive messages
- Profile: Replaced hardcoded data with useAuth()
- Forum: Fixed thread detail query, premium check on posts
- Misc: /dua→/prayer, forum thread API handles ?id= param

QA gate: pending
2026-07-06 20:20:31 +08:00
FalahMobile 9f33038c37 feat: implement Phase 5 Nur screen and Phase 6 Profile screen
Nur screen:
- Header with gold-purple avatar, online status, Home button
- Mood check-in with 5 emojis (gold border on selected)
- Chat with gold gradient user bubbles, purple AI bubbles
- Slash commands: /quran, /hadith, /dua, /prayer
- Input bar with gold-purple send button
- Persona switcher: NurBuddy, Ghazali, Ibn Abbas, Rabi'a

Profile screen:
- Avatar with gold border, name, email, Premium + Lvl 7 badges
- FLH Wallet card with balance display
- 2x2 quick action grid (Souq, Waqf, Forum, Halal Monitor)
- Settings list with streak, notifications, appearance, upgrade
- Sign out with red button
2026-07-03 11:44:49 +08:00
FalahMobile caa3f794b5 feat: implement Phase 4 Home screen with all 5 sections
- Prayer countdown ring (SVG circular progress, Aladhan API + fallback)
- AI morning briefing (gold gradient card, greeting by time of day)
- Verse of the day (Ayatul Kursi with Arabic, translation, gold header)
- Quick Dhikr (3 counters: SubhanAllah 33, Alhamdulillah 33, Allahu Akbar 34)
- Community feed (3 Instagram-style posts with like/comment/share actions)
2026-07-03 11:34:05 +08:00
FalahMobile c2d054e705 fix: add --webpack flag for Next.js 16 Turbopack compatibility
- serwist/next requires webpack, Next.js 16 defaults to Turbopack
- dev/build scripts now use --webpack flag
- Disable serwist in dev mode to avoid warnings
2026-07-03 11:22:38 +08:00
FalahMobile 1d82eb66ac feat: implement PWA scaffold, dark design system, and 3-tab glassmorphism nav
- Add @serwist/next for PWA service worker (precaching, offline support)
- Create public/manifest.json with dark theme (#0a0a0f) and SVG icons
- Rewrite globals.css with full dark premium design system (Tailwind v4)
  - Design tokens: bg-deep, bg-card, gold, nur-purple, emerald, borders, text
  - Utility classes: .glass, .glass-light, .gold-gradient, .nur-gradient, .gold-purple-gradient
  - Prayer ring SVG styles
- Replace old 5-tab nav with 3-tab floating glassmorphism pill
  - Home (House), Nur center raised 56px gold-purple circle, Profile (User)
  - Active gold #D4AF37, inactive #666, safe-area-aware
- Create src/lib/api/wordpress.ts fetchAPI wrapper with JWT auth
- Update layout.tsx with manifest, apple-web-app, Viewport export
2026-07-02 20:54:00 +08:00
FalahMobile 61baf3ac5d chore: sync all local changes and new files
- Update next.config.ts
- Add netlify.toml, Docker configs, e2e tests
- Add env templates, docs (README, ROADMAP, TODO)
- Add vitest/playwright test configs
- Add component and lib tests
- Add install script, portainer env template
- Update .gitignore (tsbuildinfo, deno.lock, dev.db)
2026-07-02 20:01:48 +08:00
FalahMobile 2e66def057 Remove Turbopack from production build config
Turbopack cannot resolve lightningcss native .node binaries at build time.
Reverting to webpack for production builds (Turbopack dev server still works
via next dev --turbopack flag).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 10:30:01 +01:00
FalahMobile 5ef1e443a5 Fix bcryptjs corrupt install and add @types/bcryptjs
bcryptjs node_modules directory was empty (corrupt install). Removed and
reinstalled cleanly; added @types/bcryptjs so TypeScript resolves the module.
Prisma client regeneration also resolved all schema-related TS errors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 10:20:39 +01:00
FalahMobile 32dfc7b892 Fix remaining dark theme pages: thread detail and halal monitor
forum/[threadId]/page.tsx and halal-monitor/page.tsx were missed in
the initial light theme pass — now fully converted to white cards,
emerald buttons, and light backgrounds.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 09:58:20 +01:00
FalahMobile 8ce5fae25c Fix QA issues: bottom padding consistency and sign-out spacing
- dhikr, nur, upgrade: pb-20 → pb-24 on guest/loading screens for
  consistent clearance above the 64px bottom nav
- profile: add mb-4 to sign-out wrapper so it doesn't flush the nav

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 09:50:13 +01:00
FalahMobile 857e4c948a Redesign all pages to light theme (Qalby-inspired mobile UI)
Complete UI overhaul replacing dark gold theme with a clean mobile-first
light theme: gradient emerald headers, white card components, light backgrounds,
emerald/amber CTAs, and a new home dashboard with feature grid navigation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 09:42:26 +01:00
FalahMobile 1834370e40 Mobile-first UI overhaul for Souq, Wallet, Forum pages
- Souq: bottom sheets, emoji category chips, skeleton loading, toast notifications
- Wallet: gradient balance card with GBP preview, status badges, clean history
- Forum: back nav, skeleton loading, bottom sheet create, thread author avatars
- All: pb-24 clearance, rounded-2xl, active: press states, no hover: classes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 09:19:45 +01:00
FalahMobile e2365e29fe Add Prayer Times, Dhikr counter, Quran tracker with mobile UI overhaul
- Prayer times page: arc progress ring, next-prayer countdown, clean prayer list
- Dhikr counter: SVG ring with glow, 3-phase sequence (SubhanAllah/Alhamdulillah/AllahuAkbar), streak tracking
- Daily Quran reading tracker API routes (GET/POST)
- Bottom nav updated: Prayer + Dhikr tabs, Repeat2 icon for dhikr, Moon for prayer
- Home page redirects to /prayer as primary entry point
- Dockerfile: openssl added to builder stage, explicit prisma schema path fixes build
- Schema: dhikrStreak, quranStreak on User; DhikrSession and QuranReading models
- Stub wallet top-up checkout to fix TypeScript build error

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 08:05:51 +01:00
88 changed files with 6845 additions and 1799 deletions
-80
View File
@@ -1,80 +0,0 @@
# /deploy — Falah OS Deployment Command
Deploy Falah OS CE to the production Docker host at 192.168.0.17 via SSH.
## Context
- **Docker host:** 192.168.0.17 (LAN only, not reachable from internet)
- **Portainer UI:** http://192.168.0.17:9000 (credentials: Admin / bizgEh-xirgyh-3mowta)
- **Repo:** https://github.com/falah-consultancy-limited/falah-os-master (branch: main)
- **Deploy method:** SSH into host → clone repo → docker compose up --build
- **Deploy script:** `scripts/deploy-ssh.sh` (already committed to main)
## Pre-generated secrets (already embedded in deploy-ssh.sh)
```
JWT_SECRET=iLlAXxewIvlPlqv0pj7ITk0dFV0FNi0gVDE5sliX8AwjjxQC
ENCRYPTION_KEY=usYw2LCmKmOpWFuhiAw7X0DVCqRlK9h8oF4bJ4mWqPYiZKdM
ADMIN_SECRET=dd8GU158UJaL4krWWlxWq4uey1AhzmSzNolT10e4lXtC3rBT
POSTGRES_PASSWORD=vjeDTUdeBpMms3rZINwY4zlDVlDnz6vE
REDIS_PASSWORD=BrQkdok11Zfdyu1cCRSGSZYwRk33o20s
```
## What to do
1. Ensure you are on a machine on the same LAN as 192.168.0.17.
2. Make sure your SSH key is accepted by the host (try `ssh root@192.168.0.17 echo ok`).
3. Ensure this repo is up to date: `git pull origin main`
4. Run the deployment:
```bash
bash scripts/deploy-ssh.sh root@192.168.0.17
```
If the SSH user is not root, pass it as an argument:
```bash
bash scripts/deploy-ssh.sh ubuntu@192.168.0.17
```
5. After deployment, verify all 7 services are healthy:
| Service | URL |
|---------|-----|
| Desktop UI | http://192.168.0.17:3005 |
| API Gateway | http://192.168.0.17:3000/health |
| Ummah ID | http://192.168.0.17:3001/health |
| Wallet | http://192.168.0.17:3002/health |
| RAMZ | http://192.168.0.17:3003/health |
| Mock-Net | http://192.168.0.17:3004/health |
| falahd | http://192.168.0.17:3006/health |
## Troubleshooting
- **SSH key refused:** Add your public key to `~/.ssh/authorized_keys` on the host, or use password auth (`ssh -o PasswordAuthentication=yes`).
- **git clone fails on host:** The host has no internet. Use `docker context create remote --docker "host=ssh://root@192.168.0.17"` on your local machine and run `docker compose up --build` locally pointing at the remote daemon.
- **Port 3005 not responding:** Likely the React app container failed to build. Check logs: `ssh root@192.168.0.17 "cd /opt/falah-os && docker compose logs app --tail=50"`.
- **Port 3006 not responding:** falahd TypeScript build failed. Check: `ssh root@192.168.0.17 "cd /opt/falah-os && docker compose logs falahd --tail=50"`.
- **api-gateway not healthy:** It depends on all 6 upstream services being healthy. Fix the failing upstream first.
## Services architecture
```
:3000 api-gateway (nginx) — routes all /ummahid /wallet /ramz /mocknet /falahd traffic
:3001 ummahid — identity, ZK proof, JWT auth
:3002 wallet — FLH token, transfers, 1.5% fee
:3003 ramz — Shariah contracts (6 templates, 7 rules)
:3004 mocknet — sandbox / chaos testnet
:3005 app — React 18 desktop UI (Vite build)
:3006 falahd — tRPC daemon: system metrics, app lifecycle
:5432 postgres — reserved for v1.4 persistence
:6379 redis — reserved for v1.4 persistence
```
## Admin access
All `/api/admin/*` routes require header: `x-admin-secret: dd8GU158UJaL4krWWlxWq4uey1AhzmSzNolT10e4lXtC3rBT`
```bash
# Example: list all wallets
curl http://192.168.0.17:3002/api/admin/wallets \
-H "x-admin-secret: dd8GU158UJaL4krWWlxWq4uey1AhzmSzNolT10e4lXtC3rBT"
# Example: system metrics via falahd tRPC
curl http://192.168.0.17:3006/trpc/system.metrics
```
+9 -4
View File
@@ -1,7 +1,12 @@
node_modules node_modules
.next .next
*.db
.env
.git .git
Dockerfile *.md
docker-compose.yml .env*
.gitignore
.eslint*
.prettier*
.next
npm-debug.log
yarn-debug.log
yarn-error.log
+20
View File
@@ -24,6 +24,26 @@ REDIS_PASSWORD=
# iBaaS API Key for EE Gateway # iBaaS API Key for EE Gateway
IBAAS_API_KEY=falah-os-ibaas-key-2026 IBAAS_API_KEY=falah-os-ibaas-key-2026
# =============================================================================
# POLAR — required for subscription payments (polar.sh)
# =============================================================================
# Get from polar.sh > Settings > Developers > Access Tokens
POLAR_ACCESS_TOKEN=
# Get from polar.sh > Settings > Webhooks > your endpoint secret
POLAR_WEBHOOK_SECRET=
# App URL for Polar redirect URLs
NEXT_PUBLIC_APP_URL=http://localhost:3000
# =============================================================================
# AI — required for Nur AI coaching
# =============================================================================
OPENCORE_URL=https://opencode.ai/zen/go/v1/chat/completions
OPENCORE_API_KEY=
# ============================================================================= # =============================================================================
# OPTIONAL — have sensible defaults # OPTIONAL — have sensible defaults
# ============================================================================= # =============================================================================
+34
View File
@@ -0,0 +1,34 @@
name: Build & Deploy Mobile
on:
push:
branches:
- staging
env:
REGISTRY: ghcr.io
IMAGE_NAME: maifors/falah-mobile
SWARM_SERVICE: falah_falah-mobile
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Log in to Container Registry
run: echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login ${{ env.REGISTRY }} -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
- name: Build and Push Docker Image
run: |
IMAGE_TAG="${REGISTRY}/${IMAGE_NAME}:staging-${GITHUB_SHA::7}"
docker build -t ${IMAGE_TAG} -t ${REGISTRY}/${IMAGE_NAME}:staging .
docker push ${IMAGE_TAG}
docker push ${REGISTRY}/${IMAGE_NAME}:staging
- name: Deploy to Swarm
run: |
docker service update \
--image ${REGISTRY}/${IMAGE_NAME}:staging \
--with-registry-auth \
${SWARM_SERVICE}
-87
View File
@@ -1,87 +0,0 @@
name: Deploy to Netlify
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm install --ignore-scripts
- name: Install service dependencies
run: |
for dir in docker/services/ummahid docker/services/wallet docker/services/ramz docker/services/mocknet; do
(cd "$dir" && npm install)
done
- run: npm test
env:
JWT_SECRET: test-jwt-secret-32-chars-minimum!
ENCRYPTION_KEY: test-encryption-key-32-chars-min!!!
ADMIN_SECRET: test-admin-secret
POSTGRES_PASSWORD: test-postgres-password
REDIS_PASSWORD: test-redis-password
NODE_ENV: test
CI: true
deploy:
runs-on: ubuntu-latest
needs: test
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm install --ignore-scripts
- run: npx netlify-cli deploy --dir=./netlify --functions=./netlify/functions --prod --message "Deploy ${{ github.sha }}"
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
preview:
runs-on: ubuntu-latest
needs: test
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm install --ignore-scripts
- run: npx netlify-cli deploy --dir=./netlify --functions=./netlify/functions --message "Preview PR#${{ github.event.pull_request.number }}"
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
notify-failure:
runs-on: ubuntu-latest
if: failure() && github.event_name == 'push'
needs: [test, deploy]
steps:
- name: Send Slack notification
uses: slackapi/slack-github-action@v1.25.0
with:
payload: |
{
"text": "🚨 Falah OS Deployment Failed",
"blocks": [{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*Falah OS Deployment Failed!*\nRepo: ${{ github.repository }}\nBranch: ${{ github.ref_name }}\nCommit: ${{ github.sha }}\nWorkflow: ${{ github.workflow }}"
}
}]
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
+39
View File
@@ -0,0 +1,39 @@
name: QA Workflow
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node 20
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run Vitest (Unit + Integration)
run: npx vitest run
- name: Install Playwright Browsers
run: npx playwright install --with-deps chromium
- name: Run Playwright (E2E)
run: npx playwright test
- name: Upload Playwright report
uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
retention-days: 30
+17
View File
@@ -31,6 +31,8 @@ yarn-error.log*
# Testing # Testing
coverage/ coverage/
playwright-report/
test-results/
# Secrets (DO NOT COMMIT) # Secrets (DO NOT COMMIT)
*.pem *.pem
@@ -43,6 +45,21 @@ coverage/
*.sql *.sql
!infrastructure/postgres/init.sql !infrastructure/postgres/init.sql
# TypeScript
*.tsbuildinfo
# Deno (not used in this project)
deno.lock
# SQLite dev database
dev.db
dev.db-journal
prisma/dev.db
prisma/dev.db-journal
# Generated service worker (serwist build output)
public/sw.js
# Claude (workspace config, not project code) # Claude (workspace config, not project code)
.claude/* .claude/*
!.claude/commands/ !.claude/commands/
-102
View File
@@ -1,102 +0,0 @@
# Hermes ↔ Pi Alignment Status
## Communication Method
| Channel | Status | Notes |
|---------|--------|-------|
| SSH to MacBook Air (192.168.0.8) | ❌ Blocked | Ports closed, auth fails |
| Tailscale (100.76.3.26) | ❌ Blocked | Ping works, all ports closed |
| Bonjour/mDNS | ✅ Discovered | `wm's MacBook Air` visible |
| Gitea Shared Repo | ✅ Working | Used as message board |
**Resolution:** Created Gitea issue #1 as shared message board.
## Deliverables
### 1. Gitea Repo: `wmj/falah-mobile`
- **URL:** `http://13.140.161.244:3080/wmj/falah-mobile`
- **Branch:** `feat/learn-module`
- **Issue #1:** Hermes Alignment Request
### 2. Schema Changes
```prisma
model Course {
id String @id @default(cuid())
slug String @unique
title String
description String
difficulty String
imageUrl String?
modules Module[]
}
model Module {
id String @id @default(cuid())
courseId String
title String
description String
content String // markdown
videoUrl String?
order Int
quizData String? // JSON: [{question, options, correctIndex}]
}
```
### 3. Seed Data
| Course | Modules | Difficulty |
|--------|---------|------------|
| Daily Fiqh for Beginners | 5 | beginner |
Each module includes:
- `content`: Markdown with Key Concept, Details, Reflection, Action Step
- `quizData`: 1 question with 4 options
- `order`: Sequence number
### 4. Pages Created
| Route | Purpose |
|-------|---------|
| `/learn` | Course listing with difficulty badges |
| `/learn/[courseSlug]/[moduleId]` | Module content + quiz |
### 5. Hermes Patch Applied
```javascript
// seed-learn.js
modules.map(({ id: _id, courseId: _cid, ...m }) => ({
...m,
quizData: typeof m.quizData === 'string'
? m.quizData
: JSON.stringify(m.quizData || []),
})),
```
## Next Steps (Pending Hermes)
| Task | Owner | Status |
|------|-------|--------|
| Audio player integration | Hermes / Pi | ⏳ Pending |
| Progress tracking per user | Hermes | ⏳ Pending |
| PostgreSQL schema migration | Hermes | ⏳ Pending |
| Merge seed data from MacBook Air | Hermes | ⏳ Pending |
| Quiz scoring / validation | Pi | ⏳ Pending |
| Branch merge to main | Hermes | ⏳ Pending |
## How to Sync
```bash
# Pull Pi's branch
git fetch http://13.140.161.244:3080/wmj/falah-mobile feat/learn-module
git checkout -b feat/learn-module origin/feat/learn-module
# Or create PR via Gitea UI
# http://13.140.161.244:3080/wmj/falah-mobile/pulls
```
---
**Pi** — mac-mini-1 (100.72.2.115)
**Hermes** — MacBook Air (100.76.3.26)
**Gitea** — git.falahos.my / 13.140.161.244:3080
Executable
+133
View File
@@ -0,0 +1,133 @@
.PHONY: help install build up down restart logs health backup deploy clean \
ummahid-logs wallet-logs ramz-logs mocknet-logs api-logs app-logs \
db-shell db-backup db-restore redis-shell redis-flush \
stats ps setup env-check test test-watch test-coverage
# Falah OS v1.3 — Docker Compose management
# Usage: make help
help: ## Show available commands
@echo "Falah OS v1.3"
@echo ""
@echo "Usage: make [command]"
@echo ""
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}'
# ---------------------------------------------------------------------------
# Core lifecycle
# ---------------------------------------------------------------------------
install: ## Full install: generate secrets, write .env, start stack, health check
@bash install.sh
setup: ## First-time setup: copy .env.example → .env (manual secret entry)
@if [ -f .env ]; then echo ".env already exists, skipping."; else cp .env.example .env && echo "Created .env — fill in secrets before running 'make up'"; fi
env-check: ## Verify required env vars are set
@missing=0; \
for var in JWT_SECRET ENCRYPTION_KEY ADMIN_SECRET POSTGRES_PASSWORD REDIS_PASSWORD; do \
if [ -z "$$(grep -E "^$$var=.+" .env 2>/dev/null | cut -d= -f2)" ]; then \
echo "MISSING: $$var"; missing=1; \
fi; \
done; \
[ $$missing -eq 0 ] && echo "All required env vars are set." || exit 1
test: ## Run all tests
npm test
test-watch: ## Run tests in watch mode
npm run test:watch
test-coverage: ## Run tests with coverage report
npm run test:coverage
build: ## Build all service images
docker compose build
up: env-check ## Start the full stack
docker compose up -d
@echo "Waiting for services..."
@sleep 8
@$(MAKE) health
down: ## Stop and remove containers (preserves volumes)
docker compose down
restart: ## Restart all containers
docker compose restart
logs: ## Tail logs from all services
docker compose logs -f --tail=100
health: ## Run health checks against all services
@./scripts/health-check.sh
ps: ## Show container status
docker compose ps
stats: ## Live container resource usage
docker stats --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.NetIO}}"
# ---------------------------------------------------------------------------
# Per-service logs
# ---------------------------------------------------------------------------
ummahid-logs: ## Tail Ummah ID logs
docker compose logs -f ummahid
wallet-logs: ## Tail Wallet logs
docker compose logs -f wallet
ramz-logs: ## Tail RAMZ logs
docker compose logs -f ramz
mocknet-logs: ## Tail Mock-Net logs
docker compose logs -f mocknet
falahd-logs: ## Tail falahd daemon logs
docker compose logs -f falahd
api-logs: ## Tail API Gateway logs
docker compose logs -f api-gateway
app-logs: ## Tail App logs
docker compose logs -f app
# ---------------------------------------------------------------------------
# Database
# ---------------------------------------------------------------------------
db-shell: ## Open PostgreSQL shell
docker compose exec postgres psql -U $${POSTGRES_USER:-postgres}
db-backup: ## Dump all databases to backups/
@mkdir -p backups
docker compose exec postgres pg_dumpall -U $${POSTGRES_USER:-postgres} \
| gzip > backups/postgres_$$(date +%Y%m%d_%H%M%S).sql.gz
@echo "Backup saved to backups/"
db-restore: ## Restore from backup: make db-restore FILE=backups/postgres_xxx.sql.gz
@[ -n "$(FILE)" ] || (echo "Usage: make db-restore FILE=backups/postgres_xxx.sql.gz" && exit 1)
gunzip -c $(FILE) | docker compose exec -T postgres psql -U $${POSTGRES_USER:-postgres}
# ---------------------------------------------------------------------------
# Redis
# ---------------------------------------------------------------------------
redis-shell: ## Open Redis CLI
docker compose exec redis redis-cli -a $${REDIS_PASSWORD}
redis-flush: ## Flush all Redis data (will prompt for confirmation)
@echo "WARNING: this deletes all Redis data."
@read -p "Type 'yes' to confirm: " c && [ "$$c" = "yes" ] || exit 1
docker compose exec redis redis-cli -a $${REDIS_PASSWORD} FLUSHALL
# ---------------------------------------------------------------------------
# Cleanup
# ---------------------------------------------------------------------------
clean: ## Remove containers AND volumes (destroys all data — prompts first)
@echo "WARNING: this destroys all Falah OS containers and volumes."
@read -p "Type 'yes' to confirm: " c && [ "$$c" = "yes" ] || exit 1
docker compose down -v
@echo "Done. Run 'make up' to start fresh."
+483
View File
@@ -0,0 +1,483 @@
# Falah OS Master - Repository Guide
## 📚 Overview
Falah OS Master is a monorepo structure that manages the Falah Operating System ecosystem through Git submodules. This repository contains links to all core modules and services that make up the complete Falah OS platform.
## 🏗️ Repository Structure
This project follows a **modular architecture** with the following components:
```
Falah-OS-Master/
├── .gitmodules # Submodule configuration
├── README.md # This file
├── falah-os/ # Core OS
├── ulp-portal/ # User Portal
├── falah-os-sdk-py/ # Python SDK
├── falah-os-sdk/ # Main SDK
├── falah-os-mocknet/ # Mock Network
├── alah-os-mocknet/ # Alternative Mock Network
├── falah-os-ummahid/ # UmmaID Component
├── falah-os-ramz/ # RAMZ Component
├── falah-os-admin/ # Admin Dashboard
├── falah-os-app/ # Main Application
├── falah-os-istore/ # Store Interface
├── falah-os-dev-porta/ # Developer Portal
└── falah-os-landing/ # Landing Page
```
## 📋 Module Documentation
### Core Modules
| Module | Repository | Purpose | Status |
|--------|-----------|---------|--------|
| **falah-os** | `maifors/falah-os` | Core operating system kernel and runtime | Active |
| **falah-os-sdk** | `maifors/falah-os-sdk` | Main SDK for development | Active |
| **falah-os-sdk-py** | `maifors/falah-os-sdk-py` | Python SDK wrapper | Active |
### Portal & Interface Modules
| Module | Repository | Purpose | Status |
|--------|-----------|---------|--------|
| **ulp-portal** | `maifors/ulp-portal` | User Portal Interface | Active |
| **falah-os-admin** | `maifors/falah-os-admin` | Administration Dashboard | Active |
| **falah-os-dev-porta** | `maifors/falah-os-dev-porta` | Developer Portal | Active |
| **falah-os-landing** | `maifors/falah-os-landing` | Marketing Landing Page | Active |
### Application & Service Modules
| Module | Repository | Purpose | Status |
|--------|-----------|---------|--------|
| **falah-os-app** | `maifors/falah-os-app` | Main Application | Active |
| **falah-os-istore** | `maifors/falah-os-istore` | App Store Interface | Active |
### Identity & Network Modules
| Module | Repository | Purpose | Status |
|--------|-----------|---------|--------|
| **falah-os-ummahid** | `maifors/falah-os-ummahid` | Identity Management (UmmaID) | Active |
| **falah-os-ramz** | `maifors/falah-os-ramz` | RAMZ Protocol/Service | Active |
### Testing & Infrastructure Modules
| Module | Repository | Purpose | Status |
|--------|-----------|---------|--------|
| **falah-os-mocknet** | `maifors/falah-os-mocknet` | Mock Network for Testing | Active |
| **alah-os-mocknet** | `maifors/alah-os-mocknet` | Alternative Mock Network | Active |
---
## 🚀 Getting Started
### Prerequisites
- Git (v2.13+)
- Basic Unix/Linux knowledge
- Access to all submodule repositories
### Initial Setup
#### Clone the Repository with All Submodules
```bash
# Clone with recursive submodules initialization
git clone --recurse-submodules https://github.com/Falah-Consultancy-Limited/Falah-OS-Master.git
# Navigate to the repository
cd Falah-OS-Master
```
#### If Already Cloned Without Submodules
```bash
# Initialize and fetch all submodules
git submodule update --init --recursive
# Or use the shorthand
git submodule init
git submodule update
```
#### Update All Submodules to Latest
```bash
# Fetch the latest commits from all submodules
git submodule foreach git pull origin main
# Or use the parallel version (Git 2.8+)
git submodule foreach --parallel git pull origin main
```
---
## 📖 Development Workflow (SDLC)
### Phase 1: Planning & Requirements
1. **Issue Creation**: Create GitHub issues for new features/bugs
2. **Milestone Assignment**: Assign to relevant milestones
3. **Documentation**: Update module documentation if affected
### Phase 2: Development
#### Branch Strategy (Git Flow)
```
main (production)
└── develop (staging)
├── feature/description
├── bugfix/issue-number
└── hotfix/issue-number
```
#### Creating Feature Branches
```bash
# Update main/develop
git checkout develop
git pull origin develop
# Create feature branch
git checkout -b feature/ISSUE-NUMBER-description
# For specific module
cd falah-os
git checkout -b feature/ISSUE-NUMBER-description
git push origin feature/ISSUE-NUMBER-description
```
#### Working with Submodules
```bash
# Navigate to specific module
cd falah-os-admin
# Create and checkout feature branch
git checkout -b feature/new-feature
# Make changes and commit
git add .
git commit -m "feat: add new feature"
# Push changes
git push origin feature/new-feature
# Go back to master repo
cd ..
# Update submodule reference (if needed)
git add falah-os-admin
git commit -m "chore: update falah-os-admin submodule"
git push origin feature/ISSUE-NUMBER
```
### Phase 3: Testing
#### Unit Testing
```bash
# Run tests in specific module
cd <module-name>
npm test # For Node.js projects
pytest # For Python projects
go test ./... # For Go projects
```
#### Integration Testing
```bash
# Use mock networks for integration testing
cd falah-os-mocknet
# Follow module-specific test documentation
```
#### Staging Deployment
- Deploy to staging environment
- Run end-to-end tests
- Validate across all modules
### Phase 4: Code Review & Quality
#### Pull Request Checklist
- [ ] Feature branch created from `develop`
- [ ] All tests pass locally
- [ ] Code follows project style guide
- [ ] Documentation updated
- [ ] No breaking changes
- [ ] Linked to relevant GitHub issues
#### Review Requirements
- Minimum 2 approvals required
- CI/CD pipeline passes
- No merge conflicts
- Code coverage maintained
### Phase 5: Deployment
#### Staging Release
```bash
# Create release branch
git checkout -b release/v1.x.x develop
# Update version numbers and documentation
# Test thoroughly
# Create PR to main
# After approval and merge, tag the release
git tag -a v1.x.x -m "Release version 1.x.x"
git push origin v1.x.x
```
#### Production Deployment
- Merge `release/v1.x.x` to `main`
- Tag with version number
- Deploy to production
- Update all submodules references if needed
---
## 📝 Documentation Standards
### Commit Message Format
```
<type>(<scope>): <subject>
<body>
<footer>
```
#### Types
- `feat`: A new feature
- `fix`: A bug fix
- `docs`: Documentation only changes
- `style`: Changes that don't affect code meaning
- `refactor`: Code change that neither fixes a bug nor adds a feature
- `perf`: Code change that improves performance
- `test`: Adding or updating tests
- `chore`: Changes to build process, dependencies, etc.
#### Example
```
feat(admin-dashboard): add user management panel
Implemented new user management interface in the admin panel
with the following features:
- User listing with pagination
- Create/Edit/Delete user operations
- Role assignment
Fixes #123
```
### Pull Request Template
```markdown
## Description
Provide a brief summary of the changes
## Type of Change
- [ ] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Documentation update
## Related Issue
Closes #ISSUE_NUMBER
## Testing
Describe the tests run and results
## Screenshots (if applicable)
Add screenshots for UI changes
## Checklist
- [ ] My code follows the style guidelines
- [ ] I have performed a self-review
- [ ] I have commented complex areas
- [ ] I have updated documentation
- [ ] Tests pass locally
- [ ] No new warnings generated
```
### Module README Requirements
Each module should include:
1. **Overview**: What the module does
2. **Installation**: How to set it up
3. **Quick Start**: Basic usage example
4. **API Documentation**: For libraries/SDKs
5. **Configuration**: Setup and environment variables
6. **Testing**: How to run tests
7. **Contributing**: Contribution guidelines
8. **License**: License information
---
## 🔄 Release Management
### Versioning
All modules follow **Semantic Versioning (SemVer)**: `MAJOR.MINOR.PATCH`
- **MAJOR**: Breaking changes
- **MINOR**: New features (backward compatible)
- **PATCH**: Bug fixes (backward compatible)
### Release Cycle
1. **Development Phase** (2-4 weeks)
- Features merged to `develop`
- Daily integration testing
2. **Release Candidate Phase** (1 week)
- Release branch created: `release/v1.x.x`
- Bug fixes only
- Release notes prepared
3. **Production Release**
- Merge to `main`
- Tag release: `git tag -a v1.x.x`
- Update all dependent modules
---
## 🛠️ Common Tasks
### Sync All Submodules with Remote
```bash
git submodule foreach --recursive git fetch origin
git submodule foreach --recursive git pull origin main
```
### Check Submodule Status
```bash
git submodule status
```
### Add New Submodule
```bash
git submodule add https://github.com/maifors/new-module.git new-module
git add .gitmodules new-module
git commit -m "chore: add new-module submodule"
git push origin main
```
### Remove a Submodule
```bash
git submodule deinit -f path/to/module
rm -rf .git/modules/path/to/module
git rm -f path/to/module
git commit -m "chore: remove module submodule"
```
---
## 📋 Module Dependency Map
```
falah-os (Core)
├── falah-os-sdk (SDK)
│ ├── falah-os-app (App)
│ ├── falah-os-admin (Admin)
│ └── falah-os-istore (Store)
├── falah-os-sdk-py (Python SDK)
├── falah-os-ummahid (Identity)
└── falah-os-ramz (Protocol)
Portals & Interfaces
├── ulp-portal (User Portal)
├── falah-os-dev-porta (Dev Portal)
├── falah-os-landing (Landing)
└── falah-os-admin (Admin Portal)
Testing & Infrastructure
├── falah-os-mocknet (Mock Network)
└── alah-os-mocknet (Alt Mock Network)
```
---
## 🤝 Contributing Guidelines
### Before Starting
1. Check existing issues and PRs
2. Discuss major changes in issues first
3. Create branch from appropriate base (`develop` for features)
4. Keep commits atomic and well-documented
### During Development
1. Write clean, well-commented code
2. Follow project code style
3. Add/update tests for changes
4. Update documentation
5. Keep branch up-to-date with base branch
### Before Creating PR
1. Run all tests locally
2. Update CHANGELOG.md
3. Verify no conflicts
4. Add meaningful PR description
5. Link to relevant issues
---
## 📚 Additional Resources
- **Main Repository**: https://github.com/Falah-Consultancy-Limited/Falah-OS-Master
- **GitHub Issues**: Report bugs and request features
- **Project Board**: Track development progress
- **Wiki**: Additional documentation (if available)
---
## ✅ Checklist for New Team Members
- [ ] Cloned repository with all submodules
- [ ] Set up local development environment
- [ ] Reviewed this README
- [ ] Reviewed CONTRIBUTING.md in each module
- [ ] Understood branching strategy
- [ ] Set up IDE/editor with project standards
- [ ] Ran tests successfully
- [ ] Created first feature branch
- [ ] Reviewed commit message standards
- [ ] Understood release workflow
---
## 📞 Support & Contact
For questions or issues:
1. **GitHub Issues**: Create an issue in the relevant module repository
2. **Discussions**: Use GitHub Discussions for general questions
3. **Team Communication**: Reach out to the team lead
---
## 📄 License
This project and all submodules are licensed under the terms specified in each module's LICENSE file.
---
**Last Updated**: 2026-05-07
**Maintained By**: Falah Consultancy Limited
**Repository**: https://github.com/Falah-Consultancy-Limited/Falah-OS-Master
+68
View File
@@ -0,0 +1,68 @@
# Falah OS — Roadmap
**Deployment target:** Docker Compose + Komodo (solo-operator)
**Last updated:** April 13, 2026
---
## v1.3 — Current release
**Theme: Production readiness foundation**
### Completed
- [x] Netlify serverless API (auth, screening, certificates, products, finance, revocations)
- [x] Docker Compose stack — Ummah ID, Wallet, RAMZ, Mock-Net, nginx gateway
- [x] Healthchecks on all containers
- [x] Komodo deployment config (`komodo.toml`)
- [x] Makefile with lifecycle commands
- [x] Postgres init.sql + data layer provisioned (reserved for v1.4)
- [x] Rate limiting on nginx gateway
- [x] `.env.example` with all required vars documented
- [x] Hardcoded secrets removed — required vars fail loudly at startup
- [x] Documentation system — end-user, admin, developer, wiki, changelog
- [x] QA checklist and release checklist
### Known gaps (accepted for v1.3)
- No database persistence (in-memory only — see [Limitations](docs/wiki/limitations.md))
- No automated tests
- Frontend source not in repository
- CORS not restricted by origin on Docker services
---
## v1.4 — Next release
**Theme: Persistence + reliability**
- [ ] Wire Postgres to all 4 core services (ummahid, wallet, ramz, mocknet)
- [ ] Redis session caching
- [ ] User management API (create, update, deactivate accounts)
- [ ] Automated backup schedule via cron
- [ ] Basic unit tests for Netlify functions (Vitest)
- [ ] Basic unit tests for Docker services (Jest + supertest)
- [ ] CORS restricted to configured allowed origins
- [ ] Structured JSON logging with request IDs
---
## v1.5 — Future
**Theme: Developer experience**
- [ ] Frontend source code in repository (React + Vite + Tailwind)
- [ ] API versioning and formal deprecation policy
- [ ] Webhook system for external integrations
- [ ] Expanded Shariah contract templates
- [ ] SDK starter kits (JavaScript)
---
## Not in scope
These were in the previous roadmap. They are out of scope for a solo-operator product:
- Kubernetes / Istio / service mesh
- HashiCorp Vault (use OS secrets or Komodo secret store)
- Multi-region DR / chaos engineering
- Horizontal scaling beyond single Docker Compose host
- Mobile native SDKs (v2.x at earliest)
+148
View File
@@ -0,0 +1,148 @@
# Falah OS v1.3 Production - Implementation Tasks
## Phase 1: Core Infrastructure (Weeks 1-2)
### Week 1: Database & Redis HA
- [ ] Create `docker-compose.ha.yml` with PostgreSQL replicas
- [ ] Set up streaming replication configuration
- [ ] Configure pgBouncer for connection pooling
- [ ] Set up Redis Sentinel for automatic failover
- [ ] Configure Redis persistence (AOF + RDB)
- [ ] Create backup script with S3/GCS upload
- [ ] Set up automated daily backup schedule (cron)
- [ ] Test failover scenarios
### Week 2: Load Balancing & Service Mesh
- [ ] Install Traefik/HAProxy
- [ ] Configure SSL termination
- [ ] Set up health checks for all services
- [ ] Configure upstream routing
- [ ] (Optional) Install Istio
- [ ] Configure mTLS between services
- [ ] Update docker-compose.yml with new infrastructure
## Phase 2: Observability (Weeks 2-3)
### Week 2: Metrics & Logging
- [ ] Deploy Prometheus
- [ ] Create service exporters (Node, PostgreSQL, Redis)
- [ ] Build Grafana dashboards (system, API, business)
- [ ] Deploy Loki for log aggregation
- [ ] Implement trace ID propagation
- [ ] Configure log retention policies
- [ ] Set up error alerting
### Week 3: Alerting & Tracing
- [ ] Configure Prometheus Alertmanager
- [ ] Set up notification channels (email, Slack)
- [ ] Implement on-call rotation
- [ ] Deploy OpenTelemetry collectors
- [ ] Set up Jaeger for tracing
- [ ] Document runbooks for common alerts
## Phase 3: Security (Weeks 3-4)
### Week 3: Secrets & Network
- [ ] Deploy HashiCorp Vault
- [ ] Configure dynamic database credentials
- [ ] Migrate all secrets to Vault
- [ ] Implement volume encryption
- [ ] Deploy WAF (ModSecurity/Cloudflare)
- [ ] Configure network policies
### Week 4: Audit & Compliance
- [ ] Implement immutable audit logging
- [ ] Create transaction audit trail
- [ ] Run dependency vulnerability scan (Trivy)
- [ ] Scan all Docker images for CVEs
- [ ] Conduct penetration testing
- [ ] Document compliance controls
## Phase 4: Reliability (Weeks 4-5)
### Week 4: Disaster Recovery
- [ ] Design multi-region architecture
- [ ] Create DR automation scripts
- [ ] Document RTO/RPO procedures
- [ ] Test failover to DR region
- [ ] Verify backup restoration
### Week 5: Chaos & Resilience
- [ ] Deploy Chaos Mesh
- [ ] Create failure injection scenarios
- [ ] Implement circuit breakers
- [ ] Add retry with exponential backoff
- [ ] Set up dead letter queues
- [ ] Run first Game Day exercise
## Phase 5: Performance (Weeks 5-6)
### Week 5: Caching & Optimization
- [ ] Integrate CDN (Cloudflare/Fastly)
- [ ] Implement Redis API caching
- [ ] Enable HTTP/2 on nginx
- [ ] Configure response compression
- [ ] Optimize PostgreSQL queries
- [ ] Add indexes for common queries
### Week 6: Load Testing & Launch
- [ ] Write k6 load test scenarios
- [ ] Run baseline performance tests
- [ ] Conduct stress testing
- [ ] Define performance budgets
- [ ] Finalize SLA documentation
- [ ] Production go-live
---
## Documentation Tasks
- [ ] Update architecture diagrams
- [ ] Create deployment runbooks
- [ ] Document API references
- [ ] Write security policies
- [ ] Create user guides
## Testing Tasks
- [ ] Unit tests for all services
- [ ] Integration tests for API Gateway
- [ ] E2E tests for critical paths
- [ ] Performance benchmarks
- [ ] Security penetration tests
---
## New Features (If Time Permits)
- [ ] Multi-tenant isolation
- [ ] API versioning implementation
- [ ] Webhook system
- [ ] Plugin system design
- [ ] Mobile SDKs (iOS/Android)
---
## Dependencies
### External Services Needed
- S3/GCS bucket for backups
- CDN provider (Cloudflare/Fastly)
- Monitoring domain
- SSL certificates (Let's Encrypt)
- Email service for alerts (SendGrid/SES)
- PagerDuty/OpsGenie for on-call
### Tools Required
- k6 (load testing)
- Trivy (container scanning)
- Chaos Mesh (chaos engineering)
- Vault (secrets management)
- Prometheus/Grafana (monitoring)
- Loki (logging)
- Jaeger (tracing)
---
## Notes
- All critical (P0) tasks must complete before production launch
- P1 tasks should complete within 2 weeks of launch
- P2 tasks are optional for v1.3
- Review progress weekly with stakeholders
+148
View File
@@ -0,0 +1,148 @@
# Falah OS CE — Optional Community Extras
# Heavy open-source tools for advanced communities.
#
# Usage (add on top of the main stack):
# docker compose -f docker-compose.yml -f docker-compose.extras.yml up -d
#
# Or enable individual services:
# docker compose -f docker-compose.yml -f docker-compose.extras.yml up -d rocketchat mattermost-db mattermost
#
# Requirements: additional 4 GB RAM recommended.
services:
# ---------------------------------------------------------------------------
# Rocket.Chat — Full-featured community messaging
# Access: http://<host>:3100
# Replaces the built-in chat service for large communities
# ---------------------------------------------------------------------------
rocketchat:
image: rocket.chat:6
container_name: falah-rocketchat
ports:
- "3100:3000"
environment:
- MONGO_URL=mongodb://mongo-rc:27017/rocketchat
- MONGO_OPLOG_URL=mongodb://mongo-rc:27017/local
- ROOT_URL=http://localhost:3100
- PORT=3000
- DEPLOY_PLATFORM=docker
depends_on:
- mongo-rc
networks:
- falah-network
restart: unless-stopped
mongo-rc:
image: mongo:6
container_name: falah-mongo-rc
command: mongod --oplogSize 128 --replSet rs0
volumes:
- mongo-rc-data:/data/db
networks:
- falah-network
restart: unless-stopped
mongo-rc-init:
image: mongo:6
container_name: falah-mongo-rc-init
command: >
bash -c "sleep 10 && mongosh --host mongo-rc --eval 'rs.initiate({_id: \"rs0\", members: [{_id: 0, host: \"mongo-rc:27017\"}]})'"
depends_on:
- mongo-rc
networks:
- falah-network
restart: on-failure
# ---------------------------------------------------------------------------
# Moodle — Learning Management System
# Access: http://<host>:3200
# For madrasah, Islamic college, and structured course delivery
# ---------------------------------------------------------------------------
moodle:
image: bitnami/moodle:latest
container_name: falah-moodle
ports:
- "3200:8080"
environment:
- MOODLE_DATABASE_HOST=moodle-db
- MOODLE_DATABASE_PORT_NUMBER=3306
- MOODLE_DATABASE_NAME=moodle
- MOODLE_DATABASE_USER=moodle
- MOODLE_DATABASE_PASSWORD=${POSTGRES_PASSWORD}
- MOODLE_USERNAME=admin
- MOODLE_PASSWORD=${ADMIN_SECRET}
- MOODLE_EMAIL=admin@falah.os
- MOODLE_SITE_NAME=Falah OS Learning
depends_on:
- moodle-db
volumes:
- moodle-data:/bitnami/moodle
- moodle-data-dir:/bitnami/moodledata
networks:
- falah-network
restart: unless-stopped
moodle-db:
image: mariadb:10.6
container_name: falah-moodle-db
environment:
- MARIADB_ROOT_PASSWORD=${POSTGRES_PASSWORD}
- MARIADB_DATABASE=moodle
- MARIADB_USER=moodle
- MARIADB_PASSWORD=${POSTGRES_PASSWORD}
volumes:
- moodle-db-data:/var/lib/mysql
networks:
- falah-network
restart: unless-stopped
# ---------------------------------------------------------------------------
# Nextcloud — File sharing, calendar, contacts, collaborative docs
# Access: http://<host>:3300
# For neighbourhood committees, college research, document sharing
# ---------------------------------------------------------------------------
nextcloud:
image: nextcloud:28-apache
container_name: falah-nextcloud
ports:
- "3300:80"
environment:
- POSTGRES_HOST=nextcloud-db
- POSTGRES_DB=nextcloud
- POSTGRES_USER=nextcloud
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- NEXTCLOUD_ADMIN_USER=admin
- NEXTCLOUD_ADMIN_PASSWORD=${ADMIN_SECRET}
- NEXTCLOUD_TRUSTED_DOMAINS=localhost 192.168.0.17
depends_on:
- nextcloud-db
volumes:
- nextcloud-data:/var/www/html
networks:
- falah-network
restart: unless-stopped
nextcloud-db:
image: postgres:16-alpine
container_name: falah-nextcloud-db
environment:
- POSTGRES_DB=nextcloud
- POSTGRES_USER=nextcloud
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
volumes:
- nextcloud-db-data:/var/lib/postgresql/data
networks:
- falah-network
restart: unless-stopped
volumes:
mongo-rc-data:
moodle-data:
moodle-data-dir:
moodle-db-data:
nextcloud-data:
nextcloud-db-data:
+330
View File
@@ -0,0 +1,330 @@
# Falah OS v1.3 - High Availability Infrastructure
# Usage: docker-compose -f docker-compose.yml -f docker-compose.ha.yml up -d
version: '3.8'
services:
# =============================================================================
# HIGH AVAILABILITY DATABASE
# =============================================================================
postgres-primary:
image: postgres:16-alpine
container_name: falah-postgres-primary
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=falahsecure
- POSTGRES_DB=falahdb
- POSTGRES_HOST_AUTH_METHOD=trust
volumes:
- postgres-primary-data:/var/lib/postgresql/data
- ./infrastructure/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql
networks:
- falah-network
deploy:
resources:
limits:
cpus: '2.0'
memory: 4G
reservations:
cpus: '1.0'
memory: 2G
command: >
postgres
-c max_connections=300
-c shared_buffers=2GB
-c effective_cache_size=6GB
-c maintenance_work_mem=512MB
-c checkpoint_completion_target=0.9
-c wal_level=replica
-c max_wal_senders=10
-c wal_keep_size=1GB
-c hot_standby=on
postgres-replica-1:
image: postgres:16-alpine
container_name: falah-postgres-replica-1
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=falahsecure
- POSTGRES_DB=falahdb
volumes:
- postgres-replica-1-data:/var/lib/postgresql/data
networks:
- falah-network
depends_on:
- postgres-primary
deploy:
resources:
limits:
cpus: '2.0'
memory: 4G
command: >
postgres
-c hot_standby=on
-c hot_standby_feedback=on
-c max_standby_streaming_delay=30s
postgres-replica-2:
image: postgres:16-alpine
container_name: falah-postgres-replica-2
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=falahsecure
- POSTGRES_DB=falahdb
volumes:
- postgres-replica-2-data:/var/lib/postgresql/data
networks:
- falah-network
depends_on:
- postgres-primary
deploy:
resources:
limits:
cpus: '2.0'
memory: 4G
command: >
postgres
-c hot_standby=on
-c hot_standby_feedback=on
-c max_standby_streaming_delay=30s
# =============================================================================
# REDIS HIGH AVAILABILITY (Sentinel)
# =============================================================================
redis-master:
image: redis:7-alpine
container_name: falah-redis-master
command: >
redis-server
--appendonly yes
--maxmemory 2gb
--maxmemory-policy allkeys-lru
--requirepass falahsecure
volumes:
- redis-master-data:/data
networks:
- falah-network
deploy:
resources:
limits:
cpus: '1.0'
memory: 2G
redis-replica-1:
image: redis:7-alpine
container_name: falah-redis-replica-1
command: >
redis-server
--appendonly yes
--replicaof falah-redis-master 6379
--masterauth falahsecure
--requirepass falahsecure
volumes:
- redis-replica-1-data:/data
networks:
- falah-network
depends_on:
- redis-master
deploy:
resources:
limits:
cpus: '1.0'
memory: 2G
redis-replica-2:
image: redis:7-alpine
container_name: falah-redis-replica-2
command: >
redis-server
--appendonly yes
--replicaof falah-redis-master 6379
--masterauth falahsecure
--requirepass falahsecure
volumes:
- redis-replica-2-data:/data
networks:
- falah-network
depends_on:
- redis-master
deploy:
resources:
limits:
cpus: '1.0'
memory: 2G
redis-sentinel:
image: redis:7-alpine
container_name: falah-redis-sentinel
command: >
redis-sentinel
--sentinel monitor falah-master falah-redis-master 6379 2
--sentinel down-after-milliseconds falah-master 5000
--sentinel failover-timeout falah-master 60000
--sentinel parallel-syncs falah-master 1
volumes:
- ./infrastructure/redis/sentinel.conf:/etc/redis/sentinel.conf
networks:
- falah-network
depends_on:
- redis-master
- redis-replica-1
- redis-replica-2
# =============================================================================
# CONNECTION POOLING
# =============================================================================
pgbouncer:
image: pgbouncer/pgbouncer:latest
container_name: falah-pgbouncer
environment:
- DATABASE_URL=postgresql://postgres:falahsecure@falah-postgres-primary:5432/falahdb
- POOL_MODE=transaction
- MAX_CLIENT_conn=500
- DEFAULT_POOL_SIZE=50
- MIN_POOL_SIZE=10
volumes:
- ./infrastructure/pgbouncer/pgbouncer.ini:/etc/pgbouncer/pgbouncer.ini
- ./infrastructure/pgbouncer/userlist.txt:/etc/pgbouncer/userlist.txt
ports:
- "6432:5432"
networks:
- falah-network
depends_on:
- postgres-primary
# =============================================================================
# LOAD BALANCER (Traefik)
# =============================================================================
traefik:
image: traefik:v3.0
container_name: falah-traefik
command:
- "--api.insecure=true"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--certificatesresolvers.letsencrypt.acme.email=admin@falah-os.com"
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
- "--certificatesresolvers.letsencrypt.acme.tlschallenge=true"
- "--log.level=INFO"
- "--accesslog=true"
ports:
- "80:80"
- "443:443"
- "8080:8080"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./infrastructure/traefik/traefik.yml:/etc/traefik/traefik.yml:ro
- ./infrastructure/traefik/acme.json:/letsencrypt/acme.json
networks:
- falah-network
restart: unless-stopped
# =============================================================================
# OBSERVABILITY STACK
# =============================================================================
prometheus:
image: prom/prometheus:latest
container_name: falah-prometheus
volumes:
- ./infrastructure/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- prometheus-data:/prometheus
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
- '--storage.tsdb.retention.time=30d'
ports:
- "9090:9090"
networks:
- falah-network
restart: unless-stopped
grafana:
image: grafana/grafana:latest
container_name: falah-grafana
environment:
- GF_SECURITY_ADMIN_USER=admin
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD:-changeme}
- GF_INSTALL_PLUGINS=grafana-piechart-panel
volumes:
- grafana-data:/var/lib/grafana
- ./infrastructure/grafana/dashboards:/etc/grafana/provisioning/dashboards:ro
- ./infrastructure/grafana/datasources:/etc/grafana/provisioning/datasources:ro
ports:
- "3000:3000"
networks:
- falah-network
depends_on:
- prometheus
restart: unless-stopped
loki:
image: grafana/loki:latest
container_name: falah-loki
volumes:
- ./infrastructure/loki/loki-config.yml:/etc/loki/local-config.yaml:ro
- loki-data:/loki
ports:
- "3100:3100"
networks:
- falah-network
restart: unless-stopped
promtail:
image: grafana/promtail:latest
container_name: falah-promtail
volumes:
- ./infrastructure/promtail/promtail-config.yml:/etc/promtail/config.yml:ro
- /var/lib/docker/containers:/var/lib/docker/containers:ro
command:
- "-config.file=/etc/promtail/config.yml"
networks:
- falah-network
restart: unless-stopped
alertmanager:
image: prom/alertmanager:latest
container_name: falah-alertmanager
volumes:
- ./infrastructure/alertmanager/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro
- alertmanager-data:/alertmanager
ports:
- "9093:9093"
networks:
- falah-network
restart: unless-stopped
volumes:
postgres-primary-data:
driver: local
postgres-replica-1-data:
driver: local
postgres-replica-2-data:
driver: local
redis-master-data:
driver: local
redis-replica-1-data:
driver: local
redis-replica-2-data:
driver: local
prometheus-data:
driver: local
grafana-data:
driver: local
loki-data:
driver: local
alertmanager-data:
driver: local
networks:
falah-network:
driver: bridge
ipam:
config:
- subnet: 172.28.0.0/16
+203
View File
@@ -0,0 +1,203 @@
# Local Development with Docker
# Usage: docker-compose -f docker-compose.local.yml up -d
version: '3.8'
services:
# =============================================================================
# CORE LAYER - Falah OS Services
# =============================================================================
# Ummah ID - Sovereign Identity Service
ummahid:
build:
context: ./docker/services/ummahid
dockerfile: Dockerfile
container_name: falah-ummahid
ports:
- "3001:3000"
environment:
- NODE_ENV=development
- SERVICE_NAME=ummahid
- REDIS_URL=redis://redis:6379
- DATABASE_URL=postgresql://postgres:falahdev@postgres:5432/falahid
- JWT_SECRET=dev_jwt_secret_change_in_production
- ENCRYPTION_KEY=dev_encryption_key_change_in_production
depends_on:
- postgres
- redis
networks:
- falah-dev-network
restart: unless-stopped
# Wallet Service - FLH Currency Management
wallet:
build:
context: ./docker/services/wallet
dockerfile: Dockerfile
container_name: falah-wallet
ports:
- "3002:3000"
environment:
- NODE_ENV=development
- SERVICE_NAME=wallet
- REDIS_URL=redis://redis:6379
- DATABASE_URL=postgresql://postgres:falahdev@postgres:5432/falahwallet
- MOCKNET_URL=http://mocknet:3000
- PROTOCOL_FEE_PERCENT=1.5
depends_on:
- postgres
- redis
- ummahid
networks:
- falah-dev-network
restart: unless-stopped
# RAMZ Contract Engine - Shariah-Compliant Smart Contracts
ramz:
build:
context: ./docker/services/ramz
dockerfile: Dockerfile
container_name: falah-ramz
ports:
- "3003:3000"
environment:
- NODE_ENV=development
- SERVICE_NAME=ramz
- REDIS_URL=redis://redis:6379
- DATABASE_URL=postgresql://postgres:falahdev@postgres:5432/falahramz
- SHARIAH_RULES=NO_RIBA,NO_GHARAR,NO_MAYSIR,HALAL_COMMODITY,ASSET_BACKED,MUTUAL_CONSENT,NO_BAI_INAH
depends_on:
- postgres
- redis
- ummahid
- wallet
networks:
- falah-dev-network
restart: unless-stopped
# Mock-Net Sandbox - Digital Twin Environment
mocknet:
build:
context: ./docker/services/mocknet
dockerfile: Dockerfile
container_name: falah-mocknet
ports:
- "3004:3000"
environment:
- NODE_ENV=development
- SERVICE_NAME=mocknet
- REDIS_URL=redis://redis:6379
- DATABASE_URL=postgresql://postgres:falahdev@postgres:5432/falahmock
- ENABLE_CHAOS_TESTING=true
depends_on:
- postgres
- redis
networks:
- falah-dev-network
restart: unless-stopped
# =============================================================================
# API GATEWAY
# =============================================================================
api-gateway:
build:
context: ./docker/services/api-gateway
dockerfile: Dockerfile
container_name: falah-api-gateway
ports:
- "3000:3000"
environment:
- NODE_ENV=development
- SERVICE_NAME=api-gateway
- REDIS_URL=redis://redis:6379
- UMMAHID_URL=http://ummahid:3000
- WALLET_URL=http://wallet:3000
- RAMZ_URL=http://ramz:3000
- MOCKNET_URL=http://mocknet:3000
depends_on:
- redis
- ummahid
- wallet
- ramz
networks:
- falah-dev-network
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
# =============================================================================
# DATA LAYER
# =============================================================================
postgres:
image: postgres:16-alpine
container_name: falah-postgres-dev
ports:
- "5432:5432"
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=falahdev
- POSTGRES_DB=falahdb
volumes:
- postgres-dev-data:/var/lib/postgresql/data
- ./docker/infrastructure/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql
networks:
- falah-dev-network
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: falah-redis-dev
ports:
- "6379:6379"
volumes:
- redis-dev-data:/data
networks:
- falah-dev-network
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
command: redis-server --appendonly yes
# =============================================================================
# FRONTEND (Netlify Dev)
# =============================================================================
# Uncomment for local frontend development
# frontend:
# image: node:20-alpine
# working_dir: /app
# command: sh -c "npm install -g netlify-cli && netlify dev"
# volumes:
# - .:/app
# ports:
# - "8888:8888"
# - "1313:1313"
# networks:
# - falah-dev-network
volumes:
postgres-dev-data:
driver: local
redis-dev-data:
driver: local
networks:
falah-dev-network:
driver: bridge
ipam:
config:
- subnet: 172.30.0.0/16
+177
View File
@@ -0,0 +1,177 @@
# FalahMobile — Production Readiness Bugfix Plan
> **Lead:** pi-agent · **Date:** 2026-07-06 · **Branch:** `staging` · **Target:** `stagingx.falahos.my`
---
## Situation Report
Complete codebase audit of FalahMobile identified **25 issues** across 6 independent domains. Zero file overlap between domains enables parallel execution.
### Build Status (Before)
| Check | Status |
|-------|--------|
| `next build` | ❌ Fails — TypeScript errors |
| `tsc --noEmit` | ❌ 10 errors (missing deps, conflicting types) |
| ESLint | ❌ Circular dependency crash |
| Vitest | ❌ Missing deps |
| Playwright | ❌ Missing deps |
---
## Squad Execution Plan
```
┌─────────────────────────────────────┐
│ Lead: pi-agent │
│ - Branch: staging │
│ - Coordination & QA gate │
│ - Gitea wiki │
└─────────────────────────────────────┘
┌───────────┬───────────────┼───────────────┬───────────┐
│ │ │ │ │
┌────┴────┐ ┌────┴────┐ ┌──────┴──────┐ ┌──────┴──────┐ ┌──┴──────┐
│Squad 1 │ │Squad 2 │ │ Squad 3 │ │ Squad 4 │ │Squad 5 │
│Security │ │ Build │ │ Money │ │ Core Chat │ │ Profile │
│OpenCode │ │OpenCode │ │ OpenCode │ │ OpenCode │ │Claude │
└────┬────┘ └────┬────┘ └──────┬──────┘ └──────┬──────┘ └──┬──────┘
│ │ │ │ │
3 files 3 files 2 files 1 file 1 file
```
| Squad | Commander | Files | Task |
|-------|-----------|-------|------|
| 🛡️ **Security** | OpenCode | `chat/daily/route.ts`, `chat/history/[userId]/route.ts`, `forum/posts/route.ts` | Add auth verification & premium checks |
| 🏗️ **Build** | OpenCode | `package.json`, `node_modules/` | Install missing devDeps, fix vitest setup |
| 💰 **Money** | OpenCode | `wallet/route.ts`, `wallet/page.tsx` | Fix cashout balance deduction, UI refresh |
| 🧠 **Core Chat** | OpenCode | `nur/page.tsx` | Wire send/receive, persona API, mood check-in |
| 👤 **Profile** | Claude | `profile/page.tsx` | Replace hardcoded data with `useAuth()` |
### Quick Fixes (Applied Directly by Lead)
| # | Fix | File |
|---|-----|------|
| 1 | Remove JWT fallback secret — fail loudly in prod | `src/lib/auth.ts` |
| 2 | Fix ESLint config — remove circular dependency | `eslint.config.mjs` |
| 3 | Fix tsconfig — exclude test files from build check | `tsconfig.json` |
| 4 | Fix `.gitignore` — wildcard `.env*` coverage | `.gitignore` |
| 5 | Fix `/dua``/prayer` slash command | `nur/page.tsx` |
| 6 | Fix forum thread detail query param | `forum/[threadId]/page.tsx` |
---
## QA Gate Checklist
> **Non-negotiable.** Every item must pass before staging deployment.
### Build & Type Safety
```
[ ] next build --webpack — Compiles without errors
[ ] tsc --noEmit — Zero type errors
[ ] eslint src/ — Zero lint errors
```
### Tests
```
[ ] npx vitest run — All unit tests pass
[ ] npx playwright test — All e2e tests pass
```
### Functional Verification
```
[ ] Auth flow: register → login → protected route
[ ] Cashout flow: create cashout → balance deducted
[ ] Nur chat: send message → AI responds → message appears
[ ] Persona switch: change scholar → API called → persisted
[ ] Mood check-in: select mood → stored in localStorage
[ ] Forum: create thread → post reply → view thread detail
[ ] Prayer times: loads with geolocation
[ ] Halal Monitor: search city → shows map with markers
[ ] Wallet: balance displayed correctly → cashout submitted
```
### Security
```
[ ] All API endpoints auth-protected (except login/register)
[ ] No hardcoded secrets in source code
[ ] Premium-gated features return 403 for free users
```
### Docker & Deployment
```
[ ] docker build -t falah-mobile:staging . — Builds successfully
[ ] Docker Swarm stack deploys cleanly
[ ] Health check passes after deployment
[ ] Traefik routes correctly
```
---
## Deployment Runbook — Staging
### 1. Build Docker Image
```bash
docker build -t falahos/falah-mobile:staging .
```
### 2. Push to Registry
```bash
docker tag falahos/falah-mobile:staging git.falahos.my/falahos/falah-mobile:staging
docker push git.falahos.my/falahos/falah-mobile:staging
```
### 3. Deploy to Swarm
```bash
# Update the service with the new image
docker service update \
--image git.falahos.my/falahos/falah-mobile:staging \
--with-registry-auth \
falah_falah-mobile
```
### 4. Verify
```bash
docker service ps falah_falah-mobile
curl -I https://stagingx.falahos.my/mobile
```
### 5. Rollback (if needed)
```bash
docker service rollback falah_falah-mobile
```
---
## Infrastructure
### Docker Swarm Stack
- **Manager Node:** `vmi3361598` (Contabo VPS, 8GB RAM)
- **Stack Name:** `falah`
- **Service:** `falah_falah-mobile`
- **Image:** `falahos/falah-mobile:latest` → staging tag
- **Proxy:** Traefik with Let's Encrypt
- **Route:** `Host(falahos.my) && PathPrefix(/mobile)`
- **Data:** SQLite on Docker volume `falah-mobile-data`
### Credentials (Bitwarden: `bitwarden.falahos.my`)
- **Gitea:** `wmj` / `Abedib@99` at `git.falahos.my`
- **Staging SSH:** `root@13.140.161.244` / `Abedib@99`
- **Gitea API Token:** `hermes-bot` token
---
## Rollback Procedure
```bash
# Rollback the service to previous image
ssh root@13.140.161.244
docker service rollback falah_falah-mobile
docker service ps falah_falah-mobile
# If rollback fails, redeploy from known-good backup
docker service update \
--image falahos/falah-mobile:latest \
falah_falah-mobile
```
+46
View File
@@ -0,0 +1,46 @@
import { test, expect } from '@playwright/test';
test.describe('Authentication Flow', () => {
test('should handle login, registration, and logout', async ({ page }) => {
// Visit site → redirects to /home → see bottom nav
await page.goto('/');
await expect(page.locator('nav')).toBeVisible();
// Click Profile tab to find Login button
await page.click('text=Profile');
await expect(page).toHaveURL(/\/profile/);
// Click Login → see login form
await page.click('a[href="/login"]');
await expect(page.locator('form')).toBeVisible();
// Try invalid login → see error message
await page.fill('input[type="email"]', 'invalid@example.com');
await page.fill('input[type="password"]', 'wrongpassword');
await page.click('button[type="submit"]');
await expect(page.locator('text=Invalid credentials')).toBeVisible();
// Navigate to registration
await page.click('a[href="/register"]');
// Fill registration form → submit → redirect to home
const randomEmail = `newuser_${Date.now()}_${Math.floor(Math.random() * 1000)}@example.com`;
await page.fill('input[type="text"]', 'Test User');
await page.fill('input[type="email"]', randomEmail);
await page.fill('input[placeholder="Min. 8 characters"]', 'securepassword123');
await page.click('button[type="submit"]');
// Check redirect and authentication state
await expect(page).toHaveURL(/\/nur/);
// Go to Profile tab and check authenticated state
await page.click('text=Profile');
await expect(page.locator('nav')).toContainText('Profile');
await expect(page.locator('button:has-text("Logout")')).toBeVisible();
// Click logout → redirect to login → see login button
await page.click('button:has-text("Logout")');
await expect(page).toHaveURL(/\/login/);
await expect(page.locator('button:has-text("Sign In")')).toBeVisible();
});
});
+19 -12
View File
@@ -1,13 +1,20 @@
import { dirname } from "path"; // ESLint flat config for FalahMobile
import { fileURLToPath } from "url"; // Uses @eslint/js directly to avoid eslintrc circular dependency
import { FlatCompat } from "@eslint/eslintrc"; import js from '@eslint/js'
import nextPlugin from '@next/eslint-plugin-next'
const __filename = fileURLToPath(import.meta.url); export default [
const __dirname = dirname(__filename); js.configs.recommended,
{
const compat = new FlatCompat({ plugins: {
baseDirectory: __dirname, '@next/next': nextPlugin,
}); },
rules: {
const eslintConfig = [...compat.extends("next/core-web-vitals")]; ...nextPlugin.configs.recommended.rules,
export default eslintConfig; ...nextPlugin.configs['core-web-vitals'].rules,
},
},
{
ignores: ['**/node_modules/**', '.next/**', '**/*.js', '**/*.mjs'],
},
]
Executable
+240
View File
@@ -0,0 +1,240 @@
#!/usr/bin/env bash
# =============================================================================
# Falah OS — One-line installer
#
# curl -fsSL https://raw.githubusercontent.com/falah-consultancy-limited/falah-os-master/main/install.sh | bash
# — or —
# wget -qO- https://raw.githubusercontent.com/falah-consultancy-limited/falah-os-master/main/install.sh | bash
#
# What it does:
# 1. Checks prerequisites (git, docker, docker compose, openssl)
# 2. Clones the repo (or pulls if already cloned)
# 3. Generates all required secrets
# 4. Writes .env (skips any var already set by the environment)
# 5. Starts the stack with docker compose
# 6. Runs a health check
# =============================================================================
set -euo pipefail
# ---------------------------------------------------------------------------
# Colours & helpers
# ---------------------------------------------------------------------------
GREEN='\033[0;32m'
BLUE='\033[0;34m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
BOLD='\033[1m'
NC='\033[0m'
info() { echo -e "${BLUE}[falah]${NC} $*"; }
success() { echo -e "${GREEN}[falah]${NC} $*"; }
warn() { echo -e "${YELLOW}[falah]${NC} $*"; }
fatal() { echo -e "${RED}[falah] ERROR:${NC} $*" >&2; exit 1; }
# ---------------------------------------------------------------------------
# Banner
# ---------------------------------------------------------------------------
echo ""
echo -e "${BOLD}${GREEN}╔═══════════════════════════════════════╗${NC}"
echo -e "${BOLD}${GREEN}║ Falah OS — Installer v1.3 ║${NC}"
echo -e "${BOLD}${GREEN}║ Shariah-compliant digital economy ║${NC}"
echo -e "${BOLD}${GREEN}╚═══════════════════════════════════════╝${NC}"
echo ""
# ---------------------------------------------------------------------------
# Prerequisite checks
# ---------------------------------------------------------------------------
info "Checking prerequisites..."
check_cmd() {
command -v "$1" >/dev/null 2>&1 || fatal "$1 is required but not installed. $2"
}
check_cmd git "Install via your package manager (apt/brew/dnf)."
check_cmd docker "Install Docker: https://docs.docker.com/get-docker/"
check_cmd openssl "Install via your package manager (apt/brew/dnf)."
# Docker Compose v2 (plugin) or v1 (standalone)
if docker compose version >/dev/null 2>&1; then
COMPOSE="docker compose"
elif command -v docker-compose >/dev/null 2>&1; then
COMPOSE="docker-compose"
else
fatal "Docker Compose is required. Install: https://docs.docker.com/compose/install/"
fi
# Docker daemon running?
docker info >/dev/null 2>&1 || fatal "Docker daemon is not running. Start Docker and try again."
success "Prerequisites OK"
echo ""
# ---------------------------------------------------------------------------
# Clone or update
# ---------------------------------------------------------------------------
REPO_URL="https://github.com/falah-consultancy-limited/falah-os-master.git"
INSTALL_DIR="${FALAH_INSTALL_DIR:-$HOME/falah-os}"
if [ -d "$INSTALL_DIR/.git" ]; then
info "Found existing install at $INSTALL_DIR — pulling latest..."
git -C "$INSTALL_DIR" pull --ff-only origin main
else
info "Cloning Falah OS into $INSTALL_DIR..."
git clone --depth 1 "$REPO_URL" "$INSTALL_DIR"
fi
cd "$INSTALL_DIR"
success "Codebase ready at $INSTALL_DIR"
echo ""
# ---------------------------------------------------------------------------
# Generate secrets
# ---------------------------------------------------------------------------
gen_secret() {
openssl rand -base64 48 | tr -d '=+/' | cut -c1-48
}
info "Generating secrets..."
# Respect any secrets already exported in the environment
JWT_SECRET="${JWT_SECRET:-$(gen_secret)}"
ENCRYPTION_KEY="${ENCRYPTION_KEY:-$(gen_secret)}"
ADMIN_SECRET="${ADMIN_SECRET:-$(gen_secret)}"
POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-$(gen_secret)}"
REDIS_PASSWORD="${REDIS_PASSWORD:-$(gen_secret)}"
success "Secrets generated"
echo ""
# ---------------------------------------------------------------------------
# Write .env
# ---------------------------------------------------------------------------
if [ -f .env ]; then
warn ".env already exists — merging generated secrets for any blank values..."
# For each required var, only fill in if currently blank/missing in .env
fill_if_blank() {
local var="$1" val="$2"
if grep -qE "^${var}=\s*$" .env 2>/dev/null || ! grep -qE "^${var}=" .env 2>/dev/null; then
# Escape forward slashes in value for sed
local escaped_val
escaped_val=$(printf '%s\n' "$val" | sed 's/[\/&]/\\&/g')
if grep -qE "^${var}=" .env; then
sed -i "s|^${var}=.*|${var}=${escaped_val}|" .env
else
echo "${var}=${val}" >> .env
fi
fi
}
fill_if_blank JWT_SECRET "$JWT_SECRET"
fill_if_blank ENCRYPTION_KEY "$ENCRYPTION_KEY"
fill_if_blank ADMIN_SECRET "$ADMIN_SECRET"
fill_if_blank POSTGRES_PASSWORD "$POSTGRES_PASSWORD"
fill_if_blank REDIS_PASSWORD "$REDIS_PASSWORD"
else
info "Writing .env..."
cp .env.example .env
# Replace blank values for required vars
sed -i "s|^JWT_SECRET=.*|JWT_SECRET=${JWT_SECRET}|" .env
sed -i "s|^ENCRYPTION_KEY=.*|ENCRYPTION_KEY=${ENCRYPTION_KEY}|" .env
sed -i "s|^ADMIN_SECRET=.*|ADMIN_SECRET=${ADMIN_SECRET}|" .env
sed -i "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=${POSTGRES_PASSWORD}|" .env
sed -i "s|^REDIS_PASSWORD=.*|REDIS_PASSWORD=${REDIS_PASSWORD}|" .env
fi
success ".env configured"
echo ""
# ---------------------------------------------------------------------------
# Verify .env before launch
# ---------------------------------------------------------------------------
info "Verifying environment..."
missing=0
for var in JWT_SECRET ENCRYPTION_KEY ADMIN_SECRET POSTGRES_PASSWORD REDIS_PASSWORD; do
val=$(grep -E "^${var}=.+" .env 2>/dev/null | cut -d= -f2- || true)
if [ -z "$val" ]; then
warn "Still missing: $var"
missing=1
fi
done
[ "$missing" -eq 0 ] || fatal "Cannot start with missing required env vars. Edit .env and re-run."
success "Environment OK"
echo ""
# ---------------------------------------------------------------------------
# Start the stack
# ---------------------------------------------------------------------------
info "Starting Falah OS stack..."
$COMPOSE pull --quiet
$COMPOSE up -d
echo ""
info "Waiting for services to become ready..."
sleep 10
# ---------------------------------------------------------------------------
# Health check (inline — no dependency on scripts/health-check.sh)
# ---------------------------------------------------------------------------
PASS=0
FAIL=0
check_http() {
local name="$1" url="$2"
printf " %-20s" "$name"
if curl -sf --max-time 5 "$url" >/dev/null 2>&1; then
echo -e "${GREEN}healthy${NC}"
PASS=$((PASS + 1))
else
echo -e "${RED}not responding${NC}"
FAIL=$((FAIL + 1))
fi
}
echo -e "${BLUE}Service health:${NC}"
check_http "API Gateway" "http://localhost:3000/health"
check_http "Ummah ID" "http://localhost:3001/health"
check_http "Wallet" "http://localhost:3002/health"
check_http "RAMZ" "http://localhost:3003/health"
check_http "Mock-Net" "http://localhost:3004/health"
check_http "App" "http://localhost:3005/health"
check_http "falahd" "http://localhost:3006/health"
echo ""
# ---------------------------------------------------------------------------
# Done
# ---------------------------------------------------------------------------
if [ "$FAIL" -eq 0 ]; then
echo -e "${BOLD}${GREEN}╔═══════════════════════════════════════╗${NC}"
echo -e "${BOLD}${GREEN}║ Falah OS is running! ║${NC}"
echo -e "${BOLD}${GREEN}╚═══════════════════════════════════════╝${NC}"
echo ""
echo -e " Dashboard: ${BOLD}http://localhost:3005${NC}"
echo -e " API: ${BOLD}http://localhost:3000${NC}"
echo -e " Admin key: stored in ${BOLD}${INSTALL_DIR}/.env${NC}"
echo ""
echo -e " Useful commands:"
echo -e " cd ${INSTALL_DIR}"
echo -e " make logs # tail all service logs"
echo -e " make health # re-run health checks"
echo -e " make down # stop the stack"
echo ""
else
warn "${FAIL} service(s) did not respond. Check logs:"
echo ""
echo -e " cd ${INSTALL_DIR} && make logs"
echo ""
echo -e " The stack is running — services may still be starting up."
echo -e " Re-run health check in ~30 s: ${BOLD}make health${NC}"
fi
+38
View File
@@ -0,0 +1,38 @@
## Falah OS v1.3 — Komodo Deployment Config
## https://komo.do/docs/resources/stack
##
## This file defines the Falah OS stack for Komodo.
## Import via: Komodo UI → Stacks → Import → paste this file.
[[stack]]
name = "falah-os"
description = "Falah OS v1.3 — Halal Token Engine + iBaaS EE Gateway"
## Point at your repo and the compose file within it
[stack.config]
repo = "https://github.com/Falah-Consultancy-Limited/Falah-OS-Master.git"
branch = "main"
compose_file = "docker-compose.yml"
## Environment — set these in Komodo's secret store, not here
## Komodo UI → Variables → New Variable (mark as Secret)
##
## Required secrets to configure:
## JWT_SECRET — openssl rand -base64 32
## ENCRYPTION_KEY — openssl rand -base64 32
## ADMIN_SECRET — openssl rand -base64 32
## POSTGRES_PASSWORD — strong password
## REDIS_PASSWORD — strong password
##
## Optional:
## PROTOCOL_FEE_PERCENT (default: 1.5)
## SHARIAH_RULES (default: NO_RIBA,NO_GHARAR,...)
## ENABLE_CHAOS_TESTING (default: false)
## Webhook — enable auto-deploy on git push
[stack.config.webhook]
enabled = true
branch = "main"
## Alert on stack events (configure in Komodo UI)
## Komodo UI → Alerters → connect Slack / email as needed
+18
View File
@@ -0,0 +1,18 @@
[build]
command = "npx prisma db push && node scripts/seed-db.js && npm run build"
publish = ".next"
[[plugins]]
package = "@netlify/plugin-nextjs"
[context.production]
environment = { NODE_VERSION = "22" }
[context.deploy-preview]
environment = { NODE_VERSION = "22" }
[context.branch-deploy]
environment = { NODE_VERSION = "22" }
[functions]
included_files = ["prisma/dev.db", "dev.db", "node_modules/.prisma/client/dev.db"]
+11 -7
View File
@@ -1,10 +1,14 @@
import type { NextConfig } from "next"; import type { NextConfig } from 'next'
import withSerwistInit from '@serwist/next'
const withSerwist = withSerwistInit({
swSrc: 'src/sw.ts',
swDest: 'public/sw.js',
disable: process.env.NODE_ENV === 'development',
})
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: "standalone", output: process.env.NETLIFY === 'true' ? undefined : 'standalone',
turbopack: { }
root: process.cwd(),
},
};
export default nextConfig; export default withSerwist(nextConfig)
+16 -6
View File
@@ -3,33 +3,43 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev --webpack",
"build": "next build", "build": "next build --webpack",
"start": "next start", "start": "next start",
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"@prisma/client": "^5.22.0", "@prisma/client": "^5.22.0",
"@serwist/cli": "^9.5.11",
"@serwist/next": "^9.5.11",
"@types/bcryptjs": "^2.4.6",
"@types/leaflet": "^1.9.14",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"jose": "^6.2.3", "jose": "^6.2.3",
"leaflet": "^1.9.4",
"lucide-react": "^1.17.0", "lucide-react": "^1.17.0",
"next": "16.2.7", "next": "16.2.7",
"prisma": "^5.22.0", "prisma": "^5.22.0",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"stripe": "^17.0.0",
"leaflet": "^1.9.4",
"react-leaflet": "^5.0.0", "react-leaflet": "^5.0.0",
"@types/leaflet": "^1.9.14" "serwist": "^9.5.11",
"stripe": "^17.0.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@playwright/test": "^1.52.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@vitejs/plugin-react": "^4.4.1",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.2.7", "eslint-config-next": "16.2.7",
"jsdom": "^26.0.0",
"tailwindcss": "^4", "tailwindcss": "^4",
"typescript": "^5" "typescript": "^5",
"vitest": "^3.1.1"
} }
} }
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: 2,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'https://6a4e348d18f0e0308e7547ae--mobile2-staging-falah.netlify.app',
trace: 'on-first-retry',
video: 'retain-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});
+20
View File
@@ -0,0 +1,20 @@
# Falah OS v1.3 — Portainer Stack Environment Variables
# Paste these into Portainer → Stacks → (your stack) → Environment variables
# Generate secrets with: openssl rand -base64 48 | tr -d '=+/' | cut -c1-48
#
# REQUIRED — stack will not start without these
# ─────────────────────────────────────────────
JWT_SECRET=
ENCRYPTION_KEY=
ADMIN_SECRET=
POSTGRES_PASSWORD=
REDIS_PASSWORD=
# OPTIONAL — safe defaults shown
# ────────────────────────────────
POSTGRES_USER=postgres
PROTOCOL_FEE_PERCENT=1.5
SHARIAH_RULES=NO_RIBA,NO_GHARAR,NO_MAYSIR,HALAL_COMMODITY,ASSET_BACKED,MUTUAL_CONSENT,NO_BAI_INAH
ENABLE_CHAOS_TESTING=false
BIN
View File
Binary file not shown.
+33 -23
View File
@@ -1,6 +1,6 @@
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
binaryTargets = ["native", "linux-musl"] binaryTargets = ["native", "rhel-openssl-1.0.x", "rhel-openssl-3.0.x", "linux-musl", "debian-openssl-3.0.x"]
} }
datasource db { datasource db {
@@ -22,6 +22,15 @@ model User {
preferredName String? preferredName String?
coachingGoals String? coachingGoals String?
lastCoachedAt DateTime? lastCoachedAt DateTime?
dailyMsgCount Int @default(0)
dailyMsgResetAt DateTime?
stripeCustomerId String?
stripeSubscriptionId String?
trialEndsAt DateTime?
dhikrStreak Int @default(0)
dhikrStreakDate DateTime?
quranStreak Int @default(0)
quranStreakDate DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
listings Listing[] listings Listing[]
@@ -148,32 +157,33 @@ model HalalUsage {
periodEnd DateTime? periodEnd DateTime?
} }
// ── Micro-Learning (Learn Module) ── model HalalPlaceCache {
model Course {
id String @id @default(cuid()) id String @id @default(cuid())
slug String @unique cacheKey String @unique
title String data String
description String expiresAt DateTime
difficulty String // beginner | intermediate | advanced
imageUrl String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
modules Module[]
} }
model Module { model DhikrSession {
id String @id @default(cuid()) id String @id @default(cuid())
courseId String userId String
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) date String
title String count Int @default(0)
description String completed Boolean @default(false)
content String // markdown
videoUrl String?
order Int
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
quizData String? // JSON string: [{question, options:[], correctIndex}] @@unique([userId, date])
@@index([userId])
}
model QuranReading {
id String @id @default(cuid())
userId String
date String
pagesRead Int @default(0)
goalPages Int @default(1)
createdAt DateTime @default(now())
@@unique([userId, date])
@@index([userId])
} }
-76
View File
@@ -1,76 +0,0 @@
#!/usr/bin/env node
/**
* Seed script for micro-learning courses.
* Reads prisma/seed-learn.json and creates courses/modules in the database.
*
* Run:
* npx prisma db push
* node prisma/seed-learn.js
*/
const { PrismaClient } = require('@prisma/client');
const fs = require('fs');
const path = require('path');
const prisma = new PrismaClient();
async function main() {
const seedPath = path.join(__dirname, 'seed-learn.json');
const raw = fs.readFileSync(seedPath, 'utf-8');
const data = JSON.parse(raw);
console.log(`Seeding ${data.courses.length} course(s)...`);
for (const course of data.courses) {
const { modules, ...courseData } = course;
// Upsert course (match by slug)
const upserted = await prisma.course.upsert({
where: { slug: courseData.slug },
update: {
title: courseData.title,
description: courseData.description,
difficulty: courseData.difficulty,
imageUrl: courseData.imageUrl,
},
create: {
slug: courseData.slug,
title: courseData.title,
description: courseData.description,
difficulty: courseData.difficulty,
imageUrl: courseData.imageUrl,
},
});
console.log(` Course: ${upserted.title} (${upserted.id})`);
// Delete old modules (clean re-seed for dev)
await prisma.module.deleteMany({ where: { courseId: upserted.id } });
// Create modules — strip id/courseId since Prisma auto-generates them
if (modules && modules.length > 0) {
await prisma.module.createMany({
data: modules.map(({ id: _id, courseId: _cid, ...m }) => ({
...m,
courseId: upserted.id,
quizData:
typeof m.quizData === 'string'
? m.quizData
: JSON.stringify(m.quizData || []),
})),
});
console.log(`${modules.length} module(s) created`);
}
}
console.log('Seeding complete.');
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
-83
View File
@@ -1,83 +0,0 @@
{
"courses": [
{
"slug": "daily-fiqh-beginner",
"title": "Daily Fiqh for Beginners",
"description": "Essential rulings for everyday Muslim life — from waking up to going to bed. Short lessons you can listen to during your commute or while making breakfast.",
"difficulty": "beginner",
"imageUrl": "/images/courses/daily-fiqh-beginner.jpg",
"modules": [
{
"title": "The Intention of Wudu",
"description": "Wudu is not just washing body parts — it is a spiritual reset. Learn how intention transforms a shower into worship.",
"content": "## 🎯 Key Concept\n\nWudu is not just washing body parts — it is a spiritual reset. The Prophet ﷺ said, \"When a Muslim performs wudu and washes his face, every sin he committed with his eyes is washed away. When he washes his hands, every sin committed with his hands is washed away.\" (Sahih Muslim 244)\n\nBut wudu only counts if you **intend** it. The intention is the invisible thread that transforms a shower into worship.\n\n## 📖 Details\n\n**What is the intention?**\n\nIt is simply knowing in your heart: *I am doing this to purify myself for prayer, or to remove ritual impurity.* You do not need to speak it aloud. The scholars say the intention is \"the aim of the heart.\"\n\n**When to make it:**\n\nThe intention must exist when you begin washing your face — the first act of wudu. If you start washing and then remember, \"Oh, I should make wudu,\" it counts as long as you intended it before finishing.\n\n**Common mistake:**\n\nSome people say \"Bismillah\" and assume that is the intention. Bismillah is recommended, but it is not the intention itself. The intention lives in the heart, not on the tongue.\n\n## 🤔 Reflection\n\nThink about your last wudu. Were you rushing through it while mentally scrolling your to-do list? What if you paused at the tap and thought: *This water is washing away more than dust — it is washing away mistakes?*\n\n## ⚡ Action Step\n\nBefore your next prayer, stand at the sink for five seconds. Say silently: *I intend wudu to purify myself for prayer.* Feel the intention settle. Then begin.\n\n---\n\n*Sources: Sahih al-Bukhari, Sahih Muslim, Fiqh us-Sunnah (Sayyid Sabiq)*",
"videoUrl": null,
"order": 1,
"quizData": [
{
"question": "Where does the intention for wudu need to be made?",
"options": ["Before touching water", "While washing the face", "After finishing wudu", "Loudly, so others can hear"],
"correctIndex": 1
}
]
},
{
"title": "How to Perform Wudu Step by Step",
"description": "Allah describes wudu in the Quran with elegant precision. Learn the four acts in order: face, arms, head, feet.",
"content": "## 🎯 Key Concept\n\nAllah describes wudu in the Quran with elegant precision: \"Wash your faces, your hands to the elbows, wipe your heads, and wash your feet to the ankles.\" (5:6). Four acts, in order, done mindfully.\n\n## 📖 Details\n\n**Step 1: Face**\nWash from the hairline to the chin, and from ear to ear. The water must touch the skin. If you have a thick beard, run your wet fingers through it. The Prophet ﷺ did this.\n\n**Step 2: Arms to Elbows**\nWash from fingertips to elbows. Start with the right arm, then the left. Some scholars say order is recommended, not mandatory — but following the Sunnah brings barakah.\n\n**Step 3: Head**\nWipe the head with wet hands, from front to back and back to front. You only need to touch the hair or scalp. A single wipe is enough.\n\n**Step 4: Ears**\nWipe the inside and back of the ears with your wet index fingers and thumbs. The Prophet ﷺ said, \"The ears are part of the head.\" (Sunan Abi Dawud 111)\n\n**Step 5: Feet to Ankles**\nWash the feet, including between the toes, up to the ankle bone. Start right, then left.\n\n**What to say:**\nBismillah before starting. After finishing, the Prophet ﷺ would say: \"Ashhadu an la ilaha ill-Allah wahdahu la sharika lah, wa ashhadu anna Muhammadan abduhu wa rasuluh\" — bearing witness to the Oneness of Allah and the prophethood of Muhammad.\n\n## 🤔 Reflection\n\nWudu takes about two minutes. Yet it washes away sins and prepares you to stand before Allah. Compare that to the time you spend on social media. What if wudu became your favorite two minutes of the day?\n\n## ⚡ Action Step\n\nPerform wudu now, even if you do not need to pray immediately. Pay attention to every limb. Do not rush. Notice how your heart slows down.\n\n---\n\n*Sources: Quran 5:6, Sahih al-Bukhari, Sunan Abi Dawud*",
"videoUrl": null,
"order": 2,
"quizData": [
{
"question": "How many essential steps (pillars) does salah have?",
"options": ["5", "7", "10", "14"],
"correctIndex": 3
}
]
},
{
"title": "When Wudu Breaks",
"description": "Wudu is a fragile state. Know what breaks it and what does not, so you never pray in an invalid state.",
"content": "## 🎯 Key Concept\n\nWudu is a fragile state. The Prophet ﷺ described it as light on the face that fades when something breaks it. Knowing what breaks wudu saves you from praying in an invalid state — which is like building a house on sand.\n\n## 📖 Details\n\n**The eight things that break wudu:**\n\n1. **Anything exiting the front or back passage** — urine, stool, gas, or any other substance\n2. **Deep sleep** — if you lose awareness while lying down\n3. **Loss of consciousness** — fainting, intoxication, or anesthesia\n4. **Touching the private parts** — with the palm or inner fingers\n5. **Touching another's private parts** — with desire\n6. **Eating camel meat** — a specific ruling for camel\n7. **Apostasy** — leaving Islam (Allah forbid)\n8. **Blood or pus** — flowing from the body (minority view, but worth knowing)\n\n**What does NOT break wudu:**\n\n- Touching a non-mahram (the Prophet ﷺ shook hands with women)\n- Kissing your spouse (with or without desire)\n- Bleeding from a small cut\n- Vomiting\n- Laughing loudly in prayer (this breaks the prayer, not the wudu)\n- Doubting whether you broke wudu — certainty is required\n\n**The doubt rule:**\nIf you are unsure whether you broke wudu, assume you did not. The Prophet ﷺ said, \"If one of you feels something in his stomach and is unsure whether something came out, he should not leave the mosque unless he hears a sound or smells an odor.\" (Sahih Muslim 550)\n\n## 🤔 Reflection\n\nMany Muslims anxiously question their wudu status. How many prayers have been delayed by unnecessary doubt? The Sunnah teaches: build on certainty, not suspicion. Are you overthinking your purity?\n\n## ⚡ Action Step\n\nMake a mental note of the doubt rule. Next time you wonder, \"Did I break wudu?\" — if you are not sure, you did not. Proceed with confidence.\n\n---\n\n*Sources: Sahih al-Bukhari, Sahih Muslim, Fiqh us-Sunnah*",
"videoUrl": null,
"order": 3,
"quizData": [
{
"question": "If you are unsure whether you broke wudu, what should you assume?",
"options": ["Assume you broke it and make wudu again", "Assume you did not break it and continue", "Ask a friend what they think", "Skip prayer to be safe"],
"correctIndex": 1
}
]
},
{
"title": "The Call to Prayer",
"description": "The adhan is more than a reminder — it is an invitation from Allah. Learn what to do when you hear it.",
"content": "## 🎯 Key Concept\n\nThe adhan is more than a reminder — it is an invitation from Allah. When you hear it, you are being personally called to success. The Prophet ﷺ said, \"When you hear the muezzin, repeat what he says, then invoke blessings on me.\" (Sahih Muslim 384)\n\n## 📖 Details\n\n**What to do when you hear the adhan:**\n\n1. **Repeat after the muezzin** — silently or softly, phrase by phrase\n2. **Send blessings on the Prophet ﷺ** after the muezzin finishes\n3. **Ask for the wasilah** — the highest level of Paradise\n4. **Make dua** — your supplication between adhan and iqamah is not rejected\n\n**The exact dua:**\n\nAfter sending blessings on the Prophet ﷺ, say:\n> *Allahumma Rabba hadhihid-da'watit-tammah, was-salatil-qa'imah, ati Muhammadanil-wasilata wal-fadhilah, wab'athu maqaman mahmuda nilladhi wa'adtah.*\n\n(O Allah, Lord of this perfect call and established prayer, grant Muhammad the wasilah and virtue, and raise him to the praised station You promised him.)\n\n**After the adhan:**\n\nDo not rush. The time between adhan and iqamah is precious. Use it for:\n- Dua (supplication)\n- Optional prayer (rawatib/sunna prayers)\n- Quiet preparation\n\n**Modern challenge:**\n\nMany of us hear the adhan on our phones rather than from a mosque. The same rules apply. Pause. Respond. Let the call interrupt your day intentionally.\n\n## 🤔 Reflection\n\nThe adhan interrupts work, sleep, conversation, and entertainment — on purpose. It is a scheduled disruption designed to reorient your heart. Do you treat it as an annoyance or an invitation? What would change if you stopped everything for 60 seconds when you heard it?\n\n## ⚡ Action Step\n\nSet your phone's adhan notification to a voice you love, not a jarring beep. For the next three adhans, stop what you are doing, repeat the words, and make one sincere dua before the iqamah.\n\n---\n\n*Sources: Sahih al-Bukhari, Sahih Muslim, Sunan al-Nasa'i*",
"videoUrl": null,
"order": 4,
"quizData": [
{
"question": "What should you do immediately after hearing the adhan?",
"options": ["Rush to the prayer mat", "Repeat what the muezzin says", "Start eating if you were about to break your fast", "Change into clean clothes"],
"correctIndex": 1
}
]
},
{
"title": "The Essentials of Salah",
"description": "Salah is the backbone of a Muslim's day. Learn the 14 pillars that keep your prayer valid.",
"content": "## 🎯 Key Concept\n\nSalah is the backbone of a Muslim's day. The Prophet ﷺ said, \"The first matter that the slave will be brought to account for on the Day of Judgment is the prayer. If it is sound, the rest of his deeds will be sound. If it is corrupt, the rest of his deeds will be corrupt.\" (Sunan al-Tirmidhi 413)\n\nSalah has **14 pillars (arkan)**. Missing any one intentionally invalidates the prayer. Missing it by forgetfulness requires the forgetfulness prostration (sujud as-sahw) at the end.\n\n## 📖 Details\n\n**The 14 Pillars of Salah:**\n\n**Before Salah:**\n1. **Standing** (if able) — for obligatory prayers\n2. **The opening takbir** — saying *Allahu Akbar* to begin\n3. **Reciting al-Fatiha** — in every rak'ah of every prayer\n4. **Bowing (ruku)** — with tranquility\n5. **Rising from ruku** — with tranquility\n6. **Prostration (sujud)** — forehead, nose, hands, knees, and toes touching the ground\n7. **Sitting between prostrations** — with tranquility\n8. **The final tashahhud** — the testimony after the last sitting\n9. **Sitting for the final tashahhud** — with tranquility\n10. **The taslim** — saying *As-salamu alaykum* to end the prayer\n\n**During the prayer:**\n11. **Order** — the pillars must be performed in sequence\n12. **Tranquility (tuma'ninah)** — each position must be still for a moment\n13. **Intention** — knowing which prayer you are performing\n14. **Facing the qibla** — toward the Ka'bah in Makkah\n\n**The Forgetfulness Prostration:**\n\nIf you accidentally miss a pillar (like skipping a ruku or adding an extra rak'ah), prostrate twice *before* the taslim and say: *Subhana Rabbiyal-A'la* (Glory be to my Lord, the Most High).\n\n## 🤔 Reflection\n\nMany Muslims pray quickly, rushing through positions like a checklist. The Prophet ﷺ prayed so slowly that a companion said, \"I wanted to do something bad but remembered I was in prayer.\" (Sahih Muslim 543) What if your prayer was so present that it stopped you from sinning?\n\n## ⚡ Action Step\n\nIn your next prayer, add one extra second to each position. Feel your weight in ruku. Feel the ground beneath your forehead in sujud. Notice your breathing slow. That one second is the difference between a transaction and a conversation.\n\n---\n\n*Sources: Quran 2:238, Sahih al-Bukhari, Sahih Muslim, Sunan al-Tirmidhi*",
"videoUrl": null,
"order": 5,
"quizData": [
{
"question": "Which surah must be recited in every rak'ah of every prayer?",
"options": ["Surah al-Ikhlas", "Surah al-Fatiha", "Surah al-Baqarah", "Any surah the worshipper chooses"],
"correctIndex": 1
}
]
}
]
}
]
}
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 192 192">
<rect width="192" height="192" rx="32" fill="#0a0a0f"/>
<path d="M86 36c-28 8-50 34-50 64 0 36 28 64 64 64 18 0 34-8 46-20-6 2-12 4-18 4-30 0-54-24-54-54 0-22 14-42 34-50l-22-8z" fill="#D4AF37"/>
<path d="M114 56l8 22 20 4-16 14 6 22-18-10-18 10 6-22-16-14 20-4z" fill="#A78BFA"/>
</svg>

After

Width:  |  Height:  |  Size: 382 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<rect width="512" height="512" rx="64" fill="#0a0a0f"/>
<path d="M220 72c-74 20-130 90-130 170 0 96 76 172 172 172 48 0 90-20 122-54-16 6-32 10-48 10-82 0-146-64-146-146 0-58 36-110 90-134l-60-18z" fill="#D4AF37"/>
<path d="M308 144l20 58 54 10-42 36 14 58-48-26-48 26 14-58-42-36 54-10z" fill="#A78BFA"/>
</svg>

After

Width:  |  Height:  |  Size: 407 B

+23
View File
@@ -0,0 +1,23 @@
{
"name": "Falah",
"short_name": "Falah",
"description": "Your Islamic lifestyle companion",
"start_url": "/home",
"display": "standalone",
"background_color": "#0a0a0f",
"theme_color": "#0a0a0f",
"icons": [
{
"src": "/icons/icon-192.svg",
"sizes": "192x192",
"type": "image/svg+xml",
"purpose": "any maskable"
},
{
"src": "/icons/icon-512.svg",
"sizes": "512x512",
"type": "image/svg+xml",
"purpose": "any maskable"
}
]
}
+56
View File
@@ -0,0 +1,56 @@
const { PrismaClient } = require('@prisma/client');
const bcrypt = require('bcryptjs');
const prisma = new PrismaClient();
async function main() {
console.log("Seeding database...");
try {
const password = await bcrypt.hash('password123', 10);
const userData = [
{ email: 'ghazali@falahos.my', name: 'Abu Hamid Al-Ghazali', passwordHash: password, flhBalance: 5000 },
{ email: 'ibnabbas@falahos.my', name: 'Abdullah Ibn Abbas', passwordHash: password, flhBalance: 3000 },
{ email: 'rabia@falahos.my', name: "Rabi'a Al-Adawiyya", passwordHash: password, flhBalance: 2000 },
{ email: 'demo@falahos.my', name: 'Demo User', passwordHash: password, flhBalance: 500 },
];
const users = [];
for (const u of userData) {
let user = await prisma.user.findUnique({ where: { email: u.email } });
if (!user) {
user = await prisma.user.create({ data: u });
}
users.push(user);
}
const listings = [
{ title: 'Digital Quran Study Planner', description: 'Comprehensive digital planner for Quran memorization tracking with daily logs and revision schedules.', category: 'E-Books', priceFlh: 150, sellerId: users[0].id },
{ title: 'Islamic Art Calligraphy Print', description: 'Beautiful "Bismillah" calligraphy print, handmade. High-quality 300gsm paper, A3 size.', category: 'Design', priceFlh: 250, sellerId: users[1].id },
{ title: 'Online Arabic Course - Beginner', description: '12-week structured Arabic course with weekly live sessions, worksheets, and community support.', category: 'Courses', priceFlh: 500, sellerId: users[2].id },
{ title: 'Halal Snack Box - Monthly Subscription', description: 'Curated box of halal-certified snacks delivered monthly. International treats included.', category: 'Other', priceFlh: 80, sellerId: users[3].id },
{ title: 'Digital Dhikr Counter App', description: 'Beautiful dhikr counter with daily adhkar tracking, goals, and badges.', category: 'Software', priceFlh: 30, sellerId: users[0].id },
{ title: 'Handcrafted Tasbih (Prayer Beads)', description: 'Premium olive wood tasbih, 33 beads with tassel. Handcrafted by artisans. Gift box included.', category: 'Other', priceFlh: 120, sellerId: users[1].id },
{ title: 'Islamic Parenting E-Book Bundle', description: '5 e-books on raising righteous children: discipline, faith, education, screen time, character.', category: 'E-Books', priceFlh: 45, sellerId: users[2].id },
{ title: 'Tajweed Mastery Video Course', description: 'Complete Tajweed rules with 20 video lessons, practice exercises, and progress quizzes.', category: 'Courses', priceFlh: 350, sellerId: users[0].id },
];
let created = 0;
for (const l of listings) {
const existing = await prisma.listing.findFirst({ where: { title: l.title } });
if (!existing) {
await prisma.listing.create({ data: l });
created++;
}
}
console.log(`Successfully seeded ${users.length} users and ${created} new listings.`);
} catch (error) {
console.error("Error during seeding:", error);
process.exit(1);
} finally {
await prisma.$disconnect();
}
}
main();
+7 -3
View File
@@ -1,13 +1,17 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { verifyJWT } from '@/lib/auth' import { verifyJWT, isActivePremium } from '@/lib/auth'
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const auth = req.headers.get('authorization') const auth = req.headers.get('authorization')
if (!auth?.startsWith('Bearer ')) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) if (!auth?.startsWith('Bearer ')) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const payload = await verifyJWT(auth.slice(7)) const payload = await verifyJWT(auth.slice(7))
if (!payload) return NextResponse.json({ error: 'Invalid token' }, { status: 401 }) if (!payload) return NextResponse.json({ error: 'Invalid token' }, { status: 401 })
const user = await prisma.user.findUnique({ where: { id: payload.id }, select: { id: true, email: true, name: true, isPremium: true, isPro: true, flhBalance: true, experienceLevel: true, madhab: true, coachPersona: true, preferredName: true, coachingGoals: true, lastCoachedAt: true, createdAt: true } }) let user = await prisma.user.findUnique({ where: { id: payload.id }, select: { id: true, email: true, name: true, isPremium: true, isPro: true, trialEndsAt: true, flhBalance: true, experienceLevel: true, madhab: true, coachPersona: true, preferredName: true, coachingGoals: true, lastCoachedAt: true, createdAt: true } })
if (!user) return NextResponse.json({ error: 'User not found' }, { status: 404 }) if (!user) return NextResponse.json({ error: 'User not found' }, { status: 404 })
return NextResponse.json({ user }) // Auto-downgrade expired trials
if (user.isPremium && !user.isPro && user.trialEndsAt && user.trialEndsAt < new Date()) {
user = await prisma.user.update({ where: { id: payload.id }, data: { isPremium: false }, select: { id: true, email: true, name: true, isPremium: true, isPro: true, trialEndsAt: true, flhBalance: true, experienceLevel: true, madhab: true, coachPersona: true, preferredName: true, coachingGoals: true, lastCoachedAt: true, createdAt: true } })
}
return NextResponse.json({ user: { ...user, isPremium: isActivePremium(user) } })
} }
+16 -1
View File
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { verifyJWT } from '@/lib/auth' import { verifyJWT, isActivePremium } from '@/lib/auth'
export async function PATCH(req: NextRequest) { export async function PATCH(req: NextRequest) {
try { try {
@@ -36,6 +36,21 @@ export async function PATCH(req: NextRequest) {
return NextResponse.json({ error: 'Invalid coachPersona' }, { status: 400 }) return NextResponse.json({ error: 'Invalid coachPersona' }, { status: 400 })
} }
// Gate scholar personas behind premium
const PREMIUM_PERSONAS = ['ghazali', 'ibnabbas', 'rabia']
if (coachPersona && PREMIUM_PERSONAS.includes(coachPersona)) {
const currentUser = await prisma.user.findUnique({
where: { id: payload.id },
select: { isPremium: true, isPro: true, trialEndsAt: true },
})
if (!currentUser || !isActivePremium(currentUser)) {
return NextResponse.json(
{ error: 'premium_required', feature: 'scholar_personas' },
{ status: 403 }
)
}
}
const updateData: Record<string, unknown> = {} const updateData: Record<string, unknown> = {}
if (preferredName !== undefined) updateData.preferredName = preferredName if (preferredName !== undefined) updateData.preferredName = preferredName
if (experienceLevel !== undefined) updateData.experienceLevel = experienceLevel if (experienceLevel !== undefined) updateData.experienceLevel = experienceLevel
+2 -1
View File
@@ -8,8 +8,9 @@ export async function POST(req: NextRequest) {
if (!email || !name || !password) return NextResponse.json({ error: 'All fields required' }, { status: 400 }) if (!email || !name || !password) return NextResponse.json({ error: 'All fields required' }, { status: 400 })
const exists = await prisma.user.findUnique({ where: { email } }) const exists = await prisma.user.findUnique({ where: { email } })
if (exists) return NextResponse.json({ error: 'Email already registered' }, { status: 400 }) if (exists) return NextResponse.json({ error: 'Email already registered' }, { status: 400 })
const trialEndsAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
const user = await prisma.user.create({ const user = await prisma.user.create({
data: { email, name, passwordHash: hashPassword(password) }, data: { email, name, passwordHash: hashPassword(password), isPremium: true, trialEndsAt },
}) })
const token = await signJWT({ id: user.id, email: user.email }) const token = await signJWT({ id: user.id, email: user.email })
return NextResponse.json({ user: { id: user.id, email: user.email, name: user.name, isPremium: user.isPremium, isPro: user.isPro, flhBalance: user.flhBalance, experienceLevel: user.experienceLevel, madhab: user.madhab, coachPersona: user.coachPersona, preferredName: user.preferredName, coachingGoals: user.coachingGoals, lastCoachedAt: user.lastCoachedAt, createdAt: user.createdAt }, token }, { status: 201 }) return NextResponse.json({ user: { id: user.id, email: user.email, name: user.name, isPremium: user.isPremium, isPro: user.isPro, flhBalance: user.flhBalance, experienceLevel: user.experienceLevel, madhab: user.madhab, coachPersona: user.coachPersona, preferredName: user.preferredName, coachingGoals: user.coachingGoals, lastCoachedAt: user.lastCoachedAt, createdAt: user.createdAt }, token }, { status: 201 })
+9 -3
View File
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { verifyJWT } from '@/lib/auth'
// ───────────────────────────────────────────── // ─────────────────────────────────────────────
// Greeting pools — tiered by engagement level // Greeting pools — tiered by engagement level
@@ -44,10 +45,15 @@ function pickRandom<T>(pool: T[]): T {
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
const { userId } = await req.json() const authHeader = req.headers.get('authorization')
if (!userId) { if (!authHeader?.startsWith('Bearer ')) {
return NextResponse.json({ error: 'userId required' }, { status: 400 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const payload = await verifyJWT(authHeader.slice(7))
if (!payload) {
return NextResponse.json({ error: 'Invalid token' }, { status: 401 })
}
const userId = payload.id
const user = await prisma.user.findUnique({ where: { id: userId } }) const user = await prisma.user.findUnique({ where: { id: userId } })
if (!user) { if (!user) {
@@ -1,10 +1,20 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { verifyJWT } from '@/lib/auth'
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const auth = req.headers.get('authorization')
if (!auth?.startsWith('Bearer ')) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const payload = await verifyJWT(auth.slice(7))
if (!payload) return NextResponse.json({ error: 'Invalid token' }, { status: 401 })
const { pathname } = new URL(req.url) const { pathname } = new URL(req.url)
const userId = pathname.split('/').pop() const userId = pathname.split('/').pop()
if (!userId) return NextResponse.json({ error: 'userId required' }, { status: 400 }) if (!userId) return NextResponse.json({ error: 'userId required' }, { status: 400 })
if (userId !== payload.id) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const user = await prisma.user.findUnique({ where: { id: userId } }) const user = await prisma.user.findUnique({ where: { id: userId } })
if (!user) return NextResponse.json({ error: 'User not found' }, { status: 404 }) if (!user) return NextResponse.json({ error: 'User not found' }, { status: 404 })
const history = await prisma.chatHistory.findMany({ const history = await prisma.chatHistory.findMany({
+26 -3
View File
@@ -5,6 +5,7 @@ import type { UserContext, CoachingMemory } from '@/lib/ai'
import { parseCommand, handleCommand } from '@/lib/commands' import { parseCommand, handleCommand } from '@/lib/commands'
import type { SlashCommand } from '@/lib/commands' import type { SlashCommand } from '@/lib/commands'
import { SCHOLAR_PERSONAS } from '@/lib/personas' import { SCHOLAR_PERSONAS } from '@/lib/personas'
import { isActivePremium } from '@/lib/auth'
// Per-user seen-content tracking (in-memory, resets on server restart) // Per-user seen-content tracking (in-memory, resets on server restart)
const userSeenContent = new Map<string, Map<SlashCommand, Set<number>>>() const userSeenContent = new Map<string, Map<SlashCommand, Set<number>>>()
@@ -31,9 +32,27 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'userId and message required' }, { status: 400 }) return NextResponse.json({ error: 'userId and message required' }, { status: 400 })
} }
const user = await prisma.user.findUnique({ where: { id: userId } }) let user = await prisma.user.findUnique({ where: { id: userId } })
if (!user) return NextResponse.json({ error: 'User not found' }, { status: 404 }) if (!user) return NextResponse.json({ error: 'User not found' }, { status: 404 })
// Daily message limit for free users
if (!isActivePremium(user)) {
const todayStart = new Date()
todayStart.setHours(0, 0, 0, 0)
if (!user.dailyMsgResetAt || user.dailyMsgResetAt < todayStart) {
user = await prisma.user.update({
where: { id: userId },
data: { dailyMsgCount: 0, dailyMsgResetAt: new Date() },
})
}
if (user.dailyMsgCount >= 10) {
return NextResponse.json(
{ error: 'daily_limit_reached', limit: 10, used: user.dailyMsgCount },
{ status: 429 }
)
}
}
// Moderate user message // Moderate user message
const mod = moderateContent(message) const mod = moderateContent(message)
if (!mod.approved) { if (!mod.approved) {
@@ -158,10 +177,13 @@ export async function POST(req: NextRequest) {
}, },
}) })
// Update lastCoachedAt // Update lastCoachedAt and increment daily message counter for free users
await prisma.user.update({ await prisma.user.update({
where: { id: userId }, where: { id: userId },
data: { lastCoachedAt: new Date() }, data: {
lastCoachedAt: new Date(),
...(!isActivePremium(user) ? { dailyMsgCount: { increment: 1 } } : {}),
},
}) })
return NextResponse.json({ return NextResponse.json({
@@ -171,6 +193,7 @@ export async function POST(req: NextRequest) {
experienceLevel: user.experienceLevel || '', experienceLevel: user.experienceLevel || '',
madhab: user.madhab, madhab: user.madhab,
streakDays, streakDays,
dailyMsgCount: isActivePremium(user) ? null : user.dailyMsgCount + 1,
}, },
}) })
} catch (e) { } catch (e) {
+10
View File
@@ -0,0 +1,10 @@
import { NextResponse } from 'next/server'
import verses from '@/lib/daily-quran.json'
export async function GET() {
const dayOfYear = Math.floor(
(Date.now() - new Date(new Date().getFullYear(), 0, 0).getTime()) / 86400000
)
const verse = verses[dayOfYear % verses.length]
return NextResponse.json(verse)
}
+44
View File
@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { verifyJWT } from '@/lib/auth'
export async function POST(req: NextRequest) {
const auth = req.headers.get('authorization')
if (!auth?.startsWith('Bearer ')) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const payload = await verifyJWT(auth.slice(7))
if (!payload) return NextResponse.json({ error: 'Invalid token' }, { status: 401 })
const { count } = await req.json()
const today = new Date().toISOString().slice(0, 10)
const session = await prisma.dhikrSession.upsert({
where: { userId_date: { userId: payload.id, date: today } },
create: { userId: payload.id, date: today, count: count ?? 0, completed: true },
update: { count: count ?? 0, completed: true },
})
// Update streak
const user = await prisma.user.findUnique({
where: { id: payload.id },
select: { dhikrStreak: true, dhikrStreakDate: true },
})
const yesterday = new Date()
yesterday.setDate(yesterday.getDate() - 1)
const yesterdayStr = yesterday.toISOString().slice(0, 10)
const lastDate = user?.dhikrStreakDate?.toISOString().slice(0, 10)
let newStreak = 1
if (lastDate === yesterdayStr) {
newStreak = (user?.dhikrStreak ?? 0) + 1
} else if (lastDate === today) {
newStreak = user?.dhikrStreak ?? 1
}
await prisma.user.update({
where: { id: payload.id },
data: { dhikrStreak: newStreak, dhikrStreakDate: new Date() },
})
return NextResponse.json({ streak: newStreak, completed: true })
}
+23
View File
@@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { verifyJWT } from '@/lib/auth'
export async function GET(req: NextRequest) {
const auth = req.headers.get('authorization')
if (!auth?.startsWith('Bearer ')) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const payload = await verifyJWT(auth.slice(7))
if (!payload) return NextResponse.json({ error: 'Invalid token' }, { status: 401 })
const today = new Date().toISOString().slice(0, 10)
const [user, session] = await Promise.all([
prisma.user.findUnique({ where: { id: payload.id }, select: { dhikrStreak: true, dhikrStreakDate: true } }),
prisma.dhikrSession.findUnique({ where: { userId_date: { userId: payload.id, date: today } } }),
])
return NextResponse.json({
streak: user?.dhikrStreak ?? 0,
todayCount: session?.count ?? 0,
todayCompleted: session?.completed ?? false,
date: today,
})
}
+7
View File
@@ -20,6 +20,13 @@ export async function POST(req: NextRequest) {
if (!auth?.startsWith('Bearer ')) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) if (!auth?.startsWith('Bearer ')) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const payload = await verifyJWT(auth.slice(7)) const payload = await verifyJWT(auth.slice(7))
if (!payload) return NextResponse.json({ error: 'Invalid token' }, { status: 401 }) if (!payload) return NextResponse.json({ error: 'Invalid token' }, { status: 401 })
// Premium check
const user = await prisma.user.findUnique({ where: { id: payload.id } })
if (!user || (!user.isPremium && !user.isPro)) {
return NextResponse.json({ error: 'Premium subscription required to reply' }, { status: 403 })
}
const { threadId, content } = await req.json() const { threadId, content } = await req.json()
if (!threadId || !content) return NextResponse.json({ error: 'threadId and content required' }, { status: 400 }) if (!threadId || !content) return NextResponse.json({ error: 'threadId and content required' }, { status: 400 })
const moderation = moderateContent(content) const moderation = moderateContent(content)
+16
View File
@@ -6,6 +6,22 @@ import { moderateContent } from '@/lib/ai'
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url) const { searchParams } = new URL(req.url)
const categoryId = searchParams.get('categoryId') const categoryId = searchParams.get('categoryId')
const threadId = searchParams.get('id')
// Single thread by ID
if (threadId) {
const thread = await prisma.forumThread.findUnique({
where: { id: threadId },
include: {
author: { select: { id: true, name: true, isPremium: true, isPro: true } },
category: { select: { id: true, name: true } },
_count: { select: { posts: true } },
},
})
return NextResponse.json({ thread })
}
// List threads by category
const where = categoryId ? { categoryId } : {} const where = categoryId ? { categoryId } : {}
const threads = await prisma.forumThread.findMany({ const threads = await prisma.forumThread.findMany({
where: { ...where, shariahStatus: 'approved' }, where: { ...where, shariahStatus: 'approved' },
+7
View File
@@ -32,7 +32,14 @@ export async function DELETE(req: NextRequest) {
if (!auth?.startsWith('Bearer ')) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) if (!auth?.startsWith('Bearer ')) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const payload = await verifyJWT(auth.slice(7)) const payload = await verifyJWT(auth.slice(7))
if (!payload) return NextResponse.json({ error: 'Invalid token' }, { status: 401 }) if (!payload) return NextResponse.json({ error: 'Invalid token' }, { status: 401 })
// Support deletion by internal id (body) or by itemId (query param)
const itemIdParam = new URL(req.url).searchParams.get('itemId')
if (itemIdParam) {
await prisma.halalBookmark.deleteMany({ where: { itemId: itemIdParam, userId: payload.id } })
} else {
const { id } = await req.json() const { id } = await req.json()
await prisma.halalBookmark.deleteMany({ where: { id, userId: payload.id } }) await prisma.halalBookmark.deleteMany({ where: { id, userId: payload.id } })
}
return NextResponse.json({ success: true }) return NextResponse.json({ success: true })
} }
+157
View File
@@ -0,0 +1,157 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { verifyJWT } from '@/lib/auth'
const FREE_DAILY_LIMIT = 20
const CACHE_TTL_MS = 24 * 60 * 60 * 1000
interface OverpassElement {
id: number
lat?: number
lon?: number
center?: { lat: number; lon: number }
tags?: Record<string, string>
}
interface Place {
id: string
lat: number
lon: number
name: string
type: 'mosque' | 'restaurant'
address?: string
}
const OVERPASS_ENDPOINTS = [
'https://overpass-api.de/api/interpreter',
'https://maps.mail.ru/osm/tools/overpass/api/interpreter',
'https://overpass.kumi.systems/api/interpreter',
]
async function queryOverpass(query: string): Promise<OverpassElement[]> {
let lastError: Error | null = null
for (const endpoint of OVERPASS_ENDPOINTS) {
try {
const res = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json',
},
body: `data=${encodeURIComponent(query)}`,
signal: AbortSignal.timeout(15000),
})
if (!res.ok) { lastError = new Error(`Overpass error ${res.status} from ${endpoint}`); continue }
const data = await res.json()
return data.elements || []
} catch (e) {
lastError = e as Error
}
}
throw lastError ?? new Error('All Overpass endpoints failed')
}
function buildMosqueQuery(lat: number, lon: number, radius: number): string {
return `[out:json][timeout:10];(node["amenity"="place_of_worship"]["religion"="muslim"](around:${radius},${lat},${lon});way["amenity"="place_of_worship"]["religion"="muslim"](around:${radius},${lat},${lon}););out center;`
}
function buildRestaurantQuery(lat: number, lon: number, radius: number): string {
return `[out:json][timeout:10];(node["diet:halal"="yes"](around:${radius},${lat},${lon});node["cuisine"~"halal"](around:${radius},${lat},${lon});way["diet:halal"="yes"](around:${radius},${lat},${lon}););out center;`
}
function elementsToPlaces(elements: OverpassElement[], type: 'mosque' | 'restaurant'): Place[] {
return elements
.filter(e => {
const lat = e.lat ?? e.center?.lat
const lon = e.lon ?? e.center?.lon
return lat !== undefined && lon !== undefined
})
.map(e => ({
id: String(e.id),
lat: (e.lat ?? e.center?.lat)!,
lon: (e.lon ?? e.center?.lon)!,
name: e.tags?.name || e.tags?.['name:en'] || (type === 'mosque' ? 'Mosque' : 'Halal Restaurant'),
type,
address: [e.tags?.['addr:street'], e.tags?.['addr:city']].filter(Boolean).join(', ') || undefined,
}))
}
async function getUserFromRequest(req: NextRequest) {
const auth = req.headers.get('authorization')
if (!auth?.startsWith('Bearer ')) return null
const payload = await verifyJWT(auth.slice(7))
if (!payload) return null
return prisma.user.findUnique({ where: { id: payload.id } })
}
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url)
const lat = parseFloat(searchParams.get('lat') || '')
const lon = parseFloat(searchParams.get('lng') || '')
const radius = Math.min(parseInt(searchParams.get('radius') || '5000'), 10000)
if (isNaN(lat) || isNaN(lon)) {
return NextResponse.json({ error: 'lat and lng required' }, { status: 400 })
}
const user = await getUserFromRequest(req)
const isPremium = user?.isPremium || user?.isPro || false
// Rate limit free users
if (user && !isPremium) {
const today = new Date()
today.setHours(0, 0, 0, 0)
let usage = await prisma.halalUsage.findUnique({ where: { userId: user.id } })
if (!usage || !usage.periodEnd || usage.periodEnd < today) {
usage = await prisma.halalUsage.upsert({
where: { userId: user.id },
create: { userId: user.id, queriesUsed: 0, queriesLimit: FREE_DAILY_LIMIT, periodEnd: new Date(today.getTime() + 86400000) },
update: { queriesUsed: 0, queriesLimit: FREE_DAILY_LIMIT, periodEnd: new Date(today.getTime() + 86400000) },
})
}
if (usage.queriesUsed >= FREE_DAILY_LIMIT) {
return NextResponse.json({ error: 'daily_limit_reached', limit: FREE_DAILY_LIMIT }, { status: 429 })
}
}
// Check cache
const cacheKey = `${isPremium ? 'premium' : 'free'}:${lat.toFixed(2)}:${lon.toFixed(2)}:${radius}`
const cached = await prisma.halalPlaceCache.findUnique({ where: { cacheKey } })
if (cached && cached.expiresAt > new Date()) {
return NextResponse.json(JSON.parse(cached.data))
}
// Fetch from Overpass
const mosqueElements = await queryOverpass(buildMosqueQuery(lat, lon, radius))
const mosques = elementsToPlaces(mosqueElements, 'mosque')
let restaurants: Place[] = []
if (isPremium) {
const restaurantElements = await queryOverpass(buildRestaurantQuery(lat, lon, radius))
restaurants = elementsToPlaces(restaurantElements, 'restaurant')
}
const result = { mosques, restaurants }
// Cache result
await prisma.halalPlaceCache.upsert({
where: { cacheKey },
create: { cacheKey, data: JSON.stringify(result), expiresAt: new Date(Date.now() + CACHE_TTL_MS) },
update: { data: JSON.stringify(result), expiresAt: new Date(Date.now() + CACHE_TTL_MS) },
})
// Increment usage for free users
if (user && !isPremium) {
await prisma.halalUsage.update({
where: { userId: user.id },
data: { queriesUsed: { increment: 1 } },
})
}
return NextResponse.json(result)
} catch (e) {
console.error('Halal places error:', e)
return NextResponse.json({ error: 'Failed to fetch places' }, { status: 500 })
}
}
-5
View File
@@ -1,5 +0,0 @@
import { NextResponse } from 'next/server'
export async function GET() {
return NextResponse.json({ status: 'ok', service: 'falah-mobile' })
}
-5
View File
@@ -1,5 +0,0 @@
import { NextResponse } from 'next/server'
export async function GET() {
return NextResponse.json({ status: 'ok', service: 'nurbuddy' })
}
+37
View File
@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from 'next/server'
// Proxies aladhan.com — free, no API key required
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url)
const lat = searchParams.get('lat')
const lng = searchParams.get('lng')
const method = searchParams.get('method') || '3' // MWL
if (!lat || !lng) return NextResponse.json({ error: 'lat and lng required' }, { status: 400 })
try {
const res = await fetch(
`https://api.aladhan.com/v1/timings?latitude=${lat}&longitude=${lng}&method=${method}`,
{ signal: AbortSignal.timeout(8000) }
)
if (!res.ok) return NextResponse.json({ error: 'Prayer times unavailable' }, { status: 502 })
const data = await res.json()
const timings = data.data?.timings
const date = data.data?.date
return NextResponse.json({
timings: {
Fajr: timings?.Fajr,
Sunrise: timings?.Sunrise,
Dhuhr: timings?.Dhuhr,
Asr: timings?.Asr,
Maghrib: timings?.Maghrib,
Isha: timings?.Isha,
},
hijri: date?.hijri,
gregorian: date?.gregorian,
})
} catch {
return NextResponse.json({ error: 'Prayer times unavailable' }, { status: 502 })
}
}
+65
View File
@@ -0,0 +1,65 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { verifyJWT } from '@/lib/auth'
export async function GET(req: NextRequest) {
const auth = req.headers.get('authorization')
if (!auth?.startsWith('Bearer ')) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const payload = await verifyJWT(auth.slice(7))
if (!payload) return NextResponse.json({ error: 'Invalid token' }, { status: 401 })
const today = new Date().toISOString().slice(0, 10)
const [user, reading] = await Promise.all([
prisma.user.findUnique({ where: { id: payload.id }, select: { quranStreak: true, quranStreakDate: true } }),
prisma.quranReading.findUnique({ where: { userId_date: { userId: payload.id, date: today } } }),
])
return NextResponse.json({
streak: user?.quranStreak ?? 0,
todayRead: reading?.pagesRead ?? 0,
todayGoal: reading?.goalPages ?? 1,
date: today,
})
}
export async function POST(req: NextRequest) {
const auth = req.headers.get('authorization')
if (!auth?.startsWith('Bearer ')) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const payload = await verifyJWT(auth.slice(7))
if (!payload) return NextResponse.json({ error: 'Invalid token' }, { status: 401 })
const { pages = 1, goal = 1 } = await req.json()
const today = new Date().toISOString().slice(0, 10)
const reading = await prisma.quranReading.upsert({
where: { userId_date: { userId: payload.id, date: today } },
create: { userId: payload.id, date: today, pagesRead: pages, goalPages: goal },
update: { pagesRead: pages, goalPages: goal },
})
const goalMet = reading.pagesRead >= reading.goalPages
if (goalMet) {
const user = await prisma.user.findUnique({
where: { id: payload.id },
select: { quranStreak: true, quranStreakDate: true },
})
const yesterday = new Date()
yesterday.setDate(yesterday.getDate() - 1)
const yesterdayStr = yesterday.toISOString().slice(0, 10)
const lastDate = user?.quranStreakDate?.toISOString().slice(0, 10)
let newStreak = 1
if (lastDate === yesterdayStr) newStreak = (user?.quranStreak ?? 0) + 1
else if (lastDate === today) newStreak = user?.quranStreak ?? 1
await prisma.user.update({
where: { id: payload.id },
data: { quranStreak: newStreak, quranStreakDate: new Date() },
})
return NextResponse.json({ streak: newStreak, goalMet: true, pagesRead: reading.pagesRead })
}
return NextResponse.json({ streak: null, goalMet: false, pagesRead: reading.pagesRead })
}
+4 -4
View File
@@ -7,10 +7,10 @@ export async function POST() {
const password = await bcrypt.hash('password123', 10) const password = await bcrypt.hash('password123', 10)
const userData = [ const userData = [
{ email: 'ghazali@falahos.my', name: 'Abu Hamid Al-Ghazali', password, flhBalance: 5000 }, { email: 'ghazali@falahos.my', name: 'Abu Hamid Al-Ghazali', passwordHash: password, flhBalance: 5000 },
{ email: 'ibnabbas@falahos.my', name: 'Abdullah Ibn Abbas', password, flhBalance: 3000 }, { email: 'ibnabbas@falahos.my', name: 'Abdullah Ibn Abbas', passwordHash: password, flhBalance: 3000 },
{ email: 'rabia@falahos.my', name: "Rabi'a Al-Adawiyya", password, flhBalance: 2000 }, { email: 'rabia@falahos.my', name: "Rabi'a Al-Adawiyya", passwordHash: password, flhBalance: 2000 },
{ email: 'demo@falahos.my', name: 'Demo User', password, flhBalance: 500 }, { email: 'demo@falahos.my', name: 'Demo User', passwordHash: password, flhBalance: 500 },
] ]
const users: any[] = [] const users: any[] = []
@@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { verifyJWT } from '@/lib/auth'
// Map the legacy priceId keys the upgrade page sends → Polar product IDs
const PRICE_ID_MAP: Record<string, string> = {
price_premium_monthly: 'a291a3d9-fa82-4e88-ba89-c6005401398f',
price_pro_monthly: '7f983017-e1f3-440e-be2a-5a5d19c9c7b0',
}
export async function POST(req: NextRequest) {
if (!process.env.POLAR_ACCESS_TOKEN) {
return NextResponse.json({ error: 'Payment not configured' }, { status: 503 })
}
const auth = req.headers.get('authorization')
if (!auth?.startsWith('Bearer ')) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const payload = await verifyJWT(auth.slice(7))
if (!payload) return NextResponse.json({ error: 'Invalid token' }, { status: 401 })
const { priceId } = await req.json()
if (!priceId) return NextResponse.json({ error: 'priceId required' }, { status: 400 })
const productId = PRICE_ID_MAP[priceId] || priceId
if (!productId) return NextResponse.json({ error: 'Invalid product' }, { status: 400 })
const user = await prisma.user.findUnique({ where: { id: payload.id } })
if (!user) return NextResponse.json({ error: 'User not found' }, { status: 404 })
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
const res = await fetch('https://api.polar.sh/v1/checkouts', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.POLAR_ACCESS_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
products: [{ product_id: productId }],
success_url: `${appUrl}/upgrade?upgrade=success`,
customer_email: user.email,
metadata: { userId: user.id },
}),
})
if (!res.ok) {
const err = await res.text()
console.error('Polar checkout error:', err)
return NextResponse.json({ error: 'Failed to create checkout' }, { status: 500 })
}
const data = await res.json()
return NextResponse.json({ url: data.url })
}
+9 -1
View File
@@ -12,7 +12,15 @@ export async function POST(req: NextRequest) {
const user = await prisma.user.findUnique({ where: { id: payload.id } }) const user = await prisma.user.findUnique({ where: { id: payload.id } })
if (!user || user.flhBalance < amountFlh) return NextResponse.json({ error: 'Insufficient balance' }, { status: 400 }) if (!user || user.flhBalance < amountFlh) return NextResponse.json({ error: 'Insufficient balance' }, { status: 400 })
const fiatAmount = (amountFlh / 100) * 0.8 const fiatAmount = (amountFlh / 100) * 0.8
const cashout = await prisma.cashoutRequest.create({ data: { userId: payload.id, amountFlh, fiatAmount } })
// Deduct balance + create cashout in a transaction
const [cashout] = await prisma.$transaction([
prisma.cashoutRequest.create({ data: { userId: payload.id, amountFlh, fiatAmount } }),
prisma.user.update({
where: { id: payload.id },
data: { flhBalance: { decrement: amountFlh } },
}),
])
return NextResponse.json({ cashout }) return NextResponse.json({ cashout })
} }
@@ -0,0 +1,5 @@
import { NextResponse } from 'next/server'
export async function POST() {
return NextResponse.json({ error: 'Not yet implemented' }, { status: 501 })
}
+46
View File
@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { verifyJWT } from '@/lib/auth'
export async function POST(req: NextRequest) {
try {
const auth = req.headers.get('authorization')
if (!auth?.startsWith('Bearer ')) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const payload = await verifyJWT(auth.slice(7))
if (!payload) {
return NextResponse.json({ error: 'Invalid token' }, { status: 401 })
}
const { projectId, amountFlh } = await req.json()
if (!projectId || !amountFlh || amountFlh <= 0) {
return NextResponse.json({ error: 'Invalid project or amount' }, { status: 400 })
}
const user = await prisma.user.findUnique({ where: { id: payload.id } })
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 })
}
if (user.flhBalance < amountFlh) {
return NextResponse.json({ error: 'Insufficient FLH balance' }, { status: 400 })
}
// Decrement the user's balance
const updatedUser = await prisma.user.update({
where: { id: payload.id },
data: { flhBalance: { decrement: amountFlh } },
})
return NextResponse.json({
success: true,
newBalance: updatedUser.flhBalance,
message: `Successfully contributed ${amountFlh} FLH to the project.`
})
} catch (e) {
console.error(e)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
+87
View File
@@ -0,0 +1,87 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { createHmac } from 'crypto'
// Polar product IDs → tier flags
const PRODUCT_TIERS: Record<string, { isPremium: boolean; isPro: boolean }> = {
'a291a3d9-fa82-4e88-ba89-c6005401398f': { isPremium: true, isPro: false }, // Premium
'7f983017-e1f3-440e-be2a-5a5d19c9c7b0': { isPremium: true, isPro: true }, // Pro
}
function verifySignature(body: string, headers: Headers): boolean {
const secret = process.env.POLAR_WEBHOOK_SECRET
if (!secret) return false
// Polar uses Svix: signature is over "id.timestamp.body"
const webhookId = headers.get('webhook-id')
const timestamp = headers.get('webhook-timestamp')
const signature = headers.get('webhook-signature')
if (!webhookId || !timestamp || !signature) return false
const signedContent = `${webhookId}.${timestamp}.${body}`
const secretBytes = Buffer.from(secret.replace('whsec_', ''), 'base64')
const computed = createHmac('sha256', secretBytes).update(signedContent).digest('base64')
// Signature header may contain multiple: "v1,<sig1> v1,<sig2>"
return signature.split(' ').some(s => s === `v1,${computed}`)
}
export async function POST(req: NextRequest) {
const body = await req.text()
if (process.env.POLAR_WEBHOOK_SECRET && !verifySignature(body, req.headers)) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
}
let event: { type: string; data: any }
try {
event = JSON.parse(body)
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
}
const { type, data } = event
try {
switch (type) {
case 'subscription.created':
case 'subscription.updated': {
const userId = data.metadata?.userId || data.customer?.metadata?.userId
if (!userId) break
const productId = data.product_id || data.product?.id
const tier = productId ? PRODUCT_TIERS[productId] : null
const isActive = ['active', 'trialing'].includes(data.status)
if (isActive && tier) {
await prisma.user.update({
where: { id: userId },
data: tier,
})
} else if (!isActive) {
await prisma.user.update({
where: { id: userId },
data: { isPremium: false, isPro: false },
})
}
break
}
case 'subscription.revoked':
case 'subscription.canceled': {
const userId = data.metadata?.userId || data.customer?.metadata?.userId
if (!userId) break
await prisma.user.update({
where: { id: userId },
data: { isPremium: false, isPro: false },
})
break
}
}
} catch (e) {
console.error('Polar webhook handler error:', e)
return NextResponse.json({ error: 'Handler error' }, { status: 500 })
}
return NextResponse.json({ received: true })
}
+305
View File
@@ -0,0 +1,305 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useAuth } from '@/lib/AuthContext'
import { useRouter } from 'next/navigation'
import { Flame, CheckCircle2, RotateCcw } from 'lucide-react'
const DHIKR_SEQUENCE = [
{
arabic: 'سُبْحَانَ اللَّهِ',
transliteration: 'SubhanAllah',
meaning: 'Glory be to Allah',
target: 33,
ring: '#10b981', // emerald
glow: 'rgba(16,185,129,0.25)',
bg: 'rgba(16,185,129,0.08)',
},
{
arabic: 'الْحَمْدُ لِلَّهِ',
transliteration: 'Alhamdulillah',
meaning: 'All praise is for Allah',
target: 33,
ring: '#3b82f6', // blue
glow: 'rgba(59,130,246,0.25)',
bg: 'rgba(59,130,246,0.08)',
},
{
arabic: 'اللَّهُ أَكْبَرُ',
transliteration: 'Allahu Akbar',
meaning: 'Allah is the Greatest',
target: 34,
ring: '#D4AF37', // gold
glow: 'rgba(212,175,55,0.3)',
bg: 'rgba(212,175,55,0.08)',
},
]
const TOTAL = DHIKR_SEQUENCE.reduce((s, d) => s + d.target, 0)
export default function DhikrPage() {
const { user, token } = useAuth()
const router = useRouter()
const [phase, setPhase] = useState(0)
const [count, setCount] = useState(0)
const [totalCount, setTotalCount] = useState(0)
const [streak, setStreak] = useState(0)
const [todayCompleted, setTodayCompleted] = useState(false)
const [justFinished, setJustFinished] = useState(false)
const [loading, setLoading] = useState(true)
const [ripple, setRipple] = useState(false)
const current = DHIKR_SEQUENCE[phase]
useEffect(() => {
if (!token) { setLoading(false); return }
fetch('/api/dhikr/status', { headers: { Authorization: `Bearer ${token}` } })
.then(r => r.json())
.then(d => {
setStreak(d.streak ?? 0)
setTodayCompleted(d.todayCompleted ?? false)
if (d.todayCompleted) setTotalCount(TOTAL)
})
.catch(() => { })
.finally(() => setLoading(false))
}, [token])
const handleTap = useCallback(async () => {
if (todayCompleted || justFinished) return
if (typeof navigator !== 'undefined' && 'vibrate' in navigator) navigator.vibrate(10)
setRipple(true)
setTimeout(() => setRipple(false), 300)
const newCount = count + 1
const newTotal = totalCount + 1
setCount(newCount)
setTotalCount(newTotal)
if (newCount >= current.target) {
if (phase < DHIKR_SEQUENCE.length - 1) {
setTimeout(() => { setPhase(p => p + 1); setCount(0) }, 350)
} else {
setJustFinished(true)
setTodayCompleted(true)
if (token) {
try {
const res = await fetch('/api/dhikr/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ count: TOTAL }),
})
const data = await res.json()
setStreak(data.streak ?? streak + 1)
} catch { }
}
}
}
}, [count, totalCount, phase, current, token, todayCompleted, justFinished, streak])
const handleReset = () => {
setPhase(0); setCount(0); setTotalCount(0)
setJustFinished(false); setTodayCompleted(false)
}
const progress = current ? count / current.target : 1
const R = 58
const C = 2 * Math.PI * R
const strokeDashoffset = C * (1 - progress)
if (!user) {
return (
<div className="min-h-dvh flex flex-col items-center justify-center bg-bg-deep px-6 pb-24 text-white">
<div className="text-5xl mb-6">📿</div>
<h2 className="text-xl font-bold text-white mb-2">Daily Dhikr</h2>
<p className="text-text-secondary text-sm text-center mb-8">
Build a daily habit of remembrance. Sign in to track your streak.
</p>
<button onClick={() => router.push('/login')}
className="w-full max-w-xs gold-gradient text-bg-deep py-4 rounded-2xl font-bold active:scale-[0.97] transition-all cursor-pointer shadow-sm shadow-gold/10">
Sign In
</button>
</div>
)
}
if (loading) {
return (
<div className="min-h-dvh flex items-center justify-center bg-bg-deep pb-24">
<div className="w-8 h-8 rounded-full border-2 border-gold/30 border-t-gold animate-spin" />
</div>
)
}
return (
<div className="min-h-dvh bg-bg-deep flex flex-col pb-32 select-none text-white">
{/* Header gradient */}
<div className="bg-gradient-to-br from-emerald-950/40 via-bg-card to-bg-deep border-b border-border/80 rounded-b-3xl px-5 pt-14 pb-8 flex items-start justify-between">
<div>
<h1 className="text-xl font-bold text-white">Daily Dhikr</h1>
<p className="text-[11px] text-text-secondary mt-0.5">100 remembrances · after prayer</p>
</div>
{/* Streak badge inside header */}
<div className="flex items-center gap-1.5 bg-bg-card/80 border border-border rounded-xl px-3 py-2">
<Flame size={14} className="text-orange-400 animate-pulse" />
<span className="text-sm font-bold text-white">{streak}</span>
</div>
</div>
{/* Phase tabs — below header */}
<div className="flex gap-2 px-5 mt-4 mb-2">
{DHIKR_SEQUENCE.map((d, i) => {
const isActive = i === phase
const isDone = i < phase
return (
<div
key={i}
className={`flex-1 py-1.5 rounded-xl text-center transition-all duration-300 ${
isActive
? 'bg-bg-card shadow-lg border border-gold/40'
: isDone
? 'bg-bg-card/60 border border-border/60'
: 'bg-bg-card/30 border border-border/30'
}`}
>
<div
className="text-[10px] font-semibold truncate px-1"
style={{ color: isActive ? '#D4AF37' : isDone ? d.ring : '#666' }}
>
{d.transliteration}
</div>
<div
className="mx-3 mt-1 h-0.5 rounded-full transition-all duration-500"
style={{
background: isDone ? d.ring : isActive ? `${d.ring}99` : 'var(--color-border)',
}}
/>
</div>
)
})}
</div>
{/* Completed today (not just-finished) */}
{todayCompleted && !justFinished ? (
<div className="flex-1 flex flex-col items-center justify-center px-6 text-center">
<div className="bg-bg-card rounded-3xl border border-border p-10 w-full max-w-xs mx-auto flex flex-col items-center">
<div className="w-24 h-24 rounded-full border-2 border-gold/30 bg-gold/5 flex items-center justify-center mb-5">
<CheckCircle2 size={44} className="text-gold" />
</div>
<h2 className="text-2xl font-bold text-white mb-1">MashaAllah!</h2>
<p className="text-text-secondary text-sm mb-5">You&apos;ve completed your daily dhikr</p>
<div className="flex items-center gap-2 bg-orange-950/20 border border-orange-900/40 rounded-2xl px-5 py-3 text-orange-400">
<Flame size={18} className="text-orange-400 animate-pulse" />
<span className="text-lg font-bold">{streak} day streak</span>
</div>
</div>
<button onClick={handleReset} className="mt-8 flex items-center gap-2 text-text-muted text-sm active:scale-[0.97] transition-transform cursor-pointer hover:text-text-secondary">
<RotateCcw size={13} /> Repeat again
</button>
</div>
) : justFinished ? (
/* Completion screen */
<div className="flex-1 flex flex-col items-center justify-center px-6 text-center">
<div className="text-6xl mb-5 animate-bounce"></div>
<h2 className="text-3xl font-bold text-white mb-1">SubhanAllah!</h2>
<p className="text-text-secondary text-sm mb-6">100 remembrances complete</p>
<div className="flex items-center gap-2 bg-orange-950/20 border border-orange-900/40 rounded-2xl px-6 py-4 mb-6 text-orange-400 animate-pulse">
<Flame size={22} className="text-orange-400" />
<span className="text-2xl font-bold">{streak} day streak</span>
</div>
<div className="bg-bg-card border border-border rounded-2xl px-5 py-4 max-w-xs">
<p className="text-xs text-text-secondary leading-relaxed">
&ldquo;Whoever says SubhanAllah 33 times, Alhamdulillah 33 times, and Allahu Akbar 34 times after each prayer&rdquo;
</p>
<p className="text-[11px] text-text-muted mt-2"> Sahih Muslim</p>
</div>
<button onClick={handleReset} className="mt-8 flex items-center gap-2 text-text-muted text-sm active:scale-[0.97] transition-transform cursor-pointer hover:text-text-secondary">
<RotateCcw size={13} /> Repeat again
</button>
</div>
) : (
/* Main counter */
<div className="flex-1 flex flex-col items-center justify-center px-5">
{/* Ring counter — card */}
<div className="bg-bg-card rounded-3xl border border-border mx-5 py-8 px-6 flex flex-col items-center w-full">
<button
onClick={handleTap}
className="relative flex items-center justify-center active:scale-[0.96] transition-transform cursor-pointer"
style={{ WebkitTapHighlightColor: 'transparent' }}
aria-label={`Tap to count ${current.transliteration}`}
>
{/* Outer glow ring */}
<div
className="absolute rounded-full transition-opacity duration-300"
style={{
width: 216, height: 216,
background: `radial-gradient(circle, ${current.glow} 0%, transparent 70%)`,
opacity: ripple ? 1 : 0.5,
}}
/>
{/* SVG progress ring */}
<svg width="200" height="200" className="-rotate-90" style={{ filter: `drop-shadow(0 0 8px ${current.glow})` }}>
{/* Track */}
<circle cx="100" cy="100" r={R} fill="none" stroke="var(--color-border)" strokeWidth="8" />
{/* Progress */}
<circle
cx="100" cy="100" r={R} fill="none"
stroke={current.ring}
strokeWidth="8"
strokeLinecap="round"
strokeDasharray={C}
strokeDashoffset={strokeDashoffset}
className="transition-all duration-200"
/>
</svg>
{/* Center content */}
<div
className="absolute rounded-full flex flex-col items-center justify-center border border-border/60"
style={{
inset: 28,
background: `radial-gradient(circle at 40% 30%, var(--color-bg-card), var(--color-bg-deep))`,
}}
>
<span className="text-5xl font-extrabold text-white tabular-nums leading-none">{count}</span>
<span className="text-[11px] text-text-secondary mt-1">of {current.target}</span>
</div>
</button>
{/* Dhikr text below ring, inside card */}
<div className="text-center mt-6 space-y-1.5">
<p className="text-3xl text-white leading-relaxed font-light">{current.arabic}</p>
<p className="font-bold text-base" style={{ color: current.ring }}>{current.transliteration}</p>
<p className="text-text-secondary text-sm">{current.meaning}</p>
</div>
</div>
<p className="text-text-muted text-xs mt-6">Tap to count</p>
</div>
)}
{/* Bottom progress bar */}
{!justFinished && (
<div className="px-5 pb-8 mt-4">
<div className="flex items-center justify-between text-[11px] text-text-secondary mb-2">
<span>Total today</span>
<span className="tabular-nums">{Math.min(totalCount, TOTAL)} / {TOTAL}</span>
</div>
<div className="h-1.5 bg-bg-card border border-border rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-300"
style={{
width: `${Math.min((totalCount / TOTAL) * 100, 100)}%`,
background: 'linear-gradient(to right, #10b981, #3b82f6, #D4AF37)',
}}
/>
</div>
</div>
)}
</div>
)
}
+65 -40
View File
@@ -42,7 +42,7 @@ export default function ThreadDetailPage() {
if (!threadId) return if (!threadId) return
setLoading(true) setLoading(true)
Promise.all([ Promise.all([
fetch(`/api/forum/threads?id=${threadId}`).then(r => r.json()), fetch(`/api/forum/threads?categoryId=all&id=${threadId}`).then(r => r.json()),
fetch(`/api/forum/posts?threadId=${threadId}`).then(r => r.json()), fetch(`/api/forum/posts?threadId=${threadId}`).then(r => r.json()),
]) ])
.then(([threadData, postsData]) => { .then(([threadData, postsData]) => {
@@ -74,12 +74,12 @@ export default function ThreadDetailPage() {
if (loading) { if (loading) {
return ( return (
<div className="max-w-3xl mx-auto p-4 sm:p-6"> <div className="min-h-dvh bg-[#f5f4f0] pb-24 px-4 pt-6">
<div className="animate-pulse space-y-4"> <div className="animate-pulse space-y-4 max-w-3xl mx-auto">
<div className="h-4 bg-gray-800 rounded w-24" /> <div className="h-4 bg-gray-200 rounded w-24" />
<div className="h-8 bg-gray-800 rounded w-3/4" /> <div className="h-8 bg-gray-200 rounded w-3/4" />
<div className="h-4 bg-gray-800 rounded w-1/3" /> <div className="h-4 bg-gray-200 rounded w-1/3" />
<div className="h-32 bg-gray-800 rounded" /> <div className="h-32 bg-gray-200 rounded-2xl" />
</div> </div>
</div> </div>
) )
@@ -87,60 +87,80 @@ export default function ThreadDetailPage() {
if (error || !thread) { if (error || !thread) {
return ( return (
<div className="max-w-3xl mx-auto p-4 sm:p-6"> <div className="min-h-dvh bg-[#f5f4f0] pb-24 px-4 pt-6 max-w-3xl mx-auto">
<button onClick={() => router.push('/forum')} className="flex items-center gap-1 text-[#D4AF37] hover:underline mb-4 text-sm"> <button
onClick={() => router.push('/forum')}
className="flex items-center gap-1 text-emerald-600 text-sm mb-4 active:opacity-70 transition"
>
<ArrowLeft size={16} /> Back to Forum <ArrowLeft size={16} /> Back to Forum
</button> </button>
<div className="bg-[#111118] border border-gray-800 rounded-lg p-8 text-center"> <div className="bg-white border border-gray-100 shadow-sm rounded-2xl p-8 text-center">
<MessageCircle size={40} className="mx-auto text-gray-600 mb-3" /> <MessageCircle size={40} className="mx-auto text-gray-300 mb-3" />
<p className="text-gray-400">{error || 'Thread not found'}</p> <p className="text-gray-500">{error || 'Thread not found'}</p>
</div> </div>
</div> </div>
) )
} }
return ( return (
<div className="max-w-3xl mx-auto p-4 sm:p-6 space-y-6"> <div className="min-h-dvh bg-[#f5f4f0] pb-24">
<button onClick={() => router.push('/forum')} className="flex items-center gap-1 text-[#D4AF37] hover:underline text-sm"> <div className="max-w-3xl mx-auto px-4 pt-6 space-y-4">
<button
onClick={() => router.push('/forum')}
className="flex items-center gap-1 text-emerald-600 text-sm active:opacity-70 transition"
>
<ArrowLeft size={16} /> Back to Forum <ArrowLeft size={16} /> Back to Forum
</button> </button>
<div className="bg-[#111118] border border-gray-800 rounded-lg p-5 space-y-3"> {/* Thread card */}
<div className="bg-white border border-gray-100 shadow-sm rounded-2xl p-5 space-y-3">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<span className="bg-[#D4AF37]/10 text-[#D4AF37] text-xs font-semibold px-2 py-0.5 rounded">{thread.category.name}</span> <span className="bg-emerald-50 text-emerald-700 text-xs font-semibold px-2.5 py-1 rounded-lg">
{thread.category.name}
</span>
{thread.shariahStatus === 'approved' ? ( {thread.shariahStatus === 'approved' ? (
<span className="flex items-center gap-1 text-green-500 text-xs"><ShieldCheck size={14} /> Shariah Approved</span> <span className="flex items-center gap-1 text-green-600 text-xs">
<ShieldCheck size={13} /> Shariah Approved
</span>
) : ( ) : (
<span className="flex items-center gap-1 text-yellow-500 text-xs"><ShieldAlert size={14} /> {thread.shariahStatus === 'pending' ? 'Pending Review' : 'Flagged'}</span> <span className="flex items-center gap-1 text-yellow-600 text-xs">
<ShieldAlert size={13} /> {thread.shariahStatus === 'pending' ? 'Pending Review' : 'Flagged'}
</span>
)} )}
</div> </div>
<h1 className="text-xl font-bold">{thread.title}</h1> <h1 className="text-lg font-bold text-gray-900">{thread.title}</h1>
<div className="text-sm text-gray-500"> <p className="text-xs text-gray-400">
By <span className="text-gray-300">{thread.author.name}</span> &middot; {new Date(thread.createdAt).toLocaleDateString()} By <span className="text-gray-600 font-medium">{thread.author.name}</span> &middot; {new Date(thread.createdAt).toLocaleDateString()}
</div> </p>
<p className="text-sm text-gray-300 whitespace-pre-wrap leading-relaxed">{thread.content}</p> <p className="text-sm text-gray-700 whitespace-pre-wrap leading-relaxed">{thread.content}</p>
</div> </div>
<div className="space-y-4"> {/* Replies section */}
<h2 className="text-lg font-semibold flex items-center gap-2"> <div className="space-y-3">
<MessageCircle size={18} className="text-[#D4AF37]" /> <h2 className="text-base font-semibold text-gray-900 flex items-center gap-2">
<MessageCircle size={16} className="text-emerald-600" />
Replies ({posts.length}) Replies ({posts.length})
</h2> </h2>
{posts.length === 0 ? ( {posts.length === 0 ? (
<div className="bg-[#111118] border border-gray-800 rounded-lg p-6 text-center"> <div className="bg-white border border-gray-100 shadow-sm rounded-2xl p-6 text-center">
<p className="text-gray-500 text-sm">No replies yet. Be the first to respond.</p> <p className="text-gray-400 text-sm">No replies yet. Be the first to respond.</p>
</div> </div>
) : ( ) : (
posts.map(post => ( posts.map(post => (
<div key={post.id} className="bg-[#111118] border border-gray-800 rounded-lg p-4 space-y-2"> <div key={post.id} className="bg-white border border-gray-100 shadow-sm rounded-2xl p-4 space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm font-semibold">{post.author.name}</span> <div className="flex items-center gap-2">
<span className="text-xs text-gray-500">{new Date(post.createdAt).toLocaleDateString()}</span> <div className="w-7 h-7 rounded-xl bg-emerald-100 flex items-center justify-center">
<span className="text-xs font-bold text-emerald-700">{post.author.name[0]?.toUpperCase()}</span>
</div> </div>
<p className="text-sm text-gray-300 whitespace-pre-wrap">{post.content}</p> <span className="text-sm font-semibold text-gray-900">{post.author.name}</span>
</div>
<span className="text-xs text-gray-400">{new Date(post.createdAt).toLocaleDateString()}</span>
</div>
<p className="text-sm text-gray-700 whitespace-pre-wrap">{post.content}</p>
{post.shariahStatus !== 'approved' && ( {post.shariahStatus !== 'approved' && (
<div className="flex items-center gap-1 text-yellow-500 text-xs pt-1"> <div className="flex items-center gap-1 text-yellow-600 text-xs pt-1">
<ShieldAlert size={12} /> Pending shariah review <ShieldAlert size={12} /> Pending shariah review
</div> </div>
)} )}
@@ -149,29 +169,34 @@ export default function ThreadDetailPage() {
)} )}
</div> </div>
{/* Reply form */}
{token ? ( {token ? (
<form onSubmit={handleReply} className="bg-[#111118] border border-gray-800 rounded-lg p-4 space-y-3"> <form onSubmit={handleReply} className="bg-white border border-gray-100 shadow-sm rounded-2xl p-4 space-y-3">
<textarea <textarea
placeholder="Write your reply..." placeholder="Write your reply..."
value={reply} value={reply}
onChange={e => setReply(e.target.value)} onChange={e => setReply(e.target.value)}
required required
rows={3} rows={3}
className="w-full bg-[#0a0a0f] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none resize-none" className="w-full bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-sm text-gray-900 placeholder-gray-400 focus:border-emerald-400 outline-none resize-none"
/> />
{replyError && <p className="text-red-500 text-xs">{replyError}</p>} {replyError && <p className="text-red-500 text-xs">{replyError}</p>}
<button type="submit" disabled={submitting || !reply.trim()} <button
className="flex items-center gap-1 bg-[#D4AF37] text-[#0a0a0f] px-4 py-2 rounded text-sm font-semibold disabled:opacity-50 hover:bg-[#C9A84C] transition"> type="submit"
<Send size={16} /> {submitting ? 'Posting...' : 'Post Reply'} disabled={submitting || !reply.trim()}
className="flex items-center gap-1.5 bg-emerald-600 text-white px-4 py-2.5 rounded-xl text-sm font-semibold disabled:opacity-50 active:scale-[0.97] transition-transform"
>
<Send size={15} /> {submitting ? 'Posting...' : 'Post Reply'}
</button> </button>
</form> </form>
) : ( ) : (
<div className="bg-[#111118] border border-gray-800 rounded-lg p-4 text-center"> <div className="bg-white border border-gray-100 shadow-sm rounded-2xl p-4 text-center">
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
<a href="/login" className="text-[#D4AF37] hover:underline font-semibold">Sign in</a> to reply to this thread. <a href="/login" className="text-emerald-600 font-semibold">Sign in</a> to reply to this thread.
</p> </p>
</div> </div>
)} )}
</div> </div>
</div>
) )
} }
+169 -51
View File
@@ -2,73 +2,162 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useAuth } from '@/lib/AuthContext' import { useAuth } from '@/lib/AuthContext'
import { Plus, ChevronRight } from 'lucide-react' import { Plus, ChevronRight, MessageCircle, X, ArrowLeft } from 'lucide-react'
interface Category { id: string; name: string; description: string; icon: string } interface Category { id: string; name: string; description: string; icon: string }
interface Thread { id: string; title: string; content: string; author: { name: string }; category: { name: string }; _count: { posts: number }; createdAt: string } interface Thread {
id: string; title: string; content: string
author: { name: string }; category: { name: string }
_count: { posts: number }; createdAt: string
}
export default function ForumPage() { export default function ForumPage() {
const { token } = useAuth() const { token } = useAuth()
const [categories, setCategories] = useState<Category[]>([]) const [categories, setCategories] = useState<Category[]>([])
const [threads, setThreads] = useState<Thread[]>([]) const [threads, setThreads] = useState<Thread[]>([])
const [selectedCat, setSelectedCat] = useState<string | null>(null) const [selectedCat, setSelectedCat] = useState<Category | null>(null)
const [view, setView] = useState<'categories' | 'threads'>('categories') const [view, setView] = useState<'categories' | 'threads'>('categories')
const [showCreate, setShowCreate] = useState(false) const [showCreate, setShowCreate] = useState(false)
const [loading, setLoading] = useState(true)
const [toast, setToast] = useState('')
useEffect(() => { fetch('/api/forum/categories').then(r => r.json()).then(d => setCategories(d.categories || [])) }, []) useEffect(() => {
fetch('/api/forum/categories')
.then(r => r.json())
.then(d => setCategories(d.categories || []))
.finally(() => setLoading(false))
}, [])
const loadThreads = async (catId?: string) => { const loadThreads = async (catId: string) => {
const params = catId ? `?categoryId=${catId}` : '' setLoading(true)
const res = await fetch(`/api/forum/threads${params}`) const res = await fetch(`/api/forum/threads?categoryId=${catId}`)
const data = await res.json() const data = await res.json()
setThreads(data.threads || []) setThreads(data.threads || [])
setLoading(false)
} }
const showToast = (msg: string) => { setToast(msg); setTimeout(() => setToast(''), 3000) }
return ( return (
<div className="max-w-4xl mx-auto p-4 sm:p-6 space-y-6"> <div className="min-h-dvh bg-bg-deep pb-32 text-white">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-[#D4AF37]">Forum</h1> {/* Header */}
<div className="px-5 pt-14 pb-4 flex items-center justify-between">
<div className="flex items-center gap-3">
{view === 'threads' && (
<button onClick={() => { setView('categories'); setSelectedCat(null) }} className="w-9 h-9 flex items-center justify-center rounded-xl bg-bg-card border border-border active:opacity-60 cursor-pointer">
<ArrowLeft size={18} className="text-text-secondary" />
</button>
)}
<div>
<h1 className="text-xl font-bold text-white">{view === 'threads' && selectedCat ? selectedCat.name : 'Forum'}</h1>
<p className="text-[11px] text-text-secondary mt-0.5">
{view === 'categories' ? 'Community discussions' : `${threads.length} threads`}
</p>
</div>
</div>
{token && ( {token && (
<button onClick={() => setShowCreate(true)} <button
className="flex items-center gap-1 bg-[#D4AF37] text-[#0a0a0f] px-3 py-2 rounded text-sm font-semibold hover:bg-[#C9A84C] transition"><Plus size={16} /> New Thread</button> onClick={() => setShowCreate(true)}
className="flex items-center gap-1.5 gold-gradient text-bg-deep px-4 py-2.5 rounded-xl font-bold text-sm active:scale-[0.97] transition-transform cursor-pointer shadow-sm shadow-gold/10"
>
<Plus size={16} /> Post
</button>
)} )}
</div> </div>
{view === 'categories' ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> {/* Loading */}
{categories.map(c => ( {loading && (
<button key={c.id} onClick={() => { setSelectedCat(c.id); setView('threads'); loadThreads(c.id) }} <div className="px-5 pt-4 space-y-3">
className="bg-[#111118] border border-gray-800 rounded-lg p-4 text-left hover:border-gray-700 transition"> {[...Array(4)].map((_, i) => (
<div className="flex items-center gap-3"> <div key={i} className="h-20 rounded-2xl bg-bg-card border border-border/50 animate-pulse" />
<span className="text-2xl">{c.icon}</span> ))}
<div className="flex-1">
<h3 className="font-semibold">{c.name}</h3>
<p className="text-xs text-gray-500">{c.description}</p>
</div> </div>
<ChevronRight size={16} className="text-gray-600" /> )}
{/* Categories view */}
{!loading && view === 'categories' && (
<div className="px-5 pt-4 space-y-3">
{categories.length === 0 ? (
<div className="text-center py-16">
<div className="text-4xl mb-3">🕌</div>
<p className="text-text-secondary text-sm">No categories yet</p>
</div>
) : categories.map(c => (
<button
key={c.id}
onClick={() => { setSelectedCat(c); setView('threads'); loadThreads(c.id) }}
className="w-full text-left bg-bg-card rounded-2xl border border-border p-4 active:scale-[0.97] transition-transform hover:border-gold/30 cursor-pointer"
>
<div className="flex items-center gap-3">
<span className="text-2xl w-10 h-10 flex items-center justify-center bg-bg-deep border border-border rounded-xl shrink-0">
{c.icon || '💬'}
</span>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-white text-sm">{c.name}</h3>
<p className="text-xs text-text-secondary mt-0.5 line-clamp-1">{c.description}</p>
</div>
<ChevronRight size={16} className="text-text-muted shrink-0" />
</div> </div>
</button> </button>
))} ))}
</div> </div>
) : ( )}
<div className="space-y-3">
<button onClick={() => setView('categories')} className="text-sm text-[#D4AF37] hover:underline mb-2 inline-block">&larr; Back to categories</button> {/* Threads view */}
{threads.map(t => ( {!loading && view === 'threads' && (
<div key={t.id} className="bg-[#111118] border border-gray-800 rounded-lg p-4 space-y-1"> <div className="px-5 pt-4 space-y-3">
<h3 className="font-semibold">{t.title}</h3> {threads.length === 0 ? (
<p className="text-sm text-gray-400 line-clamp-2">{t.content}</p> <div className="text-center py-16">
<div className="flex items-center gap-3 text-xs text-gray-500 pt-1"> <div className="text-4xl mb-3">💬</div>
<span>{t.author.name}</span><span>{t.category.name}</span><span>{t._count.posts} replies</span><span>{new Date(t.createdAt).toLocaleDateString()}</span> <p className="text-white font-semibold mb-1">No threads yet</p>
<p className="text-text-secondary text-sm">Start the first discussion</p>
</div>
) : threads.map(t => (
<div key={t.id} className="bg-bg-card rounded-2xl border border-border p-4">
<h3 className="font-semibold text-white text-sm leading-snug mb-1">{t.title}</h3>
<p className="text-xs text-text-secondary line-clamp-2 mb-3">{t.content}</p>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<div className="w-5 h-5 rounded-full bg-bg-deep border border-border flex items-center justify-center">
<span className="text-[9px] text-[#D4AF37] font-bold">{t.author.name[0]}</span>
</div>
<span className="text-[11px] text-text-secondary">{t.author.name}</span>
</div>
<div className="flex items-center gap-1 text-[11px] text-text-secondary">
<MessageCircle size={12} />
{t._count.posts}
</div>
</div> </div>
</div> </div>
))} ))}
</div> </div>
)} )}
{showCreate && token && <CreateThreadModal token={token} categories={categories} onClose={() => setShowCreate(false)} onCreated={() => { setShowCreate(false); if (selectedCat) loadThreads(selectedCat) }} />}
{/* Toast */}
{toast && (
<div className="fixed bottom-24 left-5 right-5 bg-bg-card border border-border text-white rounded-2xl px-4 py-3 text-sm text-center z-50 shadow-xl">
{toast}
</div>
)}
{/* Create thread bottom sheet */}
{showCreate && token && (
<CreateThreadSheet
token={token}
categories={categories}
onClose={() => setShowCreate(false)}
onCreated={() => { if (selectedCat) loadThreads(selectedCat.id); showToast('Thread posted!') }}
showToast={showToast}
/>
)}
</div> </div>
) )
} }
function CreateThreadModal({ token, categories, onClose, onCreated }: { token: string; categories: Category[]; onClose: () => void; onCreated: () => void }) { function CreateThreadSheet({ token, categories, onClose, onCreated, showToast }: {
token: string; categories: Category[]; onClose: () => void; onCreated: () => void; showToast: (m: string) => void
}) {
const [title, setTitle] = useState('') const [title, setTitle] = useState('')
const [content, setContent] = useState('') const [content, setContent] = useState('')
const [categoryId, setCategoryId] = useState(categories[0]?.id || '') const [categoryId, setCategoryId] = useState(categories[0]?.id || '')
@@ -76,29 +165,58 @@ function CreateThreadModal({ token, categories, onClose, onCreated }: { token: s
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
if (!categoryId) return
setLoading(true) setLoading(true)
const res = await fetch('/api/forum/threads', { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ title, content, categoryId }) }) try {
const res = await fetch('/api/forum/threads', {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ title, content, categoryId }),
})
const data = await res.json()
if (res.ok) { onCreated(); onClose() }
else showToast(data.error || 'Failed to post')
} catch { showToast('Something went wrong') }
setLoading(false) setLoading(false)
if (res.ok) { onCreated() } else { const d = await res.json(); alert(d.error) }
} }
return ( return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center p-4 z-50"> <div className="fixed inset-0 z-50 flex flex-col justify-end" onClick={onClose}>
<form onSubmit={handleSubmit} className="bg-[#111118] border border-gray-800 rounded-lg p-6 max-w-md w-full space-y-4"> <div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
<h2 className="text-lg font-bold">New Thread</h2> <form
<select value={categoryId} onChange={e => setCategoryId(e.target.value)} onSubmit={handleSubmit}
className="w-full bg-[#0a0a0f] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none"> className="relative bg-bg-card border-t border-x border-border rounded-t-3xl p-6 pb-10 space-y-4"
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)} onClick={e => e.stopPropagation()}
</select> >
<input type="text" placeholder="Title" value={title} onChange={e => setTitle(e.target.value)} required <div className="flex items-center justify-between mb-1">
className="w-full bg-[#0a0a0f] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none" /> <div className="w-10 h-1 bg-border rounded-full mx-auto absolute left-1/2 -translate-y-1/2 top-3" />
<textarea placeholder="Content" value={content} onChange={e => setContent(e.target.value)} required rows={4} <h2 className="text-lg font-bold text-white mt-2">New Thread</h2>
className="w-full bg-[#0a0a0f] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none" /> <button type="button" onClick={onClose} className="w-8 h-8 flex items-center justify-center rounded-full bg-bg-deep border border-border text-text-muted mt-2 cursor-pointer">
<button type="submit" disabled={loading} <X size={15} />
className="w-full bg-[#D4AF37] text-[#0a0a0f] py-2 rounded font-semibold disabled:opacity-50 hover:bg-[#C9A84C] transition"> </button>
{loading ? 'Posting...' : 'Create Thread'} </div>
<select
value={categoryId} onChange={e => setCategoryId(e.target.value)}
className="w-full bg-bg-deep border border-border rounded-xl px-4 py-3.5 text-sm text-white focus:border-gold focus:outline-none"
>
{categories.map(c => <option key={c.id} value={c.id} className="bg-bg-card">{c.icon} {c.name}</option>)}
</select>
<input
type="text" placeholder="Thread title" value={title} onChange={e => setTitle(e.target.value)} required
className="w-full bg-bg-deep border border-border rounded-xl px-4 py-4 text-sm text-white placeholder-text-muted focus:border-gold focus:outline-none"
/>
<textarea
placeholder="Share your thoughts…" value={content} onChange={e => setContent(e.target.value)} required rows={4}
className="w-full bg-bg-deep border border-border rounded-xl px-4 py-3 text-sm text-white placeholder-text-muted focus:border-gold focus:outline-none resize-none"
/>
<button
type="submit" disabled={loading}
className="w-full gold-gradient text-bg-deep py-4 rounded-2xl font-bold disabled:opacity-50 active:scale-[0.97] transition-transform cursor-pointer"
>
{loading ? 'Posting…' : 'Post Thread'}
</button> </button>
<button type="button" onClick={onClose} className="w-full text-gray-500 text-sm hover:text-white">Cancel</button>
</form> </form>
</div> </div>
) )
+97 -14
View File
@@ -1,26 +1,50 @@
@tailwind base; @import "tailwindcss";
@tailwind components;
@tailwind utilities; @theme {
--color-bg-deep: #0a0a0f;
--color-bg-card: #111118;
--color-gold: #D4AF37;
--color-gold-light: #e2c55e;
--color-nur-purple: #A78BFA;
--color-nur-bg: #1a1a2e;
--color-emerald: #10B981;
--color-border: #222;
--color-border-light: #333;
--color-text-primary: #fff;
--color-text-secondary: #999;
--color-text-muted: #666;
--color-gold-gradient-start: #D4AF37;
--color-gold-gradient-end: #B8860B;
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
--font-arabic: "Noto Naskh Arabic", "Traditional Arabic", serif;
}
:root { :root {
--background: #0a0a0f; --nav-floating-bottom: 24px;
--foreground: #ededed; }
--gold: #D4AF37;
--gold-dark: #C9A84C; html {
height: 100%;
color-scheme: dark;
} }
body { body {
color: var(--foreground); height: 100%;
background: var(--background); color: var(--color-text-primary);
font-family: Arial, Helvetica, sans-serif; background: var(--color-bg-deep);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
overscroll-behavior: none;
} }
* { * {
box-sizing: border-box; box-sizing: border-box;
padding: 0; padding: 0;
margin: 0; margin: 0;
-webkit-tap-highlight-color: transparent;
} }
a { a {
@@ -28,8 +52,67 @@ a {
text-decoration: none; text-decoration: none;
} }
@media (prefers-color-scheme: dark) { /* Prevent iOS zoom on input focus */
html { input, select, textarea {
color-scheme: dark; font-size: 16px;
} }
/* Scrollbar hidden utility */
.scrollbar-none::-webkit-scrollbar { display: none; }
.scrollbar-none { -ms-overflow-style: none; scrollbar-width: none; }
/* Chat scroll */
.chat-scroll {
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
}
/* Glassmorphism utility (Spatial Vision OS style) */
.glass {
background: rgba(255, 255, 255, 0.07);
backdrop-filter: blur(30px) saturate(180%);
-webkit-backdrop-filter: blur(30px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.12);
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.4);
}
.glass-light {
background: rgba(255, 255, 255, 0.03);
backdrop-filter: blur(20px) saturate(150%);
-webkit-backdrop-filter: blur(20px) saturate(150%);
border: 1px solid rgba(255, 255, 255, 0.06);
}
/* Gold gradient */
.gold-gradient {
background: linear-gradient(135deg, #D4AF37, #B8860B);
}
/* Nur purple gradient */
.nur-gradient {
background: linear-gradient(135deg, #A78BFA, #7C3AED);
}
/* Gold-purple combined gradient (for center Nur tab) */
.gold-purple-gradient {
background: linear-gradient(135deg, #D4AF37, #A78BFA);
}
/* Prayer countdown ring SVG */
.prayer-ring {
transform: rotate(-90deg);
}
.prayer-ring-bg {
fill: none;
stroke: rgba(255, 255, 255, 0.08);
stroke-width: 4;
}
.prayer-ring-fg {
fill: none;
stroke: #D4AF37;
stroke-width: 4;
stroke-linecap: round;
transition: stroke-dashoffset 1s ease;
} }
+332 -75
View File
@@ -1,102 +1,359 @@
'use client' 'use client'
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef, useCallback } from 'react'
import { useAuth } from '@/lib/AuthContext' import { useAuth } from '@/lib/AuthContext'
import { getWorldMonitorIframeUrl, broadcastAuthToIframe } from '@/lib/halal-monitor-bridge' import { Crown, Lock, Search, MapPin, Navigation, Bookmark, X, Loader2 } from 'lucide-react'
import { Crown, Shield, Lock } from 'lucide-react' import dynamic from 'next/dynamic'
export default function HalalMonitorPage() { // Leaflet must be loaded client-side only (no SSR)
const { token, user, loading: authLoading } = useAuth() const MapContainer = dynamic(() => import('react-leaflet').then(m => m.MapContainer), { ssr: false })
const iframeRef = useRef<HTMLIFrameElement>(null) const TileLayer = dynamic(() => import('react-leaflet').then(m => m.TileLayer), { ssr: false })
const [loaded, setLoaded] = useState(false) const Marker = dynamic(() => import('react-leaflet').then(m => m.Marker), { ssr: false })
const [error, setError] = useState<string | null>(null) const Popup = dynamic(() => import('react-leaflet').then(m => m.Popup), { ssr: false })
// Broadcast auth to iframe when both are ready interface Place {
useEffect(() => { id: string
if (!loaded || !token || !user || !iframeRef.current) return lat: number
try { lon: number
broadcastAuthToIframe(iframeRef.current, { name: string
token, type: 'mosque' | 'restaurant'
user: { address?: string
id: user.id, }
name: user.name || user.email,
email: user.email,
isPremium: user.isPremium ?? false,
},
})
} catch (e) {
console.error('halal-monitor auth broadcast failed', e)
}
}, [loaded, token, user])
if (authLoading) { interface MapCenter {
lat: number
lon: number
}
function UpgradeCTA() {
return ( return (
<div className="max-w-6xl mx-auto p-8 text-center"> <div className="min-h-dvh bg-bg-deep pb-32 flex flex-col items-center justify-center px-6 text-center space-y-4 text-white">
<div className="animate-pulse text-gray-500">Loading...</div> <div className="w-16 h-16 rounded-2xl bg-bg-card border border-gold/20 flex items-center justify-center">
<Crown size={32} className="text-gold" />
</div>
<h1 className="text-2xl font-bold text-white">Halal Monitor</h1>
<p className="text-text-secondary max-w-md text-sm">
Find mosques and halal restaurants anywhere in the world. Available to Premium members.
</p>
<div className="bg-bg-card border border-border shadow-lg rounded-3xl p-6 max-w-sm w-full space-y-3">
<ul className="text-sm text-text-secondary space-y-2 text-left">
<li className="flex items-center gap-2"><span className="text-gold"></span> Mosques and prayer spaces worldwide</li>
<li className="flex items-center gap-2"><span className="text-gold"></span> Halal restaurants near you</li>
<li className="flex items-center gap-2"><span className="text-gold"></span> Bookmark favourite places</li>
<li className="flex items-center gap-2"><span className="text-gold"></span> City search anywhere in the world</li>
</ul>
<a
href="/upgrade"
className="block w-full gold-gradient text-bg-deep py-3.5 rounded-2xl font-bold active:scale-[0.97] transition-transform text-center cursor-pointer shadow-sm shadow-gold/10"
>
Upgrade Now
</a>
</div>
</div> </div>
) )
} }
if (!token || !user) { function LoginGate() {
return ( return (
<div className="max-w-6xl mx-auto p-8 flex flex-col items-center justify-center min-h-[60vh] text-center space-y-4"> <div className="min-h-dvh bg-bg-deep pb-32 flex flex-col items-center justify-center px-6 text-center space-y-4 text-white">
<Lock size={48} className="text-gray-600" /> <div className="w-16 h-16 rounded-2xl bg-bg-card border border-border flex items-center justify-center">
<h1 className="text-2xl font-bold text-[#D4AF37]">Halal Monitor</h1> <Lock size={32} className="text-text-muted" />
<p className="text-gray-400 max-w-md"> </div>
Sign in to access real-time halal compliance monitoring, Shariah screening, and market intelligence. <h1 className="text-2xl font-bold text-white">Halal Monitor</h1>
<p className="text-text-secondary max-w-md text-sm">
Sign in to access the global halal map mosques, prayer spaces, and halal restaurants near you.
</p> </p>
<a href="/login" <a
className="inline-flex items-center gap-2 bg-[#D4AF37] text-[#0a0a0f] px-6 py-2 rounded font-semibold hover:bg-[#C9A84C] transition"> href="/login"
className="inline-flex items-center gap-2 gold-gradient text-bg-deep px-8 py-4 rounded-2xl font-bold active:scale-[0.97] transition-transform cursor-pointer shadow-sm shadow-gold/10"
>
Sign In Sign In
</a> </a>
</div> </div>
) )
} }
if (!user.isPremium) { export default function HalalMonitorPage() {
return ( const { token, user, loading: authLoading } = useAuth()
<div className="max-w-6xl mx-auto p-8 flex flex-col items-center justify-center min-h-[60vh] text-center space-y-4"> const [places, setPlaces] = useState<Place[]>([])
<Crown size={48} className="text-[#D4AF37]" /> const [center, setCenter] = useState<MapCenter | null>(null)
<h1 className="text-2xl font-bold text-[#D4AF37]">Halal Monitor</h1> const [loading, setLoading] = useState(false)
<p className="text-gray-400 max-w-md"> const [error, setError] = useState<string | null>(null)
Real-time halal compliance monitoring and Shariah screening is available to Premium members. const [citySearch, setCitySearch] = useState('')
</p> const [bookmarked, setBookmarked] = useState<Set<string>>(new Set())
<div className="bg-[#111118] border border-gray-800 rounded-lg p-6 max-w-sm w-full space-y-3"> const [leafletLoaded, setLeafletLoaded] = useState(false)
<Shield size={32} className="text-[#D4AF37] mx-auto" /> const mapRef = useRef<any>(null)
<h2 className="font-bold">Upgrade to Premium</h2>
<ul className="text-sm text-gray-400 space-y-1"> // Load Leaflet CSS
<li> Real-time Shariah screening</li> useEffect(() => {
<li> Halal stock monitoring</li> if (typeof window === 'undefined') return
<li> Compliance alerts</li> const link = document.createElement('link')
<li> Market intelligence</li> link.rel = 'stylesheet'
</ul> link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css'
<button className="w-full bg-[#D4AF37] text-[#0a0a0f] py-2 rounded font-semibold hover:bg-[#C9A84C] transition"> document.head.appendChild(link)
Upgrade Now setLeafletLoaded(true)
</button> return () => { document.head.removeChild(link) }
</div> }, [])
</div>
// Load existing bookmarks
useEffect(() => {
if (!token) return
fetch('/api/halal/bookmarks', { headers: { 'Authorization': `Bearer ${token}` } })
.then(r => r.json())
.then(d => {
if (d.bookmarks) setBookmarked(new Set(d.bookmarks.map((b: any) => b.itemId)))
})
.catch(() => {})
}, [token])
const fetchPlaces = useCallback(async (lat: number, lon: number) => {
setLoading(true)
setError(null)
try {
const res = await fetch(`/api/halal/places?lat=${lat}&lng=${lon}&radius=5000`, {
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
})
const data = await res.json()
if (res.status === 429) {
setError('Daily search limit reached. Upgrade to Premium for unlimited searches.')
return
}
if (!res.ok) throw new Error(data.error || 'Failed to load places')
setPlaces([...data.mosques, ...(data.restaurants || [])])
} catch (e: any) {
setError(e.message || 'Failed to load places')
} finally {
setLoading(false)
}
}, [token])
const handleGeolocate = () => {
if (!navigator.geolocation) {
setError('Geolocation is not supported by your browser.')
return
}
setLoading(true)
navigator.geolocation.getCurrentPosition(
pos => {
const { latitude: lat, longitude: lon } = pos.coords
setCenter({ lat, lon })
fetchPlaces(lat, lon)
},
() => {
setLoading(false)
setError('Could not get your location. Try searching by city.')
}
) )
} }
// Premium user — show the World Monitor const handleCitySearch = async (e: React.FormEvent) => {
e.preventDefault()
if (!citySearch.trim()) return
setLoading(true)
setError(null)
try {
const res = await fetch(
`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(citySearch)}&format=json&limit=1`,
{ headers: { 'User-Agent': 'FalahMobile/1.0' } }
)
const results = await res.json()
if (!results.length) {
setError('City not found. Try a different search term.')
setLoading(false)
return
}
const lat = parseFloat(results[0].lat)
const lon = parseFloat(results[0].lon)
setCenter({ lat, lon })
fetchPlaces(lat, lon)
} catch {
setError('City search failed. Please try again.')
setLoading(false)
}
}
const handleBookmark = async (place: Place) => {
if (!token) return
const isBookmarked = bookmarked.has(place.id)
if (isBookmarked) {
const res = await fetch(`/api/halal/bookmarks?itemId=${place.id}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` },
})
if (res.ok) setBookmarked(prev => { const next = new Set(prev); next.delete(place.id); return next })
} else {
const res = await fetch('/api/halal/bookmarks', {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ itemId: place.id, itemType: place.type, label: place.name }),
})
if (res.ok) setBookmarked(prev => new Set([...prev, place.id]))
}
}
if (authLoading) {
return ( return (
<div className="w-full h-[calc(100vh-3.5rem)] flex flex-col"> <div className="min-h-dvh bg-bg-deep pb-24 flex items-center justify-center">
<div className="bg-[#111118] border-b border-gray-800 px-4 py-2 flex items-center justify-between shrink-0"> <div className="w-8 h-8 rounded-full border-2 border-gold/30 border-t-gold animate-spin" />
<div className="flex items-center gap-2">
<Crown size={16} className="text-[#D4AF37]" />
<h1 className="text-sm font-semibold text-[#D4AF37]">Halal Monitor</h1>
</div> </div>
{error && <span className="text-xs text-red-400">{error}</span>} )
}
if (!token || !user) return <LoginGate />
if (!user.isPremium && !user.isPro) return <UpgradeCTA />
const mosques = places.filter(p => p.type === 'mosque')
const restaurants = places.filter(p => p.type === 'restaurant')
return (
<div className="flex flex-col h-[calc(100vh-3.5rem)] text-white bg-bg-deep">
{/* Toolbar */}
<div className="bg-bg-deep border-b border-border/80 px-4 py-2.5 flex items-center gap-3 shrink-0 flex-wrap">
<div className="flex items-center gap-1.5">
<Crown size={15} className="text-gold" />
<span className="text-sm font-semibold text-white">Halal Monitor</span>
</div> </div>
<iframe <form onSubmit={handleCitySearch} className="flex gap-2 flex-1 max-w-sm">
ref={iframeRef} <div className="relative flex-1">
src={getWorldMonitorIframeUrl()} <Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-text-muted" />
className="flex-1 w-full border-0" <input
sandbox="allow-scripts allow-same-origin allow-forms allow-popups" type="text"
onLoad={() => setLoaded(true)} placeholder="Search city..."
onError={() => setError('Failed to load World Monitor')} value={citySearch}
title="Halal Monitor" onChange={e => setCitySearch(e.target.value)}
className="w-full bg-bg-card border border-border rounded-xl pl-8 pr-3 py-1.5 text-xs text-white placeholder-text-muted focus:border-gold outline-none"
/> />
</div> </div>
<button type="submit" className="gold-gradient text-bg-deep px-3 py-1.5 rounded-xl text-xs font-bold active:scale-[0.97] transition-transform cursor-pointer">
Go
</button>
</form>
<button
onClick={handleGeolocate}
className="flex items-center gap-1 text-xs text-text-secondary hover:text-gold transition cursor-pointer"
>
<Navigation size={13} /> Near me
</button>
{loading && <Loader2 size={14} className="animate-spin text-gold" />}
{places.length > 0 && (
<span className="text-xs text-text-muted ml-auto">
{mosques.length} mosques · {restaurants.length} restaurants
</span>
)}
</div>
{/* Error bar */}
{error && (
<div className="bg-red-950/20 border-b border-red-900/40 px-4 py-2 flex items-center justify-between shrink-0">
<span className="text-xs text-red-400">{error}</span>
<button onClick={() => setError(null)} className="cursor-pointer text-red-400 hover:text-red-300"><X size={14} /></button>
</div>
)}
{/* Map */}
<div className="flex-1 relative">
{!center ? (
<div className="absolute inset-0 flex flex-col items-center justify-center text-center space-y-4 bg-bg-deep text-white">
<MapPin size={40} className="text-text-muted" />
<p className="text-text-secondary text-sm">Search a city or use your location to find halal places.</p>
<button
onClick={handleGeolocate}
className="flex items-center gap-2 gold-gradient text-bg-deep px-5 py-3 rounded-2xl text-sm font-bold active:scale-[0.97] transition-transform cursor-pointer shadow-sm shadow-gold/10"
>
<Navigation size={16} /> Use My Location
</button>
</div>
) : leafletLoaded ? (
<MapWithMarkers
center={center}
places={places}
bookmarked={bookmarked}
onBookmark={handleBookmark}
/>
) : null}
</div>
</div>
)
}
function MapWithMarkers({
center,
places,
bookmarked,
onBookmark,
}: {
center: MapCenter
places: Place[]
bookmarked: Set<string>
onBookmark: (p: Place) => void
}) {
const [L, setL] = useState<any>(null)
useEffect(() => {
import('leaflet').then(leaflet => {
// Fix default marker icons for Next.js
delete (leaflet.Icon.Default.prototype as any)._getIconUrl
leaflet.Icon.Default.mergeOptions({
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
})
setL(leaflet)
})
}, [])
if (!L) return null
const mosqueIcon = new L.Icon({
iconUrl: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-green.png',
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41],
})
const restaurantIcon = new L.Icon({
iconUrl: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-orange.png',
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41],
})
return (
<MapContainer
center={[center.lat, center.lon]}
zoom={14}
style={{ height: '100%', width: '100%' }}
key={`${center.lat}-${center.lon}`}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{places.map(place => (
<Marker
key={place.id}
position={[place.lat, place.lon]}
icon={place.type === 'mosque' ? mosqueIcon : restaurantIcon}
>
<Popup>
<div className="min-w-[160px]">
<p className="font-semibold text-sm">{place.name}</p>
{place.address && <p className="text-xs text-gray-500 mt-0.5">{place.address}</p>}
<p className="text-xs text-gray-400 mt-1 capitalize">{place.type}</p>
<button
onClick={() => onBookmark(place)}
className="mt-2 flex items-center gap-1 text-xs text-blue-600 hover:text-blue-800"
>
<Bookmark size={12} />
{bookmarked.has(place.id) ? 'Bookmarked' : 'Bookmark'}
</button>
</div>
</Popup>
</Marker>
))}
</MapContainer>
) )
} }
+514
View File
@@ -0,0 +1,514 @@
'use client'
import { useState, useEffect, useMemo, useCallback } from 'react'
import Link from 'next/link'
import { Heart, MessageCircle, Share2, RotateCcw, Bell, Clock } from 'lucide-react'
/* ─── Types & Constants ─────────────────────────────────────── */
type PrayerName = 'Fajr' | 'Dhuhr' | 'Asr' | 'Maghrib' | 'Isha'
const PRAYERS: PrayerName[] = ['Fajr', 'Dhuhr', 'Asr', 'Maghrib', 'Isha']
interface Timings {
Fajr: string
Sunrise: string
Dhuhr: string
Asr: string
Maghrib: string
Isha: string
}
const AYATUL_KURSI = {
arabic:
'ٱللَّهُ لَآ إِلَـٰهَ إِلَّا هُوَ ٱلْحَىُّ ٱلْقَيُّومُ ۚ لَا تَأْخُذُهُۥ سِنَةٌ وَلَا نَوْمٌ ۚ لَّهُۥ مَا فِى ٱلسَّمَـٰوَٰتِ وَمَا فِى ٱلْأَرْضِ ۗ مَن ذَا ٱلَّذِى يَشْفَعُ عِندَهُۥٓ إِلَّا بِإِذْنِهِۦ ۚ يَعْلَمُ مَا بَيْنَ أَيْدِيهِمْ وَمَا خَلْفَهُمْ ۖ وَلَا يُحِيطُونَ بِشَىْءٍ مِّنْ عِلْمِهِۦٓ إِلَّا بِمَا شَآءَ ۚ وَسِعَ كُرْسِيُّهُ ٱلسَّمَـٰوَٰتِ وَٱلْأَرْضَ ۖ وَلَا يَـُٔودُهُۥ حِفْظُهُمَا ۚ وَهُوَ ٱلْعَلِىُّ ٱلْعَظِيمُ',
translation:
'Allah! There is no god but Him, the Ever-Living, the Sustainer of existence. Neither drowsiness nor sleep overtakes Him. To Him belongs whatever is in the heavens and whatever is on the earth. Who is it that can intercede with Him except by His permission? He knows what is before them and what will be after them, and they encompass nothing of His knowledge except what He wills. His Kursi extends over the heavens and the earth, and their preservation tires Him not. And He is the Most High, the Most Great.',
surah: 'Surah Al-Baqarah (2:255)',
}
const DHIKR_ITEMS = [
{ arabic: 'سُبْحَانَ ٱللَّٰهِ', latin: 'SubhanAllah', target: 33, color: '#D4AF37' },
{ arabic: 'ٱلْحَمْدُ لِلَّٰهِ', latin: 'Alhamdulillah', target: 33, color: '#10B981' },
{ arabic: 'ٱللَّٰهُ أَكْبَرُ', latin: 'Allahu Akbar', target: 34, color: '#D4AF37' },
]
const POSTS = [
{
id: 1,
name: 'Ahmad R.',
avatar: 'AR',
content:
'Alhamdulillah, completed my first full month of Fajr prayer on time! ☀️ The early morning blessings are real.',
likes: 24,
comments: 5,
},
{
id: 2,
name: 'Fatima Z.',
avatar: 'FZ',
content:
'Just finished reading Surah Al-Kahf. The story of the people of the cave never gets old. SubhanAllah how Allah protects the believers! 📖',
likes: 42,
comments: 8,
},
{
id: 3,
name: 'Omar H.',
avatar: 'OH',
content:
'Anyone looking for a good tafsir of Juz Amma? I highly recommend "The Clear Quran" by Dr. Mustafa Khattab. Very accessible English. 🤲',
likes: 18,
comments: 12,
},
]
/* ─── Helpers ────────────────────────────────────────────────── */
const RING_R = 40
const RING_CIRCUMFERENCE = 2 * Math.PI * RING_R
const DHIKR_R = 22
const DHIKR_CIRCUMFERENCE = 2 * Math.PI * DHIKR_R
function toMins(t: string): number {
const [h, m] = t.split(':').map(Number)
return h * 60 + m
}
function fmtCountdown(mins: number): string {
if (mins <= 0) return 'Now'
const h = Math.floor(mins / 60)
const m = mins % 60
if (h === 0) return `${m}m`
if (m === 0) return `${h}h`
return `${h}h ${m}m`
}
function getGreeting(h: number): string {
if (h < 5) return 'Late Night Reflection'
if (h < 12) return 'Good Morning'
if (h < 17) return 'Good Afternoon'
if (h < 20) return 'Good Evening'
return 'Night Reflection'
}
/* ─── Sub-components ─────────────────────────────────────────── */
function DhikrCard({
item,
count,
onTap,
onReset,
}: {
item: (typeof DHIKR_ITEMS)[number]
count: number
onTap: () => void
onReset: () => void
}) {
const progress = Math.min(count / item.target, 1)
const dashoffset = DHIKR_CIRCUMFERENCE * (1 - progress)
const isComplete = count >= item.target
return (
<div className="flex flex-col items-center gap-2">
{/* Circular ring + count display */}
<button
onClick={onTap}
className="relative w-20 h-20 flex items-center justify-center active:scale-90 transition-transform"
>
<svg width="80" height="80" viewBox="0 0 80 80" className="absolute inset-0">
<circle
cx="40" cy="40" r={DHIKR_R}
fill="none"
stroke="rgba(255,255,255,0.06)"
strokeWidth="4"
/>
<circle
cx="40" cy="40" r={DHIKR_R}
fill="none"
stroke={item.color}
strokeWidth="4"
strokeLinecap="round"
strokeDasharray={DHIKR_CIRCUMFERENCE}
strokeDashoffset={dashoffset}
className="transition-all duration-500 ease-out"
style={{ transform: 'rotate(-90deg)', transformOrigin: 'center' }}
/>
</svg>
<span className={`text-xl font-bold ${isComplete ? 'text-[#D4AF37]' : 'text-white'}`}>
{count}
</span>
</button>
{/* Label */}
<div className="flex flex-col items-center gap-0.5">
<span className="text-[13px] font-arabic leading-tight text-white/90">{item.arabic}</span>
<span className="text-[10px] text-[#999]">{item.latin}</span>
<span className="text-[10px] text-[#666]">/ {item.target}</span>
</div>
{/* Reset */}
{count > 0 && (
<button
onClick={onReset}
className="text-[#666] hover:text-[#999] transition-colors p-0.5"
title={`Reset ${item.latin}`}
>
<RotateCcw size={12} />
</button>
)}
</div>
)
}
function PostCard({ post }: { post: (typeof POSTS)[number] }) {
const [liked, setLiked] = useState(false)
const [likeCount, setLikeCount] = useState(post.likes)
const handleLike = () => {
setLiked((p) => !p)
setLikeCount((p) => (liked ? p - 1 : p + 1))
}
return (
<div className="glass rounded-[24px] overflow-hidden">
{/* Author row */}
<div className="flex items-center gap-3 px-4 pt-4 pb-2">
<div className="w-9 h-9 rounded-full bg-[#D4AF37]/20 flex items-center justify-center shrink-0">
<span className="text-xs font-bold text-[#D4AF37]">{post.avatar}</span>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-white truncate">{post.name}</p>
<p className="text-[11px] text-[#666]">Just now</p>
</div>
</div>
{/* Content */}
<div className="px-4 py-2">
<p className="text-sm text-[#ccc] leading-relaxed">{post.content}</p>
</div>
{/* Action bar */}
<div className="flex items-center gap-5 px-4 py-3 border-t border-[#222]">
<button
onClick={handleLike}
className="flex items-center gap-1.5 active:scale-90 transition-transform"
>
<Heart
size={17}
className={liked ? 'fill-[#D4AF37] text-[#D4AF37]' : 'text-[#666]'}
/>
<span className={`text-xs ${liked ? 'text-[#D4AF37]' : 'text-[#666]'}`}>
{likeCount}
</span>
</button>
<button className="flex items-center gap-1.5 active:scale-90 transition-transform">
<MessageCircle size={17} className="text-[#666]" />
<span className="text-xs text-[#666]">{post.comments}</span>
</button>
<button className="ml-auto active:scale-90 transition-transform">
<Share2 size={16} className="text-[#666]" />
</button>
</div>
</div>
)
}
/* ─── Home Page ──────────────────────────────────────────────── */
export default function HomePage() {
const [now, setNow] = useState(new Date())
const [timings, setTimings] = useState<Timings | null>(null)
const [nextPrayer, setNextPrayer] = useState<{
name: PrayerName
time: string
minsLeft: number
progress: number
} | null>(null)
// Dhikr counters
const [dhikrCounts, setDhikrCounts] = useState([0, 0, 0])
// Tick every 30s
useEffect(() => {
const id = setInterval(() => setNow(new Date()), 30000)
return () => clearInterval(id)
}, [])
// Fetch prayer times
useEffect(() => {
fetch(
'https://api.aladhan.com/v1/timingsByCity?city=Makkah&country=SaudiArabia&method=4'
)
.then((r) => r.json())
.then((d) => {
if (d.data?.timings) {
setTimings(d.data.timings as Timings)
}
})
.catch(() => {
// Hardcoded fallback — approximate Makkah times for the demo
setTimings({
Fajr: '04:30',
Sunrise: '05:45',
Dhuhr: '12:20',
Asr: '15:45',
Maghrib: '18:55',
Isha: '20:15',
})
})
}, [])
// Compute next prayer + ring progress
useEffect(() => {
if (!timings) return
const nowMins = now.getHours() * 60 + now.getMinutes()
for (let i = 0; i < PRAYERS.length; i++) {
const pm = toMins(timings[PRAYERS[i]])
if (pm > nowMins) {
// Found the next prayer
const prevPrayer = i > 0 ? PRAYERS[i - 1] : PRAYERS[PRAYERS.length - 1]
const prevMins = toMins(timings[prevPrayer])
const windowMinutes = pm - prevMins
const elapsed = nowMins - prevMins
const progress = Math.min(Math.max(elapsed / windowMinutes, 0), 1)
setNextPrayer({
name: PRAYERS[i],
time: timings[PRAYERS[i]],
minsLeft: pm - nowMins,
progress,
})
return
}
}
// After Isha — next is Fajr (next day)
const fajrMins = toMins(timings.Fajr) + 24 * 60
const ishaMins = toMins(timings.Isha)
const windowMinutes = fajrMins - ishaMins
const elapsed = nowMins - ishaMins
const progress = Math.min(Math.max(elapsed / windowMinutes, 0), 1)
setNextPrayer({
name: 'Fajr',
time: timings.Fajr,
minsLeft: fajrMins - nowMins,
progress,
})
}, [timings, now])
const ringDashoffset = nextPrayer
? RING_CIRCUMFERENCE * (1 - nextPrayer.progress)
: RING_CIRCUMFERENCE
const greeting = useMemo(() => getGreeting(now.getHours()), [now])
// Dhikr handlers
const handleDhikrTap = useCallback((idx: number) => {
setDhikrCounts((prev) => {
const copy = [...prev]
if (copy[idx] < DHIKR_ITEMS[idx].target) {
copy[idx] = copy[idx] + 1
}
return copy
})
}, [])
const handleDhikrReset = useCallback((idx: number) => {
setDhikrCounts((prev) => {
const copy = [...prev]
copy[idx] = 0
return copy
})
}, [])
const allDhikrComplete = dhikrCounts.every(
(c, i) => c >= DHIKR_ITEMS[i].target
)
return (
<div className="min-h-dvh bg-[#0a0a0f] pb-32 select-none">
{/* ═══ Scrollable content ═══ */}
<div className="overflow-y-auto">
{/* ── Header ── */}
<div className="px-5 pt-14 pb-2">
<div className="flex items-start justify-between mb-1">
<div>
<p className="text-[#999] text-xs font-medium mb-0.5">{greeting}</p>
<h1 className="text-2xl font-bold text-white">
Assalamualaikum
</h1>
<p className="text-[#999] text-sm mt-0.5">Welcome to Falah</p>
</div>
<button className="w-9 h-9 glass-light rounded-xl flex items-center justify-center active:scale-90 transition-transform">
<Bell size={17} className="text-[#999]" />
</button>
</div>
</div>
{/* ─── 1. Prayer Countdown Ring ─── */}
<div className="mx-5 mb-5">
<div className="glass rounded-[24px] p-5">
<div className="flex items-center gap-5">
{/* SVG Ring */}
<div className="relative shrink-0">
<svg width="100" height="100" viewBox="0 0 100 100">
<circle
cx="50" cy="50" r={RING_R}
className="prayer-ring-bg"
/>
<circle
cx="50" cy="50" r={RING_R}
className="prayer-ring-fg"
strokeDasharray={RING_CIRCUMFERENCE}
strokeDashoffset={ringDashoffset}
/>
</svg>
{/* Center emoji icon */}
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-2xl">🕌</span>
</div>
</div>
{/* Next prayer info */}
<div className="flex-1 min-w-0">
<p className="text-[11px] text-[#666] font-medium uppercase tracking-wider mb-1">
Next Prayer
</p>
{nextPrayer ? (
<>
<p className="text-xl font-bold text-white">{nextPrayer.name}</p>
<p className="text-sm text-[#D4AF37] mt-0.5">
{nextPrayer.time}
</p>
<div className="flex items-center gap-1.5 mt-2">
<Clock size={13} className="text-[#D4AF37]" />
<span className="text-lg font-bold text-[#D4AF37] tabular-nums">
{fmtCountdown(nextPrayer.minsLeft)}
</span>
<span className="text-[11px] text-[#666]">remaining</span>
</div>
</>
) : (
<p className="text-sm text-[#999]">Loading prayer times...</p>
)}
</div>
</div>
</div>
</div>
{/* ─── 2. AI Morning Briefing ─── */}
<div className="mx-5 mb-5">
<div className="glass rounded-[24px] p-5 relative overflow-hidden bg-gradient-to-br from-[#D4AF37]/5 to-transparent">
{/* Decorative glow */}
<div className="absolute -top-10 -right-10 w-32 h-32 bg-[#D4AF37]/10 rounded-full blur-3xl" />
<div className="relative z-10">
<div className="flex items-center gap-2 mb-3">
<span className="text-lg">🌙</span>
<span className="text-xs font-semibold text-[#D4AF37] uppercase tracking-widest">
Morning Briefing
</span>
</div>
<p className="text-white text-base font-semibold leading-relaxed mb-2">
Assalamualaikum! 🌙 Your spiritual briefing for today...
</p>
<p className="text-[#999] text-sm leading-relaxed">
Today is a new opportunity to grow closer to Allah. Remember that
every good deed, no matter how small, is multiplied by the Most
Generous. Start your day with intention, pray Fajr on time, and
carry that light through the hours ahead.
</p>
<Link
href="/nur"
className="inline-block mt-4 text-xs font-semibold text-[#D4AF37] border border-[#D4AF37]/30 rounded-full px-4 py-1.5 active:scale-95 transition-transform"
>
Chat with NurBuddy
</Link>
</div>
</div>
</div>
{/* ─── 3. Verse of the Day ─── */}
<div className="mx-5 mb-5">
<div className="glass rounded-[24px] overflow-hidden">
{/* Gold header bar */}
<div className="gold-gradient px-4 py-2.5 flex items-center gap-2">
<span className="text-white text-xs font-bold uppercase tracking-wider">
Verse of the Day
</span>
<span className="text-white/70 text-[10px] ml-auto">
{AYATUL_KURSI.surah}
</span>
</div>
<div className="px-5 py-5">
<p
className="text-right text-lg text-white leading-[2] mb-4 font-light"
style={{ fontFamily: '"Noto Naskh Arabic", "Traditional Arabic", serif' }}
>
{AYATUL_KURSI.arabic}
</p>
<div className="w-8 h-px bg-[#D4AF37]/40 mx-auto mb-4" />
<p className="text-[#bbb] text-sm leading-relaxed italic">
&ldquo;{AYATUL_KURSI.translation}&rdquo;
</p>
</div>
</div>
</div>
{/* ─── 4. Quick Dhikr ─── */}
<div className="mx-5 mb-5">
<div className="glass rounded-[24px] p-5">
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-2">
<span className="text-base">📿</span>
<span className="text-xs font-semibold text-[#D4AF37] uppercase tracking-widest">
Quick Dhikr
</span>
</div>
{allDhikrComplete && (
<span className="text-[10px] text-emerald-400 font-semibold">
Completed
</span>
)}
</div>
<div className="flex justify-around gap-3">
{DHIKR_ITEMS.map((item, idx) => (
<DhikrCard
key={idx}
item={item}
count={dhikrCounts[idx]}
onTap={() => handleDhikrTap(idx)}
onReset={() => handleDhikrReset(idx)}
/>
))}
</div>
</div>
</div>
{/* ─── 5. Community Feed ─── */}
<div className="mx-5 mb-5">
<div className="flex items-center gap-2 mb-3">
<span className="text-base">💬</span>
<span className="text-xs font-semibold text-[#999] uppercase tracking-widest">
Community
</span>
</div>
<div className="space-y-3">
{POSTS.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
</div>
{/* Bottom spacer */}
<div className="h-4" />
</div>
</div>
)
}
+21 -5
View File
@@ -1,20 +1,36 @@
import type { Metadata } from 'next' import type { Metadata, Viewport } from 'next'
import { AuthProvider } from '@/lib/AuthContext' import { AuthProvider } from '@/lib/AuthContext'
import Navbar from '@/components/Navbar' import BottomNav from '@/components/BottomNav'
import './globals.css' import './globals.css'
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Falah - Shariah-Compliant Digital Marketplace', title: 'Falah — Your Islamic Lifestyle Companion',
description: 'Souq, Nur AI, Forum, Wallet', description: 'Nur AI coaching, daily spiritual digest, prayer times, halal marketplace & community',
manifest: '/manifest.json',
appleWebApp: {
capable: true,
statusBarStyle: 'black-translucent',
title: 'Falah',
},
}
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
viewportFit: 'cover',
themeColor: '#0a0a0f',
} }
export default function RootLayout({ children }: { children: React.ReactNode }) { export default function RootLayout({ children }: { children: React.ReactNode }) {
return ( return (
<html lang="en" className="dark"> <html lang="en" className="dark">
<head>
<link rel="apple-touch-icon" href="/icons/icon-512.svg" />
</head>
<body> <body>
<AuthProvider> <AuthProvider>
<Navbar />
<main>{children}</main> <main>{children}</main>
<BottomNav />
</AuthProvider> </AuthProvider>
</body> </body>
</html> </html>
@@ -1,125 +0,0 @@
import { prisma } from "@/lib/prisma";
import Link from "next/link";
import { notFound } from "next/navigation";
interface Props {
params: Promise<{ courseSlug: string; moduleId: string }>;
}
export default async function ModulePage({ params }: Props) {
const { courseSlug, moduleId } = await params;
const moduleData = await prisma.module.findFirst({
where: {
id: moduleId,
course: { slug: courseSlug },
},
include: {
course: true,
},
});
if (!moduleData) return notFound();
const quiz = moduleData.quizData ? JSON.parse(moduleData.quizData) : null;
return (
<main className="min-h-screen bg-gray-50 dark:bg-gray-900">
<div className="max-w-3xl mx-auto px-4 py-8">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-gray-500 mb-6">
<Link href="/learn" className="hover:text-emerald-600">
Learn
</Link>
<span>/</span>
<span>{moduleData.course.title}</span>
<span>/</span>
<span className="text-gray-900 dark:text-white font-medium">
{moduleData.title}
</span>
</div>
{/* Header */}
<div className="mb-8">
<span className="text-xs font-medium text-emerald-600 bg-emerald-50 px-2 py-1 rounded">
Module {moduleData.order}
</span>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mt-2">
{moduleData.title}
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
{moduleData.description}
</p>
</div>
{/* Content */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6 mb-6">
<div
className="prose dark:prose-invert max-w-none"
dangerouslySetInnerHTML={{ __html: renderMarkdown(moduleData.content) }}
/>
</div>
{/* Quiz */}
{quiz && quiz.length > 0 && (
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Quick Check
</h2>
<div className="space-y-4">
{quiz.map((q: any, i: number) => (
<div key={i} className="border-b border-gray-100 dark:border-gray-700 last:border-0 pb-4 last:pb-0">
<p className="font-medium text-gray-900 dark:text-white mb-2">
{i + 1}. {q.question}
</p>
<div className="space-y-1">
{q.options.map((opt: string, j: number) => (
<label
key={j}
className="flex items-center gap-2 p-2 rounded hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer"
>
<input
type="radio"
name={`q-${i}`}
className="text-emerald-600"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">
{opt}
</span>
</label>
))}
</div>
</div>
))}
</div>
</div>
)}
{/* Navigation */}
<div className="flex items-center justify-between mt-8">
<Link
href="/learn"
className="text-sm text-emerald-600 hover:text-emerald-700 font-medium"
>
All Courses
</Link>
</div>
</div>
</main>
);
}
// Simple markdown renderer (production should use a proper library)
function renderMarkdown(md: string): string {
return md
.replace(/## 🎯 Key Concept/g, '<h2 class="text-xl font-bold text-gray-900 dark:text-white mt-6 mb-3">🎯 Key Concept</h2>')
.replace(/## 📖 Details/g, '<h2 class="text-xl font-bold text-gray-900 dark:text-white mt-6 mb-3">📖 Details</h2>')
.replace(/## 🤔 Reflection/g, '<h2 class="text-xl font-bold text-gray-900 dark:text-white mt-6 mb-3">🤔 Reflection</h2>')
.replace(/## ⚡ Action Step/g, '<h2 class="text-xl font-bold text-gray-900 dark:text-white mt-6 mb-3">⚡ Action Step</h2>')
.replace(/## /g, '<h2 class="text-xl font-bold text-gray-900 dark:text-white mt-6 mb-3">')
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\n\n/g, '</p><p class="mb-4 text-gray-700 dark:text-gray-300">')
.replace(/\n/g, '<br>')
.replace(/^/, '<p class="mb-4 text-gray-700 dark:text-gray-300">')
.replace(/$/, '</p>');
}
-111
View File
@@ -1,111 +0,0 @@
import Link from "next/link";
import { prisma } from "@/lib/prisma";
export default async function LearnPage() {
const courses = await prisma.course.findMany({
orderBy: { createdAt: "desc" },
include: {
modules: {
orderBy: { order: "asc" },
},
},
});
return (
<main className="min-h-screen bg-gray-50 dark:bg-gray-900">
<div className="max-w-4xl mx-auto px-4 py-8">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
Micro-Learning
</h1>
<p className="text-gray-600 dark:text-gray-400 mb-8">
Bite-sized Islamic lessons for your daily commute.
</p>
<div className="space-y-6">
{courses.map((course) => (
<div
key={course.id}
className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden"
>
<div className="p-6">
<div className="flex items-start justify-between mb-4">
<div>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
{course.title}
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{course.description}
</p>
</div>
<span
className={`px-3 py-1 rounded-full text-xs font-medium ${
course.difficulty === "beginner"
? "bg-green-100 text-green-700"
: course.difficulty === "intermediate"
? "bg-yellow-100 text-yellow-700"
: "bg-red-100 text-red-700"
}`}
>
{course.difficulty}
</span>
</div>
<div className="space-y-2">
{course.modules.map((module) => (
<Link
key={module.id}
href={`/learn/${course.slug}/${module.id}`}
className="flex items-center justify-between p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<div className="flex items-center gap-3">
<span className="w-8 h-8 rounded-full bg-emerald-100 text-emerald-700 flex items-center justify-center text-sm font-medium">
{module.order}
</span>
<div>
<p className="font-medium text-gray-900 dark:text-white">
{module.title}
</p>
<p className="text-xs text-gray-500">
{module.quizData ? "Quiz included" : "No quiz"}
</p>
</div>
</div>
<svg
className="w-5 h-5 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</Link>
))}
</div>
</div>
</div>
))}
</div>
{courses.length === 0 && (
<div className="text-center py-16">
<p className="text-gray-500 dark:text-gray-400">
No courses available yet.
</p>
<p className="text-sm text-gray-400 mt-2">
Run{" "}
<code className="bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded">
node prisma/seed-learn.js
</code>{" "}
to seed data.
</p>
</div>
)}
</div>
</main>
);
}
+78 -15
View File
@@ -4,12 +4,14 @@ import { useState } from 'react'
import { useAuth } from '@/lib/AuthContext' import { useAuth } from '@/lib/AuthContext'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import Link from 'next/link' import Link from 'next/link'
import { Eye, EyeOff } from 'lucide-react'
export default function LoginPage() { export default function LoginPage() {
const { login } = useAuth() const { login } = useAuth()
const router = useRouter() const router = useRouter()
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [showPass, setShowPass] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@@ -17,28 +19,89 @@ export default function LoginPage() {
e.preventDefault() e.preventDefault()
setLoading(true) setLoading(true)
setError('') setError('')
try { await login(email, password); router.push('/souq') } try {
catch (err: any) { setError(err.message) } await login(email, password)
finally { setLoading(false) } router.push('/nur')
} catch (err: any) {
setError(err.message || 'Invalid email or password')
} finally {
setLoading(false)
}
} }
return ( return (
<div className="max-w-sm mx-auto mt-20 p-6 space-y-4"> <div className="min-h-dvh flex flex-col bg-bg-deep px-6">
<h1 className="text-2xl font-bold text-center text-[#D4AF37]">Welcome Back</h1> {/* Brand */}
<form onSubmit={handleSubmit} className="space-y-3"> <div className="flex flex-col items-center pt-16 pb-10">
<input type="email" placeholder="Email" value={email} onChange={e => setEmail(e.target.value)} required <div className="w-20 h-20 rounded-3xl gold-gradient flex items-center justify-center mb-5 shadow-lg shadow-gold/20">
className="w-full bg-[#111118] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none" /> <span className="text-3xl font-bold text-bg-deep">ف</span>
<input type="password" placeholder="Password" value={password} onChange={e => setPassword(e.target.value)} required </div>
className="w-full bg-[#111118] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none" /> <h1 className="text-2xl font-bold text-white tracking-tight">Welcome back</h1>
{error && <p className="text-red-400 text-xs">{error}</p>} <p className="text-text-secondary text-sm mt-1">Sign in to your Falah account</p>
<button type="submit" disabled={loading} </div>
className="w-full bg-[#D4AF37] text-[#0a0a0f] py-2 rounded font-semibold disabled:opacity-50 hover:bg-[#C9A84C] transition">
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-4 flex-1">
<div className="space-y-1.5">
<label className="text-xs font-semibold text-text-secondary uppercase tracking-widest">Email</label>
<input
type="email"
placeholder="you@example.com"
value={email}
onChange={e => setEmail(e.target.value)}
required
autoComplete="email"
inputMode="email"
className="w-full bg-bg-card border border-border rounded-xl px-4 py-4 text-white placeholder-text-muted focus:border-gold focus:outline-none transition"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-semibold text-text-secondary uppercase tracking-widest">Password</label>
<div className="relative">
<input
type={showPass ? 'text' : 'password'}
placeholder="••••••••"
value={password}
onChange={e => setPassword(e.target.value)}
required
autoComplete="current-password"
className="w-full bg-bg-card border border-border rounded-xl px-4 py-4 pr-14 text-white placeholder-text-muted focus:border-gold focus:outline-none transition"
/>
<button
type="button"
onClick={() => setShowPass(v => !v)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-text-muted p-1"
>
{showPass ? <EyeOff size={20} /> : <Eye size={20} />}
</button>
</div>
</div>
{error && (
<div className="bg-red-950/20 border border-red-900/40 rounded-xl px-4 py-3">
<p className="text-red-400 text-sm">{error}</p>
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full gold-gradient text-bg-deep py-4 rounded-2xl font-bold text-base disabled:opacity-50 active:scale-[0.97] transition-all mt-2 shadow-sm shadow-gold/10 cursor-pointer"
>
{loading ? 'Signing in...' : 'Sign In'} {loading ? 'Signing in...' : 'Sign In'}
</button> </button>
</form> </form>
<p className="text-center text-sm text-gray-500">
No account? <Link href="/register" className="text-[#D4AF37] hover:underline">Register</Link> <div className="py-8 space-y-4 text-center">
<p className="text-sm text-text-secondary">
No account?{' '}
<Link href="/register" className="text-gold font-semibold">
Create one free
</Link>
</p> </p>
<p className="text-xs text-text-muted">بِسْمِ اللَّهِ الرَّحْمَٰنِ الرَّحِيمِ</p>
</div>
</div> </div>
) )
} }
+243 -329
View File
@@ -1,384 +1,298 @@
'use client' 'use client'
import { useState, useEffect, useRef } from 'react' import { useState, useRef, useEffect, useCallback } from 'react'
import { Send, Bot, Settings, User, Sparkles, BookOpen, Target, X, Crown, Scroll, Heart, Flame } from 'lucide-react' import Link from 'next/link'
import { useAuth } from '@/lib/AuthContext' import { useAuth } from '@/lib/AuthContext'
import { SCHOLAR_PERSONAS, PERSONA_COLORS } from '@/lib/personas' import { House, Settings, ArrowUp } from 'lucide-react'
import type { CoachPersona } from '@/lib/personas'
const PERSONA_ICONS: Record<CoachPersona, typeof Bot> = { /* ─── Types & Data ───────────────────────────────────────────── */
nurbuddy: Bot,
ghazali: Scroll, const MOODS = [
ibnabbas: Crown, { emoji: '😊', label: 'Happy' },
rabia: Heart, { emoji: '😌', label: 'Peaceful' },
{ emoji: '😢', label: 'Sad' },
{ emoji: '😡', label: 'Angry' },
{ emoji: '😴', label: 'Tired' },
]
const SLASH_COMMANDS = ['/quran', '/hadith', '/prayer', '/zikr']
const PERSONAS = [
{ id: 'nurbuddy', name: 'NurBuddy' },
{ id: 'ghazali', name: 'Ghazali' },
{ id: 'ibnabbas', name: 'Ibn Abbas' },
{ id: 'rabia', name: "Rabi'a" },
]
const EXAMPLE_MESSAGES: { role: 'user' | 'ai'; content: string }[] = [
{
role: 'user',
content: 'Assalamualaikum! Can you tell me about the importance of Fajr prayer?',
},
{
role: 'ai',
content:
"Waalaikumsalam! 🌅 Fajr holds immense blessings. The Prophet ﷺ said: \"Whoever prays the Fajr prayer, they are under Allah's protection.\" (Muslim). It's a time when angels witness your prayer, and starting your day with Fajr brings barakah that carries through the entire day. Would you like a practical tip to help with waking up for Fajr?",
},
{
role: 'user',
content: "Yes please! I struggle with waking up on time.",
},
{
role: 'ai',
content:
'Here are 3 tips that work, by the will of Allah:\n\n1️⃣ Sleep with wudu — the Prophet ﷺ recommended it and it makes waking easier.\n2️⃣ Set your intention (niyyah) before sleeping — tell yourself "I will wake for Fajr."\n3️⃣ Use the Qaylulah (afternoon nap) — a short 20-min rest before Dhuhr helps with the early morning.\n\nStart with one and build the habit. Allah sees your effort! 🤲',
},
]
/* ─── Component ──────────────────────────────────────────────── */
interface Message {
role: 'user' | 'ai'
content: string
} }
export default function NurPage() { export default function NurPage() {
const { user, token } = useAuth() const { token, user } = useAuth()
const [messages, setMessages] = useState<{ role: string; content: string; time?: string; metadata?: any }[]>([])
const [input, setInput] = useState('') const [input, setInput] = useState('')
const [loading, setLoading] = useState(false) const [selectedMood, setSelectedMood] = useState<number | null>(null)
const [showSettings, setShowSettings] = useState(false) const [messages, setMessages] = useState<Message[]>(EXAMPLE_MESSAGES)
const [actionItems, setActionItems] = useState<string[]>([]) const [activePersona, setActivePersona] = useState('nurbuddy')
const [profile, setProfile] = useState({ const [sending, setSending] = useState(false)
preferredName: user?.preferredName || '',
experienceLevel: user?.experienceLevel || 'new',
madhab: user?.madhab || 'unspecified',
coachPersona: (user?.coachPersona as CoachPersona) || 'nurbuddy',
})
const [savingProfile, setSavingProfile] = useState(false)
const chatEnd = useRef<HTMLDivElement>(null) const chatEnd = useRef<HTMLDivElement>(null)
const [dailyLoading, setDailyLoading] = useState(false) const scrollRef = useRef<HTMLDivElement>(null)
const dailyChecked = useRef(false)
const currentPersona = SCHOLAR_PERSONAS[profile.coachPersona] || SCHOLAR_PERSONAS.nurbuddy // Restore mood from localStorage
const PersonaIcon = PERSONA_ICONS[profile.coachPersona] || Bot
// Load chat history when user is available
useEffect(() => { useEffect(() => {
if (!user) return const today = new Date().toISOString().slice(0, 10)
setProfile(p => ({ const saved = localStorage.getItem(`flh_mood_${today}`)
...p, if (saved) setSelectedMood(parseInt(saved))
preferredName: user.preferredName || '', }, [])
experienceLevel: user.experienceLevel || 'new',
madhab: user.madhab || 'unspecified',
coachPersona: (user.coachPersona as CoachPersona) || 'nurbuddy',
}))
const loadHistory = async () => {
try {
const res = await fetch(`/api/chat/history/${user.id}`, {
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
})
const data = await res.json()
if (data.history?.length > 0) {
setMessages(data.history.map((h: any) => ({
role: h.role,
content: h.content,
time: new Date(h.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
metadata: h.metadata ? JSON.parse(h.metadata) : undefined,
})))
const items: string[] = []
for (const h of data.history) {
if (h.metadata) {
try {
const meta = JSON.parse(h.metadata)
if (meta.actionItems) items.push(...meta.actionItems)
} catch { /* ignore */ }
}
}
setActionItems(items.slice(-8))
}
// Daily check-in — bot greets user warmly on new day // Auto-scroll to bottom on mount and when messages change
if (!dailyChecked.current) { useEffect(() => {
dailyChecked.current = true chatEnd.current?.scrollIntoView({ behavior: 'smooth' })
checkDailyGreeting() }, [messages])
}
} catch { /* ignore */ }
}
loadHistory()
}, [user, token])
const checkDailyGreeting = async () => { const handleSend = useCallback(async () => {
if (!user || !token) return if (!input.trim() || !token || !user || sending) return
setDailyLoading(true) const userMessage = input.trim()
try { setInput('')
const res = await fetch('/api/chat/daily', { setSending(true)
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify({ userId: user.id }),
})
const data = await res.json()
if (data.needsGreeting && data.message) {
setMessages(prev => [...prev, {
role: 'assistant',
content: data.message,
time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
}])
}
} catch { /* silent */ }
setDailyLoading(false)
}
useEffect(() => { chatEnd.current?.scrollIntoView({ behavior: 'smooth' }) }, [messages]) // Add user message to chat
setMessages(prev => [...prev, { role: 'user', content: userMessage }])
const handleSend = async () => {
if (!input.trim() || loading || !user) return
const msg = input; setInput('')
setMessages(prev => [...prev, { role: 'user', content: msg, time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }])
setLoading(true)
try { try {
const res = await fetch('/api/chat/send', { const res = await fetch('/api/chat/send', {
method: 'POST', method: 'POST',
headers: { headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
}, },
body: JSON.stringify({ userId: user.id, message: msg }), body: JSON.stringify({ userId: user.id, message: userMessage }),
}) })
const data = await res.json() const data = await res.json()
if (res.ok) { if (data.response) {
setMessages(prev => [...prev, { setMessages(prev => [...prev, { role: 'ai', content: data.response }])
role: 'assistant',
content: data.response,
time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
metadata: data.metadata,
}])
if (data.metadata?.actionItems) {
setActionItems(prev => [...prev, ...data.metadata.actionItems].slice(-8))
}
} else { } else {
setMessages(prev => [...prev, { setMessages(prev => [...prev, { role: 'ai', content: data.error || 'Sorry, I had trouble responding. Please try again.' }])
role: 'assistant',
content: data.error || 'Something went wrong. Please try again.',
time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
}])
} }
} catch { } catch {
setMessages(prev => [...prev, { setMessages(prev => [...prev, { role: 'ai', content: 'Connection error. Please check your connection and try again.' }])
role: 'assistant', } finally {
content: 'I\'m having trouble connecting right now. Please try again in a moment.', setSending(false)
time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
}])
}
setLoading(false)
} }
}, [input, token, user, sending])
const handleSaveProfile = async () => { const handlePersonaChange = useCallback(async (personaId: string) => {
setActivePersona(personaId)
if (!token) return if (!token) return
setSavingProfile(true)
try { try {
const res = await fetch('/api/auth/profile', { await fetch('/api/auth/profile', {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, headers: {
body: JSON.stringify(profile), 'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ coachPersona: personaId }),
}) })
if (res.ok) {
setShowSettings(false)
const meRes = await fetch('/api/auth/me', { headers: { 'Authorization': `Bearer ${token}` } })
const meData = await meRes.json()
if (meData.user) {
localStorage.setItem('flh_user', JSON.stringify(meData.user))
}
}
} catch { /* ignore */ } } catch { /* ignore */ }
setSavingProfile(false) }, [token])
}
const handleMoodSelect = useCallback((idx: number | null) => {
const displayName = user?.preferredName || user?.name || 'Guest' setSelectedMood(idx)
const levelLabel = { if (idx !== null) {
new: 'New to Islam', const today = new Date().toISOString().slice(0, 10)
growing: 'Growing in Faith', localStorage.setItem(`flh_mood_${today}`, String(idx))
seasoned: 'Seasoned Muslim',
}[user?.experienceLevel || 'new'] || 'New to Islam'
const madhabLabel = {
hanafi: 'Hanafi',
shafii: "Shafi'i",
maliki: 'Maliki',
hanbali: 'Hanbali',
unspecified: 'Madhab Neutral',
}[user?.madhab || 'unspecified'] || 'Madhab Neutral'
if (!user) {
return (
<div className="max-w-3xl mx-auto p-4 sm:p-6">
<div className="bg-[#111118] border border-gray-800 rounded-lg p-8 text-center">
<Bot className="text-[#D4AF37] mx-auto mb-4" size={48} />
<h2 className="text-xl font-bold text-gray-100 mb-2">NurBuddy</h2>
<p className="text-gray-400 text-sm">Please log in to chat with NurBuddy.</p>
</div>
</div>
)
} }
}, [])
return ( return (
<div className="max-w-3xl mx-auto p-4 sm:p-6 space-y-4"> <div className="min-h-dvh bg-bg-deep flex flex-col pb-32 select-none">
{/* Header */} {/* ═══ Header ═══ */}
<div className="bg-gradient-to-r from-[#0a0a0f] to-[#1a1a2e] border border-[#D4AF37]/20 rounded-lg p-6"> <div className="shrink-0 flex items-center gap-3 px-5 pt-14 pb-3">
<div className="flex items-start justify-between"> {/* Avatar */}
<div className="flex-1"> <div className="w-14 h-14 rounded-full gold-purple-gradient flex items-center justify-center shrink-0 shadow-lg shadow-purple-500/10">
<div className="flex items-center gap-2 mb-1"> <span className="text-xl font-bold text-white">N</span>
<PersonaIcon className={PERSONA_COLORS[profile.coachPersona]} size={24} />
<h2 className="text-2xl font-bold">{currentPersona.name}</h2>
</div> </div>
<p className="text-sm text-gray-400 mb-1">{currentPersona.title}</p> {/* Name + online dot */}
<p className="text-xs text-gray-500 mb-3 italic">{currentPersona.era}</p> <div className="flex-1 min-w-0">
<div className="flex flex-wrap gap-2"> <div className="flex items-center gap-2">
<span className="text-xs px-2 py-0.5 rounded-full bg-[#D4AF37]/10 text-[#D4AF37] border border-[#D4AF37]/20 flex items-center gap-1"> <h1 className="text-white font-bold text-base">NurBuddy</h1>
<User size={10} /> {displayName} <span className="w-2 h-2 rounded-full bg-[#10B981] shrink-0 shadow-[0_0_6px_rgba(16,185,129,0.6)]" />
</span> <span className="text-[10px] text-[#10B981] font-medium">Online</span>
<span className="text-xs px-2 py-0.5 rounded-full bg-blue-500/10 text-blue-400 border border-blue-500/20 flex items-center gap-1">
<Sparkles size={10} /> {levelLabel}
</span>
<span className="text-xs px-2 py-0.5 rounded-full bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 flex items-center gap-1">
<BookOpen size={10} /> {madhabLabel}
</span>
</div> </div>
<p className="text-[#999] text-xs truncate">Your Islamic AI Companion</p>
</div> </div>
<button {/* Actions */}
onClick={() => setShowSettings(!showSettings)} <div className="flex items-center gap-1.5">
className="p-2 rounded-lg bg-gray-800/50 hover:bg-gray-700 text-gray-400 hover:text-[#D4AF37] transition" <Link
href="/home"
className="w-9 h-9 rounded-xl glass-light flex items-center justify-center active:scale-90 transition-transform"
> >
<Settings size={18} /> <House size={17} className="text-[#D4AF37]" />
</Link>
<button className="w-9 h-9 rounded-xl glass-light flex items-center justify-center active:scale-90 transition-transform">
<Settings size={16} className="text-[#666]" />
</button> </button>
</div> </div>
</div> </div>
{/* Settings Panel */} <div className="shrink-0 px-5 pb-3">
{showSettings && ( <div className="glass rounded-2xl px-4 py-3">
<div className="bg-[#111118] border border-gray-800 rounded-lg p-5 space-y-5"> <p className="text-[11px] text-[#666] font-medium uppercase tracking-widest mb-2.5">
<div className="flex items-center justify-between"> How are you feeling?
<h3 className="text-sm font-semibold text-gray-200">Coach Profile</h3> </p>
<button onClick={() => setShowSettings(false)} className="text-gray-500 hover:text-gray-300"> <div className="flex justify-between">
<X size={16} /> {MOODS.map((mood, idx) => (
</button>
</div>
{/* Scholar Selector */}
<div className="space-y-2">
<label className="text-xs text-gray-500 block">Choose Your Spiritual Guide</label>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{(Object.keys(SCHOLAR_PERSONAS) as CoachPersona[]).map((personaId) => {
const persona = SCHOLAR_PERSONAS[personaId]
const Icon = PERSONA_ICONS[personaId]
const isActive = profile.coachPersona === personaId
return (
<button <button
key={personaId} key={idx}
onClick={() => setProfile(p => ({ ...p, coachPersona: personaId }))} onClick={() => handleMoodSelect(idx === selectedMood ? null : idx)}
className={`p-3 rounded-lg border text-left transition ${ className={`w-12 h-12 rounded-full flex items-center justify-center text-2xl active:scale-90 transition-all ${
isActive selectedMood === idx
? 'border-[#D4AF37]/50 bg-[#D4AF37]/5' ? 'border-2 border-[#D4AF37] bg-[#D4AF37]/10 shadow-[0_0_12px_rgba(212,175,55,0.15)]'
: 'border-gray-800 bg-[#0a0a0f] hover:border-gray-600' : 'border-2 border-transparent opacity-60 hover:opacity-100'
}`} }`}
> >
<Icon size={18} className={`mb-1 ${isActive ? PERSONA_COLORS[personaId] : 'text-gray-500'}`} /> {mood.emoji}
<p className={`text-xs font-semibold ${isActive ? 'text-gray-200' : 'text-gray-400'}`}>{persona.name}</p> </button>
<p className="text-[10px] text-gray-500 mt-0.5 leading-tight">{persona.title}</p> ))}
</div>
</div>
</div>
{/* ═══ Chat Messages ═══ */}
<div
ref={scrollRef}
className="flex-1 overflow-y-auto px-5 py-2 space-y-4 chat-scroll"
>
{messages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full pb-12">
<div className="w-16 h-16 rounded-full gold-purple-gradient flex items-center justify-center mb-4 opacity-60">
<span className="text-2xl">🧠</span>
</div>
<p className="text-[#666] text-sm text-center max-w-xs">
Start a conversation with NurBuddy your AI Islamic companion.
</p>
</div>
) : (
messages.map((msg, i) => (
<div
key={i}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'} items-end gap-2`}
>
{/* AI avatar */}
{msg.role === 'ai' && (
<div className="w-7 h-7 rounded-full bg-[#1a1a2e] border border-[rgba(167,139,250,0.2)] flex items-center justify-center shrink-0 mb-0.5">
<span className="text-[10px]">🧠</span>
</div>
)}
{/* Bubble */}
<div
className={`max-w-[80%] px-4 py-3 ${
msg.role === 'user'
? 'gold-gradient text-[#0a0a0f] rounded-2xl rounded-br-sm font-semibold'
: 'glass-light text-white rounded-2xl rounded-bl-sm'
}`}
>
<p className="text-sm leading-relaxed whitespace-pre-wrap">{msg.content}</p>
</div>
{/* User avatar placeholder */}
{msg.role === 'user' && (
<div className="w-7 h-7 rounded-full bg-[#D4AF37]/20 border border-[#D4AF37]/30 flex items-center justify-center shrink-0 mb-0.5">
<span className="text-[10px] font-bold text-[#D4AF37]">U</span>
</div>
)}
</div>
))
)}
<div ref={chatEnd} />
</div>
{/* ═══ Slash Commands ═══ */}
<div className="shrink-0 px-5 pb-2">
<div className="flex gap-2 overflow-x-auto scrollbar-none -mx-5 px-5">
{SLASH_COMMANDS.map((cmd) => (
<button
key={cmd}
onClick={() => setInput(cmd + ' ')}
className="shrink-0 glass-light rounded-full px-4 py-1.5 active:scale-95 transition-transform"
>
<span className="text-xs text-[#D4AF37] font-medium">{cmd}</span>
</button>
))}
</div>
</div>
<div className="shrink-0 px-5 pb-1">
<div className="flex items-center gap-2 glass rounded-[28px] px-4 py-2">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}}
placeholder="Ask NurBuddy anything..."
className="flex-1 bg-transparent text-sm text-white placeholder-[#666] outline-none border-none"
/>
<button
onClick={handleSend}
disabled={!input.trim()}
className="w-9 h-9 rounded-full gold-purple-gradient flex items-center justify-center disabled:opacity-30 active:scale-90 transition-all shrink-0"
>
<ArrowUp size={17} className="text-white" />
</button>
</div>
</div>
{/* ═══ Persona Switcher ═══ */}
<div className="shrink-0 px-5 pb-2">
<div className="flex gap-2 overflow-x-auto scrollbar-none -mx-5 px-5">
{PERSONAS.map((p) => {
const isActive = activePersona === p.id
return (
<button
key={p.id}
onClick={() => handlePersonaChange(p.id)}
className={`shrink-0 rounded-full px-3.5 py-1.5 text-xs font-semibold active:scale-95 transition-all ${
isActive
? 'bg-[#D4AF37]/15 border border-[#D4AF37]/40 text-[#D4AF37]'
: 'glass-light text-[#666]'
}`}
>
{p.name}
</button> </button>
) )
})} })}
</div> </div>
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label className="text-xs text-gray-500 mb-1 block">Preferred Name</label>
<input
type="text"
value={profile.preferredName}
onChange={e => setProfile(p => ({ ...p, preferredName: e.target.value }))}
placeholder="How your guide addresses you"
className="w-full bg-[#0a0a0f] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none"
/>
</div>
<div>
<label className="text-xs text-gray-500 mb-1 block">Experience Level</label>
<select
value={profile.experienceLevel}
onChange={e => setProfile(p => ({ ...p, experienceLevel: e.target.value }))}
className="w-full bg-[#0a0a0f] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none"
>
<option value="new">New to Islam</option>
<option value="growing">Growing in Faith</option>
<option value="seasoned">Seasoned Muslim</option>
</select>
</div>
<div>
<label className="text-xs text-gray-500 mb-1 block">Madhab</label>
<select
value={profile.madhab}
onChange={e => setProfile(p => ({ ...p, madhab: e.target.value }))}
className="w-full bg-[#0a0a0f] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none"
>
<option value="unspecified">Madhab Neutral</option>
<option value="hanafi">Hanafi</option>
<option value="shafii">Shafi&apos;i</option>
<option value="maliki">Maliki</option>
<option value="hanbali">Hanbali</option>
</select>
</div>
</div>
<button
onClick={handleSaveProfile}
disabled={savingProfile}
className="bg-[#D4AF37] text-[#0a0a0f] px-4 py-2 rounded text-sm font-semibold hover:bg-[#C9A84C] disabled:opacity-50 transition"
>
{savingProfile ? 'Saving...' : 'Save Profile'}
</button>
</div>
)}
{/* Action Items */}
{actionItems.length > 0 && (
<div className="bg-[#111118] border border-[#D4AF37]/10 rounded-lg p-4">
<div className="flex items-center gap-2 mb-2">
<Target size={14} className="text-[#D4AF37]" />
<h3 className="text-xs font-semibold text-[#D4AF37]">Your Commitments</h3>
</div>
<div className="flex flex-wrap gap-2">
{actionItems.map((item, i) => (
<span key={i} className="text-xs px-2 py-1 rounded-full bg-[#D4AF37]/5 text-gray-300 border border-[#D4AF37]/10">
{item.length > 60 ? item.slice(0, 60) + '...' : item}
</span>
))}
</div>
</div>
)}
{/* Chat */}
<div className="bg-[#111118] border border-gray-800 rounded-lg p-4 space-y-3 max-h-[60vh] overflow-y-auto">
{messages.length === 0 ? (
<div className="text-center py-8 space-y-3">
<PersonaIcon className="text-gray-600 mx-auto" size={32} />
<p className="text-gray-500 text-sm">{currentPersona.greeting}</p>
</div>
) : (
messages.map((m, i) => (
<div key={i} className={`flex ${m.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[85%] rounded-lg px-4 py-2.5 text-sm ${m.role === 'user' ? 'bg-[#D4AF37]/10 border border-[#D4AF37]/20' : 'bg-gray-800'}`}>
<p className="text-gray-100 whitespace-pre-wrap leading-relaxed">{m.content}</p>
{m.time && <p className="text-[10px] text-gray-500 mt-1.5">{m.time}</p>}
</div>
</div>
))
)}
{loading && (
<div className="flex justify-start">
<div className="bg-gray-800 rounded-lg px-4 py-2 text-sm text-gray-400">
<span className="animate-pulse">{currentPersona.name} is reflecting...</span>
</div>
</div>
)}
{dailyLoading && (
<div className="flex justify-start">
<div className="bg-gray-800 rounded-lg px-4 py-2 text-sm text-gray-400">
<span className="animate-pulse text-[#D4AF37]">{currentPersona.name} is greeting you...</span>
</div>
</div>
)}
<div ref={chatEnd} />
</div>
{/* Input */}
<div className="flex gap-2">
<input
type="text"
placeholder={`Ask ${currentPersona.name} a question...`}
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() } }}
className="flex-1 bg-[#0a0a0f] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none"
/>
<button
onClick={handleSend}
disabled={loading || !input.trim()}
className="bg-[#D4AF37] text-[#0a0a0f] px-3 py-2 rounded text-sm font-semibold hover:bg-[#C9A84C] disabled:opacity-50 transition"
>
<Send size={16} />
</button>
</div>
</div> </div>
) )
} }
+1 -1
View File
@@ -1,5 +1,5 @@
import { redirect } from 'next/navigation' import { redirect } from 'next/navigation'
export default function Home() { export default function Home() {
redirect('/souq') redirect('/home')
} }
+336
View File
@@ -0,0 +1,336 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { MapPin, RefreshCw } from 'lucide-react'
type Timings = {
Fajr: string
Sunrise: string
Dhuhr: string
Asr: string
Maghrib: string
Isha: string
}
type HijriDate = {
date: string
month: { en: string }
year: string
}
const PRAYERS = ['Fajr', 'Dhuhr', 'Asr', 'Maghrib', 'Isha'] as const
type PrayerName = typeof PRAYERS[number]
const PRAYER_DATA: Record<PrayerName, { emoji: string; arabic: string }> = {
Fajr: { emoji: '🌙', arabic: 'الفجر' },
Dhuhr: { emoji: '🕛', arabic: 'الظهر' },
Asr: { emoji: '🌤', arabic: 'العصر' },
Maghrib: { emoji: '🌅', arabic: 'المغرب' },
Isha: { emoji: '🌃', arabic: 'العشاء' },
}
function toMins(timeStr: string) {
const [h, m] = timeStr.replace(/\s*(AM|PM)$/i, '').split(':').map(Number)
return h * 60 + m
}
function fmt12(timeStr: string) {
const [h, m] = timeStr.replace(/\s*(AM|PM)$/i, '').split(':').map(Number)
const ampm = h < 12 ? 'AM' : 'PM'
const hour = h % 12 || 12
return `${hour}:${String(m).padStart(2, '0')} ${ampm}`
}
function fmtCountdown(mins: number) {
const h = Math.floor(mins / 60)
const m = mins % 60
if (h === 0) return { value: String(m), unit: m === 1 ? 'minute' : 'minutes' }
if (m === 0) return { value: String(h), unit: h === 1 ? 'hour' : 'hours' }
return { value: `${h}h ${m}m`, unit: 'remaining' }
}
function getNextPrayer(timings: Timings, nowMins: number): { name: PrayerName; minsLeft: number; prevMins: number } {
for (let i = 0; i < PRAYERS.length; i++) {
const pm = toMins(timings[PRAYERS[i]])
if (pm > nowMins) {
const prevMins = i === 0 ? 0 : toMins(timings[PRAYERS[i - 1]])
return { name: PRAYERS[i], minsLeft: pm - nowMins, prevMins }
}
}
const fajrMins = toMins(timings.Fajr)
const lastPrayerMins = toMins(timings.Isha)
return { name: 'Fajr', minsLeft: (24 * 60 - nowMins) + fajrMins, prevMins: lastPrayerMins }
}
export default function PrayerPage() {
const [timings, setTimings] = useState<Timings | null>(null)
const [hijri, setHijri] = useState<HijriDate | null>(null)
const [city, setCity] = useState<string>('')
const [error, setError] = useState<string>('')
const [loading, setLoading] = useState(false)
const [locationDenied, setLocationDenied] = useState(false)
const [now, setNow] = useState(new Date())
useEffect(() => {
const id = setInterval(() => setNow(new Date()), 30000)
return () => clearInterval(id)
}, [])
const fetchTimes = useCallback(async (lat: number, lng: number) => {
setError('')
try {
const res = await fetch(`/api/prayer/times?lat=${lat}&lng=${lng}`)
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Failed')
setTimings(data.timings)
setHijri(data.hijri)
} catch {
setError('Could not load prayer times. Tap refresh to try again.')
} finally {
setLoading(false)
}
}, [])
const requestLocation = useCallback(() => {
if (!navigator.geolocation) {
setError('Geolocation not supported')
return
}
setLoading(true)
setLocationDenied(false)
navigator.geolocation.getCurrentPosition(
async (pos) => {
const { latitude: lat, longitude: lng } = pos.coords
try {
const r = await fetch(
`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json`,
{ headers: { 'Accept-Language': 'en' } }
)
const d = await r.json()
setCity(d.address?.city || d.address?.town || d.address?.village || '')
} catch { }
fetchTimes(lat, lng)
},
(err) => {
setLoading(false)
if (err.code === 1) setLocationDenied(true)
else setError('Could not get your location')
},
{ timeout: 10000 }
)
}, [fetchTimes])
useEffect(() => { requestLocation() }, [requestLocation])
const nowMins = now.getHours() * 60 + now.getMinutes()
const next = timings ? getNextPrayer(timings, nowMins) : null
const countdown = next ? fmtCountdown(next.minsLeft) : null
// Arc progress: how far through the interval to next prayer
const arcPercent = next && timings
? (() => {
const windowMins = next.minsLeft + (nowMins - next.prevMins)
return Math.min(1, (nowMins - next.prevMins) / Math.max(1, windowMins))
})()
: 0
// SVG arc
const R = 56
const C = 2 * Math.PI * R
const strokeOffset = C * (1 - arcPercent)
return (
<div className="min-h-dvh bg-bg-deep flex flex-col pb-32 select-none text-white">
{/* Header gradient card */}
<div className="bg-gradient-to-br from-emerald-950/40 via-bg-card to-bg-deep border-b border-border/80 rounded-b-3xl px-5 pt-14 pb-8">
{/* Top bar inside header */}
<div className="flex items-center justify-between mb-6">
<div>
<div className="flex items-center gap-1.5">
{city ? (
<>
<MapPin size={12} className="text-gold" />
<span className="text-xs text-text-secondary">{city}</span>
</>
) : (
<span className="text-xs text-text-muted">Detecting location</span>
)}
</div>
{hijri && (
<p className="text-[11px] text-text-muted mt-0.5">
{hijri.date} {hijri.month.en} {hijri.year} AH
</p>
)}
</div>
<button
onClick={requestLocation}
disabled={loading}
className="w-9 h-9 rounded-xl bg-bg-card border border-border flex items-center justify-center active:scale-[0.97] transition-transform cursor-pointer"
>
<RefreshCw size={15} className={`text-text-secondary ${loading ? 'animate-spin' : ''}`} />
</button>
</div>
{/* Next prayer arc + countdown inside header */}
{timings && next && countdown && (
<div className="flex flex-col items-center">
{/* Arc clock */}
<div className="relative flex items-center justify-center mb-5">
<svg width="160" height="160" className="-rotate-90">
{/* Background ring */}
<circle cx="80" cy="80" r={R} fill="none" stroke="rgba(255,255,255,0.06)" strokeWidth="6" />
{/* Progress arc */}
<circle
cx="80" cy="80" r={R} fill="none"
stroke="#D4AF37"
strokeWidth="6"
strokeLinecap="round"
strokeDasharray={C}
strokeDashoffset={strokeOffset}
className="transition-all duration-1000"
/>
{/* Glow dot at end of arc */}
<circle
cx="80" cy="80" r={R} fill="none"
stroke="rgba(255,255,255,0.3)"
strokeWidth="2"
strokeLinecap="round"
strokeDasharray="1"
strokeDashoffset={strokeOffset - 2}
opacity="0.4"
/>
</svg>
{/* Center content */}
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-3xl mb-0.5">{PRAYER_DATA[next.name].emoji}</span>
<span className="text-[10px] text-text-muted uppercase tracking-widest">Next Prayer</span>
<span className="text-base font-bold text-white mt-0.5">{next.name}</span>
</div>
</div>
{/* Countdown */}
<div className="text-center">
<p className="text-5xl font-extrabold text-white tabular-nums leading-none tracking-tight">
{countdown.value}
</p>
<p className="text-sm text-text-secondary mt-1.5">{countdown.unit}</p>
<p className="text-xs text-text-muted mt-1">
{next.name} at {fmt12(timings[next.name])}
</p>
</div>
</div>
)}
{/* Loading skeleton inside header */}
{loading && !timings && (
<div className="flex flex-col items-center gap-3 py-4">
<div className="w-40 h-40 rounded-full bg-bg-card border border-border animate-pulse" />
<div className="w-24 h-8 rounded-xl bg-bg-card border border-border animate-pulse mt-2" />
<div className="w-32 h-4 rounded-lg bg-bg-card border border-border animate-pulse" />
</div>
)}
</div>
{/* Location denied */}
{locationDenied && (
<div className="mx-5 mt-5 bg-bg-card border border-border rounded-3xl p-8 text-center">
<div className="text-4xl mb-4">🕌</div>
<p className="text-white font-bold text-lg mb-1">Enable Location</p>
<p className="text-text-secondary text-sm mb-6">Prayer times require your location to be accurate</p>
<button
onClick={requestLocation}
className="w-full gold-gradient text-bg-deep font-bold py-4 rounded-2xl active:scale-[0.97] transition-transform cursor-pointer"
>
Allow Location Access
</button>
</div>
)}
{/* Error */}
{error && !locationDenied && (
<div className="mx-5 mt-4 bg-red-950/20 border border-red-900/40 rounded-2xl px-4 py-3 text-center">
<p className="text-red-400 text-sm">{error}</p>
</div>
)}
{/* Prayer list */}
{timings && next && (
<div className="px-4 mt-5 space-y-2">
{PRAYERS.map((prayer) => {
const pMins = toMins(timings[prayer])
const isNext = prayer === next.name
const isPast = pMins < nowMins && !isNext
return (
<div
key={prayer}
className={`flex items-center gap-3 px-4 py-3.5 rounded-2xl transition-all ${
isNext
? 'bg-[#D4AF37]/10 border border-[#D4AF37]/40'
: isPast
? 'bg-bg-card/60 border border-border/60 shadow-sm opacity-50'
: 'bg-bg-card border border-border shadow-sm'
}`}
>
{/* Emoji */}
<span className="text-xl w-7 text-center">{PRAYER_DATA[prayer].emoji}</span>
{/* Names */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className={`font-semibold text-sm ${isNext ? 'text-[#D4AF37]' : 'text-white'}`}>
{prayer}
</span>
{isNext && (
<span className="text-[10px] bg-[#D4AF37]/20 text-[#D4AF37] px-1.5 py-0.5 rounded-full font-medium">
Next
</span>
)}
</div>
<span className="text-[11px] text-text-secondary">{PRAYER_DATA[prayer].arabic}</span>
</div>
{/* Time */}
<span className={`text-sm font-bold tabular-nums ${isNext ? 'text-[#D4AF37]' : 'text-text-secondary'}`}>
{fmt12(timings[prayer])}
</span>
</div>
)
})}
{/* Sunrise */}
<div className="flex items-center gap-3 px-4 py-2.5 bg-bg-card/40 rounded-2xl border border-border/40 shadow-sm opacity-40">
<span className="text-lg w-7 text-center">🌄</span>
<span className="flex-1 text-xs text-text-muted">Sunrise</span>
<span className="text-xs text-text-muted tabular-nums">{fmt12(timings.Sunrise)}</span>
</div>
</div>
)}
{/* Loading skeleton for prayer list */}
{loading && !timings && (
<div className="px-4 mt-5 space-y-2">
{[...Array(5)].map((_, i) => (
<div key={i} className="bg-bg-card rounded-2xl border border-border px-4 py-3.5 flex items-center gap-3 animate-pulse">
<div className="w-7 h-7 rounded-full bg-bg-deep border border-border" />
<div className="flex-1 space-y-1.5">
<div className="h-3.5 w-20 rounded bg-bg-deep" />
<div className="h-2.5 w-12 rounded bg-bg-deep" />
</div>
<div className="h-3.5 w-16 rounded bg-bg-deep" />
</div>
))}
</div>
)}
{/* Footer note */}
{timings && (
<p className="text-center text-[10px] text-text-muted mt-8 mb-2">
Muslim World League (MWL) · AlAdhan
</p>
)}
</div>
)
}
+212 -235
View File
@@ -1,259 +1,236 @@
'use client' 'use client'
import { useState, useEffect } from 'react'
import { useAuth } from '@/lib/AuthContext'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { User, Crown, Wallet, BookOpen, Sparkles, ShoppingBag, Shield, Target, Save, LogOut } from 'lucide-react' import Link from 'next/link'
import { useAuth } from '@/lib/AuthContext'
import {
ChevronRight,
ShoppingBag,
HandHeart,
MessageSquareText,
MapPin,
Flame,
Bell,
Sun,
Crown,
LogOut,
Wallet,
} from 'lucide-react'
/* ─── Quick Actions ──────────────────────────────────────────── */
const QUICK_ACTIONS = [
{ href: '/souq', icon: ShoppingBag, label: 'Souq' },
{ href: '/waqf', icon: HandHeart, label: 'Waqf' },
{ href: '/forum', icon: MessageSquareText, label: 'Forum' },
{ href: '/halal-monitor', icon: MapPin, label: 'Halal Monitor' },
]
/* ─── Settings Items ─────────────────────────────────────────── */
const SETTINGS = [
{
icon: Flame,
label: 'Spirituality Stats',
value: 'N/A',
color: 'text-orange-400',
},
{
icon: Bell,
label: 'Notifications',
value: '',
color: 'text-[#999]',
},
{
icon: Sun,
label: 'Appearance',
value: 'Dark Mode',
color: 'text-[#999]',
},
{
icon: Crown,
label: 'Upgrade to Pro',
value: '',
color: 'text-[#D4AF37]',
gold: true,
},
]
/* ─── Component ──────────────────────────────────────────────── */
export default function ProfilePage() { export default function ProfilePage() {
const { token, user, loading: authLoading } = useAuth()
const router = useRouter() const router = useRouter()
const [saving, setSaving] = useState(false) const { user, loading } = useAuth()
const [profile, setProfile] = useState({
preferredName: '',
experienceLevel: 'new',
madhab: 'unspecified',
coachPersona: 'nurbuddy',
})
useEffect(() => { const handleSignOut = () => {
if (!authLoading && !token) router.push('/login')
}, [token, authLoading, router])
useEffect(() => {
if (!user) return
const saved = localStorage.getItem('flh_user')
if (saved) {
try {
const u = JSON.parse(saved)
setProfile({
preferredName: u.preferredName || '',
experienceLevel: u.experienceLevel || 'new',
madhab: u.madhab || 'unspecified',
coachPersona: u.coachPersona || 'nurbuddy',
})
} catch { /* ignore */ }
}
}, [user])
const handleSave = async () => {
if (!token) return
setSaving(true)
try {
const res = await fetch('/api/auth/profile', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify(profile),
})
if (res.ok) {
const meRes = await fetch('/api/auth/me', { headers: { 'Authorization': `Bearer ${token}` } })
const meData = await meRes.json()
if (meData.user) {
localStorage.setItem('flh_user', JSON.stringify(meData.user))
}
}
} catch { /* ignore */ }
setSaving(false)
}
const tier = user?.isPro ? 'Pro' : user?.isPremium ? 'Premium' : 'Free'
const tierColor = user?.isPro ? 'text-purple-400' : user?.isPremium ? 'text-[#D4AF37]' : 'text-gray-400'
const memberSince = user?.createdAt
? new Date(user.createdAt).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })
: 'N/A'
const experienceLabel = {
new: 'New to Islam',
growing: 'Growing in Faith',
seasoned: 'Seasoned Muslim',
}[profile.experienceLevel] || 'New to Islam'
if (authLoading) {
return <ProfileSkeleton />
}
if (!token) return null
return (
<div className="max-w-3xl mx-auto p-4 sm:p-6 space-y-6">
<h1 className="text-2xl font-bold text-[#D4AF37]">Profile</h1>
{/* Account Info Card */}
<div className="bg-[#111118] border border-gray-800 rounded-lg p-6">
<div className="flex items-center gap-4">
<div className="w-16 h-16 rounded-full bg-[#D4AF37]/10 border border-[#D4AF37]/30 flex items-center justify-center">
<User size={28} className="text-[#D4AF37]" />
</div>
<div>
<h2 className="text-lg font-bold">{user?.name || 'User'}</h2>
<p className="text-sm text-gray-400">{user?.email || ''}</p>
<p className="text-xs text-gray-500 mt-1">Member since {memberSince}</p>
</div>
</div>
</div>
{/* Stats Row */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div className="bg-[#111118] border border-gray-800 rounded-lg p-4">
<Wallet size={18} className="text-[#D4AF37] mb-2" />
<p className="text-xl font-bold">{user?.flhBalance?.toLocaleString() || 0}</p>
<p className="text-xs text-gray-500">FLH Balance</p>
</div>
<div className="bg-[#111118] border border-gray-800 rounded-lg p-4">
<Crown size={18} className={`mb-2 ${tierColor}`} />
<p className={`text-xl font-bold ${tierColor}`}>{tier}</p>
<p className="text-xs text-gray-500">Tier</p>
</div>
<div className="bg-[#111118] border border-gray-800 rounded-lg p-4">
<ShoppingBag size={18} className="text-blue-400 mb-2" />
<p className="text-xl font-bold">0</p>
<p className="text-xs text-gray-500">Purchases</p>
</div>
<div className="bg-[#111118] border border-gray-800 rounded-lg p-4">
<Sparkles size={18} className="text-emerald-400 mb-2" />
<p className="text-xl font-bold capitalize">{experienceLabel}</p>
<p className="text-xs text-gray-500">Level</p>
</div>
</div>
{/* Edit Profile Form */}
<div className="bg-[#111118] border border-gray-800 rounded-lg p-6 space-y-5">
<h2 className="font-semibold flex items-center gap-2">
<Shield size={16} className="text-[#D4AF37]" /> Edit Profile
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="text-xs text-gray-500 mb-1 block">Preferred Name</label>
<input
type="text"
value={profile.preferredName}
onChange={e => setProfile(p => ({ ...p, preferredName: e.target.value }))}
placeholder="Enter preferred name"
className="w-full bg-[#0a0a0f] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none"
/>
</div>
<div>
<label className="text-xs text-gray-500 mb-1 block">Experience Level</label>
<select
value={profile.experienceLevel}
onChange={e => setProfile(p => ({ ...p, experienceLevel: e.target.value }))}
className="w-full bg-[#0a0a0f] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none"
>
<option value="new">New to Islam</option>
<option value="growing">Growing in Faith</option>
<option value="seasoned">Seasoned Muslim</option>
</select>
</div>
<div>
<label className="text-xs text-gray-500 mb-1 block">Madhab</label>
<select
value={profile.madhab}
onChange={e => setProfile(p => ({ ...p, madhab: e.target.value }))}
className="w-full bg-[#0a0a0f] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none"
>
<option value="unspecified">Madhab Neutral</option>
<option value="hanafi">Hanafi</option>
<option value="shafii">Shafi&apos;i</option>
<option value="maliki">Maliki</option>
<option value="hanbali">Hanbali</option>
</select>
</div>
<div>
<label className="text-xs text-gray-500 mb-1 block">Coach Persona</label>
<select
value={profile.coachPersona}
onChange={e => setProfile(p => ({ ...p, coachPersona: e.target.value }))}
className="w-full bg-[#0a0a0f] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none"
>
<option value="nurbuddy">NurBuddy</option>
<option value="ghazali">Al-Ghazali</option>
<option value="ibnabbas">Ibn Abbas</option>
<option value="rabia">Rabi'a al-Adawiyya</option>
</select>
</div>
</div>
<button
onClick={handleSave}
disabled={saving}
className="flex items-center gap-2 bg-[#D4AF37] text-[#0a0a0f] px-4 py-2 rounded text-sm font-semibold hover:bg-[#C9A84C] disabled:opacity-50 transition"
>
<Save size={16} /> {saving ? 'Saving...' : 'Save Changes'}
</button>
</div>
{/* Upgrade CTA */}
{!user?.isPremium && !user?.isPro && (
<div className="bg-gradient-to-r from-[#D4AF37]/10 to-[#0a0a0f] border border-[#D4AF37]/20 rounded-lg p-6">
<div className="flex items-center gap-3 mb-3">
<Crown size={24} className="text-[#D4AF37]" />
<div>
<h2 className="font-bold text-[#D4AF37]">Go Premium</h2>
<p className="text-sm text-gray-400">Unlock exclusive features and content</p>
</div>
</div>
<button
onClick={() => router.push('/upgrade')}
className="bg-[#D4AF37] text-[#0a0a0f] px-5 py-2 rounded text-sm font-semibold hover:bg-[#C9A84C] transition"
>
Upgrade Now
</button>
</div>
)}
{/* Sign Out */}
<button
onClick={() => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
localStorage.removeItem('flh_token') localStorage.clear()
localStorage.removeItem('flh_user')
} }
router.push('/login') router.push('/login')
}} }
className="w-full flex items-center justify-center gap-2 border border-red-800 text-red-400 hover:bg-red-900/20 rounded-lg py-3 text-sm font-semibold transition"
>
<LogOut size={16} /> Sign Out
</button>
</div>
)
}
function ProfileSkeleton() { if (loading) {
return ( return (
<div className="max-w-3xl mx-auto p-4 sm:p-6 space-y-6 animate-pulse"> <div className="min-h-dvh bg-[#0a0a0f] flex items-center justify-center">
<div className="h-8 w-24 bg-gray-800 rounded" /> <div className="w-8 h-8 rounded-full border-2 border-[#D4AF37]/30 border-t-[#D4AF37] animate-spin" />
<div className="bg-[#111118] border border-gray-800 rounded-lg p-6"> </div>
)
}
const displayName = user?.name || 'Guest'
const initial = displayName.charAt(0).toUpperCase()
const flhBalance = user?.flhBalance ?? 0
const usdValue = (flhBalance * 0.004).toFixed(2)
return (
<div className="min-h-dvh bg-[#0a0a0f] pb-32 select-none">
{/* Scrollable content */}
<div className="overflow-y-auto">
{/* ═══ Profile Header ═══ */}
<div className="px-5 pt-14 pb-6">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="w-16 h-16 rounded-full bg-gray-800" /> {/* Avatar */}
<div className="space-y-2"> <div className="w-[60px] h-[60px] rounded-full bg-[#D4AF37]/20 border-2 border-[#D4AF37]/30 flex items-center justify-center shrink-0">
<div className="h-5 w-32 bg-gray-800 rounded" /> <span className="text-xl font-bold text-[#D4AF37]">{initial}</span>
<div className="h-4 w-48 bg-gray-800 rounded" /> </div>
<div className="h-3 w-28 bg-gray-800 rounded" />
{/* Name + badges */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h2 className="text-white font-bold text-lg truncate">{displayName}</h2>
</div>
<p className="text-[#999] text-sm truncate">{user?.email}</p>
<div className="flex items-center gap-2 mt-2">
{user?.isPremium && (
<span className="text-[10px] font-bold text-white bg-[#D4AF37] rounded-full px-2.5 py-0.5 uppercase tracking-wider">
Premium
</span>
)}
{user?.isPro && (
<span className="text-[10px] font-bold text-white bg-[#10B981] rounded-full px-2.5 py-0.5 uppercase tracking-wider">
Pro
</span>
)}
{user?.experienceLevel && (
<span className="text-[10px] font-bold text-white bg-[#10B981] rounded-full px-2.5 py-0.5 uppercase tracking-wider">
{user.experienceLevel}
</span>
)}
</div> </div>
</div> </div>
</div> </div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{[...Array(4)].map((_, i) => (
<div key={i} className="bg-[#111118] border border-gray-800 rounded-lg p-4 space-y-2">
<div className="h-5 w-5 bg-gray-800 rounded" />
<div className="h-6 w-16 bg-gray-800 rounded" />
<div className="h-3 w-20 bg-gray-800 rounded" />
</div> </div>
))}
{/* ═══ FLH Wallet Card ═══ */}
<div className="mx-5 mb-5">
<Link
href="/wallet"
className="block bg-gradient-to-br from-[#D4AF37]/10 to-[#B8860B]/5 rounded-2xl border border-[#D4AF37]/20 p-5 active:scale-[0.98] transition-transform"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-[#D4AF37]/15 flex items-center justify-center">
<Wallet size={20} className="text-[#D4AF37]" />
</div> </div>
<div className="bg-[#111118] border border-gray-800 rounded-lg p-6 space-y-4"> <div>
<div className="h-5 w-28 bg-gray-800 rounded" /> <p className="text-[11px] text-[#666] font-medium uppercase tracking-widest mb-0.5">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> FLH Wallet
{[...Array(4)].map((_, i) => ( </p>
<div key={i} className="space-y-2"> <div className="flex items-baseline gap-2">
<div className="h-3 w-24 bg-gray-800 rounded" /> <span className="text-xl font-bold text-[#D4AF37]">
<div className="h-9 w-full bg-gray-800 rounded" /> {flhBalance.toLocaleString()}
</span>
<span className="text-xs text-[#999]"> ${usdValue} USD</span>
</div> </div>
))}
</div> </div>
<div className="h-9 w-32 bg-gray-800 rounded" />
</div> </div>
<div className="h-12 w-full bg-gray-800 rounded-lg" /> <ChevronRight size={18} className="text-[#D4AF37]" />
</div>
</Link>
</div>
{/* ═══ Quick Action Grid (2x2) ═══ */}
<div className="mx-5 mb-5">
<p className="text-[11px] text-[#666] font-medium uppercase tracking-widest mb-3">
Quick Actions
</p>
<div className="grid grid-cols-2 gap-3">
{QUICK_ACTIONS.map((action) => {
const Icon = action.icon
return (
<Link
key={action.href}
href={action.href}
className="bg-[#111118] rounded-2xl border border-[#222] p-4 active:scale-[0.96] transition-transform"
>
<div className="w-10 h-10 rounded-xl bg-[#D4AF37]/10 flex items-center justify-center mb-3">
<Icon size={20} className="text-[#D4AF37]" />
</div>
<p className="text-white text-sm font-semibold">{action.label}</p>
</Link>
)
})}
</div>
</div>
{/* ═══ Settings List ═══ */}
<div className="mx-5 mb-5">
<p className="text-[11px] text-[#666] font-medium uppercase tracking-widest mb-3">
Settings
</p>
<div className="bg-[#111118] rounded-2xl border border-[#222] overflow-hidden divide-y divide-[#222]">
{SETTINGS.map((item, idx) => {
const Icon = item.icon
return (
<button
key={idx}
className="w-full flex items-center gap-3 px-4 py-4 active:bg-[#1a1a24] transition-colors text-left"
>
<div className="w-8 h-8 rounded-lg bg-[#0a0a0f] border border-[#222] flex items-center justify-center shrink-0">
<Icon size={15} className={item.color} />
</div>
<div className="flex-1 min-w-0">
<span
className={`text-sm font-medium ${
item.gold ? 'text-[#D4AF37]' : 'text-white'
}`}
>
{item.label}
</span>
{item.value && (
<span className="text-[11px] text-[#999] ml-2">{item.value}</span>
)}
</div>
<ChevronRight size={15} className="text-[#666] shrink-0" />
</button>
)
})}
</div>
</div>
{/* ═══ Sign Out / Login ═══ */}
<div className="mx-5 mb-6">
{!user ? (
<Link
href="/login"
className="w-full flex items-center justify-center gap-2 py-4 rounded-2xl gold-gradient text-bg-deep font-bold active:scale-[0.97] transition-all text-center"
>
Login
</Link>
) : (
<button
onClick={handleSignOut}
className="w-full flex items-center justify-center gap-2 py-4 rounded-2xl border border-red-900/40 bg-red-950/20 active:bg-red-950/40 transition-colors cursor-pointer"
>
<LogOut size={16} className="text-red-400" />
<span className="text-sm font-semibold text-red-400">Logout</span>
</button>
)}
</div>
{/* Bottom spacer */}
<div className="h-4" />
</div>
</div> </div>
) )
} }
+98 -19
View File
@@ -4,13 +4,15 @@ import { useState } from 'react'
import { useAuth } from '@/lib/AuthContext' import { useAuth } from '@/lib/AuthContext'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import Link from 'next/link' import Link from 'next/link'
import { Eye, EyeOff, Sparkles } from 'lucide-react'
export default function RegisterPage() { export default function RegisterPage() {
const { register } = useAuth() const { register } = useAuth()
const router = useRouter() const router = useRouter()
const [email, setEmail] = useState('')
const [name, setName] = useState('') const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [showPass, setShowPass] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@@ -18,30 +20,107 @@ export default function RegisterPage() {
e.preventDefault() e.preventDefault()
setLoading(true) setLoading(true)
setError('') setError('')
try { await register(email, name, password); router.push('/souq') } try {
catch (err: any) { setError(err.message) } await register(email, name, password)
finally { setLoading(false) } router.push('/nur')
} catch (err: any) {
setError(err.message || 'Something went wrong')
} finally {
setLoading(false)
}
} }
return ( return (
<div className="max-w-sm mx-auto mt-20 p-6 space-y-4"> <div className="min-h-dvh flex flex-col bg-bg-deep px-6">
<h1 className="text-2xl font-bold text-center text-[#D4AF37]">Join Falah</h1> {/* Brand */}
<form onSubmit={handleSubmit} className="space-y-3"> <div className="flex flex-col items-center pt-12 pb-8">
<input type="text" placeholder="Name" value={name} onChange={e => setName(e.target.value)} required <div className="w-20 h-20 rounded-3xl gold-gradient flex items-center justify-center mb-5 shadow-lg shadow-gold/20">
className="w-full bg-[#111118] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none" /> <span className="text-3xl font-bold text-bg-deep">ف</span>
<input type="email" placeholder="Email" value={email} onChange={e => setEmail(e.target.value)} required </div>
className="w-full bg-[#111118] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none" /> <h1 className="text-2xl font-bold text-white tracking-tight">Join Falah</h1>
<input type="password" placeholder="Password" value={password} onChange={e => setPassword(e.target.value)} required <p className="text-text-secondary text-sm mt-1">Your Islamic lifestyle companion</p>
className="w-full bg-[#111118] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none" /> <div className="flex items-center gap-1.5 mt-4 bg-emerald-950/20 border border-emerald-900/40 rounded-full px-3.5 py-2">
{error && <p className="text-red-400 text-xs">{error}</p>} <Sparkles size={13} className="text-emerald-400" />
<button type="submit" disabled={loading} <span className="text-xs text-emerald-400 font-semibold">7-day Premium trial free</span>
className="w-full bg-[#D4AF37] text-[#0a0a0f] py-2 rounded font-semibold disabled:opacity-50 hover:bg-[#C9A84C] transition"> </div>
{loading ? 'Creating account...' : 'Create Account'} </div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-4 flex-1">
<div className="space-y-1.5">
<label className="text-xs font-semibold text-text-secondary uppercase tracking-widest">Your Name</label>
<input
type="text"
placeholder="Ahmad Abdullah"
value={name}
onChange={e => setName(e.target.value)}
required
autoComplete="name"
className="w-full bg-bg-card border border-border rounded-xl px-4 py-4 text-white placeholder-text-muted focus:border-gold focus:outline-none transition"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-semibold text-text-secondary uppercase tracking-widest">Email</label>
<input
type="email"
placeholder="you@example.com"
value={email}
onChange={e => setEmail(e.target.value)}
required
autoComplete="email"
inputMode="email"
className="w-full bg-bg-card border border-border rounded-xl px-4 py-4 text-white placeholder-text-muted focus:border-gold focus:outline-none transition"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-semibold text-text-secondary uppercase tracking-widest">Password</label>
<div className="relative">
<input
type={showPass ? 'text' : 'password'}
placeholder="Min. 8 characters"
value={password}
onChange={e => setPassword(e.target.value)}
required
minLength={8}
autoComplete="new-password"
className="w-full bg-bg-card border border-border rounded-xl px-4 py-4 pr-14 text-white placeholder-text-muted focus:border-gold focus:outline-none transition"
/>
<button
type="button"
onClick={() => setShowPass(v => !v)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-text-muted p-1"
>
{showPass ? <EyeOff size={20} /> : <Eye size={20} />}
</button>
</div>
</div>
{error && (
<div className="bg-red-950/20 border border-red-900/40 rounded-xl px-4 py-3">
<p className="text-red-400 text-sm">{error}</p>
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full gold-gradient text-bg-deep py-4 rounded-2xl font-bold text-base disabled:opacity-50 active:scale-[0.97] transition-all mt-1 shadow-sm shadow-gold/10 cursor-pointer"
>
{loading ? 'Creating account...' : 'Create Free Account'}
</button> </button>
</form> </form>
<p className="text-center text-sm text-gray-500">
Have an account? <Link href="/login" className="text-[#D4AF37] hover:underline">Sign in</Link> <div className="py-8 space-y-3 text-center">
<p className="text-sm text-text-secondary">
Already have an account?{' '}
<Link href="/login" className="text-gold font-semibold">
Sign in
</Link>
</p> </p>
<p className="text-xs text-text-muted">بِسْمِ اللَّهِ الرَّحْمَٰنِ الرَّحِيمِ</p>
</div>
</div> </div>
) )
} }
+220 -78
View File
@@ -1,29 +1,47 @@
'use client' 'use client'
import { useState, useEffect } from 'react' import { useState, useEffect, useRef } from 'react'
import { useAuth } from '@/lib/AuthContext' import { useAuth } from '@/lib/AuthContext'
import { Search, Plus, ShoppingCart, Zap } from 'lucide-react' import { Search, Plus, ShoppingCart, Zap, X, ChevronRight, Package } from 'lucide-react'
interface Listing { id: string; sellerId: string; title: string; category: string; price_flh: number; seller: string; description: string; status: string; featured: boolean; featuredUntil: string | null; fileType: string | null; createdAt: string } interface Listing {
id: string; sellerId: string; title: string; category: string
price_flh: number; seller: string; description: string; status: string
featured: boolean; featuredUntil: string | null; fileType: string | null; createdAt: string
}
const CATEGORIES = ['All', 'E-Books', 'Courses', 'Design', 'Audio', 'Video', 'Software', 'Other'] const CATEGORIES = ['All', 'E-Books', 'Courses', 'Design', 'Audio', 'Video', 'Software', 'Other']
const CATEGORY_ICONS: Record<string, string> = {
'All': '🏪', 'E-Books': '📚', 'Courses': '🎓', 'Design': '🎨',
'Audio': '🎵', 'Video': '🎬', 'Software': '💻', 'Other': '📦',
}
export default function SouqPage() { export default function SouqPage() {
const { token, user, loading: authLoading } = useAuth() const { token, user } = useAuth()
const [listings, setListings] = useState<Listing[]>([]) const [listings, setListings] = useState<Listing[]>([])
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [category, setCategory] = useState('All') const [category, setCategory] = useState('All')
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [showCreate, setShowCreate] = useState(false) const [showCreate, setShowCreate] = useState(false)
const [selectedListing, setSelectedListing] = useState<Listing | null>(null) const [selectedListing, setSelectedListing] = useState<Listing | null>(null)
const [toast, setToast] = useState('')
const fetchListings = async () => { const fetchListings = async () => {
try { const res = await fetch('/api/marketplace/listings'); const data = await res.json(); setListings(data.listings || []) } try {
catch (e) { console.error(e) } finally { setLoading(false) } const res = await fetch('/api/marketplace/listings')
const data = await res.json()
setListings(data.listings || [])
} catch { } finally { setLoading(false) }
} }
useEffect(() => { fetchListings() }, []) useEffect(() => { fetchListings() }, [])
const showToast = (msg: string) => {
setToast(msg)
setTimeout(() => setToast(''), 3000)
}
const filtered = listings.filter(l => { const filtered = listings.filter(l => {
if (category !== 'All' && l.category !== category) return false if (category !== 'All' && l.category !== category) return false
if (search && !l.title.toLowerCase().includes(search.toLowerCase())) return false if (search && !l.title.toLowerCase().includes(search.toLowerCase())) return false
@@ -32,93 +50,190 @@ export default function SouqPage() {
const handlePurchase = async (listingId: string) => { const handlePurchase = async (listingId: string) => {
if (!token) return if (!token) return
const res = await fetch('/api/marketplace/purchase', { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ listingId }) }) const res = await fetch('/api/marketplace/purchase', {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ listingId }),
})
const data = await res.json() const data = await res.json()
if (res.ok) { fetchListings() } else { alert(data.error) } if (res.ok) { fetchListings(); showToast('Purchase successful!'); setSelectedListing(null) }
else showToast(data.error || 'Purchase failed')
} }
if (authLoading) return <div className="p-8 text-center text-gray-500">Loading...</div>
return ( return (
<div className="max-w-6xl mx-auto p-4 sm:p-6 space-y-6"> <div className="min-h-dvh bg-bg-deep pb-32 select-none text-white">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<h1 className="text-2xl font-bold text-[#D4AF37]">Souq</h1> {/* Header */}
<div className="flex gap-2"> <div className="px-5 pt-14 pb-4">
<div className="relative flex-1 sm:flex-none"> <div className="flex items-center justify-between mb-4">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" /> <div>
<input type="text" placeholder="Search..." value={search} onChange={e => setSearch(e.target.value)} <h1 className="text-xl font-bold text-white">Souq</h1>
className="w-full sm:w-48 bg-[#111118] border border-gray-700 rounded pl-9 pr-3 py-2 text-sm focus:border-[#D4AF37] outline-none" /> <p className="text-[11px] text-text-secondary mt-0.5">Islamic digital marketplace</p>
</div> </div>
{token && ( {token && (
<button onClick={() => setShowCreate(true)} <button
className="flex items-center gap-1 bg-[#D4AF37] text-[#0a0a0f] px-3 py-2 rounded text-sm font-semibold hover:bg-[#C9A84C] transition"> onClick={() => setShowCreate(true)}
<Plus size={16} /> Create className="flex items-center gap-1.5 gold-gradient text-bg-deep px-4 py-2.5 rounded-xl font-bold text-sm active:scale-[0.97] transition-transform cursor-pointer shadow-sm shadow-gold/10"
>
<Plus size={16} /> Sell
</button> </button>
)} )}
</div> </div>
{/* Search */}
<div className="relative">
<Search size={16} className="absolute left-4 top-1/2 -translate-y-1/2 text-text-muted" />
<input
type="search"
placeholder="Search products…"
value={search}
onChange={e => setSearch(e.target.value)}
className="w-full bg-bg-card border border-border rounded-xl pl-11 pr-4 py-4 text-sm text-white placeholder-text-muted focus:border-gold focus:outline-none transition"
/>
</div> </div>
<div className="flex gap-2 overflow-x-auto pb-2"> </div>
{/* Category chips */}
<div className="flex gap-2 px-5 py-4 overflow-x-auto scrollbar-none border-b border-border/40" style={{ scrollbarWidth: 'none' }}>
{CATEGORIES.map(c => ( {CATEGORIES.map(c => (
<button key={c} onClick={() => setCategory(c)} <button
className={`px-3 py-1 rounded-full text-xs whitespace-nowrap transition ${category === c ? 'bg-[#D4AF37] text-[#0a0a0f] font-semibold' : 'bg-gray-800 text-gray-400 hover:text-white'}`}>{c}</button> key={c}
onClick={() => setCategory(c)}
className={`flex items-center gap-1.5 px-3.5 py-2 rounded-xl text-xs font-semibold whitespace-nowrap transition-all active:scale-95 cursor-pointer ${
category === c
? 'gold-gradient text-bg-deep shadow-sm shadow-gold/15'
: 'bg-bg-card text-text-secondary border border-border'
}`}
>
<span>{CATEGORY_ICONS[c]}</span>
{c}
</button>
))} ))}
</div> </div>
{loading ? (
<div className="text-center text-gray-500 py-12">Loading listings...</div> {/* Loading */}
) : filtered.length === 0 ? ( {loading && (
<div className="text-center text-gray-500 py-12">No listings found</div> <div className="px-5 pt-4 space-y-3">
) : ( {[...Array(4)].map((_, i) => (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> <div key={i} className="h-28 rounded-2xl bg-bg-card border border-border/50 animate-pulse" />
))}
</div>
)}
{/* Empty */}
{!loading && filtered.length === 0 && (
<div className="flex flex-col items-center justify-center pt-20 px-6 text-center">
<div className="text-5xl mb-4">🏪</div>
<p className="text-white font-semibold mb-1">Nothing here yet</p>
<p className="text-text-secondary text-sm">Be the first to list a product</p>
</div>
)}
{/* Listings */}
{!loading && filtered.length > 0 && (
<div className="px-5 pt-4 space-y-3">
{filtered.map(l => ( {filtered.map(l => (
<div key={l.id} className="bg-[#111118] border border-gray-800 rounded-lg p-4 space-y-2 hover:border-gray-700 transition cursor-pointer" onClick={() => setSelectedListing(l)}> <button
<div className="flex items-start justify-between"> key={l.id}
<span className="text-xs text-gray-500 bg-gray-800 px-2 py-0.5 rounded">{l.category}</span> onClick={() => setSelectedListing(l)}
{l.featured && <Zap size={14} className="text-[#D4AF37]" />} className="w-full text-left bg-bg-card rounded-2xl border border-border p-4 active:scale-[0.97] transition-transform hover:border-gold/30 cursor-pointer"
>
<div className="flex items-start gap-3">
{/* Icon block */}
<div className="w-12 h-12 rounded-xl bg-bg-deep border border-border flex items-center justify-center shrink-0 text-xl">
{CATEGORY_ICONS[l.category] || '📦'}
</div> </div>
<h3 className="font-semibold">{l.title}</h3> <div className="flex-1 min-w-0">
<p className="text-sm text-gray-400 line-clamp-2">{l.description}</p> <div className="flex items-start justify-between gap-2">
<div className="flex items-center justify-between pt-2"> <h3 className="font-semibold text-white text-sm leading-tight line-clamp-1">{l.title}</h3>
<span className="text-[#D4AF37] font-bold">{l.price_flh.toLocaleString()} FLH</span> {l.featured && <Zap size={13} className="text-[#D4AF37] shrink-0 mt-0.5" />}
<span className="text-xs text-gray-500">{l.seller}</span> </div>
<p className="text-xs text-text-secondary mt-0.5 line-clamp-1">{l.description}</p>
<div className="flex items-center justify-between mt-2">
<span className="text-[#D4AF37] font-bold text-sm">{l.price_flh.toLocaleString()} FLH</span>
<span className="text-[11px] text-text-muted">{l.seller}</span>
</div>
</div>
<ChevronRight size={16} className="text-text-muted shrink-0 mt-1" />
</div> </div>
{token && l.sellerId !== user?.id && (
<button onClick={e => { e.stopPropagation(); handlePurchase(l.id) }}
className="w-full flex items-center justify-center gap-1 bg-[#D4AF37]/10 text-[#D4AF37] border border-[#D4AF37]/30 rounded py-1.5 text-sm hover:bg-[#D4AF37]/20 transition">
<ShoppingCart size={14} /> Buy
</button> </button>
)}
</div>
))} ))}
</div> </div>
)} )}
{/* Toast */}
{toast && (
<div className="fixed bottom-24 left-5 right-5 bg-bg-card border border-border text-white rounded-2xl px-4 py-3 text-sm text-center z-50 shadow-xl">
{toast}
</div>
)}
{/* Detail bottom sheet */}
{selectedListing && ( {selectedListing && (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center p-4 z-50" onClick={() => setSelectedListing(null)}> <div className="fixed inset-0 z-50 flex flex-col justify-end" onClick={() => setSelectedListing(null)}>
<div className="bg-[#111118] border border-gray-800 rounded-lg p-6 max-w-lg w-full space-y-4" onClick={e => e.stopPropagation()}> <div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
<div className="flex items-start justify-between"> <div
<h2 className="text-xl font-bold">{selectedListing.title}</h2> className="relative bg-bg-card border-t border-x border-border rounded-t-3xl p-6 pb-10 max-h-[80dvh] overflow-y-auto"
<button onClick={() => setSelectedListing(null)} className="text-gray-500 hover:text-white"></button> onClick={e => e.stopPropagation()}
>
{/* Handle */}
<div className="w-10 h-1 bg-border rounded-full mx-auto mb-5" />
<div className="flex items-start gap-3 mb-4">
<div className="w-14 h-14 rounded-2xl bg-bg-deep border border-border flex items-center justify-center text-2xl shrink-0">
{CATEGORY_ICONS[selectedListing.category] || '📦'}
</div> </div>
<p className="text-sm text-gray-400">{selectedListing.description}</p> <div className="flex-1 min-w-0">
<div className="flex gap-2 text-sm"> <h2 className="text-lg font-bold text-white leading-tight">{selectedListing.title}</h2>
<span className="bg-gray-800 px-2 py-0.5 rounded">{selectedListing.category}</span> <p className="text-xs text-text-muted mt-0.5">by {selectedListing.seller}</p>
<span className="text-gray-500">by {selectedListing.seller}</span>
</div> </div>
<button onClick={() => setSelectedListing(null)} className="w-8 h-8 flex items-center justify-center rounded-full bg-bg-deep border border-border text-text-muted cursor-pointer">
<X size={15} />
</button>
</div>
<span className="inline-block text-[11px] bg-bg-deep border border-border text-text-secondary px-3 py-1 rounded-full mb-3">{selectedListing.category}</span>
<p className="text-sm text-text-secondary leading-relaxed mb-5">{selectedListing.description}</p>
<div className="flex items-center justify-between mb-5">
<p className="text-2xl font-bold text-[#D4AF37]">{selectedListing.price_flh.toLocaleString()} FLH</p> <p className="text-2xl font-bold text-[#D4AF37]">{selectedListing.price_flh.toLocaleString()} FLH</p>
{token && selectedListing.sellerId !== user?.id && ( {user && <p className="text-xs text-text-muted">Your balance: {user.flhBalance?.toLocaleString() || 0} FLH</p>}
<button onClick={() => { handlePurchase(selectedListing.id); setSelectedListing(null) }} </div>
className="w-full bg-[#D4AF37] text-[#0a0a0f] py-2 rounded font-semibold hover:bg-[#C9A84C] transition">
Purchase {token && selectedListing.sellerId !== user?.id ? (
<button
onClick={() => handlePurchase(selectedListing.id)}
className="w-full flex items-center justify-center gap-2 gold-gradient text-bg-deep py-4 rounded-2xl font-bold text-base active:scale-[0.97] transition-transform cursor-pointer"
>
<ShoppingCart size={18} /> Purchase Now
</button>
) : selectedListing.sellerId === user?.id ? (
<div className="w-full text-center py-4 rounded-2xl border border-border text-text-muted text-sm bg-bg-deep">Your listing</div>
) : (
<button onClick={() => setSelectedListing(null)} className="w-full text-center py-4 rounded-2xl border border-border text-text-muted text-sm bg-bg-deep">
Sign in to purchase
</button> </button>
)} )}
</div> </div>
</div> </div>
)} )}
{showCreate && token && <CreateListingModal token={token} userId={user!.id} onClose={() => setShowCreate(false)} onCreated={fetchListings} />}
{/* Create listing bottom sheet */}
{showCreate && token && (
<CreateListingSheet
token={token}
onClose={() => setShowCreate(false)}
onCreated={fetchListings}
showToast={showToast}
/>
)}
</div> </div>
) )
} }
function CreateListingModal({ token, onClose, onCreated }: { token: string; userId: string; onClose: () => void; onCreated: () => void }) { function CreateListingSheet({ token, onClose, onCreated, showToast }: {
token: string; onClose: () => void; onCreated: () => void; showToast: (m: string) => void
}) {
const [title, setTitle] = useState('') const [title, setTitle] = useState('')
const [description, setDescription] = useState('') const [description, setDescription] = useState('')
const [category, setCategory] = useState('E-Books') const [category, setCategory] = useState('E-Books')
@@ -128,31 +243,58 @@ function CreateListingModal({ token, onClose, onCreated }: { token: string; user
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
setLoading(true) setLoading(true)
const res = await fetch('/api/marketplace/listings', { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ title, description, category, priceFlh: parseInt(priceFlh) }) }) try {
const res = await fetch('/api/marketplace/listings', {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ title, description, category, priceFlh: parseInt(priceFlh) }),
})
const data = await res.json() const data = await res.json()
if (res.ok) { onCreated(); onClose(); showToast('Listing created!') }
else showToast(data.error || 'Failed to create')
} catch { showToast('Something went wrong') }
setLoading(false) setLoading(false)
if (res.ok) { onCreated(); onClose() } else { alert(data.error) }
} }
return ( return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center p-4 z-50"> <div className="fixed inset-0 z-50 flex flex-col justify-end" onClick={onClose}>
<form onSubmit={handleSubmit} className="bg-[#111118] border border-gray-800 rounded-lg p-6 max-w-md w-full space-y-4"> <div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
<h2 className="text-lg font-bold">Create Listing</h2> <form
<input type="text" placeholder="Title" value={title} onChange={e => setTitle(e.target.value)} required onSubmit={handleSubmit}
className="w-full bg-[#0a0a0f] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none" /> className="relative bg-bg-card border-t border-x border-border rounded-t-3xl p-6 pb-10 space-y-4"
<textarea placeholder="Description" value={description} onChange={e => setDescription(e.target.value)} required rows={3} onClick={e => e.stopPropagation()}
className="w-full bg-[#0a0a0f] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none" /> >
<select value={category} onChange={e => setCategory(e.target.value)} <div className="w-10 h-1 bg-border rounded-full mx-auto mb-2" />
className="w-full bg-[#0a0a0f] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none"> <h2 className="text-lg font-bold text-white">New Listing</h2>
{['E-Books', 'Courses', 'Design', 'Audio', 'Video', 'Software', 'Other'].map(c => <option key={c} value={c}>{c}</option>)}
<input
type="text" placeholder="Product title" value={title} onChange={e => setTitle(e.target.value)} required
className="w-full bg-bg-deep border border-border rounded-xl px-4 py-4 text-sm text-white placeholder-text-muted focus:border-gold focus:outline-none"
/>
<textarea
placeholder="Description" value={description} onChange={e => setDescription(e.target.value)} required rows={3}
className="w-full bg-bg-deep border border-border rounded-xl px-4 py-3 text-sm text-white placeholder-text-muted focus:border-gold focus:outline-none resize-none"
/>
<select
value={category} onChange={e => setCategory(e.target.value)}
className="w-full bg-bg-deep border border-border rounded-xl px-4 py-3.5 text-sm text-white focus:border-gold focus:outline-none"
>
{['E-Books', 'Courses', 'Design', 'Audio', 'Video', 'Software', 'Other'].map(c => <option key={c} className="bg-bg-card">{c}</option>)}
</select> </select>
<input type="number" placeholder="Price (FLH)" value={priceFlh} onChange={e => setPriceFlh(e.target.value)} required min={1} <input
className="w-full bg-[#0a0a0f] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none" /> type="number" placeholder="Price in FLH" value={priceFlh} onChange={e => setPriceFlh(e.target.value)} required min={1}
<button type="submit" disabled={loading} inputMode="numeric"
className="w-full bg-[#D4AF37] text-[#0a0a0f] py-2 rounded font-semibold disabled:opacity-50 hover:bg-[#C9A84C] transition"> className="w-full bg-bg-deep border border-border rounded-xl px-4 py-4 text-sm text-white placeholder-text-muted focus:border-gold focus:outline-none"
{loading ? 'Creating...' : 'Create Listing'} />
<button
type="submit" disabled={loading}
className="w-full gold-gradient text-bg-deep py-4 rounded-2xl font-bold disabled:opacity-50 active:scale-[0.97] transition-transform cursor-pointer"
>
{loading ? 'Creating…' : 'Create Listing'}
</button>
<button type="button" onClick={onClose} className="w-full text-text-muted text-sm py-2 cursor-pointer hover:text-text-secondary">
Cancel
</button> </button>
<button type="button" onClick={onClose} className="w-full text-gray-500 text-sm hover:text-white">Cancel</button>
</form> </form>
</div> </div>
) )
+83 -36
View File
@@ -3,7 +3,7 @@
import { useEffect, Suspense } from 'react' import { useEffect, Suspense } from 'react'
import { useAuth } from '@/lib/AuthContext' import { useAuth } from '@/lib/AuthContext'
import { useRouter, useSearchParams } from 'next/navigation' import { useRouter, useSearchParams } from 'next/navigation'
import { Crown, Zap, Check, Loader2 } from 'lucide-react' import { Crown, Zap, Check, ChevronLeft } from 'lucide-react'
const TIERS = [ const TIERS = [
{ {
@@ -11,16 +11,18 @@ const TIERS = [
price: '$0', price: '$0',
period: 'forever', period: 'forever',
priceId: null, priceId: null,
href: '/nur',
features: [ features: [
'Access to Souq marketplace', 'Nur AI coaching (10 msg/day)',
'Nur AI coaching (basic)', 'Souq marketplace',
'Forum access', 'Forum access',
'FLH wallet & cashouts', 'FLH wallet & cashouts',
], ],
cta: 'Get Started', cta: 'Continue Free',
href: '/souq',
highlighted: false, highlighted: false,
icon: Zap, icon: Zap,
iconColor: 'text-gray-400',
badge: null,
}, },
{ {
name: 'Premium', name: 'Premium',
@@ -28,14 +30,16 @@ const TIERS = [
period: '/month', period: '/month',
priceId: 'price_premium_monthly', priceId: 'price_premium_monthly',
features: [ features: [
'Everything in Free', 'Unlimited Nur AI coaching',
'Nur AI unlimited coaching',
'Scholar personas (Al-Ghazali, Ibn Abbas, Rabia)', 'Scholar personas (Al-Ghazali, Ibn Abbas, Rabia)',
'Halal Monitor — mosques & restaurants',
'Priority support', 'Priority support',
], ],
cta: 'Subscribe', cta: 'Start Premium',
highlighted: true, highlighted: true,
icon: Crown, icon: Crown,
iconColor: 'text-[#D4AF37]',
badge: 'MOST POPULAR',
}, },
{ {
name: 'Pro', name: 'Pro',
@@ -48,9 +52,11 @@ const TIERS = [
'Custom integrations', 'Custom integrations',
'Direct line to the team', 'Direct line to the team',
], ],
cta: 'Subscribe', cta: 'Go Pro',
highlighted: false, highlighted: false,
icon: Crown, icon: Crown,
iconColor: 'text-purple-500',
badge: null,
}, },
] ]
@@ -70,7 +76,7 @@ function UpgradeContent() {
alert('Welcome to Premium! Your account has been upgraded.') alert('Welcome to Premium! Your account has been upgraded.')
router.replace('/upgrade') router.replace('/upgrade')
} else if (canceled === 'true') { } else if (canceled === 'true') {
alert('Checkout canceled. Please try again if you changed your mind.') alert('Checkout canceled.')
router.replace('/upgrade') router.replace('/upgrade')
} }
}, [searchParams, router]) }, [searchParams, router])
@@ -94,60 +100,93 @@ function UpgradeContent() {
} }
} }
if (authLoading) return <div className="p-8 text-center text-gray-500">Loading...</div> if (authLoading) return (
<div className="min-h-dvh bg-bg-deep flex items-center justify-center pb-24">
<div className="w-8 h-8 rounded-full border-2 border-gold/30 border-t-gold animate-spin" />
</div>
)
if (!token) return null if (!token) return null
return ( return (
<div className="max-w-6xl mx-auto p-4 sm:p-6 space-y-8"> <div className="min-h-dvh bg-bg-deep pb-32 text-white">
<div className="text-center space-y-2">
<h1 className="text-3xl font-bold text-[#D4AF37]">Upgrade Your Experience</h1> {/* Gradient header */}
<p className="text-gray-400">Choose the plan that fits your journey</p> <div className="bg-gradient-to-br from-emerald-950/40 via-bg-card to-bg-deep border-b border-border/80 px-4 pt-14 pb-8">
<button
onClick={() => router.back()}
className="w-9 h-9 flex items-center justify-center rounded-xl bg-bg-card border border-border text-white mb-4 active:scale-[0.97] transition-all cursor-pointer"
>
<ChevronLeft size={20} />
</button>
<h1 className="text-2xl font-bold text-white">Choose your plan</h1>
<p className="text-sm text-text-secondary mt-1">Cancel anytime, no hidden fees</p>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 items-start">
{/* Trial reminder */}
<div className="mx-4 -mt-4 mb-5 bg-bg-card border border-emerald-900/30 rounded-2xl shadow-md px-4 py-3">
<p className="text-xs text-emerald-400 font-medium">New accounts include a 7-day Premium trial free</p>
</div>
{/* Tiers */}
<div className="px-4 space-y-4">
{TIERS.map((tier) => { {TIERS.map((tier) => {
const Icon = tier.icon const Icon = tier.icon
return ( return (
<div <div
key={tier.name} key={tier.name}
className={`relative rounded-xl p-6 space-y-5 transition ${ className={`relative bg-bg-card rounded-3xl shadow-lg border p-5 ${
tier.highlighted tier.highlighted
? 'bg-gradient-to-b from-[#D4AF37]/10 to-[#111118] border-2 border-[#D4AF37] shadow-lg shadow-[#D4AF37]/5 scale-105' ? 'border-gold/60 shadow-gold/5'
: 'bg-[#111118] border border-gray-800 hover:border-gray-700' : 'border-border'
}`} }`}
> >
{tier.highlighted && ( {tier.badge && (
<div className="absolute -top-3 left-1/2 -translate-x-1/2 bg-[#D4AF37] text-[#0a0a0f] text-xs font-bold px-3 py-1 rounded-full"> <div className="absolute -top-3 left-5 bg-gradient-to-r from-[#D4AF37] to-amber-500 text-bg-deep text-[10px] font-bold px-3 py-1 rounded-full tracking-widest">
BEST VALUE {tier.badge}
</div> </div>
)} )}
<div className="text-center space-y-2">
<Icon size={32} className={tier.highlighted ? 'text-[#D4AF37] mx-auto' : 'text-gray-500 mx-auto'} /> <div className="flex items-start justify-between mb-4">
<h2 className="text-xl font-bold">{tier.name}</h2> <div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-2xl flex items-center justify-center border border-border/60 ${
tier.highlighted ? 'bg-[#D4AF37]/10' : 'bg-bg-deep'
}`}>
<Icon size={20} className={tier.iconColor} />
</div>
<div> <div>
<span className="text-3xl font-bold">{tier.price}</span> <h2 className="font-bold text-white">{tier.name}</h2>
<span className="text-gray-500 text-sm">{tier.period}</span> <div className="flex items-baseline gap-0.5">
<span className="text-xl font-bold text-white">{tier.price}</span>
<span className="text-text-muted text-xs">{tier.period}</span>
</div> </div>
</div> </div>
<ul className="space-y-3"> </div>
</div>
<ul className="space-y-2.5 mb-5">
{tier.features.map((f) => ( {tier.features.map((f) => (
<li key={f} className="flex items-start gap-2 text-sm text-gray-300"> <li key={f} className="flex items-start gap-2.5">
<Check size={16} className="text-[#D4AF37] mt-0.5 shrink-0" /> <Check size={14} className="text-gold mt-0.5 shrink-0" />
<span>{f}</span> <span className="text-sm text-text-secondary">{f}</span>
</li> </li>
))} ))}
</ul> </ul>
{tier.priceId ? ( {tier.priceId ? (
<button <button
onClick={() => handleSubscribe(tier.priceId!)} onClick={() => handleSubscribe(tier.priceId!)}
className="w-full bg-[#D4AF37] text-[#0a0a0f] py-2.5 rounded-lg font-bold hover:bg-[#C9A84C] transition flex items-center justify-center gap-2" className={`w-full py-4 rounded-2xl font-bold text-sm active:scale-[0.97] transition-all cursor-pointer ${
tier.highlighted
? 'gold-gradient text-bg-deep'
: 'bg-purple-950/20 text-[#A78BFA] border border-purple-900/40 hover:bg-purple-950/40'
}`}
> >
<Loader2 size={16} className="animate-spin hidden" />
{tier.cta} {tier.cta}
</button> </button>
) : ( ) : (
<button <button
onClick={() => router.push(tier.href)} onClick={() => router.push(tier.href ?? '/')}
className="w-full border border-gray-700 text-gray-300 py-2.5 rounded-lg font-semibold hover:bg-gray-800 transition" className="w-full py-4 rounded-2xl font-bold text-sm border border-border text-text-secondary bg-bg-deep active:scale-[0.97] transition-all cursor-pointer"
> >
{tier.cta} {tier.cta}
</button> </button>
@@ -156,13 +195,21 @@ function UpgradeContent() {
) )
})} })}
</div> </div>
<p className="text-center text-xs text-text-muted mt-6 px-8">
Secure payments via Polar. Cancel anytime from your account settings.
</p>
</div> </div>
) )
} }
export default function UpgradePage() { export default function UpgradePage() {
return ( return (
<Suspense fallback={<div className="p-8 text-center text-gray-500">Loading...</div>}> <Suspense fallback={
<div className="min-h-dvh bg-[#f5f4f0] flex items-center justify-center pb-24">
<div className="w-8 h-8 rounded-full border-2 border-emerald-300 border-t-emerald-600 animate-spin" />
</div>
}>
<UpgradeContent /> <UpgradeContent />
</Suspense> </Suspense>
) )
+130 -33
View File
@@ -2,8 +2,8 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useAuth } from '@/lib/AuthContext' import { useAuth } from '@/lib/AuthContext'
import { ArrowUpRight, History } from 'lucide-react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { ArrowDownLeft, Clock, CheckCircle2, XCircle, Wallet } from 'lucide-react'
export default function WalletPage() { export default function WalletPage() {
const { token, user, loading: authLoading } = useAuth() const { token, user, loading: authLoading } = useAuth()
@@ -11,54 +11,151 @@ export default function WalletPage() {
const [amount, setAmount] = useState('') const [amount, setAmount] = useState('')
const [cashouts, setCashouts] = useState<any[]>([]) const [cashouts, setCashouts] = useState<any[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [toast, setToast] = useState('')
useEffect(() => { if (!authLoading && !token) router.push('/login') }, [token, authLoading, router]) useEffect(() => { if (!authLoading && !token) router.push('/login') }, [token, authLoading, router])
useEffect(() => { if (!token) return; fetch('/api/wallet', { headers: { 'Authorization': `Bearer ${token}` } }).then(r => r.json()).then(d => setCashouts(d.requests || [])).catch(() => {}) }, [token]) useEffect(() => {
if (!token) return
fetch('/api/wallet', { headers: { Authorization: `Bearer ${token}` } })
.then(r => r.json()).then(d => setCashouts(d.requests || [])).catch(() => { })
}, [token])
const showToast = (msg: string) => { setToast(msg); setTimeout(() => setToast(''), 3000) }
const handleCashout = async () => { const handleCashout = async () => {
if (!token || !amount) return if (!token || !amount || parseInt(amount) < 100) return
setLoading(true) setLoading(true)
const res = await fetch('/api/wallet', { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ amountFlh: parseInt(amount) }) }) const res = await fetch('/api/wallet', {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ amountFlh: parseInt(amount) }),
})
setLoading(false) setLoading(false)
const data = await res.json() const data = await res.json()
if (res.ok) { setAmount(''); alert('Cashout submitted!') } else { alert(data.error) } if (res.ok) {
setAmount('')
showToast('Cashout request submitted!')
// Refresh cashout history
fetch('/api/wallet', { headers: { Authorization: `Bearer ${token}` } })
.then(r => r.json()).then(d => setCashouts(d.requests || [])).catch(() => {})
// Force a page reload to refresh user balance from AuthContext
setTimeout(() => window.location.reload(), 1500)
} else showToast(data.error || 'Something went wrong')
} }
if (authLoading) return <div className="p-8 text-center text-gray-500">Loading...</div> if (authLoading) return (
<div className="min-h-dvh flex items-center justify-center bg-bg-deep pb-20">
<div className="w-8 h-8 rounded-full border-2 border-gold/30 border-t-gold animate-spin" />
</div>
)
if (!token) return null if (!token) return null
const fiatValue = amount ? ((parseInt(amount) || 0) * 0.008).toFixed(2) : '0.00'
return ( return (
<div className="max-w-3xl mx-auto p-4 sm:p-6 space-y-6"> <div className="min-h-dvh bg-bg-deep pb-32 text-white">
<h1 className="text-2xl font-bold text-[#D4AF37]">Wallet</h1>
<div className="bg-gradient-to-r from-[#D4AF37]/10 to-[#0a0a0f] border border-[#D4AF37]/20 rounded-lg p-6"> {/* Header */}
<p className="text-sm text-gray-400 mb-1">Balance</p> <div className="px-5 pt-14 pb-4">
<p className="text-4xl font-bold text-[#D4AF37]">{user?.flhBalance?.toLocaleString() || 0} <span className="text-lg text-gray-500">FLH</span></p> <h1 className="text-xl font-bold text-white">Wallet</h1>
<p className="text-[11px] text-text-secondary mt-0.5">Manage your FLH balance</p>
</div> </div>
<div className="bg-[#111118] border border-gray-800 rounded-lg p-6 space-y-4">
<h2 className="font-semibold flex items-center gap-2"><ArrowUpRight size={16} className="text-[#D4AF37]" /> Cash Out</h2> {/* Balance card */}
<p className="text-xs text-gray-500">Rate: 100 FLH = £0.80 (20% platform spread). Minimum 100 FLH.</p> <div className="mx-5 mb-5 rounded-3xl overflow-hidden shadow-lg glass border border-gold/30 relative">
<div className="flex gap-2"> <div className="absolute inset-0 bg-gradient-to-br from-gold/10 to-transparent opacity-60 pointer-events-none" />
<input type="number" placeholder="Amount (FLH)" value={amount} onChange={e => setAmount(e.target.value)} min={100} <div className="px-6 py-6 relative z-10">
className="flex-1 bg-[#0a0a0f] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none" /> <div className="flex items-center gap-2 mb-4">
<button onClick={handleCashout} disabled={loading || !amount || parseInt(amount) < 100} <Wallet size={16} className="text-gold" />
className="bg-[#D4AF37] text-[#0a0a0f] px-4 py-2 rounded text-sm font-semibold disabled:opacity-50 hover:bg-[#C9A84C] transition"> <span className="text-xs text-text-secondary font-semibold uppercase tracking-wider">FLH Balance</span>
{loading ? 'Processing...' : 'Cash Out'} </div>
<p className="text-5xl font-extrabold text-white tabular-nums leading-none">
{(user?.flhBalance || 0).toLocaleString()}
</p>
<p className="text-gold font-bold mt-1.5 text-base">FLH Tokens</p>
<p className="text-xs text-text-secondary mt-3">
£{((user?.flhBalance || 0) * 0.008).toFixed(2)} GBP at current rate
</p>
</div>
</div>
{/* Cash Out */}
<div className="mx-5 mb-5 glass rounded-3xl p-5">
<div className="flex items-center gap-2 mb-1">
<ArrowDownLeft size={16} className="text-gold" />
<h2 className="font-bold text-white text-sm">Cash Out</h2>
</div>
<p className="text-[11px] text-text-muted mb-4">100 FLH = £0.80 · Minimum 100 FLH</p>
<div className="space-y-3">
<input
type="number"
placeholder="Amount in FLH"
value={amount}
onChange={e => setAmount(e.target.value)}
min={100}
inputMode="numeric"
className="w-full bg-bg-deep border border-border rounded-xl px-4 py-4 text-white placeholder-text-muted text-sm focus:border-gold focus:outline-none"
/>
{amount && parseInt(amount) > 0 && (
<div className="flex items-center justify-between px-1">
<span className="text-xs text-text-secondary">{parseInt(amount).toLocaleString()} FLH</span>
<span className="text-xs text-gold font-semibold">£{fiatValue} GBP</span>
</div>
)}
<button
onClick={handleCashout}
disabled={loading || !amount || parseInt(amount) < 100}
className="w-full gold-gradient text-bg-deep py-4 rounded-2xl font-bold text-sm disabled:opacity-40 active:scale-[0.97] transition-all cursor-pointer"
>
{loading ? 'Submitting…' : 'Request Cash Out'}
</button> </button>
</div> {amount && parseInt(amount) < 100 && (
</div> <p className="text-xs text-red-400 text-center">Minimum 100 FLH</p>
<div className="bg-[#111118] border border-gray-800 rounded-lg p-6 space-y-3">
<h2 className="font-semibold flex items-center gap-2"><History size={16} className="text-[#D4AF37]" /> History</h2>
{cashouts.length === 0 ? (
<p className="text-sm text-gray-500">No cashout requests yet</p>
) : (
cashouts.map(c => (
<div key={c.id} className="flex items-center justify-between text-sm">
<span className="text-gray-400">{c.amountFlh} FLH £{c.fiatAmount.toFixed(2)}</span>
<span className={`text-xs ${c.status === 'pending' ? 'text-yellow-400' : 'text-green-400'}`}>{c.status}</span>
</div>
))
)} )}
</div> </div>
</div> </div>
{/* History */}
<div className="mx-5 glass rounded-3xl p-5">
<h2 className="font-bold text-white text-sm mb-4">History</h2>
{cashouts.length === 0 ? (
<div className="text-center py-6">
<p className="text-text-muted text-sm">No cashout requests yet</p>
</div>
) : (
<div className="space-y-3">
{cashouts.map(c => (
<div key={c.id} className="flex items-center gap-3 py-2 border-b border-border/40 last:border-b-0">
<div className="w-9 h-9 rounded-full bg-bg-deep border border-border flex items-center justify-center shrink-0">
{c.status === 'pending'
? <Clock size={16} className="text-gold" />
: c.status === 'approved'
? <CheckCircle2 size={16} className="text-emerald" />
: <XCircle size={16} className="text-red-500" />
}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-white font-medium">{c.amountFlh.toLocaleString()} FLH</p>
<p className="text-xs text-text-secondary">£{c.fiatAmount.toFixed(2)} GBP</p>
</div>
<span className={`text-[10px] font-bold uppercase tracking-wider px-2.5 py-1 rounded-full border ${
c.status === 'pending' ? 'bg-yellow-500/10 text-yellow-500 border-yellow-500/20'
: c.status === 'approved' ? 'bg-emerald/10 text-emerald border-emerald/20'
: 'bg-red-500/10 text-red-500 border-red-500/20'
}`}>{c.status}</span>
</div>
))}
</div>
)}
</div>
{/* Toast */}
{toast && (
<div className="fixed bottom-24 left-5 right-5 bg-bg-card border border-border text-white rounded-2xl px-4 py-3 text-sm text-center z-50 shadow-xl">
{toast}
</div>
)}
</div>
) )
} }
+272
View File
@@ -0,0 +1,272 @@
'use client'
import { useState, useEffect } from 'react'
import { useAuth } from '@/lib/AuthContext'
import { useRouter } from 'next/navigation'
import { ArrowLeft, Gift, Heart, ShieldAlert, CheckCircle2, ChevronRight, X } from 'lucide-react'
import Link from 'next/link'
interface WaqfProject {
id: string
title: string
description: string
raisedFlh: number
targetFlh: number
category: string
icon: string
}
const INITIAL_PROJECTS: WaqfProject[] = [
{
id: 'mosque-expansion',
title: 'Mosque Expansion & Solar Power',
description: 'Provide sustainable solar energy and double the capacity of the rural community mosque.',
raisedFlh: 42000,
targetFlh: 80000,
category: 'Infrastructure',
icon: '🕌'
},
{
id: 'clean-water-well',
title: 'Solar Water Wells & Pumps',
description: 'Construct a clean drinking water borehole system powered entirely by solar pumps.',
raisedFlh: 21500,
targetFlh: 30000,
category: 'Water',
icon: '💧'
},
{
id: 'quran-distribution',
title: 'Quran Publishing & Distribution',
description: 'Print and translate 10,000 copies of the Quran to school children in underserved areas.',
raisedFlh: 7800,
targetFlh: 15000,
category: 'Education',
icon: '📖'
},
{
id: 'orphans-school',
title: 'Orphans Digital Literacy School',
description: 'Sponsor the construction of a new digital classroom and computers for Islamic orphanage.',
raisedFlh: 31000,
targetFlh: 50000,
category: 'Education',
icon: '💻'
}
]
export default function WaqfPage() {
const { token, user, loading: authLoading } = useAuth()
const router = useRouter()
const [projects, setProjects] = useState<WaqfProject[]>(INITIAL_PROJECTS)
const [selectedProject, setSelectedProject] = useState<WaqfProject | null>(null)
const [amount, setAmount] = useState('')
const [loading, setLoading] = useState(false)
const [toast, setToast] = useState('')
useEffect(() => {
if (!authLoading && !token) router.push('/login')
}, [token, authLoading, router])
const showToast = (msg: string) => {
setToast(msg)
setTimeout(() => setToast(''), 3000)
}
const handleContribute = async () => {
if (!token || !selectedProject || !amount) return
const flhAmount = parseInt(amount)
if (flhAmount <= 0) return
if (user && user.flhBalance < flhAmount) {
showToast('Insufficient FLH balance')
return
}
setLoading(true)
try {
const res = await fetch('/api/waqf/contribute', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
projectId: selectedProject.id,
amountFlh: flhAmount
})
})
const data = await res.json()
if (res.ok) {
// Increment project progress locally
setProjects(prev =>
prev.map(p =>
p.id === selectedProject.id ? { ...p, raisedFlh: p.raisedFlh + flhAmount } : p
)
)
showToast('JazakAllah Khair! Contribution successful!')
setSelectedProject(null)
setAmount('')
// Refresh page to sync balance
setTimeout(() => window.location.reload(), 1500)
} else {
showToast(data.error || 'Contribution failed')
}
} catch {
showToast('Something went wrong')
} finally {
setLoading(false)
}
}
if (authLoading) {
return (
<div className="min-h-dvh flex items-center justify-center bg-bg-deep pb-20">
<div className="w-8 h-8 rounded-full border-2 border-gold/30 border-t-gold animate-spin" />
</div>
)
}
if (!token) return null
return (
<div className="min-h-dvh bg-bg-deep pb-32 text-white select-none">
{/* Header */}
<div className="px-5 pt-14 pb-4 flex items-center gap-3">
<Link href="/profile" className="w-9 h-9 flex items-center justify-center rounded-xl bg-bg-card border border-border text-white active:scale-95 transition-all">
<ArrowLeft size={18} />
</Link>
<div>
<h1 className="text-xl font-bold text-white">Waqf Chain</h1>
<p className="text-[11px] text-text-secondary mt-0.5">Sadaqah Jariyah as Smart Endowments</p>
</div>
</div>
{/* Spirituality Stats Card */}
<div className="mx-5 mb-5 rounded-3xl bg-gradient-to-br from-emerald-950/40 via-bg-card to-bg-deep border border-border p-5">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-2xl bg-gold/10 flex items-center justify-center shrink-0">
<Heart size={22} className="text-gold" />
</div>
<div>
<h2 className="text-sm font-semibold text-white">Your Endowment Impact</h2>
<p className="text-xs text-text-secondary mt-0.5">Every contribution remains verified on the Waqf ledger.</p>
<p className="text-[11px] text-gold mt-2 font-medium">Your current balance: {user?.flhBalance?.toLocaleString() || 0} FLH</p>
</div>
</div>
</div>
{/* Projects List */}
<div className="px-5 space-y-4">
<p className="text-[11px] text-text-muted font-medium uppercase tracking-widest">Active Waqf Initiatives</p>
{projects.map(p => {
const percent = Math.min(100, Math.round((p.raisedFlh / p.targetFlh) * 100))
return (
<button
key={p.id}
onClick={() => setSelectedProject(p)}
className="w-full text-left bg-bg-card border border-border rounded-2xl p-4 active:scale-[0.98] transition-all hover:border-gold/30 cursor-pointer block"
>
<div className="flex items-start gap-3">
<span className="text-2xl w-12 h-12 flex items-center justify-center bg-bg-deep border border-border rounded-xl shrink-0">
{p.icon}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<span className="text-[10px] bg-bg-deep border border-border text-text-secondary px-2.5 py-0.5 rounded-full uppercase tracking-wider font-semibold">
{p.category}
</span>
<span className="text-xs text-gold font-bold">{percent}%</span>
</div>
<h3 className="font-bold text-white text-sm mt-2 leading-tight">{p.title}</h3>
<p className="text-xs text-text-secondary mt-1 line-clamp-2 leading-relaxed">{p.description}</p>
{/* Progress bar */}
<div className="w-full h-1.5 bg-bg-deep border border-border rounded-full overflow-hidden mt-3.5">
<div
className="h-full rounded-full gold-gradient transition-all duration-500"
style={{ width: `${percent}%` }}
/>
</div>
<div className="flex justify-between items-center mt-2.5 text-[10px] text-text-muted">
<span>{p.raisedFlh.toLocaleString()} FLH raised</span>
<span>Goal: {p.targetFlh.toLocaleString()} FLH</span>
</div>
</div>
</div>
</button>
)}
)}
</div>
{/* Contribution Detail Sheet */}
{selectedProject && (
<div className="fixed inset-0 z-50 flex flex-col justify-end" onClick={() => setSelectedProject(null)}>
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
<div
className="relative bg-bg-card border-t border-x border-border rounded-t-3xl p-6 pb-10 max-h-[85dvh] overflow-y-auto"
onClick={e => e.stopPropagation()}
>
{/* Handle */}
<div className="w-10 h-1 bg-border rounded-full mx-auto mb-5" />
<div className="flex items-start gap-3 mb-4">
<div className="w-14 h-14 rounded-2xl bg-bg-deep border border-border flex items-center justify-center text-2xl shrink-0">
{selectedProject.icon}
</div>
<div className="flex-1 min-w-0">
<h2 className="text-lg font-bold text-white leading-tight">{selectedProject.title}</h2>
<p className="text-xs text-text-muted mt-0.5">Waqf Smart Contract Endowment</p>
</div>
<button onClick={() => setSelectedProject(null)} className="w-8 h-8 flex items-center justify-center rounded-full bg-bg-deep border border-border text-text-muted cursor-pointer">
<X size={15} />
</button>
</div>
<p className="text-sm text-text-secondary leading-relaxed mb-5">{selectedProject.description}</p>
<div className="space-y-4">
<div className="space-y-1.5">
<label className="text-xs font-semibold text-text-secondary uppercase tracking-widest">Contribute Amount (FLH)</label>
<input
type="number"
placeholder="Enter amount to contribute"
value={amount}
onChange={e => setAmount(e.target.value)}
min={1}
inputMode="numeric"
className="w-full bg-bg-deep border border-border rounded-xl px-4 py-4 text-white placeholder-text-muted text-sm focus:border-gold focus:outline-none"
/>
</div>
{amount && parseInt(amount) > 0 && (
<div className="flex justify-between text-xs px-1 text-text-secondary">
<span>Balance after Waqf:</span>
<span className="font-semibold text-gold">
{Math.max(0, (user?.flhBalance || 0) - parseInt(amount)).toLocaleString()} FLH
</span>
</div>
)}
<button
onClick={handleContribute}
disabled={loading || !amount || parseInt(amount) <= 0 || (user ? user.flhBalance < parseInt(amount) : false)}
className="w-full gold-gradient text-bg-deep py-4 rounded-2xl font-bold text-base active:scale-[0.97] transition-transform cursor-pointer disabled:opacity-40"
>
{loading ? 'Processing Endowment…' : 'Contribute Waqf'}
</button>
</div>
</div>
</div>
)}
{/* Toast */}
{toast && (
<div className="fixed bottom-24 left-5 right-5 bg-bg-card border border-border text-white rounded-2xl px-4 py-3 text-sm text-center z-50 shadow-xl">
{toast}
</div>
)}
</div>
)
}
+94
View File
@@ -0,0 +1,94 @@
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { House, Brain, User } from 'lucide-react'
const tabs = [
{ href: '/home', icon: House, label: 'Home' },
{ href: '/nur', icon: Brain, label: 'Nur', center: true },
{ href: '/profile', icon: User, label: 'Profile' },
]
export default function BottomNav() {
const pathname = usePathname()
// Hide on auth pages
if (pathname === '/login' || pathname === '/register') return null
return (
<nav className="fixed bottom-0 left-0 right-0 z-50 flex justify-center pointer-events-none">
<div
className="glass rounded-[28px] px-5 py-1.5 flex items-center gap-2 pointer-events-auto"
style={{
marginBottom: 'max(env(safe-area-inset-bottom, 0px), 16px)',
boxShadow: '0 8px 32px rgba(0,0,0,0.6)',
}}
>
{tabs.map(({ href, icon: Icon, label, center }) => {
const isActive =
pathname === href ||
(href !== '/home' && pathname.startsWith(href + '/'))
if (center) {
return (
<Link
key={href}
href={href}
className="relative flex flex-col items-center active:scale-95 transition-transform"
style={{ marginTop: '-20px' }}
>
<div
className={`w-14 h-14 rounded-full flex items-center justify-center ${
isActive
? 'gold-purple-gradient shadow-lg shadow-purple-500/20'
: 'gold-purple-gradient opacity-80'
}`}
style={{
border: isActive
? '2px solid rgba(255,255,255,0.15)'
: '2px solid rgba(255,255,255,0.06)',
}}
>
<Icon
size={22}
className={isActive ? 'text-white' : 'text-white/80'}
strokeWidth={isActive ? 2.5 : 1.8}
/>
</div>
<span
className={`text-[10px] font-semibold mt-0.5 ${
isActive ? 'text-gold' : 'text-text-muted'
}`}
>
{label}
</span>
</Link>
)
}
return (
<Link
key={href}
href={href}
className="flex flex-col items-center justify-center gap-0.5 px-6 py-2 active:scale-95 transition-transform"
>
<Icon
size={20}
className={isActive ? 'text-gold' : 'text-text-muted'}
strokeWidth={isActive ? 2.5 : 1.8}
/>
<span
className={`text-[10px] font-semibold ${
isActive ? 'text-gold' : 'text-text-muted'
}`}
>
{label}
</span>
</Link>
)
})}
</div>
</nav>
)
}
+2 -59
View File
@@ -1,59 +1,2 @@
'use client' // Replaced by BottomNav — kept for import compatibility
export default function Navbar() { return null }
import Link from 'next/link'
import { ShoppingCart, Bot, MessageCircle, Wallet, Crown, LogIn, User } from 'lucide-react'
import { useAuth } from '@/lib/AuthContext'
export default function Navbar() {
const { user, token } = useAuth()
const linkClass = (path: string) => {
return `flex items-center gap-1 text-sm transition ${typeof window !== 'undefined' && window.location.pathname === path ? 'text-[#D4AF37]' : 'text-gray-400 hover:text-white'}`
}
return (
<nav className="sticky top-0 z-50 bg-[#0a0a0f]/80 backdrop-blur border-b border-gray-800">
<div className="max-w-6xl mx-auto px-4 h-14 flex items-center justify-between">
<Link href="/" className="text-lg font-bold text-[#D4AF37]">FLH</Link>
<div className="flex items-center gap-4">
<Link href="/souq" className={linkClass('/souq')}>
<ShoppingCart size={16} />
<span className="hidden sm:inline">Souq</span>
</Link>
<Link href="/nur" className={linkClass('/nur')}>
<Bot size={16} />
<span className="hidden sm:inline">Nur</span>
</Link>
<Link href="/forum" className={linkClass('/forum')}>
<MessageCircle size={16} />
<span className="hidden sm:inline">Forum</span>
</Link>
<Link href="/wallet" className={linkClass('/wallet')}>
<Wallet size={16} />
<span className="hidden sm:inline">Wallet</span>
</Link>
<div className="w-px h-5 bg-gray-700" />
{token && user?.isPremium ? (
<Link href="/halal-monitor" className={linkClass('/halal-monitor')}>
<Crown size={16} />
<span className="hidden sm:inline">Monitor</span>
</Link>
) : token ? (
<Link href="/halal-monitor" className="flex items-center gap-1 text-sm transition text-gray-500 hover:text-[#D4AF37]">
<Crown size={14} />
</Link>
) : null}
{token ? (
<Link href="/profile" className="text-gray-400 hover:text-white transition">
<User size={16} />
</Link>
) : (
<Link href="/login" className="text-gray-400 hover:text-white transition">
<LogIn size={16} />
</Link>
)}
</div>
</div>
</nav>
)
}
+41
View File
@@ -0,0 +1,41 @@
import { render, screen } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
// We create a mock Navbar component since the real one might be complex
// and we just want to demonstrate a sample test
const Navbar = ({ isAuthenticated, user }: { isAuthenticated: boolean; user?: { name: string } }) => {
return (
<nav>
<div>FalahMobile</div>
{isAuthenticated ? (
<div>
<span>Welcome, {user?.name}</span>
<button>Logout</button>
</div>
) : (
<div>
<button>Login</button>
<button>Sign Up</button>
</div>
)}
</nav>
)
}
describe('Navbar Component', () => {
it('should show login and signup when unauthenticated', () => {
render(<Navbar isAuthenticated={false} />)
expect(screen.getByText('Login')).toBeInTheDocument()
expect(screen.getByText('Sign Up')).toBeInTheDocument()
expect(screen.queryByText('Logout')).not.toBeInTheDocument()
})
it('should show user menu and logout when authenticated', () => {
render(<Navbar isAuthenticated={true} user={{ name: 'Test User' }} />)
expect(screen.getByText('Welcome, Test User')).toBeInTheDocument()
expect(screen.getByText('Logout')).toBeInTheDocument()
expect(screen.queryByText('Login')).not.toBeInTheDocument()
})
})
+76
View File
@@ -0,0 +1,76 @@
import { describe, it, expect } from 'vitest'
describe('Auth Utilities', () => {
describe('Email validation', () => {
// Assuming validateEmail is in the auth utility
const validateEmail = (email: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
it('should return true for valid emails', () => {
expect(validateEmail('test@example.com')).toBe(true)
expect(validateEmail('user.name+tag@example.co.uk')).toBe(true)
})
it('should return false for invalid emails', () => {
expect(validateEmail('invalid-email')).toBe(false)
expect(validateEmail('test@.com')).toBe(false)
expect(validateEmail('@example.com')).toBe(false)
})
})
describe('Password strength', () => {
// Assuming checkPasswordStrength is in the auth utility
const checkPasswordStrength = (pass: string) => {
if (pass.length < 8) return 'weak'
if (!/[A-Z]/.test(pass) || !/[0-9]/.test(pass) || !/[^a-zA-Z0-9]/.test(pass)) return 'medium'
return 'strong'
}
it('should return weak for passwords under 8 characters', () => {
expect(checkPasswordStrength('pass')).toBe('weak')
})
it('should return medium for passwords without symbols or numbers', () => {
expect(checkPasswordStrength('password123')).toBe('medium')
})
it('should return strong for passwords with mixed case, numbers, and symbols', () => {
expect(checkPasswordStrength('StrongPass123!')).toBe('strong')
})
})
describe('Login/Logout operations', () => {
it('should handle login successfully with correct credentials', async () => {
// Mock login function
const login = async (user: string, pass: string) => {
if (user === 'user@example.com' && pass === 'password') {
return { success: true, token: 'fake-jwt-token' }
}
return { success: false }
}
const result = await login('user@example.com', 'password')
expect(result.success).toBe(true)
expect(result).toHaveProperty('token', 'fake-jwt-token')
})
it('should fail login with incorrect credentials', async () => {
const login = async (user: string, pass: string) => {
if (user === 'user@example.com' && pass === 'password') {
return { success: true, token: 'fake-jwt-token' }
}
return { success: false }
}
const result = await login('user@example.com', 'wrongpassword')
expect(result.success).toBe(false)
})
it('should handle logout successfully', async () => {
// Mock logout function
const logout = async () => true
const result = await logout()
expect(result).toBe(true)
})
})
})
+25
View File
@@ -0,0 +1,25 @@
const WP_API = process.env.NEXT_PUBLIC_WP_API_URL || ''
export async function fetchAPI<T>(
endpoint: string,
options?: RequestInit
): Promise<T> {
const token =
typeof window !== 'undefined'
? localStorage.getItem('flh_token')
: null
const res = await fetch(`${WP_API}/${endpoint}`, {
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
...options,
})
if (!res.ok) {
throw new Error(`WordPress API error: ${res.status}`)
}
return res.json()
}
+10 -1
View File
@@ -1,7 +1,10 @@
import { SignJWT, jwtVerify } from 'jose' import { SignJWT, jwtVerify } from 'jose'
import bcrypt from 'bcryptjs' import bcrypt from 'bcryptjs'
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-fallback' const JWT_SECRET = process.env.JWT_SECRET
if (!JWT_SECRET && process.env.NODE_ENV === 'production') {
throw new Error('JWT_SECRET environment variable is required in production')
}
const secret = new TextEncoder().encode(JWT_SECRET) const secret = new TextEncoder().encode(JWT_SECRET)
export function hashPassword(password: string): string { export function hashPassword(password: string): string {
@@ -20,6 +23,12 @@ export async function signJWT(payload: { id: string; email: string }): Promise<s
.sign(secret) .sign(secret)
} }
export function isActivePremium(user: { isPremium: boolean; isPro: boolean; trialEndsAt?: Date | null }): boolean {
if (!user.isPremium && !user.isPro) return false
if (user.trialEndsAt && user.trialEndsAt < new Date()) return false
return true
}
export async function verifyJWT(token: string): Promise<{ id: string; email: string } | null> { export async function verifyJWT(token: string): Promise<{ id: string; email: string } | null> {
try { try {
const { payload } = await jwtVerify(token, secret) const { payload } = await jwtVerify(token, secret)
+272
View File
@@ -0,0 +1,272 @@
[
{
"day": 1,
"surah": "Al-Fatiha",
"ayah": "1:1",
"arabic": "بِسْمِ اللَّهِ الرَّحْمَٰنِ الرَّحِيمِ",
"translation": "In the name of Allah, the Entirely Merciful, the Especially Merciful.",
"reflection": "Every action begun with bismillah is an act of worship. The scholars taught that the one who says bismillah with awareness invites divine blessing into their deed — whether it is eating, travelling, or beginning a conversation. How many of your actions today began with His name?",
"source": "Sahih International"
},
{
"day": 2,
"surah": "Al-Baqarah",
"ayah": "2:286",
"arabic": "لَا يُكَلِّفُ اللَّهُ نَفْسًا إِلَّا وُسْعَهَا",
"translation": "Allah does not burden a soul beyond that it can bear.",
"reflection": "Every trial you face has been measured precisely for you — not to break you, but to build you. Ibn Kathir noted that this verse is a source of comfort for the believer: Allah, who created you, knows the exact weight your soul can carry. Whatever you face today, you were made for it.",
"source": "Sahih International"
},
{
"day": 3,
"surah": "Al-Imran",
"ayah": "3:173",
"arabic": "حَسْبُنَا اللَّهُ وَنِعْمَ الْوَكِيلُ",
"translation": "Allah is sufficient for us, and He is the best Disposer of affairs.",
"reflection": "These were the words of the believers when told that a great army had gathered against them — and they responded not with fear, but with tawakkul. Al-Ghazali wrote that true reliance on Allah is not passivity, but a heart at peace while the hands remain at work.",
"source": "Sahih International"
},
{
"day": 4,
"surah": "Al-Inshirah",
"ayah": "94:5-6",
"arabic": "فَإِنَّ مَعَ الْعُسْرِ يُسْرًا ۝ إِنَّ مَعَ الْعُسْرِ يُسْرًا",
"translation": "For indeed, with hardship will be ease. Indeed, with hardship will be ease.",
"reflection": "Ibn Abbas noted something profound: the word 'hardship' (al-usr) is definite — the same hardship. But 'ease' (yusr) is indefinite — two different eases. So one hardship is accompanied by at least two eases. The mathematics of Allah's mercy always favour the believer.",
"source": "Sahih International"
},
{
"day": 5,
"surah": "Al-Baqarah",
"ayah": "2:152",
"arabic": "فَاذْكُرُونِي أَذْكُرْكُمْ",
"translation": "So remember Me; I will remember you.",
"reflection": "Consider the weight of this promise: when you remember Allah — in your morning, your prayer, a quiet moment — He remembers you before His angels. Rabi'a al-Adawiya once said: 'My love for Allah leaves no room for hatred of anyone — the heart occupied with the Beloved has no space for another.'",
"source": "Sahih International"
},
{
"day": 6,
"surah": "Az-Zumar",
"ayah": "39:53",
"arabic": "لَا تَقْنَطُوا مِن رَّحْمَةِ اللَّهِ ۚ إِنَّ اللَّهَ يَغْفِرُ الذُّنُوبَ جَمِيعًا",
"translation": "Do not despair of the mercy of Allah. Indeed, Allah forgives all sins.",
"reflection": "This verse was revealed as a direct response to despair. The Prophet ﷺ said this is the most hope-giving verse in the Quran. No sin is too large for His forgiveness — only the refusal to seek it closes the door. Whatever has passed between you and Allah, today is a new opening.",
"source": "Sahih International"
},
{
"day": 7,
"surah": "Al-Baqarah",
"ayah": "2:155",
"arabic": "وَلَنَبْلُوَنَّكُم بِشَيْءٍ مِّنَ الْخَوْفِ وَالْجُوعِ",
"translation": "And We will surely test you with something of fear and hunger and a loss of wealth and lives and fruits, but give good tidings to the patient.",
"reflection": "The trials of this life are not punishments but purifications. The Prophet ﷺ said: 'No fatigue, illness, worry, or grief befalls a Muslim — even the prick of a thorn — except that Allah expiates some of his sins.' Your difficulty is not abandonment. It is attention.",
"source": "Sahih International"
},
{
"day": 8,
"surah": "Al-Hujurat",
"ayah": "49:13",
"arabic": "إِنَّ أَكْرَمَكُمْ عِندَ اللَّهِ أَتْقَاكُمْ",
"translation": "Indeed, the most noble of you in the sight of Allah is the most righteous of you.",
"reflection": "Rank before Allah is not measured by wealth, lineage, or position — but by taqwa, the consciousness of Him in every moment. Ibn Abbas explained that taqwa is not just avoiding the forbidden, but a constant awareness that Allah sees all that you do, even in private.",
"source": "Sahih International"
},
{
"day": 9,
"surah": "Al-Mulk",
"ayah": "67:2",
"arabic": "الَّذِي خَلَقَ الْمَوْتَ وَالْحَيَاةَ لِيَبْلُوَكُمْ أَيُّكُمْ أَحْسَنُ عَمَلًا",
"translation": "He who created death and life to test you as to which of you is best in deed.",
"reflection": "Al-Fudayl ibn Iyad, commenting on this verse, said: 'The best deed is that which is most sincere and most correct — sincere meaning done for Allah's sake alone, correct meaning done in accordance with the Sunnah.' Quality over quantity. One sincere sajdah outweighs a thousand performed for show.",
"source": "Sahih International"
},
{
"day": 10,
"surah": "Ar-Ra'd",
"ayah": "13:28",
"arabic": "أَلَا بِذِكْرِ اللَّهِ تَطْمَئِنُّ الْقُلُوبُ",
"translation": "Verily, in the remembrance of Allah do hearts find rest.",
"reflection": "The human heart was made for this — not for the accumulation of things, or the approval of people, but for the remembrance of its Creator. Al-Ghazali wrote in the Ihya: the heart is like a mirror — when polished with dhikr, it reflects divine light; when neglected, it rusts. What does your mirror look like today?",
"source": "Sahih International"
},
{
"day": 11,
"surah": "Al-Isra",
"ayah": "17:44",
"arabic": "وَإِن مِّن شَيْءٍ إِلَّا يُسَبِّحُ بِحَمْدِهِ وَلَٰكِن لَّا تَفْقَهُونَ تَسْبِيحَهُمْ",
"translation": "There is not a thing except that it exalts Allah by His praise, but you do not understand their exaltation.",
"reflection": "The birds outside your window, the leaves in the wind, the water in your glass — all are engaged in worship you cannot hear. You are surrounded by a universe in constant prayer. The believer's task is to join the chorus, not to be the only silent voice.",
"source": "Sahih International"
},
{
"day": 12,
"surah": "Al-Kahf",
"ayah": "18:10",
"arabic": "رَبَّنَا آتِنَا مِن لَّدُنكَ رَحْمَةً وَهَيِّئْ لَنَا مِنْ أَمْرِنَا رَشَدًا",
"translation": "Our Lord, grant us from Yourself mercy and prepare for us from our affair right guidance.",
"reflection": "This was the du'a of the People of the Cave — young men who left everything for their faith. Two things they asked for: mercy (rahmah) and right guidance (rashad). In every confusion and crossroads of life, this is all we truly need: His mercy to sustain us, His guidance to direct us.",
"source": "Sahih International"
},
{
"day": 13,
"surah": "Al-Qasas",
"ayah": "28:24",
"arabic": "رَبِّ إِنِّي لِمَا أَنزَلْتَ إِلَيَّ مِنْ خَيْرٍ فَقِيرٌ",
"translation": "My Lord, indeed I am, for whatever good You would send down to me, in need.",
"reflection": "These were the words of Prophet Musa after a long journey, exhausted, sitting by a well with no provisions. He did not complain — he simply confessed his need to Allah. The scholars called this the du'a of complete dependence. Sometimes the most powerful prayer is the most honest one: 'I need You.'",
"source": "Sahih International"
},
{
"day": 14,
"surah": "Al-Anbiya",
"ayah": "21:87",
"arabic": "لَّا إِلَٰهَ إِلَّا أَنتَ سُبْحَانَكَ إِنِّي كُنتُ مِنَ الظَّالِمِينَ",
"translation": "There is no deity except You; exalted are You. Indeed, I have been of the wrongdoers.",
"reflection": "The du'a of Yunus from within the whale — three layers of darkness, complete helplessness. And yet this supplication is called the greatest means of answered prayer by the Prophet ﷺ. It contains tawhid, glorification, and honest self-accountability. When you feel most trapped, this is the key.",
"source": "Sahih International"
},
{
"day": 15,
"surah": "Al-Baqarah",
"ayah": "2:186",
"arabic": "وَإِذَا سَأَلَكَ عِبَادِي عَنِّي فَإِنِّي قَرِيبٌ",
"translation": "And when My servants ask you concerning Me — indeed I am near.",
"reflection": "Notice: Allah did not say 'Tell them I am near.' He said 'I am near' — directly, without intermediary. There is no verse in the Quran more intimate than this. Rabi'a al-Adawiya spent her nights in this nearness: 'O Allah, whatever share of this world You have allotted me, give it to your enemies; and whatever share of the next world, give it to Your friends — for You alone suffice me.'",
"source": "Sahih International"
},
{
"day": 16,
"surah": "Al-Furqan",
"ayah": "25:63",
"arabic": "وَعِبَادُ الرَّحْمَٰنِ الَّذِينَ يَمْشُونَ عَلَى الْأَرْضِ هَوْنًا",
"translation": "And the servants of the Most Merciful are those who walk upon the earth with humility.",
"reflection": "The first quality Allah names for His beloved servants is how they walk — with ease, without arrogance, without burdening the earth. The Prophet ﷺ walked with a slight forward lean, as if descending a slope. There is an entire spiritual practice in how you move through the world.",
"source": "Sahih International"
},
{
"day": 17,
"surah": "Al-Talaq",
"ayah": "65:3",
"arabic": "وَمَن يَتَوَكَّلْ عَلَى اللَّهِ فَهُوَ حَسْبُهُ",
"translation": "And whoever relies upon Allah — then He is sufficient for him.",
"reflection": "The Prophet ﷺ was asked about a man who left his camel without tying it, saying he trusted in Allah. He replied: 'Tie your camel, then put your trust in Allah.' Tawakkul is not the absence of effort — it is the release of anxiety after effort. You do your part; you leave the rest to the One who owns all outcomes.",
"source": "Sahih International"
},
{
"day": 18,
"surah": "Al-Imran",
"ayah": "3:8",
"arabic": "رَبَّنَا لَا تُزِغْ قُلُوبَنَا بَعْدَ إِذْ هَدَيْتَنَا",
"translation": "Our Lord, let not our hearts deviate after You have guided us.",
"reflection": "The heart is not static — it turns. The word 'qalb' in Arabic comes from the root meaning 'to flip.' Even the Prophet ﷺ made this du'a regularly. Guidance is not a destination arrived at once; it is a direction maintained daily. Ask Allah today to keep your heart facing toward Him.",
"source": "Sahih International"
},
{
"day": 19,
"surah": "Al-Hashr",
"ayah": "59:19",
"arabic": "وَلَا تَكُونُوا كَالَّذِينَ نَسُوا اللَّهَ فَأَنسَاهُمْ أَنفُسَهُمْ",
"translation": "And do not be like those who forgot Allah, so He made them forget themselves.",
"reflection": "Forgetting Allah does not only mean abandoning prayer — it means losing yourself. When Allah is absent from your consciousness, you lose the map of who you are and why you exist. Al-Ghazali said: the one who does not know Allah cannot truly know himself.",
"source": "Sahih International"
},
{
"day": 20,
"surah": "Al-Dhuha",
"ayah": "93:3",
"arabic": "مَا وَدَّعَكَ رَبُّكَ وَمَا قَلَىٰ",
"translation": "Your Lord has not taken leave of you, nor has He despised you.",
"reflection": "These verses were revealed when revelation paused and the Prophet ﷺ was distressed, fearing he had been abandoned. Allah responded with a tender reassurance. This verse is for every person who has ever felt Allah has gone silent in their life: He has not left. He has not forgotten. He is not displeased. He is near.",
"source": "Sahih International"
},
{
"day": 21,
"surah": "Al-Asr",
"ayah": "103:1-3",
"arabic": "وَالْعَصْرِ ۝ إِنَّ الْإِنسَانَ لَفِي خُسْرٍ ۝ إِلَّا الَّذِينَ آمَنُوا وَعَمِلُوا الصَّالِحَاتِ",
"translation": "By time, indeed, mankind is in loss — except for those who have believed and done righteous deeds.",
"reflection": "Al-Shafi'i said: if people only reflected deeply on this surah, it would suffice them. The default condition of a life without faith and good action is loss — not punishment, but the quiet waste of time. Every moment not oriented toward the good is subtracted. What will today add?",
"source": "Sahih International"
},
{
"day": 22,
"surah": "Al-Imran",
"ayah": "3:31",
"arabic": "قُلْ إِن كُنتُمْ تُحِبُّونَ اللَّهَ فَاتَّبِعُونِي يُحْبِبْكُمُ اللَّهُ",
"translation": "Say: If you should love Allah, then follow me, so Allah will love you.",
"reflection": "This is called ayat al-mahabbah — the verse of love. Love of Allah is not a feeling alone; it is a practice. It is following the Prophet ﷺ in his generosity, his patience, his gentleness, his prayer. Ibn Kathir noted: the sign that your love is real is that it changes your behaviour.",
"source": "Sahih International"
},
{
"day": 23,
"surah": "Al-Ahzab",
"ayah": "33:41",
"arabic": "يَا أَيُّهَا الَّذِينَ آمَنُوا اذْكُرُوا اللَّهَ ذِكْرًا كَثِيرًا",
"translation": "O you who have believed, remember Allah with much remembrance.",
"reflection": "'Much remembrance' — the scholars differed on the minimum, but agreed there is no maximum. Your tongue can be in dhikr while your hands are working, while you wait, while you walk. The Prophet ﷺ said: 'The comparison of the one who remembers Allah and the one who does not is like the comparison of the living and the dead.'",
"source": "Sahih International"
},
{
"day": 24,
"surah": "Al-Nahl",
"ayah": "16:97",
"arabic": "مَنْ عَمِلَ صَالِحًا مِّن ذَكَرٍ أَوْ أُنثَىٰ وَهُوَ مُؤْمِنٌ فَلَنُحْيِيَنَّهُ حَيَاةً طَيِّبَةً",
"translation": "Whoever does righteousness, whether male or female, while he is a believer — We will surely cause him to live a good life.",
"reflection": "A 'good life' (hayat tayyibah) — the scholars said this does not mean wealth or ease, but contentment and peace of heart. Ibn Abbas said: it is sustenance that is halal and satisfaction with what one has. The richest person is the one whose heart is at rest.",
"source": "Sahih International"
},
{
"day": 25,
"surah": "Az-Zumar",
"ayah": "39:10",
"arabic": "إِنَّمَا يُوَفَّى الصَّابِرُونَ أَجْرَهُم بِغَيْرِ حِسَابٍ",
"translation": "Indeed, the patient will be given their reward without account.",
"reflection": "Every other deed is rewarded in proportion: ten times, seven hundred times. But patience — sabr — is the only deed rewarded 'without account.' The scholars said this means: immeasurably, limitlessly. Whatever you are patiently enduring today, know that Allah is counting it in a currency that has no ceiling.",
"source": "Sahih International"
},
{
"day": 26,
"surah": "Al-Baqarah",
"ayah": "2:45",
"arabic": "وَاسْتَعِينُوا بِالصَّبْرِ وَالصَّلَاةِ",
"translation": "And seek help through patience and prayer.",
"reflection": "When the world becomes heavy, you are given two tools: sabr and salah. Patience quiets the ego; prayer reconnects the soul. The Prophet ﷺ, when something distressed him, would immediately stand for prayer. Before you seek advice, before you seek comfort from others, try these two.",
"source": "Sahih International"
},
{
"day": 27,
"surah": "Al-Hadid",
"ayah": "57:3",
"arabic": "هُوَ الْأَوَّلُ وَالْآخِرُ وَالظَّاهِرُ وَالْبَاطِنُ",
"translation": "He is the First and the Last, the Evident and the Immanent.",
"reflection": "Four names that encompass all of existence: before everything and after everything, visible in His creation and hidden in His essence. Al-Ghazali spent an entire chapter of the Ihya on these four names alone. To know Allah is not a single realisation but a lifelong unfolding. Each day you are meant to know Him more.",
"source": "Sahih International"
},
{
"day": 28,
"surah": "Al-Fajr",
"ayah": "89:27-28",
"arabic": "يَا أَيَّتُهَا النَّفْسُ الْمُطْمَئِنَّةُ ۝ ارْجِعِي إِلَىٰ رَبِّكِ رَاضِيَةً مَّرْضِيَّةً",
"translation": "O reassured soul, return to your Lord, well-pleased and pleasing to Him.",
"reflection": "The goal of the entire spiritual journey is in these two verses: a nafs mutma'innah — a soul at rest. And the call to return: not dragged back reluctantly, but returning willingly, pleased and pleasing. Rabi'a al-Adawiya prayed: 'O Allah, if I worship You out of fear of hellfire, then burn me in it. But if I worship You out of love for You, do not deprive me of Your eternal beauty.'",
"source": "Sahih International"
},
{
"day": 29,
"surah": "Al-Ikhlas",
"ayah": "112:1-2",
"arabic": "قُلْ هُوَ اللَّهُ أَحَدٌ ۝ اللَّهُ الصَّمَدُ",
"translation": "Say: He is Allah, the One. Allah, the Eternal Refuge.",
"reflection": "The Prophet ﷺ said this surah is equivalent to a third of the Quran. Al-Samad — the scholars said it means the one to whom all creation turns in need, who Himself needs nothing. Everything else in existence depends on something. Only Allah is the absolute foundation. When all else shifts, He remains.",
"source": "Sahih International"
},
{
"day": 30,
"surah": "Al-Baqarah",
"ayah": "2:201",
"arabic": "رَبَّنَا آتِنَا فِي الدُّنْيَا حَسَنَةً وَفِي الْآخِرَةِ حَسَنَةً وَقِنَا عَذَابَ النَّارِ",
"translation": "Our Lord, give us in this world good and in the Hereafter good and protect us from the punishment of the Fire.",
"reflection": "The Prophet ﷺ called this the master du'a — it encompasses everything. Good in this world: health, provision, righteous company, a grateful heart. Good in the next: His pleasure, His nearness, His paradise. And protection from the Fire. Three requests that contain the entire purpose of a Muslim life. Say it today — say it often.",
"source": "Sahih International"
}
]
+38 -3
View File
@@ -1,7 +1,42 @@
import { PrismaClient } from '@prisma/client' import { PrismaClient } from '@prisma/client'
import fs from 'fs'
import path from 'path'
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient } let prisma: PrismaClient
export const prisma = globalForPrisma.prisma ?? new PrismaClient() if (process.env.LAMBDA_TASK_ROOT || process.env.NETLIFY) {
const targetDbPath = '/tmp/dev.db'
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma if (!fs.existsSync(targetDbPath)) {
let sourceDbPath = path.join(process.cwd(), 'prisma', 'dev.db')
if (!fs.existsSync(sourceDbPath)) {
sourceDbPath = path.join(process.cwd(), 'dev.db')
}
try {
if (fs.existsSync(sourceDbPath)) {
fs.copyFileSync(sourceDbPath, targetDbPath)
fs.chmodSync(targetDbPath, 0o666)
console.log(`Copied database to writeable path: ${targetDbPath}`)
} else {
console.error(`Database template not found at: ${sourceDbPath}`)
}
} catch (e) {
console.error('Error copying SQLite database to /tmp:', e)
}
}
prisma = new PrismaClient({
datasources: {
db: {
url: `file:${targetDbPath}`
}
}
})
} else {
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }
prisma = globalForPrisma.prisma ?? new PrismaClient()
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
}
export { prisma }
+21
View File
@@ -0,0 +1,21 @@
import { defaultCache } from '@serwist/next/worker'
import type { PrecacheEntry, SerwistGlobalConfig } from 'serwist'
import { Serwist } from 'serwist'
declare global {
interface WorkerGlobalScope extends SerwistGlobalConfig {
__SW_MANIFEST: (PrecacheEntry | string)[] | undefined
}
}
declare const self: WorkerGlobalScope
const serwist = new Serwist({
precacheEntries: self.__SW_MANIFEST,
skipWaiting: true,
clientsClaim: true,
navigationPreload: true,
runtimeCaching: defaultCache,
})
serwist.addEventListeners()
+11 -3
View File
@@ -30,12 +30,20 @@
}, },
"include": [ "include": [
"next-env.d.ts", "next-env.d.ts",
"**/*.ts", "src/**/*.ts",
"**/*.tsx", "src/**/*.tsx",
".next/types/**/*.ts", ".next/types/**/*.ts",
".next/dev/types/**/*.ts" ".next/dev/types/**/*.ts"
], ],
"exclude": [ "exclude": [
"node_modules" "node_modules",
"**/*.test.ts",
"**/*.test.tsx",
"**/*.spec.ts",
"**/__tests__/**",
"e2e/**",
"playwright.config.ts",
"vitest.config.ts",
"vitest.setup.ts"
] ]
} }
File diff suppressed because one or more lines are too long
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./vitest.setup.ts'],
exclude: ['**/node_modules/**', '**/dist/**', '**/e2e/**'],
alias: {
'@': path.resolve(__dirname, './src'),
},
},
})
+1
View File
@@ -0,0 +1 @@
import '@testing-library/jest-dom/vitest'