From 5483dd291e282db7e9d223241276d43315773011 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 15 Jun 2026 09:28:22 +0200 Subject: [PATCH] =?UTF-8?q?Initial=20Falah=20Mobile=20rebuild=20=E2=80=94?= =?UTF-8?q?=20all=207=20blocks=20complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Auth system (login/register/profile) - Nur AI chat with persona system - Souq marketplace with FLH economy - Forum community with Shariah moderation - Wallet & FLH top-up - Premium/Pro tier upgrade with Polar.sh - Halal Monitor with map & bookmarks - Home dashboard with daily verse & streaks - Health endpoints Next.js 16.2.7, Prisma v5 SQLite, JWT auth, Tailwind CSS v4 --- Dockerfile | 40 + docker-compose.yml | 22 + next.config.ts | 5 +- package-lock.json | 201 ++++- package.json | 11 +- prisma/dev.db | Bin 0 -> 122880 bytes prisma/schema.prisma | 161 ++++ src/app/api/auth/login/route.ts | 59 ++ src/app/api/auth/me/route.ts | 37 + src/app/api/auth/profile/route.ts | 70 ++ src/app/api/auth/register/route.ts | 63 ++ src/app/api/chat/daily/route.ts | 53 ++ src/app/api/chat/demo/route.ts | 44 ++ src/app/api/chat/history/[userId]/route.ts | 45 ++ src/app/api/chat/send/route.ts | 158 ++++ src/app/api/daily/verse/route.ts | 12 + src/app/api/files/[listingId]/route.ts | 85 ++ src/app/api/forum/categories/route.ts | 18 + src/app/api/forum/posts/route.ts | 115 +++ src/app/api/forum/threads/route.ts | 118 +++ src/app/api/halal/bookmarks/route.ts | 129 ++++ src/app/api/halal/places/route.ts | 47 ++ src/app/api/halal/usage/route.ts | 129 ++++ src/app/api/health/route.ts | 19 + src/app/api/marketplace/feature/route.ts | 98 +++ src/app/api/marketplace/listings/route.ts | 131 ++++ src/app/api/marketplace/purchase/route.ts | 128 +++ src/app/api/nurbuddy-health/route.ts | 10 + src/app/api/seed/route.ts | 48 ++ src/app/api/seller/[sellerId]/route.ts | 54 ++ src/app/api/upgrade/create-checkout/route.ts | 108 +++ src/app/api/wallet/route.ts | 104 +++ .../wallet/top-up/create-checkout/route.ts | 70 ++ src/app/api/webhooks/polar/route.ts | 118 +++ src/app/forum/[threadId]/page.tsx | 417 ++++++++++ src/app/forum/page.tsx | 560 ++++++++++++++ src/app/globals.css | 91 ++- src/app/halal-monitor/page.tsx | 628 +++++++++++++++ src/app/layout.tsx | 43 +- src/app/login/page.tsx | 151 ++++ src/app/nur/page.tsx | 521 +++++++++++++ src/app/page.tsx | 228 ++++-- src/app/profile/loading.tsx | 10 + src/app/profile/page.tsx | 343 +++++++++ src/app/register/page.tsx | 166 ++++ src/app/souq/page.tsx | 727 ++++++++++++++++++ src/app/upgrade/page.tsx | 369 +++++++++ src/app/wallet/page.tsx | 410 ++++++++++ src/components/BottomNav.tsx | 78 ++ src/lib/AuthContext.tsx | 121 +++ src/lib/ai-server.ts | 192 +++++ src/lib/ai.ts | 22 + src/lib/auth.ts | 41 + src/lib/personas.ts | 48 ++ src/lib/prisma.ts | 9 + src/lib/tiers.ts | 34 + 56 files changed, 7619 insertions(+), 100 deletions(-) create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 prisma/dev.db create mode 100644 prisma/schema.prisma create mode 100644 src/app/api/auth/login/route.ts create mode 100644 src/app/api/auth/me/route.ts create mode 100644 src/app/api/auth/profile/route.ts create mode 100644 src/app/api/auth/register/route.ts create mode 100644 src/app/api/chat/daily/route.ts create mode 100644 src/app/api/chat/demo/route.ts create mode 100644 src/app/api/chat/history/[userId]/route.ts create mode 100644 src/app/api/chat/send/route.ts create mode 100644 src/app/api/daily/verse/route.ts create mode 100644 src/app/api/files/[listingId]/route.ts create mode 100644 src/app/api/forum/categories/route.ts create mode 100644 src/app/api/forum/posts/route.ts create mode 100644 src/app/api/forum/threads/route.ts create mode 100644 src/app/api/halal/bookmarks/route.ts create mode 100644 src/app/api/halal/places/route.ts create mode 100644 src/app/api/halal/usage/route.ts create mode 100644 src/app/api/health/route.ts create mode 100644 src/app/api/marketplace/feature/route.ts create mode 100644 src/app/api/marketplace/listings/route.ts create mode 100644 src/app/api/marketplace/purchase/route.ts create mode 100644 src/app/api/nurbuddy-health/route.ts create mode 100644 src/app/api/seed/route.ts create mode 100644 src/app/api/seller/[sellerId]/route.ts create mode 100644 src/app/api/upgrade/create-checkout/route.ts create mode 100644 src/app/api/wallet/route.ts create mode 100644 src/app/api/wallet/top-up/create-checkout/route.ts create mode 100644 src/app/api/webhooks/polar/route.ts create mode 100644 src/app/forum/[threadId]/page.tsx create mode 100644 src/app/forum/page.tsx create mode 100644 src/app/halal-monitor/page.tsx create mode 100644 src/app/login/page.tsx create mode 100644 src/app/nur/page.tsx create mode 100644 src/app/profile/loading.tsx create mode 100644 src/app/profile/page.tsx create mode 100644 src/app/register/page.tsx create mode 100644 src/app/souq/page.tsx create mode 100644 src/app/upgrade/page.tsx create mode 100644 src/app/wallet/page.tsx create mode 100644 src/components/BottomNav.tsx create mode 100644 src/lib/AuthContext.tsx create mode 100644 src/lib/ai-server.ts create mode 100644 src/lib/ai.ts create mode 100644 src/lib/auth.ts create mode 100644 src/lib/personas.ts create mode 100644 src/lib/prisma.ts create mode 100644 src/lib/tiers.ts diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..483dee9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,40 @@ +FROM node:20-alpine AS base + +# Install openssl for Prisma +RUN apk add --no-cache openssl ca-certificates + +FROM base AS deps +WORKDIR /app +COPY package*.json ./ +RUN npm ci --only=production + +FROM base AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npx prisma generate && npm run build + +FROM base AS runner +WORKDIR /app +ENV NODE_ENV=production +ENV PORT=3000 + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static +COPY --from=builder /app/public ./public +COPY --from=builder /app/prisma ./prisma +COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma + +RUN mkdir -p /app/data && chown nextjs:nodejs /app/data + +USER nextjs + +EXPOSE 3000 + +ENV DATABASE_URL="file:./dev.db" + +CMD ["node", "server.js"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..96db6f6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,22 @@ +version: "3.8" +services: + falah-mobile: + build: . + image: falah-mobile:latest + ports: + - "4011:3000" + environment: + - NODE_ENV=production + - JWT_SECRET=${JWT_SECRET:-flh-dev-jwt-secret-change-in-prod} + - DATABASE_URL=file:./dev.db + volumes: + - falah-mobile-data:/app/data + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"] + interval: 30s + timeout: 10s + retries: 3 + +volumes: + falah-mobile-data: diff --git a/next.config.ts b/next.config.ts index e9ffa30..5ee024f 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,10 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + output: "standalone", + experimental: { + optimizePackageImports: ["lucide-react"], + }, }; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index ef8dc7e..dacba49 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,18 @@ "name": "falah-mobile", "version": "0.1.0", "dependencies": { + "@prisma/client": "^5.22.0", + "@types/leaflet": "^1.9.21", + "bcryptjs": "^3.0.3", + "jose": "^6.2.3", + "leaflet": "^1.9.4", + "lucide-react": "^1.18.0", "next": "16.2.7", + "prisma": "^5.22.0", "react": "19.2.4", - "react-dom": "19.2.4" + "react-dom": "19.2.4", + "react-leaflet": "^5.0.0", + "stripe": "^22.2.1" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -1246,6 +1255,80 @@ "node": ">=12.4.0" } }, + "node_modules/@prisma/client": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz", + "integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.13" + }, + "peerDependencies": { + "prisma": "*" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + } + } + }, + "node_modules/@prisma/debug": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz", + "integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz", + "integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0", + "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "@prisma/fetch-engine": "5.22.0", + "@prisma/get-platform": "5.22.0" + } + }, + "node_modules/@prisma/engines-version": { + "version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz", + "integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/fetch-engine": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz", + "integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0", + "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "@prisma/get-platform": "5.22.0" + } + }, + "node_modules/@prisma/get-platform": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz", + "integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0" + } + }, + "node_modules/@react-leaflet/core": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz", + "integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==", + "license": "Hippocratic-2.1", + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -1551,6 +1634,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1565,11 +1654,20 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/leaflet": { + "version": "1.9.21", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz", + "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/node": { "version": "20.19.43", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -2519,6 +2617,15 @@ "node": ">=6.0.0" } }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/brace-expansion": { "version": "1.1.15", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", @@ -3671,6 +3778,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -4504,6 +4625,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -4627,6 +4757,12 @@ "node": ">=0.10" } }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -4948,6 +5084,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.18.0.tgz", + "integrity": "sha512-LZDb7H/0YfM+RJncD0hDQRCAu+vSGODqpe35TuVI8EuXaRjkczbsx7p8dY4J87F/MUSj6bpYqeI8nw8qXaAdmA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -5472,6 +5617,25 @@ "node": ">= 0.8.0" } }, + "node_modules/prisma": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz", + "integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/engines": "5.22.0" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=16.13" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -5543,6 +5707,20 @@ "dev": true, "license": "MIT" }, + "node_modules/react-leaflet": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz", + "integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==", + "license": "Hippocratic-2.1", + "dependencies": { + "@react-leaflet/core": "^3.0.0" + }, + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -6110,6 +6288,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stripe": { + "version": "22.2.1", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-22.2.1.tgz", + "integrity": "sha512-ULAtq25USBEx3yeN5zimEkfYVFETVMP5om25Ryr8ol11P62imaCZLBIEW78T7zm7Wg3wnZi+80S72yf3LCpNhw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", @@ -6438,7 +6633,7 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/unrs-resolver": { diff --git a/package.json b/package.json index e56bf25..d01f062 100644 --- a/package.json +++ b/package.json @@ -9,9 +9,18 @@ "lint": "eslint" }, "dependencies": { + "@prisma/client": "^5.22.0", + "@types/leaflet": "^1.9.21", + "bcryptjs": "^3.0.3", + "jose": "^6.2.3", + "leaflet": "^1.9.4", + "lucide-react": "^1.18.0", "next": "16.2.7", + "prisma": "^5.22.0", "react": "19.2.4", - "react-dom": "19.2.4" + "react-dom": "19.2.4", + "react-leaflet": "^5.0.0", + "stripe": "^22.2.1" }, "devDependencies": { "@tailwindcss/postcss": "^4", diff --git a/prisma/dev.db b/prisma/dev.db new file mode 100644 index 0000000000000000000000000000000000000000..c215b37944dc08df1289ce50896f62769c92e245 GIT binary patch literal 122880 zcmeI(&2!u4odn4TV^Og-_Qalqra)OWMbZ>Gu{)jNJR%>8 zSmT8TKv~QlI;vw&CY|YVkK1E=+1uXsw6n99z3gAGJ3G_Ep4vV1va`MH1MmSzKqO;H zt`uv&nvn(I!Gq^{KF{xY@Bm1*ALdnu%1zVKi6eg$yBrgQ*k`gFi^Ue$@4M{R|GCUI zru-fDR~XnHwYd=caPO;W_vHCMyXRb)|MSH^iQUBW=>zf3)cSNzpi^_gs<1lh0^h#l z=}U1*xhlj|qe1uWryAp{k&a_}`?Z{Db@Y;HJGFGMIsf8^7ewjWHR06g z_s(%tN28I`cv9Uo9BMct5{W~1O{=>ZErVxfO>CLgsMNM<7?kC_S1jh0Orc-FbtRXn z<|}fOXg1}SvRlMbNo(67PREYaM)or-x2e%kjosyNrkqB0c^VFkd+FNPZm_8>5CY(anzGGqKZcKtPt-!Mn5*TkU%hPOUEbdNDyKADM2#nh-?kJKj}-k*Ib zPK(mglJG0y^?Rvf)my}-{@-}Gul>VE>Sax}9X5lG>gb(LcXSlkRMSSMeshepHR3ec zq9RAxK;?t%P@H&Wlt`Ba{K%8sTWi)dnyRG_o#Ib4a(kHv?*cw_Ex;QZLP2U|(#`kC zk2LR&sRZw$p;hp^?}TAIrgT3Bh8FK9MgWd6jBdo^Qg&(7FbX;(?Qe-+-kB7o#YN$( zqBnH7IsY>i9yt9Dc`Zw*L${Y`OxJ^(e`uk`qqJ?QbvkZp4OGMHJf8g zN%M{A7e5k2X<Y_gjO?6 zt#yxR#HbIMZG$EC+RDlb?>f~kS>`J)*WGieu6FcSJfH5jsijg@mpt90yp6nEoitix zCsg&iN$RZ|WKR{LJQ`t(CO0+wK)~}g7X*p8|9ZId)7vcAOHafKmY;| zfB*y_009U<;M@ot~(K`(V)8d8T*__S|5^jd!6mc?i20%t2^(fSKohB*}6|2<&};4+Ow?kVSRP2X>ZY| zE1$1swMzBI!**evtbAU1a)X*XEBjA!H?3Qlr^}b!%j7As3|TiVDw7@dN?P8uHKNPJ zXvnJGw#+>$>s{GgweI`S~s7uTCeP_?rYCfUB7*6FFn52xAq?uzfiVrtvub| z%4~1a8xOKsi~jU;W9wewub=#EZTDu^Za=Ji^s^hCht^K+*6rrx@Zd-)C^0$TG zG=BHae+Ym7Z>h?EB^b6a|NGed@8|#Z+{6?b0s#m>00Izz00bZa0SG_<0uVTl0!x#h z3M1c_OuRGs>8zJJ?7f8h{h$3u|M-Cb1Rwwb2tWV=5P$##AOHafK;Rq;xZnTr{C|!+ z7)^r!1Rwwb2tWV=5P$##AOHafFado3M;Jf=0uX=z1Rwwb2tWV=5P$##&c6V@|DXRp zMhhVT0SG_<0uX=z1Rwwb2tWV=`2LR=fB*y_009U<00Izz00bZa0SKIb0et^I|9y-W zLI45~fB*y_009U<00Izz00i*;A29#{2tWV=5P$##AOHafKmY;|IR66p{(t`a7%hYV z1Rwwb2tWV=5P$##AOHaf;QK#f00Izz00bZa0SG_<0uX=z1R!w!1@Qg<{P!_h2muH{ z00Izz00bZa0SG_<0uaFWf5ZR;AOHafKmY;|fB*y_009U<;QR~V`~UgxW3&(g5P$## zAOHafKmY;|fB*y_Fel!LiPHzMx#ax6C;su`pTutB`SgK!XKH;qC(x-mVO3a#(x5>4 zQe0B53Nh7a(0%)*K(%S(MzW7)Y8G`{EHu65T$F^gs-nSWJjZ3LZxL< z(iqshm@O-riXvAs_wtII97svZA0%Y*J`sl2(BDLK zJI7HSjYdx6Np;h3sNsxABo5s*t?p*D44#=av1MALQroIwP?qyvv6xpfg?Tz9 zlw4MF3cGez+2+^C^31H;+#XBG#R6+pUSWM(W`&nGvlaJHwd7V;&Sti=nRR7+eSG|( zweb@pb*b9aI#Lx9De;crY+f51+P$#l!QI=LJNU({C@n7w&+qob+eh%i*xyME32{H| zNI`B8eMF2ms9m?zwxgOxFS2-)s4VzH2TZHs`mnB7Hk9&-Vkwcjorp_Y%VXjEc5*s@ z_rmjAq9~I27XC+xgXY$c>`$z-3{+h;bn4{Kf;?M!ZTOS^}#~3d2{~NnoOpWUGNPW`b{n?k| zv?wht3BMvjr1*wrqH{T;a z(!4vS61AMS6Nd4a()}11TD+ea0XW7mx)F~{*`-m#DCm&1za@ToXHt|F7lp5i z-q7LZ{LfT);PgA>wJf0y-Cm+GT@P;lp@kZc(zd16>A0yiPz|s1c=m6YSV`Sc_o%;M zYO-Zf$9m;OnSXlKa8#|ol;R~fRgG4r}gBnXkB9ch9A|+RhS%wm~J3GOG)V~q)6If=VDMrH{ za^>R7OsXyzrkFemnbk z_OHaB&;0lFuf_K!|8M&7#2+UAY2xoDmV|#0*4T+>^K)=vQIwSH!of%0WA=R(>sm=8 zb((einmwRy_(8tv6k|<+*!57+OsDyx!&AW9+KE$u@QH zU_q3wUl)G4XXtd_U{3(ONi4dUU^A&|(a`)5oRUc0 z{cc=(czx7ZkJPu8jvV;@FMs|WQF`w^;l*ShqiT~~>YeCqeRl|fmk_kR(~Hb-yN0}# zVo$T|mSD5)U@wWHj{}G1U4C|6)zxv+bUL`5xi=iM!RFHF!ewx2-*1;6{=?qw@D`nL zRh>wE^mbg@cyDa)c$B6oD% z$Zt^W&8F_(vx2xANb4^Mqp2F%iQM>)cSGwHcBm8mmYzmG-hOM`tDR7%@UHZ0xA1&s z-h1u);&ZPz2jG4BZ^u*HtIN;<|n|1TfRiPF`p!dFbuf9LOh7w~R)?#88X#|Ouc)a_$m={w#hhEXFp zcx@b>ym>!=i{H_EUswFL4SiPh6C>{?JmTIvYleK{ck^C7e8r*l@Dn5TV0XczzL{UU zI4*t%8Ww&tj}K4y@bo5pcsjxN6MTI7WF{^h`!fXo|Npajp@C8%009U<00Izz00bZa z0SG_<0;ef}|Ns9qwNVxXAOHafKmY;|fB*y_009U<;A{l&{r_y-Q7Qx=009U<00Izz z00bZa0SG|gGzIYe|1`Bx76c#w0SG_<0uX=z1Rwwb2teR$1n~X;Y}`>Q1Rwwb2tWV= z5P$##AOHafK;Se5@csWZwNVxXAOHafKmY;|fB*y_009U<;A{l&{r_y-Q7Qx=009U< z00Izz00bZa0SG|gGzIYe|1`Bx76c#w0SG_<0uX=z1Rwwb2teR$1l;F;Vg8S?`9IG8 z&)L)fr9uD#5P$##AOHafKmY;|fB*y_a1w#HCR0L#>gH!nqLJ2zq}{$UnVJnB!T0}@ z2qP~9AOHafKmY;|fB*y_009U<;LQ-g^Z%RCPpBmXAOHafKmY;|fB*y_009U<;3NWg z{y&Kx@ = {}; + if (name !== undefined) updateData.name = name; + if (preferredName !== undefined) updateData.preferredName = preferredName; + if (coachPersona !== undefined) updateData.coachPersona = coachPersona; + if (madhab !== undefined) updateData.madhab = madhab; + if (coachingGoals !== undefined) updateData.coachingGoals = coachingGoals; + + if (Object.keys(updateData).length === 0) { + return NextResponse.json( + { error: "No fields to update" }, + { status: 400 } + ); + } + + const user = await prisma.user.update({ + where: { id: jwtPayload.userId }, + data: updateData, + }); + + return NextResponse.json({ + user: { + id: user.id, + email: user.email, + name: user.name, + isPremium: user.isPremium, + isPro: user.isPro, + flhBalance: user.flhBalance, + dailyMsgCount: user.dailyMsgCount, + experienceLevel: user.experienceLevel, + madhab: user.madhab, + coachPersona: user.coachPersona, + preferredName: user.preferredName, + coachingGoals: user.coachingGoals, + trialEndsAt: user.trialEndsAt, + createdAt: user.createdAt, + }, + }); + } catch (error) { + console.error("Profile update error:", error); + return NextResponse.json( + { error: "Profile update failed" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts new file mode 100644 index 0000000..9474973 --- /dev/null +++ b/src/app/api/auth/register/route.ts @@ -0,0 +1,63 @@ +import { NextRequest, NextResponse } from "next/server"; +import bcrypt from "bcryptjs"; +import { prisma } from "@/lib/prisma"; +import { signJWT } from "@/lib/auth"; + +export async function POST(req: NextRequest) { + try { + const { email, name, password } = await req.json(); + + if (!email || !name || !password) { + return NextResponse.json( + { error: "Email, name, and password are required" }, + { status: 400 } + ); + } + + const existing = await prisma.user.findUnique({ where: { email } }); + if (existing) { + return NextResponse.json( + { error: "An account with this email already exists" }, + { status: 409 } + ); + } + + const passwordHash = await bcrypt.hash(password, 12); + + const user = await prisma.user.create({ + data: { + email, + name, + passwordHash, + flhBalance: 5000, + trialEndsAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + }, + }); + + 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, + }, + }); + } catch (error) { + console.error("Register error:", error); + return NextResponse.json( + { error: "Registration failed. Please try again." }, + { status: 500 } + ); + } +} diff --git a/src/app/api/chat/daily/route.ts b/src/app/api/chat/daily/route.ts new file mode 100644 index 0000000..7592bc9 --- /dev/null +++ b/src/app/api/chat/daily/route.ts @@ -0,0 +1,53 @@ +import { requireAuth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { getUserTier, DAILY_LIMITS } from "@/lib/tiers"; + +export const runtime = "nodejs"; + +export async function GET(request: Request) { + try { + const user = await requireAuth(request); + if (!user) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const dbUser = await prisma.user.findUnique({ + where: { id: user.userId }, + select: { + dailyMsgCount: true, + dailyMsgResetAt: true, + isPremium: true, + isPro: true, + }, + }); + + if (!dbUser) { + return Response.json({ error: "User not found" }, { status: 404 }); + } + + const tier = getUserTier(dbUser.isPremium, dbUser.isPro); + const limits = DAILY_LIMITS[tier]; + + const now = new Date(); + let count = dbUser.dailyMsgCount; + let resetAt = dbUser.dailyMsgResetAt; + + // Reset if new day + if (!resetAt || now.getTime() - resetAt.getTime() > 24 * 60 * 60 * 1000) { + count = 0; + resetAt = now; + } + + return Response.json({ + count, + limit: limits.msgs === Infinity ? -1 : limits.msgs, + resetAt: resetAt?.toISOString() || now.toISOString(), + tier, + remaining: + limits.msgs === Infinity ? -1 : Math.max(0, limits.msgs - count), + }); + } catch (error) { + console.error("Daily chat error:", error); + return Response.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/api/chat/demo/route.ts b/src/app/api/chat/demo/route.ts new file mode 100644 index 0000000..367ceca --- /dev/null +++ b/src/app/api/chat/demo/route.ts @@ -0,0 +1,44 @@ +export const runtime = "nodejs"; + +const DEMO_CONVERSATION: { role: string; content: string; personaId: string }[] = [ + { + role: "user", + content: "Assalamualaikum! I've been feeling really stressed about work lately.", + personaId: "nurbuddy", + }, + { + role: "assistant", + content: + "Waalaikumsalam warahmatullahi wabarakatuh! 😊 I'm sorry to hear you're feeling stressed. Remember what Allah says: **\"Verily, with hardship comes ease\"** (Quran 94:6). \n\nHere are a few things that might help:\n• Take a deep breath and recite *SubhanAllah* 33 times\n• Make wudu — it's surprisingly calming!\n• Step outside and appreciate Allah's creation for a few minutes\n\nYou're not alone, and this too shall pass, insha'Allah. 💛", + personaId: "nurbuddy", + }, + { + role: "user", + content: "That helps, thank you. Can you tell me more about patience in Islam?", + personaId: "hakim", + }, + { + role: "assistant", + content: + "Ah, sabr (patience) — one of the most beautiful virtues in Islam. Let me share a story:\n\nProphet Ayyub (Job, peace be upon him) lost everything — wealth, children, and health. Yet he never stopped praising Allah. When asked how, he said: **\"Allah gave, and Allah has taken. Blessed be Allah in both.\"**\n\nAllah rewarded him tremendously: **\"And We gave him back his family and the like thereof with them — a mercy from Us and a reminder for those who worship\"** (Quran 38:43).\n\nYour patience through work stress is a form of worship too. Every moment you endure with grace is recorded. 🌱", + personaId: "hakim", + }, + { + role: "user", + content: "SubhanAllah, that's really powerful. JazakAllah khair!", + personaId: "nurbuddy", + }, + { + role: "assistant", + content: + "Wa iyyak! 😊 So glad it resonated with you. Remember, the Prophet ﷺ said: **\"He who does not thank people, does not thank Allah.\"** \n\nMay Allah grant you ease in your work and bless your patience. Come back anytime you need a friend to talk to! 🌟", + personaId: "nurbuddy", + }, +]; + +export async function GET() { + return Response.json({ + conversation: DEMO_CONVERSATION, + personas: ["nurbuddy", "hakim"], + }); +} diff --git a/src/app/api/chat/history/[userId]/route.ts b/src/app/api/chat/history/[userId]/route.ts new file mode 100644 index 0000000..308a239 --- /dev/null +++ b/src/app/api/chat/history/[userId]/route.ts @@ -0,0 +1,45 @@ +import { requireAuth, type JWTContents } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +export const runtime = "nodejs"; + +export async function GET( + request: Request, + { params }: { params: Promise<{ userId: string }> } +) { + try { + const user = await requireAuth(request); + if (!user) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { userId } = await params; + + // Verify the requested userId matches the authenticated user + if (user.userId !== userId) { + return Response.json({ error: "Forbidden" }, { status: 403 }); + } + + const messages = await prisma.chatHistory.findMany({ + where: { userId }, + orderBy: { createdAt: "desc" }, + take: 50, + }); + + // Return in chronological order + const ordered = messages.reverse(); + + return Response.json({ + messages: ordered.map((m) => ({ + id: m.id, + role: m.role, + content: m.content, + metadata: m.metadata ? JSON.parse(m.metadata) : null, + createdAt: m.createdAt.toISOString(), + })), + }); + } catch (error) { + console.error("Chat history error:", error); + return Response.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/api/chat/send/route.ts b/src/app/api/chat/send/route.ts new file mode 100644 index 0000000..139e171 --- /dev/null +++ b/src/app/api/chat/send/route.ts @@ -0,0 +1,158 @@ +import { requireAuth, type JWTContents } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { getPersona } from "@/lib/personas"; +import { getUserTier, DAILY_LIMITS } from "@/lib/tiers"; +import { generateAIReply } from "@/lib/ai-server"; + +export const runtime = "nodejs"; + +export async function POST(request: Request) { + try { + const user = await requireAuth(request); + if (!user) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json(); + const { messages, personaId } = body as { + messages?: { role: string; content: string }[]; + personaId?: string; + }; + + if (!messages || !Array.isArray(messages) || messages.length === 0) { + return Response.json({ error: "messages array is required" }, { status: 400 }); + } + + if (!personaId || typeof personaId !== "string") { + return Response.json({ error: "personaId is required" }, { status: 400 }); + } + + // Verify persona exists + const persona = getPersona(personaId); + if (!persona) { + return Response.json({ error: "Invalid persona" }, { status: 400 }); + } + + // Get user from DB to check limits + const dbUser = await prisma.user.findUnique({ + where: { id: user.userId }, + }); + + if (!dbUser) { + return Response.json({ error: "User not found" }, { status: 404 }); + } + + const tier = getUserTier(dbUser.isPremium, dbUser.isPro); + const limits = DAILY_LIMITS[tier]; + + // Check persona access for this tier + if (!limits.personas.includes(personaId)) { + const tierName = tier.charAt(0).toUpperCase() + tier.slice(1); + return Response.json( + { + error: `This persona requires ${tier === "free" ? "Premium" : "Pro"} subscription`, + tier, + requiredTier: tier === "free" ? "premium" : "pro", + }, + { status: 403 } + ); + } + + // Check daily message limit + const now = new Date(); + let dailyMsgCount = dbUser.dailyMsgCount; + let dailyMsgResetAt = dbUser.dailyMsgResetAt; + + // Reset count if a new day + if (!dailyMsgResetAt || now.getTime() - dailyMsgResetAt.getTime() > 24 * 60 * 60 * 1000) { + dailyMsgCount = 0; + dailyMsgResetAt = now; + } + + if (limits.msgs !== Infinity && dailyMsgCount >= limits.msgs) { + return Response.json( + { + error: "Daily message limit reached", + count: dailyMsgCount, + limit: limits.msgs, + resetAt: dailyMsgResetAt.toISOString(), + }, + { status: 429 } + ); + } + + // Determine the user's last message for context + const lastUserMessage = messages[messages.length - 1]?.content || ""; + + // Get recent history from DB for context (up to 10 recent messages) + const recentHistory = await prisma.chatHistory.findMany({ + where: { userId: user.userId }, + orderBy: { createdAt: "desc" }, + take: 10, + }); + const historyContext = recentHistory + .reverse() + .map((h) => ({ role: h.role, content: h.content })); + + // Save user message(s) to ChatHistory + for (const msg of messages) { + if (msg.role === "user") { + await prisma.chatHistory.create({ + data: { + userId: user.userId, + role: "user", + content: msg.content, + metadata: JSON.stringify({ personaId }), + }, + }); + } + } + + // Generate AI response + const aiContent = generateAIReply(personaId, lastUserMessage, historyContext); + + // Save AI response to ChatHistory + const aiMessage = await prisma.chatHistory.create({ + data: { + userId: user.userId, + role: "assistant", + content: aiContent, + metadata: JSON.stringify({ personaId }), + }, + }); + + // Update daily message count + const newCount = dailyMsgCount + messages.filter((m) => m.role === "user").length; + await prisma.user.update({ + where: { id: user.userId }, + data: { + dailyMsgCount: newCount, + dailyMsgResetAt: dailyMsgResetAt, + }, + }); + + let updatedUser: any = { dailyMsgCount: newCount }; + if (newCount >= limits.msgs - 2 && limits.msgs !== Infinity) { + updatedUser.limitWarning = true; + updatedUser.limitRemaining = limits.msgs - newCount; + } + + return Response.json({ + message: { + role: "assistant", + content: aiContent, + createdAt: aiMessage.createdAt.toISOString(), + id: aiMessage.id, + }, + daily: { + count: newCount, + limit: limits.msgs, + resetAt: dailyMsgResetAt.toISOString(), + }, + user: updatedUser, + }); + } catch (error) { + console.error("Chat send error:", error); + return Response.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/api/daily/verse/route.ts b/src/app/api/daily/verse/route.ts new file mode 100644 index 0000000..38fc0d1 --- /dev/null +++ b/src/app/api/daily/verse/route.ts @@ -0,0 +1,12 @@ +export const runtime = "nodejs"; + +import { DAILY_VERSE } from "@/lib/ai"; + +export async function GET() { + const verse = DAILY_VERSE[Math.floor(Math.random() * DAILY_VERSE.length)]; + + return Response.json({ + verse, + fetchedAt: new Date().toISOString(), + }); +} diff --git a/src/app/api/files/[listingId]/route.ts b/src/app/api/files/[listingId]/route.ts new file mode 100644 index 0000000..d1a8cae --- /dev/null +++ b/src/app/api/files/[listingId]/route.ts @@ -0,0 +1,85 @@ +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/categories/route.ts b/src/app/api/forum/categories/route.ts new file mode 100644 index 0000000..d35de2d --- /dev/null +++ b/src/app/api/forum/categories/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +export async function GET(_request: NextRequest) { + try { + const categories = await prisma.forumCategory.findMany({ + orderBy: { order: "asc" }, + }); + + return NextResponse.json({ categories }); + } catch (error) { + console.error("GET /api/forum/categories 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 new file mode 100644 index 0000000..15ccd6e --- /dev/null +++ b/src/app/api/forum/posts/route.ts @@ -0,0 +1,115 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireAuth } from "@/lib/auth"; +import { getUserTier, FORUM_LIMITS } from "@/lib/tiers"; + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const threadId = searchParams.get("threadId"); + + if (!threadId) { + return NextResponse.json( + { error: "threadId query parameter is required" }, + { status: 400 } + ); + } + + // Verify thread exists + const thread = await prisma.forumThread.findUnique({ + where: { id: threadId }, + }); + + if (!thread) { + return NextResponse.json( + { error: "Thread not found" }, + { status: 404 } + ); + } + + const posts = await prisma.forumPost.findMany({ + where: { threadId }, + include: { + author: { + select: { id: true, name: true, isPremium: true, isPro: true }, + }, + }, + orderBy: { createdAt: "asc" }, + }); + + return NextResponse.json({ posts }); + } catch (error) { + console.error("GET /api/forum/posts 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 = FORUM_LIMITS[tier]; + + if (!limits.canPost) { + return NextResponse.json( + { error: "Your tier does not support posting. Upgrade to Premium or Pro to participate in discussions." }, + { status: 403 } + ); + } + + const { content, threadId } = await request.json(); + + if (!content || !threadId) { + return NextResponse.json( + { error: "Missing required fields: content, threadId" }, + { status: 400 } + ); + } + + // Verify thread exists + const thread = await prisma.forumThread.findUnique({ + where: { id: threadId }, + }); + + if (!thread) { + return NextResponse.json( + { error: "Thread not found" }, + { status: 404 } + ); + } + + const post = await prisma.forumPost.create({ + data: { + content, + threadId, + authorId: user.id, + shariahStatus: "pending", + }, + include: { + author: { + select: { id: true, name: true, isPremium: true, isPro: true }, + }, + }, + }); + + return NextResponse.json({ post }, { status: 201 }); + } catch (error) { + console.error("POST /api/forum/posts error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/forum/threads/route.ts b/src/app/api/forum/threads/route.ts new file mode 100644 index 0000000..cdca20f --- /dev/null +++ b/src/app/api/forum/threads/route.ts @@ -0,0 +1,118 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireAuth } from "@/lib/auth"; +import { getUserTier, FORUM_LIMITS } from "@/lib/tiers"; + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const categoryId = searchParams.get("categoryId"); + + const where: Record = {}; + + if (categoryId) { + where.categoryId = categoryId; + } + + const threads = await prisma.forumThread.findMany({ + where, + include: { + author: { + select: { id: true, name: true, isPremium: true, isPro: true }, + }, + category: { + select: { id: true, name: true, icon: true }, + }, + _count: { + select: { posts: true }, + }, + }, + orderBy: [ + { pinned: "desc" }, + { createdAt: "desc" }, + ], + }); + + return NextResponse.json({ threads }); + } catch (error) { + console.error("GET /api/forum/threads 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 = FORUM_LIMITS[tier]; + + if (!limits.canPost) { + return NextResponse.json( + { error: "Your tier does not support posting. Upgrade to Premium or Pro to create threads." }, + { status: 403 } + ); + } + + const { title, content, categoryId } = await request.json(); + + if (!title || !content || !categoryId) { + return NextResponse.json( + { error: "Missing required fields: title, content, categoryId" }, + { status: 400 } + ); + } + + // Verify category exists + const category = await prisma.forumCategory.findUnique({ + where: { id: categoryId }, + }); + + if (!category) { + return NextResponse.json( + { error: "Invalid category" }, + { status: 400 } + ); + } + + const thread = await prisma.forumThread.create({ + data: { + title, + content, + categoryId, + authorId: user.id, + shariahStatus: "pending", + }, + include: { + author: { + select: { id: true, name: true, isPremium: true, isPro: true }, + }, + category: { + select: { id: true, name: true, icon: true }, + }, + _count: { + select: { posts: true }, + }, + }, + }); + + return NextResponse.json({ thread }, { status: 201 }); + } catch (error) { + console.error("POST /api/forum/threads error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/halal/bookmarks/route.ts b/src/app/api/halal/bookmarks/route.ts new file mode 100644 index 0000000..ef71ca1 --- /dev/null +++ b/src/app/api/halal/bookmarks/route.ts @@ -0,0 +1,129 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireAuth } from "@/lib/auth"; + +// Mock place data lookup for enriching bookmarks +const MOCK_PLACES: Record = { + "mosque-1": { id: "mosque-1", name: "Masjid Negara", address: "Jalan Perdana, Tasik Perdana, 50480 Kuala Lumpur", lat: 3.1422, lng: 101.6929, rating: 4.7, type: "mosque", halal_certified: true }, + "mosque-2": { id: "mosque-2", name: "Masjid Jamek Sultan Abdul Samad", address: "Jalan Tun Perak, 50050 Kuala Lumpur", lat: 3.1492, lng: 101.6958, rating: 4.5, type: "mosque", halal_certified: true }, + "mosque-3": { id: "mosque-3", name: "Masjid Wilayah Persekutuan", address: "Jalan Masjid, 50546 Kuala Lumpur", lat: 3.1723, lng: 101.6887, rating: 4.8, type: "mosque", halal_certified: true }, + "mosque-4": { id: "mosque-4", name: "Masjid Putra", address: "Persiaran Persekutuan, Presint 1, 62502 Putrajaya", lat: 2.9374, lng: 101.6888, rating: 4.9, type: "mosque", halal_certified: true }, + "mosque-5": { id: "mosque-5", name: "Masjid Zahir", address: "Jalan Sultan Badlishah, 05400 Alor Setar, Kedah", lat: 6.1227, lng: 100.3663, rating: 4.6, type: "mosque", halal_certified: true }, + "rest-1": { id: "rest-1", name: "Nasi Kandar Pelita", address: "No. 17, Jalan Telawi 3, Bangsar Baru, 59100 Kuala Lumpur", lat: 3.1295, lng: 101.6708, rating: 4.2, type: "restaurant", halal_certified: true }, + "rest-2": { id: "rest-2", name: "Restoran Ana Ikan Bakar Petai", address: "Jalan Cempaka, Kampung Datuk Keramat, 54000 Kuala Lumpur", lat: 3.1625, lng: 101.7319, rating: 4.3, type: "restaurant", halal_certified: true }, + "rest-3": { id: "rest-3", name: "Satey Zainab", address: "Jalan Tun Razak, 50400 Kuala Lumpur", lat: 3.1547, lng: 101.7112, rating: 4.1, type: "restaurant", halal_certified: true }, + "rest-4": { id: "rest-4", name: "Murni Discovery", address: "No. 8, Jalan SS2/75, SS2, 47300 Petaling Jaya", lat: 3.1187, lng: 101.6263, rating: 4.0, type: "restaurant", halal_certified: true }, + "rest-5": { id: "rest-5", name: "Nazeer's Banana Leaf", address: "No. 6, Jalan Kamuning, Off Jalan Imbi, 55100 Kuala Lumpur", lat: 3.1432, lng: 101.7115, rating: 4.4, type: "restaurant", halal_certified: true }, +}; + +export async function GET(request: NextRequest) { + try { + const auth = await requireAuth(request); + if (!auth) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const bookmarks = await prisma.halalBookmark.findMany({ + where: { userId: auth.userId }, + orderBy: { createdAt: "desc" }, + }); + + // Enrich bookmarks with place info + const enriched = bookmarks.map((bm) => ({ + ...bm, + place: MOCK_PLACES[bm.itemId] ?? null, + })); + + return NextResponse.json({ bookmarks: enriched }); + } catch (error) { + console.error("GET /api/halal/bookmarks 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 { itemId, itemType, label } = await request.json(); + if (!itemId || !itemType) { + return NextResponse.json( + { error: "Missing required fields: itemId, itemType" }, + { status: 400 } + ); + } + + // Check for duplicate + const existing = await prisma.halalBookmark.findFirst({ + where: { userId: auth.userId, itemId, itemType }, + }); + if (existing) { + return NextResponse.json( + { error: "Bookmark already exists", bookmark: existing }, + { status: 409 } + ); + } + + const bookmark = await prisma.halalBookmark.create({ + data: { + userId: auth.userId, + itemId, + itemType, + label: label ?? null, + }, + }); + + return NextResponse.json({ bookmark }, { status: 201 }); + } catch (error) { + console.error("POST /api/halal/bookmarks error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +export async function DELETE(request: NextRequest) { + try { + const auth = await requireAuth(request); + if (!auth) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const id = searchParams.get("id"); + if (!id) { + return NextResponse.json( + { error: "Missing bookmark id" }, + { status: 400 } + ); + } + + // Verify ownership + const bookmark = await prisma.halalBookmark.findFirst({ + where: { id, userId: auth.userId }, + }); + if (!bookmark) { + return NextResponse.json( + { error: "Bookmark not found" }, + { status: 404 } + ); + } + + await prisma.halalBookmark.delete({ where: { id } }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("DELETE /api/halal/bookmarks error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/halal/places/route.ts b/src/app/api/halal/places/route.ts new file mode 100644 index 0000000..a4b2f24 --- /dev/null +++ b/src/app/api/halal/places/route.ts @@ -0,0 +1,47 @@ +import { NextRequest, NextResponse } from "next/server"; + +const MOCK_PLACES = [ + // Mosques + { id: "mosque-1", name: "Masjid Negara", address: "Jalan Perdana, Tasik Perdana, 50480 Kuala Lumpur", lat: 3.1422, lng: 101.6929, rating: 4.7, type: "mosque", halal_certified: true }, + { id: "mosque-2", name: "Masjid Jamek Sultan Abdul Samad", address: "Jalan Tun Perak, 50050 Kuala Lumpur", lat: 3.1492, lng: 101.6958, rating: 4.5, type: "mosque", halal_certified: true }, + { id: "mosque-3", name: "Masjid Wilayah Persekutuan", address: "Jalan Masjid, 50546 Kuala Lumpur", lat: 3.1723, lng: 101.6887, rating: 4.8, type: "mosque", halal_certified: true }, + { id: "mosque-4", name: "Masjid Putra", address: "Persiaran Persekutuan, Presint 1, 62502 Putrajaya", lat: 2.9374, lng: 101.6888, rating: 4.9, type: "mosque", halal_certified: true }, + { id: "mosque-5", name: "Masjid Zahir", address: "Jalan Sultan Badlishah, 05400 Alor Setar, Kedah", lat: 6.1227, lng: 100.3663, rating: 4.6, type: "mosque", halal_certified: true }, + + // Restaurants + { id: "rest-1", name: "Nasi Kandar Pelita", address: "No. 17, Jalan Telawi 3, Bangsar Baru, 59100 Kuala Lumpur", lat: 3.1295, lng: 101.6708, rating: 4.2, type: "restaurant", halal_certified: true }, + { id: "rest-2", name: "Restoran Ana Ikan Bakar Petai", address: "Jalan Cempaka, Kampung Datuk Keramat, 54000 Kuala Lumpur", lat: 3.1625, lng: 101.7319, rating: 4.3, type: "restaurant", halal_certified: true }, + { id: "rest-3", name: "Satey Zainab", address: "Jalan Tun Razak, 50400 Kuala Lumpur", lat: 3.1547, lng: 101.7112, rating: 4.1, type: "restaurant", halal_certified: true }, + { id: "rest-4", name: "Murni Discovery", address: "No. 8, Jalan SS2/75, SS2, 47300 Petaling Jaya", lat: 3.1187, lng: 101.6263, rating: 4.0, type: "restaurant", halal_certified: true }, + { id: "rest-5", name: "Nazeer's Banana Leaf", address: "No. 6, Jalan Kamuning, Off Jalan Imbi, 55100 Kuala Lumpur", lat: 3.1432, lng: 101.7115, rating: 4.4, type: "restaurant", halal_certified: true }, +]; + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const q = searchParams.get("q")?.toLowerCase(); + const type = searchParams.get("type")?.toLowerCase(); + + let filtered = [...MOCK_PLACES]; + + if (type && ["mosque", "restaurant", "cafe"].includes(type)) { + filtered = filtered.filter((p) => p.type === type); + } + + if (q) { + filtered = filtered.filter( + (p) => + p.name.toLowerCase().includes(q) || + p.address.toLowerCase().includes(q) + ); + } + + return NextResponse.json({ places: filtered }); + } catch (error) { + console.error("GET /api/halal/places error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/halal/usage/route.ts b/src/app/api/halal/usage/route.ts new file mode 100644 index 0000000..4797714 --- /dev/null +++ b/src/app/api/halal/usage/route.ts @@ -0,0 +1,129 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireAuth } from "@/lib/auth"; +import { getUserTier, HALAL_LIMITS } from "@/lib/tiers"; + +export async function GET(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 }, + include: { halalUsage: true }, + }); + if (!user) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + const tier = getUserTier(user.isPremium, user.isPro); + const limits = HALAL_LIMITS[tier]; + + // If no usage record exists yet, return defaults + const usage = user.halalUsage; + const queriesUsed = usage?.queriesUsed ?? 0; + const queriesLimit = usage?.queriesLimit ?? limits.queriesPerDay; + const periodEnd = usage?.periodEnd?.toISOString() ?? null; + + const remaining = Math.max(0, queriesLimit - queriesUsed); + + return NextResponse.json({ + queriesUsed, + queriesLimit, + remaining, + periodEnd, + tier, + scope: limits.scope, + }); + } catch (error) { + console.error("GET /api/halal/usage 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 }, + include: { halalUsage: true }, + }); + if (!user) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + const tier = getUserTier(user.isPremium, user.isPro); + const limits = HALAL_LIMITS[tier]; + + // Get or create usage record + let usage = user.halalUsage; + + // Check if period has expired — reset if so + const now = new Date(); + if (usage && usage.periodEnd && new Date(usage.periodEnd) < now) { + usage = await prisma.halalUsage.update({ + where: { userId: user.id }, + data: { + queriesUsed: 0, + queriesLimit: limits.queriesPerDay, + periodEnd: new Date(now.getTime() + 24 * 60 * 60 * 1000), + }, + }); + } + + if (!usage) { + usage = await prisma.halalUsage.create({ + data: { + userId: user.id, + queriesUsed: 0, + queriesLimit: limits.queriesPerDay, + periodEnd: new Date(now.getTime() + 24 * 60 * 60 * 1000), + }, + }); + } + + // Check if allowed + if (usage.queriesUsed >= usage.queriesLimit && limits.queriesPerDay !== Infinity) { + return NextResponse.json( + { + allowed: false, + remaining: 0, + queriesUsed: usage.queriesUsed, + queriesLimit: usage.queriesLimit, + error: "Daily query limit reached. Upgrade to Premium for unlimited queries.", + }, + { status: 429 } + ); + } + + // Increment usage + const updated = await prisma.halalUsage.update({ + where: { userId: user.id }, + data: { queriesUsed: { increment: 1 } }, + }); + + const remaining = Math.max(0, updated.queriesLimit - updated.queriesUsed); + + return NextResponse.json({ + allowed: true, + remaining, + queriesUsed: updated.queriesUsed, + queriesLimit: updated.queriesLimit, + }); + } catch (error) { + console.error("POST /api/halal/usage error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts new file mode 100644 index 0000000..6d80fd2 --- /dev/null +++ b/src/app/api/health/route.ts @@ -0,0 +1,19 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +export async function GET() { + let dbOk = false; + try { + await prisma.$queryRaw`SELECT 1`; + dbOk = true; + } catch { + dbOk = false; + } + + return NextResponse.json({ + status: "ok", + timestamp: new Date().toISOString(), + db: dbOk, + uptime: process.uptime(), + }); +} diff --git a/src/app/api/marketplace/feature/route.ts b/src/app/api/marketplace/feature/route.ts new file mode 100644 index 0000000..7eb0ae0 --- /dev/null +++ b/src/app/api/marketplace/feature/route.ts @@ -0,0 +1,98 @@ +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 new file mode 100644 index 0000000..9eb33b6 --- /dev/null +++ b/src/app/api/marketplace/listings/route.ts @@ -0,0 +1,131 @@ +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 new file mode 100644 index 0000000..310bb92 --- /dev/null +++ b/src/app/api/marketplace/purchase/route.ts @@ -0,0 +1,128 @@ +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, 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(false, listing.seller.isPro); + const platformFeeRate = sellerTier === "pro" ? 0 : 0.015; + const platformFee = Math.round(listing.priceFlh * platformFeeRate); + const sellerPayout = listing.priceFlh - platformFee; + + // 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 + 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" }, + }), + ]); + + return NextResponse.json( + { + purchase, + message: `Successfully purchased "${listing.title}" for ${listing.priceFlh} FLH. Platform fee: ${platformFee} FLH. Seller receives: ${sellerPayout} FLH.`, + }, + { 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/nurbuddy-health/route.ts b/src/app/api/nurbuddy-health/route.ts new file mode 100644 index 0000000..d5b021a --- /dev/null +++ b/src/app/api/nurbuddy-health/route.ts @@ -0,0 +1,10 @@ +import { NextResponse } from "next/server"; + +export async function GET() { + return NextResponse.json({ + status: "online", + model: "nurbuddy-v1", + personas_available: 6, + uptime: process.uptime(), + }); +} diff --git a/src/app/api/seed/route.ts b/src/app/api/seed/route.ts new file mode 100644 index 0000000..1c48137 --- /dev/null +++ b/src/app/api/seed/route.ts @@ -0,0 +1,48 @@ +import { NextResponse } from "next/server"; +import bcrypt from "bcryptjs"; +import { prisma } from "@/lib/prisma"; + +export async function POST() { + try { + const existing = await prisma.user.findUnique({ + where: { email: "demo@falah.app" }, + }); + + if (existing) { + return NextResponse.json( + { message: "Demo user already exists", userId: existing.id }, + { status: 200 } + ); + } + + const demoPasswordHash = await bcrypt.hash("password123", 12); + + const demoUser = await prisma.user.create({ + data: { + email: "demo@falah.app", + name: "Demo User", + passwordHash: demoPasswordHash, + flhBalance: 10000, + isPremium: true, + trialEndsAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), + preferredName: "Demo", + coachingGoals: "Learn more about Islam and improve my daily prayers", + }, + }); + + return NextResponse.json( + { + message: "Demo user created successfully", + userId: demoUser.id, + credentials: { email: "demo@falah.app", password: "password123" }, + }, + { status: 201 } + ); + } catch (error) { + console.error("Seed error:", error); + return NextResponse.json( + { error: "Seeding failed. Please try again." }, + { status: 500 } + ); + } +} diff --git a/src/app/api/seller/[sellerId]/route.ts b/src/app/api/seller/[sellerId]/route.ts new file mode 100644 index 0000000..c4c6bee --- /dev/null +++ b/src/app/api/seller/[sellerId]/route.ts @@ -0,0 +1,54 @@ +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/upgrade/create-checkout/route.ts b/src/app/api/upgrade/create-checkout/route.ts new file mode 100644 index 0000000..331a47f --- /dev/null +++ b/src/app/api/upgrade/create-checkout/route.ts @@ -0,0 +1,108 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireAuth } from "@/lib/auth"; +import { PREMIUM_PRICES } from "@/lib/tiers"; + +export async function POST(req: NextRequest) { + const jwtPayload = await requireAuth(req); + if (!jwtPayload) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const body = await req.json(); + const { priceId } = body; + + if (!priceId) { + return NextResponse.json( + { error: "priceId is required" }, + { status: 400 } + ); + } + + // Determine which tier this priceId maps to + let tier: "premium" | "pro" | null = null; + for (const [key, val] of Object.entries(PREMIUM_PRICES)) { + if (val.priceId === priceId) { + tier = key as "premium" | "pro"; + break; + } + } + + if (!tier) { + return NextResponse.json( + { error: "Invalid priceId" }, + { status: 400 } + ); + } + + // Check if user already has this tier (or higher) + const user = await prisma.user.findUnique({ + where: { id: jwtPayload.userId }, + }); + + if (!user) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + if (tier === "pro" && user.isPro) { + return NextResponse.json( + { error: "You are already a Pro member" }, + { status: 400 } + ); + } + + if (tier === "premium" && (user.isPremium || user.isPro)) { + return NextResponse.json( + { error: "You already have Premium or higher" }, + { status: 400 } + ); + } + + // In production, this would create a Polar.sh checkout session. + // For development, we mock the flow by marking the user as premium/pro. + const useMock = process.env.MOCK_PAYMENTS !== "false"; + + if (useMock) { + const trialEndsAt = new Date(); + trialEndsAt.setDate(trialEndsAt.getDate() + 7); + + const updateData: Record = { + trialEndsAt, + }; + + if (tier === "pro") { + updateData.isPro = true; + } else { + updateData.isPremium = true; + } + + await prisma.user.update({ + where: { id: jwtPayload.userId }, + data: updateData, + }); + + return NextResponse.json({ + success: true, + url: `/upgrade?upgrade=success&tier=${tier}`, + mock: true, + tier, + }); + } + + // Production path — would create a real Polar checkout session + // const polarSession = await createPolarCheckout({ priceId, userId: jwtPayload.userId }); + + return NextResponse.json({ + success: true, + url: `/upgrade?upgrade=success&tier=${tier}`, + tier, + }); + } catch (error) { + console.error("Upgrade checkout error:", error); + return NextResponse.json( + { error: "Failed to create checkout" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/wallet/route.ts b/src/app/api/wallet/route.ts new file mode 100644 index 0000000..e70e1d3 --- /dev/null +++ b/src/app/api/wallet/route.ts @@ -0,0 +1,104 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireAuth } from "@/lib/auth"; + +export async function GET(req: NextRequest) { + const jwtPayload = await requireAuth(req); + if (!jwtPayload) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const user = await prisma.user.findUnique({ + where: { id: jwtPayload.userId }, + include: { + cashouts: { + orderBy: { createdAt: "desc" }, + take: 50, + }, + }, + }); + + if (!user) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + return NextResponse.json({ + balance: user.flhBalance, + cashoutHistory: user.cashouts.map((c) => ({ + id: c.id, + amountFlh: c.amountFlh, + fiatAmount: c.fiatAmount, + status: c.status, + createdAt: c.createdAt, + })), + }); +} + +export async function POST(req: NextRequest) { + const jwtPayload = await requireAuth(req); + if (!jwtPayload) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const body = await req.json(); + const { amountFlh } = body; + + if (!amountFlh || typeof amountFlh !== "number" || amountFlh < 1000) { + return NextResponse.json( + { error: "Minimum cashout is 1,000 FLH" }, + { status: 400 } + ); + } + + const user = await prisma.user.findUnique({ + where: { id: jwtPayload.userId }, + }); + + if (!user) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + if (user.flhBalance < amountFlh) { + return NextResponse.json( + { error: "Insufficient FLH balance" }, + { status: 400 } + ); + } + + // Fiat conversion: 1000 FLH = $1.00 + const fiatAmount = Math.round((amountFlh / 1000) * 100) / 100; + + const cashout = await prisma.cashoutRequest.create({ + data: { + userId: user.id, + amountFlh, + fiatAmount, + status: "pending", + }, + }); + + // Deduct balance immediately + await prisma.user.update({ + where: { id: user.id }, + data: { flhBalance: { decrement: amountFlh } }, + }); + + return NextResponse.json({ + success: true, + cashout: { + id: cashout.id, + amountFlh: cashout.amountFlh, + fiatAmount: cashout.fiatAmount, + status: cashout.status, + createdAt: cashout.createdAt, + }, + }); + } catch (error) { + console.error("Cashout error:", error); + return NextResponse.json( + { error: "Cashout request failed" }, + { 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 new file mode 100644 index 0000000..af401de --- /dev/null +++ b/src/app/api/wallet/top-up/create-checkout/route.ts @@ -0,0 +1,70 @@ +import { NextRequest, NextResponse } from "next/server"; +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 }, +} as const; + +export async function POST(req: NextRequest) { + const jwtPayload = await requireAuth(req); + if (!jwtPayload) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const body = await req.json(); + const { amount } = body; + + if (!amount || !(amount in TOP_UP_AMOUNTS)) { + return NextResponse.json( + { error: "Invalid amount. Choose 500, 1100, or 3000 FLH." }, + { status: 400 } + ); + } + + const pricing = TOP_UP_AMOUNTS[amount as keyof typeof TOP_UP_AMOUNTS]; + + // 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) { + await prisma.user.update({ + where: { id: jwtPayload.userId }, + data: { flhBalance: { increment: amount } }, + }); + + return NextResponse.json({ + success: true, + url: "/wallet?topup=success", + mock: true, + amount, + amountUsd: pricing.usd, + }); + } + + // Production path — Polar checkout creation + // const polarSession = await createPolarCheckout({ + // amount, + // customerId: user.stripeCustomerId, + // metadata: { userId: jwtPayload.userId, type: "top-up", amount }, + // }); + + return NextResponse.json({ + success: true, + url: "/wallet?topup=success", // would be polarSession.url + amount, + amountUsd: pricing.usd, + }); + } catch (error) { + console.error("Top-up checkout error:", error); + return NextResponse.json( + { error: "Failed to create checkout" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/webhooks/polar/route.ts b/src/app/api/webhooks/polar/route.ts new file mode 100644 index 0000000..d59a51b --- /dev/null +++ b/src/app/api/webhooks/polar/route.ts @@ -0,0 +1,118 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +/** + * Polar.sh webhook handler. + * + * In production, verify the webhook signature using Polar's webhook secret. + * For development, we accept a direct ?userId=&tier= fallback for testing. + */ + +export async function POST(req: NextRequest) { + try { + // Check for direct fallback query params (dev/testing) + const url = new URL(req.url); + const directUserId = url.searchParams.get("userId"); + const directTier = url.searchParams.get("tier"); + + if (directUserId && directTier) { + return handleDirectUpgrade(directUserId, directTier); + } + + // Production path: Parse Polar webhook event + const body = await req.json(); + const event = body.type || body.event; + + if (!event) { + return NextResponse.json( + { error: "Missing event type" }, + { status: 400 } + ); + } + + // Handle checkout.completed event + if ( + event === "checkout.completed" || + event === "checkout.updated" + ) { + const checkout = body.data?.checkout || body.data; + if (!checkout) { + return NextResponse.json( + { error: "Missing checkout data" }, + { status: 400 } + ); + } + + const userId = checkout.metadata?.userId; + const priceId = checkout.product?.priceId || + checkout.productPrice?.id || + checkout.metadata?.priceId; + + if (!userId) { + return NextResponse.json( + { error: "Missing userId in metadata" }, + { status: 400 } + ); + } + + // Determine tier from priceId or product metadata + let tier: "premium" | "pro" = "premium"; + if (priceId?.includes("pro") || checkout.metadata?.tier === "pro") { + tier = "pro"; + } + + return applyUpgrade(userId, tier); + } + + // Acknowledge other events silently + return NextResponse.json({ received: true }); + } catch (error) { + console.error("Polar webhook error:", error); + return NextResponse.json( + { error: "Webhook processing failed" }, + { status: 500 } + ); + } +} + +async function handleDirectUpgrade(userId: string, tier: string) { + if (tier !== "premium" && tier !== "pro") { + return NextResponse.json( + { error: "Invalid tier. Must be 'premium' or 'pro'." }, + { status: 400 } + ); + } + return applyUpgrade(userId, tier as "premium" | "pro"); +} + +async function applyUpgrade(userId: string, tier: "premium" | "pro") { + const user = await prisma.user.findUnique({ where: { id: userId } }); + if (!user) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + const trialEndsAt = new Date(); + trialEndsAt.setDate(trialEndsAt.getDate() + 7); + + const updateData: Record = { + trialEndsAt, + }; + + if (tier === "pro") { + updateData.isPro = true; + updateData.isPremium = false; // Pro supersedes Premium + } else { + updateData.isPremium = true; + } + + await prisma.user.update({ + where: { id: userId }, + data: updateData, + }); + + return NextResponse.json({ + success: true, + tier, + trialEndsAt, + }); +} diff --git a/src/app/forum/[threadId]/page.tsx b/src/app/forum/[threadId]/page.tsx new file mode 100644 index 0000000..2293a9a --- /dev/null +++ b/src/app/forum/[threadId]/page.tsx @@ -0,0 +1,417 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { useAuth } from "@/lib/AuthContext"; +import { useRouter, useParams } from "next/navigation"; +import { + ArrowLeft, + Crown, + Star, + Clock, + AlertTriangle, + CheckCircle, + Loader2, + Lock, + Send, + ShieldCheck, + Flag, +} from "lucide-react"; + +interface AuthorInfo { + id: string; + name: string; + isPremium: boolean; + isPro: boolean; +} + +interface ThreadDetail { + id: string; + title: string; + content: string; + categoryId: string; + pinned: boolean; + shariahStatus: "pending" | "approved" | "rejected"; + shariahFlags?: string; + createdAt: string; + author: AuthorInfo; + category: { + id: string; + name: string; + icon?: string; + }; +} + +interface Post { + id: string; + content: string; + threadId: string; + shariahStatus: "pending" | "approved" | "rejected"; + shariahFlags?: string; + createdAt: string; + author: AuthorInfo; +} + +const SHARIAH_LABELS: Record = { + pending: { label: "Pending Review", color: "text-amber-400", icon: Clock }, + approved: { label: "Shariah Compliant", color: "text-emerald-400", icon: ShieldCheck }, + rejected: { label: "Flagged", color: "text-red-400", icon: Flag }, +}; + +export default function ThreadDetailPage() { + const { user, token, loading } = useAuth(); + const router = useRouter(); + const params = useParams(); + const threadId = params.threadId as string; + + const [thread, setThread] = useState(null); + const [posts, setPosts] = useState([]); + const [loadingData, setLoadingData] = useState(true); + const [error, setError] = useState(null); + + // Reply form + const [replyContent, setReplyContent] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [replyError, setReplyError] = useState(null); + + const canPost = user?.isPremium || user?.isPro; + + const fetchThread = useCallback(async () => { + try { + const res = await fetch(`/api/forum/threads`); + const data = await res.json(); + if (res.ok) { + const found = data.threads.find((t: ThreadDetail) => t.id === threadId); + if (found) { + setThread(found); + } else { + // Try fetching thread detail individually (use threads endpoint with the thread) + // Since we don't have a single-thread endpoint, we'll get it from the list + } + } + } catch { + // ignore + } + }, [threadId]); + + const fetchPosts = useCallback(async () => { + setLoadingData(true); + setError(null); + try { + const res = await fetch(`/api/forum/posts?threadId=${threadId}`); + const data = await res.json(); + if (res.ok) { + setPosts(data.posts); + } else { + setError(data.error || "Failed to load posts"); + } + } catch { + setError("Network error. Please try again."); + } finally { + setLoadingData(false); + } + }, [threadId]); + + useEffect(() => { + if (!loading && !token) { + router.push("/login"); + return; + } + }, [loading, token, router]); + + useEffect(() => { + if (token && threadId) { + fetchThread(); + fetchPosts(); + } + }, [token, threadId, fetchThread, fetchPosts]); + + const handleReply = async (e: React.FormEvent) => { + e.preventDefault(); + if (!replyContent.trim()) return; + + setReplyError(null); + setSubmitting(true); + + try { + const res = await fetch("/api/forum/posts", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + content: replyContent.trim(), + threadId, + }), + }); + + const data = await res.json(); + if (res.ok) { + setReplyContent(""); + fetchPosts(); + } else { + setReplyError(data.error || "Failed to post reply"); + } + } catch { + setReplyError("Network error. Please try again."); + } finally { + setSubmitting(false); + } + }; + + 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(); + } + + if (loading) { + return ( +
+
+
+ ); + } + + if (!user) return null; + + return ( +
+ {/* Header */} +
+
+ +

+ {thread ? thread.title : "Thread"} +

+
+
+ + {loadingData ? ( +
+ +

Loading thread...

+
+ ) : error ? ( +
+ +

{error}

+ +
+ ) : !thread ? ( +
+

Thread not found

+ +
+ ) : ( + <> + {/* Original thread post */} +
+
+ {/* Author + category */} +
+
+ {thread.author.name.charAt(0).toUpperCase()} +
+
+
+ + {thread.author.name} + + {thread.author.isPro && ( + + )} + {thread.author.isPremium && !thread.author.isPro && ( + + )} +
+
+ {thread.category.name} + · + {timeAgo(thread.createdAt)} + {/* Shariah compliance */} + · +
+ {(() => { + const Icon = SHARIAH_LABELS[thread.shariahStatus].icon; + return ; + })()} + {SHARIAH_LABELS[thread.shariahStatus].label} +
+
+
+
+ + {/* Title & content */} +

{thread.title}

+

+ {thread.content} +

+
+
+ + {/* Posts list */} +
+ {posts.length > 0 && ( +
+ + + {posts.length} {posts.length === 1 ? "Reply" : "Replies"} + +
+ )} + + {posts.map((post) => { + const shariahInfo = SHARIAH_LABELS[post.shariahStatus]; + const ShariahIcon = shariahInfo.icon; + return ( +
+ {/* Author info */} +
+
+ {post.author.name.charAt(0).toUpperCase()} +
+
+
+ + {post.author.name} + + {post.author.isPro && ( + + )} + {post.author.isPremium && !post.author.isPro && ( + + )} +
+
+
+ + {timeAgo(post.createdAt)} +
+
+ + {/* Content */} +

+ {post.content} +

+ + {/* Shariah compliance label */} +
+ + {shariahInfo.label} +
+
+ ); + })} +
+ + {/* Reply form — premium gated for free users */} +
+ {!canPost ? ( +
+ +

Premium Feature

+

+ Upgrade to Premium or Pro to join the discussion and reply to threads. +

+ +
+ ) : ( +
+
+