From 1f60f1c90848ce4da62c8d1365a63531334e8f34 Mon Sep 17 00:00:00 2001 From: wmj2024 Date: Sun, 28 Jun 2026 04:27:21 +0800 Subject: [PATCH] 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 --- .claude/commands/deploy.md | 80 ++++ .dockerignore | 7 + .env.example | 91 ++++ .env.template | 83 ++++ .github/workflows/deploy.yml | 87 ++++ .gitignore | 51 ++ .gitmodules | 51 ++ Dockerfile | 30 ++ eslint.config.mjs | 13 + halal-monitor-brief.md | 137 ++++++ next-env.d.ts | 6 + next.config.ts | 10 + package.json | 35 ++ postcss.config.mjs | 6 + prisma/dev.db | Bin 0 -> 98304 bytes prisma/schema.prisma | 179 +++++++ prisma/seed-learn.js | 76 +++ prisma/seed-learn.json | 83 ++++ src/app/api/auth/login/route.ts | 14 + src/app/api/auth/me/route.ts | 13 + src/app/api/auth/profile/route.ts | 62 +++ src/app/api/auth/register/route.ts | 17 + src/app/api/chat/daily/route.ts | 140 ++++++ src/app/api/chat/demo/route.ts | 14 + src/app/api/chat/history/[userId]/route.ts | 14 + src/app/api/chat/send/route.ts | 180 ++++++++ src/app/api/files/[listingId]/route.ts | 7 + src/app/api/forum/categories/route.ts | 18 + src/app/api/forum/posts/route.ts | 35 ++ src/app/api/forum/threads/route.ts | 41 ++ src/app/api/halal/bookmarks/route.ts | 38 ++ src/app/api/halal/usage/route.ts | 25 + src/app/api/health/route.ts | 5 + src/app/api/marketplace/feature/route.ts | 20 + src/app/api/marketplace/listings/route.ts | 37 ++ src/app/api/marketplace/purchase/route.ts | 28 ++ src/app/api/nurbuddy-health/route.ts | 5 + src/app/api/seed/route.ts | 54 +++ src/app/api/seller/[sellerId]/route.ts | 14 + src/app/api/wallet/route.ts | 28 ++ src/app/forum/[threadId]/page.tsx | 177 +++++++ src/app/forum/page.tsx | 105 +++++ src/app/globals.css | 35 ++ src/app/halal-monitor/page.tsx | 102 ++++ src/app/layout.tsx | 22 + .../learn/[courseSlug]/[moduleId]/page.tsx | 125 +++++ src/app/learn/page.tsx | 111 +++++ src/app/login/page.tsx | 44 ++ src/app/nur/page.tsx | 384 +++++++++++++++ src/app/page.tsx | 5 + src/app/profile/loading.tsx | 41 ++ src/app/profile/page.tsx | 259 +++++++++++ src/app/register/page.tsx | 47 ++ src/app/souq/page.tsx | 159 +++++++ src/app/upgrade/page.tsx | 169 +++++++ src/app/wallet/page.tsx | 64 +++ src/components/Navbar.tsx | 59 +++ src/lib/AuthContext.tsx | 66 +++ src/lib/ai.ts | 297 ++++++++++++ src/lib/auth.ts | 30 ++ src/lib/commands.ts | 436 ++++++++++++++++++ src/lib/halal-monitor-bridge.ts | 39 ++ src/lib/personas.ts | 54 +++ src/lib/prisma.ts | 7 + tsconfig.json | 41 ++ tsconfig.tsbuildinfo | 1 + 66 files changed, 4713 insertions(+) create mode 100644 .claude/commands/deploy.md create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .env.template create mode 100644 .github/workflows/deploy.yml create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 Dockerfile create mode 100644 eslint.config.mjs create mode 100644 halal-monitor-brief.md create mode 100644 next-env.d.ts create mode 100644 next.config.ts create mode 100644 package.json create mode 100644 postcss.config.mjs create mode 100644 prisma/dev.db create mode 100644 prisma/schema.prisma create mode 100644 prisma/seed-learn.js create mode 100644 prisma/seed-learn.json create mode 100644 src/app/api/auth/login/route.ts create mode 100644 src/app/api/auth/me/route.ts create mode 100644 src/app/api/auth/profile/route.ts create mode 100644 src/app/api/auth/register/route.ts create mode 100644 src/app/api/chat/daily/route.ts create mode 100644 src/app/api/chat/demo/route.ts create mode 100644 src/app/api/chat/history/[userId]/route.ts create mode 100644 src/app/api/chat/send/route.ts create mode 100644 src/app/api/files/[listingId]/route.ts create mode 100644 src/app/api/forum/categories/route.ts create mode 100644 src/app/api/forum/posts/route.ts create mode 100644 src/app/api/forum/threads/route.ts create mode 100644 src/app/api/halal/bookmarks/route.ts create mode 100644 src/app/api/halal/usage/route.ts create mode 100644 src/app/api/health/route.ts create mode 100644 src/app/api/marketplace/feature/route.ts create mode 100644 src/app/api/marketplace/listings/route.ts create mode 100644 src/app/api/marketplace/purchase/route.ts create mode 100644 src/app/api/nurbuddy-health/route.ts create mode 100644 src/app/api/seed/route.ts create mode 100644 src/app/api/seller/[sellerId]/route.ts create mode 100644 src/app/api/wallet/route.ts create mode 100644 src/app/forum/[threadId]/page.tsx create mode 100644 src/app/forum/page.tsx create mode 100644 src/app/globals.css create mode 100644 src/app/halal-monitor/page.tsx create mode 100644 src/app/layout.tsx create mode 100644 src/app/learn/[courseSlug]/[moduleId]/page.tsx create mode 100644 src/app/learn/page.tsx create mode 100644 src/app/login/page.tsx create mode 100644 src/app/nur/page.tsx create mode 100644 src/app/page.tsx create mode 100644 src/app/profile/loading.tsx create mode 100644 src/app/profile/page.tsx create mode 100644 src/app/register/page.tsx create mode 100644 src/app/souq/page.tsx create mode 100644 src/app/upgrade/page.tsx create mode 100644 src/app/wallet/page.tsx create mode 100644 src/components/Navbar.tsx create mode 100644 src/lib/AuthContext.tsx create mode 100644 src/lib/ai.ts create mode 100644 src/lib/auth.ts create mode 100644 src/lib/commands.ts create mode 100644 src/lib/halal-monitor-bridge.ts create mode 100644 src/lib/personas.ts create mode 100644 src/lib/prisma.ts create mode 100644 tsconfig.json create mode 100644 tsconfig.tsbuildinfo diff --git a/.claude/commands/deploy.md b/.claude/commands/deploy.md new file mode 100644 index 0000000..cda5840 --- /dev/null +++ b/.claude/commands/deploy.md @@ -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 +``` diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ab13198 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +node_modules +.next +*.db +.env +.git +Dockerfile +docker-compose.yml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e97ba3a --- /dev/null +++ b/.env.example @@ -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 diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..d05230f --- /dev/null +++ b/.env.template @@ -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 \ No newline at end of file diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..8be173e --- /dev/null +++ b/.github/workflows/deploy.yml @@ -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 }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d812e88 --- /dev/null +++ b/.gitignore @@ -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/ \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..9d38023 --- /dev/null +++ b/.gitmodules @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d1ca3f8 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..c3da9f3 --- /dev/null +++ b/eslint.config.mjs @@ -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; diff --git a/halal-monitor-brief.md b/halal-monitor-brief.md new file mode 100644 index 0000000..826b28e --- /dev/null +++ b/halal-monitor-brief.md @@ -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* diff --git a/next-env.d.ts b/next-env.d.ts new file mode 100644 index 0000000..9edff1c --- /dev/null +++ b/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +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. diff --git a/next.config.ts b/next.config.ts new file mode 100644 index 0000000..cd8f14f --- /dev/null +++ b/next.config.ts @@ -0,0 +1,10 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + output: "standalone", + turbopack: { + root: process.cwd(), + }, +}; + +export default nextConfig; diff --git a/package.json b/package.json new file mode 100644 index 0000000..f38721e --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/postcss.config.mjs b/postcss.config.mjs new file mode 100644 index 0000000..736723c --- /dev/null +++ b/postcss.config.mjs @@ -0,0 +1,6 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +} +export default config diff --git a/prisma/dev.db b/prisma/dev.db new file mode 100644 index 0000000000000000000000000000000000000000..85fc1ddba9b9146119afaafcd4956f45bdc24829 GIT binary patch literal 98304 zcmeI5NsJp;dVtC9CfVwiL~lK`McqD6?tZ;Q%%*Nz6?)UArd1aP_mADZek2k*bt?G7kVP6Q z-K3I2EJfG+jfGOBT&$M4YVp!WnTz}Baqi3l2d}sy#kp$v^(wcu4ga-`4Q{8hzFDli z!CfuCan>o*whggKOu9Z?*3CC9V%g?!J*(xlVr`?!E$eNnExSe8HkB@Qh6=m61|_Dh zjmtB2)naFZj@zvkH+P(a zOWRw!)k<-FtJ(*x-UCsWZ%`P-+IFS9etCVincdS85(wn8sjoes;c92NlUjiYfWv&IRIb2T(0=? zaz)x!iN!qZ8l?}G0i=$`CEe0aYdfR^7BormgVmUtqM`5Co4P7_+AL93xkZgm%}@uF z(oG-KJx?ojO;(K7$UJ8EB`1G`K1+((wk=xeGZL@P$I$e+-Im6hiBo^SF?cFd_tjve zDfqcVmayR{&s2tRKqp*f8`%X<5eH4NW>RCS))x}z=fknm$#G`r0};HfBlnC@BzEFN z@Pmt8BXq~?euM{2um@yve{3n13i?0bwp~q-n3gK-o|_!DAQ5D}ryFA&aNAHsdXNDg zY`_mW5fIS=b9tEY$cjq0NXs3dJ1&;EtGv^*`o8pPr#)EMY!X8u&E13T8O}r!_%W|L z?m=rR*>g{2UK4N@u?-66zDwKN8|C7bkJNWKGEq%BRJ^8H3V4MlcutzqQeDledmEONBx;9d366*^12GGpD&ACBa>C2hw zjAet(k2@n7`Z!Y(bvbr|%s26%|>b{)LaI6S28N4Y*o&P2j;kEo-*XuqCPEHPbezObzg)4|w-(b>NFH>)=QS zMz0dnDzU~!UN1e4!5!#L7ZS@e;n>BcaYn(kx-PKyS?A93c;pyt1fy32@P{uXfCP{L z5OBK6(T8Clhb9+YEo_!BClEM!a^1HAO^hMq!#yI|MV$m5=FYe#aS}MY3te^*MP-c z5J7EliV52m%pr0_lem^{P?o2|qC?KoxuU9)CR9$eZVUUA-0%-Q%k4K|;R?%ugP@*0 zU6(jT;~KivJO^R%o3^Pct;E7Yg~}=wEly{(DCSwtpSZlhu|B~HwtZqq94w(>^}rIa z4)^OH{FH0h7PJ8ijv*0Rfxg4~9yVH7^JZ{ON(`1yHMZZ~Wu;8Yu~2jKm2TBdhY(bt z=@ti(=~V5ofi)=Xz-VFBjdZr#kdw%kyh-O47B&eKgCL4WUFyi*9lJajUb64HDw)5!4Y zES2~?EED11`-2&#1tsW;&et--2K{KzCM-~f1E3A>h*!ARm?la(HT%`ir1Zus@Ak=dji_pa zh&MQcnr&SJZ9G%lS?6qx+=8VaY#nprU~!9VeV5gVJ3{`AdS@mWsnwS1fe@9lX+cgA zUXoH0pDyGZd_h>Dd`@b}LLrkAQw2H+TqfmRSas227TmKgrPbCA@GD~1#N2MY!t~(3 z|B=oA&qO~7L_djs7QG+6|MhrJYy}A*0VIF~kN^@u0!RP}AOR$R1dzZXB5-E57M$ea zAp2ImcP4q%Gs|k64f) zlyk#rIQQ#EiyZrc00>>_ewDSP>?syerAZ)9*-47i7D*DUC^G83OUtA2#-H&`eW6$jd-kixt|L0K|-MzjTi6s)j`!_ApP-%&n zP2ILC^rj8JVK}&Vv{We+KhASTh;RAa>cL7Eg>t!Dt1f7Iq1>GaK32~ z%QlC5v07d$);6l#vitLj%WiF=L5W4Bq7~;>VRY5?&9Yx1=N(k4RVwAJYMtfoR*RcE z-ay*Y*n^!SrR}ZVYNZH6875YDDAeT}v=isnwkzfJ%UcW*cP8!vO2GKm%FuGDyvxOF zKn*Z6!U=AB3p%$^260rNN0s$bl_k}7*ciA{ako@lEiWWML$w^-k3xoT|?p{9DLJ#TD=Eiq)PO% zLHhk_vEu@qQZdsbtT+F!7pzix1nNa4mFcdwZi?-13q74o4=cNr#HpG)n=m;9an|xsP*Bn$l8T z&8mAM8)+n7=8%~_f|c;$aIAP@oRxT;5!{W)-TB!_?DXm2z1N(J+9t8CfNucJEcG_$ z25qG;XR0%nd#N_kU<`enDT%sfQ4LNUqwK6jEdtk-WWen|j&S?kTi-apjw{g2TsXFV zdYt6?J0tY%gzjArMq*1#!4F>QN|31x`=1$Q&GtVEpPQMg51nDG0lD5 zkgmYp-GEZbYV#6Ni6-`ZpFeuv0cDa&@1lp^Zc{^{P=15nqQfNpBbGI~?|I@Dk(#72 z#JpG1*332)6AC~4e+E7c=v8~ine!I z2S++EdX>N}E^BP$_0r=Q+=1S7A+bCYj$K?DXB14U>jHb9g}eHBc4yDx|HH?A7Kr|3 zR5<>ttJYq#6FVR7H(R&8Py%^MudHE^bzN;HXM4+$wJLGG6y z|M`dE;Lkt$;;Y+AC{h!)p^bE260(J)$maz@_;e~y`9dm_;j>aImzBkpOd*xNWSfdc zO%obfQ#5v4e+BODRgMxvgF9f3wX$xiq@{?Q>=gL&zl48O0S6k|I2Lw0%Dizs7=`TP2vwxX&vCTluuR+a{u#zOXT$0ZUg&d#G zrU4*`gU^vho@5%CTqBcTQ*Jh);)<;TB=Z`Z`G(p|jx=Cy#|QSdZNMCfVN2}s2Q>Aw z8KP}fJ9amQ?yMJ zn(n*(a_lfQG?VDbOq=wcxHS#&XaKbnrd75(IEQgUnt2_OL^fCP{L5%tRlMpL7$s>5jrXsMv6p0m< zgLh7J7euV-hTST;F|2hK5LSn^83U;=x&dglt@UfQGOSQpEx*pi2a5Oy#@ULD!MymH zcsI^0(Rm>f`~6eFJEyx1`yuxIivXZ$KRq_GSwDN|FsETZ7aDE%7Fi@}&xd1)rQo~W zmb<&)u6DsaX54(QA0FwE{qQn}`q5RU=@vH$&mFXarPm$7-(0%49_7o&5(O?bKq|v7n#j^j-D!n6=dJou0 zh3jPxsa#j&p1Xe%%J25a)9{hK@iK?{@(AjmdFG(V>OOt0I|ktN?)mqgI~Iv8F9+|6 zJrnRC3=oNqFaxjPR6curWc33JN?cd1fPoA|>O$-?Tl+Y?Ak+^d_TrNt&FwBE3dh5- z&E;_cxdT{SnjVQ~;CiDQqI>aZIQAGzEpZj@*oCydl%LjBxR0>Z64%}u^zg2|JwG3g zO||F`zyE)#+d3?a1dsp{Kmter2_OL^fCP{L5zf z(?|dbAOR$R1dsp{Kmter2_OL^fCQ!@!0!Kp(fZO)S(+7&T&5Z|h{~-hrf8d{qHF&LCV0vs literal 0 HcmV?d00001 diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..4063adb --- /dev/null +++ b/prisma/schema.prisma @@ -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}] +} diff --git a/prisma/seed-learn.js b/prisma/seed-learn.js new file mode 100644 index 0000000..ff936c1 --- /dev/null +++ b/prisma/seed-learn.js @@ -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(); + }); diff --git a/prisma/seed-learn.json b/prisma/seed-learn.json new file mode 100644 index 0000000..753e5e7 --- /dev/null +++ b/prisma/seed-learn.json @@ -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 + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts new file mode 100644 index 0000000..1518dbd --- /dev/null +++ b/src/app/api/auth/login/route.ts @@ -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 }) } +} diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts new file mode 100644 index 0000000..adbb2f4 --- /dev/null +++ b/src/app/api/auth/me/route.ts @@ -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 }) +} diff --git a/src/app/api/auth/profile/route.ts b/src/app/api/auth/profile/route.ts new file mode 100644 index 0000000..742ebe7 --- /dev/null +++ b/src/app/api/auth/profile/route.ts @@ -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 = {} + 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 }) + } +} diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts new file mode 100644 index 0000000..3e71774 --- /dev/null +++ b/src/app/api/auth/register/route.ts @@ -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 }) } +} diff --git a/src/app/api/chat/daily/route.ts b/src/app/api/chat/daily/route.ts new file mode 100644 index 0000000..f7353a7 --- /dev/null +++ b/src/app/api/chat/daily/route.ts @@ -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(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 }) + } +} diff --git a/src/app/api/chat/demo/route.ts b/src/app/api/chat/demo/route.ts new file mode 100644 index 0000000..08bb0ab --- /dev/null +++ b/src/app/api/chat/demo/route.ts @@ -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 }) +} diff --git a/src/app/api/chat/history/[userId]/route.ts b/src/app/api/chat/history/[userId]/route.ts new file mode 100644 index 0000000..66299e7 --- /dev/null +++ b/src/app/api/chat/history/[userId]/route.ts @@ -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 }) +} diff --git a/src/app/api/chat/send/route.ts b/src/app/api/chat/send/route.ts new file mode 100644 index 0000000..1026f53 --- /dev/null +++ b/src/app/api/chat/send/route.ts @@ -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>>() + +function getUserSeen(userId: string): Map> { + if (!userSeenContent.has(userId)) { + userSeenContent.set(userId, new Map()) + } + return userSeenContent.get(userId)! +} + +function getSeenSet(userId: string, command: SlashCommand): Set { + 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 }) + } +} diff --git a/src/app/api/files/[listingId]/route.ts b/src/app/api/files/[listingId]/route.ts new file mode 100644 index 0000000..66de9dc --- /dev/null +++ b/src/app/api/files/[listingId]/route.ts @@ -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' }) +} diff --git a/src/app/api/forum/categories/route.ts b/src/app/api/forum/categories/route.ts new file mode 100644 index 0000000..c57e7dd --- /dev/null +++ b/src/app/api/forum/categories/route.ts @@ -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 }) +} diff --git a/src/app/api/forum/posts/route.ts b/src/app/api/forum/posts/route.ts new file mode 100644 index 0000000..d692179 --- /dev/null +++ b/src/app/api/forum/posts/route.ts @@ -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 }) +} diff --git a/src/app/api/forum/threads/route.ts b/src/app/api/forum/threads/route.ts new file mode 100644 index 0000000..381a0d4 --- /dev/null +++ b/src/app/api/forum/threads/route.ts @@ -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 }) +} diff --git a/src/app/api/halal/bookmarks/route.ts b/src/app/api/halal/bookmarks/route.ts new file mode 100644 index 0000000..dca4c5b --- /dev/null +++ b/src/app/api/halal/bookmarks/route.ts @@ -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 }) +} diff --git a/src/app/api/halal/usage/route.ts b/src/app/api/halal/usage/route.ts new file mode 100644 index 0000000..f05ea3f --- /dev/null +++ b/src/app/api/halal/usage/route.ts @@ -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 }) +} diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts new file mode 100644 index 0000000..88ce5ed --- /dev/null +++ b/src/app/api/health/route.ts @@ -0,0 +1,5 @@ +import { NextResponse } from 'next/server' + +export async function GET() { + return NextResponse.json({ status: 'ok', service: 'falah-mobile' }) +} \ No newline at end of file diff --git a/src/app/api/marketplace/feature/route.ts b/src/app/api/marketplace/feature/route.ts new file mode 100644 index 0000000..615969c --- /dev/null +++ b/src/app/api/marketplace/feature/route.ts @@ -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 }) +} diff --git a/src/app/api/marketplace/listings/route.ts b/src/app/api/marketplace/listings/route.ts new file mode 100644 index 0000000..ddc0e69 --- /dev/null +++ b/src/app/api/marketplace/listings/route.ts @@ -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 }) +} diff --git a/src/app/api/marketplace/purchase/route.ts b/src/app/api/marketplace/purchase/route.ts new file mode 100644 index 0000000..63eecdc --- /dev/null +++ b/src/app/api/marketplace/purchase/route.ts @@ -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 }) +} diff --git a/src/app/api/nurbuddy-health/route.ts b/src/app/api/nurbuddy-health/route.ts new file mode 100644 index 0000000..71914d9 --- /dev/null +++ b/src/app/api/nurbuddy-health/route.ts @@ -0,0 +1,5 @@ +import { NextResponse } from 'next/server' + +export async function GET() { + return NextResponse.json({ status: 'ok', service: 'nurbuddy' }) +} \ No newline at end of file diff --git a/src/app/api/seed/route.ts b/src/app/api/seed/route.ts new file mode 100644 index 0000000..c30b9fc --- /dev/null +++ b/src/app/api/seed/route.ts @@ -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 }) + } +} diff --git a/src/app/api/seller/[sellerId]/route.ts b/src/app/api/seller/[sellerId]/route.ts new file mode 100644 index 0000000..9d6adda --- /dev/null +++ b/src/app/api/seller/[sellerId]/route.ts @@ -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 }) +} diff --git a/src/app/api/wallet/route.ts b/src/app/api/wallet/route.ts new file mode 100644 index 0000000..971cd1c --- /dev/null +++ b/src/app/api/wallet/route.ts @@ -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 }) +} diff --git a/src/app/forum/[threadId]/page.tsx b/src/app/forum/[threadId]/page.tsx new file mode 100644 index 0000000..c701464 --- /dev/null +++ b/src/app/forum/[threadId]/page.tsx @@ -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(null) + const [posts, setPosts] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [reply, setReply] = useState('') + const [submitting, setSubmitting] = useState(false) + const [replyError, setReplyError] = useState(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 ( +
+
+
+
+
+
+
+
+ ) + } + + if (error || !thread) { + return ( +
+ +
+ +

{error || 'Thread not found'}

+
+
+ ) + } + + return ( +
+ + +
+
+ {thread.category.name} + {thread.shariahStatus === 'approved' ? ( + Shariah Approved + ) : ( + {thread.shariahStatus === 'pending' ? 'Pending Review' : 'Flagged'} + )} +
+

{thread.title}

+
+ By {thread.author.name} · {new Date(thread.createdAt).toLocaleDateString()} +
+

{thread.content}

+
+ +
+

+ + Replies ({posts.length}) +

+ + {posts.length === 0 ? ( +
+

No replies yet. Be the first to respond.

+
+ ) : ( + posts.map(post => ( +
+
+ {post.author.name} + {new Date(post.createdAt).toLocaleDateString()} +
+

{post.content}

+ {post.shariahStatus !== 'approved' && ( +
+ Pending shariah review +
+ )} +
+ )) + )} +
+ + {token ? ( +
+