Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 894edfbac9 | |||
| 1f60f1c908 |
@@ -0,0 +1,80 @@
|
|||||||
|
# /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
|
||||||
|
```
|
||||||
+4
-9
@@ -1,12 +1,7 @@
|
|||||||
node_modules
|
node_modules
|
||||||
.next
|
.next
|
||||||
|
*.db
|
||||||
|
.env
|
||||||
.git
|
.git
|
||||||
*.md
|
Dockerfile
|
||||||
.env*
|
docker-compose.yml
|
||||||
.gitignore
|
|
||||||
.eslint*
|
|
||||||
.prettier*
|
|
||||||
.next
|
|
||||||
npm-debug.log
|
|
||||||
yarn-debug.log
|
|
||||||
yarn-error.log
|
|
||||||
|
|||||||
@@ -24,26 +24,6 @@ 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
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
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}
|
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
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 }}
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
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
@@ -31,8 +31,6 @@ yarn-error.log*
|
|||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
coverage/
|
coverage/
|
||||||
playwright-report/
|
|
||||||
test-results/
|
|
||||||
|
|
||||||
# Secrets (DO NOT COMMIT)
|
# Secrets (DO NOT COMMIT)
|
||||||
*.pem
|
*.pem
|
||||||
@@ -45,21 +43,6 @@ test-results/
|
|||||||
*.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/
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# 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
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
.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."
|
|
||||||
@@ -1,483 +0,0 @@
|
|||||||
# 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
@@ -1,68 +0,0 @@
|
|||||||
# 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)
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
# 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:
|
|
||||||
@@ -1,330 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
# 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
|
|
||||||
```
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+12
-19
@@ -1,20 +1,13 @@
|
|||||||
// ESLint flat config for FalahMobile
|
import { dirname } from "path";
|
||||||
// Uses @eslint/js directly to avoid eslintrc circular dependency
|
import { fileURLToPath } from "url";
|
||||||
import js from '@eslint/js'
|
import { FlatCompat } from "@eslint/eslintrc";
|
||||||
import nextPlugin from '@next/eslint-plugin-next'
|
|
||||||
|
|
||||||
export default [
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
js.configs.recommended,
|
const __dirname = dirname(__filename);
|
||||||
{
|
|
||||||
plugins: {
|
const compat = new FlatCompat({
|
||||||
'@next/next': nextPlugin,
|
baseDirectory: __dirname,
|
||||||
},
|
});
|
||||||
rules: {
|
|
||||||
...nextPlugin.configs.recommended.rules,
|
const eslintConfig = [...compat.extends("next/core-web-vitals")];
|
||||||
...nextPlugin.configs['core-web-vitals'].rules,
|
export default eslintConfig;
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ignores: ['**/node_modules/**', '.next/**', '**/*.js', '**/*.mjs'],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|||||||
-240
@@ -1,240 +0,0 @@
|
|||||||
#!/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
@@ -1,38 +0,0 @@
|
|||||||
## 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
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
[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"]
|
|
||||||
+7
-11
@@ -1,14 +1,10 @@
|
|||||||
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: process.env.NETLIFY === 'true' ? undefined : 'standalone',
|
output: "standalone",
|
||||||
}
|
turbopack: {
|
||||||
|
root: process.cwd(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export default withSerwist(nextConfig)
|
export default nextConfig;
|
||||||
|
|||||||
+6
-16
@@ -3,43 +3,33 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --webpack",
|
"dev": "next dev",
|
||||||
"build": "next build --webpack",
|
"build": "next build",
|
||||||
"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",
|
||||||
"serwist": "^9.5.11",
|
"@types/leaflet": "^1.9.14"
|
||||||
"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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
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'] },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
# 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
|
|
||||||
Binary file not shown.
+20
-30
@@ -1,6 +1,6 @@
|
|||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
binaryTargets = ["native", "rhel-openssl-1.0.x", "rhel-openssl-3.0.x", "linux-musl", "debian-openssl-3.0.x"]
|
binaryTargets = ["native", "linux-musl"]
|
||||||
}
|
}
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
@@ -22,15 +22,6 @@ 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[]
|
||||||
@@ -157,33 +148,32 @@ model HalalUsage {
|
|||||||
periodEnd DateTime?
|
periodEnd DateTime?
|
||||||
}
|
}
|
||||||
|
|
||||||
model HalalPlaceCache {
|
// ── Micro-Learning (Learn Module) ──
|
||||||
id String @id @default(cuid())
|
|
||||||
cacheKey String @unique
|
|
||||||
data String
|
|
||||||
expiresAt DateTime
|
|
||||||
}
|
|
||||||
|
|
||||||
model DhikrSession {
|
model Course {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String
|
slug String @unique
|
||||||
date String
|
title String
|
||||||
count Int @default(0)
|
description String
|
||||||
completed Boolean @default(false)
|
difficulty String // beginner | intermediate | advanced
|
||||||
|
imageUrl String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@unique([userId, date])
|
modules Module[]
|
||||||
@@index([userId])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model QuranReading {
|
model Module {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String
|
courseId String
|
||||||
date String
|
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
||||||
pagesRead Int @default(0)
|
title String
|
||||||
goalPages Int @default(1)
|
description String
|
||||||
|
content String // markdown
|
||||||
|
videoUrl String?
|
||||||
|
order Int
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@unique([userId, date])
|
quizData String? // JSON string: [{question, options:[], correctIndex}]
|
||||||
@@index([userId])
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
#!/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();
|
||||||
|
});
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 382 B |
@@ -1,5 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 407 B |
@@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
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();
|
|
||||||
@@ -1,17 +1,13 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { verifyJWT, isActivePremium } from '@/lib/auth'
|
import { verifyJWT } 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 })
|
||||||
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 } })
|
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 } })
|
||||||
if (!user) return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
if (!user) return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
||||||
// Auto-downgrade expired trials
|
return NextResponse.json({ user })
|
||||||
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) } })
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, isActivePremium } from '@/lib/auth'
|
import { verifyJWT } from '@/lib/auth'
|
||||||
|
|
||||||
export async function PATCH(req: NextRequest) {
|
export async function PATCH(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -36,21 +36,6 @@ 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
|
||||||
|
|||||||
@@ -8,9 +8,8 @@ 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), isPremium: true, trialEndsAt },
|
data: { email, name, passwordHash: hashPassword(password) },
|
||||||
})
|
})
|
||||||
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 })
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
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
|
||||||
@@ -45,15 +44,10 @@ function pickRandom<T>(pool: T[]): T {
|
|||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const authHeader = req.headers.get('authorization')
|
const { userId } = await req.json()
|
||||||
if (!authHeader?.startsWith('Bearer ')) {
|
if (!userId) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'userId required' }, { status: 400 })
|
||||||
}
|
}
|
||||||
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,20 +1,10 @@
|
|||||||
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({
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ 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>>>()
|
||||||
@@ -32,27 +31,9 @@ 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 })
|
||||||
}
|
}
|
||||||
|
|
||||||
let 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 })
|
||||||
|
|
||||||
// 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) {
|
||||||
@@ -177,13 +158,10 @@ export async function POST(req: NextRequest) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Update lastCoachedAt and increment daily message counter for free users
|
// Update lastCoachedAt
|
||||||
await prisma.user.update({
|
await prisma.user.update({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
data: {
|
data: { lastCoachedAt: new Date() },
|
||||||
lastCoachedAt: new Date(),
|
|
||||||
...(!isActivePremium(user) ? { dailyMsgCount: { increment: 1 } } : {}),
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
@@ -193,7 +171,6 @@ 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) {
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
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 })
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -20,13 +20,6 @@ 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)
|
||||||
|
|||||||
@@ -6,22 +6,6 @@ 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' },
|
||||||
|
|||||||
@@ -32,14 +32,7 @@ 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 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,157 +0,0 @@
|
|||||||
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 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return NextResponse.json({ status: 'ok', service: 'falah-mobile' })
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return NextResponse.json({ status: 'ok', service: 'nurbuddy' })
|
||||||
|
}
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
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 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
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 })
|
|
||||||
}
|
|
||||||
@@ -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', passwordHash: password, flhBalance: 5000 },
|
{ email: 'ghazali@falahos.my', name: 'Abu Hamid Al-Ghazali', password, flhBalance: 5000 },
|
||||||
{ email: 'ibnabbas@falahos.my', name: 'Abdullah Ibn Abbas', passwordHash: password, flhBalance: 3000 },
|
{ email: 'ibnabbas@falahos.my', name: 'Abdullah Ibn Abbas', password, flhBalance: 3000 },
|
||||||
{ email: 'rabia@falahos.my', name: "Rabi'a Al-Adawiyya", passwordHash: password, flhBalance: 2000 },
|
{ email: 'rabia@falahos.my', name: "Rabi'a Al-Adawiyya", password, flhBalance: 2000 },
|
||||||
{ email: 'demo@falahos.my', name: 'Demo User', passwordHash: password, flhBalance: 500 },
|
{ email: 'demo@falahos.my', name: 'Demo User', password, flhBalance: 500 },
|
||||||
]
|
]
|
||||||
|
|
||||||
const users: any[] = []
|
const users: any[] = []
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
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 })
|
|
||||||
}
|
|
||||||
@@ -12,15 +12,7 @@ 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 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server'
|
|
||||||
|
|
||||||
export async function POST() {
|
|
||||||
return NextResponse.json({ error: 'Not yet implemented' }, { status: 501 })
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
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 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
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 })
|
|
||||||
}
|
|
||||||
@@ -1,305 +0,0 @@
|
|||||||
'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'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">
|
|
||||||
“Whoever says SubhanAllah 33 times, Alhamdulillah 33 times, and Allahu Akbar 34 times after each prayer…”
|
|
||||||
</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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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?categoryId=all&id=${threadId}`).then(r => r.json()),
|
fetch(`/api/forum/threads?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="min-h-dvh bg-[#f5f4f0] pb-24 px-4 pt-6">
|
<div className="max-w-3xl mx-auto p-4 sm:p-6">
|
||||||
<div className="animate-pulse space-y-4 max-w-3xl mx-auto">
|
<div className="animate-pulse space-y-4">
|
||||||
<div className="h-4 bg-gray-200 rounded w-24" />
|
<div className="h-4 bg-gray-800 rounded w-24" />
|
||||||
<div className="h-8 bg-gray-200 rounded w-3/4" />
|
<div className="h-8 bg-gray-800 rounded w-3/4" />
|
||||||
<div className="h-4 bg-gray-200 rounded w-1/3" />
|
<div className="h-4 bg-gray-800 rounded w-1/3" />
|
||||||
<div className="h-32 bg-gray-200 rounded-2xl" />
|
<div className="h-32 bg-gray-800 rounded" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -87,80 +87,60 @@ export default function ThreadDetailPage() {
|
|||||||
|
|
||||||
if (error || !thread) {
|
if (error || !thread) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-dvh bg-[#f5f4f0] pb-24 px-4 pt-6 max-w-3xl mx-auto">
|
<div className="max-w-3xl mx-auto p-4 sm:p-6">
|
||||||
<button
|
<button onClick={() => router.push('/forum')} className="flex items-center gap-1 text-[#D4AF37] hover:underline mb-4 text-sm">
|
||||||
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-white border border-gray-100 shadow-sm rounded-2xl p-8 text-center">
|
<div className="bg-[#111118] border border-gray-800 rounded-lg p-8 text-center">
|
||||||
<MessageCircle size={40} className="mx-auto text-gray-300 mb-3" />
|
<MessageCircle size={40} className="mx-auto text-gray-600 mb-3" />
|
||||||
<p className="text-gray-500">{error || 'Thread not found'}</p>
|
<p className="text-gray-400">{error || 'Thread not found'}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-dvh bg-[#f5f4f0] pb-24">
|
<div className="max-w-3xl mx-auto p-4 sm:p-6 space-y-6">
|
||||||
<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-[#D4AF37] hover:underline text-sm">
|
||||||
<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>
|
||||||
|
|
||||||
{/* Thread card */}
|
<div className="bg-[#111118] border border-gray-800 rounded-lg p-5 space-y-3">
|
||||||
<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-emerald-50 text-emerald-700 text-xs font-semibold px-2.5 py-1 rounded-lg">
|
<span className="bg-[#D4AF37]/10 text-[#D4AF37] text-xs font-semibold px-2 py-0.5 rounded">{thread.category.name}</span>
|
||||||
{thread.category.name}
|
|
||||||
</span>
|
|
||||||
{thread.shariahStatus === 'approved' ? (
|
{thread.shariahStatus === 'approved' ? (
|
||||||
<span className="flex items-center gap-1 text-green-600 text-xs">
|
<span className="flex items-center gap-1 text-green-500 text-xs"><ShieldCheck size={14} /> Shariah Approved</span>
|
||||||
<ShieldCheck size={13} /> Shariah Approved
|
|
||||||
</span>
|
|
||||||
) : (
|
) : (
|
||||||
<span className="flex items-center gap-1 text-yellow-600 text-xs">
|
<span className="flex items-center gap-1 text-yellow-500 text-xs"><ShieldAlert size={14} /> {thread.shariahStatus === 'pending' ? 'Pending Review' : 'Flagged'}</span>
|
||||||
<ShieldAlert size={13} /> {thread.shariahStatus === 'pending' ? 'Pending Review' : 'Flagged'}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-lg font-bold text-gray-900">{thread.title}</h1>
|
<h1 className="text-xl font-bold">{thread.title}</h1>
|
||||||
<p className="text-xs text-gray-400">
|
<div className="text-sm text-gray-500">
|
||||||
By <span className="text-gray-600 font-medium">{thread.author.name}</span> · {new Date(thread.createdAt).toLocaleDateString()}
|
By <span className="text-gray-300">{thread.author.name}</span> · {new Date(thread.createdAt).toLocaleDateString()}
|
||||||
</p>
|
</div>
|
||||||
<p className="text-sm text-gray-700 whitespace-pre-wrap leading-relaxed">{thread.content}</p>
|
<p className="text-sm text-gray-300 whitespace-pre-wrap leading-relaxed">{thread.content}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Replies section */}
|
<div className="space-y-4">
|
||||||
<div className="space-y-3">
|
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||||
<h2 className="text-base font-semibold text-gray-900 flex items-center gap-2">
|
<MessageCircle size={18} className="text-[#D4AF37]" />
|
||||||
<MessageCircle size={16} className="text-emerald-600" />
|
|
||||||
Replies ({posts.length})
|
Replies ({posts.length})
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{posts.length === 0 ? (
|
{posts.length === 0 ? (
|
||||||
<div className="bg-white border border-gray-100 shadow-sm rounded-2xl p-6 text-center">
|
<div className="bg-[#111118] border border-gray-800 rounded-lg p-6 text-center">
|
||||||
<p className="text-gray-400 text-sm">No replies yet. Be the first to respond.</p>
|
<p className="text-gray-500 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-white border border-gray-100 shadow-sm rounded-2xl p-4 space-y-2">
|
<div key={post.id} className="bg-[#111118] border border-gray-800 rounded-lg p-4 space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<span className="text-sm font-semibold">{post.author.name}</span>
|
||||||
<div className="w-7 h-7 rounded-xl bg-emerald-100 flex items-center justify-center">
|
<span className="text-xs text-gray-500">{new Date(post.createdAt).toLocaleDateString()}</span>
|
||||||
<span className="text-xs font-bold text-emerald-700">{post.author.name[0]?.toUpperCase()}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-semibold text-gray-900">{post.author.name}</span>
|
<p className="text-sm text-gray-300 whitespace-pre-wrap">{post.content}</p>
|
||||||
</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-600 text-xs pt-1">
|
<div className="flex items-center gap-1 text-yellow-500 text-xs pt-1">
|
||||||
<ShieldAlert size={12} /> Pending shariah review
|
<ShieldAlert size={12} /> Pending shariah review
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -169,34 +149,29 @@ export default function ThreadDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Reply form */}
|
|
||||||
{token ? (
|
{token ? (
|
||||||
<form onSubmit={handleReply} className="bg-white border border-gray-100 shadow-sm rounded-2xl p-4 space-y-3">
|
<form onSubmit={handleReply} className="bg-[#111118] border border-gray-800 rounded-lg 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-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"
|
className="w-full bg-[#0a0a0f] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] 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
|
<button type="submit" disabled={submitting || !reply.trim()}
|
||||||
type="submit"
|
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">
|
||||||
disabled={submitting || !reply.trim()}
|
<Send size={16} /> {submitting ? 'Posting...' : 'Post Reply'}
|
||||||
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-white border border-gray-100 shadow-sm rounded-2xl p-4 text-center">
|
<div className="bg-[#111118] border border-gray-800 rounded-lg p-4 text-center">
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
<a href="/login" className="text-emerald-600 font-semibold">Sign in</a> to reply to this thread.
|
<a href="/login" className="text-[#D4AF37] hover:underline font-semibold">Sign in</a> to reply to this thread.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+54
-172
@@ -2,162 +2,73 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useAuth } from '@/lib/AuthContext'
|
import { useAuth } from '@/lib/AuthContext'
|
||||||
import { Plus, ChevronRight, MessageCircle, X, ArrowLeft } from 'lucide-react'
|
import { Plus, ChevronRight } 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 {
|
interface Thread { id: string; title: string; content: string; author: { name: string }; category: { name: string }; _count: { posts: number }; createdAt: string }
|
||||||
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<Category | null>(null)
|
const [selectedCat, setSelectedCat] = useState<string | 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(() => {
|
useEffect(() => { fetch('/api/forum/categories').then(r => r.json()).then(d => setCategories(d.categories || [])) }, [])
|
||||||
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) => {
|
||||||
setLoading(true)
|
const params = catId ? `?categoryId=${catId}` : ''
|
||||||
const res = await fetch(`/api/forum/threads?categoryId=${catId}`)
|
const res = await fetch(`/api/forum/threads${params}`)
|
||||||
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="min-h-dvh bg-bg-deep pb-32 text-white">
|
<div className="max-w-4xl mx-auto p-4 sm:p-6 space-y-6">
|
||||||
|
|
||||||
{/* 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 && (
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Loading */}
|
|
||||||
{loading && (
|
|
||||||
<div className="px-5 pt-4 space-y-3">
|
|
||||||
{[...Array(4)].map((_, i) => (
|
|
||||||
<div key={i} className="h-20 rounded-2xl bg-bg-card border border-border/50 animate-pulse" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 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>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Threads view */}
|
|
||||||
{!loading && view === 'threads' && (
|
|
||||||
<div className="px-5 pt-4 space-y-3">
|
|
||||||
{threads.length === 0 ? (
|
|
||||||
<div className="text-center py-16">
|
|
||||||
<div className="text-4xl mb-3">💬</div>
|
|
||||||
<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 justify-between">
|
||||||
<div className="flex items-center gap-1.5">
|
<h1 className="text-2xl font-bold text-[#D4AF37]">Forum</h1>
|
||||||
<div className="w-5 h-5 rounded-full bg-bg-deep border border-border flex items-center justify-center">
|
{token && (
|
||||||
<span className="text-[9px] text-[#D4AF37] font-bold">{t.author.name[0]}</span>
|
<button onClick={() => setShowCreate(true)}
|
||||||
|
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>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[11px] text-text-secondary">{t.author.name}</span>
|
{view === 'categories' ? (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
{categories.map(c => (
|
||||||
|
<button key={c.id} onClick={() => { setSelectedCat(c.id); setView('threads'); loadThreads(c.id) }}
|
||||||
|
className="bg-[#111118] border border-gray-800 rounded-lg p-4 text-left hover:border-gray-700 transition">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<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>
|
||||||
<div className="flex items-center gap-1 text-[11px] text-text-secondary">
|
<ChevronRight size={16} className="text-gray-600" />
|
||||||
<MessageCircle size={12} />
|
|
||||||
{t._count.posts}
|
|
||||||
</div>
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<button onClick={() => setView('categories')} className="text-sm text-[#D4AF37] hover:underline mb-2 inline-block">← Back to categories</button>
|
||||||
|
{threads.map(t => (
|
||||||
|
<div key={t.id} className="bg-[#111118] border border-gray-800 rounded-lg p-4 space-y-1">
|
||||||
|
<h3 className="font-semibold">{t.title}</h3>
|
||||||
|
<p className="text-sm text-gray-400 line-clamp-2">{t.content}</p>
|
||||||
|
<div className="flex items-center gap-3 text-xs text-gray-500 pt-1">
|
||||||
|
<span>{t.author.name}</span><span>{t.category.name}</span><span>{t._count.posts} replies</span><span>{new Date(t.createdAt).toLocaleDateString()}</span>
|
||||||
</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 CreateThreadSheet({ token, categories, onClose, onCreated, showToast }: {
|
function CreateThreadModal({ token, categories, onClose, onCreated }: { token: string; categories: Category[]; onClose: () => void; onCreated: () => void }) {
|
||||||
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 || '')
|
||||||
@@ -165,58 +76,29 @@ function CreateThreadSheet({ token, categories, onClose, onCreated, showToast }:
|
|||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!categoryId) return
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
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 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 z-50 flex flex-col justify-end" onClick={onClose}>
|
<div className="fixed inset-0 bg-black/70 flex items-center justify-center p-4 z-50">
|
||||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
|
<form onSubmit={handleSubmit} className="bg-[#111118] border border-gray-800 rounded-lg p-6 max-w-md w-full space-y-4">
|
||||||
<form
|
<h2 className="text-lg font-bold">New Thread</h2>
|
||||||
onSubmit={handleSubmit}
|
<select value={categoryId} onChange={e => setCategoryId(e.target.value)}
|
||||||
className="relative bg-bg-card border-t border-x border-border rounded-t-3xl p-6 pb-10 space-y-4"
|
className="w-full bg-[#0a0a0f] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none">
|
||||||
onClick={e => e.stopPropagation()}
|
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between mb-1">
|
|
||||||
<div className="w-10 h-1 bg-border rounded-full mx-auto absolute left-1/2 -translate-y-1/2 top-3" />
|
|
||||||
<h2 className="text-lg font-bold text-white mt-2">New Thread</h2>
|
|
||||||
<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">
|
|
||||||
<X size={15} />
|
|
||||||
</button>
|
|
||||||
</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>
|
</select>
|
||||||
|
<input type="text" placeholder="Title" value={title} onChange={e => setTitle(e.target.value)} required
|
||||||
<input
|
className="w-full bg-[#0a0a0f] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none" />
|
||||||
type="text" placeholder="Thread title" value={title} onChange={e => setTitle(e.target.value)} required
|
<textarea placeholder="Content" 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-4 text-sm text-white placeholder-text-muted focus:border-gold focus:outline-none"
|
className="w-full bg-[#0a0a0f] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none" />
|
||||||
/>
|
<button type="submit" disabled={loading}
|
||||||
<textarea
|
className="w-full bg-[#D4AF37] text-[#0a0a0f] py-2 rounded font-semibold disabled:opacity-50 hover:bg-[#C9A84C] transition">
|
||||||
placeholder="Share your thoughts…" value={content} onChange={e => setContent(e.target.value)} required rows={4}
|
{loading ? 'Posting...' : 'Create Thread'}
|
||||||
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>
|
||||||
)
|
)
|
||||||
|
|||||||
+14
-97
@@ -1,50 +1,26 @@
|
|||||||
@import "tailwindcss";
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
@theme {
|
@tailwind utilities;
|
||||||
--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 {
|
||||||
--nav-floating-bottom: 24px;
|
--background: #0a0a0f;
|
||||||
}
|
--foreground: #ededed;
|
||||||
|
--gold: #D4AF37;
|
||||||
html {
|
--gold-dark: #C9A84C;
|
||||||
height: 100%;
|
|
||||||
color-scheme: dark;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
height: 100%;
|
color: var(--foreground);
|
||||||
color: var(--color-text-primary);
|
background: var(--background);
|
||||||
background: var(--color-bg-deep);
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
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 {
|
||||||
@@ -52,67 +28,8 @@ a {
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Prevent iOS zoom on input focus */
|
@media (prefers-color-scheme: dark) {
|
||||||
input, select, textarea {
|
html {
|
||||||
font-size: 16px;
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+73
-330
@@ -1,359 +1,102 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { useAuth } from '@/lib/AuthContext'
|
import { useAuth } from '@/lib/AuthContext'
|
||||||
import { Crown, Lock, Search, MapPin, Navigation, Bookmark, X, Loader2 } from 'lucide-react'
|
import { getWorldMonitorIframeUrl, broadcastAuthToIframe } from '@/lib/halal-monitor-bridge'
|
||||||
import dynamic from 'next/dynamic'
|
import { Crown, Shield, Lock } from 'lucide-react'
|
||||||
|
|
||||||
// Leaflet must be loaded client-side only (no SSR)
|
export default function HalalMonitorPage() {
|
||||||
const MapContainer = dynamic(() => import('react-leaflet').then(m => m.MapContainer), { ssr: false })
|
const { token, user, loading: authLoading } = useAuth()
|
||||||
const TileLayer = dynamic(() => import('react-leaflet').then(m => m.TileLayer), { ssr: false })
|
const iframeRef = useRef<HTMLIFrameElement>(null)
|
||||||
const Marker = dynamic(() => import('react-leaflet').then(m => m.Marker), { ssr: false })
|
const [loaded, setLoaded] = useState(false)
|
||||||
const Popup = dynamic(() => import('react-leaflet').then(m => m.Popup), { ssr: false })
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
interface Place {
|
// Broadcast auth to iframe when both are ready
|
||||||
id: string
|
useEffect(() => {
|
||||||
lat: number
|
if (!loaded || !token || !user || !iframeRef.current) return
|
||||||
lon: number
|
try {
|
||||||
name: string
|
broadcastAuthToIframe(iframeRef.current, {
|
||||||
type: 'mosque' | 'restaurant'
|
token,
|
||||||
address?: string
|
user: {
|
||||||
}
|
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])
|
||||||
|
|
||||||
interface MapCenter {
|
if (authLoading) {
|
||||||
lat: number
|
|
||||||
lon: number
|
|
||||||
}
|
|
||||||
|
|
||||||
function UpgradeCTA() {
|
|
||||||
return (
|
return (
|
||||||
<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="max-w-6xl mx-auto p-8 text-center">
|
||||||
<div className="w-16 h-16 rounded-2xl bg-bg-card border border-gold/20 flex items-center justify-center">
|
<div className="animate-pulse text-gray-500">Loading...</div>
|
||||||
<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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function LoginGate() {
|
if (!token || !user) {
|
||||||
return (
|
return (
|
||||||
<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="max-w-6xl mx-auto p-8 flex flex-col items-center justify-center min-h-[60vh] text-center space-y-4">
|
||||||
<div className="w-16 h-16 rounded-2xl bg-bg-card border border-border flex items-center justify-center">
|
<Lock size={48} className="text-gray-600" />
|
||||||
<Lock size={32} className="text-text-muted" />
|
<h1 className="text-2xl font-bold text-[#D4AF37]">Halal Monitor</h1>
|
||||||
</div>
|
<p className="text-gray-400 max-w-md">
|
||||||
<h1 className="text-2xl font-bold text-white">Halal Monitor</h1>
|
Sign in to access real-time halal compliance monitoring, Shariah screening, and market intelligence.
|
||||||
<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
|
<a href="/login"
|
||||||
href="/login"
|
className="inline-flex items-center gap-2 bg-[#D4AF37] text-[#0a0a0f] px-6 py-2 rounded font-semibold hover:bg-[#C9A84C] transition">
|
||||||
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>
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
export default function HalalMonitorPage() {
|
|
||||||
const { token, user, loading: authLoading } = useAuth()
|
|
||||||
const [places, setPlaces] = useState<Place[]>([])
|
|
||||||
const [center, setCenter] = useState<MapCenter | null>(null)
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [citySearch, setCitySearch] = useState('')
|
|
||||||
const [bookmarked, setBookmarked] = useState<Set<string>>(new Set())
|
|
||||||
const [leafletLoaded, setLeafletLoaded] = useState(false)
|
|
||||||
const mapRef = useRef<any>(null)
|
|
||||||
|
|
||||||
// Load Leaflet CSS
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof window === 'undefined') return
|
|
||||||
const link = document.createElement('link')
|
|
||||||
link.rel = 'stylesheet'
|
|
||||||
link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css'
|
|
||||||
document.head.appendChild(link)
|
|
||||||
setLeafletLoaded(true)
|
|
||||||
return () => { document.head.removeChild(link) }
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// 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.')
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCitySearch = async (e: React.FormEvent) => {
|
if (!user.isPremium) {
|
||||||
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="min-h-dvh bg-bg-deep pb-24 flex items-center justify-center">
|
<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="w-8 h-8 rounded-full border-2 border-gold/30 border-t-gold animate-spin" />
|
<Crown size={48} className="text-[#D4AF37]" />
|
||||||
|
<h1 className="text-2xl font-bold text-[#D4AF37]">Halal Monitor</h1>
|
||||||
|
<p className="text-gray-400 max-w-md">
|
||||||
|
Real-time halal compliance monitoring and Shariah screening is available to Premium members.
|
||||||
|
</p>
|
||||||
|
<div className="bg-[#111118] border border-gray-800 rounded-lg p-6 max-w-sm w-full space-y-3">
|
||||||
|
<Shield size={32} className="text-[#D4AF37] mx-auto" />
|
||||||
|
<h2 className="font-bold">Upgrade to Premium</h2>
|
||||||
|
<ul className="text-sm text-gray-400 space-y-1">
|
||||||
|
<li>• Real-time Shariah screening</li>
|
||||||
|
<li>• Halal stock monitoring</li>
|
||||||
|
<li>• Compliance alerts</li>
|
||||||
|
<li>• Market intelligence</li>
|
||||||
|
</ul>
|
||||||
|
<button className="w-full bg-[#D4AF37] text-[#0a0a0f] py-2 rounded font-semibold hover:bg-[#C9A84C] transition">
|
||||||
|
Upgrade Now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
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')
|
|
||||||
|
|
||||||
|
// Premium user — show the World Monitor
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-[calc(100vh-3.5rem)] text-white bg-bg-deep">
|
<div className="w-full h-[calc(100vh-3.5rem)] flex flex-col">
|
||||||
{/* Toolbar */}
|
<div className="bg-[#111118] border-b border-gray-800 px-4 py-2 flex items-center justify-between shrink-0">
|
||||||
<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-2">
|
||||||
<div className="flex items-center gap-1.5">
|
<Crown size={16} className="text-[#D4AF37]" />
|
||||||
<Crown size={15} className="text-gold" />
|
<h1 className="text-sm font-semibold text-[#D4AF37]">Halal Monitor</h1>
|
||||||
<span className="text-sm font-semibold text-white">Halal Monitor</span>
|
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={handleCitySearch} className="flex gap-2 flex-1 max-w-sm">
|
{error && <span className="text-xs text-red-400">{error}</span>}
|
||||||
<div className="relative flex-1">
|
</div>
|
||||||
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-text-muted" />
|
<iframe
|
||||||
<input
|
ref={iframeRef}
|
||||||
type="text"
|
src={getWorldMonitorIframeUrl()}
|
||||||
placeholder="Search city..."
|
className="flex-1 w-full border-0"
|
||||||
value={citySearch}
|
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
||||||
onChange={e => setCitySearch(e.target.value)}
|
onLoad={() => setLoaded(true)}
|
||||||
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"
|
onError={() => setError('Failed to load World Monitor')}
|
||||||
|
title="Halal Monitor"
|
||||||
/>
|
/>
|
||||||
</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='© <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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,514 +0,0 @@
|
|||||||
'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">
|
|
||||||
“{AYATUL_KURSI.translation}”
|
|
||||||
</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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+5
-21
@@ -1,36 +1,20 @@
|
|||||||
import type { Metadata, Viewport } from 'next'
|
import type { Metadata } from 'next'
|
||||||
import { AuthProvider } from '@/lib/AuthContext'
|
import { AuthProvider } from '@/lib/AuthContext'
|
||||||
import BottomNav from '@/components/BottomNav'
|
import Navbar from '@/components/Navbar'
|
||||||
import './globals.css'
|
import './globals.css'
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Falah — Your Islamic Lifestyle Companion',
|
title: 'Falah - Shariah-Compliant Digital Marketplace',
|
||||||
description: 'Nur AI coaching, daily spiritual digest, prayer times, halal marketplace & community',
|
description: 'Souq, Nur AI, Forum, Wallet',
|
||||||
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>
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
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>');
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+15
-78
@@ -4,14 +4,12 @@ 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)
|
||||||
|
|
||||||
@@ -19,89 +17,28 @@ export default function LoginPage() {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError('')
|
setError('')
|
||||||
try {
|
try { await login(email, password); router.push('/souq') }
|
||||||
await login(email, password)
|
catch (err: any) { setError(err.message) }
|
||||||
router.push('/nur')
|
finally { setLoading(false) }
|
||||||
} catch (err: any) {
|
|
||||||
setError(err.message || 'Invalid email or password')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-dvh flex flex-col bg-bg-deep px-6">
|
<div className="max-w-sm mx-auto mt-20 p-6 space-y-4">
|
||||||
{/* Brand */}
|
<h1 className="text-2xl font-bold text-center text-[#D4AF37]">Welcome Back</h1>
|
||||||
<div className="flex flex-col items-center pt-16 pb-10">
|
<form onSubmit={handleSubmit} className="space-y-3">
|
||||||
<div className="w-20 h-20 rounded-3xl gold-gradient flex items-center justify-center mb-5 shadow-lg shadow-gold/20">
|
<input type="email" placeholder="Email" value={email} onChange={e => setEmail(e.target.value)} required
|
||||||
<span className="text-3xl font-bold text-bg-deep">ف</span>
|
className="w-full bg-[#111118] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none" />
|
||||||
</div>
|
<input type="password" placeholder="Password" value={password} onChange={e => setPassword(e.target.value)} required
|
||||||
<h1 className="text-2xl font-bold text-white tracking-tight">Welcome back</h1>
|
className="w-full bg-[#111118] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none" />
|
||||||
<p className="text-text-secondary text-sm mt-1">Sign in to your Falah account</p>
|
{error && <p className="text-red-400 text-xs">{error}</p>}
|
||||||
</div>
|
<button type="submit" disabled={loading}
|
||||||
|
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">
|
||||||
<div className="py-8 space-y-4 text-center">
|
No account? <Link href="/register" className="text-[#D4AF37] hover:underline">Register</Link>
|
||||||
<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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+326
-240
@@ -1,298 +1,384 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import Link from 'next/link'
|
import { Send, Bot, Settings, User, Sparkles, BookOpen, Target, X, Crown, Scroll, Heart, Flame } from 'lucide-react'
|
||||||
import { useAuth } from '@/lib/AuthContext'
|
import { useAuth } from '@/lib/AuthContext'
|
||||||
import { House, Settings, ArrowUp } from 'lucide-react'
|
import { SCHOLAR_PERSONAS, PERSONA_COLORS } from '@/lib/personas'
|
||||||
|
import type { CoachPersona } from '@/lib/personas'
|
||||||
|
|
||||||
/* ─── Types & Data ───────────────────────────────────────────── */
|
const PERSONA_ICONS: Record<CoachPersona, typeof Bot> = {
|
||||||
|
nurbuddy: Bot,
|
||||||
const MOODS = [
|
ghazali: Scroll,
|
||||||
{ emoji: '😊', label: 'Happy' },
|
ibnabbas: Crown,
|
||||||
{ emoji: '😌', label: 'Peaceful' },
|
rabia: Heart,
|
||||||
{ 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 { token, user } = useAuth()
|
const { user, token } = useAuth()
|
||||||
|
const [messages, setMessages] = useState<{ role: string; content: string; time?: string; metadata?: any }[]>([])
|
||||||
const [input, setInput] = useState('')
|
const [input, setInput] = useState('')
|
||||||
const [selectedMood, setSelectedMood] = useState<number | null>(null)
|
const [loading, setLoading] = useState(false)
|
||||||
const [messages, setMessages] = useState<Message[]>(EXAMPLE_MESSAGES)
|
const [showSettings, setShowSettings] = useState(false)
|
||||||
const [activePersona, setActivePersona] = useState('nurbuddy')
|
const [actionItems, setActionItems] = useState<string[]>([])
|
||||||
const [sending, setSending] = useState(false)
|
const [profile, setProfile] = useState({
|
||||||
|
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 scrollRef = useRef<HTMLDivElement>(null)
|
const [dailyLoading, setDailyLoading] = useState(false)
|
||||||
|
const dailyChecked = useRef(false)
|
||||||
|
|
||||||
// Restore mood from localStorage
|
const currentPersona = SCHOLAR_PERSONAS[profile.coachPersona] || SCHOLAR_PERSONAS.nurbuddy
|
||||||
|
const PersonaIcon = PERSONA_ICONS[profile.coachPersona] || Bot
|
||||||
|
|
||||||
|
// Load chat history when user is available
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const today = new Date().toISOString().slice(0, 10)
|
if (!user) return
|
||||||
const saved = localStorage.getItem(`flh_mood_${today}`)
|
setProfile(p => ({
|
||||||
if (saved) setSelectedMood(parseInt(saved))
|
...p,
|
||||||
}, [])
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-scroll to bottom on mount and when messages change
|
// Daily check-in — bot greets user warmly on new day
|
||||||
useEffect(() => {
|
if (!dailyChecked.current) {
|
||||||
chatEnd.current?.scrollIntoView({ behavior: 'smooth' })
|
dailyChecked.current = true
|
||||||
}, [messages])
|
checkDailyGreeting()
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
loadHistory()
|
||||||
|
}, [user, token])
|
||||||
|
|
||||||
const handleSend = useCallback(async () => {
|
const checkDailyGreeting = async () => {
|
||||||
if (!input.trim() || !token || !user || sending) return
|
if (!user || !token) return
|
||||||
const userMessage = input.trim()
|
setDailyLoading(true)
|
||||||
setInput('')
|
try {
|
||||||
setSending(true)
|
const res = await fetch('/api/chat/daily', {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
// Add user message to chat
|
useEffect(() => { chatEnd.current?.scrollIntoView({ behavior: 'smooth' }) }, [messages])
|
||||||
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: userMessage }),
|
body: JSON.stringify({ userId: user.id, message: msg }),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (data.response) {
|
if (res.ok) {
|
||||||
setMessages(prev => [...prev, { role: 'ai', content: data.response }])
|
setMessages(prev => [...prev, {
|
||||||
|
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, { role: 'ai', content: data.error || 'Sorry, I had trouble responding. Please try again.' }])
|
setMessages(prev => [...prev, {
|
||||||
|
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, { role: 'ai', content: 'Connection error. Please check your connection and try again.' }])
|
setMessages(prev => [...prev, {
|
||||||
} finally {
|
role: 'assistant',
|
||||||
setSending(false)
|
content: 'I\'m having trouble connecting right now. Please try again in a moment.',
|
||||||
|
time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
|
||||||
|
}])
|
||||||
|
}
|
||||||
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [input, token, user, sending])
|
|
||||||
|
|
||||||
const handlePersonaChange = useCallback(async (personaId: string) => {
|
const handleSaveProfile = async () => {
|
||||||
setActivePersona(personaId)
|
|
||||||
if (!token) return
|
if (!token) return
|
||||||
|
setSavingProfile(true)
|
||||||
try {
|
try {
|
||||||
await fetch('/api/auth/profile', {
|
const res = await fetch('/api/auth/profile', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
|
||||||
'Authorization': `Bearer ${token}`,
|
body: JSON.stringify(profile),
|
||||||
'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 */ }
|
||||||
}, [token])
|
setSavingProfile(false)
|
||||||
|
}
|
||||||
const handleMoodSelect = useCallback((idx: number | null) => {
|
|
||||||
setSelectedMood(idx)
|
const displayName = user?.preferredName || user?.name || 'Guest'
|
||||||
if (idx !== null) {
|
const levelLabel = {
|
||||||
const today = new Date().toISOString().slice(0, 10)
|
new: 'New to Islam',
|
||||||
localStorage.setItem(`flh_mood_${today}`, String(idx))
|
growing: 'Growing in Faith',
|
||||||
|
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="min-h-dvh bg-bg-deep flex flex-col pb-32 select-none">
|
<div className="max-w-3xl mx-auto p-4 sm:p-6 space-y-4">
|
||||||
{/* ═══ Header ═══ */}
|
{/* Header */}
|
||||||
<div className="shrink-0 flex items-center gap-3 px-5 pt-14 pb-3">
|
<div className="bg-gradient-to-r from-[#0a0a0f] to-[#1a1a2e] border border-[#D4AF37]/20 rounded-lg p-6">
|
||||||
{/* Avatar */}
|
<div className="flex items-start justify-between">
|
||||||
<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-1">
|
||||||
<span className="text-xl font-bold text-white">N</span>
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<PersonaIcon className={PERSONA_COLORS[profile.coachPersona]} size={24} />
|
||||||
|
<h2 className="text-2xl font-bold">{currentPersona.name}</h2>
|
||||||
</div>
|
</div>
|
||||||
{/* Name + online dot */}
|
<p className="text-sm text-gray-400 mb-1">{currentPersona.title}</p>
|
||||||
<div className="flex-1 min-w-0">
|
<p className="text-xs text-gray-500 mb-3 italic">{currentPersona.era}</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<h1 className="text-white font-bold text-base">NurBuddy</h1>
|
<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">
|
||||||
<span className="w-2 h-2 rounded-full bg-[#10B981] shrink-0 shadow-[0_0_6px_rgba(16,185,129,0.6)]" />
|
<User size={10} /> {displayName}
|
||||||
<span className="text-[10px] text-[#10B981] font-medium">Online</span>
|
</span>
|
||||||
</div>
|
<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">
|
||||||
<p className="text-[#999] text-xs truncate">Your Islamic AI Companion</p>
|
<Sparkles size={10} /> {levelLabel}
|
||||||
</div>
|
</span>
|
||||||
{/* Actions */}
|
<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">
|
||||||
<div className="flex items-center gap-1.5">
|
<BookOpen size={10} /> {madhabLabel}
|
||||||
<Link
|
</span>
|
||||||
href="/home"
|
|
||||||
className="w-9 h-9 rounded-xl glass-light flex items-center justify-center active:scale-90 transition-transform"
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="shrink-0 px-5 pb-3">
|
|
||||||
<div className="glass rounded-2xl px-4 py-3">
|
|
||||||
<p className="text-[11px] text-[#666] font-medium uppercase tracking-widest mb-2.5">
|
|
||||||
How are you feeling?
|
|
||||||
</p>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
{MOODS.map((mood, idx) => (
|
|
||||||
<button
|
<button
|
||||||
key={idx}
|
onClick={() => setShowSettings(!showSettings)}
|
||||||
onClick={() => handleMoodSelect(idx === selectedMood ? null : idx)}
|
className="p-2 rounded-lg bg-gray-800/50 hover:bg-gray-700 text-gray-400 hover:text-[#D4AF37] transition"
|
||||||
className={`w-12 h-12 rounded-full flex items-center justify-center text-2xl active:scale-90 transition-all ${
|
|
||||||
selectedMood === idx
|
|
||||||
? 'border-2 border-[#D4AF37] bg-[#D4AF37]/10 shadow-[0_0_12px_rgba(212,175,55,0.15)]'
|
|
||||||
: 'border-2 border-transparent opacity-60 hover:opacity-100'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{mood.emoji}
|
<Settings size={18} />
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ═══ Persona Switcher ═══ */}
|
{/* Settings Panel */}
|
||||||
<div className="shrink-0 px-5 pb-2">
|
{showSettings && (
|
||||||
<div className="flex gap-2 overflow-x-auto scrollbar-none -mx-5 px-5">
|
<div className="bg-[#111118] border border-gray-800 rounded-lg p-5 space-y-5">
|
||||||
{PERSONAS.map((p) => {
|
<div className="flex items-center justify-between">
|
||||||
const isActive = activePersona === p.id
|
<h3 className="text-sm font-semibold text-gray-200">Coach Profile</h3>
|
||||||
|
<button onClick={() => setShowSettings(false)} className="text-gray-500 hover:text-gray-300">
|
||||||
|
<X size={16} />
|
||||||
|
</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 (
|
return (
|
||||||
<button
|
<button
|
||||||
key={p.id}
|
key={personaId}
|
||||||
onClick={() => handlePersonaChange(p.id)}
|
onClick={() => setProfile(p => ({ ...p, coachPersona: personaId }))}
|
||||||
className={`shrink-0 rounded-full px-3.5 py-1.5 text-xs font-semibold active:scale-95 transition-all ${
|
className={`p-3 rounded-lg border text-left transition ${
|
||||||
isActive
|
isActive
|
||||||
? 'bg-[#D4AF37]/15 border border-[#D4AF37]/40 text-[#D4AF37]'
|
? 'border-[#D4AF37]/50 bg-[#D4AF37]/5'
|
||||||
: 'glass-light text-[#666]'
|
: 'border-gray-800 bg-[#0a0a0f] hover:border-gray-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{p.name}
|
<Icon size={18} className={`mb-1 ${isActive ? PERSONA_COLORS[personaId] : 'text-gray-500'}`} />
|
||||||
|
<p className={`text-xs font-semibold ${isActive ? 'text-gray-200' : 'text-gray-400'}`}>{persona.name}</p>
|
||||||
|
<p className="text-[10px] text-gray-500 mt-0.5 leading-tight">{persona.title}</p>
|
||||||
</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'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
@@ -1,5 +1,5 @@
|
|||||||
import { redirect } from 'next/navigation'
|
import { redirect } from 'next/navigation'
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
redirect('/home')
|
redirect('/souq')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,336 +0,0 @@
|
|||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+221
-198
@@ -1,236 +1,259 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation'
|
import { useState, useEffect } from 'react'
|
||||||
import Link from 'next/link'
|
|
||||||
import { useAuth } from '@/lib/AuthContext'
|
import { useAuth } from '@/lib/AuthContext'
|
||||||
import {
|
import { useRouter } from 'next/navigation'
|
||||||
ChevronRight,
|
import { User, Crown, Wallet, BookOpen, Sparkles, ShoppingBag, Shield, Target, Save, LogOut } from 'lucide-react'
|
||||||
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 { user, loading } = useAuth()
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [profile, setProfile] = useState({
|
||||||
|
preferredName: '',
|
||||||
|
experienceLevel: 'new',
|
||||||
|
madhab: 'unspecified',
|
||||||
|
coachPersona: 'nurbuddy',
|
||||||
|
})
|
||||||
|
|
||||||
const handleSignOut = () => {
|
useEffect(() => {
|
||||||
if (typeof window !== 'undefined') {
|
if (!authLoading && !token) router.push('/login')
|
||||||
localStorage.clear()
|
}, [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 */ }
|
||||||
}
|
}
|
||||||
router.push('/login')
|
}, [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)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
const tier = user?.isPro ? 'Pro' : user?.isPremium ? 'Premium' : 'Free'
|
||||||
return (
|
const tierColor = user?.isPro ? 'text-purple-400' : user?.isPremium ? 'text-[#D4AF37]' : 'text-gray-400'
|
||||||
<div className="min-h-dvh bg-[#0a0a0f] flex items-center justify-center">
|
const memberSince = user?.createdAt
|
||||||
<div className="w-8 h-8 rounded-full border-2 border-[#D4AF37]/30 border-t-[#D4AF37] animate-spin" />
|
? new Date(user.createdAt).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })
|
||||||
</div>
|
: 'N/A'
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const displayName = user?.name || 'Guest'
|
const experienceLabel = {
|
||||||
const initial = displayName.charAt(0).toUpperCase()
|
new: 'New to Islam',
|
||||||
const flhBalance = user?.flhBalance ?? 0
|
growing: 'Growing in Faith',
|
||||||
const usdValue = (flhBalance * 0.004).toFixed(2)
|
seasoned: 'Seasoned Muslim',
|
||||||
|
}[profile.experienceLevel] || 'New to Islam'
|
||||||
|
|
||||||
|
if (authLoading) {
|
||||||
|
return <ProfileSkeleton />
|
||||||
|
}
|
||||||
|
if (!token) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-dvh bg-[#0a0a0f] pb-32 select-none">
|
<div className="max-w-3xl mx-auto p-4 sm:p-6 space-y-6">
|
||||||
{/* Scrollable content */}
|
<h1 className="text-2xl font-bold text-[#D4AF37]">Profile</h1>
|
||||||
<div className="overflow-y-auto">
|
|
||||||
{/* ═══ Profile Header ═══ */}
|
{/* Account Info Card */}
|
||||||
<div className="px-5 pt-14 pb-6">
|
<div className="bg-[#111118] border border-gray-800 rounded-lg p-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{/* Avatar */}
|
<div className="w-16 h-16 rounded-full bg-[#D4AF37]/10 border border-[#D4AF37]/30 flex items-center justify-center">
|
||||||
<div className="w-[60px] h-[60px] rounded-full bg-[#D4AF37]/20 border-2 border-[#D4AF37]/30 flex items-center justify-center shrink-0">
|
<User size={28} className="text-[#D4AF37]" />
|
||||||
<span className="text-xl font-bold text-[#D4AF37]">{initial}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 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>
|
|
||||||
|
|
||||||
{/* ═══ 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>
|
<div>
|
||||||
<p className="text-[11px] text-[#666] font-medium uppercase tracking-widest mb-0.5">
|
<h2 className="text-lg font-bold">{user?.name || 'User'}</h2>
|
||||||
FLH Wallet
|
<p className="text-sm text-gray-400">{user?.email || ''}</p>
|
||||||
</p>
|
<p className="text-xs text-gray-500 mt-1">Member since {memberSince}</p>
|
||||||
<div className="flex items-baseline gap-2">
|
|
||||||
<span className="text-xl font-bold text-[#D4AF37]">
|
|
||||||
{flhBalance.toLocaleString()}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-[#999]">≈ ${usdValue} USD</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ChevronRight size={18} className="text-[#D4AF37]" />
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ═══ Quick Action Grid (2x2) ═══ */}
|
{/* Stats Row */}
|
||||||
<div className="mx-5 mb-5">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
<p className="text-[11px] text-[#666] font-medium uppercase tracking-widest mb-3">
|
<div className="bg-[#111118] border border-gray-800 rounded-lg p-4">
|
||||||
Quick Actions
|
<Wallet size={18} className="text-[#D4AF37] mb-2" />
|
||||||
</p>
|
<p className="text-xl font-bold">{user?.flhBalance?.toLocaleString() || 0}</p>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<p className="text-xs text-gray-500">FLH Balance</p>
|
||||||
{QUICK_ACTIONS.map((action) => {
|
</div>
|
||||||
const Icon = action.icon
|
<div className="bg-[#111118] border border-gray-800 rounded-lg p-4">
|
||||||
return (
|
<Crown size={18} className={`mb-2 ${tierColor}`} />
|
||||||
<Link
|
<p className={`text-xl font-bold ${tierColor}`}>{tier}</p>
|
||||||
key={action.href}
|
<p className="text-xs text-gray-500">Tier</p>
|
||||||
href={action.href}
|
</div>
|
||||||
className="bg-[#111118] rounded-2xl border border-[#222] p-4 active:scale-[0.96] transition-transform"
|
<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"
|
||||||
>
|
>
|
||||||
<div className="w-10 h-10 rounded-xl bg-[#D4AF37]/10 flex items-center justify-center mb-3">
|
<option value="new">New to Islam</option>
|
||||||
<Icon size={20} className="text-[#D4AF37]" />
|
<option value="growing">Growing in Faith</option>
|
||||||
|
<option value="seasoned">Seasoned Muslim</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-white text-sm font-semibold">{action.label}</p>
|
<div>
|
||||||
</Link>
|
<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'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>
|
||||||
</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
|
<button
|
||||||
key={idx}
|
onClick={handleSave}
|
||||||
className="w-full flex items-center gap-3 px-4 py-4 active:bg-[#1a1a24] transition-colors text-left"
|
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"
|
||||||
>
|
>
|
||||||
<div className="w-8 h-8 rounded-lg bg-[#0a0a0f] border border-[#222] flex items-center justify-center shrink-0">
|
<Save size={16} /> {saving ? 'Saving...' : 'Save Changes'}
|
||||||
<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>
|
</button>
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ═══ Sign Out / Login ═══ */}
|
{/* Upgrade CTA */}
|
||||||
<div className="mx-5 mb-6">
|
{!user?.isPremium && !user?.isPro && (
|
||||||
{!user ? (
|
<div className="bg-gradient-to-r from-[#D4AF37]/10 to-[#0a0a0f] border border-[#D4AF37]/20 rounded-lg p-6">
|
||||||
<Link
|
<div className="flex items-center gap-3 mb-3">
|
||||||
href="/login"
|
<Crown size={24} className="text-[#D4AF37]" />
|
||||||
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"
|
<div>
|
||||||
>
|
<h2 className="font-bold text-[#D4AF37]">Go Premium</h2>
|
||||||
Login
|
<p className="text-sm text-gray-400">Unlock exclusive features and content</p>
|
||||||
</Link>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleSignOut}
|
onClick={() => router.push('/upgrade')}
|
||||||
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"
|
className="bg-[#D4AF37] text-[#0a0a0f] px-5 py-2 rounded text-sm font-semibold hover:bg-[#C9A84C] transition"
|
||||||
>
|
>
|
||||||
<LogOut size={16} className="text-red-400" />
|
Upgrade Now
|
||||||
<span className="text-sm font-semibold text-red-400">Logout</span>
|
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bottom spacer */}
|
{/* Sign Out */}
|
||||||
<div className="h-4" />
|
<button
|
||||||
</div>
|
onClick={() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.removeItem('flh_token')
|
||||||
|
localStorage.removeItem('flh_user')
|
||||||
|
}
|
||||||
|
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() {
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto p-4 sm:p-6 space-y-6 animate-pulse">
|
||||||
|
<div className="h-8 w-24 bg-gray-800 rounded" />
|
||||||
|
<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-gray-800" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="h-5 w-32 bg-gray-800 rounded" />
|
||||||
|
<div className="h-4 w-48 bg-gray-800 rounded" />
|
||||||
|
<div className="h-3 w-28 bg-gray-800 rounded" />
|
||||||
|
</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>
|
||||||
|
<div className="bg-[#111118] border border-gray-800 rounded-lg p-6 space-y-4">
|
||||||
|
<div className="h-5 w-28 bg-gray-800 rounded" />
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
{[...Array(4)].map((_, i) => (
|
||||||
|
<div key={i} className="space-y-2">
|
||||||
|
<div className="h-3 w-24 bg-gray-800 rounded" />
|
||||||
|
<div className="h-9 w-full bg-gray-800 rounded" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="h-9 w-32 bg-gray-800 rounded" />
|
||||||
|
</div>
|
||||||
|
<div className="h-12 w-full bg-gray-800 rounded-lg" />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-98
@@ -4,15 +4,13 @@ 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 [name, setName] = useState('')
|
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
|
const [name, setName] = 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)
|
||||||
|
|
||||||
@@ -20,107 +18,30 @@ export default function RegisterPage() {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError('')
|
setError('')
|
||||||
try {
|
try { await register(email, name, password); router.push('/souq') }
|
||||||
await register(email, name, password)
|
catch (err: any) { setError(err.message) }
|
||||||
router.push('/nur')
|
finally { setLoading(false) }
|
||||||
} catch (err: any) {
|
|
||||||
setError(err.message || 'Something went wrong')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-dvh flex flex-col bg-bg-deep px-6">
|
<div className="max-w-sm mx-auto mt-20 p-6 space-y-4">
|
||||||
{/* Brand */}
|
<h1 className="text-2xl font-bold text-center text-[#D4AF37]">Join Falah</h1>
|
||||||
<div className="flex flex-col items-center pt-12 pb-8">
|
<form onSubmit={handleSubmit} className="space-y-3">
|
||||||
<div className="w-20 h-20 rounded-3xl gold-gradient flex items-center justify-center mb-5 shadow-lg shadow-gold/20">
|
<input type="text" placeholder="Name" value={name} onChange={e => setName(e.target.value)} required
|
||||||
<span className="text-3xl font-bold text-bg-deep">ف</span>
|
className="w-full bg-[#111118] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none" />
|
||||||
</div>
|
<input type="email" placeholder="Email" value={email} onChange={e => setEmail(e.target.value)} required
|
||||||
<h1 className="text-2xl font-bold text-white tracking-tight">Join Falah</h1>
|
className="w-full bg-[#111118] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none" />
|
||||||
<p className="text-text-secondary text-sm mt-1">Your Islamic lifestyle companion</p>
|
<input type="password" placeholder="Password" value={password} onChange={e => setPassword(e.target.value)} required
|
||||||
<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">
|
className="w-full bg-[#111118] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none" />
|
||||||
<Sparkles size={13} className="text-emerald-400" />
|
{error && <p className="text-red-400 text-xs">{error}</p>}
|
||||||
<span className="text-xs text-emerald-400 font-semibold">7-day Premium trial — free</span>
|
<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">
|
||||||
</div>
|
{loading ? 'Creating account...' : 'Create Account'}
|
||||||
|
|
||||||
{/* 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">
|
||||||
<div className="py-8 space-y-3 text-center">
|
Have an account? <Link href="/login" className="text-[#D4AF37] hover:underline">Sign in</Link>
|
||||||
<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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+88
-230
@@ -1,47 +1,29 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useAuth } from '@/lib/AuthContext'
|
import { useAuth } from '@/lib/AuthContext'
|
||||||
import { Search, Plus, ShoppingCart, Zap, X, ChevronRight, Package } from 'lucide-react'
|
import { Search, Plus, ShoppingCart, Zap } from 'lucide-react'
|
||||||
|
|
||||||
interface Listing {
|
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 }
|
||||||
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 } = useAuth()
|
const { token, user, loading: authLoading } = 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 {
|
try { const res = await fetch('/api/marketplace/listings'); const data = await res.json(); setListings(data.listings || []) }
|
||||||
const res = await fetch('/api/marketplace/listings')
|
catch (e) { console.error(e) } finally { setLoading(false) }
|
||||||
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
|
||||||
@@ -50,190 +32,93 @@ 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', {
|
const res = await fetch('/api/marketplace/purchase', { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ listingId }) })
|
||||||
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(); showToast('Purchase successful!'); setSelectedListing(null) }
|
if (res.ok) { fetchListings() } else { alert(data.error) }
|
||||||
else showToast(data.error || 'Purchase failed')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
if (authLoading) return <div className="p-8 text-center text-gray-500">Loading...</div>
|
||||||
<div className="min-h-dvh bg-bg-deep pb-32 select-none text-white">
|
|
||||||
|
|
||||||
{/* Header */}
|
return (
|
||||||
<div className="px-5 pt-14 pb-4">
|
<div className="max-w-6xl mx-auto p-4 sm:p-6 space-y-6">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
<div>
|
<h1 className="text-2xl font-bold text-[#D4AF37]">Souq</h1>
|
||||||
<h1 className="text-xl font-bold text-white">Souq</h1>
|
<div className="flex gap-2">
|
||||||
<p className="text-[11px] text-text-secondary mt-0.5">Islamic digital marketplace</p>
|
<div className="relative flex-1 sm:flex-none">
|
||||||
|
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
|
||||||
|
<input type="text" placeholder="Search..." value={search} onChange={e => setSearch(e.target.value)}
|
||||||
|
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" />
|
||||||
</div>
|
</div>
|
||||||
{token && (
|
{token && (
|
||||||
<button
|
<button onClick={() => setShowCreate(true)}
|
||||||
onClick={() => setShowCreate(true)}
|
className="flex items-center gap-1 bg-[#D4AF37] text-[#0a0a0f] px-3 py-2 rounded text-sm font-semibold hover:bg-[#C9A84C] transition">
|
||||||
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} /> Create
|
||||||
>
|
|
||||||
<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>
|
<div className="flex gap-2 overflow-x-auto pb-2">
|
||||||
|
|
||||||
{/* 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
|
<button key={c} onClick={() => setCategory(c)}
|
||||||
key={c}
|
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>
|
||||||
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 ? (
|
||||||
{/* Loading */}
|
<div className="text-center text-gray-500 py-12">Loading listings...</div>
|
||||||
{loading && (
|
) : filtered.length === 0 ? (
|
||||||
<div className="px-5 pt-4 space-y-3">
|
<div className="text-center text-gray-500 py-12">No listings found</div>
|
||||||
{[...Array(4)].map((_, i) => (
|
|
||||||
<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 => (
|
|
||||||
<button
|
|
||||||
key={l.id}
|
|
||||||
onClick={() => setSelectedListing(l)}
|
|
||||||
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 className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<h3 className="font-semibold text-white text-sm leading-tight line-clamp-1">{l.title}</h3>
|
|
||||||
{l.featured && <Zap size={13} className="text-[#D4AF37] shrink-0 mt-0.5" />}
|
|
||||||
</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>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</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 && (
|
|
||||||
<div className="fixed inset-0 z-50 flex flex-col justify-end" onClick={() => setSelectedListing(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-[80dvh] 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">
|
|
||||||
{CATEGORY_ICONS[selectedListing.category] || '📦'}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h2 className="text-lg font-bold text-white leading-tight">{selectedListing.title}</h2>
|
|
||||||
<p className="text-xs text-text-muted mt-0.5">by {selectedListing.seller}</p>
|
|
||||||
</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>
|
|
||||||
{user && <p className="text-xs text-text-muted">Your balance: {user.flhBalance?.toLocaleString() || 0} FLH</p>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{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">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
Sign in to purchase
|
{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)}>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<span className="text-xs text-gray-500 bg-gray-800 px-2 py-0.5 rounded">{l.category}</span>
|
||||||
|
{l.featured && <Zap size={14} className="text-[#D4AF37]" />}
|
||||||
|
</div>
|
||||||
|
<h3 className="font-semibold">{l.title}</h3>
|
||||||
|
<p className="text-sm text-gray-400 line-clamp-2">{l.description}</p>
|
||||||
|
<div className="flex items-center justify-between pt-2">
|
||||||
|
<span className="text-[#D4AF37] font-bold">{l.price_flh.toLocaleString()} FLH</span>
|
||||||
|
<span className="text-xs text-gray-500">{l.seller}</span>
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedListing && (
|
||||||
|
<div className="fixed inset-0 bg-black/70 flex items-center justify-center p-4 z-50" 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="flex items-start justify-between">
|
||||||
|
<h2 className="text-xl font-bold">{selectedListing.title}</h2>
|
||||||
|
<button onClick={() => setSelectedListing(null)} className="text-gray-500 hover:text-white">✕</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-400">{selectedListing.description}</p>
|
||||||
|
<div className="flex gap-2 text-sm">
|
||||||
|
<span className="bg-gray-800 px-2 py-0.5 rounded">{selectedListing.category}</span>
|
||||||
|
<span className="text-gray-500">by {selectedListing.seller}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-[#D4AF37]">{selectedListing.price_flh.toLocaleString()} FLH</p>
|
||||||
|
{token && selectedListing.sellerId !== user?.id && (
|
||||||
|
<button onClick={() => { handlePurchase(selectedListing.id); setSelectedListing(null) }}
|
||||||
|
className="w-full bg-[#D4AF37] text-[#0a0a0f] py-2 rounded font-semibold hover:bg-[#C9A84C] transition">
|
||||||
|
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 CreateListingSheet({ token, onClose, onCreated, showToast }: {
|
function CreateListingModal({ token, onClose, onCreated }: { token: string; userId: string; onClose: () => void; onCreated: () => void }) {
|
||||||
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')
|
||||||
@@ -243,58 +128,31 @@ function CreateListingSheet({ token, onClose, onCreated, showToast }: {
|
|||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
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 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 z-50 flex flex-col justify-end" onClick={onClose}>
|
<div className="fixed inset-0 bg-black/70 flex items-center justify-center p-4 z-50">
|
||||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
|
<form onSubmit={handleSubmit} className="bg-[#111118] border border-gray-800 rounded-lg p-6 max-w-md w-full space-y-4">
|
||||||
<form
|
<h2 className="text-lg font-bold">Create Listing</h2>
|
||||||
onSubmit={handleSubmit}
|
<input type="text" placeholder="Title" value={title} onChange={e => setTitle(e.target.value)} required
|
||||||
className="relative bg-bg-card border-t border-x border-border rounded-t-3xl p-6 pb-10 space-y-4"
|
className="w-full bg-[#0a0a0f] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none" />
|
||||||
onClick={e => e.stopPropagation()}
|
<textarea placeholder="Description" value={description} onChange={e => setDescription(e.target.value)} required rows={3}
|
||||||
>
|
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 mb-2" />
|
<select value={category} onChange={e => setCategory(e.target.value)}
|
||||||
<h2 className="text-lg font-bold text-white">New Listing</h2>
|
className="w-full bg-[#0a0a0f] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none">
|
||||||
|
{['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
|
<input type="number" placeholder="Price (FLH)" value={priceFlh} onChange={e => setPriceFlh(e.target.value)} required min={1}
|
||||||
type="number" placeholder="Price in FLH" value={priceFlh} onChange={e => setPriceFlh(e.target.value)} required min={1}
|
className="w-full bg-[#0a0a0f] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none" />
|
||||||
inputMode="numeric"
|
<button type="submit" disabled={loading}
|
||||||
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"
|
className="w-full bg-[#D4AF37] text-[#0a0a0f] py-2 rounded font-semibold disabled:opacity-50 hover:bg-[#C9A84C] transition">
|
||||||
/>
|
{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>
|
||||||
)
|
)
|
||||||
|
|||||||
+36
-83
@@ -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, ChevronLeft } from 'lucide-react'
|
import { Crown, Zap, Check, Loader2 } from 'lucide-react'
|
||||||
|
|
||||||
const TIERS = [
|
const TIERS = [
|
||||||
{
|
{
|
||||||
@@ -11,18 +11,16 @@ const TIERS = [
|
|||||||
price: '$0',
|
price: '$0',
|
||||||
period: 'forever',
|
period: 'forever',
|
||||||
priceId: null,
|
priceId: null,
|
||||||
href: '/nur',
|
|
||||||
features: [
|
features: [
|
||||||
'Nur AI coaching (10 msg/day)',
|
'Access to Souq marketplace',
|
||||||
'Souq marketplace',
|
'Nur AI coaching (basic)',
|
||||||
'Forum access',
|
'Forum access',
|
||||||
'FLH wallet & cashouts',
|
'FLH wallet & cashouts',
|
||||||
],
|
],
|
||||||
cta: 'Continue Free',
|
cta: 'Get Started',
|
||||||
|
href: '/souq',
|
||||||
highlighted: false,
|
highlighted: false,
|
||||||
icon: Zap,
|
icon: Zap,
|
||||||
iconColor: 'text-gray-400',
|
|
||||||
badge: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Premium',
|
name: 'Premium',
|
||||||
@@ -30,16 +28,14 @@ const TIERS = [
|
|||||||
period: '/month',
|
period: '/month',
|
||||||
priceId: 'price_premium_monthly',
|
priceId: 'price_premium_monthly',
|
||||||
features: [
|
features: [
|
||||||
'Unlimited Nur AI coaching',
|
'Everything in Free',
|
||||||
|
'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: 'Start Premium',
|
cta: 'Subscribe',
|
||||||
highlighted: true,
|
highlighted: true,
|
||||||
icon: Crown,
|
icon: Crown,
|
||||||
iconColor: 'text-[#D4AF37]',
|
|
||||||
badge: 'MOST POPULAR',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Pro',
|
name: 'Pro',
|
||||||
@@ -52,11 +48,9 @@ const TIERS = [
|
|||||||
'Custom integrations',
|
'Custom integrations',
|
||||||
'Direct line to the team',
|
'Direct line to the team',
|
||||||
],
|
],
|
||||||
cta: 'Go Pro',
|
cta: 'Subscribe',
|
||||||
highlighted: false,
|
highlighted: false,
|
||||||
icon: Crown,
|
icon: Crown,
|
||||||
iconColor: 'text-purple-500',
|
|
||||||
badge: null,
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -76,7 +70,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.')
|
alert('Checkout canceled. Please try again if you changed your mind.')
|
||||||
router.replace('/upgrade')
|
router.replace('/upgrade')
|
||||||
}
|
}
|
||||||
}, [searchParams, router])
|
}, [searchParams, router])
|
||||||
@@ -100,93 +94,60 @@ function UpgradeContent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authLoading) return (
|
if (authLoading) return <div className="p-8 text-center text-gray-500">Loading...</div>
|
||||||
<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="min-h-dvh bg-bg-deep pb-32 text-white">
|
<div className="max-w-6xl mx-auto p-4 sm:p-6 space-y-8">
|
||||||
|
<div className="text-center space-y-2">
|
||||||
{/* Gradient header */}
|
<h1 className="text-3xl font-bold text-[#D4AF37]">Upgrade Your Experience</h1>
|
||||||
<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">
|
<p className="text-gray-400">Choose the plan that fits your journey</p>
|
||||||
<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 bg-bg-card rounded-3xl shadow-lg border p-5 ${
|
className={`relative rounded-xl p-6 space-y-5 transition ${
|
||||||
tier.highlighted
|
tier.highlighted
|
||||||
? 'border-gold/60 shadow-gold/5'
|
? 'bg-gradient-to-b from-[#D4AF37]/10 to-[#111118] border-2 border-[#D4AF37] shadow-lg shadow-[#D4AF37]/5 scale-105'
|
||||||
: 'border-border'
|
: 'bg-[#111118] border border-gray-800 hover:border-gray-700'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{tier.badge && (
|
{tier.highlighted && (
|
||||||
<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">
|
<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">
|
||||||
{tier.badge}
|
BEST VALUE
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="text-center space-y-2">
|
||||||
<div className="flex items-start justify-between mb-4">
|
<Icon size={32} className={tier.highlighted ? 'text-[#D4AF37] mx-auto' : 'text-gray-500 mx-auto'} />
|
||||||
<div className="flex items-center gap-3">
|
<h2 className="text-xl font-bold">{tier.name}</h2>
|
||||||
<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>
|
||||||
<h2 className="font-bold text-white">{tier.name}</h2>
|
<span className="text-3xl font-bold">{tier.price}</span>
|
||||||
<div className="flex items-baseline gap-0.5">
|
<span className="text-gray-500 text-sm">{tier.period}</span>
|
||||||
<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>
|
||||||
</div>
|
<ul className="space-y-3">
|
||||||
</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.5">
|
<li key={f} className="flex items-start gap-2 text-sm text-gray-300">
|
||||||
<Check size={14} className="text-gold mt-0.5 shrink-0" />
|
<Check size={16} className="text-[#D4AF37] mt-0.5 shrink-0" />
|
||||||
<span className="text-sm text-text-secondary">{f}</span>
|
<span>{f}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
{tier.priceId ? (
|
{tier.priceId ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleSubscribe(tier.priceId!)}
|
onClick={() => handleSubscribe(tier.priceId!)}
|
||||||
className={`w-full py-4 rounded-2xl font-bold text-sm active:scale-[0.97] transition-all cursor-pointer ${
|
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"
|
||||||
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 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"
|
className="w-full border border-gray-700 text-gray-300 py-2.5 rounded-lg font-semibold hover:bg-gray-800 transition"
|
||||||
>
|
>
|
||||||
{tier.cta}
|
{tier.cta}
|
||||||
</button>
|
</button>
|
||||||
@@ -195,21 +156,13 @@ 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={
|
<Suspense fallback={<div className="p-8 text-center text-gray-500">Loading...</div>}>
|
||||||
<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>
|
||||||
)
|
)
|
||||||
|
|||||||
+28
-125
@@ -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,151 +11,54 @@ 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(() => {
|
useEffect(() => { if (!token) return; fetch('/api/wallet', { headers: { 'Authorization': `Bearer ${token}` } }).then(r => r.json()).then(d => setCashouts(d.requests || [])).catch(() => {}) }, [token])
|
||||||
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 || parseInt(amount) < 100) return
|
if (!token || !amount) return
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const res = await fetch('/api/wallet', {
|
const res = await fetch('/api/wallet', { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ amountFlh: parseInt(amount) }) })
|
||||||
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) {
|
if (res.ok) { setAmount(''); alert('Cashout submitted!') } else { alert(data.error) }
|
||||||
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 (
|
if (authLoading) return <div className="p-8 text-center text-gray-500">Loading...</div>
|
||||||
<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="min-h-dvh bg-bg-deep pb-32 text-white">
|
<div className="max-w-3xl mx-auto p-4 sm:p-6 space-y-6">
|
||||||
|
<h1 className="text-2xl font-bold text-[#D4AF37]">Wallet</h1>
|
||||||
{/* Header */}
|
<div className="bg-gradient-to-r from-[#D4AF37]/10 to-[#0a0a0f] border border-[#D4AF37]/20 rounded-lg p-6">
|
||||||
<div className="px-5 pt-14 pb-4">
|
<p className="text-sm text-gray-400 mb-1">Balance</p>
|
||||||
<h1 className="text-xl font-bold text-white">Wallet</h1>
|
<p className="text-4xl font-bold text-[#D4AF37]">{user?.flhBalance?.toLocaleString() || 0} <span className="text-lg text-gray-500">FLH</span></p>
|
||||||
<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">
|
||||||
{/* Balance card */}
|
<h2 className="font-semibold flex items-center gap-2"><ArrowUpRight size={16} className="text-[#D4AF37]" /> Cash Out</h2>
|
||||||
<div className="mx-5 mb-5 rounded-3xl overflow-hidden shadow-lg glass border border-gold/30 relative">
|
<p className="text-xs text-gray-500">Rate: 100 FLH = £0.80 (20% platform spread). Minimum 100 FLH.</p>
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-gold/10 to-transparent opacity-60 pointer-events-none" />
|
<div className="flex gap-2">
|
||||||
<div className="px-6 py-6 relative z-10">
|
<input type="number" placeholder="Amount (FLH)" value={amount} onChange={e => setAmount(e.target.value)} min={100}
|
||||||
<div className="flex items-center gap-2 mb-4">
|
className="flex-1 bg-[#0a0a0f] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none" />
|
||||||
<Wallet size={16} className="text-gold" />
|
<button onClick={handleCashout} disabled={loading || !amount || parseInt(amount) < 100}
|
||||||
<span className="text-xs text-text-secondary font-semibold uppercase tracking-wider">FLH Balance</span>
|
className="bg-[#D4AF37] text-[#0a0a0f] px-4 py-2 rounded text-sm font-semibold disabled:opacity-50 hover:bg-[#C9A84C] transition">
|
||||||
</div>
|
{loading ? 'Processing...' : 'Cash Out'}
|
||||||
<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>
|
||||||
{amount && parseInt(amount) < 100 && (
|
|
||||||
<p className="text-xs text-red-400 text-center">Minimum 100 FLH</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="bg-[#111118] border border-gray-800 rounded-lg p-6 space-y-3">
|
||||||
{/* History */}
|
<h2 className="font-semibold flex items-center gap-2"><History size={16} className="text-[#D4AF37]" /> History</h2>
|
||||||
<div className="mx-5 glass rounded-3xl p-5">
|
|
||||||
<h2 className="font-bold text-white text-sm mb-4">History</h2>
|
|
||||||
{cashouts.length === 0 ? (
|
{cashouts.length === 0 ? (
|
||||||
<div className="text-center py-6">
|
<p className="text-sm text-gray-500">No cashout requests yet</p>
|
||||||
<p className="text-text-muted text-sm">No cashout requests yet</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
cashouts.map(c => (
|
||||||
{cashouts.map(c => (
|
<div key={c.id} className="flex items-center justify-between text-sm">
|
||||||
<div key={c.id} className="flex items-center gap-3 py-2 border-b border-border/40 last:border-b-0">
|
<span className="text-gray-400">{c.amountFlh} FLH → £{c.fiatAmount.toFixed(2)}</span>
|
||||||
<div className="w-9 h-9 rounded-full bg-bg-deep border border-border flex items-center justify-center shrink-0">
|
<span className={`text-xs ${c.status === 'pending' ? 'text-yellow-400' : 'text-green-400'}`}>{c.status}</span>
|
||||||
{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>
|
||||||
|
))
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,272 +0,0 @@
|
|||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,2 +1,59 @@
|
|||||||
// Replaced by BottomNav — kept for import compatibility
|
'use client'
|
||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
+1
-10
@@ -1,10 +1,7 @@
|
|||||||
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
|
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-fallback'
|
||||||
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 {
|
||||||
@@ -23,12 +20,6 @@ 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)
|
||||||
|
|||||||
@@ -1,272 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
+3
-38
@@ -1,42 +1,7 @@
|
|||||||
import { PrismaClient } from '@prisma/client'
|
import { PrismaClient } from '@prisma/client'
|
||||||
import fs from 'fs'
|
|
||||||
import path from 'path'
|
|
||||||
|
|
||||||
let prisma: PrismaClient
|
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }
|
||||||
|
|
||||||
if (process.env.LAMBDA_TASK_ROOT || process.env.NETLIFY) {
|
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
|
||||||
const targetDbPath = '/tmp/dev.db'
|
|
||||||
|
|
||||||
if (!fs.existsSync(targetDbPath)) {
|
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
|
||||||
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 }
|
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
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()
|
|
||||||
+3
-11
@@ -30,20 +30,12 @@
|
|||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"next-env.d.ts",
|
"next-env.d.ts",
|
||||||
"src/**/*.ts",
|
"**/*.ts",
|
||||||
"src/**/*.tsx",
|
"**/*.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
@@ -1,16 +0,0 @@
|
|||||||
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 +0,0 @@
|
|||||||
import '@testing-library/jest-dom/vitest'
|
|
||||||
Reference in New Issue
Block a user