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:
wmj2024
2026-06-28 04:27:21 +08:00
commit 1f60f1c908
66 changed files with 4713 additions and 0 deletions
+80
View File
@@ -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
```
+7
View File
@@ -0,0 +1,7 @@
node_modules
.next
*.db
.env
.git
Dockerfile
docker-compose.yml
+91
View File
@@ -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
+83
View File
@@ -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
+87
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"]
+13
View File
@@ -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;
+137
View File
@@ -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*
+6
View File
@@ -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.
+10
View File
@@ -0,0 +1,10 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
turbopack: {
root: process.cwd(),
},
};
export default nextConfig;
+35
View File
@@ -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"
}
}
+6
View File
@@ -0,0 +1,6 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
}
export default config
BIN
View File
Binary file not shown.
+179
View File
@@ -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}]
}
+76
View File
@@ -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();
});
+83
View File
@@ -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
}
]
}
]
}
]
}
+14
View File
@@ -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 }) }
}
+13
View File
@@ -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 })
}
+62
View File
@@ -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 })
}
}
+17
View File
@@ -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 }) }
}
+140
View File
@@ -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 })
}
}
+14
View File
@@ -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 })
}
+180
View File
@@ -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 })
}
}
+7
View File
@@ -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' })
}
+18
View File
@@ -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 })
}
+35
View File
@@ -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 })
}
+41
View File
@@ -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 })
}
+38
View File
@@ -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 })
}
+25
View File
@@ -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 })
}
+5
View File
@@ -0,0 +1,5 @@
import { NextResponse } from 'next/server'
export async function GET() {
return NextResponse.json({ status: 'ok', service: 'falah-mobile' })
}
+20
View File
@@ -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 })
}
+37
View File
@@ -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 })
}
+28
View File
@@ -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 })
}
+5
View File
@@ -0,0 +1,5 @@
import { NextResponse } from 'next/server'
export async function GET() {
return NextResponse.json({ status: 'ok', service: 'nurbuddy' })
}
+54
View File
@@ -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 })
}
}
+14
View File
@@ -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 })
}
+28
View File
@@ -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 })
}
+177
View File
@@ -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> &middot; {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>
)
}
+105
View File
@@ -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">&larr; 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>
)
}
+35
View File
@@ -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;
}
}
+102
View File
@@ -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>
)
}
+22
View File
@@ -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>');
}
+111
View File
@@ -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>
);
}
+44
View File
@@ -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>
)
}
+384
View File
@@ -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&apos;i</option>
<option value="maliki">Maliki</option>
<option value="hanbali">Hanbali</option>
</select>
</div>
</div>
<button
onClick={handleSaveProfile}
disabled={savingProfile}
className="bg-[#D4AF37] text-[#0a0a0f] px-4 py-2 rounded text-sm font-semibold hover:bg-[#C9A84C] disabled:opacity-50 transition"
>
{savingProfile ? 'Saving...' : 'Save Profile'}
</button>
</div>
)}
{/* Action Items */}
{actionItems.length > 0 && (
<div className="bg-[#111118] border border-[#D4AF37]/10 rounded-lg p-4">
<div className="flex items-center gap-2 mb-2">
<Target size={14} className="text-[#D4AF37]" />
<h3 className="text-xs font-semibold text-[#D4AF37]">Your Commitments</h3>
</div>
<div className="flex flex-wrap gap-2">
{actionItems.map((item, i) => (
<span key={i} className="text-xs px-2 py-1 rounded-full bg-[#D4AF37]/5 text-gray-300 border border-[#D4AF37]/10">
{item.length > 60 ? item.slice(0, 60) + '...' : item}
</span>
))}
</div>
</div>
)}
{/* Chat */}
<div className="bg-[#111118] border border-gray-800 rounded-lg p-4 space-y-3 max-h-[60vh] overflow-y-auto">
{messages.length === 0 ? (
<div className="text-center py-8 space-y-3">
<PersonaIcon className="text-gray-600 mx-auto" size={32} />
<p className="text-gray-500 text-sm">{currentPersona.greeting}</p>
</div>
) : (
messages.map((m, i) => (
<div key={i} className={`flex ${m.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[85%] rounded-lg px-4 py-2.5 text-sm ${m.role === 'user' ? 'bg-[#D4AF37]/10 border border-[#D4AF37]/20' : 'bg-gray-800'}`}>
<p className="text-gray-100 whitespace-pre-wrap leading-relaxed">{m.content}</p>
{m.time && <p className="text-[10px] text-gray-500 mt-1.5">{m.time}</p>}
</div>
</div>
))
)}
{loading && (
<div className="flex justify-start">
<div className="bg-gray-800 rounded-lg px-4 py-2 text-sm text-gray-400">
<span className="animate-pulse">{currentPersona.name} is reflecting...</span>
</div>
</div>
)}
{dailyLoading && (
<div className="flex justify-start">
<div className="bg-gray-800 rounded-lg px-4 py-2 text-sm text-gray-400">
<span className="animate-pulse text-[#D4AF37]">{currentPersona.name} is greeting you...</span>
</div>
</div>
)}
<div ref={chatEnd} />
</div>
{/* Input */}
<div className="flex gap-2">
<input
type="text"
placeholder={`Ask ${currentPersona.name} a question...`}
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() } }}
className="flex-1 bg-[#0a0a0f] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none"
/>
<button
onClick={handleSend}
disabled={loading || !input.trim()}
className="bg-[#D4AF37] text-[#0a0a0f] px-3 py-2 rounded text-sm font-semibold hover:bg-[#C9A84C] disabled:opacity-50 transition"
>
<Send size={16} />
</button>
</div>
</div>
)
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation'
export default function Home() {
redirect('/souq')
}
+41
View File
@@ -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>
)
}
+259
View File
@@ -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&apos;i</option>
<option value="maliki">Maliki</option>
<option value="hanbali">Hanbali</option>
</select>
</div>
<div>
<label className="text-xs text-gray-500 mb-1 block">Coach Persona</label>
<select
value={profile.coachPersona}
onChange={e => setProfile(p => ({ ...p, coachPersona: e.target.value }))}
className="w-full bg-[#0a0a0f] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none"
>
<option value="nurbuddy">NurBuddy</option>
<option value="ghazali">Al-Ghazali</option>
<option value="ibnabbas">Ibn Abbas</option>
<option value="rabia">Rabi'a al-Adawiyya</option>
</select>
</div>
</div>
<button
onClick={handleSave}
disabled={saving}
className="flex items-center gap-2 bg-[#D4AF37] text-[#0a0a0f] px-4 py-2 rounded text-sm font-semibold hover:bg-[#C9A84C] disabled:opacity-50 transition"
>
<Save size={16} /> {saving ? 'Saving...' : 'Save Changes'}
</button>
</div>
{/* Upgrade CTA */}
{!user?.isPremium && !user?.isPro && (
<div className="bg-gradient-to-r from-[#D4AF37]/10 to-[#0a0a0f] border border-[#D4AF37]/20 rounded-lg p-6">
<div className="flex items-center gap-3 mb-3">
<Crown size={24} className="text-[#D4AF37]" />
<div>
<h2 className="font-bold text-[#D4AF37]">Go Premium</h2>
<p className="text-sm text-gray-400">Unlock exclusive features and content</p>
</div>
</div>
<button
onClick={() => router.push('/upgrade')}
className="bg-[#D4AF37] text-[#0a0a0f] px-5 py-2 rounded text-sm font-semibold hover:bg-[#C9A84C] transition"
>
Upgrade Now
</button>
</div>
)}
{/* Sign Out */}
<button
onClick={() => {
if (typeof window !== 'undefined') {
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>
)
}
+47
View File
@@ -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>
)
}
+159
View File
@@ -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>
)
}
+169
View File
@@ -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>
)
}
+64
View File
@@ -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>
)
}
+59
View File
@@ -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>
)
}
+66
View File
@@ -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
View File
@@ -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: [],
},
}
}
}
+30
View File
@@ -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
}
}
+436
View File
@@ -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: [] },
}
}
}
+39
View File
@@ -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)
}
+54
View File
@@ -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',
}
+7
View File
@@ -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
+41
View File
@@ -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