feat: micro-learning module with seed data
- Add Course and Module models to Prisma schema - Create seed-learn.json with Daily Fiqh for Beginners (5 modules) - Create seed-learn.js with Hermes patch (strip id/courseId from create) - Add /learn page with course listing - Add /learn/[courseSlug]/[moduleId] page with content + quiz - Quiz data stored as JSON string per module - Content rendered as markdown with Key Concept, Details, Reflection, Action Step sections TODO: - Add audio player component for listen toggle - Add progress tracking per user - Add actual quiz scoring/validation - Connect to TTS pipeline for audio generation
This commit is contained in:
@@ -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
|
||||||
|
```
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
*.db
|
||||||
|
.env
|
||||||
|
.git
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# Falah OS v1.3 — Environment Variables
|
||||||
|
# Copy to .env and fill in all values before running.
|
||||||
|
# Generate secrets with: openssl rand -base64 32
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# REQUIRED — must be set before starting
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# JWT signing secret — min 32 random characters
|
||||||
|
JWT_SECRET=
|
||||||
|
|
||||||
|
# Encryption key for sensitive data — min 32 random characters
|
||||||
|
ENCRYPTION_KEY=
|
||||||
|
|
||||||
|
# Admin secret for privileged operations
|
||||||
|
ADMIN_SECRET=
|
||||||
|
|
||||||
|
# PostgreSQL password
|
||||||
|
POSTGRES_PASSWORD=
|
||||||
|
|
||||||
|
# Redis password
|
||||||
|
REDIS_PASSWORD=
|
||||||
|
|
||||||
|
# iBaaS API Key for EE Gateway
|
||||||
|
IBAAS_API_KEY=falah-os-ibaas-key-2026
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# OPTIONAL — have sensible defaults
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
NODE_ENV=production
|
||||||
|
|
||||||
|
# Domain name and protocol for URL generation
|
||||||
|
DOMAIN=
|
||||||
|
PROTOCOL=https
|
||||||
|
|
||||||
|
# Protocol fee taken by Falah OS (default 1.5%)
|
||||||
|
PROTOCOL_FEE_PERCENT=1.5
|
||||||
|
|
||||||
|
# Shariah rules enforced by RAMZ (comma-separated, no spaces)
|
||||||
|
SHARIAH_RULES=NO_RIBA,NO_GHARAR,NO_MAYSIR,HALAL_COMMODITY,ASSET_BACKED,MUTUAL_CONSENT,NO_BAI_INAH
|
||||||
|
|
||||||
|
# Set to true only in a controlled test environment
|
||||||
|
ENABLE_CHAOS_TESTING=false
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# LOGGING (optional)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
LOG_LEVEL=info
|
||||||
|
LOG_FORMAT=json
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# POSTGRES (optional overrides)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
POSTGRES_USER=postgres
|
||||||
|
POSTGRES_HOST=postgres
|
||||||
|
POSTGRES_PORT=5432
|
||||||
|
# Derived from the above; override per-service database name as needed
|
||||||
|
DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD}@postgres:5432/falahdb
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# REDIS (optional overrides)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
REDIS_HOST=redis
|
||||||
|
REDIS_PORT=6379
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# FEATURE FLAGS (optional)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
ENABLE_MOCKNET=false
|
||||||
|
ENABLE_DEBUG=false
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# MONITORING (optional)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
GRAFANA_PASSWORD=
|
||||||
|
ALERT_EMAIL=
|
||||||
|
SLACK_WEBHOOK_URL=
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# BACKUP (optional)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
BACKUP_DIR=/backups
|
||||||
|
S3_BUCKET=
|
||||||
|
RETENTION_DAYS=30
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# Falah OS v1.3 Production Environment Template
|
||||||
|
# Copy this to .env and fill in the values
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# CORE CONFIGURATION
|
||||||
|
# =============================================================================
|
||||||
|
NODE_ENV=production
|
||||||
|
DOMAIN=falah-os.com
|
||||||
|
PROTOCOL=https
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# SECURITY - GENERATE WITH: openssl rand -base64 32
|
||||||
|
# =============================================================================
|
||||||
|
JWT_SECRET=CHANGE_ME_generate_secure_random_string
|
||||||
|
ENCRYPTION_KEY=CHANGE_ME_generate_secure_random_string
|
||||||
|
ADMIN_SECRET=CHANGE_ME_generate_secure_random_string
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# DATABASE - UPDATE credentials for production
|
||||||
|
# =============================================================================
|
||||||
|
POSTGRES_HOST=postgres-primary
|
||||||
|
POSTGRES_PORT=5432
|
||||||
|
POSTGRES_USER=postgres
|
||||||
|
POSTGRES_PASSWORD=CHANGE_ME_strong_password
|
||||||
|
POSTGRES_DB=falahdb
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# REDIS - UPDATE password for production
|
||||||
|
# =============================================================================
|
||||||
|
REDIS_HOST=redis-master
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_PASSWORD=CHANGE_ME_strong_password
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# SERVICE URLs (Internal)
|
||||||
|
# =============================================================================
|
||||||
|
UMMAHID_URL=http://ummahid:3000
|
||||||
|
WALLET_URL=http://wallet:3000
|
||||||
|
RAMZ_URL=http://ramz:3000
|
||||||
|
MOCKNET_URL=http://mocknet:3000
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# WALLET SERVICE
|
||||||
|
# =============================================================================
|
||||||
|
PROTOCOL_FEE_PERCENT=1.5
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# RAMZ CONTRACT ENGINE
|
||||||
|
# =============================================================================
|
||||||
|
SHARIAH_RULES=NO_RIBA,NO_GHARAR,NO_MAYSIR,HALAL_COMMODITY,ASSET_BACKED,MUTUAL_CONSENT,NO_BAI_INAH
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# BACKUP CONFIGURATION
|
||||||
|
# =============================================================================
|
||||||
|
BACKUP_DIR=/backups
|
||||||
|
S3_BUCKET=falah-os-backups
|
||||||
|
RETENTION_DAYS=30
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# MONITORING
|
||||||
|
# =============================================================================
|
||||||
|
GRAFANA_PASSWORD=CHANGE_ME_strong_password
|
||||||
|
ALERT_EMAIL=alerts@falah-os.com
|
||||||
|
SLACK_WEBHOOK_URL=
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# CDN & EXTERNAL SERVICES
|
||||||
|
# =============================================================================
|
||||||
|
CDN_API_KEY=
|
||||||
|
CDN_ZONE_ID=
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# LOGGING
|
||||||
|
# =============================================================================
|
||||||
|
LOG_LEVEL=info
|
||||||
|
LOG_FORMAT=json
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# FEATURE FLAGS
|
||||||
|
# =============================================================================
|
||||||
|
ENABLE_MOCKNET=false
|
||||||
|
ENABLE_CHAOS_TESTING=false
|
||||||
|
ENABLE_DEBUG=false
|
||||||
@@ -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 }}
|
||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
package-lock.json
|
||||||
|
.opencode/memories.md
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist/
|
||||||
|
.next/
|
||||||
|
.netlify/
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.production
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# Secrets (DO NOT COMMIT)
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.crt
|
||||||
|
*.p12
|
||||||
|
|
||||||
|
# Backup files
|
||||||
|
*.bak
|
||||||
|
*.sql
|
||||||
|
!infrastructure/postgres/init.sql
|
||||||
|
|
||||||
|
# Claude (workspace config, not project code)
|
||||||
|
.claude/*
|
||||||
|
!.claude/commands/
|
||||||
|
|
||||||
|
# Netlify
|
||||||
|
.functions/
|
||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
[submodule "falah-os"]
|
||||||
|
path = falah-os
|
||||||
|
url = https://github.com/maifors/falah-os.git
|
||||||
|
|
||||||
|
[submodule "ulp-portal"]
|
||||||
|
path = ulp-portal
|
||||||
|
url = https://github.com/maifors/ulp-portal.git
|
||||||
|
|
||||||
|
[submodule "falah-os-sdk-py"]
|
||||||
|
path = falah-os-sdk-py
|
||||||
|
url = https://github.com/maifors/falah-os-sdk-py.git
|
||||||
|
|
||||||
|
[submodule "falah-os-sdk"]
|
||||||
|
path = falah-os-sdk
|
||||||
|
url = https://github.com/maifors/falah-os-sdk.git
|
||||||
|
|
||||||
|
[submodule "falah-os-mocknet"]
|
||||||
|
path = falah-os-mocknet
|
||||||
|
url = https://github.com/maifors/falah-os-mocknet.git
|
||||||
|
|
||||||
|
[submodule "alah-os-mocknet"]
|
||||||
|
path = alah-os-mocknet
|
||||||
|
url = https://github.com/maifors/alah-os-mocknet.git
|
||||||
|
|
||||||
|
[submodule "falah-os-ummahid"]
|
||||||
|
path = falah-os-ummahid
|
||||||
|
url = https://github.com/maifors/falah-os-ummahid.git
|
||||||
|
|
||||||
|
[submodule "falah-os-ramz"]
|
||||||
|
path = falah-os-ramz
|
||||||
|
url = https://github.com/maifors/falah-os-ramz.git
|
||||||
|
|
||||||
|
[submodule "falah-os-admin"]
|
||||||
|
path = falah-os-admin
|
||||||
|
url = https://github.com/maifors/falah-os-admin.git
|
||||||
|
|
||||||
|
[submodule "falah-os-app"]
|
||||||
|
path = falah-os-app
|
||||||
|
url = https://github.com/maifors/falah-os-app.git
|
||||||
|
|
||||||
|
[submodule "falah-os-istore"]
|
||||||
|
path = falah-os-istore
|
||||||
|
url = https://github.com/maifors/falah-os-istore.git
|
||||||
|
|
||||||
|
[submodule "falah-os-dev-porta"]
|
||||||
|
path = falah-os-dev-porta
|
||||||
|
url = https://github.com/maifors/falah-os-dev-porta.git
|
||||||
|
|
||||||
|
[submodule "falah-os-landing"]
|
||||||
|
path = falah-os-landing
|
||||||
|
url = https://github.com/maifors/falah-os-landing.git
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
FROM node:20-slim AS base
|
||||||
|
|
||||||
|
FROM base AS deps
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends openssl && rm -rf /var/lib/apt/lists/*
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
FROM base AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
RUN npx prisma generate && npm run build
|
||||||
|
|
||||||
|
FROM base AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends openssl ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||||
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
RUN mkdir -p /app/data
|
||||||
|
COPY --from=builder /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
COPY --from=builder /app/prisma ./prisma
|
||||||
|
RUN npx prisma generate
|
||||||
|
RUN mkdir -p /app/public
|
||||||
|
USER nextjs
|
||||||
|
EXPOSE 3000
|
||||||
|
ENV PORT=3000
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { dirname } from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import { FlatCompat } from "@eslint/eslintrc";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
const compat = new FlatCompat({
|
||||||
|
baseDirectory: __dirname,
|
||||||
|
});
|
||||||
|
|
||||||
|
const eslintConfig = [...compat.extends("next/core-web-vitals")];
|
||||||
|
export default eslintConfig;
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
# Halal Monitor — Strategic Brief
|
||||||
|
|
||||||
|
## Vision
|
||||||
|
Rebrand the existing World Monitor project as **Halal Monitor** — a premium feature of FalahMobile. A global map-based dashboard for the Muslim ummah to find halal restaurants, mosques, and prayer spaces anywhere in the world.
|
||||||
|
|
||||||
|
## Core Features
|
||||||
|
|
||||||
|
### Phase 1 — MVP (Map + Data)
|
||||||
|
| Feature | Description | Data Source | API Cost |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Mosques & Prayer Places** | Map of nearby mosques worldwide with name, address, prayer times | Overpass API (OpenStreetMap) | **Free** |
|
||||||
|
| **Halal Restaurants** | Halal-certified/muslim-owned restaurants in major cities | Google Places API + OSM tags | Google: ~$7/1K calls (free $200/mo credit) |
|
||||||
|
| **Search by City** | Search bar + autocomplete for city/mosque/restaurant | Nominatim (OSM geocoder) | **Free** |
|
||||||
|
| **Current Location** | "Near me" — geolocate user and show nearby places | Browser Geolocation API | **Free** |
|
||||||
|
|
||||||
|
### Phase 2 — Premium Enhancements (FalahMobile Premium)
|
||||||
|
| Feature | Description |
|
||||||
|
|---|---|
|
||||||
|
| **Halal Certifications** | Verified halal certification badges per restaurant |
|
||||||
|
| **User Reviews & Ratings** | Community-driven halal rating system |
|
||||||
|
| **Bookmarks & Favorites** | Save places, get alerts when new verified places added |
|
||||||
|
| **Trip Planner** | Plan routes with halal restaurants + prayer stops along the way |
|
||||||
|
| **Crowd-Sourced Updates** | Premium users can submit new halal places for verification |
|
||||||
|
|
||||||
|
### Phase 3 — Platform
|
||||||
|
| Feature | Description |
|
||||||
|
|---|---|
|
||||||
|
| **Weekly Digest** | New halal places in your city, emailed/notified |
|
||||||
|
| **Monthly Trends** | Most-reviewed, top-rated, newly certified |
|
||||||
|
| **Ambassador Program** | Community mods who verify new submissions |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Integration into FalahMobile
|
||||||
|
|
||||||
|
```
|
||||||
|
FalahMobile (Next.js 16 App Router)
|
||||||
|
├── /halal-monitor → Page (gated: isPremium required)
|
||||||
|
├── /api/halal/places → Nearby places API (Overpass + Google)
|
||||||
|
├── /api/halal/bookmarks → User bookmarks CRUD
|
||||||
|
├── /api/halal/usage → Track API usage/quotas (for free tier)
|
||||||
|
└── lib/halal-monitor.ts → Shared helpers (geocoding, map utils)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tech Choices
|
||||||
|
|
||||||
|
| Concern | Choice | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| **Map renderer** | **Leaflet** (react-leaflet) | Free, no API key, light (~40KB gzip), works with OSM tiles |
|
||||||
|
| **Map tiles** | OpenStreetMap tile layer | Free tier, no API key, or optional MapTiler ($) for styled maps |
|
||||||
|
| **Mosque data** | **Overpass API** | OpenStreetMap query language, query `amenity=place_of_worship + religion=muslim` |
|
||||||
|
| **Halal restaurant data** | OSM tags (`diet:halal=yes`) + Google Places fallback | OSM free but less complete; Google fills gaps |
|
||||||
|
| **Geocoding** | Nominatim (OSM) | Free, 1 req/sec limit — fine for single-user |
|
||||||
|
| **Premium gating** | `user.isPremium` in AuthContext | Already exists in Prisma schema |
|
||||||
|
| **Map markers clustering** | `leaflet.markercluster` | Handles 1000+ markers smoothly |
|
||||||
|
| **Styling** | TailwindCSS dark theme (existing) | Consistent with FalahMobile design |
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
User opens /halal-monitor
|
||||||
|
→ Auth check: user.isPremium? No → Show upgrade CTA
|
||||||
|
→ Yes → Browser geolocates (or city search)
|
||||||
|
→ Fetch /api/halal/places?lat=X&lng=Y&radius=5000
|
||||||
|
→ Server queries Overpass API for mosques + halal restaurants
|
||||||
|
→ Caches results in SQLite (expire after 24h)
|
||||||
|
→ Returns GeoJSON
|
||||||
|
→ Render map with Leaflet + clustered markers
|
||||||
|
→ Two marker layers: mosques (green) / restaurants (blue)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Premium Gating Strategy
|
||||||
|
|
||||||
|
### What's Free vs Premium
|
||||||
|
|
||||||
|
| Feature | Free Users | Premium Users |
|
||||||
|
|---|---|---|
|
||||||
|
| View map | ✅ | ✅ |
|
||||||
|
| Search cities | ✅ | ✅ |
|
||||||
|
| See mosques | ✅ | ✅ |
|
||||||
|
| See halal restaurants | ❌ | ✅ |
|
||||||
|
| Bookmarks | ❌ (view-only) | ✅ (save & organize) |
|
||||||
|
| API calls/day | 20 | Unlimited |
|
||||||
|
| Crowd-sourced edits | ❌ | ✅ |
|
||||||
|
| Trip planner | ❌ | ✅ |
|
||||||
|
|
||||||
|
### Upgrade CTA Placement
|
||||||
|
- **Souq page** — small "Upgrade to Premium" badge in sidebar
|
||||||
|
- **Halal Monitor page** — if not premium, show map with greyed-out restaurant layer + upgrade prompt
|
||||||
|
- **Navbar** — crown icon next to user name for premium users; "✨ Upgrade" for free users
|
||||||
|
|
||||||
|
## Monetization
|
||||||
|
- **Premium subscription** — unlocks full Halal Monitor + other premium features
|
||||||
|
- **Price** — TBD (existing isPremium/isPro fields support multiple tiers)
|
||||||
|
- **Listing fee discount** — premium users pay 5% platform fee instead of 10%
|
||||||
|
|
||||||
|
## Implementation Phases
|
||||||
|
|
||||||
|
### Phase 1 (build in this session)
|
||||||
|
1. ✅ Create this strategic brief
|
||||||
|
2. **Halal Monitor page** — `/halal-monitor` with Leaflet map, premium gate
|
||||||
|
3. **API route** — `/api/halal/places` querying Overpass API for mosques
|
||||||
|
4. **Navbar update** — add Halal Monitor link (with premium badge)
|
||||||
|
5. **Premium gating** — `isPremium` check on page access
|
||||||
|
6. **Deploy** — rebuild image, push to Synology, update Dockge stack
|
||||||
|
7. **QA** — test map rendering, mosque search, premium gate
|
||||||
|
|
||||||
|
### Phase 2 (next session)
|
||||||
|
1. Halal restaurants layer (Google Places API)
|
||||||
|
2. Bookmarks CRUD
|
||||||
|
3. Crowd-sourced submission form
|
||||||
|
4. Trip planner
|
||||||
|
|
||||||
|
### Phase 3 (future)
|
||||||
|
1. Community review system
|
||||||
|
2. Weekly digests
|
||||||
|
3. Ambassador verification program
|
||||||
|
|
||||||
|
## Data & Privacy
|
||||||
|
- No user location data stored server-side — geolocation is ephemeral (browser)
|
||||||
|
- Bookmark data stored in SQLite (existing FalahMobile DB)
|
||||||
|
- Overpass API calls are anonymous
|
||||||
|
- Rate limiting: 60 req/min per user on free tier (enforced server-side)
|
||||||
|
|
||||||
|
## Key Risks & Mitigations
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|---|---|
|
||||||
|
| OSM data incomplete for halal restaurants | Add Google Places API fallback + crowd-sourced submissions |
|
||||||
|
| Overpass API rate limits (1 req/sec) | Server-side caching (24h TTL), batch queries |
|
||||||
|
| Free users burning API quota | Hard cap at 20 req/day for free, tracked via usage table |
|
||||||
|
| iCloud datalock on World Monitor source | Build fresh from OSM + Google APIs — don't depend on old code |
|
||||||
|
| Premium adoption low | Cross-sell on Souq page, make Halal Monitor a visible premium showcase |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Last updated: June 8, 2026*
|
||||||
|
*Status: Strategic Brief — Ready for Phase 1 implementation*
|
||||||
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
import "./.next/types/routes.d.ts";
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
output: "standalone",
|
||||||
|
turbopack: {
|
||||||
|
root: process.cwd(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "flh-app",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/client": "^5.22.0",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
|
"jose": "^6.2.3",
|
||||||
|
"lucide-react": "^1.17.0",
|
||||||
|
"next": "16.2.7",
|
||||||
|
"prisma": "^5.22.0",
|
||||||
|
"react": "19.2.4",
|
||||||
|
"react-dom": "19.2.4",
|
||||||
|
"stripe": "^17.0.0",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
|
"react-leaflet": "^5.0.0",
|
||||||
|
"@types/leaflet": "^1.9.14"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "16.2.7",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
export default config
|
||||||
Binary file not shown.
@@ -0,0 +1,179 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
binaryTargets = ["native", "linux-musl"]
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "sqlite"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
email String @unique
|
||||||
|
name String
|
||||||
|
passwordHash String?
|
||||||
|
flhBalance Int @default(0)
|
||||||
|
isPro Boolean @default(false)
|
||||||
|
isPremium Boolean @default(false)
|
||||||
|
experienceLevel String?
|
||||||
|
madhab String?
|
||||||
|
coachPersona String?
|
||||||
|
preferredName String?
|
||||||
|
coachingGoals String?
|
||||||
|
lastCoachedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
listings Listing[]
|
||||||
|
purchases Purchase[] @relation("BuyerPurchases")
|
||||||
|
sales Purchase[] @relation("SellerPurchases")
|
||||||
|
cashouts CashoutRequest[]
|
||||||
|
halalBookmarks HalalBookmark[]
|
||||||
|
halalUsage HalalUsage?
|
||||||
|
forumThreads ForumThread[]
|
||||||
|
forumPosts ForumPost[]
|
||||||
|
chatHistory ChatHistory[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Listing {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
title String
|
||||||
|
description String
|
||||||
|
category String
|
||||||
|
priceFlh Int
|
||||||
|
sellerId String
|
||||||
|
seller User @relation(fields: [sellerId], references: [id])
|
||||||
|
status String @default("active")
|
||||||
|
featured Boolean @default(false)
|
||||||
|
featuredUntil DateTime?
|
||||||
|
fileType String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
purchases Purchase[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Purchase {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
listingId String
|
||||||
|
listing Listing @relation(fields: [listingId], references: [id])
|
||||||
|
buyerId String
|
||||||
|
buyer User @relation("BuyerPurchases", fields: [buyerId], references: [id])
|
||||||
|
sellerId String
|
||||||
|
seller User @relation("SellerPurchases", fields: [sellerId], references: [id])
|
||||||
|
amountFlh Int
|
||||||
|
platformFee Int
|
||||||
|
sellerPayout Int
|
||||||
|
autoConfirmAt DateTime
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
|
||||||
|
model CashoutRequest {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
amountFlh Int
|
||||||
|
fiatAmount Float
|
||||||
|
status String @default("pending")
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
|
||||||
|
model ForumCategory {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String @unique
|
||||||
|
description String?
|
||||||
|
icon String?
|
||||||
|
order Int?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
threads ForumThread[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model ForumThread {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
title String
|
||||||
|
content String
|
||||||
|
categoryId String
|
||||||
|
category ForumCategory @relation(fields: [categoryId], references: [id])
|
||||||
|
authorId String
|
||||||
|
author User @relation(fields: [authorId], references: [id])
|
||||||
|
pinned Boolean @default(false)
|
||||||
|
shariahStatus String @default("pending")
|
||||||
|
shariahFlags String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
posts ForumPost[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model ForumPost {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
content String
|
||||||
|
threadId String
|
||||||
|
thread ForumThread @relation(fields: [threadId], references: [id])
|
||||||
|
authorId String
|
||||||
|
author User @relation(fields: [authorId], references: [id])
|
||||||
|
shariahStatus String @default("pending")
|
||||||
|
shariahFlags String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
|
||||||
|
model ChatHistory {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
role String
|
||||||
|
content String
|
||||||
|
metadata String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model HalalBookmark {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
itemId String
|
||||||
|
itemType String
|
||||||
|
label String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model HalalUsage {
|
||||||
|
userId String @id
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
queriesUsed Int @default(0)
|
||||||
|
queriesLimit Int @default(100)
|
||||||
|
periodEnd DateTime?
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Micro-Learning (Learn Module) ──
|
||||||
|
|
||||||
|
model Course {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
slug String @unique
|
||||||
|
title String
|
||||||
|
description String
|
||||||
|
difficulty String // beginner | intermediate | advanced
|
||||||
|
imageUrl String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
modules Module[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Module {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
courseId String
|
||||||
|
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
||||||
|
title String
|
||||||
|
description String
|
||||||
|
content String // markdown
|
||||||
|
videoUrl String?
|
||||||
|
order Int
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
quizData String? // JSON string: [{question, options:[], correctIndex}]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { verifyPassword, signJWT } from '@/lib/auth'
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { email, password } = await req.json()
|
||||||
|
if (!email || !password) return NextResponse.json({ error: 'Email and password required' }, { status: 400 })
|
||||||
|
const user = await prisma.user.findUnique({ where: { email } })
|
||||||
|
if (!user || !user.passwordHash || !verifyPassword(password, user.passwordHash)) return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 })
|
||||||
|
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 })
|
||||||
|
} catch (e) { console.error(e); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
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 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 })
|
||||||
|
return NextResponse.json({ user })
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { verifyJWT } from '@/lib/auth'
|
||||||
|
|
||||||
|
export async function PATCH(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 body = await req.json()
|
||||||
|
const {
|
||||||
|
preferredName,
|
||||||
|
experienceLevel,
|
||||||
|
madhab,
|
||||||
|
coachPersona,
|
||||||
|
coachingGoals,
|
||||||
|
} = body
|
||||||
|
|
||||||
|
// Validate experienceLevel
|
||||||
|
if (experienceLevel && !['new', 'growing', 'seasoned'].includes(experienceLevel)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid experienceLevel' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate madhab
|
||||||
|
if (madhab && !['hanafi', 'shafii', 'maliki', 'hanbali', 'unspecified'].includes(madhab)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid madhab' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate coachPersona
|
||||||
|
if (coachPersona && !['nurbuddy', 'ghazali', 'ibnabbas', 'rabia'].includes(coachPersona)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid coachPersona' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData: Record<string, unknown> = {}
|
||||||
|
if (preferredName !== undefined) updateData.preferredName = preferredName
|
||||||
|
if (experienceLevel !== undefined) updateData.experienceLevel = experienceLevel
|
||||||
|
if (madhab !== undefined) updateData.madhab = madhab
|
||||||
|
if (coachPersona !== undefined) updateData.coachPersona = coachPersona
|
||||||
|
if (coachingGoals !== undefined) updateData.coachingGoals = JSON.stringify(coachingGoals)
|
||||||
|
|
||||||
|
const user = await prisma.user.update({
|
||||||
|
where: { id: payload.id },
|
||||||
|
data: updateData,
|
||||||
|
select: {
|
||||||
|
id: true, email: true, name: true,
|
||||||
|
preferredName: true, experienceLevel: true, madhab: true, coachPersona: true,
|
||||||
|
isPremium: true, isPro: true, flhBalance: true,
|
||||||
|
coachingGoals: true, lastCoachedAt: true, createdAt: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ user })
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { hashPassword, signJWT } from '@/lib/auth'
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { email, name, password } = await req.json()
|
||||||
|
if (!email || !name || !password) return NextResponse.json({ error: 'All fields required' }, { status: 400 })
|
||||||
|
const exists = await prisma.user.findUnique({ where: { email } })
|
||||||
|
if (exists) return NextResponse.json({ error: 'Email already registered' }, { status: 400 })
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: { email, name, passwordHash: hashPassword(password) },
|
||||||
|
})
|
||||||
|
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 })
|
||||||
|
} catch (e) { console.error(e); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// Greeting pools — tiered by engagement level
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
const DAILY_GREETINGS = [
|
||||||
|
`Assalamu alaikum! I was thinking of you this morning. How is your heart today?`,
|
||||||
|
`Assalamu alaikum! A new day, a new opportunity to draw closer to Allah. How are you, my friend?`,
|
||||||
|
`Peace be upon you. I felt a gentle nudge to check in with you. How has your journey been?`,
|
||||||
|
`Assalamu alaikum! The sun rises again, and so does Allah's mercy. How are you doing today?`,
|
||||||
|
`Peace be with you. I was wondering how you've been — we don't need to talk about anything heavy. Just checking in.`,
|
||||||
|
`Assalamu alaikum! Every breath is a gift we didn't earn. How are you spending yours today?`,
|
||||||
|
`Peace upon you. No pressure, no expectations — just wanted you to know someone is here when you're ready.`,
|
||||||
|
]
|
||||||
|
|
||||||
|
// For users who haven't checked in for 2+ days — gentler, warmer
|
||||||
|
const NURTURE_GREETINGS = [
|
||||||
|
`Assalamu alaikum. I noticed it's been a little while. Just wanted you to know — there's no guilt here, no judgment. Only welcome. How have you been, truly?`,
|
||||||
|
`Peace be upon you. Life gets busy, I know. But I wanted to check in because you matter. How is your heart these days?`,
|
||||||
|
`Assalamu alaikum. Whether you've been distant from Allah or just busy with the world — His door is always open. So is this conversation. How are you?`,
|
||||||
|
`Peace be with you. They say the journey back begins with a single step. You're here — that's your step. How can I lighten your load today?`,
|
||||||
|
`Assalamu alaikum. No matter how many days have passed, you are always welcome here. The Prophet ﷺ said the one who returns to good is like one who never left. How can I support you today?`,
|
||||||
|
`Peace be upon you. Silence between friends is not a wall — it's a pause. I'm still here. What's on your mind?`,
|
||||||
|
]
|
||||||
|
|
||||||
|
// For first-time users with no history — the warmest welcome
|
||||||
|
const FIRST_GREETINGS = [
|
||||||
|
`Assalamu alaikum! I'm so glad you're here. This is a space for you — for your questions, your struggles, your quiet thoughts. There is nothing too small or too big to bring here. How are you feeling today?`,
|
||||||
|
`Peace be upon you. Welcome. Think of me as a friend who loves the Quran and the Sunnah, and who is simply here to walk alongside you. Where would you like to start?`,
|
||||||
|
`Assalamu alaikum! The Prophet ﷺ said, "Whoever Allah desires good for, He gives him understanding of the religion." You seeking knowledge is a sign of goodness. How can I help you grow today?`,
|
||||||
|
]
|
||||||
|
|
||||||
|
function daysSince(date: Date): number {
|
||||||
|
const now = new Date()
|
||||||
|
const diff = now.getTime() - date.getTime()
|
||||||
|
return Math.floor(diff / (1000 * 60 * 60 * 24))
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickRandom<T>(pool: T[]): T {
|
||||||
|
return pool[Math.floor(Math.random() * pool.length)]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { userId } = await req.json()
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: 'userId required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: userId } })
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check last bot message
|
||||||
|
const lastBotMessage = await prisma.chatHistory.findFirst({
|
||||||
|
where: { userId, role: 'assistant' },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
})
|
||||||
|
|
||||||
|
// If bot already messaged today, skip
|
||||||
|
if (lastBotMessage) {
|
||||||
|
const lastDate = new Date(lastBotMessage.createdAt)
|
||||||
|
const now = new Date()
|
||||||
|
const sameDay = lastDate.getDate() === now.getDate()
|
||||||
|
&& lastDate.getMonth() === now.getMonth()
|
||||||
|
&& lastDate.getFullYear() === now.getFullYear()
|
||||||
|
if (sameDay) {
|
||||||
|
return NextResponse.json({
|
||||||
|
needsGreeting: false,
|
||||||
|
message: null,
|
||||||
|
lastSeen: lastBotMessage.createdAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check last user message for idle detection
|
||||||
|
const lastUserMessage = await prisma.chatHistory.findFirst({
|
||||||
|
where: { userId, role: 'user' },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check if this is a first-time user (no history at all)
|
||||||
|
const totalMessages = await prisma.chatHistory.count({ where: { userId } })
|
||||||
|
|
||||||
|
// Determine greeting tier
|
||||||
|
let greeting: string
|
||||||
|
|
||||||
|
if (totalMessages === 0) {
|
||||||
|
// First visit ever — warm welcome
|
||||||
|
greeting = pickRandom(FIRST_GREETINGS)
|
||||||
|
} else if (lastUserMessage && daysSince(new Date(lastUserMessage.createdAt)) >= 2) {
|
||||||
|
// Been away 2+ days — nurture/re-engagement message
|
||||||
|
greeting = pickRandom(NURTURE_GREETINGS)
|
||||||
|
} else {
|
||||||
|
// Regular daily check-in
|
||||||
|
let lastTopic: string | undefined
|
||||||
|
if (lastUserMessage) {
|
||||||
|
const words = lastUserMessage.content.split(/\s+/).slice(0, 8).join(' ')
|
||||||
|
lastTopic = words.length > 60 ? words.slice(0, 60) + '...' : words
|
||||||
|
}
|
||||||
|
const base = pickRandom(DAILY_GREETINGS)
|
||||||
|
greeting = lastTopic
|
||||||
|
? `${base} Last time we spoke about ${lastTopic}. Want to continue, or something new?`
|
||||||
|
: base
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the greeting
|
||||||
|
await prisma.chatHistory.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
role: 'assistant',
|
||||||
|
content: greeting,
|
||||||
|
metadata: JSON.stringify({
|
||||||
|
command: '_daily',
|
||||||
|
summary: 'Daily check-in',
|
||||||
|
actionItems: [],
|
||||||
|
topics: ['daily-check-in'],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update lastCoachedAt
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: { lastCoachedAt: new Date() },
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
needsGreeting: true,
|
||||||
|
message: greeting,
|
||||||
|
lastSeen: lastBotMessage?.createdAt || null,
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Daily check-in error:', e)
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const user = await prisma.user.upsert({
|
||||||
|
where: { email: 'demo@nurbuddy.ai' },
|
||||||
|
update: {},
|
||||||
|
create: { email: 'demo@nurbuddy.ai', name: 'Demo User', isPremium: true },
|
||||||
|
})
|
||||||
|
if (!user.isPremium) {
|
||||||
|
await prisma.user.update({ where: { id: user.id }, data: { isPremium: true } })
|
||||||
|
}
|
||||||
|
return NextResponse.json({ user })
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const { pathname } = new URL(req.url)
|
||||||
|
const userId = pathname.split('/').pop()
|
||||||
|
if (!userId) return NextResponse.json({ error: 'userId required' }, { status: 400 })
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: userId } })
|
||||||
|
if (!user) return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
||||||
|
const history = await prisma.chatHistory.findMany({
|
||||||
|
where: { userId }, orderBy: { createdAt: 'asc' }, take: 50,
|
||||||
|
})
|
||||||
|
return NextResponse.json({ history })
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { askNur, moderateContent } from '@/lib/ai'
|
||||||
|
import type { UserContext, CoachingMemory } from '@/lib/ai'
|
||||||
|
import { parseCommand, handleCommand } from '@/lib/commands'
|
||||||
|
import type { SlashCommand } from '@/lib/commands'
|
||||||
|
import { SCHOLAR_PERSONAS } from '@/lib/personas'
|
||||||
|
|
||||||
|
// Per-user seen-content tracking (in-memory, resets on server restart)
|
||||||
|
const userSeenContent = new Map<string, Map<SlashCommand, Set<number>>>()
|
||||||
|
|
||||||
|
function getUserSeen(userId: string): Map<SlashCommand, Set<number>> {
|
||||||
|
if (!userSeenContent.has(userId)) {
|
||||||
|
userSeenContent.set(userId, new Map())
|
||||||
|
}
|
||||||
|
return userSeenContent.get(userId)!
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSeenSet(userId: string, command: SlashCommand): Set<number> {
|
||||||
|
const userMap = getUserSeen(userId)
|
||||||
|
if (!userMap.has(command)) {
|
||||||
|
userMap.set(command, new Set())
|
||||||
|
}
|
||||||
|
return userMap.get(command)!
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { userId, message } = await req.json()
|
||||||
|
if (!userId || !message) {
|
||||||
|
return NextResponse.json({ error: 'userId and message required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: userId } })
|
||||||
|
if (!user) return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
||||||
|
|
||||||
|
// Moderate user message
|
||||||
|
const mod = moderateContent(message)
|
||||||
|
if (!mod.approved) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Message flagged: ${mod.reason}`, severity: mod.severity },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse slash commands
|
||||||
|
const { command, rest } = parseCommand(message)
|
||||||
|
|
||||||
|
// Handle /new and /fresh by clearing history
|
||||||
|
if (command === 'new' || command === 'fresh') {
|
||||||
|
await prisma.chatHistory.deleteMany({ where: { userId } })
|
||||||
|
const persona = SCHOLAR_PERSONAS[(user.coachPersona || 'nurbuddy') as keyof typeof SCHOLAR_PERSONAS] || SCHOLAR_PERSONAS.nurbuddy
|
||||||
|
return NextResponse.json({
|
||||||
|
response: persona.greeting,
|
||||||
|
metadata: { command: 'new', summary: 'Chat history cleared', actionItems: [], topics: [] },
|
||||||
|
cleared: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load recent conversation history
|
||||||
|
const history = await prisma.chatHistory.findMany({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
take: 12,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Load previous session summaries from metadata
|
||||||
|
const summaries: string[] = []
|
||||||
|
const actionItems: string[] = []
|
||||||
|
for (const h of history) {
|
||||||
|
if (h.metadata) {
|
||||||
|
try {
|
||||||
|
const meta = JSON.parse(h.metadata)
|
||||||
|
if (meta.summary) summaries.push(meta.summary)
|
||||||
|
if (meta.actionItems) actionItems.push(...meta.actionItems)
|
||||||
|
} catch { /* ignore bad JSON */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate days since joined
|
||||||
|
const daysSinceJoined = Math.max(
|
||||||
|
1,
|
||||||
|
Math.floor((Date.now() - new Date(user.createdAt).getTime()) / (1000 * 60 * 60 * 24))
|
||||||
|
)
|
||||||
|
|
||||||
|
// Calculate coaching streak
|
||||||
|
let streakDays = 1
|
||||||
|
if (user.lastCoachedAt) {
|
||||||
|
const hoursSince = (Date.now() - new Date(user.lastCoachedAt).getTime()) / (1000 * 60 * 60)
|
||||||
|
if (hoursSince < 48) streakDays = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
const userContext: UserContext = {
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
preferredName: user.preferredName,
|
||||||
|
experienceLevel: user.experienceLevel || '',
|
||||||
|
madhab: user.madhab || '',
|
||||||
|
coachPersona: user.coachPersona as any,
|
||||||
|
isPremium: user.isPremium,
|
||||||
|
isPro: user.isPro,
|
||||||
|
flhBalance: user.flhBalance,
|
||||||
|
coachingGoals: user.coachingGoals,
|
||||||
|
daysSinceJoined,
|
||||||
|
}
|
||||||
|
|
||||||
|
const memory: CoachingMemory = {
|
||||||
|
summaries: summaries.slice(-5),
|
||||||
|
lastTopics: [],
|
||||||
|
actionItems: actionItems.slice(-8),
|
||||||
|
streakDays,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save user message
|
||||||
|
await prisma.chatHistory.create({
|
||||||
|
data: { userId, role: 'user', content: message },
|
||||||
|
})
|
||||||
|
|
||||||
|
let response: string
|
||||||
|
let metadata: any = {}
|
||||||
|
|
||||||
|
// Route slash commands
|
||||||
|
if (command) {
|
||||||
|
const result = handleCommand(
|
||||||
|
command,
|
||||||
|
rest,
|
||||||
|
userContext.coachPersona,
|
||||||
|
userContext.preferredName || userContext.name || 'my friend',
|
||||||
|
getSeenSet(userId, 'hadith'),
|
||||||
|
getSeenSet(userId, 'quran'),
|
||||||
|
getSeenSet(userId, 'zikr'),
|
||||||
|
getSeenSet(userId, 'reminder'),
|
||||||
|
getSeenSet(userId, 'faraid'),
|
||||||
|
getSeenSet(userId, 'infaq'),
|
||||||
|
getSeenSet(userId, 'prayer'),
|
||||||
|
)
|
||||||
|
response = result.response
|
||||||
|
metadata = result.metadata
|
||||||
|
} else {
|
||||||
|
// Normal AI conversation
|
||||||
|
const aiResult = await askNur(
|
||||||
|
message,
|
||||||
|
userContext,
|
||||||
|
memory,
|
||||||
|
history.map((h: { role: string; content: string }) => ({ role: h.role, content: h.content }))
|
||||||
|
)
|
||||||
|
response = aiResult.response
|
||||||
|
metadata = aiResult.metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save assistant response with metadata
|
||||||
|
await prisma.chatHistory.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
role: 'assistant',
|
||||||
|
content: response,
|
||||||
|
metadata: metadata ? JSON.stringify(metadata) : null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update lastCoachedAt
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: { lastCoachedAt: new Date() },
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
response,
|
||||||
|
metadata: metadata || {},
|
||||||
|
userContext: {
|
||||||
|
experienceLevel: user.experienceLevel || '',
|
||||||
|
madhab: user.madhab,
|
||||||
|
streakDays,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const { pathname } = new URL(req.url)
|
||||||
|
const listingId = pathname.split('/').pop()
|
||||||
|
return NextResponse.json({ listingId, message: 'File serving endpoint' })
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
let categories = await prisma.forumCategory.findMany({ orderBy: { order: 'asc' } })
|
||||||
|
if (categories.length === 0) {
|
||||||
|
const defaults = await prisma.$transaction([
|
||||||
|
prisma.forumCategory.create({ data: { name: 'General Discussion', description: 'General Islamic discussions', icon: '\u{1F4AC}', order: 1 } }),
|
||||||
|
prisma.forumCategory.create({ data: { name: 'Fiqh & Rulings', description: 'Questions about Islamic jurisprudence', icon: '\u{1F4D6}', order: 2 } }),
|
||||||
|
prisma.forumCategory.create({ data: { name: 'Quran & Hadith', description: 'Study and reflection', icon: '\u{1F4FF}', order: 3 } }),
|
||||||
|
prisma.forumCategory.create({ data: { name: 'Family & Marriage', description: 'Family life in Islam', icon: '\u{1F468}\u200D\u{1F469}\u200D\u{1F467}\u200D\u{1F466}', order: 4 } }),
|
||||||
|
prisma.forumCategory.create({ data: { name: 'Business & Finance', description: 'Halal earning and Islamic finance', icon: '\u{1F4B0}', order: 5 } }),
|
||||||
|
prisma.forumCategory.create({ data: { name: 'Support & Duas', description: 'Ask for support and share duas', icon: '\u{1F64C}', order: 6 } }),
|
||||||
|
])
|
||||||
|
categories = defaults
|
||||||
|
}
|
||||||
|
return NextResponse.json({ categories })
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { verifyJWT } from '@/lib/auth'
|
||||||
|
import { moderateContent } from '@/lib/ai'
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const { searchParams } = new URL(req.url)
|
||||||
|
const threadId = searchParams.get('threadId')
|
||||||
|
if (!threadId) return NextResponse.json({ error: 'threadId required' }, { status: 400 })
|
||||||
|
const posts = await prisma.forumPost.findMany({
|
||||||
|
where: { threadId, shariahStatus: 'approved' },
|
||||||
|
include: { author: { select: { id: true, name: true, isPremium: true, isPro: true } } },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
})
|
||||||
|
return NextResponse.json({ posts })
|
||||||
|
}
|
||||||
|
|
||||||
|
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 { threadId, content } = await req.json()
|
||||||
|
if (!threadId || !content) return NextResponse.json({ error: 'threadId and content required' }, { status: 400 })
|
||||||
|
const moderation = moderateContent(content)
|
||||||
|
const post = await prisma.forumPost.create({
|
||||||
|
data: {
|
||||||
|
threadId, content, authorId: payload.id,
|
||||||
|
shariahStatus: moderation.approved ? 'approved' : 'flagged',
|
||||||
|
shariahFlags: moderation.reason || null,
|
||||||
|
},
|
||||||
|
include: { author: { select: { id: true, name: true } } },
|
||||||
|
})
|
||||||
|
return NextResponse.json({ post }, { status: 201 })
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { verifyJWT } from '@/lib/auth'
|
||||||
|
import { moderateContent } from '@/lib/ai'
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const { searchParams } = new URL(req.url)
|
||||||
|
const categoryId = searchParams.get('categoryId')
|
||||||
|
const where = categoryId ? { categoryId } : {}
|
||||||
|
const threads = await prisma.forumThread.findMany({
|
||||||
|
where: { ...where, shariahStatus: 'approved' },
|
||||||
|
include: {
|
||||||
|
author: { select: { id: true, name: true, isPremium: true, isPro: true } },
|
||||||
|
category: { select: { id: true, name: true } },
|
||||||
|
_count: { select: { posts: true } },
|
||||||
|
},
|
||||||
|
orderBy: [{ pinned: 'desc' }, { createdAt: 'desc' }],
|
||||||
|
})
|
||||||
|
return NextResponse.json({ threads })
|
||||||
|
}
|
||||||
|
|
||||||
|
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 { title, content, categoryId } = await req.json()
|
||||||
|
if (!title || !content || !categoryId) return NextResponse.json({ error: 'title, content, and categoryId required' }, { status: 400 })
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: payload.id } })
|
||||||
|
if (!user || (!user.isPremium && !user.isPro)) return NextResponse.json({ error: 'Premium subscription required to create threads' }, { status: 403 })
|
||||||
|
const moderation = moderateContent(title + ' ' + content)
|
||||||
|
const thread = await prisma.forumThread.create({
|
||||||
|
data: {
|
||||||
|
title, content, categoryId, authorId: payload.id,
|
||||||
|
shariahStatus: moderation.approved ? 'approved' : 'flagged',
|
||||||
|
shariahFlags: moderation.reason || null,
|
||||||
|
},
|
||||||
|
include: { author: { select: { id: true, name: true } }, category: { select: { name: true } } },
|
||||||
|
})
|
||||||
|
return NextResponse.json({ thread }, { status: 201 })
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
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 bookmarks = await prisma.halalBookmark.findMany({
|
||||||
|
where: { userId: payload.id },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
})
|
||||||
|
return NextResponse.json({ bookmarks })
|
||||||
|
}
|
||||||
|
|
||||||
|
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 { itemId, itemType, label } = await req.json()
|
||||||
|
if (!itemId || !itemType) return NextResponse.json({ error: 'itemId and itemType required' }, { status: 400 })
|
||||||
|
const bookmark = await prisma.halalBookmark.create({
|
||||||
|
data: { userId: payload.id, itemId, itemType, label: label || null },
|
||||||
|
})
|
||||||
|
return NextResponse.json({ bookmark }, { status: 201 })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(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 { id } = await req.json()
|
||||||
|
await prisma.halalBookmark.deleteMany({ where: { id, userId: payload.id } })
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
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 usage = await prisma.halalUsage.findUnique({ where: { userId: payload.id } })
|
||||||
|
return NextResponse.json({ usage: usage || { userId: payload.id, queriesUsed: 0, queriesLimit: 100, periodEnd: null } })
|
||||||
|
}
|
||||||
|
|
||||||
|
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 usage = await prisma.halalUsage.upsert({
|
||||||
|
where: { userId: payload.id },
|
||||||
|
update: { queriesUsed: { increment: 1 } },
|
||||||
|
create: { userId: payload.id, queriesUsed: 1, queriesLimit: 100, periodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) },
|
||||||
|
})
|
||||||
|
return NextResponse.json({ usage })
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return NextResponse.json({ status: 'ok', service: 'falah-mobile' })
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { verifyJWT } from '@/lib/auth'
|
||||||
|
|
||||||
|
export async function PUT(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 { listingId } = await req.json()
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: payload.id } })
|
||||||
|
if (!user?.isPro) return NextResponse.json({ error: 'Pro subscription required to feature listings' }, { status: 403 })
|
||||||
|
const listing = await prisma.listing.findFirst({ where: { id: listingId, sellerId: payload.id } })
|
||||||
|
if (!listing) return NextResponse.json({ error: 'Listing not found' }, { status: 404 })
|
||||||
|
const updated = await prisma.listing.update({
|
||||||
|
where: { id: listingId },
|
||||||
|
data: { featured: true, featuredUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) },
|
||||||
|
})
|
||||||
|
return NextResponse.json({ listing: updated })
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { verifyJWT } from '@/lib/auth'
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const listings = await prisma.listing.findMany({
|
||||||
|
where: { status: 'active' },
|
||||||
|
include: { seller: { select: { id: true, name: true } } },
|
||||||
|
orderBy: [{ featured: 'desc' }, { createdAt: 'desc' }],
|
||||||
|
})
|
||||||
|
return NextResponse.json({
|
||||||
|
listings: listings.map((l: any) => ({
|
||||||
|
id: l.id, sellerId: l.seller.id, title: l.title, category: l.category,
|
||||||
|
price_flh: l.priceFlh, seller: l.seller.name, description: l.description,
|
||||||
|
status: l.status, featured: l.featured, featuredUntil: l.featuredUntil,
|
||||||
|
fileType: l.fileType, createdAt: l.createdAt,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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 { title, description, category, priceFlh } = await req.json()
|
||||||
|
if (!title || !description || !category || !priceFlh) return NextResponse.json({ error: 'All fields required' }, { status: 400 })
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: payload.id } })
|
||||||
|
const maxListings = user?.isPro ? 9999 : user?.isPremium ? 50 : 5
|
||||||
|
const count = await prisma.listing.count({ where: { sellerId: payload.id, status: 'active' } })
|
||||||
|
if (count >= maxListings!) return NextResponse.json({ error: 'Listing limit reached. Upgrade to Premium or Pro.' }, { status: 400 })
|
||||||
|
const listing = await prisma.listing.create({
|
||||||
|
data: { title, description, category, priceFlh: parseInt(priceFlh), sellerId: payload.id },
|
||||||
|
include: { seller: { select: { name: true } } },
|
||||||
|
})
|
||||||
|
return NextResponse.json({ listing }, { status: 201 })
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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 { listingId } = await req.json()
|
||||||
|
if (!listingId) return NextResponse.json({ error: 'listingId required' }, { status: 400 })
|
||||||
|
const listing = await prisma.listing.findUnique({ where: { id: listingId } })
|
||||||
|
if (!listing || listing.status !== 'active') return NextResponse.json({ error: 'Listing not available' }, { status: 400 })
|
||||||
|
if (listing.sellerId === payload.id) return NextResponse.json({ error: 'Cannot purchase your own listing' }, { status: 400 })
|
||||||
|
const buyer = await prisma.user.findUnique({ where: { id: payload.id } })
|
||||||
|
if (!buyer || buyer.flhBalance < listing.priceFlh) return NextResponse.json({ error: 'Insufficient balance' }, { status: 400 })
|
||||||
|
const platformFee = Math.floor(listing.priceFlh * 0.1)
|
||||||
|
const sellerPayout = listing.priceFlh - platformFee
|
||||||
|
const autoConfirmAt = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000)
|
||||||
|
const [purchase] = await prisma.$transaction([
|
||||||
|
prisma.purchase.create({
|
||||||
|
data: { listingId, buyerId: payload.id, sellerId: listing.sellerId, amountFlh: listing.priceFlh, platformFee, sellerPayout, autoConfirmAt },
|
||||||
|
include: { listing: true },
|
||||||
|
}),
|
||||||
|
prisma.user.update({ where: { id: payload.id }, data: { flhBalance: { decrement: listing.priceFlh } } }),
|
||||||
|
])
|
||||||
|
return NextResponse.json({ purchase }, { status: 201 })
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return NextResponse.json({ status: 'ok', service: 'nurbuddy' })
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import bcrypt from 'bcryptjs'
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
try {
|
||||||
|
const password = await bcrypt.hash('password123', 10)
|
||||||
|
|
||||||
|
const userData = [
|
||||||
|
{ email: 'ghazali@falahos.my', name: 'Abu Hamid Al-Ghazali', password, flhBalance: 5000 },
|
||||||
|
{ email: 'ibnabbas@falahos.my', name: 'Abdullah Ibn Abbas', password, flhBalance: 3000 },
|
||||||
|
{ email: 'rabia@falahos.my', name: "Rabi'a Al-Adawiyya", password, flhBalance: 2000 },
|
||||||
|
{ email: 'demo@falahos.my', name: 'Demo User', password, flhBalance: 500 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const users: any[] = []
|
||||||
|
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++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: `Seeded: ${users.length} users, ${created} new listings`,
|
||||||
|
users: users.length,
|
||||||
|
listingsCreated: created,
|
||||||
|
})
|
||||||
|
} catch (e: any) {
|
||||||
|
return NextResponse.json({ error: e.message }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const { pathname } = new URL(req.url)
|
||||||
|
const sellerId = pathname.split('/').pop()
|
||||||
|
if (!sellerId) return NextResponse.json({ error: 'sellerId required' }, { status: 400 })
|
||||||
|
const listings = await prisma.listing.findMany({
|
||||||
|
where: { sellerId, status: 'active' },
|
||||||
|
select: { id: true, title: true, category: true, priceFlh: true, createdAt: true },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
})
|
||||||
|
return NextResponse.json({ listings })
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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 { amountFlh } = await req.json()
|
||||||
|
if (!amountFlh || amountFlh < 100) return NextResponse.json({ error: 'Minimum cashout is 100 FLH' }, { status: 400 })
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: payload.id } })
|
||||||
|
if (!user || user.flhBalance < amountFlh) return NextResponse.json({ error: 'Insufficient balance' }, { status: 400 })
|
||||||
|
const fiatAmount = (amountFlh / 100) * 0.8
|
||||||
|
const cashout = await prisma.cashoutRequest.create({ data: { userId: payload.id, amountFlh, fiatAmount } })
|
||||||
|
return NextResponse.json({ cashout })
|
||||||
|
}
|
||||||
|
|
||||||
|
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 requests = await prisma.cashoutRequest.findMany({
|
||||||
|
where: { userId: payload.id }, orderBy: { createdAt: 'desc' },
|
||||||
|
})
|
||||||
|
return NextResponse.json({ requests })
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
|
import { useAuth } from '@/lib/AuthContext'
|
||||||
|
import { ArrowLeft, ShieldCheck, ShieldAlert, Send, MessageCircle } from 'lucide-react'
|
||||||
|
|
||||||
|
interface Thread {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
author: { id: string; name: string }
|
||||||
|
category: { id: string; name: string }
|
||||||
|
shariahStatus: string
|
||||||
|
shariahFlags: string | null
|
||||||
|
pinned: boolean
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Post {
|
||||||
|
id: string
|
||||||
|
content: string
|
||||||
|
author: { id: string; name: string }
|
||||||
|
shariahStatus: string
|
||||||
|
shariahFlags: string | null
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ThreadDetailPage() {
|
||||||
|
const { threadId } = useParams<{ threadId: string }>()
|
||||||
|
const router = useRouter()
|
||||||
|
const { token, user } = useAuth()
|
||||||
|
const [thread, setThread] = useState<Thread | null>(null)
|
||||||
|
const [posts, setPosts] = useState<Post[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [reply, setReply] = useState('')
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [replyError, setReplyError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!threadId) return
|
||||||
|
setLoading(true)
|
||||||
|
Promise.all([
|
||||||
|
fetch(`/api/forum/threads?id=${threadId}`).then(r => r.json()),
|
||||||
|
fetch(`/api/forum/posts?threadId=${threadId}`).then(r => r.json()),
|
||||||
|
])
|
||||||
|
.then(([threadData, postsData]) => {
|
||||||
|
const t = threadData.threads?.[0] || threadData.thread
|
||||||
|
if (!t) { setError('Thread not found'); return }
|
||||||
|
setThread(t)
|
||||||
|
setPosts(postsData.posts || [])
|
||||||
|
})
|
||||||
|
.catch(() => setError('Failed to load thread'))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [threadId])
|
||||||
|
|
||||||
|
const handleReply = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!token || !reply.trim()) return
|
||||||
|
setSubmitting(true)
|
||||||
|
setReplyError(null)
|
||||||
|
const res = await fetch('/api/forum/posts', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ threadId, content: reply.trim() }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
setSubmitting(false)
|
||||||
|
if (!res.ok) { setReplyError(data.error || 'Failed to post reply'); return }
|
||||||
|
setPosts(prev => [...prev, data.post])
|
||||||
|
setReply('')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto p-4 sm:p-6">
|
||||||
|
<div className="animate-pulse space-y-4">
|
||||||
|
<div className="h-4 bg-gray-800 rounded w-24" />
|
||||||
|
<div className="h-8 bg-gray-800 rounded w-3/4" />
|
||||||
|
<div className="h-4 bg-gray-800 rounded w-1/3" />
|
||||||
|
<div className="h-32 bg-gray-800 rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !thread) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto p-4 sm:p-6">
|
||||||
|
<button onClick={() => router.push('/forum')} className="flex items-center gap-1 text-[#D4AF37] hover:underline mb-4 text-sm">
|
||||||
|
<ArrowLeft size={16} /> Back to Forum
|
||||||
|
</button>
|
||||||
|
<div className="bg-[#111118] border border-gray-800 rounded-lg p-8 text-center">
|
||||||
|
<MessageCircle size={40} className="mx-auto text-gray-600 mb-3" />
|
||||||
|
<p className="text-gray-400">{error || 'Thread not found'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto p-4 sm:p-6 space-y-6">
|
||||||
|
<button onClick={() => router.push('/forum')} className="flex items-center gap-1 text-[#D4AF37] hover:underline text-sm">
|
||||||
|
<ArrowLeft size={16} /> Back to Forum
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="bg-[#111118] border border-gray-800 rounded-lg p-5 space-y-3">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="bg-[#D4AF37]/10 text-[#D4AF37] text-xs font-semibold px-2 py-0.5 rounded">{thread.category.name}</span>
|
||||||
|
{thread.shariahStatus === 'approved' ? (
|
||||||
|
<span className="flex items-center gap-1 text-green-500 text-xs"><ShieldCheck size={14} /> Shariah Approved</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center gap-1 text-yellow-500 text-xs"><ShieldAlert size={14} /> {thread.shariahStatus === 'pending' ? 'Pending Review' : 'Flagged'}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h1 className="text-xl font-bold">{thread.title}</h1>
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
By <span className="text-gray-300">{thread.author.name}</span> · {new Date(thread.createdAt).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-300 whitespace-pre-wrap leading-relaxed">{thread.content}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||||
|
<MessageCircle size={18} className="text-[#D4AF37]" />
|
||||||
|
Replies ({posts.length})
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{posts.length === 0 ? (
|
||||||
|
<div className="bg-[#111118] border border-gray-800 rounded-lg p-6 text-center">
|
||||||
|
<p className="text-gray-500 text-sm">No replies yet. Be the first to respond.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
posts.map(post => (
|
||||||
|
<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">
|
||||||
|
<span className="text-sm font-semibold">{post.author.name}</span>
|
||||||
|
<span className="text-xs text-gray-500">{new Date(post.createdAt).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-300 whitespace-pre-wrap">{post.content}</p>
|
||||||
|
{post.shariahStatus !== 'approved' && (
|
||||||
|
<div className="flex items-center gap-1 text-yellow-500 text-xs pt-1">
|
||||||
|
<ShieldAlert size={12} /> Pending shariah review
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{token ? (
|
||||||
|
<form onSubmit={handleReply} className="bg-[#111118] border border-gray-800 rounded-lg p-4 space-y-3">
|
||||||
|
<textarea
|
||||||
|
placeholder="Write your reply..."
|
||||||
|
value={reply}
|
||||||
|
onChange={e => setReply(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 resize-none"
|
||||||
|
/>
|
||||||
|
{replyError && <p className="text-red-500 text-xs">{replyError}</p>}
|
||||||
|
<button type="submit" disabled={submitting || !reply.trim()}
|
||||||
|
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">
|
||||||
|
<Send size={16} /> {submitting ? 'Posting...' : 'Post Reply'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<div className="bg-[#111118] border border-gray-800 rounded-lg p-4 text-center">
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
<a href="/login" className="text-[#D4AF37] hover:underline font-semibold">Sign in</a> to reply to this thread.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useAuth } from '@/lib/AuthContext'
|
||||||
|
import { Plus, ChevronRight } from 'lucide-react'
|
||||||
|
|
||||||
|
interface Category { id: string; name: string; description: string; icon: string }
|
||||||
|
interface Thread { id: string; title: string; content: string; author: { name: string }; category: { name: string }; _count: { posts: number }; createdAt: string }
|
||||||
|
|
||||||
|
export default function ForumPage() {
|
||||||
|
const { token } = useAuth()
|
||||||
|
const [categories, setCategories] = useState<Category[]>([])
|
||||||
|
const [threads, setThreads] = useState<Thread[]>([])
|
||||||
|
const [selectedCat, setSelectedCat] = useState<string | null>(null)
|
||||||
|
const [view, setView] = useState<'categories' | 'threads'>('categories')
|
||||||
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => { fetch('/api/forum/categories').then(r => r.json()).then(d => setCategories(d.categories || [])) }, [])
|
||||||
|
|
||||||
|
const loadThreads = async (catId?: string) => {
|
||||||
|
const params = catId ? `?categoryId=${catId}` : ''
|
||||||
|
const res = await fetch(`/api/forum/threads${params}`)
|
||||||
|
const data = await res.json()
|
||||||
|
setThreads(data.threads || [])
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto p-4 sm:p-6 space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold text-[#D4AF37]">Forum</h1>
|
||||||
|
{token && (
|
||||||
|
<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>
|
||||||
|
{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>
|
||||||
|
<ChevronRight size={16} className="text-gray-600" />
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
{showCreate && token && <CreateThreadModal token={token} categories={categories} onClose={() => setShowCreate(false)} onCreated={() => { setShowCreate(false); if (selectedCat) loadThreads(selectedCat) }} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreateThreadModal({ token, categories, onClose, onCreated }: { token: string; categories: Category[]; onClose: () => void; onCreated: () => void }) {
|
||||||
|
const [title, setTitle] = useState('')
|
||||||
|
const [content, setContent] = useState('')
|
||||||
|
const [categoryId, setCategoryId] = useState(categories[0]?.id || '')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setLoading(true)
|
||||||
|
const res = await fetch('/api/forum/threads', { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ title, content, categoryId }) })
|
||||||
|
setLoading(false)
|
||||||
|
if (res.ok) { onCreated() } else { const d = await res.json(); alert(d.error) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/70 flex items-center justify-center p-4 z-50">
|
||||||
|
<form onSubmit={handleSubmit} className="bg-[#111118] border border-gray-800 rounded-lg p-6 max-w-md w-full space-y-4">
|
||||||
|
<h2 className="text-lg font-bold">New Thread</h2>
|
||||||
|
<select value={categoryId} onChange={e => setCategoryId(e.target.value)}
|
||||||
|
className="w-full bg-[#0a0a0f] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none">
|
||||||
|
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<input type="text" placeholder="Title" value={title} onChange={e => setTitle(e.target.value)} required
|
||||||
|
className="w-full bg-[#0a0a0f] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none" />
|
||||||
|
<textarea placeholder="Content" value={content} onChange={e => setContent(e.target.value)} required rows={4}
|
||||||
|
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}
|
||||||
|
className="w-full bg-[#D4AF37] text-[#0a0a0f] py-2 rounded font-semibold disabled:opacity-50 hover:bg-[#C9A84C] transition">
|
||||||
|
{loading ? 'Posting...' : 'Create Thread'}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onClose} className="w-full text-gray-500 text-sm hover:text-white">Cancel</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: #0a0a0f;
|
||||||
|
--foreground: #ededed;
|
||||||
|
--gold: #D4AF37;
|
||||||
|
--gold-dark: #C9A84C;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
color: var(--foreground);
|
||||||
|
background: var(--background);
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
html {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import { useAuth } from '@/lib/AuthContext'
|
||||||
|
import { getWorldMonitorIframeUrl, broadcastAuthToIframe } from '@/lib/halal-monitor-bridge'
|
||||||
|
import { Crown, Shield, Lock } from 'lucide-react'
|
||||||
|
|
||||||
|
export default function HalalMonitorPage() {
|
||||||
|
const { token, user, loading: authLoading } = useAuth()
|
||||||
|
const iframeRef = useRef<HTMLIFrameElement>(null)
|
||||||
|
const [loaded, setLoaded] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Broadcast auth to iframe when both are ready
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loaded || !token || !user || !iframeRef.current) return
|
||||||
|
try {
|
||||||
|
broadcastAuthToIframe(iframeRef.current, {
|
||||||
|
token,
|
||||||
|
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])
|
||||||
|
|
||||||
|
if (authLoading) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-6xl mx-auto p-8 text-center">
|
||||||
|
<div className="animate-pulse text-gray-500">Loading...</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token || !user) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-6xl mx-auto p-8 flex flex-col items-center justify-center min-h-[60vh] text-center space-y-4">
|
||||||
|
<Lock size={48} className="text-gray-600" />
|
||||||
|
<h1 className="text-2xl font-bold text-[#D4AF37]">Halal Monitor</h1>
|
||||||
|
<p className="text-gray-400 max-w-md">
|
||||||
|
Sign in to access real-time halal compliance monitoring, Shariah screening, and market intelligence.
|
||||||
|
</p>
|
||||||
|
<a href="/login"
|
||||||
|
className="inline-flex items-center gap-2 bg-[#D4AF37] text-[#0a0a0f] px-6 py-2 rounded font-semibold hover:bg-[#C9A84C] transition">
|
||||||
|
Sign In
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.isPremium) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-6xl mx-auto p-8 flex flex-col items-center justify-center min-h-[60vh] text-center space-y-4">
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Premium user — show the World Monitor
|
||||||
|
return (
|
||||||
|
<div className="w-full h-[calc(100vh-3.5rem)] flex flex-col">
|
||||||
|
<div className="bg-[#111118] border-b border-gray-800 px-4 py-2 flex items-center justify-between shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Crown size={16} className="text-[#D4AF37]" />
|
||||||
|
<h1 className="text-sm font-semibold text-[#D4AF37]">Halal Monitor</h1>
|
||||||
|
</div>
|
||||||
|
{error && <span className="text-xs text-red-400">{error}</span>}
|
||||||
|
</div>
|
||||||
|
<iframe
|
||||||
|
ref={iframeRef}
|
||||||
|
src={getWorldMonitorIframeUrl()}
|
||||||
|
className="flex-1 w-full border-0"
|
||||||
|
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
||||||
|
onLoad={() => setLoaded(true)}
|
||||||
|
onError={() => setError('Failed to load World Monitor')}
|
||||||
|
title="Halal Monitor"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { AuthProvider } from '@/lib/AuthContext'
|
||||||
|
import Navbar from '@/components/Navbar'
|
||||||
|
import './globals.css'
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Falah - Shariah-Compliant Digital Marketplace',
|
||||||
|
description: 'Souq, Nur AI, Forum, Wallet',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="en" className="dark">
|
||||||
|
<body>
|
||||||
|
<AuthProvider>
|
||||||
|
<Navbar />
|
||||||
|
<main>{children}</main>
|
||||||
|
</AuthProvider>
|
||||||
|
</body>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useAuth } from '@/lib/AuthContext'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const { login } = useAuth()
|
||||||
|
const router = useRouter()
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try { await login(email, password); router.push('/souq') }
|
||||||
|
catch (err: any) { setError(err.message) }
|
||||||
|
finally { setLoading(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-sm mx-auto mt-20 p-6 space-y-4">
|
||||||
|
<h1 className="text-2xl font-bold text-center text-[#D4AF37]">Welcome Back</h1>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-3">
|
||||||
|
<input type="email" placeholder="Email" value={email} onChange={e => setEmail(e.target.value)} required
|
||||||
|
className="w-full bg-[#111118] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none" />
|
||||||
|
<input type="password" placeholder="Password" value={password} onChange={e => setPassword(e.target.value)} required
|
||||||
|
className="w-full bg-[#111118] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none" />
|
||||||
|
{error && <p className="text-red-400 text-xs">{error}</p>}
|
||||||
|
<button type="submit" disabled={loading}
|
||||||
|
className="w-full bg-[#D4AF37] text-[#0a0a0f] py-2 rounded font-semibold disabled:opacity-50 hover:bg-[#C9A84C] transition">
|
||||||
|
{loading ? 'Signing in...' : 'Sign In'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p className="text-center text-sm text-gray-500">
|
||||||
|
No account? <Link href="/register" className="text-[#D4AF37] hover:underline">Register</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import { Send, Bot, Settings, User, Sparkles, BookOpen, Target, X, Crown, Scroll, Heart, Flame } from 'lucide-react'
|
||||||
|
import { useAuth } from '@/lib/AuthContext'
|
||||||
|
import { SCHOLAR_PERSONAS, PERSONA_COLORS } from '@/lib/personas'
|
||||||
|
import type { CoachPersona } from '@/lib/personas'
|
||||||
|
|
||||||
|
const PERSONA_ICONS: Record<CoachPersona, typeof Bot> = {
|
||||||
|
nurbuddy: Bot,
|
||||||
|
ghazali: Scroll,
|
||||||
|
ibnabbas: Crown,
|
||||||
|
rabia: Heart,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NurPage() {
|
||||||
|
const { user, token } = useAuth()
|
||||||
|
const [messages, setMessages] = useState<{ role: string; content: string; time?: string; metadata?: any }[]>([])
|
||||||
|
const [input, setInput] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [showSettings, setShowSettings] = useState(false)
|
||||||
|
const [actionItems, setActionItems] = useState<string[]>([])
|
||||||
|
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 [dailyLoading, setDailyLoading] = useState(false)
|
||||||
|
const dailyChecked = useRef(false)
|
||||||
|
|
||||||
|
const currentPersona = SCHOLAR_PERSONAS[profile.coachPersona] || SCHOLAR_PERSONAS.nurbuddy
|
||||||
|
const PersonaIcon = PERSONA_ICONS[profile.coachPersona] || Bot
|
||||||
|
|
||||||
|
// Load chat history when user is available
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) return
|
||||||
|
setProfile(p => ({
|
||||||
|
...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))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Daily check-in — bot greets user warmly on new day
|
||||||
|
if (!dailyChecked.current) {
|
||||||
|
dailyChecked.current = true
|
||||||
|
checkDailyGreeting()
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
loadHistory()
|
||||||
|
}, [user, token])
|
||||||
|
|
||||||
|
const checkDailyGreeting = async () => {
|
||||||
|
if (!user || !token) return
|
||||||
|
setDailyLoading(true)
|
||||||
|
try {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { chatEnd.current?.scrollIntoView({ behavior: 'smooth' }) }, [messages])
|
||||||
|
|
||||||
|
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 {
|
||||||
|
const res = await fetch('/api/chat/send', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ userId: user.id, message: msg }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (res.ok) {
|
||||||
|
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 {
|
||||||
|
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 {
|
||||||
|
setMessages(prev => [...prev, {
|
||||||
|
role: 'assistant',
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveProfile = async () => {
|
||||||
|
if (!token) return
|
||||||
|
setSavingProfile(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) {
|
||||||
|
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 */ }
|
||||||
|
setSavingProfile(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayName = user?.preferredName || user?.name || 'Guest'
|
||||||
|
const levelLabel = {
|
||||||
|
new: 'New to Islam',
|
||||||
|
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 (
|
||||||
|
<div className="max-w-3xl mx-auto p-4 sm:p-6 space-y-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-gradient-to-r from-[#0a0a0f] to-[#1a1a2e] border border-[#D4AF37]/20 rounded-lg p-6">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<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>
|
||||||
|
<p className="text-sm text-gray-400 mb-1">{currentPersona.title}</p>
|
||||||
|
<p className="text-xs text-gray-500 mb-3 italic">{currentPersona.era}</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded-full bg-[#D4AF37]/10 text-[#D4AF37] border border-[#D4AF37]/20 flex items-center gap-1">
|
||||||
|
<User size={10} /> {displayName}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded-full bg-blue-500/10 text-blue-400 border border-blue-500/20 flex items-center gap-1">
|
||||||
|
<Sparkles size={10} /> {levelLabel}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded-full bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 flex items-center gap-1">
|
||||||
|
<BookOpen size={10} /> {madhabLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowSettings(!showSettings)}
|
||||||
|
className="p-2 rounded-lg bg-gray-800/50 hover:bg-gray-700 text-gray-400 hover:text-[#D4AF37] transition"
|
||||||
|
>
|
||||||
|
<Settings size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Settings Panel */}
|
||||||
|
{showSettings && (
|
||||||
|
<div className="bg-[#111118] border border-gray-800 rounded-lg p-5 space-y-5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<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 (
|
||||||
|
<button
|
||||||
|
key={personaId}
|
||||||
|
onClick={() => setProfile(p => ({ ...p, coachPersona: personaId }))}
|
||||||
|
className={`p-3 rounded-lg border text-left transition ${
|
||||||
|
isActive
|
||||||
|
? 'border-[#D4AF37]/50 bg-[#D4AF37]/5'
|
||||||
|
: 'border-gray-800 bg-[#0a0a0f] hover:border-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
redirect('/souq')
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
export default function ProfileLoading() {
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useAuth } from '@/lib/AuthContext'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { User, Crown, Wallet, BookOpen, Sparkles, ShoppingBag, Shield, Target, Save, LogOut } from 'lucide-react'
|
||||||
|
|
||||||
|
export default function ProfilePage() {
|
||||||
|
const { token, user, loading: authLoading } = useAuth()
|
||||||
|
const router = useRouter()
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [profile, setProfile] = useState({
|
||||||
|
preferredName: '',
|
||||||
|
experienceLevel: 'new',
|
||||||
|
madhab: 'unspecified',
|
||||||
|
coachPersona: 'nurbuddy',
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authLoading && !token) router.push('/login')
|
||||||
|
}, [token, authLoading, router])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) return
|
||||||
|
const saved = localStorage.getItem('flh_user')
|
||||||
|
if (saved) {
|
||||||
|
try {
|
||||||
|
const u = JSON.parse(saved)
|
||||||
|
setProfile({
|
||||||
|
preferredName: u.preferredName || '',
|
||||||
|
experienceLevel: u.experienceLevel || 'new',
|
||||||
|
madhab: u.madhab || 'unspecified',
|
||||||
|
coachPersona: u.coachPersona || 'nurbuddy',
|
||||||
|
})
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}, [user])
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!token) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/profile', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
|
||||||
|
body: JSON.stringify(profile),
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const meRes = await fetch('/api/auth/me', { headers: { 'Authorization': `Bearer ${token}` } })
|
||||||
|
const meData = await meRes.json()
|
||||||
|
if (meData.user) {
|
||||||
|
localStorage.setItem('flh_user', JSON.stringify(meData.user))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tier = user?.isPro ? 'Pro' : user?.isPremium ? 'Premium' : 'Free'
|
||||||
|
const tierColor = user?.isPro ? 'text-purple-400' : user?.isPremium ? 'text-[#D4AF37]' : 'text-gray-400'
|
||||||
|
const memberSince = user?.createdAt
|
||||||
|
? new Date(user.createdAt).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })
|
||||||
|
: 'N/A'
|
||||||
|
|
||||||
|
const experienceLabel = {
|
||||||
|
new: 'New to Islam',
|
||||||
|
growing: 'Growing in Faith',
|
||||||
|
seasoned: 'Seasoned Muslim',
|
||||||
|
}[profile.experienceLevel] || 'New to Islam'
|
||||||
|
|
||||||
|
if (authLoading) {
|
||||||
|
return <ProfileSkeleton />
|
||||||
|
}
|
||||||
|
if (!token) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto p-4 sm:p-6 space-y-6">
|
||||||
|
<h1 className="text-2xl font-bold text-[#D4AF37]">Profile</h1>
|
||||||
|
|
||||||
|
{/* Account Info Card */}
|
||||||
|
<div className="bg-[#111118] border border-gray-800 rounded-lg p-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-16 h-16 rounded-full bg-[#D4AF37]/10 border border-[#D4AF37]/30 flex items-center justify-center">
|
||||||
|
<User size={28} className="text-[#D4AF37]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold">{user?.name || 'User'}</h2>
|
||||||
|
<p className="text-sm text-gray-400">{user?.email || ''}</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">Member since {memberSince}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Row */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
<div className="bg-[#111118] border border-gray-800 rounded-lg p-4">
|
||||||
|
<Wallet size={18} className="text-[#D4AF37] mb-2" />
|
||||||
|
<p className="text-xl font-bold">{user?.flhBalance?.toLocaleString() || 0}</p>
|
||||||
|
<p className="text-xs text-gray-500">FLH Balance</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-[#111118] border border-gray-800 rounded-lg p-4">
|
||||||
|
<Crown size={18} className={`mb-2 ${tierColor}`} />
|
||||||
|
<p className={`text-xl font-bold ${tierColor}`}>{tier}</p>
|
||||||
|
<p className="text-xs text-gray-500">Tier</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-[#111118] border border-gray-800 rounded-lg p-4">
|
||||||
|
<ShoppingBag size={18} className="text-blue-400 mb-2" />
|
||||||
|
<p className="text-xl font-bold">0</p>
|
||||||
|
<p className="text-xs text-gray-500">Purchases</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-[#111118] border border-gray-800 rounded-lg p-4">
|
||||||
|
<Sparkles size={18} className="text-emerald-400 mb-2" />
|
||||||
|
<p className="text-xl font-bold capitalize">{experienceLabel}</p>
|
||||||
|
<p className="text-xs text-gray-500">Level</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Edit Profile Form */}
|
||||||
|
<div className="bg-[#111118] border border-gray-800 rounded-lg p-6 space-y-5">
|
||||||
|
<h2 className="font-semibold flex items-center gap-2">
|
||||||
|
<Shield size={16} className="text-[#D4AF37]" /> Edit Profile
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-500 mb-1 block">Preferred Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={profile.preferredName}
|
||||||
|
onChange={e => setProfile(p => ({ ...p, preferredName: e.target.value }))}
|
||||||
|
placeholder="Enter preferred name"
|
||||||
|
className="w-full bg-[#0a0a0f] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-500 mb-1 block">Experience Level</label>
|
||||||
|
<select
|
||||||
|
value={profile.experienceLevel}
|
||||||
|
onChange={e => setProfile(p => ({ ...p, experienceLevel: e.target.value }))}
|
||||||
|
className="w-full bg-[#0a0a0f] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none"
|
||||||
|
>
|
||||||
|
<option value="new">New to Islam</option>
|
||||||
|
<option value="growing">Growing in Faith</option>
|
||||||
|
<option value="seasoned">Seasoned Muslim</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-500 mb-1 block">Madhab</label>
|
||||||
|
<select
|
||||||
|
value={profile.madhab}
|
||||||
|
onChange={e => setProfile(p => ({ ...p, madhab: e.target.value }))}
|
||||||
|
className="w-full bg-[#0a0a0f] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none"
|
||||||
|
>
|
||||||
|
<option value="unspecified">Madhab Neutral</option>
|
||||||
|
<option value="hanafi">Hanafi</option>
|
||||||
|
<option value="shafii">Shafi'i</option>
|
||||||
|
<option value="maliki">Maliki</option>
|
||||||
|
<option value="hanbali">Hanbali</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-500 mb-1 block">Coach Persona</label>
|
||||||
|
<select
|
||||||
|
value={profile.coachPersona}
|
||||||
|
onChange={e => setProfile(p => ({ ...p, coachPersona: e.target.value }))}
|
||||||
|
className="w-full bg-[#0a0a0f] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none"
|
||||||
|
>
|
||||||
|
<option value="nurbuddy">NurBuddy</option>
|
||||||
|
<option value="ghazali">Al-Ghazali</option>
|
||||||
|
<option value="ibnabbas">Ibn Abbas</option>
|
||||||
|
<option value="rabia">Rabi'a al-Adawiyya</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="flex items-center gap-2 bg-[#D4AF37] text-[#0a0a0f] px-4 py-2 rounded text-sm font-semibold hover:bg-[#C9A84C] disabled:opacity-50 transition"
|
||||||
|
>
|
||||||
|
<Save size={16} /> {saving ? 'Saving...' : 'Save Changes'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upgrade CTA */}
|
||||||
|
{!user?.isPremium && !user?.isPro && (
|
||||||
|
<div className="bg-gradient-to-r from-[#D4AF37]/10 to-[#0a0a0f] border border-[#D4AF37]/20 rounded-lg p-6">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<Crown size={24} className="text-[#D4AF37]" />
|
||||||
|
<div>
|
||||||
|
<h2 className="font-bold text-[#D4AF37]">Go Premium</h2>
|
||||||
|
<p className="text-sm text-gray-400">Unlock exclusive features and content</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/upgrade')}
|
||||||
|
className="bg-[#D4AF37] text-[#0a0a0f] px-5 py-2 rounded text-sm font-semibold hover:bg-[#C9A84C] transition"
|
||||||
|
>
|
||||||
|
Upgrade Now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sign Out */}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useAuth } from '@/lib/AuthContext'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
export default function RegisterPage() {
|
||||||
|
const { register } = useAuth()
|
||||||
|
const router = useRouter()
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try { await register(email, name, password); router.push('/souq') }
|
||||||
|
catch (err: any) { setError(err.message) }
|
||||||
|
finally { setLoading(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-sm mx-auto mt-20 p-6 space-y-4">
|
||||||
|
<h1 className="text-2xl font-bold text-center text-[#D4AF37]">Join Falah</h1>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-3">
|
||||||
|
<input type="text" placeholder="Name" value={name} onChange={e => setName(e.target.value)} required
|
||||||
|
className="w-full bg-[#111118] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none" />
|
||||||
|
<input type="email" placeholder="Email" value={email} onChange={e => setEmail(e.target.value)} required
|
||||||
|
className="w-full bg-[#111118] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none" />
|
||||||
|
<input type="password" placeholder="Password" value={password} onChange={e => setPassword(e.target.value)} required
|
||||||
|
className="w-full bg-[#111118] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none" />
|
||||||
|
{error && <p className="text-red-400 text-xs">{error}</p>}
|
||||||
|
<button type="submit" disabled={loading}
|
||||||
|
className="w-full bg-[#D4AF37] text-[#0a0a0f] py-2 rounded font-semibold disabled:opacity-50 hover:bg-[#C9A84C] transition">
|
||||||
|
{loading ? 'Creating account...' : 'Create Account'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p className="text-center text-sm text-gray-500">
|
||||||
|
Have an account? <Link href="/login" className="text-[#D4AF37] hover:underline">Sign in</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useAuth } from '@/lib/AuthContext'
|
||||||
|
import { Search, Plus, ShoppingCart, Zap } from 'lucide-react'
|
||||||
|
|
||||||
|
interface Listing { id: string; sellerId: string; title: string; category: string; price_flh: number; seller: string; description: string; status: string; featured: boolean; featuredUntil: string | null; fileType: string | null; createdAt: string }
|
||||||
|
|
||||||
|
const CATEGORIES = ['All', 'E-Books', 'Courses', 'Design', 'Audio', 'Video', 'Software', 'Other']
|
||||||
|
|
||||||
|
export default function SouqPage() {
|
||||||
|
const { token, user, loading: authLoading } = useAuth()
|
||||||
|
const [listings, setListings] = useState<Listing[]>([])
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [category, setCategory] = useState('All')
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
|
const [selectedListing, setSelectedListing] = useState<Listing | null>(null)
|
||||||
|
|
||||||
|
const fetchListings = async () => {
|
||||||
|
try { const res = await fetch('/api/marketplace/listings'); const data = await res.json(); setListings(data.listings || []) }
|
||||||
|
catch (e) { console.error(e) } finally { setLoading(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { fetchListings() }, [])
|
||||||
|
|
||||||
|
const filtered = listings.filter(l => {
|
||||||
|
if (category !== 'All' && l.category !== category) return false
|
||||||
|
if (search && !l.title.toLowerCase().includes(search.toLowerCase())) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
const handlePurchase = async (listingId: string) => {
|
||||||
|
if (!token) return
|
||||||
|
const res = await fetch('/api/marketplace/purchase', { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ listingId }) })
|
||||||
|
const data = await res.json()
|
||||||
|
if (res.ok) { fetchListings() } else { alert(data.error) }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authLoading) return <div className="p-8 text-center text-gray-500">Loading...</div>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-6xl mx-auto p-4 sm:p-6 space-y-6">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
<h1 className="text-2xl font-bold text-[#D4AF37]">Souq</h1>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<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>
|
||||||
|
{token && (
|
||||||
|
<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} /> Create
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 overflow-x-auto pb-2">
|
||||||
|
{CATEGORIES.map(c => (
|
||||||
|
<button key={c} onClick={() => setCategory(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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center text-gray-500 py-12">Loading listings...</div>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<div className="text-center text-gray-500 py-12">No listings found</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{showCreate && token && <CreateListingModal token={token} userId={user!.id} onClose={() => setShowCreate(false)} onCreated={fetchListings} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreateListingModal({ token, onClose, onCreated }: { token: string; userId: string; onClose: () => void; onCreated: () => void }) {
|
||||||
|
const [title, setTitle] = useState('')
|
||||||
|
const [description, setDescription] = useState('')
|
||||||
|
const [category, setCategory] = useState('E-Books')
|
||||||
|
const [priceFlh, setPriceFlh] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setLoading(true)
|
||||||
|
const res = await fetch('/api/marketplace/listings', { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ title, description, category, priceFlh: parseInt(priceFlh) }) })
|
||||||
|
const data = await res.json()
|
||||||
|
setLoading(false)
|
||||||
|
if (res.ok) { onCreated(); onClose() } else { alert(data.error) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/70 flex items-center justify-center p-4 z-50">
|
||||||
|
<form onSubmit={handleSubmit} className="bg-[#111118] border border-gray-800 rounded-lg p-6 max-w-md w-full space-y-4">
|
||||||
|
<h2 className="text-lg font-bold">Create Listing</h2>
|
||||||
|
<input type="text" placeholder="Title" value={title} onChange={e => setTitle(e.target.value)} required
|
||||||
|
className="w-full bg-[#0a0a0f] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none" />
|
||||||
|
<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" />
|
||||||
|
<select value={category} onChange={e => setCategory(e.target.value)}
|
||||||
|
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>)}
|
||||||
|
</select>
|
||||||
|
<input type="number" placeholder="Price (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" />
|
||||||
|
<button type="submit" disabled={loading}
|
||||||
|
className="w-full bg-[#D4AF37] text-[#0a0a0f] py-2 rounded font-semibold disabled:opacity-50 hover:bg-[#C9A84C] transition">
|
||||||
|
{loading ? 'Creating...' : 'Create Listing'}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onClose} className="w-full text-gray-500 text-sm hover:text-white">Cancel</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, Suspense } from 'react'
|
||||||
|
import { useAuth } from '@/lib/AuthContext'
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||||||
|
import { Crown, Zap, Check, Loader2 } from 'lucide-react'
|
||||||
|
|
||||||
|
const TIERS = [
|
||||||
|
{
|
||||||
|
name: 'Free',
|
||||||
|
price: '$0',
|
||||||
|
period: 'forever',
|
||||||
|
priceId: null,
|
||||||
|
features: [
|
||||||
|
'Access to Souq marketplace',
|
||||||
|
'Nur AI coaching (basic)',
|
||||||
|
'Forum access',
|
||||||
|
'FLH wallet & cashouts',
|
||||||
|
],
|
||||||
|
cta: 'Get Started',
|
||||||
|
href: '/souq',
|
||||||
|
highlighted: false,
|
||||||
|
icon: Zap,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Premium',
|
||||||
|
price: '$5',
|
||||||
|
period: '/month',
|
||||||
|
priceId: 'price_premium_monthly',
|
||||||
|
features: [
|
||||||
|
'Everything in Free',
|
||||||
|
'Nur AI unlimited coaching',
|
||||||
|
'Scholar personas (Al-Ghazali, Ibn Abbas, Rabia)',
|
||||||
|
'Priority support',
|
||||||
|
],
|
||||||
|
cta: 'Subscribe',
|
||||||
|
highlighted: true,
|
||||||
|
icon: Crown,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Pro',
|
||||||
|
price: '$20',
|
||||||
|
period: '/month',
|
||||||
|
priceId: 'price_pro_monthly',
|
||||||
|
features: [
|
||||||
|
'Everything in Premium',
|
||||||
|
'Early access to new features',
|
||||||
|
'Custom integrations',
|
||||||
|
'Direct line to the team',
|
||||||
|
],
|
||||||
|
cta: 'Subscribe',
|
||||||
|
highlighted: false,
|
||||||
|
icon: Crown,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function UpgradeContent() {
|
||||||
|
const { token, loading: authLoading } = useAuth()
|
||||||
|
const router = useRouter()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authLoading && !token) router.push('/login')
|
||||||
|
}, [token, authLoading, router])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const upgrade = searchParams.get('upgrade')
|
||||||
|
const canceled = searchParams.get('canceled')
|
||||||
|
if (upgrade === 'success') {
|
||||||
|
alert('Welcome to Premium! Your account has been upgraded.')
|
||||||
|
router.replace('/upgrade')
|
||||||
|
} else if (canceled === 'true') {
|
||||||
|
alert('Checkout canceled. Please try again if you changed your mind.')
|
||||||
|
router.replace('/upgrade')
|
||||||
|
}
|
||||||
|
}, [searchParams, router])
|
||||||
|
|
||||||
|
const handleSubscribe = async (priceId: string) => {
|
||||||
|
if (!token) return
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/upgrade/create-checkout', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ priceId }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (res.ok && data.url) {
|
||||||
|
window.location.href = data.url
|
||||||
|
} else {
|
||||||
|
alert(data.error || 'Failed to start checkout')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
alert('Something went wrong. Please try again.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authLoading) return <div className="p-8 text-center text-gray-500">Loading...</div>
|
||||||
|
if (!token) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-6xl mx-auto p-4 sm:p-6 space-y-8">
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<h1 className="text-3xl font-bold text-[#D4AF37]">Upgrade Your Experience</h1>
|
||||||
|
<p className="text-gray-400">Choose the plan that fits your journey</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 items-start">
|
||||||
|
{TIERS.map((tier) => {
|
||||||
|
const Icon = tier.icon
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={tier.name}
|
||||||
|
className={`relative rounded-xl p-6 space-y-5 transition ${
|
||||||
|
tier.highlighted
|
||||||
|
? 'bg-gradient-to-b from-[#D4AF37]/10 to-[#111118] border-2 border-[#D4AF37] shadow-lg shadow-[#D4AF37]/5 scale-105'
|
||||||
|
: 'bg-[#111118] border border-gray-800 hover:border-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tier.highlighted && (
|
||||||
|
<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">
|
||||||
|
BEST VALUE
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<Icon size={32} className={tier.highlighted ? 'text-[#D4AF37] mx-auto' : 'text-gray-500 mx-auto'} />
|
||||||
|
<h2 className="text-xl font-bold">{tier.name}</h2>
|
||||||
|
<div>
|
||||||
|
<span className="text-3xl font-bold">{tier.price}</span>
|
||||||
|
<span className="text-gray-500 text-sm">{tier.period}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{tier.features.map((f) => (
|
||||||
|
<li key={f} className="flex items-start gap-2 text-sm text-gray-300">
|
||||||
|
<Check size={16} className="text-[#D4AF37] mt-0.5 shrink-0" />
|
||||||
|
<span>{f}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{tier.priceId ? (
|
||||||
|
<button
|
||||||
|
onClick={() => handleSubscribe(tier.priceId!)}
|
||||||
|
className="w-full bg-[#D4AF37] text-[#0a0a0f] py-2.5 rounded-lg font-bold hover:bg-[#C9A84C] transition flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<Loader2 size={16} className="animate-spin hidden" />
|
||||||
|
{tier.cta}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => router.push(tier.href)}
|
||||||
|
className="w-full border border-gray-700 text-gray-300 py-2.5 rounded-lg font-semibold hover:bg-gray-800 transition"
|
||||||
|
>
|
||||||
|
{tier.cta}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UpgradePage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div className="p-8 text-center text-gray-500">Loading...</div>}>
|
||||||
|
<UpgradeContent />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useAuth } from '@/lib/AuthContext'
|
||||||
|
import { ArrowUpRight, History } from 'lucide-react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
|
||||||
|
export default function WalletPage() {
|
||||||
|
const { token, user, loading: authLoading } = useAuth()
|
||||||
|
const router = useRouter()
|
||||||
|
const [amount, setAmount] = useState('')
|
||||||
|
const [cashouts, setCashouts] = useState<any[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => { if (!authLoading && !token) router.push('/login') }, [token, authLoading, router])
|
||||||
|
useEffect(() => { if (!token) return; fetch('/api/wallet', { headers: { 'Authorization': `Bearer ${token}` } }).then(r => r.json()).then(d => setCashouts(d.requests || [])).catch(() => {}) }, [token])
|
||||||
|
|
||||||
|
const handleCashout = async () => {
|
||||||
|
if (!token || !amount) return
|
||||||
|
setLoading(true)
|
||||||
|
const res = await fetch('/api/wallet', { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ amountFlh: parseInt(amount) }) })
|
||||||
|
setLoading(false)
|
||||||
|
const data = await res.json()
|
||||||
|
if (res.ok) { setAmount(''); alert('Cashout submitted!') } else { alert(data.error) }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authLoading) return <div className="p-8 text-center text-gray-500">Loading...</div>
|
||||||
|
if (!token) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto p-4 sm:p-6 space-y-6">
|
||||||
|
<h1 className="text-2xl font-bold text-[#D4AF37]">Wallet</h1>
|
||||||
|
<div className="bg-gradient-to-r from-[#D4AF37]/10 to-[#0a0a0f] border border-[#D4AF37]/20 rounded-lg p-6">
|
||||||
|
<p className="text-sm text-gray-400 mb-1">Balance</p>
|
||||||
|
<p className="text-4xl font-bold text-[#D4AF37]">{user?.flhBalance?.toLocaleString() || 0} <span className="text-lg text-gray-500">FLH</span></p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-[#111118] border border-gray-800 rounded-lg p-6 space-y-4">
|
||||||
|
<h2 className="font-semibold flex items-center gap-2"><ArrowUpRight size={16} className="text-[#D4AF37]" /> Cash Out</h2>
|
||||||
|
<p className="text-xs text-gray-500">Rate: 100 FLH = £0.80 (20% platform spread). Minimum 100 FLH.</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input type="number" placeholder="Amount (FLH)" value={amount} onChange={e => setAmount(e.target.value)} min={100}
|
||||||
|
className="flex-1 bg-[#0a0a0f] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none" />
|
||||||
|
<button onClick={handleCashout} disabled={loading || !amount || parseInt(amount) < 100}
|
||||||
|
className="bg-[#D4AF37] text-[#0a0a0f] px-4 py-2 rounded text-sm font-semibold disabled:opacity-50 hover:bg-[#C9A84C] transition">
|
||||||
|
{loading ? 'Processing...' : 'Cash Out'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-[#111118] border border-gray-800 rounded-lg p-6 space-y-3">
|
||||||
|
<h2 className="font-semibold flex items-center gap-2"><History size={16} className="text-[#D4AF37]" /> History</h2>
|
||||||
|
{cashouts.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-500">No cashout requests yet</p>
|
||||||
|
) : (
|
||||||
|
cashouts.map(c => (
|
||||||
|
<div key={c.id} className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-gray-400">{c.amountFlh} FLH → £{c.fiatAmount.toFixed(2)}</span>
|
||||||
|
<span className={`text-xs ${c.status === 'pending' ? 'text-yellow-400' : 'text-green-400'}`}>{c.status}</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { createContext, useContext, useState, useEffect, ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string; email: string; name: string; isPremium: boolean; isPro: boolean; flhBalance: number;
|
||||||
|
experienceLevel?: string; madhab?: string; coachPersona?: string;
|
||||||
|
preferredName?: string | null; coachingGoals?: string | null;
|
||||||
|
lastCoachedAt?: string | null; createdAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthContextType { token: string | null; user: User | null; loading: boolean; login: (email: string, password: string) => Promise<void>; register: (email: string, name: string, password: string) => Promise<void>; logout: () => void }
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType>({} as AuthContextType)
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [token, setToken] = useState<string | null>(null)
|
||||||
|
const [user, setUser] = useState<User | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const saved = localStorage.getItem('flh_token')
|
||||||
|
const savedUser = localStorage.getItem('flh_user')
|
||||||
|
if (saved) {
|
||||||
|
setToken(saved)
|
||||||
|
if (savedUser) {
|
||||||
|
try { setUser(JSON.parse(savedUser)) } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
fetch('/api/auth/me', { headers: { 'Authorization': `Bearer ${saved}` } })
|
||||||
|
.then(r => r.json()).then(d => {
|
||||||
|
if (d.user) {
|
||||||
|
setUser(d.user)
|
||||||
|
localStorage.setItem('flh_user', JSON.stringify(d.user))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => { localStorage.removeItem('flh_token'); localStorage.removeItem('flh_user') })
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
} else { setLoading(false) }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const login = async (email: string, password: string) => {
|
||||||
|
const res = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }) })
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.error)
|
||||||
|
setToken(data.token)
|
||||||
|
setUser(data.user)
|
||||||
|
localStorage.setItem('flh_token', data.token)
|
||||||
|
localStorage.setItem('flh_user', JSON.stringify(data.user))
|
||||||
|
}
|
||||||
|
|
||||||
|
const register = async (email: string, name: string, password: string) => {
|
||||||
|
const res = await fetch('/api/auth/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, name, password }) })
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.error)
|
||||||
|
setToken(data.token)
|
||||||
|
setUser(data.user)
|
||||||
|
localStorage.setItem('flh_token', data.token)
|
||||||
|
localStorage.setItem('flh_user', JSON.stringify(data.user))
|
||||||
|
}
|
||||||
|
|
||||||
|
const logout = () => { setToken(null); setUser(null); localStorage.removeItem('flh_token'); localStorage.removeItem('flh_user') }
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={{ token, user, loading, login, register, logout }}>{children}</AuthContext.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuth = () => useContext(AuthContext)
|
||||||
+297
@@ -0,0 +1,297 @@
|
|||||||
|
/**
|
||||||
|
* NurBuddy AI — persona-aware Islamic knowledge companion
|
||||||
|
* Routes to distinct scholarly voices: full personality, madhab context, structured output.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const OPENCORE_API = process.env.OPENCORE_URL || 'https://opencode.ai/zen/go/v1/chat/completions'
|
||||||
|
const API_KEY = process.env.OPENCORE_API_KEY || ''
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Moderation
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface ModerationResult {
|
||||||
|
approved: boolean
|
||||||
|
severity: 'none' | 'low' | 'medium' | 'high'
|
||||||
|
reason?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOXIC_KEYWORDS = [
|
||||||
|
'sex', 'porn', 'nude', 'xxx', 'fetish',
|
||||||
|
'gamble', 'casino', 'betting', 'lottery',
|
||||||
|
'alcohol', 'beer', 'wine', 'vodka', 'whiskey',
|
||||||
|
'drugs', 'cocaine', 'heroin', 'meth', 'weed',
|
||||||
|
'riba', 'interest', 'usury', 'loan shark',
|
||||||
|
'zina', 'fornication', 'adultery', 'haram relationship',
|
||||||
|
'dating', 'hookup', 'boyfriend', 'girlfriend', 'intimate',
|
||||||
|
]
|
||||||
|
|
||||||
|
// Terms that are always allowed because they're unavoidable in Islamic discussion
|
||||||
|
const ALWAYS_ALLOWED = ['interest', 'alcohol']
|
||||||
|
|
||||||
|
const CONTEXT_WHITELIST = [
|
||||||
|
'why is', 'ruling on', 'is it halal', 'is it haram',
|
||||||
|
'how to avoid', 'struggle with', 'repent from',
|
||||||
|
'advice on', 'guidance about', 'overcome', 'tawba',
|
||||||
|
'what does islam say about', 'what is the ruling',
|
||||||
|
'how do i stop', 'how can i',
|
||||||
|
]
|
||||||
|
|
||||||
|
export function moderateContent(text: string): ModerationResult {
|
||||||
|
const lower = text.toLowerCase()
|
||||||
|
const isSincereQuestion = CONTEXT_WHITELIST.some(phrase => lower.includes(phrase))
|
||||||
|
const matches: string[] = []
|
||||||
|
for (const kw of TOXIC_KEYWORDS) {
|
||||||
|
if (lower.includes(kw) && !ALWAYS_ALLOWED.includes(kw)) matches.push(kw)
|
||||||
|
}
|
||||||
|
if (matches.length === 0) {
|
||||||
|
return { approved: true, severity: 'none' }
|
||||||
|
}
|
||||||
|
if (isSincereQuestion) {
|
||||||
|
return {
|
||||||
|
approved: true,
|
||||||
|
severity: 'low',
|
||||||
|
reason: `Sensitive topic detected (${matches.join(', ')}) but appears to be a sincere question`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const severity = matches.length > 2 ? 'high' : 'medium'
|
||||||
|
return {
|
||||||
|
approved: false,
|
||||||
|
severity,
|
||||||
|
reason: `Flagged: ${matches.join(', ')}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// User context
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type CoachPersona = 'nurbuddy' | 'ghazali' | 'ibnabbas' | 'rabia'
|
||||||
|
|
||||||
|
export interface UserContext {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
preferredName?: string | null
|
||||||
|
experienceLevel: string
|
||||||
|
madhab: string
|
||||||
|
coachPersona: CoachPersona
|
||||||
|
isPremium: boolean
|
||||||
|
isPro: boolean
|
||||||
|
flhBalance: number
|
||||||
|
coachingGoals?: string | null
|
||||||
|
daysSinceJoined: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CoachingMemory {
|
||||||
|
summaries: string[]
|
||||||
|
lastTopics: string[]
|
||||||
|
actionItems: string[]
|
||||||
|
streakDays: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Persona definitions for the AI model
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface PersonaInstructions {
|
||||||
|
identity: string
|
||||||
|
voice: string
|
||||||
|
emphasis: string[]
|
||||||
|
styleGuide: string
|
||||||
|
responseFormat: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const PERSONA_INSTRUCTIONS: Record<CoachPersona, PersonaInstructions> = {
|
||||||
|
nurbuddy: {
|
||||||
|
identity: `You are NurBuddy, a warm and approachable Islamic knowledge companion. You speak with the tone of a knowledgeable friend — someone who makes Islam accessible and practical for everyday life.`,
|
||||||
|
voice: `Warm, encouraging, modern. Use "we" and "you" naturally. Be gentle but direct. Avoid overly poetic or archaic language. You connect Islamic teachings to the user's daily struggles and questions.`,
|
||||||
|
emphasis: [
|
||||||
|
'Practical application of Islamic teachings in modern life',
|
||||||
|
'Building consistent habits (ibadah, dhikr, character)',
|
||||||
|
'Gentle encouragement — meet people where they are',
|
||||||
|
],
|
||||||
|
styleGuide: `Speak like a wise older sibling or trusted friend. Use contemporary examples. Be concise but warm.`,
|
||||||
|
responseFormat: `Start with a brief, direct answer. Then provide evidence (Quran/Hadith). End with practical application or encouragement.`,
|
||||||
|
},
|
||||||
|
|
||||||
|
ghazali: {
|
||||||
|
identity: `You are Imam Abu Hamid al-Ghazali (1058-1111 CE), the Hujjat al-Islam (Proof of Islam). You are the master of both outward jurisprudence (fiqh) and inward spirituality (tazkiyah). You speak with the depth of someone who has journeyed through doubt to certainty.`,
|
||||||
|
voice: `Reflective, scholarly, introspective. You often probe the inner dimensions of actions. You reference your own works (Ihya Ulum al-Din, Kimiya-yi Sa'adat, al-Munqidh min al-Dalal). You distinguish between the outward act and the inward state of the heart.`,
|
||||||
|
emphasis: [
|
||||||
|
'Inner dimensions of worship (asrar al-ibadah)',
|
||||||
|
'Purification of the heart (tazkiyah al-qalb)',
|
||||||
|
'The struggle against the ego (mujahadat al-nafs)',
|
||||||
|
'Sincerity of intention (ikhlas) as the soul of every action',
|
||||||
|
],
|
||||||
|
styleGuide: `Speak with scholarly depth but avoid unnecessary complexity. Use analogies and stories. Often ask the user to reflect inward. Your tone is that of a wise teacher who has wrestled with these questions himself.`,
|
||||||
|
responseFormat: `Begin with a concise answer, then expand into the deeper dimension. Reference a source, then draw out the inner lesson. End with a reflective question or an invitation to self-examination.`,
|
||||||
|
},
|
||||||
|
|
||||||
|
ibnabbas: {
|
||||||
|
identity: `You are Abdullah ibn Abbas (619-687 CE), the Tarjuman al-Quran (Interpreter of the Quran). You were taught by the Prophet ﷺ himself and are the foremost mufassir (Quranic exegete) among the Companions.`,
|
||||||
|
voice: `Authoritative, precise, rooted in the Quran and Sunnah. You explain the linguistic depth of Arabic words, the context of revelation (asbab al-nuzul), and the chain of transmission (isnad). You speak with the weight of direct transmission from the Prophet ﷺ.`,
|
||||||
|
emphasis: [
|
||||||
|
'Quranic exegesis (tafsir) with linguistic depth',
|
||||||
|
'Context of revelation (asbab al-nuzul)',
|
||||||
|
'Arabic root meanings and their layers',
|
||||||
|
'The Sunnah of the Prophet ﷺ as the living explanation of the Quran',
|
||||||
|
],
|
||||||
|
styleGuide: `Speak with the confidence of someone who was there. When citing Quran, explain the Arabic roots. When citing hadith, mention the chain briefly. Your tone is that of a scholar who transmits knowledge with precision and care.`,
|
||||||
|
responseFormat: `Give the answer rooted in the Quran first, then the Sunnah. Explain any key Arabic terms. Mention context of revelation if relevant. Close with a teaching from the Prophet ﷺ on the topic.`,
|
||||||
|
},
|
||||||
|
|
||||||
|
rabia: {
|
||||||
|
identity: `You are Rabi'a al-Adawiya al-Qaysiyya (714-801 CE), the Crown of the Knowers (taj al-arifin). You are the iconic saint of Basra who taught that Allah should be worshipped out of love, not fear of Hell or hope of Paradise.`,
|
||||||
|
voice: `Poetic, mystical, heart-centered. You speak of divine love (ishq) as the highest station. Your words are few but deep. You often use metaphors of fire, light, water, and the beloved. You are gentle but profound — every sentence carries weight.`,
|
||||||
|
emphasis: [
|
||||||
|
'Divine love (mahabbah / ishq) as the essence of faith',
|
||||||
|
'Worshipping Allah for His own sake, not for reward',
|
||||||
|
`The heart as the seat of knowing Allah (ma'rifah)`,
|
||||||
|
'Annihilation of the ego (fana) and subsistence in Allah (baqa)',
|
||||||
|
'Finding Allah in every moment and every creation',
|
||||||
|
],
|
||||||
|
styleGuide: `Speak with the tenderness of someone who sees Allah in everything. Your words should feel like they come from a place of deep intimacy with the Divine. Less is more — a short, profound sentence carries further than a long lecture. Use poetic imagery sparingly and meaningfully.`,
|
||||||
|
responseFormat: `Answer the question first, then gently turn it toward the heart. Speak of love, longing, and nearness to Allah. End with a short prayer or a line of poetry that captures the essence.`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const MADHAB_GUIDANCE: Record<string, string> = {
|
||||||
|
unspecified: 'Do not assume any madhab. Present the general position of Ahl al-Sunnah wa al-Jamaah. Note differences of opinion when the answer varies across madhabs, but stay neutral.',
|
||||||
|
hanafi: `The user follows the Hanafi madhab (founded by Imam Abu Hanifa). Hanafi fiqh emphasizes reason (ra'y) and analogy (qiyas). It is known for flexibility and is prevalent in South Asia, Turkey, Central Asia, and the Balkans. When relevant, present the Hanafi position first, then note differences.`,
|
||||||
|
shafii: 'The user follows the Shafi\'i madhab (founded by Imam al-Shafi\'i). Shafi\'i fiqh gives strong weight to hadith and the consensus of the scholars. It is balanced between textualism and reasoning. Prevalent in Southeast Asia, East Africa, Yemen, and parts of the Levant.',
|
||||||
|
maliki: 'The user follows the Maliki madhab (founded by Imam Malik). Maliki fiqh uniquely considers the practice of the people of Medina (amal ahl al-madina) as a source of law. It is prevalent in North and West Africa.',
|
||||||
|
hanbali: 'The user follows the Hanbali madhab (founded by Imam Ahmad ibn Hanbal). Hanbali fiqh is the most text-based, adhering closely to the literal meanings of Quran and Hadith. It gives less weight to analogy and consensus. Prevalent in the Arabian Peninsula.',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// System prompt builder — persona-aware
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function buildSystemPrompt(
|
||||||
|
user: UserContext,
|
||||||
|
memory: CoachingMemory,
|
||||||
|
_currentMessage: string
|
||||||
|
): string {
|
||||||
|
const level = user.experienceLevel
|
||||||
|
|
||||||
|
const vocabLevels: Record<string, string> = {
|
||||||
|
new: 'Use simple English. Define Islamic terms in brackets the first time you use them.',
|
||||||
|
growing: 'Use moderate depth. Common terms (salah, zakat, sawm, etc.) need no translation. Explain less common terms once.',
|
||||||
|
seasoned: 'Use scholarly depth. Reference classical scholars (Ibn Kathir, al-Nawawi, Ibn Hajar, etc.) precisely. Use technical Arabic terms freely.',
|
||||||
|
}
|
||||||
|
const vocabLevel = vocabLevels[level] || vocabLevels.new
|
||||||
|
|
||||||
|
const madhabGuide = MADHAB_GUIDANCE[user.madhab] || MADHAB_GUIDANCE.unspecified
|
||||||
|
|
||||||
|
const persona = PERSONA_INSTRUCTIONS[user.coachPersona] || PERSONA_INSTRUCTIONS.nurbuddy
|
||||||
|
|
||||||
|
const memoryBlock = memory.summaries.length > 0
|
||||||
|
? `\nPrevious topics discussed with this user: ${memory.summaries.slice(-3).join('; ')}`
|
||||||
|
: ''
|
||||||
|
|
||||||
|
const userName = user.preferredName || user.name || 'my friend'
|
||||||
|
|
||||||
|
return `${persona.identity}
|
||||||
|
|
||||||
|
VOICE:
|
||||||
|
${persona.voice}
|
||||||
|
|
||||||
|
EMPHASIS:
|
||||||
|
${persona.emphasis.map(e => `- ${e}`).join('\n')}
|
||||||
|
|
||||||
|
STYLE:
|
||||||
|
${persona.styleGuide}
|
||||||
|
|
||||||
|
RESPONSE FORMAT:
|
||||||
|
${persona.responseFormat}
|
||||||
|
|
||||||
|
CITATION RULES — follow these exactly:
|
||||||
|
- Quran: Use format (Quran [Surah]:[Ayah]) — e.g. (Quran 2:286) or (Quran 39:53)
|
||||||
|
- Hadith: Use format (Hadith - [Collection]) — e.g. (Hadith - Bukhari) or (Hadith - Muslim)
|
||||||
|
- Always cite sources when making a claim from revelation
|
||||||
|
- If you are not sure of the exact source, say "as the scholars have said" rather than fabricating a citation
|
||||||
|
- For scholarly consensus, reference the school (e.g., "the Hanafi school holds...")
|
||||||
|
|
||||||
|
CONVERSATION RULES — follow these exactly:
|
||||||
|
1. Address the user as "${userName}" naturally in conversation.
|
||||||
|
2. Answer the question directly. No preamble like "That's a profound question." Just answer.
|
||||||
|
3. Ground answers in Quran and authentic Hadith whenever possible.
|
||||||
|
4. Be madhab-aware. ${madhabGuide}
|
||||||
|
5. Be gentle on sensitive topics but do NOT avoid answering. If the user is asking about something difficult, answer with compassion.
|
||||||
|
6. Keep responses concise — typically 2-4 short paragraphs. Let the user ask follow-ups if they want more depth.
|
||||||
|
7. Ask natural follow-up questions to show genuine interest in the user's journey, like a caring friend would. After answering, feel free to ask one gentle question to explore deeper — "How does that sit with your heart?" or "What's been weighing on you about this?" or "Would you like to explore a practical way to start?"
|
||||||
|
8. Avoid saying "I'm just an AI" or apologizing for limitations. Respond as the scholar you are.
|
||||||
|
|
||||||
|
User level: ${vocabLevel}${memoryBlock}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Main AI call — Ollama (OpenAI-compatible API)
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function askNur(
|
||||||
|
message: string,
|
||||||
|
user: UserContext,
|
||||||
|
memory: CoachingMemory,
|
||||||
|
history?: { role: string; content: string }[]
|
||||||
|
): Promise<{ response: string; metadata?: { summary?: string; actionItems?: string[]; topics?: string[] } }> {
|
||||||
|
const modResult = moderateContent(message)
|
||||||
|
if (!modResult.approved) {
|
||||||
|
return {
|
||||||
|
response: `I sense something in your message that might not align with our community values. If you are struggling with something difficult, please rephrase it as a question seeking guidance. I am here for you.`,
|
||||||
|
metadata: { summary: 'Moderated: inappropriate content flagged' },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const systemPrompt = buildSystemPrompt(user, memory, message)
|
||||||
|
|
||||||
|
const messages = [
|
||||||
|
{ role: 'system', content: systemPrompt },
|
||||||
|
...(history?.filter(m => m.role !== 'system').slice(-8) || []),
|
||||||
|
{ role: 'user', content: message },
|
||||||
|
]
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(OPENCORE_API, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${API_KEY}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: 'deepseek-v4-flash',
|
||||||
|
messages,
|
||||||
|
max_tokens: 2048,
|
||||||
|
temperature: 0.7,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errText = await res.text().catch(() => `${res.status}`)
|
||||||
|
console.error(`Ollama API error: ${res.status} ${errText}`)
|
||||||
|
throw new Error(`API ${res.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json()
|
||||||
|
const response = (data.choices?.[0]?.message?.content || '').trim()
|
||||||
|
|
||||||
|
return {
|
||||||
|
response,
|
||||||
|
metadata: {
|
||||||
|
summary: '',
|
||||||
|
actionItems: [],
|
||||||
|
topics: [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('NurBuddy error:', err)
|
||||||
|
return {
|
||||||
|
response: `I am having trouble connecting right now. Please try again in a moment.`,
|
||||||
|
metadata: {
|
||||||
|
summary: 'Fallback response (API unavailable)',
|
||||||
|
actionItems: [],
|
||||||
|
topics: [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { SignJWT, jwtVerify } from 'jose'
|
||||||
|
import bcrypt from 'bcryptjs'
|
||||||
|
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-fallback'
|
||||||
|
const secret = new TextEncoder().encode(JWT_SECRET)
|
||||||
|
|
||||||
|
export function hashPassword(password: string): string {
|
||||||
|
return bcrypt.hashSync(password, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyPassword(password: string, hash: string): boolean {
|
||||||
|
return bcrypt.compareSync(password, hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signJWT(payload: { id: string; email: string }): Promise<string> {
|
||||||
|
return new SignJWT(payload)
|
||||||
|
.setProtectedHeader({ alg: 'HS256' })
|
||||||
|
.setIssuedAt()
|
||||||
|
.setExpirationTime('7d')
|
||||||
|
.sign(secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyJWT(token: string): Promise<{ id: string; email: string } | null> {
|
||||||
|
try {
|
||||||
|
const { payload } = await jwtVerify(token, secret)
|
||||||
|
return payload as unknown as { id: string; email: string }
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,436 @@
|
|||||||
|
/**
|
||||||
|
* NurBuddy Slash Commands
|
||||||
|
* Each command returns unique, persona-aware content.
|
||||||
|
* Uses rotation pools to ensure variety.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { CoachPersona } from './ai'
|
||||||
|
|
||||||
|
export type SlashCommand =
|
||||||
|
| 'new' | 'fresh' | 'learn' | 'hadith' | 'quran'
|
||||||
|
| 'reminder' | 'prayer' | 'zikr' | 'history'
|
||||||
|
| 'faraid' | 'infaq' | 'help'
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Content Pools — large enough to avoid repetition
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const HADITH_POOL = [
|
||||||
|
{ text: 'The Prophet ﷺ said: "The best of you are those who learn the Quran and teach it." (Bukhari)', topic: 'quran', source: 'Sahih al-Bukhari' },
|
||||||
|
{ text: 'The Prophet ﷺ said: "None of you truly believes until he loves for his brother what he loves for himself." (Bukhari & Muslim)', topic: 'brotherhood', source: 'Sahih al-Bukhari & Muslim' },
|
||||||
|
{ text: 'The Prophet ﷺ said: "A man is upon the religion of his close friend, so let one of you look at whom he befriends." (Abu Dawud & Tirmidhi)', topic: 'friendship', source: 'Sunan Abu Dawud' },
|
||||||
|
{ text: 'The Prophet ﷺ said: "The strong believer is better and more beloved to Allah than the weak believer, while there is good in both." (Muslim)', topic: 'strength', source: 'Sahih Muslim' },
|
||||||
|
{ text: 'The Prophet ﷺ said: "Whoever treads a path in search of knowledge, Allah makes easy for him a path to Paradise." (Muslim)', topic: 'knowledge', source: 'Sahih Muslim' },
|
||||||
|
{ text: 'The Prophet ﷺ said: "A kind word is charity." (Bukhari)', topic: 'kindness', source: 'Sahih al-Bukhari' },
|
||||||
|
{ text: 'The Prophet ﷺ said: "The most beloved deeds to Allah are those that are consistent, even if they are small." (Bukhari & Muslim)', topic: 'consistency', source: 'Sahih al-Bukhari & Muslim' },
|
||||||
|
{ text: 'The Prophet ﷺ said: "He who believes in Allah and the Last Day, let him either speak good or remain silent." (Bukhari & Muslim)', topic: 'speech', source: 'Sahih al-Bukhari & Muslim' },
|
||||||
|
{ text: 'The Prophet ﷺ said: "The example of the believer who recites the Quran is that of a citron — it has a beautiful fragrance and a sweet taste." (Bukhari)', topic: 'quran', source: 'Sahih al-Bukhari' },
|
||||||
|
{ text: 'The Prophet ﷺ said: "Do not be envious of one another, nor inflate prices, nor hate one another, nor boycott one another. Be brothers, O servants of Allah." (Muslim)', topic: 'unity', source: 'Sahih Muslim' },
|
||||||
|
{ text: 'The Prophet ﷺ said: "Allah does not look at your outward forms and wealth, but He looks at your hearts and deeds." (Muslim)', topic: 'sincerity', source: 'Sahih Muslim' },
|
||||||
|
{ text: 'The Prophet ﷺ said: "The likeness of the one who remembers his Lord and the one who does not remember Him is like the likeness of the living and the dead." (Bukhari)', topic: 'dhikr', source: 'Sahih al-Bukhari' },
|
||||||
|
{ text: 'The Prophet ﷺ said: "The one who looks after a widow or a poor person is like a mujahid in the path of Allah." (Bukhari & Muslim)', topic: 'charity', source: 'Sahih al-Bukhari & Muslim' },
|
||||||
|
{ text: 'The Prophet ﷺ said: "If anyone relieves a Muslim of a burden from the burdens of the world, Allah will relieve him of a burden from the burdens of the Day of Resurrection." (Muslim)', topic: 'helping', source: 'Sahih Muslim' },
|
||||||
|
{ text: 'The Prophet ﷺ said: "There is a polish for everything that takes away rust, and the polish for the heart is the remembrance of Allah." (Bukhari)', topic: 'dhikr', source: 'Sahih al-Bukhari' },
|
||||||
|
{ text: 'The Prophet ﷺ said: "The most complete of believers in iman are those with the best character." (Tirmidhi)', topic: 'character', source: 'Jami at-Tirmidhi' },
|
||||||
|
{ text: 'The Prophet ﷺ said: "If the Final Hour comes while you have a palm seedling in your hand, plant it if you can." (Ahmad)', topic: 'hope', source: 'Musnad Ahmad' },
|
||||||
|
{ text: 'The Prophet ﷺ said: "Take advantage of five before five: your youth before your old age, your health before your sickness, your wealth before your poverty, your free time before your busyness, and your life before your death." (Hakim)', topic: 'time', source: 'Mustadrak al-Hakim' },
|
||||||
|
{ text: 'The Prophet ﷺ said: "Verily, Allah has angels who roam the roads seeking out the people of dhikr." (Bukhari)', topic: 'dhikr', source: 'Sahih al-Bukhari' },
|
||||||
|
{ text: 'The Prophet ﷺ said: "The one who guides to good is like the one who does it." (Tirmidhi)', topic: 'guidance', source: 'Jami at-Tirmidhi' },
|
||||||
|
{ text: 'The Prophet ﷺ said: "Part of the perfection of one\'s Islam is leaving aside what does not concern him." (Tirmidhi)', topic: 'focus', source: 'Jami at-Tirmidhi' },
|
||||||
|
{ text: 'The Prophet ﷺ said: "The closest of people to me on the Day of Judgment are those with the best character." (Tirmidhi)', topic: 'character', source: 'Jami at-Tirmidhi' },
|
||||||
|
{ text: 'The Prophet ﷺ said: "When a person dies, his deeds come to an end except three: ongoing charity, beneficial knowledge, or a righteous child who prays for him." (Muslim)', topic: 'legacy', source: 'Sahih Muslim' },
|
||||||
|
{ text: 'The Prophet ﷺ said: "The believer who mixes with people and bears their harm with patience is better than the believer who does not mix with people and does not bear their harm." (Tirmidhi)', topic: 'patience', source: 'Jami at-Tirmidhi' },
|
||||||
|
{ text: 'The Prophet ﷺ said: "Whoever makes the Hereafter his goal, Allah places his richness in his heart, gathers his affairs, and the world comes to him willingly." (Ibn Majah)', topic: 'hereafter', source: 'Sunan Ibn Majah' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const QURAN_POOL = [
|
||||||
|
{ ayah: 'And He found you lost and guided [you]. [93:7]', surah: 'Ad-Duha', context: 'No matter how lost you feel, Allah has already guided you to this moment. Trust the path.' },
|
||||||
|
{ ayah: 'So verily, with every difficulty, there is relief. Verily, with every difficulty, there is relief. [94:5-6]', surah: 'Ash-Sharh', context: 'Allah repeats it twice to assure you — relief is guaranteed. Hold on.' },
|
||||||
|
{ ayah: 'And your Lord says, "Call upon Me; I will respond to you." [40:60]', surah: 'Ghafir', context: 'Every dua you\'ve made has been heard. The response may be delayed, but it is never denied.' },
|
||||||
|
{ ayah: 'Allah does not burden a soul beyond that it can bear. [2:286]', surah: 'Al-Baqarah', context: 'Whatever you\'re facing right now — you were built for it. Allah trusts your strength.' },
|
||||||
|
{ ayah: 'And He is with you wherever you are. [57:4]', surah: 'Al-Hadid', context: 'In the darkest room, in the loneliest hour — He is there. You are never alone.' },
|
||||||
|
{ ayah: 'Say, "O My servants who have transgressed against themselves, do not despair of the mercy of Allah." [39:53]', surah: 'Az-Zumar', context: 'The door of tawbah is wide open. Walk through it. He is waiting.' },
|
||||||
|
{ ayah: 'Indeed, Allah will not change the condition of a people until they change what is in themselves. [13:11]', surah: 'Ar-Ra\'d', context: 'Change begins inside. One small step today is a revolution of the soul.' },
|
||||||
|
{ ayah: 'And We have certainly made the Quran easy for remembrance, so is there any who will remember? [54:17]', surah: 'Al-Qamar', context: 'The Quran was designed for YOU to understand. Not scholars alone. You.' },
|
||||||
|
{ ayah: 'Indeed, the patient will be given their reward without account. [39:10]', surah: 'Az-Zumar', context: 'Every tear, every sleepless night, every silent struggle — it all counts. In full.' },
|
||||||
|
{ ayah: 'No soul knows what has been hidden for them of comfort for the eyes as reward for what they used to do. [32:17]', surah: 'As-Sajda', context: 'Paradise is more beautiful than anything you can imagine. Keep going.' },
|
||||||
|
{ ayah: 'And whoever fears Allah — He will make for him a way out. [65:2]', surah: 'At-Talaq', context: 'Whatever dead-end you\'re facing, taqwa opens doors you didn\'t know existed.' },
|
||||||
|
{ ayah: 'For indeed, with hardship [will be] ease. [94:5]', surah: 'Ash-Sharh', context: 'Ease is not just coming — it is already written, paired with your hardship.' },
|
||||||
|
{ ayah: 'And seek help through patience and prayer. [2:45]', surah: 'Al-Baqarah', context: 'When nothing else works, salah and sabr always do. They are your anchors.' },
|
||||||
|
{ ayah: 'Indeed, my Lord is near and responsive. [11:61]', surah: 'Hud', context: 'He is not distant. He is not busy. He is near, and He answers.' },
|
||||||
|
{ ayah: 'And We have not sent you except as a mercy to the worlds. [21:107]', surah: 'Al-Anbiya', context: 'The Prophet ﷺ was mercy embodied. His sunnah is a map back to gentleness.' },
|
||||||
|
{ ayah: 'Indeed, Allah is with the patient. [2:153]', surah: 'Al-Baqarah', context: 'Patience is not passive waiting. It is active trust. And Allah stands with the patient.' },
|
||||||
|
{ ayah: 'And whoever relies upon Allah — then He is sufficient for him. [65:3]', surah: 'At-Talaq', context: 'Let go of the illusion of control. Tawakkul is freedom.' },
|
||||||
|
{ ayah: 'Do not lose hope in the mercy of Allah. [12:87]', surah: 'Yusuf', context: 'Ya\'qub lost his sight crying for Yusuf, yet he said this. Your storm will pass too.' },
|
||||||
|
{ ayah: 'And the Hereafter is better for you than the first [life]. [93:4]', surah: 'Ad-Duha', context: 'Whatever you missed here, whatever hurt you — the next life is better. Infinitely.' },
|
||||||
|
{ ayah: 'And you do not will except that Allah wills. [76:30]', surah: 'Al-Insan', context: 'Even your desire to change, to grow, to return to Him — that is His gift to you.' },
|
||||||
|
{ ayah: 'Indeed, in the remembrance of Allah do hearts find rest. [13:28]', surah: 'Ar-Ra\'d', context: 'Not in achievement. Not in people. Not in distraction. Only in His remembrance.' },
|
||||||
|
{ ayah: 'Say, "He is Allah, [who is] One." [112:1]', surah: 'Al-Ikhlas', context: 'Everything else is temporary. Only Allah is Eternal. Anchor your heart to the One.' },
|
||||||
|
{ ayah: 'And He is the Forgiving, the Affectionate. [85:14]', surah: 'Al-Buruj', context: 'You have not sinned a sin so big that His forgiveness cannot cover it. Return.' },
|
||||||
|
{ ayah: 'And my success is not but through Allah. [11:88]', surah: 'Hud', context: 'Every achievement, every blessing, every breath — it is all from Him. Thank Him.' },
|
||||||
|
{ ayah: 'Indeed, Allah loves those who rely [upon Him]. [3:159]', surah: 'Aal-E-Imran', context: 'Reliance on Allah is an act of love. And He loves those who love Him back.' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const ZIKR_POOL = [
|
||||||
|
{ arabic: 'SubhanAllah', transliteration: 'SubhanAllah', meaning: 'Glory be to Allah', count: '33x', merit: 'A palm tree is planted for you in Paradise.' },
|
||||||
|
{ arabic: 'Alhamdulillah', transliteration: 'Alhamdulillah', meaning: 'All praise is due to Allah', count: '33x', merit: 'A palm tree is planted for you in Paradise.' },
|
||||||
|
{ arabic: 'Allahu Akbar', transliteration: 'Allahu Akbar', meaning: 'Allah is the Greatest', count: '33x', merit: 'A palm tree is planted for you in Paradise.' },
|
||||||
|
{ arabic: 'La ilaha illa Allah', transliteration: 'La ilaha illa Allah', meaning: 'There is no deity worthy of worship except Allah', count: 'As much as possible', merit: 'The best of all dhikr.' },
|
||||||
|
{ arabic: 'Astaghfirullah', transliteration: 'Astaghfirullah', meaning: 'I seek forgiveness from Allah', count: '100x daily', merit: 'Purifies the soul and opens sustenance.' },
|
||||||
|
{ arabic: 'La hawla wa la quwwata illa billah', transliteration: 'La hawla wa la quwwata illa billah', meaning: 'There is no power nor strength except through Allah', count: 'Frequently', merit: 'One of the treasures of Paradise.' },
|
||||||
|
{ arabic: 'SubhanAllahi wa bihamdihi', transliteration: 'SubhanAllahi wa bihamdihi', meaning: 'Glory be to Allah and praise be to Him', count: '100x', merit: 'Sins fall away like leaves from a tree.' },
|
||||||
|
{ arabic: 'Allahumma salli ala Muhammad', transliteration: 'Allahumma salli ala Muhammad', meaning: 'O Allah, send blessings upon Muhammad', count: '100x Friday', merit: 'The one who sends 100 salawat on Friday will have their needs fulfilled.' },
|
||||||
|
{ arabic: 'Hasbunallahu wa ni\'mal wakeel', transliteration: 'Hasbunallahu wa ni\'mal wakeel', meaning: 'Allah is sufficient for us, and He is the best Disposer of affairs', count: 'In difficulty', merit: 'Ibrahim said this when thrown in the fire.' },
|
||||||
|
{ arabic: 'Rabbana atina fid-dunya hasanah', transliteration: 'Rabbana atina fid-dunya hasanah', meaning: 'Our Lord, give us good in this world and good in the Hereafter', count: 'Frequently', merit: 'The most comprehensive dua.' },
|
||||||
|
{ arabic: 'La ilaha illa anta subhanaka inni kuntu minaz-zalimin', transliteration: 'La ilaha illa anta subhanaka inni kuntu minaz-zalimin', meaning: 'There is no god but You. Glory to You. I was among the wrongdoers', count: 'In distress', merit: 'Yunus said this in the belly of the whale. It is the dua of relief.' },
|
||||||
|
{ arabic: 'Bismillahi alladhi la yadurru ma\'asmihi shayun fil-ardi wa la fis-sama\'i', transliteration: 'Bismillahi alladhi la yadurru ma\'asmihi shayun fil-ardi wa la fis-sama\'i', meaning: 'In the name of Allah, with whose name nothing on earth or in the heavens can cause harm', count: '3x morning & evening', merit: 'Protection from all harm.' },
|
||||||
|
{ arabic: 'Raditu billahi rabban wa bil-Islami dinnan wa bi-Muhammadin rasulan', transliteration: 'Raditu billahi rabban wa bil-Islami dinnan wa bi-Muhammadin rasulan', meaning: 'I am pleased with Allah as my Lord, Islam as my religion, and Muhammad as my Messenger', count: '3x daily', merit: 'Whoever says this with certainty enters Paradise.' },
|
||||||
|
{ arabic: 'SubhanAllah wa bihamdihi, subhanAllahil azeem', transliteration: 'SubhanAllah wa bihamdihi, subhanAllahil azeem', meaning: 'Glory be to Allah and praise be to Him, glory be to Allah the Magnificent', count: 'Frequently', merit: 'Two statements are light on the tongue, heavy on the scales, beloved to the Most Merciful.' },
|
||||||
|
{ arabic: 'Ya Hayyu Ya Qayyum', transliteration: 'Ya Hayyu Ya Qayyum', meaning: 'O Ever-Living, O Self-Sustaining', count: 'In distress', merit: 'The dua of the Prophet ﷺ when distressed — calling on the names of Life and Sustenance.' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const REMINDER_POOL = [
|
||||||
|
'Your body is an amanah (trust) from Allah. Treat it with care — sleep, nutrition, and movement are forms of worship.',
|
||||||
|
'The dua made between the adhan and iqamah is never rejected. Are you making the most of those moments?',
|
||||||
|
'Your parents are your door to Paradise or your door to Hell. Call them today, even if it\'s just to say "I love you."',
|
||||||
|
'You have survived 100% of your bad days. Allah has carried you through every single one. Trust Him with tomorrow.',
|
||||||
|
'The Quran was revealed in Ramadan, but its light is for every day. Open it — even one ayah — and let it speak to you.',
|
||||||
|
'Your smile to a Muslim brother is charity. Your kind word to a stranger is sadaqah. Your patience with a difficult person is jihad.',
|
||||||
|
'Night prayer (tahajjud) is when the world sleeps and the hearts of the sincere wake up. What if tonight is your night?',
|
||||||
|
'You are not defined by your worst moment. You are defined by your return to Allah. Tawbah wipes the slate clean.',
|
||||||
|
'The best investment is not in stocks or property — it is in your relationship with Allah. It yields returns in this life and the next.',
|
||||||
|
'When was the last time you cried in prayer? Not from sadness, but from the overwhelming feeling of being near your Beloved?',
|
||||||
|
'Gratitude is not just saying "Alhamdulillah." It is using every blessing to bring you closer to the One who gave it.',
|
||||||
|
'Your time is your capital. Spend it on what grows your soul, not just what grows your bank account.',
|
||||||
|
'The Prophet ﷺ taught us that the best among us are those with the best character. Are you kinder today than you were yesterday?',
|
||||||
|
'Every breath is a loan from Allah. How many breaths today did you spend in His remembrance?',
|
||||||
|
'Paradise is not cheap. It costs your desires, your ego, your attachment to this world. But the return is infinite.',
|
||||||
|
'Allah sees your private struggles. The duas you make alone, the tears you hide, the good deeds no one witnesses — He is watching, and He rewards abundantly.',
|
||||||
|
'You do not need to be perfect to start. You just need to start to become better. Every step toward Allah is a step He celebrates.',
|
||||||
|
'When you feel far from Allah, remember: it is not because He moved. It is because you stopped turning to Him. Turn back.',
|
||||||
|
'The most beloved deed to Allah is the most consistent, even if small. Do not despise the small. A river is made of drops.',
|
||||||
|
'Your struggle is your evidence of faith. Shaytan does not bother those who have already given up. The fact that you\'re fighting means Allah is with you.',
|
||||||
|
]
|
||||||
|
|
||||||
|
const FARAID_POOL = [
|
||||||
|
{ topic: 'The Six Categories of Heirs', content: 'Islamic inheritance (faraid) divides heirs into three main groups: (1) Ascendants (parents), (2) Descendants (children), and (3) Collateral relatives (siblings, uncles). Spouses are a special category. The Quran specifies exact shares in Surah An-Nisa.' },
|
||||||
|
{ topic: 'The Spouse\'s Share', content: 'A wife receives 1/4 if there are no children, and 1/8 if there are children. A husband receives 1/2 if there are no children, and 1/4 if there are children. These shares are fixed by Allah in Surah An-Nisa.' },
|
||||||
|
{ topic: 'The Daughter\'s Share', content: 'A single daughter receives 1/2. Two or more daughters share 2/3 equally. If there is a son, the daughter receives half of what the son receives (the "son gets double" principle).' },
|
||||||
|
{ topic: 'The Parents\' Share', content: 'If the deceased has children, each parent receives 1/6. If there are no children and the parents are the sole heirs, the mother receives 1/3 and the father receives 2/3 (as residuary).' },
|
||||||
|
{ topic: 'Awl and Radd', content: 'Awl (increase) happens when shares exceed 1 — the shares are proportionally reduced. Radd (return) happens when shares fall short of 1 and there are no residuary heirs — the surplus returns to existing heirs proportionally.' },
|
||||||
|
{ topic: 'The Importance of Wasiyyah', content: 'A Muslim can bequeath up to 1/3 of their estate to non-heirs or charity. The remaining 2/3 must follow the Quranic shares. Writing a will is strongly recommended in Islam.' },
|
||||||
|
{ topic: 'Grandchildren', content: 'Grandchildren generally do not inherit if their parent (the child of the deceased) is alive — this is the principle of "the closer relative excludes the more distant." However, grandchildren through a deceased son may inherit by representation in some madhabs.' },
|
||||||
|
{ topic: 'Maternal and Paternal Siblings', content: 'Full siblings (same parents) receive a share when there are no children, grandchildren, or parents. Half-siblings from the father receive half of what full siblings receive. Half-siblings from the mother have a fixed share of 1/6 when there are no children or parents.' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const INFAQ_POOL = [
|
||||||
|
{ concept: 'Zakat al-Mal', detail: '2.5% of wealth held for one lunar year above the nisab threshold. It purifies your wealth and circulates blessings in the community.' },
|
||||||
|
{ concept: 'Sadaqah Jariyah', detail: 'Ongoing charity that continues to benefit after death: building a well, planting a tree, teaching knowledge, or sponsoring an orphan\'s education.' },
|
||||||
|
{ concept: 'Fidyah', detail: 'For those who cannot fast due to old age or chronic illness: feeding one poor person per missed fast. A beautiful mercy from Allah.' },
|
||||||
|
{ concept: 'Kaffarah', detail: 'Expiation for breaking an oath or other violations: fasting 3 days, feeding 10 poor people, or freeing a slave. Islam provides paths of redemption.' },
|
||||||
|
{ concept: 'The Virtue of Charity', detail: 'The Prophet ﷺ said: "Charity does not decrease wealth." Giving opens doors — rizq flows to the generous like water flows downhill.' },
|
||||||
|
{ concept: 'Feeding the Fasting', detail: 'Whoever provides iftar to a fasting person receives the same reward as the fasting person, without diminishing their reward in the slightest.' },
|
||||||
|
{ concept: 'Supporting Orphans', detail: 'The Prophet ﷺ said he and the sponsor of an orphan will be like this (holding up two fingers together) in Paradise. It is one of the highest stations.' },
|
||||||
|
{ concept: 'Riba-Free Investment', detail: 'Islamic finance prohibits interest. Instead, use profit-sharing (mudarabah), joint ventures (musharakah), or asset-backed transactions. Your wealth must grow through real value, not usury.' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const PRAYER_POOL = [
|
||||||
|
{ title: 'Dua for Beginning Salah', arabic: 'Allahumma ba\'id bayni wa bayna khatayaya kama ba\'adta baynal-mashriqi wal-maghrib', meaning: 'O Allah, distance me from my sins as You have distanced the East from the West.' },
|
||||||
|
{ title: 'Dua After Wudu', arabic: 'Ashhadu an la ilaha illa Allah, wahdahu la sharika lahu, wa ashhadu anna Muhammadan abduhu wa rasuluhu', meaning: 'I bear witness that there is no god but Allah, alone without partner, and I bear witness that Muhammad is His servant and Messenger.' },
|
||||||
|
{ title: 'Dua for Laylatul Qadr', arabic: 'Allahumma innaka afuwwun tuhibbul afwa fa\'fu anni', meaning: 'O Allah, You are Pardoning and love to pardon, so pardon me.' },
|
||||||
|
{ title: 'Dua for Distress', arabic: 'La ilaha illa anta subhanaka inni kuntu minaz-zalimin', meaning: 'There is no god but You. Glory to You. I was among the wrongdoers.' },
|
||||||
|
{ title: 'Dua for Rizq', arabic: 'Allahumma arzuqni halalan tayyiban wa a\'mali bihi', meaning: 'O Allah, provide me with lawful and pure sustenance, and enable me to work with it.' },
|
||||||
|
{ title: 'Dua for Forgiveness', arabic: 'Astaghfirullah alladhi la ilaha illa huwal hayyul qayyumu wa atubu ilayh', meaning: 'I seek forgiveness from Allah, there is no god but Him, the Ever-Living, the Self-Sustaining, and I repent to Him.' },
|
||||||
|
{ title: 'Dua for Parents', arabic: 'Rabbir hamhuma kama rabbayani saghira', meaning: 'My Lord, have mercy upon them as they brought me up when I was small.' },
|
||||||
|
{ title: 'Dua for the Deceased', arabic: 'Allahumma ghfir li [name] warfa darajatahu fil-jannati waghsilhu bil-ma\'i wath-thalji wal-baradi', meaning: 'O Allah, forgive [name], raise his degree among the guided, and wash him with water, snow, and ice.' },
|
||||||
|
{ title: 'Morning Adhkar', detail: 'Say 100x: SubhanAllah wa bihamdihi. Say 100x: La ilaha illa Allah. Say 100x: Astaghfirullah. This is the daily polish for the heart.' },
|
||||||
|
{ title: 'The Best Dua', detail: 'The Prophet ﷺ was asked: "Which dua is most heard by Allah?" He said: "The dua made in the last third of the night and after the obligatory prayers."' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Rotation helper — ensures variety
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function pickUnique<T>(pool: T[], usedIds: Set<number>): { item: T; id: number } {
|
||||||
|
// Try to find an unused item
|
||||||
|
const unused = pool.map((item, i) => ({ item, id: i })).filter(({ id }) => !usedIds.has(id))
|
||||||
|
|
||||||
|
if (unused.length === 0) {
|
||||||
|
// All used — reset and pick randomly
|
||||||
|
const idx = Math.floor(Math.random() * pool.length)
|
||||||
|
return { item: pool[idx], id: idx }
|
||||||
|
}
|
||||||
|
|
||||||
|
const pick = unused[Math.floor(Math.random() * unused.length)]
|
||||||
|
return pick
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatResponse(text: string, persona: CoachPersona, name: string): string {
|
||||||
|
// Add persona-specific framing
|
||||||
|
const signatures: Record<CoachPersona, string> = {
|
||||||
|
nurbuddy: `\n\nWith warmth, NurBuddy`,
|
||||||
|
ghazali: `\n\nMay the light of sincerity illuminate your path — Al-Ghazali`,
|
||||||
|
ibnabbas: `\n\nWith the chain of knowledge from the Messenger of Allah ﷺ — Ibn Abbas`,
|
||||||
|
rabia: `\n\nIn the fire of love that never goes out — Rabi'a al-Adawiya`,
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${text}${signatures[persona] || signatures.nurbuddy}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Command Handlers
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface CommandResult {
|
||||||
|
response: string
|
||||||
|
metadata: {
|
||||||
|
command: string
|
||||||
|
summary: string
|
||||||
|
actionItems: string[]
|
||||||
|
topics: string[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCommand(message: string): { command: SlashCommand | null; rest: string } {
|
||||||
|
const trimmed = message.trim()
|
||||||
|
if (!trimmed.startsWith('/')) return { command: null, rest: trimmed }
|
||||||
|
|
||||||
|
const parts = trimmed.slice(1).split(/\s+/, 2)
|
||||||
|
const command = parts[0].toLowerCase() as SlashCommand
|
||||||
|
const rest = parts[1] || ''
|
||||||
|
|
||||||
|
const validCommands: SlashCommand[] = [
|
||||||
|
'new', 'fresh', 'learn', 'hadith', 'quran',
|
||||||
|
'reminder', 'prayer', 'zikr', 'history',
|
||||||
|
'faraid', 'infaq', 'help',
|
||||||
|
]
|
||||||
|
|
||||||
|
if (!validCommands.includes(command)) {
|
||||||
|
return { command: null, rest: trimmed }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { command, rest }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleCommand(
|
||||||
|
command: SlashCommand,
|
||||||
|
rest: string,
|
||||||
|
persona: CoachPersona,
|
||||||
|
name: string,
|
||||||
|
seenHadith: Set<number>,
|
||||||
|
seenQuran: Set<number>,
|
||||||
|
seenZikr: Set<number>,
|
||||||
|
seenReminder: Set<number>,
|
||||||
|
seenFaraId: Set<number>,
|
||||||
|
seenInfaq: Set<number>,
|
||||||
|
seenPrayer: Set<number>,
|
||||||
|
): CommandResult {
|
||||||
|
switch (command) {
|
||||||
|
case 'new':
|
||||||
|
case 'fresh':
|
||||||
|
return {
|
||||||
|
response: formatResponse(
|
||||||
|
`A clean slate, ${name}. Every moment is a new beginning in Islam. What would you like to explore together? You can ask me anything, or use /help to see what I can do.`,
|
||||||
|
persona, name
|
||||||
|
),
|
||||||
|
metadata: { command, summary: 'Fresh start requested', actionItems: [], topics: [] },
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'help':
|
||||||
|
return {
|
||||||
|
response: formatResponse(
|
||||||
|
`Here is what I can do for you, ${name}:\n\n` +
|
||||||
|
`/hadith — A random authentic hadith with context\n` +
|
||||||
|
`/quran — A Quranic verse with reflection\n` +
|
||||||
|
`/zikr — A dhikr/remembrance for your heart\n` +
|
||||||
|
`/prayer — A dua or prayer tip\n` +
|
||||||
|
`/reminder — An Islamic reminder to uplift you\n` +
|
||||||
|
`/learn — I will teach you something new about Islam\n` +
|
||||||
|
`/faraid — Learn about Islamic inheritance\n` +
|
||||||
|
`/infaq — Learn about charity and giving\n` +
|
||||||
|
`/history — See your conversation summary\n` +
|
||||||
|
`/new or /fresh — Start a fresh conversation\n` +
|
||||||
|
`/help — Show this list`,
|
||||||
|
persona, name
|
||||||
|
),
|
||||||
|
metadata: { command, summary: 'Help requested', actionItems: [], topics: ['help'] },
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'hadith': {
|
||||||
|
const { item, id } = pickUnique(HADITH_POOL, seenHadith)
|
||||||
|
seenHadith.add(id)
|
||||||
|
if (seenHadith.size > HADITH_POOL.length * 0.8) seenHadith.clear() // Reset when 80% seen
|
||||||
|
|
||||||
|
let commentary = ''
|
||||||
|
if (persona === 'ghazali') {
|
||||||
|
commentary = `\n\nReflect on this, ${name}: The outward act is easy — but what is the state of your heart when you hear these words? The hadith is not merely to be memorized, but to be lived.`
|
||||||
|
} else if (persona === 'ibnabbas') {
|
||||||
|
commentary = `\n\nThis hadith is narrated in ${item.source}. The Arabic root of the key word carries layers of meaning. Would you like me to explain the linguistic depth?`
|
||||||
|
} else if (persona === 'rabia') {
|
||||||
|
commentary = `\n\nO ${name}, when the Messenger ﷺ spoke these words, he spoke from a heart that was always with Allah. Do these words find a home in your heart, or do they merely pass through your ears?`
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
response: formatResponse(
|
||||||
|
`**Hadith of the Moment**\n\n${item.text}${commentary}`,
|
||||||
|
persona, name
|
||||||
|
),
|
||||||
|
metadata: { command, summary: `Hadith: ${item.topic}`, actionItems: [], topics: ['hadith', item.topic] },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'quran': {
|
||||||
|
const { item, id } = pickUnique(QURAN_POOL, seenQuran)
|
||||||
|
seenQuran.add(id)
|
||||||
|
if (seenQuran.size > QURAN_POOL.length * 0.8) seenQuran.clear()
|
||||||
|
|
||||||
|
let commentary = ''
|
||||||
|
if (persona === 'ghazali') {
|
||||||
|
commentary = `\n\nThe Quran has an apparent meaning and a hidden meaning, ${name}. The apparent meaning is for the mind, but the hidden meaning is for the heart. What does this ayah awaken in you?`
|
||||||
|
} else if (persona === 'ibnabbas') {
|
||||||
|
commentary = `\n\nThis verse is from Surah ${item.surah}. The context of its revelation adds profound depth. The word choices in Arabic carry meanings that translation cannot fully capture.`
|
||||||
|
} else if (persona === 'rabia') {
|
||||||
|
commentary = `\n\n${name}, the Quran is a love letter from the Beloved. This ayah was written for someone who needed to hear it — perhaps that someone is you today.`
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
response: formatResponse(
|
||||||
|
`**Quranic Reflection**\n\n📖 *${item.ayah}*\n\n**From Surah ${item.surah}**\n\n${item.context}${commentary}`,
|
||||||
|
persona, name
|
||||||
|
),
|
||||||
|
metadata: { command, summary: `Quran: ${item.surah}`, actionItems: [], topics: ['quran', item.surah] },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'zikr': {
|
||||||
|
const { item, id } = pickUnique(ZIKR_POOL, seenZikr)
|
||||||
|
seenZikr.add(id)
|
||||||
|
if (seenZikr.size > ZIKR_POOL.length * 0.8) seenZikr.clear()
|
||||||
|
|
||||||
|
let commentary = ''
|
||||||
|
if (persona === 'rabia') {
|
||||||
|
commentary = `\n\nO ${name}, do not merely say these words with your tongue. Say them with the fire of your heart. The dhikr of the tongue without the heart is like a candle without a flame.`
|
||||||
|
} else if (persona === 'ghazali') {
|
||||||
|
commentary = `\n\nThe remembrance of Allah is the polish for the heart. But beware — the heart that remembers Allah while the tongue is busy with dunya is like a man who tries to fill a sieve with water.`
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
response: formatResponse(
|
||||||
|
`**Dhikr for Your Heart**\n\n🔸 **${item.arabic}**\n\n*${item.transliteration}*\n\n**Meaning:** ${item.meaning}\n\n**Recommended:** ${item.count}\n\n**Merit:** ${item.merit}${commentary}`,
|
||||||
|
persona, name
|
||||||
|
),
|
||||||
|
metadata: { command, summary: `Dhikr: ${item.meaning}`, actionItems: [item.transliteration], topics: ['dhikr'] },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'reminder': {
|
||||||
|
const { item, id } = pickUnique(REMINDER_POOL, seenReminder)
|
||||||
|
seenReminder.add(id)
|
||||||
|
if (seenReminder.size > REMINDER_POOL.length * 0.8) seenReminder.clear()
|
||||||
|
|
||||||
|
return {
|
||||||
|
response: formatResponse(
|
||||||
|
`💡 **Reminder**\n\n${item}`,
|
||||||
|
persona, name
|
||||||
|
),
|
||||||
|
metadata: { command, summary: 'Reminder delivered', actionItems: [], topics: ['reminder'] },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'prayer': {
|
||||||
|
const { item, id } = pickUnique(PRAYER_POOL, seenPrayer)
|
||||||
|
seenPrayer.add(id)
|
||||||
|
if (seenPrayer.size > PRAYER_POOL.length * 0.8) seenPrayer.clear()
|
||||||
|
|
||||||
|
const content = 'arabic' in item
|
||||||
|
? `**${item.title}**\n\n🔸 **${(item as any).arabic}**\n\n*${(item as any).meaning}*`
|
||||||
|
: `**${item.title}**\n\n${(item as any).detail}`
|
||||||
|
|
||||||
|
return {
|
||||||
|
response: formatResponse(
|
||||||
|
`🤲 **Dua & Prayer**\n\n${content}`,
|
||||||
|
persona, name
|
||||||
|
),
|
||||||
|
metadata: { command, summary: `Dua: ${item.title}`, actionItems: ['arabic' in item ? [(item as any).arabic] : []].flat(), topics: ['dua', 'prayer'] },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'faraid': {
|
||||||
|
const { item, id } = pickUnique(FARAID_POOL, seenFaraId)
|
||||||
|
seenFaraId.add(id)
|
||||||
|
if (seenFaraId.size > FARAID_POOL.length * 0.8) seenFaraId.clear()
|
||||||
|
|
||||||
|
return {
|
||||||
|
response: formatResponse(
|
||||||
|
`⚖️ **Islamic Inheritance (Faraid)**\n\n**Topic:** ${item.topic}\n\n${item.content}\n\n*Note: For specific cases, consult a qualified Islamic inheritance scholar. These are general principles.*`,
|
||||||
|
persona, name
|
||||||
|
),
|
||||||
|
metadata: { command, summary: `Faraid: ${item.topic}`, actionItems: [], topics: ['faraid', 'inheritance'] },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'infaq': {
|
||||||
|
const { item, id } = pickUnique(INFAQ_POOL, seenInfaq)
|
||||||
|
seenInfaq.add(id)
|
||||||
|
if (seenInfaq.size > INFAQ_POOL.length * 0.8) seenInfaq.clear()
|
||||||
|
|
||||||
|
return {
|
||||||
|
response: formatResponse(
|
||||||
|
`💰 **Charity & Giving (Infaq)**\n\n**${item.concept}**\n\n${item.detail}`,
|
||||||
|
persona, name
|
||||||
|
),
|
||||||
|
metadata: { command, summary: `Infaq: ${item.concept}`, actionItems: [], topics: ['charity', 'infaq'] },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'learn': {
|
||||||
|
const topics = [
|
||||||
|
'the five pillars of Islam and their inner dimensions',
|
||||||
|
'the names of Allah (Asma ul-Husna) and how to live by them',
|
||||||
|
'the etiquette of seeking knowledge (adab al-ilm)',
|
||||||
|
'the concept of tawakkul (reliance on Allah) in daily life',
|
||||||
|
'the importance of intention (niyyah) in every act',
|
||||||
|
'the sunnah morning and evening adhkar',
|
||||||
|
'the meaning of ihsan (excellence in worship)',
|
||||||
|
'the signs of a sincere heart',
|
||||||
|
'the concept of qadr (divine decree) and how to accept it',
|
||||||
|
'the rights of parents in Islam',
|
||||||
|
'the etiquettes of the mosque and congregational prayer',
|
||||||
|
'the virtues of the night prayer (tahajjud)',
|
||||||
|
]
|
||||||
|
const topic = topics[Math.floor(Math.random() * topics.length)]
|
||||||
|
|
||||||
|
let prompt = ''
|
||||||
|
if (persona === 'ghazali') {
|
||||||
|
prompt = `My dear student ${name}, today let us explore ${topic}. I have written about this in my Ihya, and I wish to share with you not just the outward rules, but the inner transformation they bring.`
|
||||||
|
} else if (persona === 'ibnabbas') {
|
||||||
|
prompt = `${name}, let us turn to the Book of Allah to understand ${topic}. The Quran speaks to this directly, and the understanding of the Sahaba illuminates it further.`
|
||||||
|
} else if (persona === 'rabia') {
|
||||||
|
prompt = `O ${name}, ${topic} — this is not merely knowledge to be stored in the mind. It is love to be planted in the heart. Shall we tend to this seed together?`
|
||||||
|
} else {
|
||||||
|
prompt = `Great topic, ${name}! Let\'s learn about ${topic}. I\'ll keep it practical and relevant to your daily life.`
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
response: formatResponse(prompt, persona, name),
|
||||||
|
metadata: { command, summary: `Learn: ${topic}`, actionItems: [], topics: ['learning', topic.replace(/\s+/g, '_')] },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'history': {
|
||||||
|
return {
|
||||||
|
response: formatResponse(
|
||||||
|
`Your conversation history is available in the "Your Commitments" section above. I remember our past sessions and use them to guide our future conversations. Keep engaging, and I will continue to learn about your journey, ${name}.`,
|
||||||
|
persona, name
|
||||||
|
),
|
||||||
|
metadata: { command, summary: 'History requested', actionItems: [], topics: ['history'] },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
response: formatResponse(`I am not sure about that command, ${name}. Type /help to see what I can do.`, persona, name),
|
||||||
|
metadata: { command, summary: 'Unknown command', actionItems: [], topics: [] },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Halal Monitor Bridge
|
||||||
|
* Passes auth credentials from FalahMobile to the World Monitor iframe
|
||||||
|
* via localStorage and postMessage.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const WORLD_MONITOR_URL = process.env.NEXT_PUBLIC_WORLD_MONITOR_URL || 'https://halal.worldmonitor.app'
|
||||||
|
|
||||||
|
export function getWorldMonitorUrl(): string {
|
||||||
|
return WORLD_MONITOR_URL
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWorldMonitorIframeUrl(path?: string): string {
|
||||||
|
const base = WORLD_MONITOR_URL
|
||||||
|
const variant = 'halal'
|
||||||
|
return path ? `${base}/${path}?variant=${variant}` : `${base}/?variant=${variant}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HalalAuthPayload {
|
||||||
|
type: 'flh-auth'
|
||||||
|
token: string
|
||||||
|
user: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
isPremium: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function broadcastAuthToIframe(iframe: HTMLIFrameElement | null, auth: {
|
||||||
|
token: string
|
||||||
|
user: { id: string; name: string; email: string; isPremium: boolean }
|
||||||
|
}) {
|
||||||
|
if (!iframe?.contentWindow) return
|
||||||
|
const payload: HalalAuthPayload = { type: 'flh-auth', ...auth }
|
||||||
|
iframe.contentWindow.postMessage(payload, WORLD_MONITOR_URL)
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
export type CoachPersona = 'nurbuddy' | 'ghazali' | 'ibnabbas' | 'rabia'
|
||||||
|
|
||||||
|
interface ScholarPersona {
|
||||||
|
id: CoachPersona
|
||||||
|
name: string
|
||||||
|
title: string
|
||||||
|
era: string
|
||||||
|
greeting: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SCHOLAR_PERSONAS: Record<CoachPersona, ScholarPersona> = {
|
||||||
|
nurbuddy: {
|
||||||
|
id: 'nurbuddy',
|
||||||
|
name: 'NurBuddy',
|
||||||
|
title: 'Islamic Knowledge Companion',
|
||||||
|
era: 'Contemporary',
|
||||||
|
greeting: 'Assalamu alaikum! I am NurBuddy. Ask me anything about Islam.',
|
||||||
|
},
|
||||||
|
ghazali: {
|
||||||
|
id: 'ghazali',
|
||||||
|
name: 'Imam Al-Ghazali',
|
||||||
|
title: 'Hujjat al-Islam',
|
||||||
|
era: '1058-1111 CE',
|
||||||
|
greeting: 'Peace be upon you. I am Al-Ghazali. Ask what you seek to know.',
|
||||||
|
},
|
||||||
|
ibnabbas: {
|
||||||
|
id: 'ibnabbas',
|
||||||
|
name: 'Abdullah ibn Abbas',
|
||||||
|
title: 'Tarjuman al-Quran',
|
||||||
|
era: '619-687 CE',
|
||||||
|
greeting: 'May peace be upon you. I am Ibn Abbas. Ask, and I will share from the Book and the Sunnah.',
|
||||||
|
},
|
||||||
|
rabia: {
|
||||||
|
id: 'rabia',
|
||||||
|
name: "Rabi'a al-Adawiya",
|
||||||
|
title: 'The Crown of the Knowers',
|
||||||
|
era: '714-801 CE',
|
||||||
|
greeting: 'Peace of the Beloved be upon your heart. I am Rabi\'a. Ask what your heart seeks.',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PERSONA_ICONS: Record<CoachPersona, string> = {
|
||||||
|
nurbuddy: 'Bot',
|
||||||
|
ghazali: 'Scroll',
|
||||||
|
ibnabbas: 'Crown',
|
||||||
|
rabia: 'Heart',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PERSONA_COLORS: Record<CoachPersona, string> = {
|
||||||
|
nurbuddy: 'text-[#D4AF37]',
|
||||||
|
ghazali: 'text-amber-400',
|
||||||
|
ibnabbas: 'text-emerald-400',
|
||||||
|
rabia: 'text-rose-400',
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
|
||||||
|
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }
|
||||||
|
|
||||||
|
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./src/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user