From cfff74e2dbec2e5c21c75ac50618ce5252257f6a Mon Sep 17 00:00:00 2001 From: root Date: Wed, 24 Jun 2026 07:02:03 +0200 Subject: [PATCH] feat: Souq native integration + auth simplification + CE-wide enhancements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Souq Marketplace: - Native souq pages: browse+filters, create listing, detail+seller profile, orders+chat - New API: reviews (POST), sellers/[id], upload (multipart), polar-checkout webhook - Listings API enhanced: minPrice, maxPrice, sortBy, deliveryDays filters - Seller workflow: start order, mark delivered, upload files, confirm delivery - Wallet: 5 Polar.sh FLH tiers, production checkout, mock fallback, success banner - Fix: order redirect /souq/history → /souq/orders Auth System: - Unified auth page, removed OAuth provider routing (2 files deleted) - Deleted oauth.ts (319 lines) — simplified auth chain - Streamlined login/register API routes Prisma Schema (+179 lines): - New models: Listing, Purchase, Review, Group/GroupMember - Gamification: DailyStreak, XpTransaction, Achievement, UserLevel - Learning: LearnCourse/Module/Enrollment/Certificate - Notifications, Referrals, Dhikr, PremiumBenefits Deleted legacy code: - src/app/api/marketplace/* (3 files) — replaced by /api/souq/* - src/app/api/files/[listingId]/route.ts — replaced by /api/souq/upload - src/app/api/seller/[sellerId]/route.ts — replaced by /api/souq/sellers/[id] Infrastructure: - Dockerfile: standalone output, Prisma runtime migration - docker-compose.yml: Polar env vars, healthcheck fix - docker-compose.staging.yml: staging on port 4014 - .gitea/workflows/ci.yml: CI pipeline --- Dockerfile | 30 +- docker-compose.staging.yml | 41 + docker-compose.yml | 15 +- package-lock.json | 455 ++++++- package.json | 2 + prisma/schema.prisma | 179 ++- public/apple-touch-icon.png | Bin 0 -> 1271 bytes public/favicon.ico | Bin 0 -> 379 bytes public/icon-192x192.png | Bin 0 -> 1658 bytes public/icon-512x512.png | Bin 0 -> 4926 bytes public/icons/icon-128x128.png | Bin 0 -> 1058 bytes public/icons/icon-144x144.png | Bin 0 -> 1199 bytes public/icons/icon-152x152.png | Bin 0 -> 1271 bytes public/icons/icon-192x192.png | Bin 0 -> 1658 bytes public/icons/icon-384x384.png | Bin 0 -> 3455 bytes public/icons/icon-48x48.png | Bin 0 -> 379 bytes public/icons/icon-512x512.png | Bin 0 -> 4926 bytes public/icons/icon-72x72.png | Bin 0 -> 574 bytes public/icons/icon-96x96.png | Bin 0 -> 759 bytes public/manifest.json | 49 + public/sw.js | 89 ++ scripts/init-gitea-courses.sh | 156 +++ scripts/qa-learn.py | 328 +++++ scripts/seed-courses.ts | 296 +++++ scripts/seed-souq.ts | 640 +++++++++ src/app/api/auth/[provider]/callback/route.ts | 144 --- src/app/api/auth/[provider]/route.ts | 68 - src/app/api/auth/login/route.ts | 68 +- src/app/api/auth/register/route.ts | 118 +- src/app/api/files/[listingId]/route.ts | 85 -- src/app/api/forum/posts/route.ts | 17 + src/app/api/forum/threads/route.ts | 19 + src/app/api/groups/join/route.ts | 75 ++ .../api/learn/certificates/[serial]/route.ts | 53 + src/app/api/learn/certificates/route.ts | 26 + .../certificates/verify/[serial]/route.ts | 39 + src/app/api/learn/courses/[slug]/route.ts | 120 ++ src/app/api/learn/courses/route.ts | 58 + src/app/api/learn/progress/route.ts | 215 ++++ src/app/api/learn/sync/route.ts | 530 ++++++++ src/app/api/learn/tts/route.ts | 132 ++ src/app/api/marketplace/feature/route.ts | 98 -- src/app/api/marketplace/listings/route.ts | 131 -- src/app/api/marketplace/purchase/route.ts | 136 -- src/app/api/seller/[sellerId]/route.ts | 54 - src/app/api/souq/categories/route.ts | 21 + src/app/api/souq/listings/[id]/route.ts | 56 + src/app/api/souq/listings/route.ts | 182 +++ src/app/api/souq/orders/[id]/route.ts | 250 ++++ src/app/api/souq/orders/route.ts | 213 +++ src/app/api/souq/reviews/route.ts | 113 ++ src/app/api/souq/sellers/[id]/route.ts | 84 ++ src/app/api/souq/upload/route.ts | 93 ++ .../wallet/top-up/create-checkout/route.ts | 77 +- src/app/api/webhooks/polar-checkout/route.ts | 114 ++ src/app/api/webhooks/polar/route.ts | 274 +++- src/app/auth/page.tsx | 112 +- src/app/groups/[id]/page.tsx | 2 +- src/app/layout.tsx | 25 + src/app/learn/[slug]/[moduleId]/page.tsx | 775 +++++++++++ src/app/learn/[slug]/page.tsx | 455 +++++++ src/app/learn/page.tsx | 456 +++++++ src/app/offline/page.tsx | 20 + src/app/page.tsx | 18 +- src/app/souq/[id]/page.tsx | 719 +++++++++++ src/app/souq/create/page.tsx | 523 ++++++++ src/app/souq/orders/[id]/page.tsx | 1141 +++++++++++++++++ src/app/souq/orders/page.tsx | 254 ++++ src/app/souq/page.tsx | 1076 +++++++--------- src/app/souq/seller/[id]/page.tsx | 319 +++++ src/app/verify/[serial]/page.tsx | 310 +++++ src/app/wallet/page.tsx | 41 +- src/components/BottomNav.tsx | 228 +++- src/components/PWARegister.tsx | 29 + src/lib/AuthContext.tsx | 29 +- src/lib/auth.ts | 84 +- src/lib/learn.ts | 365 ++++++ src/lib/oauth.ts | 319 ----- src/lib/shariah-moderation.ts | 249 ++++ tests/qa-learn-prod.py | 669 ++++++++++ tests/qa-visual.py | 72 +- 81 files changed, 12132 insertions(+), 2101 deletions(-) create mode 100644 docker-compose.staging.yml create mode 100644 public/apple-touch-icon.png create mode 100644 public/favicon.ico create mode 100644 public/icon-192x192.png create mode 100644 public/icon-512x512.png create mode 100644 public/icons/icon-128x128.png create mode 100644 public/icons/icon-144x144.png create mode 100644 public/icons/icon-152x152.png create mode 100644 public/icons/icon-192x192.png create mode 100644 public/icons/icon-384x384.png create mode 100644 public/icons/icon-48x48.png create mode 100644 public/icons/icon-512x512.png create mode 100644 public/icons/icon-72x72.png create mode 100644 public/icons/icon-96x96.png create mode 100644 public/manifest.json create mode 100644 public/sw.js create mode 100644 scripts/init-gitea-courses.sh create mode 100644 scripts/qa-learn.py create mode 100644 scripts/seed-courses.ts create mode 100644 scripts/seed-souq.ts delete mode 100644 src/app/api/auth/[provider]/callback/route.ts delete mode 100644 src/app/api/auth/[provider]/route.ts delete mode 100644 src/app/api/files/[listingId]/route.ts create mode 100644 src/app/api/groups/join/route.ts create mode 100644 src/app/api/learn/certificates/[serial]/route.ts create mode 100644 src/app/api/learn/certificates/route.ts create mode 100644 src/app/api/learn/certificates/verify/[serial]/route.ts create mode 100644 src/app/api/learn/courses/[slug]/route.ts create mode 100644 src/app/api/learn/courses/route.ts create mode 100644 src/app/api/learn/progress/route.ts create mode 100644 src/app/api/learn/sync/route.ts create mode 100644 src/app/api/learn/tts/route.ts delete mode 100644 src/app/api/marketplace/feature/route.ts delete mode 100644 src/app/api/marketplace/listings/route.ts delete mode 100644 src/app/api/marketplace/purchase/route.ts delete mode 100644 src/app/api/seller/[sellerId]/route.ts create mode 100644 src/app/api/souq/categories/route.ts create mode 100644 src/app/api/souq/listings/[id]/route.ts create mode 100644 src/app/api/souq/listings/route.ts create mode 100644 src/app/api/souq/orders/[id]/route.ts create mode 100644 src/app/api/souq/orders/route.ts create mode 100644 src/app/api/souq/reviews/route.ts create mode 100644 src/app/api/souq/sellers/[id]/route.ts create mode 100644 src/app/api/souq/upload/route.ts create mode 100644 src/app/api/webhooks/polar-checkout/route.ts create mode 100644 src/app/learn/[slug]/[moduleId]/page.tsx create mode 100644 src/app/learn/[slug]/page.tsx create mode 100644 src/app/learn/page.tsx create mode 100644 src/app/offline/page.tsx create mode 100644 src/app/souq/[id]/page.tsx create mode 100644 src/app/souq/create/page.tsx create mode 100644 src/app/souq/orders/[id]/page.tsx create mode 100644 src/app/souq/orders/page.tsx create mode 100644 src/app/souq/seller/[id]/page.tsx create mode 100644 src/app/verify/[serial]/page.tsx create mode 100644 src/components/PWARegister.tsx create mode 100644 src/lib/learn.ts delete mode 100644 src/lib/oauth.ts create mode 100644 src/lib/shariah-moderation.ts create mode 100644 tests/qa-learn-prod.py diff --git a/Dockerfile b/Dockerfile index 820e1d3..84c5ab2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,9 @@ +# ── Falah Mobile — Production Dockerfile ────────────────────────────── +# Requires npm run build to be run first on the host (Next.js standalone) +# Build sequence: +# cd /app && npm ci && npm run build +# docker build -t falah-mobile:latest . + FROM node:20-alpine RUN apk add --no-cache openssl ca-certificates curl @@ -10,13 +16,14 @@ RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs COPY .next/standalone/falah-mobile ./ COPY .next/static ./.next/static COPY public ./public -COPY prisma/schema.prisma ./prisma/schema.prisma + +# Copy prisma CLI and schema for runtime migrations +COPY node_modules/prisma ./node_modules/prisma +COPY node_modules/.prisma/client ./node_modules/.prisma/client +COPY node_modules/@prisma ./node_modules/@prisma +COPY prisma ./prisma # Fix Turbopack's hashed Prisma client path -# Turbopack resolves @prisma/client to @prisma/client- but the -# build-time symlink points to a path that doesn't exist in Docker. -# We copy the existing @prisma/client to the hash name so Node.js -# can resolve it when the Turbopack runtime requires it. RUN if [ -d ".next/node_modules/@prisma" ]; then \ for d in .next/node_modules/@prisma/client-*; do \ name=$(basename "$d"); \ @@ -25,7 +32,16 @@ RUN if [ -d ".next/node_modules/@prisma" ]; then \ done; \ fi -RUN mkdir -p /app/data +RUN mkdir -p /app/data && chown nextjs:nodejs /app/data + +# Fix permissions so nextjs user can run prisma CLI and read schema +RUN chown -R nextjs:nodejs /app/node_modules/@prisma /app/node_modules/prisma /app/node_modules/.prisma /app/prisma 2>/dev/null || true + +# Startup script — migrate volumes DB then launch server +RUN printf '#!/bin/sh\n\ +cd /app\n\ +node ./node_modules/prisma/build/index.js db push --skip-generate 2>&1\n\ +exec node server.js\n' > /app/start.sh && chmod +x /app/start.sh USER nextjs EXPOSE 3000 @@ -33,4 +49,4 @@ EXPOSE 3000 ENV NODE_ENV=production ENV DATABASE_URL="file:/app/data/dev.db" -CMD ["node", "server.js"] +CMD ["/app/start.sh"] diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml new file mode 100644 index 0000000..96ccd00 --- /dev/null +++ b/docker-compose.staging.yml @@ -0,0 +1,41 @@ +version: "3.8" +services: + falah-mobile-staging: + build: + context: . + dockerfile: Dockerfile + image: falah-mobile:staging + ports: + - "4014:3000" + environment: + - NODE_ENV=production + - HOSTNAME=0.0.0.0 + - NEXT_PUBLIC_APP_URL=https://falahos.my/mobile-staging + - JWT_SECRET=${JWT_SECRET:-flh-staging-jwt-secret} + - DATABASE_URL=file:/app/data/staging.db + - LLM_API_KEY=${LLM_API_KEY:-} + - LLM_BASE_URL=${LLM_BASE_URL:-} + - LLM_MODEL=${LLM_MODEL:-qwen3.6-plus} + - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-} + - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-} + - GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID:-} + - GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-} + # Polar.sh payments (optional — mock mode used when unset) + - POLAR_ACCESS_TOKEN=${POLAR_ACCESS_TOKEN:-} + - POLAR_FLH_500=${POLAR_FLH_500:-} + - POLAR_FLH_1000=${POLAR_FLH_1000:-} + - POLAR_FLH_5000=${POLAR_FLH_5000:-} + - POLAR_FLH_10000=${POLAR_FLH_10000:-} + - POLAR_FLH_50000=${POLAR_FLH_50000:-} + - POLAR_WEBHOOK_SECRET=${POLAR_WEBHOOK_SECRET:-} + volumes: + - falah-mobile-staging-data:/app/data + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3000/mobile/api/health"] + interval: 30s + timeout: 10s + retries: 3 + +volumes: + falah-mobile-staging-data: diff --git a/docker-compose.yml b/docker-compose.yml index bf5202e..eac0a18 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,15 +9,20 @@ services: - NODE_ENV=production - HOSTNAME=0.0.0.0 - NEXT_PUBLIC_APP_URL=https://falahos.my/mobile - - JWT_SECRET=${JWT_SECRET:-flh-dev-jwt-secret-change-in-prod} + - JWT_SECRET=${JWT_SECRET} - DATABASE_URL=file:/app/data/dev.db + - UMMAHID_URL=https://ummahid.falahos.my - LLM_API_KEY=${LLM_API_KEY:-} - LLM_BASE_URL=${LLM_BASE_URL:-} - LLM_MODEL=${LLM_MODEL:-qwen3.6-plus} - - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-} - - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-} - - GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID:-} - - GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-} + # Polar.sh payments (optional — mock mode used when unset) + - POLAR_ACCESS_TOKEN=${POLAR_ACCESS_TOKEN:-} + - POLAR_FLH_500=${POLAR_FLH_500:-} + - POLAR_FLH_1000=${POLAR_FLH_1000:-} + - POLAR_FLH_5000=${POLAR_FLH_5000:-} + - POLAR_FLH_10000=${POLAR_FLH_10000:-} + - POLAR_FLH_50000=${POLAR_FLH_50000:-} + - POLAR_WEBHOOK_SECRET=${POLAR_WEBHOOK_SECRET:-} volumes: - falah-mobile-data:/app/data restart: unless-stopped diff --git a/package-lock.json b/package-lock.json index dacba49..54630ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,9 @@ "leaflet": "^1.9.4", "lucide-react": "^1.18.0", "next": "16.2.7", + "pdfkit": "^0.19.1", "prisma": "^5.22.0", + "qrcode": "^1.5.4", "react": "19.2.4", "react-dom": "19.2.4", "react-leaflet": "^5.0.0", @@ -1207,6 +1209,30 @@ "node": ">= 10" } }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2352,11 +2378,19 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -2605,6 +2639,26 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.10.37", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz", @@ -2650,6 +2704,24 @@ "node": ">=8" } }, + "node_modules/brotli": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", + "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.1.2" + } + }, + "node_modules/browserify-zlib": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz", + "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==", + "license": "MIT", + "dependencies": { + "pako": "~1.0.5" + } + }, "node_modules/browserslist": { "version": "4.28.2", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", @@ -2744,6 +2816,15 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001799", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", @@ -2787,11 +2868,30 @@ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -2804,7 +2904,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/concat-map": { @@ -2922,6 +3021,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2975,6 +3083,18 @@ "node": ">=8" } }, + "node_modules/dfa": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz", + "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==", + "license": "MIT" + }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -3641,7 +3761,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -3762,6 +3881,23 @@ "dev": true, "license": "ISC" }, + "node_modules/fontkit": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz", + "integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==", + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.5.12", + "brotli": "^1.3.2", + "clone": "^2.1.2", + "dfa": "^1.2.0", + "fast-deep-equal": "^3.1.3", + "restructure": "^3.0.0", + "tiny-inflate": "^1.0.3", + "unicode-properties": "^1.4.0", + "unicode-trie": "^2.0.0" + } + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -3856,6 +3992,15 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -4352,6 +4497,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-generator-function": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", @@ -4634,6 +4788,12 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/js-md5": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz", + "integrity": "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==", + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -5038,6 +5198,25 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/linebreak": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", + "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==", + "license": "MIT", + "dependencies": { + "base64-js": "0.0.8", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/linebreak/node_modules/base64-js": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz", + "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -5509,6 +5688,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -5526,7 +5720,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5549,6 +5742,20 @@ "dev": true, "license": "MIT" }, + "node_modules/pdfkit": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.19.1.tgz", + "integrity": "sha512-6Gzk+wDwTs4VSxsR5rCMTnIl5nlmkye1oWB0l2hDB1EX6ZNSIBroKQEv+2+fPPn+stVjyqzmsqRJVDfB9fo5DA==", + "license": "MIT", + "dependencies": { + "@noble/ciphers": "^1.0.0", + "@noble/hashes": "^1.6.0", + "fontkit": "^2.0.4", + "js-md5": "^0.8.3", + "linebreak": "^1.1.0", + "png-js": "^1.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -5568,6 +5775,23 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/png-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/png-js/-/png-js-1.1.0.tgz", + "integrity": "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==", + "dependencies": { + "browserify-zlib": "^0.2.0" + } + }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -5658,6 +5882,23 @@ "node": ">=6" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -5765,6 +6006,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resolve": { "version": "2.0.0-next.7", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", @@ -5809,6 +6065,12 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/restructure": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz", + "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==", + "license": "MIT" + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -5915,6 +6177,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -6151,6 +6419,26 @@ "node": ">= 0.4" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/string.prototype.includes": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", @@ -6265,6 +6553,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -6375,6 +6675,12 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -6636,6 +6942,32 @@ "devOptional": true, "license": "MIT" }, + "node_modules/unicode-properties": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz", + "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.0", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/unicode-trie": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", + "license": "MIT", + "dependencies": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } + }, + "node_modules/unicode-trie/node_modules/pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", + "license": "MIT" + }, "node_modules/unrs-resolver": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", @@ -6798,6 +7130,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/which-typed-array": { "version": "1.1.22", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", @@ -6830,6 +7168,26 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -6837,6 +7195,93 @@ "dev": true, "license": "ISC" }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yargs/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index d01f062..9f2a83c 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,9 @@ "leaflet": "^1.9.4", "lucide-react": "^1.18.0", "next": "16.2.7", + "pdfkit": "^0.19.1", "prisma": "^5.22.0", + "qrcode": "^1.5.4", "react": "19.2.4", "react-dom": "19.2.4", "react-leaflet": "^5.0.0", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3f07f6d..29e9e4f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -55,6 +55,7 @@ model User { userLevel UserLevel? dhikrSessions DhikrSession[] dhikrDays DhikrDay[] + reviewListings Review[] @relation("ListingReviews") } model DailyStreak { @@ -107,7 +108,15 @@ model Listing { title String description String category String + subcategory String? priceFlh Int + packages String? // JSON: [{name, description, price, deliveryDays, revisions}] + tags String? // JSON: ["tag1", "tag2"] + images String? // JSON: ["url1"] + deliveryDays Int? + rating Float @default(0) + reviewCount Int @default(0) + salesCount Int @default(0) sellerId String seller User @relation(fields: [sellerId], references: [id]) status String @default("active") @@ -117,21 +126,48 @@ model Listing { createdAt DateTime @default(now()) purchases Purchase[] + reviews Review[] } 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()) + 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]) + packageName String? + amountFlh Int + platformFee Int + sellerPayout Int + status String @default("pending") // pending, paid, in_progress, delivered, completed, disputed, cancelled + messages String? // JSON: [{from, text, timestamp}] + deliveryFiles String? // JSON: [{name, size, url}] + autoConfirmAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + reviews Review[] +} + +model Review { + id String @id @default(cuid()) + listingId String + listing Listing @relation(fields: [listingId], references: [id]) + purchaseId String + purchase Purchase @relation(fields: [purchaseId], references: [id]) + reviewerId String + reviewer User @relation("ListingReviews", fields: [reviewerId], references: [id]) + sellerId String + rating Int + title String? + text String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([listingId]) + @@index([sellerId]) } model CashoutRequest { @@ -333,6 +369,123 @@ model DhikrDay { @@index([userId]) } -// Add Relations to User model +// ────────────────────────────────────── +// LEARN / MICRO LEARNING MODULE +// ────────────────────────────────────── + +model LearnCourse { + id String @id @default(cuid()) + slug String @unique + title String + author String + description String + imageUrl String? + moduleCount Int @default(0) + totalMinutes Int @default(0) + difficulty String @default("beginner") + giteaPath String // path in Gitea courses repo + priceFlh Int? // FLH one-time price + priceUsd Float? // USD one-time price via Polar + subscriptionOnly Boolean @default(false) + published Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + modules LearnModule[] + enrollments LearnEnrollment[] + certificates LearnCertificate[] +} + +model LearnModule { + id String @id @default(cuid()) + courseId String + course LearnCourse @relation(fields: [courseId], references: [id], onDelete: Cascade) + order Int + title String + slug String + keyTakeaway String? + duration Int @default(5) // minutes + content String? // cached markdown body + audioPath String? // cached TTS audio file path + quizData String? // JSON: [{question, options[], correctIndex}] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + progress LearnModuleProgress[] + + @@unique([courseId, slug]) +} + +model LearnEnrollment { + id String @id @default(cuid()) + userId String + courseId String + course LearnCourse @relation(fields: [courseId], references: [id]) + purchaseType String @default("one_time") // "one_time" | "subscription" | "free" + completed Boolean @default(false) + progress Float @default(0) // 0.0 to 1.0 + startedAt DateTime @default(now()) + completedAt DateTime? + updatedAt DateTime @updatedAt + + modules LearnModuleProgress[] + + @@unique([userId, courseId]) + @@index([userId]) +} + +model LearnModuleProgress { + id String @id @default(cuid()) + enrollmentId String + enrollment LearnEnrollment @relation(fields: [enrollmentId], references: [id], onDelete: Cascade) + moduleId String + module LearnModule @relation(fields: [moduleId], references: [id], onDelete: Cascade) + completed Boolean @default(false) + score Float? // quiz score 0-100 + listened Boolean @default(false) + completedAt DateTime? + updatedAt DateTime @updatedAt + + @@unique([enrollmentId, moduleId]) +} + +model LearnSubscription { + id String @id @default(cuid()) + userId String + status String @default("active") // active | cancelled | expired + polarSubId String? @unique + currentPeriodStart DateTime + currentPeriodEnd DateTime + cancelledAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId]) + @@index([status]) +} + +model LearnCertificate { + id String @id @default(cuid()) + serialNumber String @unique + userId String + userName String // cached at issue time + courseId String + course LearnCourse @relation(fields: [courseId], references: [id]) + moduleCount Int @default(0) + score Float? // aggregate quiz score + pdfPath String? // /data/certificates/{serial}.pdf + issuedAt DateTime @default(now()) + viewedAt DateTime? + revoked Boolean @default(false) + revokedAt DateTime? + hashOnChain String? // SHA256 for future blockchain anchoring + metadata String? // JSON extra + + @@index([serialNumber]) + @@index([userId]) + @@index([courseId, userId]) +} + +// Add relations to User model /// NOTE: The Notification and Referral relations are declared on the models above. /// User now implicitly has: notifications Notification[], referredReferrals Referral[] (via "Referrer"), referrerReferral Referral? (via "Referred") diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..78ebbd329996a1dcd7946650fa5c444689d6a1a3 GIT binary patch literal 1271 zcmV1^@s67{VYS000EONkl1x8%hv)Ra2V8n%^R$EyMjJR;t!)6`@MqIe+S3753Qvjze17mvO zSw9+Lg27cC7}E=_e7D2|gR2r4(+jPAnK8lOjKJWEz~Jh|xc`EmuU0?wF_rhvk4t~< z-=DvYk3`??et%0l!LEjMR_5^11=cI7HQ8cI5!mRc)?|on>cFCgwKQ{R(*>py)tcF2 znCZHgDIv&CgL&zhts*eoYukMcB=onTqwGSgnG+yqnJ>=x9fnbZW!9+#Qk z9>@fywy~a+_9FZxQfV_shizM=1g$yk6hT)uWUpR~u*OmL5 z5yv0-J}lHt{^+`%wu{H zS8P*EFvKMt7}G1hZh>X|CILhz7;)+764>Z35@H;=3C8r&A{Q96xChqq^2qek!vupX znZTG{np^|p=@AkWjOnFIA~5J8Ft`dI7?($F;RAy=+YftmFi|07RK>orD>EL>n_eeW@P zo}7%g03ic28*?M;ags7j#ahVAORuI}x(Zec2 zyl6c_WkY*V+6$S$%(OPvi`pK@Tq2jUT{6|eDx@Y@O?AVsP+qI-1gp(D&6L9`WG7f{ zh7++g%bKQ`U^Vq=HcME=bb-}WVw)|h+SGv+QA3+KtRh8Vwb8L0fhdEP)J27(-rz?N{{k)L-F=IcVZf?%OC4#JfZFNHL1 z!Xr^vV#E`LG?QrbREBa^tv*jM!~%Q|A#-@x?ET16nuSE%UeC?^_x;iJdoF~lI0C;! zQ}i$+GQbs{BQe7MiF=8cw#72gQ@pgzW&bX;<-O0w_cTj z8u5Dzk965e)QZTVO=fuIw$1qaMm66-KgZO zT&l}mG|gY`p8I;egvkevBk>?!z>VOh*-BW9f&y1G}J6;nN%P7jVR7IdCsfQS_Bz+Y%Wb?_BGrwvt3I)Cv6F^Rc14y&0G zr|)#{-U)id&GM8pWc+!WiSi+7c)2QT9XNmI@0hAj`Q8|$T7tVW_`p(eO{Duea5EZ& zNI}JX@^>reUbfGjWl0w1X9}MyR+eYg;cb{S$Nr}q2^xc4!i3V~Q6U`j!a+y2tp5m0 z(a_$;QB=8>8m43*^PK7bDaz7k=N<*v$)&)ZBUA*M%cH>W#(Ik&ybH2#x=M1=i33U$ z5&ttfcow^M>4Bx1#fK>wQ&!a9naMC-e~_A1DF;;Ia7oWdgD<=xJt2}K4&1OFXcdV= z^zdCsGjYQIr_S&lNEz`-_$7M8b|jrR&XILSL?dBDi=ayS>O^D{(LDNS=j!DK-CknU z?1jL7b+vYvH0hDy^uJ8oFKMR_yt3=bwC(_o@Q)sD_@$uIJS|Q`TdM6D*uCE;{RE7S;7~gDy|8lN?KYMScN*+p=Ah)@CjPh*fM!P<5um#DHB)f zlA=hz?aHFFS~Byl>T?_37drPX*++)eyvJ7oIdDuTRWnwe=C5Qtp;IR2*Hn86M$WY6 z%7^y7)Vw(YdWzyxn;tR7wzsOx7txA*D!-g>;I#DYmv|^|&5~>OP9#B4)FVD|0e;KW zZGI{Pj*LGId^2zZs{}=xu24YY6D8%}xB+|ZE$b67h&yUiH#c+an%}&s3o}$i3TE2? zZ?_IW6Z?%WXBiN{UA8rH&9BdXCt$n-tJx7i(|ic4(FrY{CxfeQK|3cVTl7%GJ^1ZCL<#O?jTxZsq( zEo}m$u@$wmt$}x14#ag3R>R)Ab0FkvwhgiYA){4z)8#J#`JY!)!F9hJFaP-v=JM6T(?iJ?`{1%akWB)`mR9m@?r&e!fd51j z&1XLPBe`X5tE#dB>q^Y`D#as1F9}IzeMWJ>okSkqj?Zg5)|l32brtX#+jyRyreUf) ztqE_k5tEHeoGEiGZ2uVRI!h?^r5DfS#iM{%HUWO2>YMP&=`+}Tk|sn}K0|$A-$+F` zathEdBhfzvYz>MCYz^R*`~?_u$GZRk literal 0 HcmV?d00001 diff --git a/public/icon-512x512.png b/public/icon-512x512.png new file mode 100644 index 0000000000000000000000000000000000000000..893aad92e6f94ef821e795a1a7d368184c2825bd GIT binary patch literal 4926 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 Z4%uhb&3>NliO3k1E?(hsVUaNDzW^RV8s-21 literal 0 HcmV?d00001 diff --git a/public/icons/icon-128x128.png b/public/icons/icon-128x128.png new file mode 100644 index 0000000000000000000000000000000000000000..214c92de2ece2491f65d705bac550093c8cd4c4c GIT binary patch literal 1058 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7xzrV1DW8;uumf=j~kYqT2=nF0-}P zZ~16j#CTt){lwX##u)d=gQ|y@Xg)HxHU1m6GQ^YVB0q0e$jsY+|MfB2xb5lOapu}@ zwtU(3p)bDKq+d{R?q=YcH+j-&Rl#)}3m()Kc(&X=YA#w=p}Bc8Tfm2XmbuOIr5qj} ze`s)8nNg(9rp&nZtBeCnsz1Zpe>-YUZxaK`J@-C#i)qqlorbV_IlIka%qq({7F?a* z&%S`oqo2j&9Pg_CXKb!8oP7Dyb%J!=-@li??5daF&)(nC|F*84e>>|IwmLT#MJEme z?GMIE91o)yRzJwL>3$HzFbzm|J@8_<2BbS5I5FrSyj~&D7{##u;B|v;hiXL)uOAHG zHwJJWut{9IK~DIp*a0ig#fi6sGNcr|e=uC%7{qbFL~(V3Wm+*~gxVpdwq*0Y(iw(~ z;ot7xe4K0{ZeYzBBl_=zr<`S4+XqHvMx`}t<|QX^%5!{co)9W5ty*)yp+RzW{nI6G zT=EfrbPrVju-(vC9Le0nH^0#7^o&=@sWRCI|5!P2PZO^Ca`XKE`Y-uz(`vXoetdoT zb4J9mD^08O8GVG$noN^hme062tbPh-`r)IN4Tt6*4`Fm$Q`c%Q{)#Q&$LE=942ReH z74qdWXMA0s*u}7Sb^OEi&D$7{NWEaW@G72rE<=*;Vb+ea{Wexv4Np88o?PHq@N0YG z3Wi<3p7YOQ6EFh`1#m3*d!LVsu|-x$!4em8X3f~UWcBWjvK5Szl)=b2hG8!I4=3IU zGMp1+v>3PWu1W7=_|-XGeuWf60QZ`-Sqxc>@)t}Q7KlZh3}LK_au8b6xQX$I)iUk{ zVh`H(F{&JlnZ|M9cdvucndW_rTyd%i^52aZ-D1QO=AW5m6!)x9Kexe0=*r_1+3%lp z6`EPxA1BLf|IO}h6kAmHb;pcjIYmMzepy_XTi4T^dZyvw=H^C;{z;$zwHx}fGW@ag zF*>z*!WmYEXJJa8%aUd79F!Oi{IsalPx|Jd%y6Kz$V7PB2Sy`Cu0YoWu6+)b`VEDP zsuFG0ycikoab`FEjL_KLAhkZ4srUn{IFrbpjt7cAnzR|Y)+kkQ?RyZ^z|_ql*Q~!o zY(au*)AI-1iYzN;s=Bls$adlixUA)J1heF@Ny#gxN3Ya%U@;lAgxDp&@!7LZd zCiL?LFfVx?$*|P{n&8D8bOq+?OTIU4Q0X|mJ!dPc%5x8(EHJzLUCF58D8sk8rut*+ zuH+Y!FT4JEW!Z4+fV_Tc*)@jh{1s*oo?S}XtK{&PQK+$d*|a>tg!jy<&AfM`KiB)^ XY0N3Uv0x)G?=yJ1`njxgN@xNAjX=6n literal 0 HcmV?d00001 diff --git a/public/icons/icon-144x144.png b/public/icons/icon-144x144.png new file mode 100644 index 0000000000000000000000000000000000000000..99f9c822b74e50aed2666a140e937925fe3beab8 GIT binary patch literal 1199 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 literal 0 HcmV?d00001 diff --git a/public/icons/icon-384x384.png b/public/icons/icon-384x384.png new file mode 100644 index 0000000000000000000000000000000000000000..d5be21fd7371ec8ad8553d3d5dc9e46858cf6fa5 GIT binary patch literal 3455 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 literal 0 HcmV?d00001 diff --git a/public/icons/icon-48x48.png b/public/icons/icon-48x48.png new file mode 100644 index 0000000000000000000000000000000000000000..8fcb3d30e32ad7968c12c36b0eb2d8dc16a670f6 GIT binary patch literal 379 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 Z4%uhb&3>NliO3k1E?(hsVUaNDzW^RV8s-21 literal 0 HcmV?d00001 diff --git a/public/icons/icon-72x72.png b/public/icons/icon-72x72.png new file mode 100644 index 0000000000000000000000000000000000000000..debcbf633cca81bce3da70f2dfb4f0fc5387adce GIT binary patch literal 574 zcmV-E0>S->P)1%u5cs zF7pI=irK?DO{NKIEeCNAW`eFDCt!ImCW5Xc$C}dvMGHAO&yA9LET2QC1f7wCdNo^@ z>ps;$j#&_8S3iSl(qr9#b$KZ9RpX#ma@O^F&Bt82m{nt1%<7A~SZR7%E>&0nT~WNd z9Famnt;QlPfh4FGIU-!NS1}jVQ+h-S1ob>2J%U|K!BkLh-AjZCQlTIP3R0jT1qxE2 zAO#9i$QIP&7$=ABzFD<~T}*+36p92{e*=04m>`8>L8GOpiydZ2ss!bZiF%M!ouI2p zLe=B)kVZ=iq-+0kSKgz@ zjpPcjvl`x_Q0rB&*SzNf8?r7Qhf?5yIt5ojF+7sl0kdTMM9jcUTv1dAf|#N$lL*(O zRn~%-qCZ|P;sjAr^Z)<= literal 0 HcmV?d00001 diff --git a/public/icons/icon-96x96.png b/public/icons/icon-96x96.png new file mode 100644 index 0000000000000000000000000000000000000000..8d8a1e7d38c789e1eb84207a409433da32f25978 GIT binary patch literal 759 zcmV z%W=an3`8MMPP(KIH;~R!ggbKsX~#KaXiKsv@dIF?Xx}wtl5IVBmrFg{yaoj0dOb)%thFKkUVN`#zDE>Z&k1V`T91Ri7vx9 zeuk`o+Q9dSDkPKu?a*^Xk`Y3HSMW zL=RuYe~xYfju3vrbFMA|jud{vYtH5Zju?KzWA3K6BCI|@$$?U1Y>xx7*;$Zg_!>b( zy_aNC0b1c}g%CA-5d#57Dg7v8hxwjAqvzrm_bO+e)E zBLi5t@~wslKq`Q%|Ao5%hCioE0NYPLt`)%5{|@{* z;Ow{w;ObweSpfUL4s1bSC;*I$0QUbptN<_+0EPm0#1%T-iz~ewatN<|lnJSoW0le4WhkFZR70gi)P+J9*@PY?nFlqu=1#PHS z043A|Z$VKcT<`Ovm@0OSr8HJg+OZ=IAvKvli6)0lZe=-Z)?Hl2;XI$@ULhP2JHSQj6oA!BcUlT!k39h>)qR)0p@jMaWZ^RaO&k?qBZnSDZ zEe>u~AlSKZ^KLL91SGh9eIWP%hhRjzg4N=G>yTa4do767(9dRuB~Q>` pHMCK*cxxVEGW~~?u}*cz_yx$e+F0LBHwyp&002ovPDHLkV1ko8O%wnC literal 0 HcmV?d00001 diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..33666af --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,49 @@ +{ + "name": "Falah — Islamic Lifestyle", + "short_name": "Falah", + "description": "Nur AI coaching, Halal marketplace, community & wallet", + "start_url": "/mobile", + "scope": "/mobile", + "display": "standalone", + "orientation": "portrait", + "background_color": "#0a0a0f", + "theme_color": "#0a0a0f", + "categories": ["lifestyle", "education", "finance"], + "lang": "en", + "dir": "ltr", + "id": "/mobile/", + "icons": [ + { "src": "/mobile/icons/icon-48x48.png", "sizes": "48x48", "type": "image/png" }, + { "src": "/mobile/icons/icon-72x72.png", "sizes": "72x72", "type": "image/png" }, + { "src": "/mobile/icons/icon-96x96.png", "sizes": "96x96", "type": "image/png" }, + { "src": "/mobile/icons/icon-128x128.png", "sizes": "128x128", "type": "image/png" }, + { "src": "/mobile/icons/icon-144x144.png", "sizes": "144x144", "type": "image/png" }, + { "src": "/mobile/icons/icon-152x152.png", "sizes": "152x152", "type": "image/png" }, + { "src": "/mobile/icons/icon-192x192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "/mobile/icons/icon-384x384.png", "sizes": "384x384", "type": "image/png" }, + { "src": "/mobile/icons/icon-512x512.png", "sizes": "512x512", "type": "image/png" }, + { "src": "/mobile/icons/icon-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, + { "src": "/mobile/icons/icon-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } + ], + "screenshots": [], + "shortcuts": [ + { + "name": "Nur AI Coach", + "short_name": "Nur AI", + "description": "Open AI coaching assistant", + "url": "/mobile/nur" + }, + { + "name": "Halal Market", + "short_name": "Market", + "description": "Browse halal marketplace", + "url": "/mobile/market" + }, + { + "name": "Wallet", + "short_name": "Wallet", + "description": "Falah wallet & payments", + "url": "/mobile/wallet" + } + ] +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..272c6ec --- /dev/null +++ b/public/sw.js @@ -0,0 +1,89 @@ +// Falah PWA Service Worker +const CACHE = "falah-cache-v1"; +const IMMUTABLE_CACHE = "falah-immutable-v1"; + +// Assets to pre-cache on install +const PRECACHE_ASSETS = [ + "/mobile/manifest.json", + "/mobile/icons/icon-192x192.png", + "/mobile/icons/icon-512x512.png", + "/mobile/apple-touch-icon.png", + "/mobile/offline" +]; + +// Install: pre-cache core assets +self.addEventListener("install", (event) => { + event.waitUntil( + caches.open(IMMUTABLE_CACHE).then((cache) => cache.addAll(PRECACHE_ASSETS)) + ); + self.skipWaiting(); +}); + +// Activate: clean old caches +self.addEventListener("activate", (event) => { + event.waitUntil( + caches.keys().then((keys) => + Promise.all( + keys + .filter((k) => k !== CACHE && k !== IMMUTABLE_CACHE) + .map((k) => caches.delete(k)) + ) + ) + ); + self.clients.claim(); +}); + +// Fetch: network-first for pages, cache-first for static assets +self.addEventListener("fetch", (event) => { + const { request } = event; + const url = new URL(request.url); + + // Only handle same-origin /mobile requests + if (!url.pathname.startsWith("/mobile")) return; + + // API calls — never cache + if (url.pathname.startsWith("/mobile/api/")) return; + + // Static assets (icons, fonts, images) — cache-first + if ( + url.pathname.match(/\.(png|jpg|jpeg|gif|svg|ico|webp|woff2?|css)$/) + ) { + event.respondWith(cacheFirst(request)); + return; + } + + // Navigation & pages — network-first with fallback + if (request.mode === "navigate") { + event.respondWith(networkFirst(request)); + return; + } + + // Everything else — network-only + event.respondWith(fetch(request).catch(() => caches.match(request))); +}); + +async function networkFirst(request) { + try { + const response = await fetch(request); + if (response.ok) { + const cache = await caches.open(CACHE); + cache.put(request, response.clone()); + } + return response; + } catch { + const cached = await caches.match(request); + if (cached) return cached; + // Offline fallback page + return caches.match("/mobile/offline"); + } +} + +async function cacheFirst(request) { + const cached = await caches.match(request); + if (cached) return cached; + try { + return await fetch(request); + } catch { + return new Response("", { status: 408 }); + } +} diff --git a/scripts/init-gitea-courses.sh b/scripts/init-gitea-courses.sh new file mode 100644 index 0000000..9b8e4e6 --- /dev/null +++ b/scripts/init-gitea-courses.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +# init-gitea-courses.sh — Create the courses repo in Gitea and push initial content +# Run this when Gitea is available. +# Usage: GITEA_TOKEN=xxx bash scripts/init-gitea-courses.sh + +set -euo pipefail + +GITEA_URL="${GITEA_URL:-https://git.falahos.my}" +GITEA_TOKEN="${GITEA_TOKEN:?GITEA_TOKEN is required}" +REPO_OWNER="maifors" +REPO_NAME="courses" +WORKDIR="/tmp/courses-repo" + +echo "=== Initializing Courses Repo ===" + +# Create repo via Gitea API +echo "Creating repo $REPO_OWNER/$REPO_NAME..." +curl -s -X POST "$GITEA_URL/api/v1/admin/users/$REPO_OWNER/repos" \ + -H "Authorization: token $GITEA_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"name\": \"$REPO_NAME\", \"description\": \"Falah Academy micro learning course content\", \"private\": false, \"auto_init\": false}" \ + || echo "Repo may already exist, continuing..." + +# Clone or create local working directory +rm -rf "$WORKDIR" +mkdir -p "$WORKDIR" +cd "$WORKDIR" + +git init +git config user.name "Falah Academy Bot" +git config user.email "academy@falahos.my" + +# ─── Course Index ─── +mkdir -p courses +cat > courses/course-index.yaml << 'INDEXEOF' +courses: + - slug: "atomic-habits" + title: "Atomic Habits" + author: "James Clear" + - slug: "deep-work" + title: "Deep Work" + author: "Cal Newport" + - slug: "7-habits" + title: "The 7 Habits of Highly Effective People" + author: "Stephen R. Covey" +INDEXEOF + +# ─── Atomic Habits Course ─── +COURSE="courses/atomic-habits" +mkdir -p "$COURSE" + +cat > "$COURSE/course.yaml" << 'COURSEEOF' +slug: "atomic-habits" +title: "Atomic Habits" +author: "James Clear" +description: "Master the science of habit formation in just 25 minutes." +imageUrl: "" +moduleCount: 5 +totalMinutes: 25 +difficulty: "beginner" +priceFlh: 499 +priceUsd: 4.99 +subscriptionOnly: false +published: true +modules: + - slug: "the-1-percent-rule" + order: 1 + - slug: "identity-based-habits" + order: 2 + - slug: "the-4-laws" + order: 3 + - slug: "habit-stacking" + order: 4 + - slug: "design-your-environment" + order: 5 +COURSEEOF + +# Module 1: The 1% Rule +MODULE="$COURSE/the-1-percent-rule" +mkdir -p "$MODULE" +cat > "$MODULE/module.yaml" << 'YAMLEOF' +title: "The 1% Rule" +order: 1 +keyTakeaway: "Small habits don't just add up — they compound. Improving just 1% every day leads to being 37x better after one year." +duration: 5 +YAMLEOF + +cat > "$MODULE/lesson.md" << 'MDEOF' +# The 1% Rule + +Habits are the compound interest of self-improvement. The same way that money multiplies through compound interest, the effects of your habits multiply as you repeat them. + +## The Math of Small Changes + +If you get 1% better each day for one year, you'll end up 37 times better. Conversely, getting 1% worse each day for a year will decline you nearly to zero. + +Success is the product of daily habits — not once-in-a-lifetime transformations. +MDEOF + +cat > "$MODULE/quiz.yaml" << 'QUIZEOF' +questions: + - question: "If you get 1% better every day, how much better after one year?" + options: + - "About 3x better" + - "About 37x better" + - "About 10x better" + - "About 100x better" + correctIndex: 1 + - question: "What is the 'Valley of Disappointment'?" + options: + - "A period where habits feel boring" + - "The phase where you work but see no results" + - "The time between starting a new habit" + - "When you lose motivation completely" + correctIndex: 1 +QUIZEOF + +# Module 5: Design Your Environment +MODULE="$COURSE/design-your-environment" +mkdir -p "$MODULE" +cat > "$MODULE/module.yaml" << 'YAMLEOF' +title: "Design Your Environment" +order: 5 +keyTakeaway: "Your surroundings shape your behavior more than willpower. Design your environment for success by reducing friction for good habits." +duration: 5 +YAMLEOF + +cat > "$MODULE/lesson.md" << 'MDEOF' +# Design Your Environment + +Willpower is overrated. The most reliable way to stick to good habits is to design your environment so that the right choice is the easy choice. +MDEOF + +cat > "$MODULE/quiz.yaml" << 'QUIZEOF' +questions: + - question: "What is a commitment device?" + options: + - "A promise to yourself" + - "A present choice that locks in better future behavior" + - "A device that tracks habits" + - "An accountability partner" + correctIndex: 1 +QUIZEOF + +# ─── Commit & Push ─── +git add -A +git commit -m "feat: initial course content - Atomic Habits" + +git remote add origin "$GITEA_URL/$REPO_OWNER/$REPO_NAME.git" +git branch -M main +git push -u origin main + +echo "" +echo "=== Done! Repo: $GITEA_URL/$REPO_OWNER/$REPO_NAME ===" +echo "Configure Gitea webhook to POST to https://falahos.my/api/learn/sync" +echo " with secret matching SYNC_SECRET env var." diff --git a/scripts/qa-learn.py b/scripts/qa-learn.py new file mode 100644 index 0000000..f48dc8c --- /dev/null +++ b/scripts/qa-learn.py @@ -0,0 +1,328 @@ +#!/usr/bin/env python3 +""" +qa-learn.py — Falah Academy Micro Learning QA Test Suite +8-Layer Model: System, API, Render, Scenario, Edge, Mobile, Perf, Security + +Usage: python3 qa-learn.py [--url https://host:port] +""" + +import urllib.request +import urllib.error +import json +import sys +import time +import re +import ssl +import os + +BASE_URL = "http://localhost:3456" +PASS = 0 +FAIL = 0 +SKIP = 0 +RESULTS = [] + +ctx = ssl.create_default_context() +ctx.check_hostname = False +ctx.verify_mode = ssl.CERT_NONE + +def req(path, method="GET", data=None, headers=None, expect_json=False): + url = f"{BASE_URL}/mobile{path}" + hdrs = { + "User-Agent": "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36", + "Accept": "application/json" if expect_json else "text/html,application/xhtml+xml", + } + if headers: + hdrs.update(headers) + body = json.dumps(data).encode() if data else None + req = urllib.request.Request(url, data=body, headers=hdrs, method=method) + try: + resp = urllib.request.urlopen(req, timeout=15, context=ctx) + text = resp.read().decode("utf-8", errors="replace") + return {"status": resp.status, "text": text, "headers": dict(resp.headers)} + except urllib.error.HTTPError as e: + text = e.read().decode("utf-8", errors="replace") + return {"status": e.code, "text": text, "headers": dict(e.headers)} + except Exception as e: + return {"status": 0, "text": str(e), "headers": {}} + +def check(name, condition, detail=""): + global PASS, FAIL + if condition: + PASS += 1 + RESULTS.append(f" ✅ PASS | {name}") + else: + FAIL += 1 + RESULTS.append(f" ❌ FAIL | {name} | {detail}") + +def section(title): + RESULTS.append(f"\n─── {title} ───") + +def json_body(resp): + try: + return json.loads(resp["text"]) if resp["text"].strip().startswith("{") else None + except: + return None + +# ════════════════════════════════════════ +# LAYER 1: SYSTEM — Route existence +# ════════════════════════════════════════ +section("LAYER 1: SYSTEM — Route Existence") + +routes = [ + ("/api/health", 200), + ("/api/learn/courses", 200), + ("/learn", 200), + ("/verify/TEST-12345", 200), +] +for path, expected in routes: + r = req(path) + check(f"GET {path} → HTTP {r['status']}", r["status"] == expected, f"Got {r['status']}") + +# Non-existent routes should 404 +r = req("/api/learn/nonexistent") +check("Non-existent API → 404", r["status"] == 404, f"Got {r['status']}") + +# ════════════════════════════════════════ +# LAYER 2: API — Response shape contracts +# ════════════════════════════════════════ +section("LAYER 2: API — Response Shape") + +# Courses listing +r = req("/api/learn/courses", expect_json=True) +data = json_body(r) +check("Courses returns JSON", data is not None, f"Got: {r['text'][:100]}") +if data: + courses = data.get("courses", []) + check("Courses has 'courses' array", isinstance(courses, list)) + check("Courses is not empty", len(courses) > 0, f"Got {len(courses)} courses") + if courses: + c = courses[0] + check("Course has 'id'", "id" in c) + check("Course has 'slug'", "slug" in c) + check("Course has 'title'", "title" in c) + check("Course has 'moduleCount'", "moduleCount" in c) + check("Course has 'difficulty'", "difficulty" in c) + check("Course slug is non-empty", bool(c.get("slug"))) + +# Course detail (requires auth → 401) +r = req("/api/learn/courses/atomic-habits", expect_json=True) +check("Course detail without auth → 401", r["status"] == 401, f"Got {r['status']}") + +# Verify endpoint public +r = req("/api/learn/certificates/verify/FAL-TEST-00000-00000000-AAAAA", expect_json=True) +data = json_body(r) +check("Verify endpoint returns JSON (not HTML)", data is not None, f"Got: {r['text'][:100]}") +if data: + check("Verify returns 'valid' field", "valid" in data) + check("Fake serial → valid=false", data.get("valid") is False, f"Got: {data}") + +# ════════════════════════════════════════ +# LAYER 3: RENDER — Content scan +# ════════════════════════════════════════ +section("LAYER 3: RENDER — Content Scan") + +# Learn page should NOT have error strings +r = req("/learn") +error_strings = ["Internal Server Error", "Cannot read properties", "TypeError", "Error:"] +for err in error_strings: + if err in r["text"] and "Error: 404" not in r["text"]: + check(f"Learn page: no '{err}' in HTML", False, f"Found: {err}") + else: + check(f"Learn page: no '{err}' in HTML", True) + +# Check for valid HTML structure +check("Learn page: has ", "", "", " 0: + check("4c: Quiz question has 'question'", "question" in quiz[0]) + check("4c: Quiz question has 'options'", "options" in quiz[0]) + check("4c: Quiz question has 'correctIndex'", "correctIndex" in quiz[0]) + except: + check("4c: Quiz parsing", False, "Invalid quiz JSON") + +# 4d. Enroll in a course (it's seeded, should be accessible) +# Actually enroll endpoint might not exist as enroll - we enroll via webhook +# Let's just verify the course data is complete +r = req("/api/learn/courses", expect_json=True) +data = json_body(r) +if data: + courses = data.get("courses", []) + if courses: + c = courses[0] + check("4d: Course has modules", c.get("moduleCount", 0) > 0, f"moduleCount={c.get('moduleCount')}") + check("4d: Course has description", bool(c.get("description"))) + check("4d: Course has difficulty", c.get("difficulty") in ("beginner", "intermediate", "advanced")) + +# ════════════════════════════════════════ +# LAYER 5: EDGE — Error handling +# ════════════════════════════════════════ +section("LAYER 5: EDGE — Edge Cases") + +# Empty slug → should 404 +r = req("/api/learn/courses/") +check("Empty slug → handled (200 or 404)", r["status"] in (200, 404), f"Got {r['status']}") + +# Non-existent slug +r = req("/api/learn/courses/this-course-does-not-exist") +check("Non-existent course → 401 (auth reqd)", r["status"] == 401, f"Got {r['status']}") + +# Invalid serial for verify +r = req("/api/learn/certificates/verify/INVALID", expect_json=True) +data = json_body(r) +check("Invalid serial → {valid:false}", data is not None and data.get("valid") is False, + f"Got: {r['text'][:100]}") + +# POST to GET-only endpoint → should 405 or gracefully handled +r = req("/api/learn/courses", method="POST") +check("POST to courses list", r["status"] in (405, 404, 400, 200), + f"Got {r['status']}") + +# Query params injection +r = req("/api/learn/courses?slug=atomic-habits'; DROP TABLE;--") +check("SQL injection attempt handled", r["status"] != 500, f"Got {r['status']}") + +# Special characters in slug +r = req("/api/learn/courses/../../../etc/passwd") +check("Path traversal → handled (401/404 not 500)", r["status"] != 500, + f"Got {r['status']}") + +# ════════════════════════════════════════ +# LAYER 6: MOBILE — Mobile-first checks +# ════════════════════════════════════════ +section("LAYER 6: MOBILE — Mobile Readiness") + +r = req("/learn") +html = r["text"] +# Viewport meta +check("Has viewport meta", 'name="viewport"' in html or 'name="viewport"' in html) +# Touch-friendly: look for mobile-like containers +check("Has mobile-container class", 'mobile-container' in html) +# Safe area +check("Has safe-area-bottom", 'safe-area-bottom' in html) +# Dark theme +check("Has dark theme class", 'className="dark"' in html or 'class="dark"' in html) + +# Check health endpoint returns quickly +start = time.time() +req("/api/health") +elapsed = time.time() - start +check(f"Health response < 2s", elapsed < 2.0, f"Took {elapsed:.2f}s") + +# ════════════════════════════════════════ +# LAYER 7: PERF — Performance checks +# ════════════════════════════════════════ +section("LAYER 7: PERFORMANCE") + +for path, label in [("/api/learn/courses", "Courses list"), + ("/learn", "Learn page")]: + times = [] + for _ in range(3): + start = time.time() + req(path) + times.append(time.time() - start) + avg = sum(times) / len(times) + check(f"{label} avg response < 5s", avg < 5.0, f"Avg: {avg:.2f}s") + +# Payload size check +r = req("/api/learn/courses") +check("Courses API payload < 100KB", len(r["text"]) < 100_000, f"Size: {len(r['text'])} bytes") + +# ════════════════════════════════════════ +# LAYER 8: SECURITY — Basic +# ════════════════════════════════════════ +section("LAYER 8: SECURITY — Basic Checks") + +# No secrets in HTML output +r = req("/learn") +secrets = ["JWT_SECRET", "DATABASE_URL", "POLAR_ACCESS_TOKEN", "flh_token="] +for secret in secrets: + if secret in r["text"]: + check(f"No '{secret}' leaked in HTML", False, "SECRET LEAKED") + break +else: + check("No secrets leaked in HTML", True) + +# No stack traces exposed +r = req("/api/learn/courses/__non_existent__") +check("No stack traces in error responses", "at" not in r["text"] or "Traceback" not in r["text"], + "Stack trace might be exposed") + +# ════════════════════════════════════════ +# SUMMARY +# ════════════════════════════════════════ +section("═══════════════════════════════════") +section(f"QA SUMMARY: {PASS} passed, {FAIL} failed, {SKIP} skipped") + +grade = "A" if FAIL == 0 else "B" if FAIL <= 3 else "C" if FAIL <= 10 else "D" +section(f"GRADE: {grade}") +section(f"VERDICT: {'✅ DEPLOY OK' if grade in ('A', 'B') else '❌ BLOCKED — fix critical items'}") + +print("\n".join(RESULTS)) +print(f"\nTotal: {PASS} passed, {FAIL} failed, {SKIP} skipped — Grade {grade}") + +sys.exit(0 if grade in ("A", "B") else 1) diff --git a/scripts/seed-courses.ts b/scripts/seed-courses.ts new file mode 100644 index 0000000..c3122ca --- /dev/null +++ b/scripts/seed-courses.ts @@ -0,0 +1,296 @@ +/** + * seed-courses.ts — Seed initial micro learning courses into the database. + * + * Run: npx tsx scripts/seed-courses.ts + * Or via curl: POST /api/learn/seed (for production use) + * + * This seeds the first published courses from the Gitea content repo, + * or directly defines course/module structure for the MVP. + */ + +import { PrismaClient } from "@prisma/client"; + +const prisma = new PrismaClient(); + +const COURSES = [ + { + slug: "atomic-habits", + title: "Atomic Habits", + author: "James Clear", + description: + "Master the science of habit formation in just 25 minutes. Learn how small daily changes compound into remarkable results through the 1% rule, identity-based habits, and the 4 laws of behavior change.", + imageUrl: null, + moduleCount: 5, + totalMinutes: 25, + difficulty: "beginner", + giteaPath: "courses/atomic-habits", + priceFlh: 499, // 4.99 in FLH cents + priceUsd: 4.99, + subscriptionOnly: false, + published: true, + modules: [ + { + order: 1, + title: "The 1% Rule", + slug: "the-1-percent-rule", + keyTakeaway: + "Small habits don't just add up — they compound. Improving just 1% every day leads to being 37x better after one year.", + duration: 5, + content: `# The 1% Rule + +Habits are the compound interest of self-improvement. The same way that money multiplies through compound interest, the effects of your habits multiply as you repeat them. + +## The Math of Small Changes + +If you get 1% better each day for one year, you'll end up 37 times better. Conversely, getting 1% worse each day for a year will decline you nearly to zero. + +Success is the product of daily habits — not once-in-a-lifetime transformations. + +## Why We Ignore Small Changes + +We expect linear progress but live in a world of delayed returns. The most powerful outcomes are invisible during the early stages. This is the "Valley of Disappointment" — you do the right things but don't see results immediately. + +## The Plateau of Latent Potential + +Just like a ice cube melts at 32°F after hours of warming from 20°F to 31°F, your habits break through when you cross a critical threshold. Don't judge your success by what you see today. Your work is accumulating. + +## Your Action Step + +Identify one habit you want to build. Ask: "Can I make it 1% better today?"`, + quizData: JSON.stringify([ + { + question: "If you get 1% better every day, how much better will you be after one year?", + options: ["About 3x better", "About 37x better", "About 10x better", "About 100x better"], + correctIndex: 1, + }, + { + question: "What is the 'Valley of Disappointment'?", + options: [ + "A period where habits feel boring", + "The phase where you work but see no results", + "The time between starting a new habit", + "When you lose motivation completely", + ], + correctIndex: 1, + }, + { + question: "At what temperature does ice melt?", + options: ["30°F", "32°F", "35°F", "40°F"], + correctIndex: 1, + }, + ]), + }, + { + order: 2, + title: "Identity-Based Habits", + slug: "identity-based-habits", + keyTakeaway: + "The most effective way to change your habits is to focus on who you want to become, not what you want to achieve.", + duration: 5, + content: `# Identity-Based Habits + +There are three levels of change: outcomes, processes, and identity. Most people focus on outcomes (what you get) instead of identity (who you are). + +## The Two-Step Process + +1. **Decide the type of person you want to be.** +2. **Prove it to yourself with small wins.** + +Your identity emerges from your habits. Every action is a vote for the type of person you wish to become. + +## The Habit Loop + +Every habit follows a four-step loop: +- **Cue** — The trigger that initiates the behavior +- **Craving** — The motivational force behind the habit +- **Response** — The actual habit you perform +- **Reward** — The benefit you gain from the habit + +## Your Action Step + +Instead of saying "I want to run a marathon," say "I am a runner." Then go for a 5-minute jog.`, + quizData: JSON.stringify([ + { + question: "What are the three levels of change mentioned?", + options: [ + "Outcomes, Processes, Identity", + "Goals, Actions, Results", + "Start, Middle, End", + "Mind, Body, Soul", + ], + correctIndex: 0, + }, + { + question: "Each action is a ___ for the type of person you wish to become.", + options: ["Reminder", "Vote", "Proof", "Promise"], + correctIndex: 1, + }, + ]), + }, + { + order: 3, + title: "The 4 Laws of Behavior Change", + slug: "the-4-laws", + keyTakeaway: + "To build a good habit: make it obvious, attractive, easy, and satisfying. To break a bad habit: invert each law.", + duration: 5, + content: `# The 4 Laws of Behavior Change + +## Law 1: Make It Obvious +Design your environment so the cues for good habits are visible and the cues for bad habits are invisible. + +## Law 2: Make It Attractive +Use temptation bundling — pair an action you want to do with an action you need to do. + +## Law 3: Make It Easy +The most effective form of learning is practice, not planning. Reduce friction. The Two-Minute Rule: when you start a new habit, it should take less than two minutes. + +## Law 4: Make It Satisfying +Use immediate rewards. What is immediately rewarded is repeated. What is immediately punished is avoided. + +## The Inversion (Breaking Bad Habits) +- Make it **invisible** +- Make it **unattractive** +- Make it **difficult** +- Make it **unsatisfying**`, + quizData: JSON.stringify([ + { + question: "What is Law 1 of behavior change?", + options: ["Make It Easy", "Make It Obvious", "Make It Attractive", "Make It Satisfying"], + correctIndex: 1, + }, + { + question: "The Two-Minute Rule states that a new habit should take:", + options: ["Less than 5 minutes", "Less than 2 minutes", "Exactly 2 minutes", "As long as needed"], + correctIndex: 1, + }, + ]), + }, + { + order: 4, + title: "Habit Stacking", + slug: "habit-stacking", + keyTakeaway: + "The best way to build a new habit is to anchor it to an existing one using the formula: After [CURRENT HABIT], I will [NEW HABIT].", + duration: 5, + content: `# Habit Stacking + +One of the best ways to build a new habit is to identify a current habit you already do each day and then stack your new behavior on top. + +## The Formula + +> After [CURRENT HABIT], I will [NEW HABIT]. + +## Examples +- After I pour my morning coffee, I will meditate for 60 seconds. +- After I sit down to dinner, I will say one thing I'm grateful for. +- After I take off my work shoes, I will change into my workout clothes. + +## The Key: Pairing Specificity + +The more specific your plan, the more likely you are to follow through. Use implementation intentions: +> "I will [BEHAVIOR] at [TIME] in [LOCATION]."`, + quizData: JSON.stringify([ + { + question: "What is habit stacking?", + options: [ + "Doing multiple habits at once", + "Anchoring a new habit to an existing one", + "Stacking rewards on top of each other", + "Creating a list of habits", + ], + correctIndex: 1, + }, + ]), + }, + { + order: 5, + title: "Design Your Environment", + slug: "design-your-environment", + keyTakeaway: + "Your surroundings shape your behavior more than willpower. Design your environment for success by reducing friction for good habits and increasing it for bad ones.", + duration: 5, + content: `# Design Your Environment + +Willpower is overrated. The most reliable way to stick to good habits is to design your environment so that the right choice is the easy choice. + +## Friction + +Every habit is initiated by a cue. If you want to make a habit a big part of your life, make the cue a big part of your environment. + +- Want to read more? Keep a book on your pillow. +- Want to drink more water? Fill a bottle and place it on your desk. +- Want to practice guitar? Leave it in the middle of the room. + +## One Space, One Use + +Don't mix contexts. Your bed is for sleep. Your desk is for work. When you mix contexts, you mix habits. + +## Commitment Devices + +A commitment device is a choice you make in the present that locks in better behavior in the future. Delete the games from your phone. Unsubscribe from junk food delivery. + +## Your Final Action Step + +Choose ONE habit to focus on this week. Apply all 4 laws. Track it. Repeat.`, + quizData: JSON.stringify([ + { + question: "What is a commitment device?", + options: [ + "A promise to yourself", + "A present choice that locks in better future behavior", + "A device that tracks habits", + "An accountability partner", + ], + correctIndex: 1, + }, + { + question: "Wanting to read more — where should you keep the book?", + options: ["On your shelf", "On your pillow", "On your desk", "In your bag"], + correctIndex: 1, + }, + ]), + }, + ], + }, +]; + +async function main() { + console.log("🌱 Seeding micro learning courses...\n"); + + for (const courseData of COURSES) { + const { modules, ...courseFields } = courseData; + + // Upsert the course + const course = await prisma.learnCourse.upsert({ + where: { slug: courseFields.slug }, + create: courseFields, + update: courseFields, + }); + + console.log(` 📖 Course: ${course.title} (${course.slug})`); + + // Upsert each module + for (const mod of modules) { + await prisma.learnModule.upsert({ + where: { + courseId_slug: { courseId: course.id, slug: mod.slug }, + }, + create: { ...mod, courseId: course.id }, + update: { ...mod, courseId: course.id }, + }); + console.log(` 📝 Module ${mod.order}: ${mod.title}`); + } + } + + console.log(`\n✅ Seeded ${COURSES.length} course(s) successfully.`); +} + +main() + .catch((e) => { + console.error("❌ Seed failed:", e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/scripts/seed-souq.ts b/scripts/seed-souq.ts new file mode 100644 index 0000000..a522a4a --- /dev/null +++ b/scripts/seed-souq.ts @@ -0,0 +1,640 @@ +import { prisma } from '@/lib/prisma'; + +async function main() { + console.log('🌱 Seeding souq demo data...\n'); + + // ── Find or create seller user ─────────────────────────────────────────── + let seller = await prisma.user.findFirst(); + + if (!seller) { + console.log(' ℹ️ No users found. Creating a demo seller user...'); + seller = await prisma.user.create({ + data: { + email: 'demo-seller@falah.app', + name: 'Demo Seller', + flhBalance: 10000, + }, + }); + } + console.log(` 👤 Seller: ${seller.name} (${seller.email})`); + + // ── Find or create buyer user (needed for purchase → review chain) ─────── + let buyer = await prisma.user.findFirst({ where: { email: 'demo-buyer@falah.app' } }); + + if (!buyer) { + buyer = await prisma.user.create({ + data: { + email: 'demo-buyer@falah.app', + name: 'Demo Buyer', + flhBalance: 50000, + }, + }); + } + console.log(` 👤 Buyer: ${buyer.name} (${buyer.email})\n`); + + // ── Define 6 demo listings ──────────────────────────────────────────────── + const listingsData = [ + { + title: 'Modern React Dashboard', + description: + 'A fully responsive admin dashboard built with React, TypeScript, and Tailwind CSS. Includes authentication, data visualization charts, dark mode support, and a reusable component library. Perfect for startups and SaaS platforms.', + category: 'programming-tech', + subcategory: 'Frontend', + priceFlh: 15000, + deliveryDays: 10, + packages: [ + { + name: 'Basic', + description: 'Single-page dashboard with 3 core charts, responsive layout, and basic theming.', + price: 8000, + deliveryDays: 7, + revisions: 2, + }, + { + name: 'Standard', + description: 'Multi-page dashboard with authentication, API integration, 6+ chart types, and dark mode.', + price: 15000, + deliveryDays: 10, + revisions: 3, + }, + { + name: 'Premium', + description: 'Full enterprise dashboard with real-time data, user management, role-based access control, and one-click deployment.', + price: 30000, + deliveryDays: 14, + revisions: 5, + }, + ], + tags: ['react', 'typescript', 'tailwind', 'dashboard', 'admin', 'frontend'], + images: ['https://placehold.co/600x400/3b82f6/white?text=React+Dashboard'], + salesCount: 12, + rating: 4.8, + reviewCount: 8, + }, + { + title: 'Flutter Mobile App — MVP Kit', + description: + 'Cross-platform mobile application built with Flutter and Dart. Includes state management (Riverpod), REST API integration, push notifications, and a clean Material-You design. Works on both iOS and Android.', + category: 'programming-tech', + subcategory: 'Mobile App Development', + priceFlh: 20000, + deliveryDays: 14, + packages: [ + { + name: 'Basic', + description: 'Single-screen app with 2 core features, basic navigation, and static data.', + price: 10000, + deliveryDays: 10, + revisions: 2, + }, + { + name: 'Standard', + description: 'Multi-screen app with API integration, local storage, push notifications, and 4-6 feature modules.', + price: 20000, + deliveryDays: 14, + revisions: 3, + }, + { + name: 'Premium', + description: 'Full production-ready app with backend integration, CI/CD pipeline, app store submission support, and analytics.', + price: 40000, + deliveryDays: 21, + revisions: 5, + }, + ], + tags: ['flutter', 'dart', 'mobile', 'cross-platform', 'android', 'ios'], + images: ['https://placehold.co/600x400/8b5cf6/white?text=Flutter+App'], + salesCount: 8, + rating: 4.6, + reviewCount: 5, + }, + { + title: 'SEO Blog Post Package', + description: + 'Research-backed, SEO-optimized blog content tailored to your niche. Each article includes keyword research, meta descriptions, internal linking suggestions, and a featured image brief. Plagiarism-free with Grammarly certification.', + category: 'writing-translation', + subcategory: 'Content Writing', + priceFlh: 5000, + deliveryDays: 3, + packages: [ + { + name: 'Basic', + description: '1 x 800-word blog post with basic keyword optimization and meta description.', + price: 3000, + deliveryDays: 2, + revisions: 1, + }, + { + name: 'Standard', + description: '3 x 1200-word blog posts with comprehensive keyword strategy, headings, and image briefs.', + price: 8000, + deliveryDays: 5, + revisions: 2, + }, + { + name: 'Premium', + description: '6 x 1500-word pillar posts with cluster content strategy, competitor analysis, and social media snippets.', + price: 15000, + deliveryDays: 10, + revisions: 3, + }, + ], + tags: ['seo', 'blog', 'content-writing', 'copywriting', 'wordpress'], + images: ['https://placehold.co/600x400/10b981/white?text=Content+Writing'], + salesCount: 25, + rating: 4.9, + reviewCount: 20, + }, + { + title: 'Complete Brand Identity Design', + description: + 'A comprehensive brand identity package including logo (primary, secondary, icon), color palette, typography system, business card mockup, and brand guidelines PDF. Delivered in AI, EPS, PNG, and SVG formats.', + category: 'graphics-design', + subcategory: 'Logo & Brand Identity', + priceFlh: 18000, + deliveryDays: 12, + packages: [ + { + name: 'Basic', + description: 'Logo design with 3 concepts, 2 revisions, and final files in PNG and SVG.', + price: 8000, + deliveryDays: 7, + revisions: 2, + }, + { + name: 'Standard', + description: 'Full identity kit: logo + color palette + typography + 2 mockups + brand guidelines PDF.', + price: 18000, + deliveryDays: 12, + revisions: 3, + }, + { + name: 'Premium', + description: 'Complete brand launch: full identity kit + 6 mockups (stationery, signage, social) + vector files + brand strategy document.', + price: 35000, + deliveryDays: 18, + revisions: 5, + }, + ], + tags: ['branding', 'logo', 'design', 'identity', 'graphic-design'], + images: ['https://placehold.co/600x400/f59e0b/white?text=Brand+Identity'], + salesCount: 15, + rating: 4.7, + reviewCount: 11, + }, + { + title: 'Full SEO Audit & Optimization', + description: + 'Technical SEO audit covering site speed, mobile-friendliness, crawlability, indexation, and Core Web Vitals. Includes a prioritized action plan and hands-on implementation of fixes. Compatible with WordPress, Shopify, Webflow, and custom sites.', + category: 'digital-marketing', + subcategory: 'Search', + priceFlh: 12000, + deliveryDays: 7, + packages: [ + { + name: 'Basic', + description: 'SEO audit report with 10+ checkpoints, Core Web Vitals analysis, and a prioritized fix list.', + price: 6000, + deliveryDays: 4, + revisions: 1, + }, + { + name: 'Standard', + description: 'Audit + hands-on fixes for top 15 issues, meta tag optimization, and schema markup implementation.', + price: 12000, + deliveryDays: 7, + revisions: 2, + }, + { + name: 'Premium', + description: 'Full-site optimization: audit + fixes + content gap analysis + backlink audit + monthly ranking report template.', + price: 25000, + deliveryDays: 12, + revisions: 3, + }, + ], + tags: ['seo', 'marketing', 'audit', 'optimization', 'web-vitals'], + images: ['https://placehold.co/600x400/ef4444/white?text=SEO+Audit'], + salesCount: 20, + rating: 4.5, + reviewCount: 14, + }, + { + title: 'Professional Video Editing', + description: + 'High-quality video editing for YouTube, social media, or corporate use. Includes cutting, color grading, motion graphics, sound design, captions, and intro/outro animation. Delivered in 1080p or 4K.', + category: 'video-animation', + subcategory: 'Editing & Post-Production', + priceFlh: 25000, + deliveryDays: 10, + packages: [ + { + name: 'Basic', + description: 'Up to 5-minute video: trimming, transitions, background music, and simple text overlays.', + price: 12000, + deliveryDays: 5, + revisions: 2, + }, + { + name: 'Standard', + description: 'Up to 15-minute video: full edit including color grading, motion graphics, captions, and sound design.', + price: 25000, + deliveryDays: 10, + revisions: 3, + }, + { + name: 'Premium', + description: 'Up to 30-minute video: cinematic edit with custom animations, multi-cam sync, advanced VFX, and 4K export.', + price: 50000, + deliveryDays: 15, + revisions: 5, + }, + ], + tags: ['video', 'editing', 'production', 'youtube', 'motion-graphics'], + images: ['https://placehold.co/600x400/ec4899/white?text=Video+Editing'], + salesCount: 6, + rating: 5.0, + reviewCount: 4, + }, + { + title: 'Islamic Nasheed — Audio Production', + description: + 'Professional audio production for Islamic nasheeds, spoken word, and Quran recitation projects. Includes vocal recording, mixing, mastering, and background instrumental arrangement. Delivered in high-quality WAV and MP3 formats with optional music-free versions.', + category: 'music-audio', + subcategory: 'Production & Composition', + priceFlh: 10000, + deliveryDays: 7, + packages: [ + { + name: 'Basic', + description: '1-track recording: vocal tuning, basic mixing, and MP3 export up to 3 minutes.', + price: 5000, + deliveryDays: 5, + revisions: 2, + }, + { + name: 'Standard', + description: '2-track EP: full recording, mixing, mastering, and instrumental arrangement. Up to 6 minutes total.', + price: 10000, + deliveryDays: 7, + revisions: 3, + }, + { + name: 'Premium', + description: 'Full album production (up to 6 tracks): multi-track recording, advanced mixing & mastering, sound design, and distribution-ready masters.', + price: 25000, + deliveryDays: 14, + revisions: 5, + }, + ], + tags: ['nasheed', 'audio', 'production', 'mixing', 'islamic', 'music'], + images: ['https://placehold.co/600x400/a855f7/white?text=Audio+Production'], + salesCount: 9, + rating: 4.8, + reviewCount: 7, + }, + { + title: 'AI Chatbot — Islamic Knowledge Base', + description: + 'Custom AI chatbot built with LLM integration, trained on Islamic texts, fiqh rulings, and Quranic tafsir. Includes conversation history, context-aware responses, and deployment-ready API. Perfect for Islamic apps, websites, and community platforms.', + category: 'ai-services', + subcategory: 'AI Development', + priceFlh: 25000, + deliveryDays: 14, + packages: [ + { + name: 'Basic', + description: 'Single-purpose Q&A bot with 50 pre-defined responses and simple keyword matching.', + price: 12000, + deliveryDays: 7, + revisions: 2, + }, + { + name: 'Standard', + description: 'LLM-powered chatbot with Islamic knowledge base (Quran, hadith, fiqh), conversation memory, and web widget deployment.', + price: 25000, + deliveryDays: 14, + revisions: 3, + }, + { + name: 'Premium', + description: 'Full AI assistant platform: multi-persona support (mufti, alim, murabbi), RAG pipeline, analytics dashboard, and API-first architecture.', + price: 50000, + deliveryDays: 21, + revisions: 5, + }, + ], + tags: ['ai', 'chatbot', 'islamic', 'llm', 'knowledge-base', 'automation'], + images: ['https://placehold.co/600x400/06b6d4/white?text=AI+Chatbot'], + salesCount: 5, + rating: 4.9, + reviewCount: 4, + }, + { + title: 'Halal Business Strategy Consulting', + description: + 'Shariah-compliant business consulting for startups and SMEs. Covers business model validation, market analysis, financial planning, and growth strategy — all aligned with Islamic finance principles (no riba, no gharar). Includes a comprehensive strategy document.', + category: 'consulting', + subcategory: 'Business Consulting', + priceFlh: 20000, + deliveryDays: 10, + packages: [ + { + name: 'Basic', + description: '1-hour strategy call + 5-page business assessment report with key recommendations.', + price: 8000, + deliveryDays: 5, + revisions: 1, + }, + { + name: 'Standard', + description: '3 strategy sessions + full business plan: market analysis, financial projections, and Shariah compliance audit.', + price: 20000, + deliveryDays: 10, + revisions: 2, + }, + { + name: 'Premium', + description: 'End-to-end business launch: strategy, legal structure (Shariah-compliant), fundraising deck, 3-month roadmap, and quarterly check-ins.', + price: 45000, + deliveryDays: 21, + revisions: 4, + }, + ], + tags: ['consulting', 'business', 'shariah', 'strategy', 'startup'], + images: ['https://placehold.co/600x400/64748b/white?text=Consulting'], + salesCount: 11, + rating: 4.7, + reviewCount: 9, + }, + { + title: 'Data Analytics Dashboard — Google Looker Studio', + description: + 'Custom data analytics dashboard built in Google Looker Studio (or open-source Metabase). Includes data source integration, real-time visualizations, KPI tracking, and automated weekly reports. Perfect for e-commerce, SaaS, and marketplace platforms.', + category: 'data', + subcategory: 'Data Analytics', + priceFlh: 12000, + deliveryDays: 7, + packages: [ + { + name: 'Basic', + description: 'Single-source dashboard with 5 core KPIs, 3 visualization pages, and weekly email report.', + price: 6000, + deliveryDays: 5, + revisions: 2, + }, + { + name: 'Standard', + description: 'Multi-source dashboard (up to 3 data sources), 10+ KPIs, custom date filters, and automated Slack/email reports.', + price: 12000, + deliveryDays: 7, + revisions: 3, + }, + { + name: 'Premium', + description: 'Full analytics suite: unlimited data sources, advanced calculated metrics, user-level permissions, embedded dashboards, and API access.', + price: 25000, + deliveryDays: 14, + revisions: 5, + }, + ], + tags: ['data', 'analytics', 'dashboard', 'looker', 'metabase', 'kpi'], + images: ['https://placehold.co/600x400/14b8a6/white?text=Data+Dashboard'], + salesCount: 14, + rating: 4.6, + reviewCount: 10, + }, + { + title: 'E-Commerce Store — Shopify & WooCommerce', + description: + 'Full e-commerce store setup on Shopify or WooCommerce with halal-compliant product catalog, payment gateway integration, shipping configuration, and SEO optimization. Includes theme customization, product import (up to 100 items), and staff training.', + category: 'business', + subcategory: 'E-Commerce', + priceFlh: 18000, + deliveryDays: 10, + packages: [ + { + name: 'Basic', + description: 'Store setup: theme installation, 20 product listings, payment gateway, and basic SEO.', + price: 8000, + deliveryDays: 5, + revisions: 2, + }, + { + name: 'Standard', + description: 'Full store: custom theme tweaks, 50 product listings, shipping zones, tax rules, email marketing integration, and Google Analytics.', + price: 18000, + deliveryDays: 10, + revisions: 3, + }, + { + name: 'Premium', + description: 'Enterprise store: 100+ products, multi-currency, multi-language, custom features, abandoned cart recovery, loyalty program, and 1-month support.', + price: 35000, + deliveryDays: 18, + revisions: 5, + }, + ], + tags: ['ecommerce', 'shopify', 'woocommerce', 'store', 'halal', 'business'], + images: ['https://placehold.co/600x400/6366f1/white?text=E-Commerce'], + salesCount: 18, + rating: 4.8, + reviewCount: 15, + }, + { + title: 'Life Coaching — Islamic Personal Development', + description: + 'Personalized life coaching grounded in Islamic principles of self-development (tazkiyah). Includes goal setting, habit tracking, time management (barakah-based), and spiritual growth planning. Sessions available via video call or chat.', + category: 'personal-growth', + subcategory: 'Self Improvement', + priceFlh: 8000, + deliveryDays: 3, + packages: [ + { + name: 'Basic', + description: '1 x 45-min coaching session + personalized action plan with 3 key goals.', + price: 4000, + deliveryDays: 2, + revisions: 1, + }, + { + name: 'Standard', + description: '4 weekly sessions + habit tracking spreadsheet + daily WhatsApp check-ins + mid-program review.', + price: 12000, + deliveryDays: 28, + revisions: 2, + }, + { + name: 'Premium', + description: '3-month program: 12 sessions + comprehensive personality assessment + Quran journal + group accountability circle + lifetime resources.', + price: 30000, + deliveryDays: 90, + revisions: 4, + }, + ], + tags: ['coaching', 'personal-development', 'tazkiyah', 'islamic', 'growth'], + images: ['https://placehold.co/600x400/65a30d/white?text=Life+Coaching'], + salesCount: 22, + rating: 4.9, + reviewCount: 18, + }, + { + title: 'Product Photography — Halal E-Commerce Ready', + description: + 'Professional product photography optimized for e-commerce marketplaces. Each product is shot on a clean background with proper lighting, color correction, and retouching. Includes white-background and lifestyle-style options. Delivered in web-optimized and print-ready formats.', + category: 'photography', + subcategory: 'Product Photography', + priceFlh: 7000, + deliveryDays: 5, + packages: [ + { + name: 'Basic', + description: '5 product photos: white background, basic color correction, and web-optimized export.', + price: 3500, + deliveryDays: 3, + revisions: 1, + }, + { + name: 'Standard', + description: '15 product photos: white + 1 lifestyle setup, advanced retouching, shadow effects, and multiple formats.', + price: 7000, + deliveryDays: 5, + revisions: 2, + }, + { + name: 'Premium', + description: '30 product photos: multiple angles, 2 lifestyle setups, 360-degree view, video clip per product, and brand style guide integration.', + price: 15000, + deliveryDays: 10, + revisions: 3, + }, + ], + tags: ['photography', 'product', 'ecommerce', 'food', 'retouching'], + images: ['https://placehold.co/600x400/f97316/white?text=Photography'], + salesCount: 30, + rating: 4.7, + reviewCount: 22, + }, + { + title: 'Halal Bookkeeping & Financial Reporting', + description: + 'Shariah-compliant bookkeeping and financial reporting services for small businesses and freelancers. Includes income/expense tracking, profit calculation (zakat-ready), invoice management, and monthly financial statements. Uses accounting software (Xero, QuickBooks, or Wave).', + category: 'finance', + subcategory: 'Accounting', + priceFlh: 10000, + deliveryDays: 7, + packages: [ + { + name: 'Basic', + description: 'Monthly bookkeeping: up to 50 transactions, income statement, and expense categorization.', + price: 5000, + deliveryDays: 5, + revisions: 1, + }, + { + name: 'Standard', + description: 'Monthly bookkeeping + quarterly financial statements + zakat calculation + GST/SST preparation.', + price: 12000, + deliveryDays: 7, + revisions: 2, + }, + { + name: 'Premium', + description: 'Full financial management: weekly bookkeeping, monthly statements, annual audit support, tax filing, cash flow forecasting, and dedicated finance advisor.', + price: 30000, + deliveryDays: 14, + revisions: 4, + }, + ], + tags: ['finance', 'accounting', 'bookkeeping', 'zakat', 'halal', 'tax'], + images: ['https://placehold.co/600x400/eab308/white?text=Financial+Services'], + salesCount: 16, + rating: 4.8, + reviewCount: 13, + }, + ]; + + // ── Clean up existing seed data for idempotency ─────────────────────────── + console.log(' 🧹 Clearing previous seed data...'); + await prisma.review.deleteMany({}); + await prisma.purchase.deleteMany({}); + await prisma.listing.deleteMany({}); + + // ── Create listings ─────────────────────────────────────────────────────── + console.log(' 📦 Creating listings...'); + const createdListings: Array<{ id: string; title: string }> = []; + + for (const data of listingsData) { + const listing = await prisma.listing.create({ + data: { + title: data.title, + description: data.description, + category: data.category, + subcategory: data.subcategory, + priceFlh: data.priceFlh, + packages: JSON.stringify(data.packages), + tags: JSON.stringify(data.tags), + images: JSON.stringify(data.images), + deliveryDays: data.deliveryDays, + rating: data.rating, + reviewCount: data.reviewCount, + salesCount: data.salesCount, + sellerId: seller.id, + status: 'active', + featured: false, + }, + }); + createdListings.push({ id: listing.id, title: listing.title }); + console.log(` ✅ ${listing.title}`); + } + + // ── Create a demo purchase (so we can create a review) ──────────────────── + console.log('\n 🛒 Creating demo purchase...'); + const firstListing = createdListings[0]; + const purchase = await prisma.purchase.create({ + data: { + listingId: firstListing.id, + buyerId: buyer.id, + sellerId: seller.id, + packageName: 'Standard', + amountFlh: 15000, + platformFee: 1500, + sellerPayout: 13500, + status: 'completed', + autoConfirmAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // auto-confirm in 7 days + }, + }); + console.log(` ✅ Purchase for "${firstListing.title}" (Standard package)`); + + // ── Create a demo review ────────────────────────────────────────────────── + console.log('\n ⭐ Creating demo review...'); + const review = await prisma.review.create({ + data: { + listingId: firstListing.id, + purchaseId: purchase.id, + reviewerId: buyer.id, + sellerId: seller.id, + rating: 5, + title: 'Exceptional quality and professionalism!', + text: 'The developer delivered a polished dashboard that exceeded expectations. Clean TypeScript code, thorough documentation, and great communication throughout. Highly recommended for any React project.', + }, + }); + console.log(` ✅ Review (${review.rating}★) on "${firstListing.title}"`); + + // ── Summary ─────────────────────────────────────────────────────────────── + console.log('\n' + '═'.repeat(50)); + console.log('🎉 Souq seed completed successfully!'); + console.log(` • ${createdListings.length} listings created`); + console.log(` • 1 purchase created`); + console.log(` • 1 review created`); + console.log(` • Seller: ${seller.name} (${seller.email})`); + console.log(` • Buyer: ${buyer.name} (${buyer.email})`); + console.log('═'.repeat(50)); +} + +main().catch((e) => { + console.error('❌ Seed failed:', e); + process.exit(1); +}).finally(async () => { + await prisma.$disconnect(); +}); diff --git a/src/app/api/auth/[provider]/callback/route.ts b/src/app/api/auth/[provider]/callback/route.ts deleted file mode 100644 index aeb1985..0000000 --- a/src/app/api/auth/[provider]/callback/route.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { cookies } from "next/headers"; -import { - type Provider, - exchangeCode, - findOrCreateOAuthUser, - verifyState, -} from "@/lib/oauth"; -import { signJWT } from "@/lib/auth"; - -const VALID_PROVIDERS = ["google", "github"]; - -async function handleCallback( - req: NextRequest, - provider: string -): Promise { - try { - // Grab code and state from query params or POST body - const url = new URL(req.url); - let code = url.searchParams.get("code"); - let stateParam = url.searchParams.get("state"); - - // Try reading POST body if code wasn't in query params - if (!code) { - try { - const contentType = req.headers.get("content-type") || ""; - if (contentType.includes("form")) { - const formData = await req.formData(); - code = formData.get("code") as string | null; - stateParam = formData.get("state") as string | null; - } - } catch { - // not a form POST, that's fine - } - } - - if (!code) { - return NextResponse.json( - { error: "Missing authorization code" }, - { status: 400 } - ); - } - - if (!stateParam) { - return NextResponse.json( - { error: "Missing state parameter" }, - { status: 400 } - ); - } - - // Verify state from cookie (CSRF protection) - const cookieStore = await cookies(); - const storedState = cookieStore.get("oauth_state")?.value; - - if (!storedState) { - return NextResponse.json( - { - error: "Missing state cookie. Please start the OAuth flow again.", - }, - { status: 400 } - ); - } - - const statePayload = await verifyState(stateParam); - if (!statePayload) { - return NextResponse.json( - { - error: - "Invalid or expired state. Please start the OAuth flow again.", - }, - { status: 400 } - ); - } - - // Verify the state and provider match - if (statePayload.provider !== provider) { - return NextResponse.json( - { error: "State/provider mismatch" }, - { status: 400 } - ); - } - - // Clear the state cookie - cookieStore.set("oauth_state", "", { - httpOnly: true, - path: "/", - maxAge: 0, - }); - - // Build the redirect URI (must match the one used in the authorization request) - const forwardedProto = req.headers.get("x-forwarded-proto") || "https"; - const rawHost = req.headers.get("host") || ""; - const isDockerHost = /^[a-f0-9]{12,}$/i.test(rawHost.split(":")[0]); - const forwardedHost = req.headers.get("x-forwarded-host") || (isDockerHost ? "falahos.my" : rawHost); - const baseUrl = `${forwardedProto}://${forwardedHost}`; - const redirectUri = `${baseUrl}/mobile/api/auth/${provider}/callback`; - - // Exchange code for user info - const providerUser = await exchangeCode( - provider as Provider, - code, - redirectUri - ); - - // Find or create user in database - const user = await findOrCreateOAuthUser( - provider as Provider, - providerUser - ); - - // Generate JWT - const token = await signJWT({ - userId: user.id, - email: user.email, - isPremium: user.isPremium, - isPro: user.isPro, - }); - - // Redirect to frontend callback page with token - const frontendUrl = `${baseUrl}/mobile/auth/callback?token=${encodeURIComponent(token)}`; - return NextResponse.redirect(frontendUrl); - } catch (error) { - console.error(`${provider} OAuth callback error:`, error); - const message = - error instanceof Error ? error.message : "OAuth callback failed"; - return NextResponse.json({ error: message }, { status: 500 }); - } -} - -export async function GET( - req: NextRequest, - { params }: { params: Promise<{ provider: string }> } -) { - const { provider } = await params; - if (!VALID_PROVIDERS.includes(provider)) { - return NextResponse.json( - { - error: `Unsupported provider. Must be one of: ${VALID_PROVIDERS.join(", ")}`, - }, - { status: 400 } - ); - } - return handleCallback(req, provider); -} diff --git a/src/app/api/auth/[provider]/route.ts b/src/app/api/auth/[provider]/route.ts deleted file mode 100644 index e460411..0000000 --- a/src/app/api/auth/[provider]/route.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { cookies } from "next/headers"; -import { - type Provider, - PROVIDER_CONFIGS, - generateState, - buildAuthorizationUrl, -} from "@/lib/oauth"; - -const VALID_PROVIDERS = ["google", "github"]; - -export async function GET( - _req: NextRequest, - { params }: { params: Promise<{ provider: string }> } -) { - const { provider } = await params; - - if (!VALID_PROVIDERS.includes(provider)) { - return NextResponse.json( - { error: `Unsupported provider. Must be one of: ${VALID_PROVIDERS.join(", ")}` }, - { status: 400 } - ); - } - - const config = PROVIDER_CONFIGS[provider as Provider]; - const clientId = process.env[config.clientIdEnv]; - - if (!clientId) { - return NextResponse.json( - { - error: `${provider} OAuth is not configured. Missing ${config.clientIdEnv} environment variable.`, - }, - { status: 503 } - ); - } - - // Build the redirect URI — the callback URL for this provider - // Use forwarded headers (Traefik/Cloudflare) or the original Host header - // to ensure the public domain is used, not the Docker container hostname. - const forwardedProto = _req.headers.get("x-forwarded-proto") || "https"; - const rawHost = _req.headers.get("host") || ""; - const isDockerHost = /^[a-f0-9]{12,}$/i.test(rawHost.split(":")[0]); - const forwardedHost = _req.headers.get("x-forwarded-host") || (isDockerHost ? "falahos.my" : rawHost); - const baseUrl = `${forwardedProto}://${forwardedHost}`; - const redirectUri = `${baseUrl}/mobile/api/auth/${provider}/callback`; - - // Generate state for CSRF protection - const state = await generateState(provider as Provider); - - // Store state in a cookie - const cookieStore = await cookies(); - cookieStore.set("oauth_state", state, { - httpOnly: true, - secure: process.env.NODE_ENV === "production", - sameSite: "lax", - path: "/", - maxAge: 60 * 10, // 10 minutes - }); - - // Build the authorization URL and redirect - const authUrl = buildAuthorizationUrl( - provider as Provider, - state, - redirectUri - ); - - return NextResponse.redirect(authUrl); -} diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index d9719cd..769cc23 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -1,7 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; -import bcrypt from "bcryptjs"; import { prisma } from "@/lib/prisma"; -import { signJWT } from "@/lib/auth"; +import { UMMAHID_URL } from "@/lib/auth"; export async function POST(req: NextRequest) { try { @@ -14,39 +13,48 @@ export async function POST(req: NextRequest) { ); } - const user = await prisma.user.findUnique({ where: { email } }); - if (!user || !user.passwordHash) { - return NextResponse.json( - { error: "Invalid email or password" }, - { status: 401 } - ); - } - - const valid = await bcrypt.compare(password, user.passwordHash); - if (!valid) { - return NextResponse.json( - { error: "Invalid email or password" }, - { status: 401 } - ); - } - - const token = await signJWT({ - userId: user.id, - email: user.email, - isPremium: user.isPremium, - isPro: user.isPro, + // Proxy to Ummah ID + const ummahRes = await fetch(`${UMMAHID_URL}/api/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), }); + const ummahData = await ummahRes.json(); + + if (!ummahRes.ok) { + return NextResponse.json( + { error: ummahData.error || "Authentication failed" }, + { status: ummahRes.status } + ); + } + + const { token, user: ummahUser } = ummahData; + + // Find or create local user by email + let localUser = await prisma.user.findUnique({ where: { email } }); + if (!localUser) { + localUser = await prisma.user.create({ + data: { + email, + name: ummahUser?.name || email?.split("@")[0] || "User", + provider: "ummahid", + providerId: ummahUser.id, + walletBalance: 5000, + }, + }); + } + return NextResponse.json({ token, user: { - id: user.id, - email: user.email, - name: user.name, - isPremium: user.isPremium, - isPro: user.isPro, - flhBalance: user.flhBalance, - dailyMsgCount: user.dailyMsgCount, + id: localUser.id, + email: localUser.email, + name: localUser.name, + isPremium: localUser.isPremium, + isPro: localUser.isPro, + flhBalance: localUser.flhBalance, + dailyMsgCount: localUser.dailyMsgCount, }, }); } catch (error) { diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index a6937e5..0a842fe 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -1,15 +1,10 @@ import { NextRequest, NextResponse } from "next/server"; -import bcrypt from "bcryptjs"; import { prisma } from "@/lib/prisma"; -import { signJWT } from "@/lib/auth"; -import { findReferrerByCode } from "@/lib/referral"; - -const REFERRER_REWARD_FLH = 200; -const REFERRED_BONUS_FLH = 1000; +import { UMMAHID_URL } from "@/lib/auth"; export async function POST(req: NextRequest) { try { - const { email, name, password, referralCode } = await req.json(); + const { email, name, password } = await req.json(); if (!email || !name || !password) { return NextResponse.json( @@ -18,96 +13,49 @@ export async function POST(req: NextRequest) { ); } - const existing = await prisma.user.findUnique({ where: { email } }); - if (existing) { + // Proxy to Ummah ID + const ummahRes = await fetch(`${UMMAHID_URL}/api/auth/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, name, password }), + }); + + const ummahData = await ummahRes.json(); + + if (!ummahRes.ok) { return NextResponse.json( - { error: "An account with this email already exists" }, - { status: 409 } + { error: ummahData.error || "Registration failed" }, + { status: ummahRes.status } ); } - const passwordHash = await bcrypt.hash(password, 12); + const { token, user: ummahUser } = ummahData; - // Look up referrer if a referral code was provided - let referrerId: string | null = null; - if (referralCode && typeof referralCode === "string") { - const users = await prisma.user.findMany({ - select: { id: true }, + // Create or find local user (find first to avoid duplicates) + let localUser = await prisma.user.findUnique({ where: { email } }); + if (!localUser) { + localUser = await prisma.user.create({ + data: { + email, + name: ummahUser?.name || name, + provider: "ummahid", + flhBalance: 5000, + trialEndsAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + }, }); - referrerId = await findReferrerByCode(users, referralCode); } - // Calculate starting balance - let startingBalance = 5000; // default sign-up bonus - if (referrerId) { - startingBalance += REFERRED_BONUS_FLH; // +1,000 FLH for using a referral code - } - - const user = await prisma.user.create({ - data: { - email, - name, - passwordHash, - provider: "email", - flhBalance: startingBalance, - trialEndsAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), - }, - }); - - // If referral code was valid, create the referral record and credit the referrer - if (referrerId) { - await prisma.$transaction([ - // Create the referral record - prisma.referral.create({ - data: { - referrerId, - referredId: user.id, - status: "joined", - rewardFlh: REFERRER_REWARD_FLH, - }, - }), - // Credit the referrer - prisma.user.update({ - where: { id: referrerId }, - data: { flhBalance: { increment: REFERRER_REWARD_FLH } }, - }), - // Notify the referrer - prisma.notification.create({ - data: { - userId: referrerId, - type: "referral_bonus", - title: "Referral Bonus Earned!", - body: `${name} joined Falah using your referral code — you earned ${REFERRER_REWARD_FLH} FLH!`, - data: JSON.stringify({ - referredUserId: user.id, - referredName: name, - rewardFlh: REFERRER_REWARD_FLH, - }), - }, - }), - ]); - } - - const token = await signJWT({ - userId: user.id, - email: user.email, - isPremium: user.isPremium, - isPro: user.isPro, - }); - return NextResponse.json({ token, user: { - id: user.id, - email: user.email, - name: user.name, - isPremium: user.isPremium, - isPro: user.isPro, - flhBalance: user.flhBalance, - dailyMsgCount: user.dailyMsgCount, + id: localUser.id, + email: localUser.email, + name: localUser.name, + isPremium: localUser.isPremium, + isPro: localUser.isPro, + flhBalance: localUser.flhBalance, + dailyMsgCount: localUser.dailyMsgCount, }, - referralApplied: referrerId ? true : false, - referralReward: referrerId ? REFERRED_BONUS_FLH : 0, }); } catch (error) { console.error("Register error:", error); diff --git a/src/app/api/files/[listingId]/route.ts b/src/app/api/files/[listingId]/route.ts deleted file mode 100644 index d1a8cae..0000000 --- a/src/app/api/files/[listingId]/route.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { prisma } from "@/lib/prisma"; -import { requireAuth } from "@/lib/auth"; - -export async function GET( - request: NextRequest, - { params }: { params: Promise<{ listingId: string }> } -) { - try { - const auth = await requireAuth(request); - if (!auth) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const { listingId } = await params; - - const listing = await prisma.listing.findUnique({ - where: { id: listingId }, - select: { - id: true, - title: true, - fileType: true, - sellerId: true, - priceFlh: true, - status: true, - }, - }); - - if (!listing) { - return NextResponse.json({ error: "Listing not found" }, { status: 404 }); - } - - // Check if user is the seller - if (listing.sellerId === auth.userId) { - return NextResponse.json({ - file: { - id: listing.id, - title: listing.title, - fileType: listing.fileType || "unknown", - url: null, - message: "You are the seller. File delivery is handled after purchase confirmation.", - }, - }); - } - - // Check if user has purchased this listing - const purchase = await prisma.purchase.findFirst({ - where: { - listingId, - buyerId: auth.userId, - }, - select: { - id: true, - amountFlh: true, - createdAt: true, - autoConfirmAt: true, - }, - }); - - if (!purchase) { - return NextResponse.json( - { error: "You have not purchased this listing" }, - { status: 403 } - ); - } - - return NextResponse.json({ - file: { - id: listing.id, - title: listing.title, - fileType: listing.fileType || "unknown", - url: null, // Placeholder — file storage will be implemented in a future update - purchaseId: purchase.id, - purchasedAt: purchase.createdAt, - message: "File delivery system coming soon. Your purchase is confirmed.", - }, - }); - } catch (error) { - console.error("GET /api/files/[listingId] error:", error); - return NextResponse.json( - { error: "Internal server error" }, - { status: 500 } - ); - } -} diff --git a/src/app/api/forum/posts/route.ts b/src/app/api/forum/posts/route.ts index 15ccd6e..c81e275 100644 --- a/src/app/api/forum/posts/route.ts +++ b/src/app/api/forum/posts/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; import { requireAuth } from "@/lib/auth"; import { getUserTier, FORUM_LIMITS } from "@/lib/tiers"; +import { moderateContent } from "@/lib/shariah-moderation"; export async function GET(request: NextRequest) { try { @@ -104,6 +105,22 @@ export async function POST(request: NextRequest) { }, }); + // ── Auto-moderation for post content ────────────────────────────── + const result = moderateContent(content); + if (result.status === "approved") { + await prisma.forumPost.update({ + where: { id: post.id }, + data: { shariahStatus: "approved" }, + }); + post.shariahStatus = "approved"; + } else { + await prisma.forumPost.update({ + where: { id: post.id }, + data: { shariahFlags: JSON.stringify(result.flags) }, + }); + post.shariahFlags = JSON.stringify(result.flags); + } + return NextResponse.json({ post }, { status: 201 }); } catch (error) { console.error("POST /api/forum/posts error:", error); diff --git a/src/app/api/forum/threads/route.ts b/src/app/api/forum/threads/route.ts index f9bfe2b..8e4afb2 100644 --- a/src/app/api/forum/threads/route.ts +++ b/src/app/api/forum/threads/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; import { requireAuth } from "@/lib/auth"; import { getUserTier, FORUM_LIMITS } from "@/lib/tiers"; +import { moderateContent } from "@/lib/shariah-moderation"; export async function GET(request: NextRequest) { try { @@ -150,6 +151,24 @@ export async function POST(request: NextRequest) { }, }); + // ── Auto-moderation for public threads ─────────────────────────── + if (!groupId) { + const result = moderateContent(content, title); + if (result.status === "approved") { + await prisma.forumThread.update({ + where: { id: thread.id }, + data: { shariahStatus: "approved" }, + }); + thread.shariahStatus = "approved"; + } else { + await prisma.forumThread.update({ + where: { id: thread.id }, + data: { shariahFlags: JSON.stringify(result.flags) }, + }); + thread.shariahFlags = JSON.stringify(result.flags); + } + } + return NextResponse.json({ thread }, { status: 201 }); } catch (error) { console.error("POST /api/forum/threads error:", error); diff --git a/src/app/api/groups/join/route.ts b/src/app/api/groups/join/route.ts new file mode 100644 index 0000000..f29fdb5 --- /dev/null +++ b/src/app/api/groups/join/route.ts @@ -0,0 +1,75 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireAuth } from "@/lib/auth"; + +// POST /api/groups/join — join by invite code (no group ID needed) +export async function POST(request: NextRequest) { + try { + const auth = await requireAuth(request); + if (!auth) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { inviteCode } = await request.json(); + + if (!inviteCode || typeof inviteCode !== "string") { + return NextResponse.json( + { error: "Missing required field: inviteCode" }, + { status: 400 } + ); + } + + // Find the group by its unique invite code + const group = await prisma.group.findUnique({ + where: { inviteCode }, + include: { + members: { + where: { userId: auth.userId }, + }, + }, + }); + + if (!group) { + return NextResponse.json( + { error: "Invalid invite code — group not found" }, + { status: 404 } + ); + } + + // Check if user is already a member + if (group.members.length > 0) { + return NextResponse.json( + { error: "You are already a member of this group" }, + { status: 409 } + ); + } + + // Add user as member + const membership = await prisma.groupMember.create({ + data: { + groupId: group.id, + userId: auth.userId, + role: "member", + }, + include: { + user: { + select: { id: true, name: true, isPremium: true, isPro: true }, + }, + group: { + select: { id: true, name: true }, + }, + }, + }); + + return NextResponse.json( + { membership, group: { id: group.id, name: group.name } }, + { status: 201 } + ); + } catch (error) { + console.error("POST /api/groups/join error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/learn/certificates/[serial]/route.ts b/src/app/api/learn/certificates/[serial]/route.ts new file mode 100644 index 0000000..1f87969 --- /dev/null +++ b/src/app/api/learn/certificates/[serial]/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireAuth } from "@/lib/auth"; +import fs from "fs"; + +// GET /api/learn/certificates/[serial] — download certificate PDF +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ serial: string }> } +) { + try { + const auth = await requireAuth(request); + if (!auth) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { serial } = await params; + + const certificate = await prisma.learnCertificate.findUnique({ + where: { serialNumber: serial }, + }); + + if (!certificate) { + return NextResponse.json( + { error: "Certificate not found" }, + { status: 404 } + ); + } + + if (!certificate.pdfPath) { + return NextResponse.json( + { error: "Certificate PDF not available" }, + { status: 404 } + ); + } + + const pdfBuffer = fs.readFileSync(certificate.pdfPath); + + return new NextResponse(pdfBuffer, { + status: 200, + headers: { + "Content-Type": "application/pdf", + "Content-Disposition": `attachment; filename="${serial}.pdf"`, + }, + }); + } catch (error) { + console.error("GET /api/learn/certificates/[serial] error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/learn/certificates/route.ts b/src/app/api/learn/certificates/route.ts new file mode 100644 index 0000000..fdaa15b --- /dev/null +++ b/src/app/api/learn/certificates/route.ts @@ -0,0 +1,26 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireAuth } from "@/lib/auth"; + +// GET /api/learn/certificates — list authenticated user's certificates +export async function GET(request: NextRequest) { + try { + const auth = await requireAuth(request); + if (!auth) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const certificates = await prisma.learnCertificate.findMany({ + where: { userId: auth.userId }, + orderBy: { issuedAt: "desc" }, + }); + + return NextResponse.json({ certificates }); + } catch (error) { + console.error("GET /api/learn/certificates error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/learn/certificates/verify/[serial]/route.ts b/src/app/api/learn/certificates/verify/[serial]/route.ts new file mode 100644 index 0000000..cd6f2a3 --- /dev/null +++ b/src/app/api/learn/certificates/verify/[serial]/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +// GET /api/learn/certificates/verify/[serial] — public verification +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ serial: string }> } +) { + try { + const { serial } = await params; + + const certificate = await prisma.learnCertificate.findUnique({ + where: { serialNumber: serial }, + include: { + course: { + select: { title: true }, + }, + }, + }); + + if (!certificate) { + return NextResponse.json({ valid: false }); + } + + return NextResponse.json({ + valid: !certificate.revoked, + serialNumber: certificate.serialNumber, + userName: certificate.userName, + courseTitle: certificate.course.title, + moduleCount: certificate.moduleCount, + score: certificate.score, + issuedAt: certificate.issuedAt.toISOString(), + revoked: certificate.revoked, + }); + } catch (error) { + console.error("GET /api/learn/certificates/verify/[serial] error:", error); + return NextResponse.json({ valid: false }); + } +} diff --git a/src/app/api/learn/courses/[slug]/route.ts b/src/app/api/learn/courses/[slug]/route.ts new file mode 100644 index 0000000..a0ca75b --- /dev/null +++ b/src/app/api/learn/courses/[slug]/route.ts @@ -0,0 +1,120 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireAuth } from "@/lib/auth"; + +// GET /api/learn/courses/[slug] — course detail with modules and user progress +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ slug: string }> } +) { + try { + const auth = await requireAuth(request); + if (!auth) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { slug } = await params; + + const course = await prisma.learnCourse.findUnique({ + where: { slug }, + include: { + modules: { + orderBy: { order: "asc" }, + }, + }, + }); + + if (!course) { + return NextResponse.json( + { error: "Course not found" }, + { status: 404 } + ); + } + + // Fetch user's enrollment for this course (if any) + let enrollment = await prisma.learnEnrollment.findUnique({ + where: { + userId_courseId: { + userId: auth.userId, + courseId: course.id, + }, + }, + }); + + // Auto-enroll premium users (check local user record, not JWT claims) + // The JWT's licenseTier may not match the local user's isPremium flag + if (!enrollment) { + const localUser = await prisma.user.findUnique({ + where: { id: auth.userId }, + select: { isPremium: true }, + }); + if (localUser?.isPremium) { + enrollment = await prisma.learnEnrollment.create({ + data: { + userId: auth.userId, + courseId: course.id, + purchaseType: "free", + }, + }); + } + } + + // Fetch module progress for this enrollment (if enrolled) + let moduleProgressMap: Record = {}; + if (enrollment) { + const progresses = await prisma.learnModuleProgress.findMany({ + where: { enrollmentId: enrollment.id }, + }); + for (const p of progresses) { + moduleProgressMap[p.moduleId] = { + completed: p.completed, + score: p.score, + listened: p.listened, + }; + } + } + + // Build response + const modules = course.modules.map((mod) => ({ + id: mod.id, + order: mod.order, + title: mod.title, + slug: mod.slug, + keyTakeaway: mod.keyTakeaway, + duration: mod.duration, + content: mod.content, + quizData: mod.quizData ? JSON.parse(mod.quizData) : null, + progress: moduleProgressMap[mod.id] ?? null, + })); + + return NextResponse.json({ + course: { + id: course.id, + slug: course.slug, + title: course.title, + author: course.author, + description: course.description, + imageUrl: course.imageUrl, + moduleCount: course.moduleCount, + totalMinutes: course.totalMinutes, + difficulty: course.difficulty, + giteaPath: course.giteaPath, + }, + modules, + enrollment: enrollment + ? { + id: enrollment.id, + purchaseType: enrollment.purchaseType, + completed: enrollment.completed, + progress: enrollment.progress, + } + : null, + }); + } catch (error) { + console.error("GET /api/learn/courses/[slug] error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/learn/courses/route.ts b/src/app/api/learn/courses/route.ts new file mode 100644 index 0000000..18a7293 --- /dev/null +++ b/src/app/api/learn/courses/route.ts @@ -0,0 +1,58 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireAuth } from "@/lib/auth"; + +export async function GET(request: NextRequest) { + try { + const auth = await requireAuth(request); + const userId = auth?.userId; + + const courses = await prisma.learnCourse.findMany({ + where: { published: true }, + orderBy: { createdAt: "desc" }, + }); + + // If authenticated, fetch the user's enrollments for the returned courses + let enrollmentsMap = new Map(); + if (userId && courses.length > 0) { + const enrollments = await prisma.learnEnrollment.findMany({ + where: { + userId, + courseId: { in: courses.map((c) => c.id) }, + }, + select: { courseId: true, progress: true, completed: true }, + }); + for (const e of enrollments) { + enrollmentsMap.set(e.courseId, { progress: e.progress, completed: e.completed }); + } + } + + const result = courses.map((course) => { + const enrollment = enrollmentsMap.get(course.id); + return { + id: course.id, + slug: course.slug, + title: course.title, + author: course.author, + description: course.description, + imageUrl: course.imageUrl, + moduleCount: course.moduleCount, + totalMinutes: course.totalMinutes, + difficulty: course.difficulty, + priceFlh: course.priceFlh, + priceUsd: course.priceUsd, + subscriptionOnly: course.subscriptionOnly, + enrolled: userId ? !!enrollment : null, + ...(enrollment ? { enrollment: { progress: enrollment.progress, completed: enrollment.completed } } : {}), + }; + }); + + return NextResponse.json({ courses: result }); + } catch (error) { + console.error("GET /api/learn/courses error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/learn/progress/route.ts b/src/app/api/learn/progress/route.ts new file mode 100644 index 0000000..9e51bbf --- /dev/null +++ b/src/app/api/learn/progress/route.ts @@ -0,0 +1,215 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireAuth } from "@/lib/auth"; +import { + generateCertificatePdf, + generateSerialNumber, + buildVerifyUrl, +} from "@/lib/learn"; + +export async function POST(request: Request) { + const jwt = await requireAuth(request); + if (!jwt) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const body = await request.json(); + const { moduleId, completed, score, listened } = body; + + if (!moduleId || typeof completed !== "boolean") { + return NextResponse.json( + { error: "moduleId (string) and completed (boolean) are required" }, + { status: 400 } + ); + } + + // 1. Find the module with its course relation + const module = await prisma.learnModule.findUnique({ + where: { id: moduleId }, + include: { course: true }, + }); + + if (!module) { + return NextResponse.json({ error: "Module not found" }, { status: 404 }); + } + + if (!module.course.published) { + return NextResponse.json( + { error: "Course is not published" }, + { status: 403 } + ); + } + + // 2. Find the enrollment for this user + course + let enrollment = await prisma.learnEnrollment.findUnique({ + where: { + userId_courseId: { + userId: jwt.userId, + courseId: module.courseId, + }, + }, + }); + + if (!enrollment) { + return NextResponse.json( + { error: "You are not enrolled in this course" }, + { status: 403 } + ); + } + + // 3. Upsert LearnModuleProgress + await prisma.learnModuleProgress.upsert({ + where: { + enrollmentId_moduleId: { + enrollmentId: enrollment.id, + moduleId: module.id, + }, + }, + update: { + completed, + ...(score !== undefined ? { score } : {}), + ...(listened !== undefined ? { listened } : {}), + ...(completed ? { completedAt: new Date() } : {}), + }, + create: { + enrollmentId: enrollment.id, + moduleId: module.id, + completed, + ...(score !== undefined ? { score } : {}), + ...(listened !== undefined ? { listened } : {}), + ...(completed ? { completedAt: new Date() } : {}), + }, + }); + + // 4. Count completed modules vs total to compute progress + const totalModules = await prisma.learnModule.count({ + where: { courseId: module.courseId }, + }); + + const completedProgressRecords = await prisma.learnModuleProgress.count({ + where: { + enrollmentId: enrollment.id, + completed: true, + }, + }); + + const progress = + totalModules > 0 ? completedProgressRecords / totalModules : 0; + + // 5. If completing (progress >= 1), handle certificate + enrollment atomically + let certificate: { + serialNumber: string; + verifyUrl: string; + pdfPath: string; + } | undefined; + + if (progress >= 1 && !enrollment.completed) { + const now = new Date(); + + // Fetch user info for certificate (before transaction) + const user = await prisma.user.findUnique({ + where: { id: jwt.userId }, + select: { name: true }, + }); + + const course = module.course; + const serialNumber = generateSerialNumber( + jwt.userId, + course.id, + course.slug, + now + ); + + const certificateData = { + serialNumber, + userName: user?.name ?? "Student", + courseTitle: course.title, + courseAuthor: course.author, + moduleCount: totalModules, + score: score ?? undefined, + issuedAt: now, + verifyUrl: buildVerifyUrl(serialNumber), + }; + + // Generate PDF before transaction — if it fails, transaction never runs + const pdfPath = await generateCertificatePdf(certificateData); + + // All DB mutations inside a single Prisma transaction for atomicity + const result = await prisma.$transaction(async (tx) => { + // Race condition guard: check no existing certificate for this user + course + const existingCert = await tx.learnCertificate.findFirst({ + where: { userId: jwt.userId, courseId: course.id }, + }); + + if (existingCert) { + // Already issued — still update enrollment but don't create another + const updatedEnrollment = await tx.learnEnrollment.update({ + where: { id: enrollment.id }, + data: { + progress, + completed: true, + completedAt: now, + }, + }); + return { enrollment: updatedEnrollment, certificate: null }; + } + + // Update enrollment (progress + completion) and create certificate atomically + const updatedEnrollment = await tx.learnEnrollment.update({ + where: { id: enrollment.id }, + data: { + progress, + completed: true, + completedAt: now, + }, + }); + + await tx.learnCertificate.create({ + data: { + serialNumber, + userId: jwt.userId, + userName: user?.name ?? "Student", + courseId: course.id, + moduleCount: totalModules, + score: score ?? null, + pdfPath, + issuedAt: now, + }, + }); + + return { + enrollment: updatedEnrollment, + certificate: { + serialNumber, + verifyUrl: certificateData.verifyUrl, + pdfPath, + }, + }; + }); + + enrollment = result.enrollment; + if (result.certificate) { + certificate = result.certificate; + } + } else { + // No completion — just update progress outside transaction + enrollment = await prisma.learnEnrollment.update({ + where: { id: enrollment.id }, + data: { progress }, + }); + } + + return NextResponse.json({ + progress, + completed: enrollment.completed, + ...(certificate ? { certificate } : {}), + }); + } catch (error) { + console.error("Learn progress error:", error); + return NextResponse.json( + { error: "Failed to update progress" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/learn/sync/route.ts b/src/app/api/learn/sync/route.ts new file mode 100644 index 0000000..b478794 --- /dev/null +++ b/src/app/api/learn/sync/route.ts @@ -0,0 +1,530 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +// ─── Simple YAML parser for the subset of YAML we expect ─── + +/** Strip surrounding quotes from a string */ +function stripQuotes(s: string): string { + if ( + (s.startsWith('"') && s.endsWith('"')) || + (s.startsWith("'") && s.endsWith("'")) + ) { + return s.slice(1, -1); + } + return s; +} + +/** Parse a scalar value (string, number, boolean, null) */ +function parseScalar(raw: string): string | number | boolean | null { + const trimmed = raw.trim(); + if (trimmed === "null" || trimmed === "~") return null; + if (trimmed === "true") return true; + if (trimmed === "false") return false; + // Try number + const num = Number(trimmed); + if (!isNaN(num) && trimmed !== "") return num; + return stripQuotes(trimmed); +} + +interface ParseResult { + value: any; + nextLine: number; +} + +/** + * Parse a block of YAML lines starting at `startLine`. + * Detects whether the block is a list (lines start with `- ` at base indent) + * or an object (key: value pairs). + */ +function parseBlock(lines: string[], startLine: number, baseIndent: number): ParseResult { + // Skip empty lines + let i = startLine; + while (i < lines.length && lines[i].trim() === "") i++; + + if (i >= lines.length) return { value: null, nextLine: i }; + + const firstNonEmpty = lines[i]; + + // Detect leading whitespace of the first content line + const firstIndent = firstNonEmpty.search(/\S/); + + // Check if this is a list (first non-empty line starts with "- " at base indent) + if (firstNonEmpty.trimStart().startsWith("- ")) { + return parseList(lines, i, firstIndent); + } + + // Otherwise treat as an object (key: value) + return parseMapping(lines, i, firstIndent); +} + +/** Parse a YAML sequence (list) */ +function parseList(lines: string[], startLine: number, indent: number): ParseResult { + const items: any[] = []; + let i = startLine; + + while (i < lines.length) { + const trimmed = lines[i].trim(); + if (trimmed === "") { + i++; + continue; + } + // Check current indent + const currentIndent = lines[i].search(/\S/); + if (currentIndent < indent) break; // outdented, we're done + if (currentIndent !== indent) { + // If it's indented more, it might be part of the previous item's value + // For our use case, this shouldn't happen at the top level of a list + i++; + continue; + } + + if (!trimmed.startsWith("- ")) { + // Not a list item anymore + break; + } + + const rest = trimmed.slice(2).trim(); + + // Check if the item has inline value or is a sub-block + if (rest === "") { + // Empty list item - could have sub-items indented below + // Peek ahead for sub-block + if (i + 1 < lines.length) { + const nextIndent = lines[i + 1].search(/\S/); + if (nextIndent > indent) { + const sub = parseBlock(lines, i + 1, nextIndent); + items.push(sub.value); + i = sub.nextLine; + continue; + } + } + items.push(null); + i++; + } else if (rest.includes(":") && !rest.startsWith('"') && !rest.startsWith("'")) { + // The item might be an inline mapping start: "key: value" or just "key:" + // Actually, for our format, modules have: `- slug: "value"` which is inline + // Let's check if there are sub-properties on subsequent lines + if (i + 1 < lines.length) { + const nextIndent = lines[i + 1].search(/\S/); + if (nextIndent > indent) { + // This list item has sub-properties; parse the rest inline as a mapping + // Treat this line as the first key:value of the sub-mapping + const subResult = parseMappingFromLine(lines, i, indent); + items.push(subResult.value); + i = subResult.nextLine; + continue; + } + } + // No sub-properties, treat the whole line as a mapping key:value + const colonIdx = rest.indexOf(":"); + const key = stripQuotes(rest.slice(0, colonIdx).trim()); + const valStr = rest.slice(colonIdx + 1).trim(); + const val = valStr ? parseScalar(valStr) : null; + const obj: Record = {}; + obj[key] = val; + items.push(obj); + i++; + } else { + // Simple scalar list item + items.push(parseScalar(rest)); + i++; + } + } + + return { value: items, nextLine: i }; +} + +/** + * Parse a mapping (key: value) starting from a specific line. + * The mapping ends when indentation returns to or above baseIndent. + */ +function parseMapping(lines: string[], startLine: number, baseIndent: number): ParseResult { + const result: Record = {}; + let i = startLine; + + while (i < lines.length) { + const line = lines[i]; + const trimmedLine = line.trim(); + if (trimmedLine === "") { + i++; + continue; + } + + const currentIndent = line.search(/\S/); + if (currentIndent < baseIndent) break; // outdented + if (currentIndent !== baseIndent) { + // This shouldn't happen at the mapping level, skip + i++; + continue; + } + + // Must be a key: value pair + if (!trimmedLine.includes(":")) { + i++; + continue; + } + + const colonIdx = trimmedLine.indexOf(":"); + const key = stripQuotes(trimmedLine.slice(0, colonIdx).trim()); + let rest = trimmedLine.slice(colonIdx + 1).trim(); + + if (rest === "") { + // Value might be a nested block on subsequent lines + // Check next line + if (i + 1 < lines.length) { + const nextIndent = lines[i + 1].search(/\S/); + if (nextIndent > currentIndent) { + const subResult = parseBlock(lines, i + 1, nextIndent); + result[key] = subResult.value; + i = subResult.nextLine; + continue; + } + } + // No value or empty value + result[key] = null; + i++; + } else { + // Inline scalar value + result[key] = parseScalar(rest); + i++; + } + } + + return { value: result, nextLine: i }; +} + +/** + * Special case: when a list item like `- slug: "value"` has sub-properties + * on subsequent indented lines, parse it as a mapping starting with the current + * line's content. + */ +function parseMappingFromLine(lines: string[], startLine: number, parentIndent: number): ParseResult { + const result: Record = {}; + let i = startLine; + + // Parse the first line which already has content like "- slug: value" + const firstLine = lines[i]; + const afterDash = firstLine.trim().slice(2).trim(); + const colonIdx = afterDash.indexOf(":"); + const firstKey = stripQuotes(afterDash.slice(0, colonIdx).trim()); + const firstValStr = afterDash.slice(colonIdx + 1).trim(); + result[firstKey] = firstValStr ? parseScalar(firstValStr) : null; + i++; + + // Now parse subsequent lines at a higher indent + const subIndent = parentIndent + 2; // typical YAML indent + while (i < lines.length) { + const line = lines[i]; + const trimmedLine = line.trim(); + if (trimmedLine === "") { + i++; + continue; + } + + const currentIndent = line.search(/\S/); + if (currentIndent <= parentIndent) break; // back to parent level + + if (!trimmedLine.includes(":")) { + i++; + continue; + } + + const ci = trimmedLine.indexOf(":"); + const key = stripQuotes(trimmedLine.slice(0, ci).trim()); + const rest = trimmedLine.slice(ci + 1).trim(); + + if (rest === "") { + // Could have nested value below + if (i + 1 < lines.length) { + const nextIndent = lines[i + 1].search(/\S/); + if (nextIndent > currentIndent) { + const subResult = parseBlock(lines, i + 1, nextIndent); + result[key] = subResult.value; + i = subResult.nextLine; + continue; + } + } + result[key] = null; + i++; + } else { + result[key] = parseScalar(rest); + i++; + } + } + + return { value: result, nextLine: i }; +} + +// ─── Gitea API helpers ─── + +const GITEA_BASE = "https://git.falahos.my/api/v1/repos/maifors/courses/contents"; + +function getGiteaToken(): string { + const token = process.env.GITEA_TOKEN; + if (!token) { + throw new Error("GITEA_TOKEN environment variable is not set"); + } + return token; +} + +interface GiteaFileResponse { + name: string; + path: string; + content: string; + encoding: string; +} + +async function fetchGiteaFile(filePath: string): Promise { + const url = `${GITEA_BASE}/${filePath}`; + const token = getGiteaToken(); + + const res = await fetch(url, { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/json", + }, + }); + + if (!res.ok) { + throw new Error( + `Gitea API error: ${res.status} ${res.statusText} for ${filePath}` + ); + } + + const data: GiteaFileResponse = await res.json(); + if (data.encoding !== "base64" || !data.content) { + throw new Error(`Unexpected Gitea response format for ${filePath}`); + } + + return Buffer.from(data.content, "base64").toString("utf-8"); +} + +// ─── Course sync logic ─── + +interface CourseYaml { + title?: string; + author?: string; + description?: string; + imageUrl?: string; + difficulty?: string; + priceFlh?: number; + priceUsd?: number; + subscriptionOnly?: boolean; + published?: boolean; + modules?: Array<{ + slug?: string; + title?: string; + keyTakeaway?: string; + duration?: number; + }>; +} + +interface QuizQuestion { + question?: string; + options?: string[]; + correctIndex?: number; +} + +interface QuizYaml { + questions?: QuizQuestion[]; +} + +async function syncCourse(slug: string): Promise { + // Fetch course.yaml + const courseYamlText = await fetchGiteaFile(`courses/${slug}/course.yaml`); + const parsed = parseBlock(courseYamlText.split("\n"), 0, 0); + const courseData = parsed.value as CourseYaml; + + if (!courseData || typeof courseData !== "object") { + throw new Error(`Invalid course.yaml for slug: ${slug}`); + } + + const giteaPath = `courses/${slug}`; + + // Upsert the course + const course = await prisma.learnCourse.upsert({ + where: { slug }, + create: { + slug, + title: courseData.title || slug, + author: courseData.author || "Unknown", + description: courseData.description || "", + imageUrl: courseData.imageUrl || null, + difficulty: courseData.difficulty || "beginner", + giteaPath, + priceFlh: courseData.priceFlh ?? null, + priceUsd: courseData.priceUsd ?? null, + subscriptionOnly: courseData.subscriptionOnly ?? false, + published: courseData.published ?? false, + moduleCount: 0, + totalMinutes: 0, + }, + update: { + title: courseData.title || slug, + author: courseData.author || "Unknown", + description: courseData.description || "", + imageUrl: courseData.imageUrl || null, + difficulty: courseData.difficulty || "beginner", + giteaPath, + priceFlh: courseData.priceFlh ?? null, + priceUsd: courseData.priceUsd ?? null, + subscriptionOnly: courseData.subscriptionOnly ?? false, + published: courseData.published ?? false, + }, + }); + + // Sync modules + let totalMinutes = 0; + const modules = courseData.modules || []; + + for (let order = 0; order < modules.length; order++) { + const modData = modules[order]; + if (!modData || !modData.slug) continue; + + const modSlug = modData.slug; + const modTitle = modData.title || modSlug; + + // Fetch lesson.md + let lessonContent: string | null = null; + try { + lessonContent = await fetchGiteaFile( + `courses/${slug}/modules/${modSlug}/lesson.md` + ); + } catch { + // lesson.md might not exist yet + lessonContent = null; + } + + // Fetch quiz.yaml + let quizData: string | null = null; + try { + const quizYamlText = await fetchGiteaFile( + `courses/${slug}/modules/${modSlug}/quiz.yaml` + ); + const quizParsed = parseBlock(quizYamlText.split("\n"), 0, 0); + const quiz = quizParsed.value as QuizYaml; + if (quiz && Array.isArray(quiz.questions)) { + quizData = JSON.stringify(quiz.questions); + } + } catch { + // quiz.yaml might not exist yet + quizData = null; + } + + const duration = modData.duration || 5; + totalMinutes += duration; + + await prisma.learnModule.upsert({ + where: { + courseId_slug: { + courseId: course.id, + slug: modSlug, + }, + }, + create: { + courseId: course.id, + order, + title: modTitle, + slug: modSlug, + keyTakeaway: modData.keyTakeaway || null, + duration, + content: lessonContent, + quizData, + }, + update: { + order, + title: modTitle, + keyTakeaway: modData.keyTakeaway || null, + duration, + content: lessonContent ?? undefined, + quizData: quizData ?? undefined, + }, + }); + } + + // Update course module count and total minutes + await prisma.learnCourse.update({ + where: { id: course.id }, + data: { + moduleCount: modules.length, + totalMinutes, + }, + }); +} + +// ─── Route handlers ─── + +/** + * GET /api/learn/sync — health check + */ +export async function GET() { + return NextResponse.json({ status: "ok", lastSync: null }); +} + +/** + * POST /api/learn/sync — Gitea webhook receiver + * + * Accepts a secret token via ?token= query param or Authorization: Bearer header. + * Compares against SYNC_SECRET env var. + * On success, fetches courses from Gitea and syncs to local DB. + */ +export async function POST(req: NextRequest) { + try { + // 1. Validate secret token + const url = new URL(req.url); + const token = + url.searchParams.get("token") || + req.headers.get("Authorization")?.replace(/^Bearer\s+/i, ""); + + if (!token || token !== process.env.SYNC_SECRET) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + // 2. Fetch course index from Gitea + const indexYamlText = await fetchGiteaFile("courses/course-index.yaml"); + const indexParsed = parseBlock(indexYamlText.split("\n"), 0, 0); + const indexValue = indexParsed.value; + + // The index should be a simple list of slugs + let courseSlugs: string[] = []; + if (Array.isArray(indexValue)) { + courseSlugs = indexValue + .map((item: any) => (typeof item === "string" ? item.trim() : null)) + .filter((s: string | null): s is string => !!s); + } else if (typeof indexValue === "object" && indexValue !== null) { + // Fallback: object keys as slugs + courseSlugs = Object.keys(indexValue); + } + + if (courseSlugs.length === 0) { + return NextResponse.json( + { error: "No courses found in course-index.yaml" }, + { status: 400 } + ); + } + + // 3. Sync each course + const syncedCourses: string[] = []; + + for (const slug of courseSlugs) { + try { + await syncCourse(slug); + syncedCourses.push(slug); + } catch (err) { + console.error(`Failed to sync course "${slug}":`, err); + // Continue with other courses + } + } + + return NextResponse.json({ + synced: syncedCourses.length, + courses: syncedCourses, + }); + } catch (error) { + console.error("Gitea sync error:", error); + return NextResponse.json( + { error: "Sync failed: " + (error instanceof Error ? error.message : "Unknown error") }, + { status: 500 } + ); + } +} diff --git a/src/app/api/learn/tts/route.ts b/src/app/api/learn/tts/route.ts new file mode 100644 index 0000000..1a355da --- /dev/null +++ b/src/app/api/learn/tts/route.ts @@ -0,0 +1,132 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireAuth } from "@/lib/auth"; +import { existsSync } from "fs"; +import { join } from "path"; + +/** + * GET /api/learn/tts?moduleId=xxx + * Returns module text content and whether audio is cached on disk. + * The client uses Web Speech API as the baseline — no server-side TTS yet. + */ +export async function GET(request: NextRequest) { + const jwt = await requireAuth(request); + if (!jwt) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const { searchParams } = new URL(request.url); + const moduleId = searchParams.get("moduleId"); + + if (!moduleId) { + return NextResponse.json( + { error: "moduleId query parameter is required" }, + { status: 400 } + ); + } + + const module = await prisma.learnModule.findUnique({ + where: { id: moduleId }, + select: { id: true, content: true, audioPath: true }, + }); + + if (!module) { + return NextResponse.json({ error: "Module not found" }, { status: 404 }); + } + + let hasAudio = false; + if (module.audioPath) { + const fullPath = join(process.cwd(), "public", module.audioPath); + hasAudio = existsSync(fullPath); + } + + return NextResponse.json({ + moduleId: module.id, + text: module.content ?? "", + hasAudio, + }); + } catch (error) { + console.error("GET /api/learn/tts error:", error); + return NextResponse.json( + { error: "Failed to retrieve TTS data" }, + { status: 500 } + ); + } +} + +/** + * POST /api/learn/tts + * Body: { moduleId } + * 1) Verifies auth, 2) Finds the LearnModule, 3) Checks if audio is cached on disk, + * 4) Returns text content so the client can use Web Speech API (SpeechSynthesis). + * Server-side TTS generation can be added later. + */ +export async function POST(request: Request) { + const jwt = await requireAuth(request); + if (!jwt) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const body = await request.json(); + const { moduleId } = body; + + if (!moduleId) { + return NextResponse.json( + { error: "moduleId is required" }, + { status: 400 } + ); + } + + const module = await prisma.learnModule.findUnique({ + where: { id: moduleId }, + select: { id: true, content: true, audioPath: true }, + }); + + if (!module) { + return NextResponse.json({ error: "Module not found" }, { status: 404 }); + } + + const text = module.content ?? ""; + + // Check if audio is already cached on disk + if (module.audioPath) { + const fullPath = join(process.cwd(), "public", module.audioPath); + if (existsSync(fullPath)) { + return NextResponse.json({ + audioUrl: module.audioPath.startsWith("/") + ? module.audioPath + : `/audio/${module.audioPath}`, + cached: true, + text, + }); + } + } + + // No cached audio — return text for client-side Web Speech API + // If we don't have an audioPath yet, set a pending marker so future + // server-side TTS knows this module is queued. + if (!module.audioPath) { + await prisma.learnModule.update({ + where: { id: module.id }, + data: { audioPath: "__pending__" }, + }); + } + + return NextResponse.json({ + text, + moduleId: module.id, + ttsUrl: null, + cached: false, + message: + "Audio not yet generated. Use Web Speech API (SpeechSynthesis) on the client to speak this text.", + }); + } catch (error) { + console.error("POST /api/learn/tts error:", error); + return NextResponse.json( + { error: "Failed to process TTS request" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/marketplace/feature/route.ts b/src/app/api/marketplace/feature/route.ts deleted file mode 100644 index 7eb0ae0..0000000 --- a/src/app/api/marketplace/feature/route.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { prisma } from "@/lib/prisma"; -import { requireAuth } from "@/lib/auth"; - -export async function POST(request: NextRequest) { - try { - const auth = await requireAuth(request); - if (!auth) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const user = await prisma.user.findUnique({ where: { id: auth.userId } }); - if (!user) { - return NextResponse.json({ error: "User not found" }, { status: 404 }); - } - - if (!user.isPro) { - return NextResponse.json( - { error: "Only Pro members can feature listings. Upgrade to Pro to access this feature." }, - { status: 403 } - ); - } - - const { listingId } = await request.json(); - - if (!listingId) { - return NextResponse.json( - { error: "Missing required field: listingId" }, - { status: 400 } - ); - } - - // Verify listing exists and belongs to user - const listing = await prisma.listing.findUnique({ - where: { id: listingId }, - }); - - if (!listing) { - return NextResponse.json({ error: "Listing not found" }, { status: 404 }); - } - - if (listing.sellerId !== user.id) { - return NextResponse.json( - { error: "You can only feature your own listings" }, - { status: 403 } - ); - } - - if (listing.status !== "active") { - return NextResponse.json( - { error: "Cannot feature a sold or inactive listing" }, - { status: 400 } - ); - } - - if (listing.featured) { - return NextResponse.json( - { error: "Listing is already featured" }, - { status: 400 } - ); - } - - // Check FLH balance - const FEATURE_FEE = 100; - if (user.flhBalance < FEATURE_FEE) { - return NextResponse.json( - { error: `Insufficient FLH balance. Featuring costs ${FEATURE_FEE} FLH. Your balance: ${user.flhBalance} FLH.` }, - { status: 400 } - ); - } - - // Deduct fee and set featured - const [updatedListing] = await prisma.$transaction([ - prisma.listing.update({ - where: { id: listingId }, - data: { - featured: true, - featuredUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days - }, - }), - prisma.user.update({ - where: { id: user.id }, - data: { flhBalance: { decrement: FEATURE_FEE } }, - }), - ]); - - return NextResponse.json({ - listing: updatedListing, - message: `Listing featured for 30 days. ${FEATURE_FEE} FLH deducted.`, - }); - } catch (error) { - console.error("POST /api/marketplace/feature error:", error); - return NextResponse.json( - { error: "Internal server error" }, - { status: 500 } - ); - } -} diff --git a/src/app/api/marketplace/listings/route.ts b/src/app/api/marketplace/listings/route.ts deleted file mode 100644 index 9eb33b6..0000000 --- a/src/app/api/marketplace/listings/route.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { prisma } from "@/lib/prisma"; -import { requireAuth } from "@/lib/auth"; -import { getUserTier, MARKETPLACE_LIMITS } from "@/lib/tiers"; - -const CATEGORIES = ["E-Books", "Courses", "Design", "Audio", "Video", "Software", "Other"]; - -export async function GET(request: NextRequest) { - try { - const { searchParams } = new URL(request.url); - const category = searchParams.get("category"); - const search = searchParams.get("search"); - - const where: any = { status: "active" }; - - if (category && CATEGORIES.includes(category)) { - where.category = category; - } - - if (search) { - where.OR = [ - { title: { contains: search } }, - { description: { contains: search } }, - ]; - } - - const listings = await prisma.listing.findMany({ - where, - include: { - seller: { - select: { id: true, name: true, isPremium: true, isPro: true }, - }, - }, - orderBy: [ - { featured: "desc" }, - { createdAt: "desc" }, - ], - }); - - return NextResponse.json({ listings }); - } catch (error) { - console.error("GET /api/marketplace/listings error:", error); - return NextResponse.json( - { error: "Internal server error" }, - { status: 500 } - ); - } -} - -export async function POST(request: NextRequest) { - try { - const auth = await requireAuth(request); - if (!auth) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const user = await prisma.user.findUnique({ where: { id: auth.userId } }); - if (!user) { - return NextResponse.json({ error: "User not found" }, { status: 404 }); - } - - const tier = getUserTier(user.isPremium, user.isPro); - const limits = MARKETPLACE_LIMITS[tier]; - - if (!limits.canSell) { - return NextResponse.json( - { error: "Your tier does not support selling. Upgrade to Premium or Pro to create listings." }, - { status: 403 } - ); - } - - const { title, description, category, priceFlh } = await request.json(); - - if (!title || !description || !category || priceFlh == null) { - return NextResponse.json( - { error: "Missing required fields: title, description, category, priceFlh" }, - { status: 400 } - ); - } - - if (!CATEGORIES.includes(category)) { - return NextResponse.json( - { error: `Invalid category. Must be one of: ${CATEGORIES.join(", ")}` }, - { status: 400 } - ); - } - - if (typeof priceFlh !== "number" || priceFlh < 1 || !Number.isInteger(priceFlh)) { - return NextResponse.json( - { error: "priceFlh must be a positive integer" }, - { status: 400 } - ); - } - - // Check listing count limit - const activeListingsCount = await prisma.listing.count({ - where: { sellerId: user.id, status: "active" }, - }); - - if (activeListingsCount >= limits.maxListings) { - return NextResponse.json( - { error: `You have reached the maximum of ${limits.maxListings} active listings for your tier.` }, - { status: 403 } - ); - } - - const listing = await prisma.listing.create({ - data: { - title, - description, - category, - priceFlh, - sellerId: user.id, - status: "active", - }, - include: { - seller: { - select: { id: true, name: true, isPremium: true, isPro: true }, - }, - }, - }); - - return NextResponse.json({ listing }, { status: 201 }); - } catch (error) { - console.error("POST /api/marketplace/listings error:", error); - return NextResponse.json( - { error: "Internal server error" }, - { status: 500 } - ); - } -} diff --git a/src/app/api/marketplace/purchase/route.ts b/src/app/api/marketplace/purchase/route.ts deleted file mode 100644 index 825a433..0000000 --- a/src/app/api/marketplace/purchase/route.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { prisma } from "@/lib/prisma"; -import { requireAuth } from "@/lib/auth"; -import { getUserTier } from "@/lib/tiers"; - -export async function POST(request: NextRequest) { - try { - const auth = await requireAuth(request); - if (!auth) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const buyer = await prisma.user.findUnique({ where: { id: auth.userId } }); - if (!buyer) { - return NextResponse.json({ error: "User not found" }, { status: 404 }); - } - - const { listingId } = await request.json(); - - if (!listingId) { - return NextResponse.json( - { error: "Missing required field: listingId" }, - { status: 400 } - ); - } - - // Get listing with seller info - const listing = await prisma.listing.findUnique({ - where: { id: listingId }, - include: { - seller: { - select: { id: true, isPremium: true, isPro: true }, - }, - }, - }); - - if (!listing) { - return NextResponse.json({ error: "Listing not found" }, { status: 404 }); - } - - if (listing.status !== "active") { - return NextResponse.json( - { error: "This listing is no longer available" }, - { status: 400 } - ); - } - - if (listing.sellerId === buyer.id) { - return NextResponse.json( - { error: "You cannot purchase your own listing" }, - { status: 400 } - ); - } - - // Check buyer balance - if (buyer.flhBalance < listing.priceFlh) { - return NextResponse.json( - { - error: `Insufficient FLH balance. You need ${listing.priceFlh} FLH but only have ${buyer.flhBalance} FLH.`, - }, - { status: 400 } - ); - } - - // Calculate platform fee - // Pro sellers pay 0% platform fee, free/premium pay 1.5% - const sellerTier = getUserTier(listing.seller.isPremium, listing.seller.isPro); - const platformFeeRate = sellerTier === "pro" ? 0 : 0.015; - const platformFee = Math.round(listing.priceFlh * platformFeeRate); - - // FLH Earning Multiplier: Premium/Pro sellers earn 2x their payout - const sellerMultiplier = listing.seller.isPremium || listing.seller.isPro ? 2 : 1; - const basePayout = listing.priceFlh - platformFee; - const sellerPayout = basePayout * sellerMultiplier; - - // Auto-confirm in 7 days - const autoConfirmAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); - - // Execute purchase in transaction - const [purchase] = await prisma.$transaction([ - prisma.purchase.create({ - data: { - listingId: listing.id, - buyerId: buyer.id, - sellerId: listing.sellerId, - amountFlh: listing.priceFlh, - platformFee, - sellerPayout, - autoConfirmAt, - }, - include: { - listing: { - select: { - id: true, - title: true, - priceFlh: true, - }, - }, - }, - }), - // Deduct from buyer - prisma.user.update({ - where: { id: buyer.id }, - data: { flhBalance: { decrement: listing.priceFlh } }, - }), - // Add payout to seller (with multiplier applied) - prisma.user.update({ - where: { id: listing.sellerId }, - data: { flhBalance: { increment: sellerPayout } }, - }), - // Mark listing as sold - prisma.listing.update({ - where: { id: listing.id }, - data: { status: "sold" }, - }), - ]); - - const multiplierNote = sellerMultiplier > 1 - ? ` Seller earned ${sellerMultiplier}x FLH multiplier (Premium/Pro bonus).` - : ""; - - return NextResponse.json( - { - purchase, - message: `Successfully purchased "${listing.title}" for ${listing.priceFlh} FLH. Platform fee: ${platformFee} FLH. Seller receives: ${sellerPayout} FLH.${multiplierNote}`, - }, - { status: 201 } - ); - } catch (error) { - console.error("POST /api/marketplace/purchase error:", error); - return NextResponse.json( - { error: "Internal server error" }, - { status: 500 } - ); - } -} diff --git a/src/app/api/seller/[sellerId]/route.ts b/src/app/api/seller/[sellerId]/route.ts deleted file mode 100644 index c4c6bee..0000000 --- a/src/app/api/seller/[sellerId]/route.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { prisma } from "@/lib/prisma"; - -export async function GET( - request: NextRequest, - { params }: { params: Promise<{ sellerId: string }> } -) { - try { - const { sellerId } = await params; - - const seller = await prisma.user.findUnique({ - where: { id: sellerId }, - select: { - id: true, - name: true, - isPremium: true, - isPro: true, - createdAt: true, - }, - }); - - if (!seller) { - return NextResponse.json({ error: "Seller not found" }, { status: 404 }); - } - - const listings = await prisma.listing.findMany({ - where: { - sellerId, - status: "active", - }, - select: { - id: true, - title: true, - description: true, - category: true, - priceFlh: true, - featured: true, - createdAt: true, - }, - orderBy: [ - { featured: "desc" }, - { createdAt: "desc" }, - ], - }); - - return NextResponse.json({ seller, listings }); - } catch (error) { - console.error("GET /api/seller/[sellerId] error:", error); - return NextResponse.json( - { error: "Internal server error" }, - { status: 500 } - ); - } -} diff --git a/src/app/api/souq/categories/route.ts b/src/app/api/souq/categories/route.ts new file mode 100644 index 0000000..9be5832 --- /dev/null +++ b/src/app/api/souq/categories/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from "next/server"; + +const CATEGORIES = [ + { id: "graphics-design", name: "Graphics & Design", icon: "🎨", color: "bg-pink-900/30 text-pink-400", subcategories: ["Logo & Brand Identity", "Art & Illustration", "Web & App Design", "Product & Gaming", "Print Design", "Visual Design", "Marketing Design", "Packaging & Covers", "Architecture & Building Design", "Fashion & Merchandise", "3D Design"] }, + { id: "digital-marketing", name: "Digital Marketing", icon: "📈", color: "bg-emerald-900/30 text-emerald-400", subcategories: ["Search", "Social", "Methods & Techniques", "Analytics & Strategy", "Channel Specific", "Industry & Purpose-Specific"] }, + { id: "writing-translation", name: "Writing & Translation", icon: "✍️", color: "bg-amber-900/30 text-amber-400", subcategories: ["Content Writing", "Editing & Critique", "Book & eBook Publishing", "Career Writing", "Business & Marketing Copy", "Translation & Transcription", "Industry Specific Content"] }, + { id: "video-animation", name: "Video & Animation", icon: "🎬", color: "bg-red-900/30 text-red-400", subcategories: ["Editing & Post-Production", "Social & Marketing Videos", "Animation", "Motion Graphics", "Filmed Production", "Explainer Videos", "AI Video"] }, + { id: "music-audio", name: "Music & Audio", icon: "🎵", color: "bg-purple-900/30 text-purple-400", subcategories: ["Production & Composition", "Engineering & Mixing", "Voice Over & Narration", "Streaming & Distribution", "DJing & Remixing", "Sound Design", "Lessons & Coaching"] }, + { id: "programming-tech", name: "Programming & Tech", icon: "💻", color: "bg-blue-900/30 text-blue-400", subcategories: ["Website Development", "Mobile App Development", "AI Development", "Game Development", "Cloud & Cybersecurity", "Data Science & Analytics", "Blockchain & Crypto", "DevOps & Infrastructure", "Chatbots & Automation", "API Development", "E-Commerce Development", "Support & IT"] }, + { id: "ai-services", name: "AI Services", icon: "🤖", color: "bg-cyan-900/30 text-cyan-400", subcategories: ["AI Development", "AI Artists", "AI Video", "AI Audio", "AI Content", "AI for Business", "AI Consulting"] }, + { id: "consulting", name: "Consulting", icon: "💼", color: "bg-slate-700/30 text-slate-400", subcategories: ["Business Consulting", "Marketing Strategy", "Data Consulting", "Coaching & Mentoring", "Tech Consulting", "Management Consulting"] }, + { id: "data", name: "Data", icon: "📊", color: "bg-teal-900/30 text-teal-400", subcategories: ["Data Entry", "Data Processing", "Data Analytics", "Data Visualization", "Data Science", "Data Mining", "Databases"] }, + { id: "business", name: "Business", icon: "🏢", color: "bg-indigo-900/30 text-indigo-400", subcategories: ["Financial Services", "Legal", "Management", "E-Commerce", "Sales", "Admin Support", "Project Management", "Customer Service", "HR & Recruiting"] }, + { id: "personal-growth", name: "Personal Growth", icon: "🌱", color: "bg-lime-900/30 text-lime-400", subcategories: ["Self Improvement", "Fashion & Style", "Wellness & Fitness", "Gaming", "Leisure", "Life Coaching", "Spiritual Guidance"] }, + { id: "photography", name: "Photography", icon: "📷", color: "bg-orange-900/30 text-orange-400", subcategories: ["Portrait Photography", "Product Photography", "Lifestyle Photography", "Real Estate Photography", "Event Photography", "Food Photography", "Drone Photography"] }, + { id: "finance", name: "Finance", icon: "💰", color: "bg-yellow-900/30 text-yellow-400", subcategories: ["Accounting", "Tax Services", "Corporate Finance", "FP&A", "Fundraising", "Wealth Management", "Financial Planning"] }, +]; + +export async function GET() { + return NextResponse.json({ categories: CATEGORIES }); +} diff --git a/src/app/api/souq/listings/[id]/route.ts b/src/app/api/souq/listings/[id]/route.ts new file mode 100644 index 0000000..53fb0eb --- /dev/null +++ b/src/app/api/souq/listings/[id]/route.ts @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +// GET /api/souq/listings/[id] — single listing with seller info + reviews +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + + const listing = await prisma.listing.findUnique({ + where: { id }, + include: { + seller: { + select: { id: true, name: true, email: true, avatar: true }, + }, + reviews: { + include: { + reviewer: { + select: { id: true, name: true, avatar: true }, + }, + }, + orderBy: { createdAt: "desc" }, + }, + _count: { + select: { purchases: true }, + }, + }, + }); + + if (!listing) { + return NextResponse.json( + { error: "Listing not found" }, + { status: 404 } + ); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data = listing as any; + return NextResponse.json({ + listing: { + ...data, + packages: data.packages ? JSON.parse(data.packages) : null, + tags: data.tags ? JSON.parse(data.tags) : null, + images: data.images ? JSON.parse(data.images) : null, + }, + }); + } catch (error) { + console.error("GET /api/souq/listings/[id] error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/souq/listings/route.ts b/src/app/api/souq/listings/route.ts new file mode 100644 index 0000000..eb71046 --- /dev/null +++ b/src/app/api/souq/listings/route.ts @@ -0,0 +1,182 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireAuth } from "@/lib/auth"; + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const category = searchParams.get("category"); + const search = searchParams.get("search"); + const featured = searchParams.get("featured"); + const sellerId = searchParams.get("sellerId"); + const minPrice = searchParams.get("minPrice"); + const maxPrice = searchParams.get("maxPrice"); + const sortBy = searchParams.get("sortBy"); + const deliveryDays = searchParams.get("deliveryDays"); + const page = parseInt(searchParams.get("page") || "1", 10); + const limit = parseInt(searchParams.get("limit") || "20", 10); + const skip = (page - 1) * limit; + + const where: Record = {}; + + // Only return active listings by default + where.status = "active"; + + if (category) { + where.category = category; + } + + if (search) { + where.OR = [ + { title: { contains: search } }, + { description: { contains: search } }, + ]; + } + + if (featured === "true") { + where.featured = true; + } + + if (sellerId) { + where.sellerId = sellerId; + } + + // Price range filter + if (minPrice || maxPrice) { + const priceFilter: Record = {}; + if (minPrice) priceFilter.gte = parseInt(minPrice, 10); + if (maxPrice) priceFilter.lte = parseInt(maxPrice, 10); + where.priceFlh = priceFilter; + } + + // Delivery days filter + if (deliveryDays) { + where.deliveryDays = { lte: parseInt(deliveryDays, 10) }; + } + + // Build orderBy based on sortBy + let orderBy: Record[]; + switch (sortBy) { + case "price_asc": + orderBy = [{ priceFlh: "asc" }, { featured: "desc" }, { createdAt: "desc" }]; + break; + case "price_desc": + orderBy = [{ priceFlh: "desc" }, { featured: "desc" }, { createdAt: "desc" }]; + break; + case "rating": + orderBy = [{ rating: "desc" }, { featured: "desc" }, { createdAt: "desc" }]; + break; + case "popular": + orderBy = [{ salesCount: "desc" }, { featured: "desc" }, { createdAt: "desc" }]; + break; + case "newest": + orderBy = [{ createdAt: "desc" }, { featured: "desc" }]; + break; + default: + orderBy = [{ featured: "desc" }, { createdAt: "desc" }]; + break; + } + + const [listings, total] = await Promise.all([ + prisma.listing.findMany({ + where, + include: { + seller: { + select: { id: true, name: true, email: true, avatar: true }, + }, + }, + orderBy, + skip, + take: limit, + }), + prisma.listing.count({ where }), + ]); + + return NextResponse.json({ + listings, + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }, + }); + } catch (error) { + console.error("GET /api/souq/listings error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +export async function POST(request: NextRequest) { + try { + const auth = await requireAuth(request); + if (!auth) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const user = await prisma.user.findUnique({ where: { id: auth.userId } }); + if (!user) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + const { + title, + description, + category, + subcategory, + priceFlh, + packages, + tags, + images, + deliveryDays, + fileType, + } = await request.json(); + + if (!title || !description || !category || !priceFlh) { + return NextResponse.json( + { error: "Missing required fields: title, description, category, priceFlh" }, + { status: 400 } + ); + } + + if (typeof priceFlh !== "number" || priceFlh < 0) { + return NextResponse.json( + { error: "priceFlh must be a non-negative number" }, + { status: 400 } + ); + } + + const listing = await prisma.listing.create({ + data: { + title, + description, + category, + subcategory: subcategory || null, + priceFlh, + packages: packages ? JSON.stringify(packages) : null, + tags: tags ? JSON.stringify(tags) : null, + images: images ? JSON.stringify(images) : null, + deliveryDays: deliveryDays || null, + fileType: fileType || null, + sellerId: user.id, + status: "active", + }, + include: { + seller: { + select: { id: true, name: true, email: true, avatar: true }, + }, + }, + }); + + return NextResponse.json({ listing }, { status: 201 }); + } catch (error) { + console.error("POST /api/souq/listings error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/souq/orders/[id]/route.ts b/src/app/api/souq/orders/[id]/route.ts new file mode 100644 index 0000000..d2ba9e6 --- /dev/null +++ b/src/app/api/souq/orders/[id]/route.ts @@ -0,0 +1,250 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireAuth } from "@/lib/auth"; + +// GET /api/souq/orders/[id] — order detail +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const auth = await requireAuth(request); + if (!auth) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + + const order = await prisma.purchase.findUnique({ + where: { id }, + include: { + listing: { + select: { id: true, title: true, category: true, subcategory: true, priceFlh: true, description: true, deliveryDays: true }, + }, + buyer: { + select: { id: true, name: true, email: true, avatar: true }, + }, + seller: { + select: { id: true, name: true, email: true, avatar: true }, + }, + }, + }); + + if (!order) { + return NextResponse.json( + { error: "Order not found" }, + { status: 404 } + ); + } + + // Verify user is either the buyer or seller + if (order.buyerId !== auth.userId && order.sellerId !== auth.userId) { + return NextResponse.json( + { error: "Forbidden: you are not a party to this order" }, + { status: 403 } + ); + } + + // Check if the current user (buyer) has already reviewed this order + let hasReviewed = false; + if (order.status === "completed" && order.buyerId === auth.userId) { + const existingReview = await prisma.review.findFirst({ + where: { purchaseId: order.id, reviewerId: auth.userId }, + select: { id: true }, + }); + hasReviewed = !!existingReview; + } + + // Parse JSON fields + const data = order as any; + return NextResponse.json({ + order: { + ...data, + messages: data.messages ? JSON.parse(data.messages) : [], + deliveryFiles: data.deliveryFiles ? JSON.parse(data.deliveryFiles) : [], + }, + hasReviewed, + }); + } catch (error) { + console.error("GET /api/souq/orders/[id] error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +// PATCH /api/souq/orders/[id] — update order status or add messages +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const auth = await requireAuth(request); + if (!auth) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + + const order = await prisma.purchase.findUnique({ + where: { id }, + }); + + if (!order) { + return NextResponse.json( + { error: "Order not found" }, + { status: 404 } + ); + } + + // Verify user is either the buyer or seller + if (order.buyerId !== auth.userId && order.sellerId !== auth.userId) { + return NextResponse.json( + { error: "Forbidden: you are not a party to this order" }, + { status: 403 } + ); + } + + const body = await request.json(); + const { status: newStatus, message, deliveryFiles } = body; + + const updateData: Record = {}; + + // Update status if provided + if (newStatus) { + const validStatuses = [ + "pending", + "paid", + "in_progress", + "delivered", + "completed", + "disputed", + "cancelled", + ]; + + if (!validStatuses.includes(newStatus)) { + return NextResponse.json( + { + error: `Invalid status. Must be one of: ${validStatuses.join(", ")}`, + }, + { status: 400 } + ); + } + + // Validate status transitions + const validTransitions: Record = { + paid: ["in_progress", "cancelled"], + in_progress: ["delivered", "disputed"], + delivered: ["completed", "disputed"], + completed: [], + disputed: ["completed", "cancelled"], + cancelled: [], + pending: ["paid", "cancelled"], + }; + + const allowed = validTransitions[order.status] || []; + if (!allowed.includes(newStatus)) { + return NextResponse.json( + { + error: `Cannot transition from "${order.status}" to "${newStatus}"`, + }, + { status: 400 } + ); + } + + // Only seller can mark as in_progress or delivered + if ( + (newStatus === "in_progress" || newStatus === "delivered") && + auth.userId !== order.sellerId + ) { + return NextResponse.json( + { error: "Only the seller can update to this status" }, + { status: 403 } + ); + } + + // Only buyer can mark as completed + if (newStatus === "completed" && auth.userId !== order.buyerId) { + return NextResponse.json( + { error: "Only the buyer can mark an order as completed" }, + { status: 403 } + ); + } + + updateData.status = newStatus; + } + + // Add a message if provided + if (message) { + if (typeof message !== "string" || message.trim().length === 0) { + return NextResponse.json( + { error: "Message must be a non-empty string" }, + { status: 400 } + ); + } + + const existingMessages = order.messages + ? (JSON.parse(order.messages) as Array>) + : []; + + const newMessage = { + from: auth.userId, + text: message.trim(), + timestamp: new Date().toISOString(), + }; + + existingMessages.push(newMessage); + updateData.messages = JSON.stringify(existingMessages); + } + + // Update delivery files if provided + if (deliveryFiles) { + if (!Array.isArray(deliveryFiles)) { + return NextResponse.json( + { error: "deliveryFiles must be an array" }, + { status: 400 } + ); + } + updateData.deliveryFiles = JSON.stringify(deliveryFiles); + } + + if (Object.keys(updateData).length === 0) { + return NextResponse.json( + { error: "No fields to update. Provide status, message, or deliveryFiles." }, + { status: 400 } + ); + } + + const updated = await prisma.purchase.update({ + where: { id }, + data: updateData, + include: { + listing: { + select: { id: true, title: true, category: true }, + }, + buyer: { + select: { id: true, name: true, email: true, avatar: true }, + }, + seller: { + select: { id: true, name: true, email: true, avatar: true }, + }, + }, + }); + + const data = updated as any; + return NextResponse.json({ + order: { + ...data, + messages: data.messages ? JSON.parse(data.messages) : [], + deliveryFiles: data.deliveryFiles ? JSON.parse(data.deliveryFiles) : [], + }, + }); + } catch (error) { + console.error("PATCH /api/souq/orders/[id] error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/souq/orders/route.ts b/src/app/api/souq/orders/route.ts new file mode 100644 index 0000000..19b7a97 --- /dev/null +++ b/src/app/api/souq/orders/route.ts @@ -0,0 +1,213 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireAuth } from "@/lib/auth"; + +// GET /api/souq/orders — my orders (as buyer or seller) +export async function GET(request: NextRequest) { + try { + const auth = await requireAuth(request); + if (!auth) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const role = searchParams.get("role"); // "buyer" | "seller" | null (both) + const page = parseInt(searchParams.get("page") || "1", 10); + const limit = parseInt(searchParams.get("limit") || "20", 10); + const skip = (page - 1) * limit; + + const where: Record = {}; + + if (role === "buyer") { + where.buyerId = auth.userId; + } else if (role === "seller") { + where.sellerId = auth.userId; + } else { + where.OR = [ + { buyerId: auth.userId }, + { sellerId: auth.userId }, + ]; + } + + const [orders, total] = await Promise.all([ + prisma.purchase.findMany({ + where, + include: { + listing: { + select: { id: true, title: true, category: true, priceFlh: true }, + }, + buyer: { + select: { id: true, name: true, email: true, avatar: true }, + }, + seller: { + select: { id: true, name: true, email: true, avatar: true }, + }, + }, + orderBy: { createdAt: "desc" }, + skip, + take: limit, + }), + prisma.purchase.count({ where }), + ]); + + // Parse messages JSON for each order + const parsed = orders.map((order) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const o = order as any; + return { + ...o, + messages: o.messages ? JSON.parse(o.messages) : null, + deliveryFiles: o.deliveryFiles ? JSON.parse(o.deliveryFiles) : null, + }; + }); + + return NextResponse.json({ + orders: parsed, + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }, + }); + } catch (error) { + console.error("GET /api/souq/orders error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +// POST /api/souq/orders — place a new order +export async function POST(request: NextRequest) { + try { + const auth = await requireAuth(request); + if (!auth) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const user = await prisma.user.findUnique({ where: { id: auth.userId } }); + if (!user) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + const { listingId, packageName } = await request.json(); + + if (!listingId) { + return NextResponse.json( + { error: "Missing required field: listingId" }, + { status: 400 } + ); + } + + // Validate listing exists + const listing = await prisma.listing.findUnique({ + where: { id: listingId }, + }); + + if (!listing) { + return NextResponse.json( + { error: "Listing not found" }, + { status: 404 } + ); + } + + if (listing.status !== "active") { + return NextResponse.json( + { error: "Listing is not available for purchase" }, + { status: 400 } + ); + } + + // Prevent buying own listing + if (listing.sellerId === user.id) { + return NextResponse.json( + { error: "You cannot purchase your own listing" }, + { status: 400 } + ); + } + + // Determine amount: if packageName provided, look it up in packages JSON + let amountFlh = listing.priceFlh; + if (packageName) { + if (!listing.packages) { + return NextResponse.json( + { error: "This listing has no packages" }, + { status: 400 } + ); + } + + const packages = JSON.parse(listing.packages) as Array<{ + name: string; + price?: number; + }>; + const selectedPackage = packages.find((p) => p.name === packageName); + + if (!selectedPackage) { + return NextResponse.json( + { error: `Package "${packageName}" not found` }, + { status: 400 } + ); + } + + amountFlh = selectedPackage.price ?? listing.priceFlh; + } + + // Check buyer balance + if (user.flhBalance < amountFlh) { + return NextResponse.json( + { error: "Insufficient FLH balance" }, + { status: 400 } + ); + } + + // Calculate fees + const platformFee = Math.round(amountFlh * 0.015); + const sellerPayout = amountFlh - platformFee; + + // Create purchase and deduct balance in a transaction + const [purchase] = await prisma.$transaction([ + prisma.purchase.create({ + data: { + listingId, + buyerId: user.id, + sellerId: listing.sellerId, + packageName: packageName || null, + amountFlh, + platformFee, + sellerPayout, + status: "paid", + }, + include: { + listing: { + select: { id: true, title: true, category: true, priceFlh: true }, + }, + buyer: { + select: { id: true, name: true, email: true, avatar: true }, + }, + seller: { + select: { id: true, name: true, email: true, avatar: true }, + }, + }, + }), + prisma.user.update({ + where: { id: user.id }, + data: { flhBalance: { decrement: amountFlh } }, + }), + // Increment sales count on the listing + prisma.listing.update({ + where: { id: listingId }, + data: { salesCount: { increment: 1 } }, + }), + ]); + + return NextResponse.json({ order: purchase }, { status: 201 }); + } catch (error) { + console.error("POST /api/souq/orders error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/souq/reviews/route.ts b/src/app/api/souq/reviews/route.ts new file mode 100644 index 0000000..d5151f8 --- /dev/null +++ b/src/app/api/souq/reviews/route.ts @@ -0,0 +1,113 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireAuth } from "@/lib/auth"; + +// POST /api/souq/reviews — create a review +export async function POST(request: NextRequest) { + try { + const auth = await requireAuth(request); + if (!auth) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { listingId, orderId, rating, title, text } = await request.json(); + + // ── Validate fields ── + if (!listingId || !orderId) { + return NextResponse.json( + { error: "Missing required fields: listingId, orderId" }, + { status: 400 } + ); + } + + if (!rating || typeof rating !== "number" || rating < 1 || rating > 5 || !Number.isInteger(rating)) { + return NextResponse.json( + { error: "Rating must be an integer between 1 and 5" }, + { status: 400 } + ); + } + + // ── Ensure the order exists and belongs to this user ── + const order = await prisma.purchase.findUnique({ + where: { id: orderId }, + include: { listing: { select: { id: true, sellerId: true } } }, + }); + + if (!order) { + return NextResponse.json({ error: "Order not found" }, { status: 404 }); + } + + if (order.buyerId !== auth.userId) { + return NextResponse.json( + { error: "Only the buyer can review this order" }, + { status: 403 } + ); + } + + if (order.status !== "completed") { + return NextResponse.json( + { error: "Can only review completed orders" }, + { status: 400 } + ); + } + + if (order.listingId !== listingId) { + return NextResponse.json( + { error: "Listing does not match this order" }, + { status: 400 } + ); + } + + // ── Check if user already reviewed this purchase ── + const existing = await prisma.review.findFirst({ + where: { purchaseId: orderId, reviewerId: auth.userId }, + }); + + if (existing) { + return NextResponse.json( + { error: "You have already reviewed this order" }, + { status: 409 } + ); + } + + // ── Create the review and update listing stats in a transaction ── + const [review] = await prisma.$transaction(async (tx) => { + const created = await tx.review.create({ + data: { + listingId, + purchaseId: orderId, + reviewerId: auth.userId, + sellerId: order.listing.sellerId, + rating, + title: title || null, + text: text ?? "", + }, + }); + + // Recalculate listing average rating and count + const agg = await tx.review.aggregate({ + where: { listingId }, + _avg: { rating: true }, + _count: { rating: true }, + }); + + await tx.listing.update({ + where: { id: listingId }, + data: { + rating: agg._avg.rating ?? 0, + reviewCount: agg._count.rating, + }, + }); + + return [created]; + }); + + return NextResponse.json({ review }, { status: 201 }); + } catch (error) { + console.error("POST /api/souq/reviews error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/souq/sellers/[id]/route.ts b/src/app/api/souq/sellers/[id]/route.ts new file mode 100644 index 0000000..ffd6a21 --- /dev/null +++ b/src/app/api/souq/sellers/[id]/route.ts @@ -0,0 +1,84 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +// GET /api/souq/sellers/[id] — seller profile data +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + + // Get user info + const user = await prisma.user.findUnique({ + where: { id }, + select: { + id: true, + name: true, + email: true, + avatar: true, + createdAt: true, + }, + }); + + if (!user) { + return NextResponse.json( + { error: "Seller not found" }, + { status: 404 } + ); + } + + // Get review stats + const reviewAgg = await prisma.review.aggregate({ + where: { sellerId: id }, + _avg: { rating: true }, + _count: true, + }); + + // Get sales count + const salesCount = await prisma.purchase.count({ + where: { sellerId: id }, + }); + + // Get active listings + const listings = await prisma.listing.findMany({ + where: { sellerId: id, status: "active" }, + orderBy: { createdAt: "desc" }, + include: { + seller: { + select: { id: true, name: true, email: true, avatar: true }, + }, + }, + }); + + // Parse JSON fields + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const parsedListings = listings.map((l: any) => ({ + ...l, + packages: l.packages ? JSON.parse(l.packages) : null, + tags: l.tags ? JSON.parse(l.tags) : null, + images: l.images ? JSON.parse(l.images) : null, + })); + + return NextResponse.json({ + seller: { + id: user.id, + name: user.name, + email: user.email, + avatar: user.avatar, + memberSince: user.createdAt, + listingsCount: parsedListings.length, + averageRating: reviewAgg._avg.rating ?? 0, + reviewCount: reviewAgg._count, + salesCount, + listings: parsedListings, + }, + }); + } catch (error) { + console.error("GET /api/souq/sellers/[id] error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/souq/upload/route.ts b/src/app/api/souq/upload/route.ts new file mode 100644 index 0000000..eb10356 --- /dev/null +++ b/src/app/api/souq/upload/route.ts @@ -0,0 +1,93 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireAuth } from "@/lib/auth"; +import { writeFile } from "fs/promises"; +import path from "path"; + +const MAX_SIZE = 10 * 1024 * 1024; // 10 MB +const ALLOWED_MIME = [ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + "application/pdf", + "application/zip", + "application/x-zip-compressed", + "application/x-rar-compressed", + "application/x-7z-compressed", + "application/gzip", +]; + +// POST /api/souq/upload — upload a delivery file +export async function POST(request: NextRequest) { + try { + const auth = await requireAuth(request); + if (!auth) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const formData = await request.formData(); + const file = formData.get("file") as File | null; + const orderId = formData.get("orderId") as string | null; + + if (!file) { + return NextResponse.json({ error: "No file provided" }, { status: 400 }); + } + + if (!orderId) { + return NextResponse.json( + { error: "orderId is required" }, + { status: 400 } + ); + } + + // Check file size + if (file.size > MAX_SIZE) { + return NextResponse.json( + { error: "File too large. Maximum 10 MB." }, + { status: 413 } + ); + } + + // Only check mime type if available; allow fallback for unknown types + if (file.type && !ALLOWED_MIME.includes(file.type)) { + return NextResponse.json( + { + error: + "Invalid file type. Allowed: images, PDFs, ZIP, RAR, 7z, GZIP.", + }, + { status: 400 } + ); + } + + // Generate unique filename + const timestamp = Date.now(); + const safeName = file.name.replace(/[^a-zA-Z0-9._-]/g, "_"); + const fileName = `${orderId}-${timestamp}-${safeName}`; + + // Save to disk + const uploadDir = path.join( + process.cwd(), + "public", + "uploads", + "deliveries" + ); + const filePath = path.join(uploadDir, fileName); + const buffer = Buffer.from(await file.arrayBuffer()); + await writeFile(filePath, buffer); + + // Return the public URL path + const url = `/uploads/deliveries/${fileName}`; + + return NextResponse.json({ + url, + name: file.name, + size: file.size, + }); + } catch (error) { + console.error("POST /api/souq/upload error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/wallet/top-up/create-checkout/route.ts b/src/app/api/wallet/top-up/create-checkout/route.ts index af401de..9ae3ff2 100644 --- a/src/app/api/wallet/top-up/create-checkout/route.ts +++ b/src/app/api/wallet/top-up/create-checkout/route.ts @@ -3,9 +3,11 @@ import { prisma } from "@/lib/prisma"; import { requireAuth } from "@/lib/auth"; const TOP_UP_AMOUNTS = { - 500: { usd: 4.99 }, - 1100: { usd: 9.99 }, - 3000: { usd: 24.99 }, + 500: { usd: 0.99, bonus: 0, priceEnv: "POLAR_FLH_500" }, + 1000: { usd: 0.99, bonus: 0, priceEnv: "POLAR_FLH_1000" }, + 5000: { usd: 4.99, bonus: 500, priceEnv: "POLAR_FLH_5000" }, + 10000: { usd: 9.99, bonus: 2000, priceEnv: "POLAR_FLH_10000" }, + 50000: { usd: 49.99, bonus: 15000, priceEnv: "POLAR_FLH_50000" }, } as const; export async function POST(req: NextRequest) { @@ -20,22 +22,19 @@ export async function POST(req: NextRequest) { if (!amount || !(amount in TOP_UP_AMOUNTS)) { return NextResponse.json( - { error: "Invalid amount. Choose 500, 1100, or 3000 FLH." }, + { error: "Invalid amount. Choose 500, 1000, 5000, 10000, or 50000 FLH." }, { status: 400 } ); } const pricing = TOP_UP_AMOUNTS[amount as keyof typeof TOP_UP_AMOUNTS]; + const totalFlh = amount + (pricing.bonus || 0); - // In production, this would create a Polar.sh checkout session. - // For development, we mock the flow by directly adding FLH balance. - // Replace with actual Polar API integration when ready. - const useMock = process.env.MOCK_PAYMENTS !== "false"; - - if (useMock) { + // Mock mode: use when POLAR_ACCESS_TOKEN is not configured + if (!process.env.POLAR_ACCESS_TOKEN) { await prisma.user.update({ where: { id: jwtPayload.userId }, - data: { flhBalance: { increment: amount } }, + data: { flhBalance: { increment: totalFlh } }, }); return NextResponse.json({ @@ -43,27 +42,67 @@ export async function POST(req: NextRequest) { url: "/wallet?topup=success", mock: true, amount, + bonus: pricing.bonus || 0, amountUsd: pricing.usd, }); } - // Production path — Polar checkout creation - // const polarSession = await createPolarCheckout({ - // amount, - // customerId: user.stripeCustomerId, - // metadata: { userId: jwtPayload.userId, type: "top-up", amount }, - // }); + // Production — Polar.sh checkout session + const productPriceId = process.env[pricing.priceEnv]; + if (!productPriceId) { + return NextResponse.json( + { error: `Polar price not configured for ${amount} FLH. Set ${pricing.priceEnv} env var.` }, + { status: 500 } + ); + } + + const origin = req.headers.get("origin") || req.headers.get("host") || ""; + const baseUrl = origin.startsWith("http") ? origin : `https://${origin}`; + + const polarResponse = await fetch("https://api.polar.sh/v1/checkouts/", { + method: "POST", + headers: { + Authorization: `Bearer ${process.env.POLAR_ACCESS_TOKEN}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + product_price_id: productPriceId, + success_url: `${baseUrl}/wallet?topup=success&amount=${amount}`, + customer_email: jwtPayload.email, + return_url: `${baseUrl}/wallet`, + metadata: { + type: "flh_purchase", + flh_amount: totalFlh, + user_id: jwtPayload.userId, + user_email: jwtPayload.email, + }, + allow_trial: false, + }), + }); + + if (!polarResponse.ok) { + const errorBody = await polarResponse.text(); + console.error("Polar API error:", polarResponse.status, errorBody); + return NextResponse.json( + { error: `Checkout creation failed (HTTP ${polarResponse.status})` }, + { status: 502 } + ); + } + + const polarData = await polarResponse.json(); return NextResponse.json({ success: true, - url: "/wallet?topup=success", // would be polarSession.url + url: polarData.url, + checkout_id: polarData.id, amount, amountUsd: pricing.usd, + bonus: pricing.bonus || 0, }); } catch (error) { console.error("Top-up checkout error:", error); return NextResponse.json( - { error: "Failed to create checkout" }, + { error: error instanceof Error ? error.message : "Failed to create checkout" }, { status: 500 } ); } diff --git a/src/app/api/webhooks/polar-checkout/route.ts b/src/app/api/webhooks/polar-checkout/route.ts new file mode 100644 index 0000000..0ffcaf3 --- /dev/null +++ b/src/app/api/webhooks/polar-checkout/route.ts @@ -0,0 +1,114 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +// Polar.sh webhook for FLH purchase checkouts +// Receives checkout.completed events and credits the buyer's FLH balance + +export async function POST(request: NextRequest) { + try { + // Log the incoming webhook for debugging + const body = await request.json(); + const eventType = body.type || ""; + console.log("[polar-webhook] Received:", JSON.stringify({ type: eventType, id: body.data?.id })); + + // Only process checkout events + if (!eventType.startsWith("checkout.")) { + return NextResponse.json({ received: true, ignored: true }, { status: 200 }); + } + + // Verify webhook secret if configured + const webhookSecret = process.env.POLAR_WEBHOOK_SECRET; + if (webhookSecret) { + const signature = request.headers.get("polar-signature") || ""; + // In production, verify the signature using crypto.timingSafeEqual + // For now, use the secret as a simple check + if (signature !== webhookSecret) { + console.warn("[polar-webhook] Invalid signature"); + return NextResponse.json({ error: "Invalid signature" }, { status: 401 }); + } + } + + const checkout = body.data || {}; + const metadata = checkout.metadata || {}; + + // Only process FLH purchase events + if (metadata.type !== "flh_purchase") { + return NextResponse.json({ received: true, ignored: true }, { status: 200 }); + } + + // Only process completed/paid checkouts + if (checkout.status !== "succeeded" && checkout.status !== "paid") { + return NextResponse.json( + { received: true, status: checkout.status }, + { status: 200 } + ); + } + + const userId = metadata.user_id; + const flhAmount = parseInt(metadata.flh_amount, 10); + + if (!userId || !flhAmount || flhAmount < 1) { + console.error("[polar-webhook] Invalid metadata:", { userId, flhAmount }); + return NextResponse.json( + { error: "Invalid metadata: userId and flh_amount required" }, + { status: 400 } + ); + } + + // Check if this checkout was already processed (idempotency) + const checkoutId = checkout.id || body.id; + if (checkoutId) { + const existing = await prisma.xpTransaction.findFirst({ + where: { + userId, + reason: `flh_purchase_${checkoutId}`, + }, + }); + if (existing) { + console.log(`[polar-webhook] Checkout ${checkoutId} already processed, skipping`); + return NextResponse.json({ received: true, duplicate: true }, { status: 200 }); + } + } + + // Credit the user's FLH balance + const user = await prisma.user.findUnique({ where: { id: userId } }); + if (!user) { + console.error(`[polar-webhook] User not found: ${userId}`); + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + await prisma.user.update({ + where: { id: userId }, + data: { flhBalance: { increment: flhAmount } }, + }); + + // Record the transaction for idempotency and audit + if (checkoutId) { + await prisma.xpTransaction.create({ + data: { + userId, + amount: flhAmount, + reason: `flh_purchase_${checkoutId}`, + }, + }); + } + + console.log(`[polar-webhook] Credited ${flhAmount} FLH to user ${userId}. New balance: ${user.flhBalance + flhAmount}`); + return NextResponse.json( + { + received: true, + success: true, + flhCredited: flhAmount, + userId, + newBalance: user.flhBalance + flhAmount, + }, + { status: 200 } + ); + } catch (error) { + console.error("[polar-webhook] Error:", error); + return NextResponse.json( + { error: "Webhook processing failed" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/webhooks/polar/route.ts b/src/app/api/webhooks/polar/route.ts index d59a51b..d697511 100644 --- a/src/app/api/webhooks/polar/route.ts +++ b/src/app/api/webhooks/polar/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; +import crypto from "crypto"; import { prisma } from "@/lib/prisma"; /** @@ -20,7 +21,38 @@ export async function POST(req: NextRequest) { } // Production path: Parse Polar webhook event - const body = await req.json(); + const rawBody = await req.text(); + + // Verify webhook signature + const signature = req.headers.get("webhook-id") || req.headers.get("polar-signature"); + const webhookSecret = process.env.POLAR_WEBHOOK_SECRET; + + if (webhookSecret) { + if (!signature) { + console.error("Polar webhook signature missing"); + return NextResponse.json( + { error: "Missing webhook signature" }, + { status: 401 } + ); + } + + const expectedSignature = crypto + .createHmac("sha256", webhookSecret) + .update(rawBody) + .digest("hex"); + + if (signature !== expectedSignature) { + console.error("Polar webhook signature mismatch"); + return NextResponse.json( + { error: "Invalid webhook signature" }, + { status: 401 } + ); + } + } else { + console.warn("POLAR_WEBHOOK_SECRET not set — skipping signature verification (dev mode)"); + } + + const body = JSON.parse(rawBody); const event = body.type || body.event; if (!event) { @@ -43,6 +75,39 @@ export async function POST(req: NextRequest) { ); } + const metadataType = checkout.metadata?.type; + const customerEmail = checkout.customer?.email; + + // --- Course purchase flow --- + if (metadataType === "course_purchase") { + const courseSlug = checkout.metadata?.course_slug; + if (!courseSlug) { + return NextResponse.json( + { error: "Missing course_slug in metadata for course_purchase" }, + { status: 400 } + ); + } + if (!customerEmail) { + return NextResponse.json( + { error: "Missing customer email for course_purchase" }, + { status: 400 } + ); + } + return handleCoursePurchase(customerEmail, courseSlug); + } + + // --- Learn subscription flow --- + if (metadataType === "learn_subscription") { + if (!customerEmail) { + return NextResponse.json( + { error: "Missing customer email for learn_subscription" }, + { status: 400 } + ); + } + return handleLearnSubscription(customerEmail, checkout); + } + + // --- Fallback: premium/pro upgrade (existing logic) --- const userId = checkout.metadata?.userId; const priceId = checkout.product?.priceId || checkout.productPrice?.id || @@ -64,6 +129,11 @@ export async function POST(req: NextRequest) { return applyUpgrade(userId, tier); } + // Handle subscription.cancelled and subscription.revoked events + if (event === "subscription.cancelled" || event === "subscription.revoked") { + return handleSubscriptionCancelled(body.data); + } + // Acknowledge other events silently return NextResponse.json({ received: true }); } catch (error) { @@ -116,3 +186,205 @@ async function applyUpgrade(userId: string, tier: "premium" | "pro") { trialEndsAt, }); } + +/** + * Handle a course_purchase checkout. + * Looks up the user by email, finds the LearnCourse by slug, + * and creates (or re-activates) a LearnEnrollment with purchaseType='one_time'. + */ +async function handleCoursePurchase(customerEmail: string, courseSlug: string) { + // Find the user by email + const user = await prisma.user.findUnique({ where: { email: customerEmail } }); + if (!user) { + return NextResponse.json( + { error: "User not found for email" }, + { status: 404 } + ); + } + + // Find the course by slug + const course = await prisma.learnCourse.findUnique({ where: { slug: courseSlug } }); + if (!course) { + return NextResponse.json( + { error: "Course not found" }, + { status: 404 } + ); + } + + // Upsert enrollment: create or re-activate on re-purchase + await prisma.learnEnrollment.upsert({ + where: { + userId_courseId: { userId: user.id, courseId: course.id }, + }, + create: { + userId: user.id, + courseId: course.id, + purchaseType: "one_time", + progress: 0, + startedAt: new Date(), + }, + update: { + purchaseType: "one_time", + completed: false, + progress: 0, + completedAt: null, + startedAt: new Date(), + }, + }); + + return NextResponse.json({ + success: true, + type: "course_purchase", + userId: user.id, + courseId: course.id, + courseSlug, + }); +} + +/** + * Handle a learn_subscription checkout. + * Looks up the user by email and creates/updates a LearnSubscription record. + * Uses Polar subscription data for period management. + */ +async function handleLearnSubscription(customerEmail: string, checkout: any) { + // Find the user by email + const user = await prisma.user.findUnique({ where: { email: customerEmail } }); + if (!user) { + return NextResponse.json( + { error: "User not found for email" }, + { status: 404 } + ); + } + + // Extract subscription details from checkout data + const polarSubId = + checkout.subscription?.id || + checkout.metadata?.subscriptionId || + null; + + const now = new Date(); + const currentPeriodStart = checkout.subscription?.currentPeriodStart + ? new Date(checkout.subscription.currentPeriodStart) + : now; + const currentPeriodEnd = checkout.subscription?.currentPeriodEnd + ? new Date(checkout.subscription.currentPeriodEnd) + : new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000); // Default 30 days + + // Upsert subscription record per user (one active subscription per user) + if (polarSubId) { + await prisma.learnSubscription.upsert({ + where: { polarSubId }, + create: { + userId: user.id, + polarSubId, + status: "active", + currentPeriodStart, + currentPeriodEnd, + }, + update: { + userId: user.id, + status: "active", + currentPeriodStart, + currentPeriodEnd, + cancelledAt: null, + }, + }); + } else { + // No polarSubId: check if user already has a subscription and update it + const existing = await prisma.learnSubscription.findFirst({ + where: { userId: user.id }, + orderBy: { createdAt: "desc" }, + }); + + if (existing) { + await prisma.learnSubscription.update({ + where: { id: existing.id }, + data: { + status: "active", + currentPeriodStart, + currentPeriodEnd, + cancelledAt: null, + }, + }); + } else { + await prisma.learnSubscription.create({ + data: { + userId: user.id, + status: "active", + currentPeriodStart, + currentPeriodEnd, + }, + }); + } + } + + // Also update the User's premium status to match the active subscription + await prisma.user.update({ + where: { id: user.id }, + data: { + isPremium: true, + trialEndsAt: currentPeriodEnd, + }, + }); + + return NextResponse.json({ + success: true, + type: "learn_subscription", + userId: user.id, + }); +} + +/** + * Handle a subscription.cancelled or subscription.revoked event. + * Sets the LearnSubscription status to 'cancelled' and removes the user's isPremium flag. + */ +async function handleSubscriptionCancelled(data: any) { + // Extract the Polar subscription ID from the event data + const polarSubId = + data?.id || + data?.subscription?.id || + data?.metadata?.subscriptionId || + null; + + if (!polarSubId) { + return NextResponse.json( + { error: "Missing subscription ID in event data" }, + { status: 400 } + ); + } + + // Find the LearnSubscription by the Polar subscription ID + const subscription = await prisma.learnSubscription.findUnique({ + where: { polarSubId }, + }); + + if (!subscription) { + return NextResponse.json( + { error: "LearnSubscription not found for polarSubId" }, + { status: 404 } + ); + } + + // Mark the subscription as cancelled + await prisma.learnSubscription.update({ + where: { id: subscription.id }, + data: { + status: "cancelled", + cancelledAt: new Date(), + }, + }); + + // Remove the user's premium flag + await prisma.user.update({ + where: { id: subscription.userId }, + data: { + isPremium: false, + }, + }); + + return NextResponse.json({ + success: true, + type: "subscription_cancelled", + userId: subscription.userId, + }); +} diff --git a/src/app/auth/page.tsx b/src/app/auth/page.tsx index b3e8526..357a76c 100644 --- a/src/app/auth/page.tsx +++ b/src/app/auth/page.tsx @@ -9,44 +9,10 @@ import ErrorFeedback from "@/components/ErrorFeedback"; type AuthMode = "login" | "register"; type PageState = "select" | "email-form"; -/** Inline Google SVG logo */ -function GoogleLogo({ className }: { className?: string }) { - return ( - - - - - - - ); -} - -/** Inline GitHub SVG logo */ -function GitHubLogo({ className }: { className?: string }) { - return ( - - - - ); -} - function AuthPageInner() { - const { user, login, register, oauthLogin, loading: authLoading } = useAuth(); + const { user, login, register, loading: authLoading } = useAuth(); const router = useRouter(); const searchParams = useSearchParams(); - const refCode = searchParams.get("ref"); // Redirect to main page if already logged in (e.g. after OAuth popup completes) useEffect(() => { @@ -61,11 +27,6 @@ function AuthPageInner() { const [error, setError] = useState(""); const [submitting, setSubmitting] = useState(false); - const handleProviderClick = (provider: string) => { - setError(""); - oauthLogin(provider); - }; - const handleEmailClick = () => { setError(""); setPageState("email-form"); @@ -98,7 +59,7 @@ function AuthPageInner() { if (mode === "login") { await login(email.trim(), password); } else { - await register(email.trim(), name.trim(), password, refCode || undefined); + await register(email.trim(), name.trim(), password); } router.push("/"); } catch (err: unknown) { @@ -120,45 +81,14 @@ function AuthPageInner() {
☪️
-

Welcome to Falah

+

Sign in with Ummah ID

- Your Islamic lifestyle companion + Your Ummah ID works across all Falah apps

- {/* SSO Buttons */} + {/* Sign-in Methods */}
- {/* Google */} - - - {/* GitHub */} - - - {/* Divider */} -
-
- or -
-
- {/* Email */}
- - {/* Demo Credentials */} -
-

- Demo Account -

-

- Email:{" "} - demo@falahos.my -

-

- Password:{" "} - password123 -

-
); @@ -345,26 +260,9 @@ function AuthPageInner() {

Get 5,000 FLH{" "} free on sign up + 7-day premium trial - {refCode && ( - <> - {" "}+{" "} - 1,000 FLH{" "} - referral bonus - - )}

)} - - {/* Demo credentials (subtle) */} -
-

- Demo:{" "} - demo@falahos.my{" "} - /{" "} - password123 -

-
); diff --git a/src/app/groups/[id]/page.tsx b/src/app/groups/[id]/page.tsx index 33c71a4..2b0af41 100644 --- a/src/app/groups/[id]/page.tsx +++ b/src/app/groups/[id]/page.tsx @@ -92,7 +92,7 @@ export default function GroupDetailPage() { }); const data = await res.json(); if (res.ok) { - setGroup(data); + setGroup(data.group); } else { setError(data.error || "Failed to load group"); } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 9cfc13a..cf9692e 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import "./globals.css"; import { AuthProvider } from "@/lib/AuthContext"; import BottomNav from "@/components/BottomNav"; +import PWARegister from "@/components/PWARegister"; export const metadata: Metadata = { title: "Falah — Islamic Lifestyle", @@ -9,6 +10,27 @@ export const metadata: Metadata = { viewport: "width=device-width, initial-scale=1, viewport-fit=cover", themeColor: "#0a0a0f", appleWebApp: { capable: true, statusBarStyle: "black-translucent" }, + manifest: "/mobile/manifest.json", + icons: { + icon: [ + { url: "/mobile/icons/icon-48x48.png", sizes: "48x48", type: "image/png" }, + { url: "/mobile/icons/icon-72x72.png", sizes: "72x72", type: "image/png" }, + { url: "/mobile/icons/icon-96x96.png", sizes: "96x96", type: "image/png" }, + { url: "/mobile/icons/icon-128x128.png", sizes: "128x128", type: "image/png" }, + { url: "/mobile/icons/icon-144x144.png", sizes: "144x144", type: "image/png" }, + { url: "/mobile/icons/icon-152x152.png", sizes: "152x152", type: "image/png" }, + { url: "/mobile/icons/icon-192x192.png", sizes: "192x192", type: "image/png" }, + { url: "/mobile/icons/icon-384x384.png", sizes: "384x384", type: "image/png" }, + { url: "/mobile/icons/icon-512x512.png", sizes: "512x512", type: "image/png" }, + ], + apple: [ + { url: "/mobile/apple-touch-icon.png", sizes: "152x152", type: "image/png" }, + ], + shortcut: [{ url: "/mobile/icons/icon-48x48.png", type: "image/png" }], + }, + other: { + "apple-mobile-web-app-title": "Falah", + }, }; export default function RootLayout({ @@ -24,8 +46,11 @@ export default function RootLayout({ + + +
{children}
diff --git a/src/app/learn/[slug]/[moduleId]/page.tsx b/src/app/learn/[slug]/[moduleId]/page.tsx new file mode 100644 index 0000000..bf4fcc7 --- /dev/null +++ b/src/app/learn/[slug]/[moduleId]/page.tsx @@ -0,0 +1,775 @@ +"use client"; + +import { useState, useEffect, useCallback, useRef } from "react"; +import { useAuth } from "@/lib/AuthContext"; +import { useRouter, useParams } from "next/navigation"; +import { + ChevronLeft, + ChevronRight, + Play, + Pause, + CheckCircle2, + RotateCcw, + Loader2, +} from "lucide-react"; +import ErrorFeedback from "@/components/ErrorFeedback"; + +// ── Types ── + +interface ModuleProgress { + completed: boolean; + score: number | null; + listened: boolean; +} + +interface Module { + id: string; + order: number; + title: string; + slug: string; + keyTakeaway: string | null; + duration: number; + content: string | null; + quizData: Record | null; + progress: ModuleProgress | null; +} + +interface Course { + id: string; + slug: string; + title: string; + author: string; + description: string; + imageUrl: string | null; + moduleCount: number; + totalMinutes: number; + difficulty: string; + giteaPath: string; +} + +interface Enrollment { + id: string; + purchaseType: string; + completed: boolean; + progress: number; +} + +interface CourseData { + course: Course; + modules: Module[]; + enrollment: Enrollment | null; +} + +interface QuizQuestion { + question: string; + options: string[]; + correctIndex: number; +} + +interface QuizData { + questions: QuizQuestion[]; +} + +// ── Helpers ── + +function parseQuizData(raw: Record | null): QuizData | null { + if (!raw) return null; + const q = raw as QuizData; + if (!Array.isArray(q.questions) || q.questions.length === 0) return null; + // Validate each question has required fields + const valid = q.questions.every( + (qq) => + typeof qq.question === "string" && + Array.isArray(qq.options) && + qq.options.length >= 2 && + typeof qq.correctIndex === "number" + ); + return valid ? q : null; +} + +// ── Component ── + +export default function LessonReaderPage() { + const { user, token, loading: authLoading } = useAuth(); + const router = useRouter(); + const params = useParams(); + + const slug = params.slug as string; + const moduleId = params.moduleId as string; // This is the module slug + + const [data, setData] = useState(null); + const [loadingData, setLoadingData] = useState(true); + const [error, setError] = useState(null); + + // Audio state + const [isPlaying, setIsPlaying] = useState(false); + const [audioLoading, setAudioLoading] = useState(false); + const [audioError, setAudioError] = useState(null); + const synthRef = useRef(null); + const audioRef = useRef(null); + + // Quiz state + const [quizAnswers, setQuizAnswers] = useState>({}); + const [quizSubmitted, setQuizSubmitted] = useState(false); + const [quizScore, setQuizScore] = useState(null); + + // Mark complete state + const [completing, setCompleting] = useState(false); + const [completed, setCompleted] = useState(false); + + // Listen query param detection (avoid useSearchParams for simplicity) + const [listenParam, setListenParam] = useState(false); + const [autoStarted, setAutoStarted] = useState(false); + + // Current module and navigation + const currentModule = data?.modules.find((m) => m.slug === moduleId) ?? null; + const moduleIndex = + data?.modules.findIndex((m) => m.slug === moduleId) ?? -1; + const prevModule = + moduleIndex > 0 ? data?.modules[moduleIndex - 1] : null; + const nextModule = + moduleIndex < (data?.modules.length ?? 1) - 1 + ? data?.modules[moduleIndex + 1] + : null; + + // Parsed quiz data + const quizData = parseQuizData(currentModule?.quizData ?? null); + + // ── Fetch ── + + const fetchCourse = useCallback(async () => { + if (!token) return; + setLoadingData(true); + setError(null); + try { + const res = await fetch(`/mobile/api/learn/courses/${slug}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (res.ok) { + const d: CourseData = await res.json(); + setData(d); + } else { + const d = await res.json().catch(() => ({})); + setError(d.error || "Failed to load course"); + } + } catch { + setError("Network error. Please try again."); + } finally { + setLoadingData(false); + } + }, [token, slug]); + + // ── Auth guard ── + + useEffect(() => { + if (!authLoading && !token) { + router.push("/auth"); + } + }, [authLoading, token, router]); + + // ── Fetch on mount ── + + useEffect(() => { + if (token && slug) { + fetchCourse(); + } + }, [token, slug, fetchCourse]); + + // ── Detect listen query param ── + + useEffect(() => { + if (typeof window !== "undefined") { + const sp = new URLSearchParams(window.location.search); + if (sp.get("listen") === "true") { + setListenParam(true); + } + } + }, []); + + // ── Set completed / score from server data ── + + useEffect(() => { + if (!currentModule) return; + if (currentModule.progress?.completed) { + setCompleted(true); + } + if ( + currentModule.progress?.score !== null && + currentModule.progress?.score !== undefined + ) { + setQuizSubmitted(true); + setQuizScore(currentModule.progress.score); + } + }, [currentModule]); + + // ── Auto-start audio when listen=true param AND data loaded ── + + useEffect(() => { + if (listenParam && currentModule && !autoStarted && !audioLoading && !isPlaying) { + setAutoStarted(true); + // Small delay to let the render settle + const t = setTimeout(() => handleListen(), 300); + return () => clearTimeout(t); + } + }, [listenParam, currentModule, autoStarted]); + + // ── Cleanup on unmount ── + + useEffect(() => { + return () => { + window.speechSynthesis?.cancel(); + if (audioRef.current) { + audioRef.current.pause(); + audioRef.current = null; + } + }; + }, []); + + // ── Audio handlers ── + + const useSpeechSynthesis = useCallback(() => { + if (!currentModule?.content) { + setAudioError("No content to read aloud."); + return; + } + + if (typeof window === "undefined" || !window.speechSynthesis) { + setAudioError("Speech synthesis is not supported in this browser."); + return; + } + + window.speechSynthesis.cancel(); + + const utterance = new SpeechSynthesisUtterance(currentModule.content); + utterance.lang = "en-US"; + utterance.rate = 0.9; + utterance.pitch = 1; + + utterance.onstart = () => setIsPlaying(true); + utterance.onend = () => setIsPlaying(false); + utterance.onerror = (e) => { + console.error("SpeechSynthesis error:", e); + setIsPlaying(false); + setAudioError("Failed to play audio via speech synthesis."); + }; + + synthRef.current = utterance; + window.speechSynthesis.speak(utterance); + }, [currentModule]); + + const handleListen = useCallback(async () => { + if (!currentModule) return; + + // Stop any current playback + stopAudio(); + + setAudioLoading(true); + setAudioError(null); + + try { + const res = await fetch( + `/mobile/api/learn/tts?moduleId=${currentModule.id}`, + { + headers: { Authorization: `Bearer ${token}` }, + } + ); + + if (res.ok) { + const ttsData = await res.json(); + if (ttsData.audioUrl) { + // Server-provided audio URL + const audio = new Audio(ttsData.audioUrl); + audioRef.current = audio; + audio.play().catch(() => { + // Autoplay blocked — fallback to speech synthesis + useSpeechSynthesis(); + }); + setIsPlaying(true); + audio.onended = () => setIsPlaying(false); + audio.onerror = () => { + // Fallback on audio error + useSpeechSynthesis(); + }; + } else { + // No audio URL in response — use speech synthesis + useSpeechSynthesis(); + } + } else { + // API error — fallback to speech synthesis + useSpeechSynthesis(); + } + } catch { + // Network error — fallback to speech synthesis + useSpeechSynthesis(); + } finally { + setAudioLoading(false); + } + }, [currentModule, token, useSpeechSynthesis]); + + const stopAudio = useCallback(() => { + if (synthRef.current) { + window.speechSynthesis?.cancel(); + synthRef.current = null; + } + if (audioRef.current) { + audioRef.current.pause(); + audioRef.current = null; + } + setIsPlaying(false); + }, []); + + const togglePlayPause = useCallback(() => { + if (isPlaying) { + stopAudio(); + } else { + handleListen(); + } + }, [isPlaying, stopAudio, handleListen]); + + // ── Quiz handlers ── + + const handleQuizAnswer = (questionIndex: number, optionIndex: number) => { + if (quizSubmitted) return; + setQuizAnswers((prev) => ({ ...prev, [questionIndex]: optionIndex })); + }; + + const handleQuizSubmit = () => { + if (!quizData) return; + + let correct = 0; + quizData.questions.forEach((q, i) => { + if (quizAnswers[i] === q.correctIndex) { + correct++; + } + }); + + const score = Math.round((correct / quizData.questions.length) * 100); + setQuizScore(score); + setQuizSubmitted(true); + }; + + // ── Mark Complete handler ── + + const handleMarkComplete = async () => { + if (!currentModule || completing || completed) return; + + setCompleting(true); + try { + const res = await fetch("/mobile/api/learn/progress", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + moduleId: currentModule.id, + completed: true, + }), + }); + + if (res.ok) { + setCompleted(true); + } else { + const d = await res.json().catch(() => ({})); + setError(d.error || "Failed to update progress"); + } + } catch { + setError("Network error. Please try again."); + } finally { + setCompleting(false); + } + }; + + // ── Loading screen (auth) ── + + if (authLoading) { + return ( +
+
+
+ ); + } + + if (!user) return null; + + // ── Loading screen (data) ── + + if (loadingData) { + return ( +
+
+
+ +
+
+
+
+ +

Loading lesson...

+
+
+ ); + } + + // ── Error state ── + + if (error) { + return ( +
+
+
+ +
+
+ +
+ ); + } + + // ── Module not found ── + + if (!currentModule) { + return ( +
+
+
+ +
+
+
+

Module not found

+ +
+
+ ); + } + + // ── Render ── + + return ( +
+ {/* ── Sticky header ── */} +
+
+ +

+ {currentModule.title} +

+ {completed && ( + + )} +
+
+ + {/* ── Content ── */} +
+ {/* Module header */} +
+ + Module {currentModule.order} + +

+ {currentModule.title} +

+ {currentModule.keyTakeaway && ( +

+ {currentModule.keyTakeaway} +

+ )} +
+ + {/* ── Listen button ── */} +
+ + {isPlaying && ( + + )} +
+ + {/* Audio error */} + {audioError && ( +
+

{audioError}

+
+ )} + + {/* ── Module content ── */} + {currentModule.content ? ( +
+
+ {currentModule.content} +
+
+ ) : ( +
+

+ No content available for this module. +

+
+ )} + + {/* ── Quiz section ── */} + {quizData && ( +
+

+ + ? + + Quiz +

+ + {/* Score banner */} + {quizSubmitted && quizScore !== null && ( +
= 80 + ? "bg-emerald-900/10 border border-emerald-800/30" + : quizScore >= 50 + ? "bg-amber-900/10 border border-amber-800/30" + : "bg-red-900/10 border border-red-800/30" + }`} + > +

= 80 + ? "text-emerald-300" + : quizScore >= 50 + ? "text-amber-300" + : "text-red-300" + }`} + > + Score: {quizScore}% ( + {Math.round( + (quizScore * quizData.questions.length) / 100 + )} + /{quizData.questions.length} correct) +

+
+ )} + + {/* Questions */} +
+ {quizData.questions.map((q, qIdx) => ( +
+

+ {qIdx + 1}. {q.question} +

+
+ {q.options.map((opt, oIdx) => { + const isSelected = quizAnswers[qIdx] === oIdx; + const isCorrect = + quizSubmitted && oIdx === q.correctIndex; + const isWrong = + quizSubmitted && isSelected && oIdx !== q.correctIndex; + + let optionStyle = + "bg-gray-800/40 border-gray-700/40 text-gray-400"; + if (isSelected && !quizSubmitted) { + optionStyle = + "bg-[#D4AF37]/10 border-[#D4AF37]/40 text-[#D4AF37]"; + } + if (quizSubmitted) { + if (isCorrect) { + optionStyle = + "bg-emerald-900/20 border-emerald-500/40 text-emerald-300"; + } else if (isWrong) { + optionStyle = + "bg-red-900/20 border-red-500/40 text-red-300"; + } else { + optionStyle = + "bg-gray-800/20 border-gray-700/30 text-gray-500"; + } + } + + return ( + + ); + })} +
+
+ ))} +
+ + {/* Submit / Retry buttons */} + {!quizSubmitted ? ( + + ) : ( + + )} +
+ )} + + {/* ── Mark Complete button ── */} + + + {/* ── Previous / Next navigation ── */} + {(prevModule || nextModule) && ( +
+ {prevModule ? ( + + ) : ( +
+ )} + + {nextModule ? ( + + ) : ( +
+ )} +
+ )} +
+
+ ); +} diff --git a/src/app/learn/[slug]/page.tsx b/src/app/learn/[slug]/page.tsx new file mode 100644 index 0000000..0924553 --- /dev/null +++ b/src/app/learn/[slug]/page.tsx @@ -0,0 +1,455 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { useAuth } from "@/lib/AuthContext"; +import { useRouter, useParams } from "next/navigation"; +import { + ChevronLeft, + CheckCircle2, + Circle, + Headphones, + Award, + Lock, + Loader2, +} from "lucide-react"; +import ErrorFeedback from "@/components/ErrorFeedback"; + +// ── Types ── + +interface ModuleProgress { + completed: boolean; + score: number | null; + listened: boolean; +} + +interface Module { + id: string; + order: number; + title: string; + slug: string; + keyTakeaway: string | null; + duration: number; + content: string | null; + quizData: Record | null; + progress: ModuleProgress | null; +} + +interface Course { + id: string; + slug: string; + title: string; + author: string; + description: string; + imageUrl: string | null; + moduleCount: number; + totalMinutes: number; + difficulty: string; + giteaPath: string; +} + +interface Enrollment { + id: string; + purchaseType: string; + completed: boolean; + progress: number; +} + +interface CourseData { + course: Course; + modules: Module[]; + enrollment: Enrollment | null; +} + +interface Certificate { + id: string; + serialNumber: string; + courseId: string; + score: number | null; + issuedAt: string; + pdfPath: string | null; +} + +// ── Helpers ── + +function getDifficultyColor(difficulty: string): string { + switch (difficulty) { + case "beginner": + return "text-emerald-400 bg-emerald-400/10 border-emerald-400/20"; + case "intermediate": + return "text-amber-400 bg-amber-400/10 border-amber-400/20"; + case "advanced": + return "text-red-400 bg-red-400/10 border-red-400/20"; + default: + return "text-gray-400 bg-gray-400/10 border-gray-400/20"; + } +} + +function formatDuration(totalMinutes: number): string { + if (totalMinutes < 60) return `${totalMinutes} min`; + const hours = Math.floor(totalMinutes / 60); + const mins = totalMinutes % 60; + return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`; +} + +// ── Component ── + +export default function LearnCoursePage() { + const { user, token, loading: authLoading } = useAuth(); + const router = useRouter(); + const params = useParams(); + const slug = params.slug as string; + + const [data, setData] = useState(null); + const [loadingData, setLoadingData] = useState(true); + const [error, setError] = useState(null); + const [certificates, setCertificates] = useState([]); + + // ── Fetch course + certificates ── + + const fetchCourse = useCallback(async () => { + if (!token) return; + setLoadingData(true); + setError(null); + try { + const [courseRes, certRes] = await Promise.all([ + fetch(`/mobile/api/learn/courses/${slug}`, { + headers: { Authorization: `Bearer ${token}` }, + }), + fetch(`/mobile/api/learn/certificates`, { + headers: { Authorization: `Bearer ${token}` }, + }), + ]); + + if (courseRes.ok) { + const d = await courseRes.json(); + setData(d); + } else { + const d = await courseRes.json().catch(() => ({})); + setError(d.error || "Failed to load course"); + } + + if (certRes.ok) { + const d = await certRes.json(); + setCertificates(d.certificates || []); + } + } catch { + setError("Network error. Please try again."); + } finally { + setLoadingData(false); + } + }, [token, slug]); + + // ── Auth guard ── + + useEffect(() => { + if (!authLoading && !token) { + router.push("/auth"); + } + }, [authLoading, token, router]); + + // ── Fetch on mount ── + + useEffect(() => { + if (token && slug) { + fetchCourse(); + } + }, [token, slug, fetchCourse]); + + // ── Derived state ── + + const isEnrolled = !!data?.enrollment; + const progress = data?.enrollment?.progress ?? 0; + const progressPercent = Math.round(progress * 100); + const completedModules = + data?.modules.filter((m) => m.progress?.completed).length ?? 0; + + const certificate = certificates.find( + (c) => c.courseId === data?.course?.id + ); + const canViewCertificate = + data?.enrollment?.completed && !!certificate?.serialNumber; + + // ── Loading state ── + + if (authLoading) { + return ( +
+
+
+ ); + } + + if (!user) return null; + + return ( +
+ {/* ── Sticky header ── */} +
+
+ +

+ {data ? data.course.title : "Course"} +

+
+
+ + {/* ── Loading / Error / Empty ── */} + {loadingData ? ( +
+ +

Loading course...

+
+ ) : error ? ( + + ) : !data ? ( +
+

Course not found

+ +
+ ) : ( + <> + {/* ── Course header card ── */} +
+
+ {/* Title + author */} +

+ {data.course.title} +

+

+ by{" "} + {data.course.author} +

+ + {/* Tags row */} +
+ + {data.course.difficulty} + + + {data.course.moduleCount} module + {data.course.moduleCount !== 1 ? "s" : ""} + + + {formatDuration(data.course.totalMinutes)} + +
+ + {/* Description */} + {data.course.description && ( +

+ {data.course.description} +

+ )} + + {/* ── Progress bar (only if enrolled) ── */} + {isEnrolled && ( +
+
+ + Progress + + + {completedModules}/{data.modules.length} modules + +
+
+
+
+ + {progressPercent}% complete + +
+ )} +
+
+ + {/* ── Purchase / Certificate call-to-action ── */} +
+ {!isEnrolled ? ( + + ) : canViewCertificate ? ( + + + + 🎓 View Certificate + + + ) : null} +
+ + {/* ── Module list ── */} +
+

+ Course Modules +

+ +
+ {/* Vertical timeline line */} +
+ + {data.modules.map((mod) => { + const isCompleted = mod.progress?.completed ?? false; + const hasQuiz = !!mod.quizData; + const quizScore = mod.progress?.score ?? null; + const hasListened = mod.progress?.listened ?? false; + + return ( +
+ {/* Timeline dot */} +
+ {isCompleted ? ( + + ) : ( + + )} +
+ + {/* Module card */} +
+ + + {/* Quiz status */} + {hasQuiz && ( + + {quizScore !== null + ? `Quiz: ${Math.round(quizScore)}%` + : "Quiz"} + + )} +
+ +
+
+ ); + })} +
+
+ + {/* ── Course footer stats ── */} +
+
+
+ Total duration + + {formatDuration(data.course.totalMinutes)} + +
+ {isEnrolled && ( +
+ Enrolled + + {data.enrollment!.purchaseType === "free" + ? "Free" + : data.enrollment!.purchaseType === "subscription" + ? "Subscription" + : "Purchased"} + +
+ )} +
+
+ + )} +
+ ); +} diff --git a/src/app/learn/page.tsx b/src/app/learn/page.tsx new file mode 100644 index 0000000..cf9a0eb --- /dev/null +++ b/src/app/learn/page.tsx @@ -0,0 +1,456 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useAuth } from "@/lib/AuthContext"; +import { useRouter } from "next/navigation"; +import { + Lock, + Book, + Headphones, + Award, + Sparkles, +} from "lucide-react"; + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +interface CourseEnrollment { + progress: number; + completed: boolean; +} + +interface LearnCourse { + id: string; + slug: string; + title: string; + author: string; + description: string; + imageUrl: string | null; + moduleCount: number; + totalMinutes: number; + difficulty: string; + priceFlh: number | null; + priceUsd: number | null; + subscriptionOnly: boolean; + enrolled: boolean | null; + enrollment?: CourseEnrollment; +} + +/* ------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------ */ + +const DIFFICULTY_STYLES: Record = { + beginner: { + label: "Beginner", + classes: "bg-green-900/30 text-[#00C48C] border border-green-800/30", + }, + intermediate: { + label: "Intermediate", + classes: "bg-amber-900/30 text-amber-400 border border-amber-800/30", + }, + advanced: { + label: "Advanced", + classes: "bg-red-900/30 text-red-400 border border-red-800/30", + }, +}; + +function difficultyLabel(diff: string): { label: string; classes: string } { + return DIFFICULTY_STYLES[diff] ?? { + label: diff, + classes: "bg-gray-800/40 text-gray-400 border border-gray-700/30", + }; +} + +function formatPrice(course: LearnCourse): string { + if (course.subscriptionOnly) return "Learn Pass"; + if (course.priceFlh === null && course.priceUsd === null) return "FREE"; + if (course.priceUsd !== null) return `$${course.priceUsd.toFixed(2)}`; + if (course.priceFlh !== null) return `FLH ${course.priceFlh.toLocaleString()}`; + return "FREE"; +} + +function isFree(course: LearnCourse): boolean { + if (course.subscriptionOnly) return false; + return course.priceFlh === null && course.priceUsd === null; +} + +/* ------------------------------------------------------------------ */ +/* Placeholder Thumbnail */ +/* ------------------------------------------------------------------ */ + +const COURSE_VISUALS: Record = { + // A few known slugs get themed visuals + default: { icon: "📖", bg: "from-[#C9A84C]/20 to-[#C9A84C]/5" }, +}; + +function courseVisual(slug: string): { icon: string; bg: string } { + return COURSE_VISUALS[slug] ?? COURSE_VISUALS.default; +} + +/* ------------------------------------------------------------------ */ +/* Page Component */ +/* ------------------------------------------------------------------ */ + +export default function LearnCatalogPage() { + const { user, token, loading } = useAuth(); + const router = useRouter(); + + const [courses, setCourses] = useState([]); + const [fetching, setFetching] = useState(true); + const [error, setError] = useState(null); + + // Redirect to auth if not logged in + useEffect(() => { + if (!loading && !token) router.push("/auth"); + }, [loading, token, router]); + + // Fetch courses + useEffect(() => { + if (!token) return; + setFetching(true); + setError(null); + fetch("/mobile/api/learn/courses", { + headers: { Authorization: `Bearer ${token}` }, + }) + .then((r) => { + if (!r.ok) throw new Error("Failed to load courses"); + return r.json(); + }) + .then((data: { courses: LearnCourse[] }) => + setCourses(data.courses || []) + ) + .catch((err: Error) => setError(err.message)) + .finally(() => setFetching(false)); + }, [token]); + + // ── Loading state ── + if (loading) { + return ( +
+
+
+ ); + } + + if (!user) return null; + + // ── Guard: token present but no user yet ── + if (!token) return null; + + // ── Enrolled / locked separation ── + const enrolled = courses.filter((c) => c.enrolled === true); + const locked = courses.filter((c) => c.enrolled !== true); + + return ( +
+ {/* ── Header ── */} +
+
+
+

Falah Learn

+

+ Micro learning courses. 5 minutes at a time. +

+
+
+ +
+
+
+ + {/* ── Error state ── */} + {error && !fetching && ( +
+

{error}

+ +
+ )} + + {/* ── Loading skeleton ── */} + {fetching && ( +
+ {[1, 2, 3].map((i) => ( +
+
+
+
+
+
+
+
+
+
+
+ ))} +
+ )} + + {/* ── Course grid ── */} + {!fetching && !error && courses.length === 0 && ( +
+ +

No courses available yet

+

+ Check back soon for new micro learning content +

+
+ )} + + {!fetching && !error && courses.length > 0 && ( + <> + {/* ── Enrolled courses section ── */} + {enrolled.length > 0 && ( +
+

+ + My Courses +

+
+ {enrolled.map((course) => ( + + ))} +
+
+ )} + + {/* ── All / locked courses section ── */} +
+

+ + {enrolled.length > 0 ? "More Courses" : "All Courses"} +

+
+ {locked.map((course) => ( + + ))} +
+
+ + )} + + {/* ── Learn Pass subscription card ── */} +
+
+ {/* Decorative glow */} +
+
+ +
+
+ +

Learn Pass

+
+

+ Get unlimited access to all courses, audio lessons, and + certificates with a Learn Pass subscription. +

+
+
+ + Audio lessons +
+
+ + Certificates +
+
+ + All courses +
+
+ + + Get Learn Pass + +
+
+
+ +
+
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Course Card */ +/* ------------------------------------------------------------------ */ + +function CourseCard({ + course, + enrolled, + router, +}: { + course: LearnCourse; + enrolled: boolean; + router: ReturnType; +}) { + const vis = courseVisual(course.slug); + const diff = difficultyLabel(course.difficulty); + + const handleClick = () => { + if (enrolled) { + router.push(`/learn/${course.slug}`); + } + }; + + return ( +
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + router.push(`/learn/${course.slug}`); + } + } + : undefined + } + > + {/* Thumbnail placeholder */} +
+ {vis.icon} + + {/* Lock overlay for locked courses */} + {!enrolled && ( +
+
+ +
+
+ )} + + {/* Progress bar for enrolled courses */} + {enrolled && course.enrollment && ( +
+
+
+ )} +
+ + {/* Content */} +
+ {/* Title */} +

+ {course.title} +

+ + {/* Author */} +

{course.author}

+ + {/* Meta row: difficulty badge + modules */} +
+ + {diff.label} + + + {course.moduleCount} module{course.moduleCount !== 1 ? "s" : ""} + + {course.totalMinutes > 0 && ( + + ~{course.totalMinutes} min + + )} +
+ + {/* Price / CTA row */} +
+ + {formatPrice(course)} + + + {!enrolled && ( + e.stopPropagation()} + > + Get on iStore → + + )} + + {enrolled && ( + + {course.enrollment?.completed ? ( + <> + Completed + + ) : course.enrollment && (course.enrollment.progress ?? 0) > 0 ? ( + <> + + {Math.round((course.enrollment.progress ?? 0) * 100)}% + + ) : ( + <> + Start + + )} + + )} +
+
+
+ ); +} diff --git a/src/app/offline/page.tsx b/src/app/offline/page.tsx new file mode 100644 index 0000000..85fcd7d --- /dev/null +++ b/src/app/offline/page.tsx @@ -0,0 +1,20 @@ +"use client"; + +export default function OfflinePage() { + return ( +
+
📡
+

You're Offline

+

+ Falah needs an internet connection for most features. + Check your connection and try again. +

+ +
+ ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 102987c..c1cf9b8 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -384,15 +384,25 @@ export default function HomePage() { ); } -function QuickAction({ href, icon: Icon, label, color }: { href: string; icon: any; label: string; color: string }) { - return ( - +function QuickAction({ href, icon: Icon, label, color, external }: { href: string; icon: any; label: string; color: string; external?: boolean }) { + const content = ( +
{label} - +
); + + if (external) { + return ( + + {content} + + ); + } + + return {content}; } function StatCard({ label, value, icon: Icon, color }: { label: string; value: string; icon: any; color: string }) { diff --git a/src/app/souq/[id]/page.tsx b/src/app/souq/[id]/page.tsx new file mode 100644 index 0000000..22dfb29 --- /dev/null +++ b/src/app/souq/[id]/page.tsx @@ -0,0 +1,719 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { useAuth } from "@/lib/AuthContext"; +import { useRouter, useParams } from "next/navigation"; +import { + ArrowLeft, + Star, + Loader2, + Check, + Clock, + RefreshCw, + ShoppingCart, + User, + MessageCircle, + ChevronRight, +} from "lucide-react"; +import ErrorFeedback from "@/components/ErrorFeedback"; + +// ── Types ───────────────────────────────────────────────────────────────────── + +interface Package { + name: string; + description: string; + price: number; + deliveryDays: number; + revisions: number; +} + +interface Seller { + id: string; + name: string; + email: string; + avatar: string | null; +} + +interface Reviewer { + id: string; + name: string; + avatar: string | null; +} + +interface Review { + id: string; + rating: number; + title: string | null; + text: string; + createdAt: string; + reviewer: Reviewer; +} + +interface ListingDetail { + id: string; + title: string; + description: string; + category: string; + subcategory: string | null; + priceFlh: number; + packages: Package[] | null; + tags: string[] | null; + images: string[] | null; + deliveryDays: number | null; + rating: number; + reviewCount: number; + salesCount: number; + seller: Seller; + reviews: Review[]; + _count: { purchases: number }; + status: string; + createdAt: string; +} + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +const CATEGORY_EMOJI: Record = { + "Web Development": "🌐", + "Mobile Development": "📱", + "Content Writing": "✍️", + "Brand Identity": "🎨", + "SEO/Marketing": "📈", + "Video Production": "🎬", + default: "📦", +}; + +const CATEGORY_COLORS: Record = { + "Web Development": "from-blue-600/40 to-blue-900/30", + "Mobile Development": "from-purple-600/40 to-purple-900/30", + "Content Writing": "from-emerald-600/40 to-emerald-900/30", + "Brand Identity": "from-amber-600/40 to-amber-900/30", + "SEO/Marketing": "from-red-600/40 to-red-900/30", + "Video Production": "from-pink-600/40 to-pink-900/30", + default: "from-gray-600/40 to-gray-900/30", +}; + +function getInitials(name: string): string { + return name + .split(" ") + .map((n) => n.charAt(0).toUpperCase()) + .slice(0, 2) + .join(""); +} + +function formatFlh(amount: number): string { + return amount.toLocaleString() + " FLH"; +} + +function timeAgo(dateStr: string): string { + const now = Date.now(); + const then = new Date(dateStr).getTime(); + const diffSec = Math.floor((now - then) / 1000); + if (diffSec < 60) return "just now"; + const diffMin = Math.floor(diffSec / 60); + if (diffMin < 60) return `${diffMin}m ago`; + const diffHr = Math.floor(diffMin / 60); + if (diffHr < 24) return `${diffHr}h ago`; + const diffDay = Math.floor(diffHr / 24); + if (diffDay < 7) return `${diffDay}d ago`; + return new Date(dateStr).toLocaleDateString(); +} + +// ── Component ───────────────────────────────────────────────────────────────── + +export default function ListingDetailPage() { + const { user, token, loading } = useAuth(); + const router = useRouter(); + const params = useParams(); + const id = params.id as string; + + const [listing, setListing] = useState(null); + const [loadingData, setLoadingData] = useState(true); + const [error, setError] = useState(null); + const [selectedPackage, setSelectedPackage] = useState(null); + const [ordering, setOrdering] = useState(false); + const [orderSuccess, setOrderSuccess] = useState(false); + const [orderError, setOrderError] = useState(null); + + // ── Auth redirect ─────────────────────────────────────────────────────── + + useEffect(() => { + if (!loading && !token) { + router.push("/auth"); + } + }, [loading, token, router]); + + // ── Fetch listing ──────────────────────────────────────────────────────── + + const fetchListing = useCallback(async () => { + if (!id) return; + setLoadingData(true); + setError(null); + try { + const res = await fetch(`/mobile/api/souq/listings/${id}`); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || `Failed to load listing (${res.status})`); + } + const data = await res.json(); + setListing(data.listing); + } catch (err: unknown) { + const message = + err instanceof Error ? err.message : "Failed to load listing"; + setError(message); + } finally { + setLoadingData(false); + } + }, [id]); + + useEffect(() => { + if (id) { + fetchListing(); + } + }, [id, fetchListing]); + + // ── Auto-select first package ─────────────────────────────────────────── + + useEffect(() => { + if (listing?.packages && listing.packages.length > 0 && !selectedPackage) { + setSelectedPackage(listing.packages[0]); + } + }, [listing, selectedPackage]); + + // ── Place order ────────────────────────────────────────────────────────── + + const handleContinue = async () => { + if (!token) { + router.push("/auth"); + return; + } + + if (!selectedPackage || !listing) return; + + setOrdering(true); + setOrderError(null); + + try { + const res = await fetch("/mobile/api/souq/orders", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + listingId: listing.id, + packageName: selectedPackage.name, + }), + }); + + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.error || "Failed to place order"); + } + + setOrderSuccess(true); + setTimeout(() => { + router.push("/souq/orders"); + }, 2000); + } catch (err: unknown) { + const message = + err instanceof Error ? err.message : "Failed to place order"; + setOrderError(message); + setTimeout(() => setOrderError(null), 5000); + } finally { + setOrdering(false); + } + }; + + // ── Loading (auth check) ──────────────────────────────────────────────── + + if (loading) { + return ( +
+
+
+ ); + } + + if (!user) return null; + + // ── Data loading state ────────────────────────────────────────────────── + + if (loadingData) { + return ( +
+ {/* Header */} +
+ +
+
+ +

Loading listing...

+
+
+ ); + } + + // ── Error state ───────────────────────────────────────────────────────── + + if (error) { + return ( +
+
+ +

Listing

+
+
+ +
+
+ ); + } + + // ── Not found state ────────────────────────────────────────────────────── + + if (!listing) { + return ( +
+
+ +

Listing

+
+
+

Listing not found

+ +
+
+ ); + } + + // ── Derived data ──────────────────────────────────────────────────────── + + const packages = listing.packages || []; + const firstImage = listing.images?.[0] || null; + const categoryEmoji = CATEGORY_EMOJI[listing.category] || CATEGORY_EMOJI.default; + const categoryColor = CATEGORY_COLORS[listing.category] || CATEGORY_COLORS.default; + const totalPrice = selectedPackage + ? Math.round(selectedPackage.price * 1.015) + : 0; + const sellerInitials = getInitials(listing.seller.name); + + return ( +
+ {/* ── Header ──────────────────────────────────────────────────────── */} +
+
+ +

+ {listing.title} +

+
+
+ + {/* ── Content ─────────────────────────────────────────────────────── */} +
+ {/* ── Image Area ────────────────────────────────────────────────── */} + {firstImage ? ( +
+ {listing.title} { + // Hide broken image, show fallback + (e.target as HTMLElement).style.display = "none"; + const fallback = (e.target as HTMLElement) + .nextElementSibling as HTMLElement | null; + if (fallback) fallback.style.display = "flex"; + }} + /> +
+ {categoryEmoji} +
+
+ ) : ( +
+ {categoryEmoji} +
+ )} + + {/* ── Title & Meta ───────────────────────────────────────────────── */} +
+

+ {listing.title} +

+ + {/* Category badge */} +
+ + {listing.category} + + {listing.subcategory && ( + {listing.subcategory} + )} +
+ + {/* Rating row */} +
+
+ + + {listing.rating.toFixed(1)} + +
+ + ({listing.reviewCount} {listing.reviewCount === 1 ? "review" : "reviews"}) + + · + + {listing.salesCount} {listing.salesCount === 1 ? "sale" : "sales"} + +
+ + {/* Seller row */} + +
+ + {/* ── Description ────────────────────────────────────────────────── */} +
+

About This Listing

+

+ {listing.description} +

+
+ + {/* ── Packages ────────────────────────────────────────────────────── */} + {packages.length > 0 && ( +
+

+ Select a Package +

+
+ {packages.map((pkg) => { + const isSelected = selectedPackage?.name === pkg.name; + const feeAmount = Math.round(pkg.price * 0.015); + + return ( + + ); + })} +
+
+ )} + + {/* ── Seller Info Section ─────────────────────────────────────────── */} +
+

+ + About the Seller +

+
+ {listing.seller.avatar ? ( + {listing.seller.name} + ) : ( +
+ {sellerInitials} +
+ )} +
+

{listing.seller.name}

+

Member since{" "} + {new Date(listing.createdAt).toLocaleDateString("en-US", { + month: "long", + year: "numeric", + })} +

+
+
+ + {/* Seller stats */} +
+
+
+ + {listing.rating.toFixed(1)} +
+

Rating

+
+
+

{listing.reviewCount}

+

+ {listing.reviewCount === 1 ? "Review" : "Reviews"} +

+
+
+

{listing.salesCount}

+

+ {listing.salesCount === 1 ? "Sale" : "Sales"} +

+
+
+ + +
+ + {/* ── Reviews ─────────────────────────────────────────────────────── */} + {listing.reviews && listing.reviews.length > 0 && ( +
+

+ + Reviews ({listing.reviews.length}) +

+
+ {listing.reviews.map((review) => ( +
+ {/* Reviewer info */} +
+ {review.reviewer.avatar ? ( + {review.reviewer.name} + ) : ( +
+ {getInitials(review.reviewer.name)} +
+ )} +
+

+ {review.reviewer.name} +

+
+
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ + {timeAgo(review.createdAt)} + +
+
+
+ + {/* Review title */} + {review.title && ( +

+ {review.title} +

+ )} + + {/* Review text */} +

+ {review.text} +

+
+ ))} +
+
+ )} + + {/* Bottom spacer */} +
+
+ + {/* ── Fixed Bottom Order Bar ───────────────────────────────────────── */} +
+ {/* Safe area inset wrapper */} +
+ {/* Success toast */} + {orderSuccess && ( +
+ + Order placed! Redirecting... +
+ )} + + {/* Error toast */} + {orderError && ( +
+ setOrderError(null)} + context="order" + /> +
+ )} + +
+ {/* Price info */} +
+

+ {selectedPackage ? selectedPackage.name : "No package selected"} +

+

+ {selectedPackage ? formatFlh(totalPrice) : "—"} +

+ {selectedPackage && ( +

+ {formatFlh(selectedPackage.price)} + 1.5% fee +

+ )} +
+ + {/* Continue button */} + +
+
+
+
+ ); +} diff --git a/src/app/souq/create/page.tsx b/src/app/souq/create/page.tsx new file mode 100644 index 0000000..0cab471 --- /dev/null +++ b/src/app/souq/create/page.tsx @@ -0,0 +1,523 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { useAuth } from "@/lib/AuthContext"; +import { useRouter } from "next/navigation"; +import { + ArrowLeft, + Plus, + X, + Loader2, + Check, + Package, + Image as ImageIcon, + Tags, + Clock, +} from "lucide-react"; + +/* ------------------------------------------------------------------ */ +/* Categories */ +/* ------------------------------------------------------------------ */ + +const CATEGORIES = [ + { id: "graphics-design", name: "Graphics & Design", icon: "🎨" }, + { id: "digital-marketing", name: "Digital Marketing", icon: "📈" }, + { id: "writing-translation", name: "Writing & Translation", icon: "✍️" }, + { id: "video-animation", name: "Video & Animation", icon: "🎬" }, + { id: "music-audio", name: "Music & Audio", icon: "🎵" }, + { id: "programming-tech", name: "Programming & Tech", icon: "💻" }, + { id: "ai-services", name: "AI Services", icon: "🤖" }, + { id: "consulting", name: "Consulting", icon: "💼" }, + { id: "data", name: "Data", icon: "📊" }, + { id: "business", name: "Business", icon: "🏢" }, + { id: "personal-growth", name: "Personal Growth", icon: "🌱" }, + { id: "photography", name: "Photography", icon: "📷" }, + { id: "finance", name: "Finance", icon: "💰" }, +]; + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +interface PackageField { + id: string; + name: string; + description: string; + price: string; + deliveryDays: string; + revisions: string; +} + +interface FormErrors { + title?: string; + category?: string; + description?: string; + priceFlh?: string; +} + +/* ------------------------------------------------------------------ */ +/* Page Component */ +/* ------------------------------------------------------------------ */ + +export default function SouqCreatePage() { + const { user, token, loading } = useAuth(); + const router = useRouter(); + + /* ---- form fields ---- */ + const [title, setTitle] = useState(""); + const [category, setCategory] = useState(""); + const [description, setDescription] = useState(""); + const [priceFlh, setPriceFlh] = useState(""); + const [deliveryDays, setDeliveryDays] = useState(""); + const [tagsInput, setTagsInput] = useState(""); + const [imageUrl, setImageUrl] = useState(""); + + /* ---- packages ---- */ + const [packages, setPackages] = useState([]); + + /* ---- ui state ---- */ + const [errors, setErrors] = useState({}); + const [submitting, setSubmitting] = useState(false); + const [success, setSuccess] = useState(false); + const [errorMessage, setErrorMessage] = useState(""); + + /* ---- auth guard ---- */ + useEffect(() => { + if (!loading && !token) { + router.push("/auth"); + } + }, [loading, token, router]); + + /* ---- auto-redirect on success ---- */ + useEffect(() => { + if (success) { + const timer = setTimeout(() => router.push("/souq"), 2000); + return () => clearTimeout(timer); + } + }, [success, router]); + + /* ---- helpers ---- */ + const generateId = useCallback( + () => Math.random().toString(36).substring(2, 10), + [] + ); + + const addPackage = () => { + setPackages((prev) => [ + ...prev, + { + id: generateId(), + name: "", + description: "", + price: "", + deliveryDays: "", + revisions: "", + }, + ]); + }; + + const removePackage = (id: string) => { + setPackages((prev) => prev.filter((p) => p.id !== id)); + }; + + const updatePackage = (id: string, field: keyof PackageField, value: string) => { + setPackages((prev) => + prev.map((p) => (p.id === id ? { ...p, [field]: value } : p)) + ); + }; + + /* ---- validation ---- */ + const validate = (): boolean => { + const errs: FormErrors = {}; + if (!title.trim()) errs.title = "Title is required"; + if (!category) errs.category = "Please select a category"; + if (!description.trim()) errs.description = "Description is required"; + if (!priceFlh || isNaN(Number(priceFlh)) || Number(priceFlh) < 0) { + errs.priceFlh = "Enter a valid price (0 or more)"; + } + setErrors(errs); + return Object.keys(errs).length === 0; + }; + + /* ---- submit ---- */ + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!validate()) return; + + setSubmitting(true); + setErrorMessage(""); + + try { + // Parse tags from comma-separated string + const tagsArr = tagsInput + .split(",") + .map((t) => t.trim()) + .filter(Boolean); + + // Build images array + const imagesArr = imageUrl.trim() ? [imageUrl.trim()] : []; + + // Build packages array (only if user added any) + const packagesArr = packages + .filter((p) => p.name.trim()) + .map((p) => ({ + name: p.name.trim(), + description: p.description.trim(), + price: Number(p.price) || 0, + deliveryDays: p.deliveryDays ? Number(p.deliveryDays) : undefined, + revisions: p.revisions ? Number(p.revisions) : undefined, + })); + + const body: Record = { + title: title.trim(), + description: description.trim(), + category, + priceFlh: Number(priceFlh), + deliveryDays: deliveryDays ? Number(deliveryDays) : undefined, + tags: tagsArr.length > 0 ? tagsArr : undefined, + images: imagesArr.length > 0 ? imagesArr : undefined, + packages: packagesArr.length > 0 ? packagesArr : undefined, + }; + + const res = await fetch("/mobile/api/souq/listings", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(body), + }); + + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.error || "Failed to create listing"); + } + + setSuccess(true); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : "Something went wrong"; + setErrorMessage(msg); + } finally { + setSubmitting(false); + } + }; + + /* ---- early returns ---- */ + if (loading) { + return ( +
+
+
+ ); + } + + if (!user) return null; + + /* ---- success state ---- */ + if (success) { + return ( +
+
+ +
+

Listing Created!

+

+ Your service has been published on Souq. +

+

Redirecting to Souq…

+
+ +
+ ); + } + + /* ---- main render ---- */ + return ( +
+ {/* Header */} +
+ +
+

Create Listing

+

Sell your service on Souq

+
+
+ + {/* Form */} +
+ {/* Title */} +
+
+ + setTitle(e.target.value)} + placeholder="e.g. I will build a modern website" + className="w-full bg-[#0a0a0f] border border-gray-800/60 rounded-xl px-4 py-3 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/50 focus:ring-1 focus:ring-[#D4AF37]/30 transition min-h-[48px]" + /> + {errors.title && ( +

{errors.title}

+ )} +
+
+ + {/* Category */} +
+ +
+ {CATEGORIES.map((cat) => { + const isActive = category === cat.id; + return ( + + ); + })} +
+ {errors.category && ( +

{errors.category}

+ )} +
+ + {/* Description */} +
+ +