Initial Falah Mobile rebuild — all 7 blocks complete

- 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
This commit is contained in:
root
2026-06-15 09:28:22 +02:00
parent ab8a2053e1
commit 5483dd291e
56 changed files with 7619 additions and 100 deletions
+40
View File
@@ -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"]
+22
View File
@@ -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:
+4 -1
View File
@@ -1,7 +1,10 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
output: "standalone",
experimental: {
optimizePackageImports: ["lucide-react"],
},
};
export default nextConfig;
+198 -3
View File
@@ -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": {
+10 -1
View File
@@ -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",
BIN
View File
Binary file not shown.
+161
View File
@@ -0,0 +1,161 @@
generator client {
provider = "prisma-client-js"
binaryTargets = ["native", "linux-musl", "debian-openssl-3.0.x"]
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
name String
passwordHash String?
flhBalance Int @default(5000)
isPro Boolean @default(false)
isPremium Boolean @default(false)
experienceLevel String?
madhab String?
coachPersona String?
preferredName String?
coachingGoals String?
lastCoachedAt DateTime?
dailyMsgCount Int @default(0)
dailyMsgResetAt DateTime?
stripeCustomerId String?
stripeSubscriptionId String?
trialEndsAt DateTime?
createdAt DateTime @default(now())
listings Listing[]
purchases Purchase[] @relation("BuyerPurchases")
sales Purchase[] @relation("SellerPurchases")
cashouts CashoutRequest[]
halalBookmarks HalalBookmark[]
halalUsage HalalUsage?
forumThreads ForumThread[]
forumPosts ForumPost[]
chatHistory ChatHistory[]
}
model Listing {
id String @id @default(cuid())
title String
description String
category String
priceFlh Int
sellerId String
seller User @relation(fields: [sellerId], references: [id])
status String @default("active")
featured Boolean @default(false)
featuredUntil DateTime?
fileType String?
createdAt DateTime @default(now())
purchases Purchase[]
}
model Purchase {
id String @id @default(cuid())
listingId String
listing Listing @relation(fields: [listingId], references: [id])
buyerId String
buyer User @relation("BuyerPurchases", fields: [buyerId], references: [id])
sellerId String
seller User @relation("SellerPurchases", fields: [sellerId], references: [id])
amountFlh Int
platformFee Int
sellerPayout Int
autoConfirmAt DateTime
createdAt DateTime @default(now())
}
model CashoutRequest {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id])
amountFlh Int
fiatAmount Float
status String @default("pending")
createdAt DateTime @default(now())
}
model ForumCategory {
id String @id @default(cuid())
name String @unique
description String?
icon String?
order Int?
createdAt DateTime @default(now())
threads ForumThread[]
}
model ForumThread {
id String @id @default(cuid())
title String
content String
categoryId String
category ForumCategory @relation(fields: [categoryId], references: [id])
authorId String
author User @relation(fields: [authorId], references: [id])
pinned Boolean @default(false)
shariahStatus String @default("pending")
shariahFlags String?
createdAt DateTime @default(now())
posts ForumPost[]
}
model ForumPost {
id String @id @default(cuid())
content String
threadId String
thread ForumThread @relation(fields: [threadId], references: [id])
authorId String
author User @relation(fields: [authorId], references: [id])
shariahStatus String @default("pending")
shariahFlags String?
createdAt DateTime @default(now())
}
model ChatHistory {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id])
role String
content String
metadata String?
createdAt DateTime @default(now())
@@index([userId])
}
model HalalBookmark {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id])
itemId String
itemType String
label String?
createdAt DateTime @default(now())
@@index([userId])
}
model HalalUsage {
userId String @id
user User @relation(fields: [userId], references: [id])
queriesUsed Int @default(0)
queriesLimit Int @default(100)
periodEnd DateTime?
}
model HalalPlaceCache {
id String @id @default(cuid())
cacheKey String @unique
data String
expiresAt DateTime
}
+59
View File
@@ -0,0 +1,59 @@
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, password } = await req.json();
if (!email || !password) {
return NextResponse.json(
{ error: "Email and password are required" },
{ status: 400 }
);
}
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,
});
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("Login error:", error);
return NextResponse.json(
{ error: "Login failed. Please try again." },
{ status: 500 }
);
}
}
+37
View File
@@ -0,0 +1,37 @@
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 },
});
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
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,
},
});
}
+70
View File
@@ -0,0 +1,70 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
const VALID_MADHABS = ["Hanafi", "Maliki", "Shafi'i", "Hanbali"];
export async function PATCH(req: NextRequest) {
const jwtPayload = await requireAuth(req);
if (!jwtPayload) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const body = await req.json();
const { name, preferredName, coachPersona, madhab, coachingGoals } = body;
if (madhab && !VALID_MADHABS.includes(madhab)) {
return NextResponse.json(
{
error: `Invalid madhab. Must be one of: ${VALID_MADHABS.join(", ")}`,
},
{ status: 400 }
);
}
const updateData: Record<string, string | undefined> = {};
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 }
);
}
}
+63
View File
@@ -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 }
);
}
}
+53
View File
@@ -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 });
}
}
+44
View File
@@ -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"],
});
}
@@ -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 });
}
}
+158
View File
@@ -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 });
}
}
+12
View File
@@ -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(),
});
}
+85
View File
@@ -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 }
);
}
}
+18
View File
@@ -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 }
);
}
}
+115
View File
@@ -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 }
);
}
}
+118
View File
@@ -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<string, unknown> = {};
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 }
);
}
}
+129
View File
@@ -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<string, any> = {
"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 }
);
}
}
+47
View File
@@ -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 }
);
}
}
+129
View File
@@ -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 }
);
}
}
+19
View File
@@ -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(),
});
}
+98
View File
@@ -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 }
);
}
}
+131
View File
@@ -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 }
);
}
}
+128
View File
@@ -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 }
);
}
}
+10
View File
@@ -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(),
});
}
+48
View File
@@ -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 }
);
}
}
+54
View File
@@ -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 }
);
}
}
@@ -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<string, unknown> = {
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 }
);
}
}
+104
View File
@@ -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 }
);
}
}
@@ -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 }
);
}
}
+118
View File
@@ -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<string, unknown> = {
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,
});
}
+417
View File
@@ -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<string, { label: string; color: string; icon: any }> = {
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<ThreadDetail | null>(null);
const [posts, setPosts] = useState<Post[]>([]);
const [loadingData, setLoadingData] = useState(true);
const [error, setError] = useState<string | null>(null);
// Reply form
const [replyContent, setReplyContent] = useState("");
const [submitting, setSubmitting] = useState(false);
const [replyError, setReplyError] = useState<string | null>(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 (
<div className="min-h-dvh bg-[#0a0a0f] flex items-center justify-center">
<div className="w-8 h-8 rounded-full border-2 border-[#D4AF37]/30 border-t-[#D4AF37] animate-spin" />
</div>
);
}
if (!user) return null;
return (
<div className="min-h-dvh bg-[#0a0a0f] pb-28">
{/* Header */}
<div className="sticky top-0 z-20 bg-[#0a0a0f]/90 backdrop-blur-lg border-b border-gray-800/40">
<div className="flex items-center gap-3 px-4 h-12">
<button
onClick={() => router.push("/forum")}
className="w-8 h-8 rounded-full bg-gray-800/60 flex items-center justify-center active:bg-gray-700/60 transition"
>
<ArrowLeft size={16} className="text-gray-400" />
</button>
<h1 className="text-sm font-semibold text-white truncate">
{thread ? thread.title : "Thread"}
</h1>
</div>
</div>
{loadingData ? (
<div className="flex flex-col items-center justify-center py-20">
<Loader2 size={32} className="text-[#D4AF37] animate-spin mb-3" />
<p className="text-sm text-gray-500">Loading thread...</p>
</div>
) : error ? (
<div className="mx-4 mt-4 bg-red-900/10 border border-red-800/30 rounded-2xl p-6 text-center">
<AlertTriangle size={32} className="mx-auto text-red-400 mb-2" />
<p className="text-sm text-red-300">{error}</p>
<button
onClick={() => { fetchThread(); fetchPosts(); }}
className="mt-3 text-xs text-[#D4AF37] underline"
>
Try again
</button>
</div>
) : !thread ? (
<div className="mx-4 mt-4 bg-gray-900/40 border border-gray-800/60 rounded-2xl p-6 text-center">
<p className="text-sm text-gray-400">Thread not found</p>
<button
onClick={() => router.push("/forum")}
className="mt-3 text-xs text-[#D4AF37] underline"
>
Back to forum
</button>
</div>
) : (
<>
{/* Original thread post */}
<div className="px-4 pt-4 pb-2">
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-4">
{/* Author + category */}
<div className="flex items-center gap-2 mb-3">
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-[#D4AF37]/20 to-gray-800/60 flex items-center justify-center text-xs font-bold text-[#D4AF37]">
{thread.author.name.charAt(0).toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-sm font-semibold text-white">
{thread.author.name}
</span>
{thread.author.isPro && (
<Crown size={12} className="text-purple-400" />
)}
{thread.author.isPremium && !thread.author.isPro && (
<Star size={12} className="text-[#D4AF37]" />
)}
</div>
<div className="flex items-center gap-1.5 text-[10px] text-gray-600">
<span className="text-gray-500">{thread.category.name}</span>
<span>·</span>
<span>{timeAgo(thread.createdAt)}</span>
{/* Shariah compliance */}
<span>·</span>
<div className={`flex items-center gap-0.5 ${SHARIAH_LABELS[thread.shariahStatus].color}`}>
{(() => {
const Icon = SHARIAH_LABELS[thread.shariahStatus].icon;
return <Icon size={9} />;
})()}
<span className="text-[9px]">{SHARIAH_LABELS[thread.shariahStatus].label}</span>
</div>
</div>
</div>
</div>
{/* Title & content */}
<h2 className="text-base font-bold text-white mb-2">{thread.title}</h2>
<p className="text-sm text-gray-300 leading-relaxed whitespace-pre-wrap">
{thread.content}
</p>
</div>
</div>
{/* Posts list */}
<div className="px-4 space-y-2.5">
{posts.length > 0 && (
<div className="flex items-center gap-2 px-1 py-2">
<MessageCircle size={14} className="text-gray-500" />
<span className="text-xs font-medium text-gray-500">
{posts.length} {posts.length === 1 ? "Reply" : "Replies"}
</span>
</div>
)}
{posts.map((post) => {
const shariahInfo = SHARIAH_LABELS[post.shariahStatus];
const ShariahIcon = shariahInfo.icon;
return (
<div
key={post.id}
className="bg-[#111118] border border-gray-800/60 rounded-2xl p-4 animate-fade-in"
>
{/* Author info */}
<div className="flex items-center gap-2 mb-2.5">
<div className="w-7 h-7 rounded-full bg-gradient-to-br from-gray-700 to-gray-800 flex items-center justify-center text-[10px] font-bold text-gray-300">
{post.author.name.charAt(0).toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-xs font-semibold text-white">
{post.author.name}
</span>
{post.author.isPro && (
<Crown size={10} className="text-purple-400" />
)}
{post.author.isPremium && !post.author.isPro && (
<Star size={10} className="text-[#D4AF37]" />
)}
</div>
</div>
<div className="flex items-center gap-1 text-[10px] text-gray-600">
<Clock size={9} />
<span>{timeAgo(post.createdAt)}</span>
</div>
</div>
{/* Content */}
<p className="text-sm text-gray-300 leading-relaxed whitespace-pre-wrap mb-2">
{post.content}
</p>
{/* Shariah compliance label */}
<div className={`flex items-center gap-0.5 ${shariahInfo.color}`}>
<ShariahIcon size={9} />
<span className="text-[9px]">{shariahInfo.label}</span>
</div>
</div>
);
})}
</div>
{/* Reply form — premium gated for free users */}
<div className="px-4 mt-4">
{!canPost ? (
<div className="bg-gradient-to-r from-amber-900/15 to-gray-900/50 border border-amber-800/20 rounded-2xl p-4 text-center">
<Lock size={20} className="mx-auto text-[#D4AF37] mb-2" />
<h3 className="text-sm font-semibold text-white mb-1">Premium Feature</h3>
<p className="text-xs text-gray-400 mb-3 max-w-xs mx-auto">
Upgrade to Premium or Pro to join the discussion and reply to threads.
</p>
<button
onClick={() => router.push("/profile")}
className="inline-flex items-center gap-1.5 bg-[#D4AF37]/15 border border-[#D4AF37]/30 rounded-xl px-4 py-2 text-xs font-medium text-[#D4AF37] active:bg-[#D4AF37]/25 transition"
>
<Crown size={12} />
Upgrade Now
</button>
</div>
) : (
<form onSubmit={handleReply} className="space-y-3">
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl overflow-hidden">
<textarea
value={replyContent}
onChange={(e) => setReplyContent(e.target.value)}
placeholder="Write your reply..."
maxLength={5000}
rows={3}
className="w-full bg-transparent px-4 py-3 text-sm text-white placeholder-gray-600 focus:outline-none resize-none"
/>
<div className="flex items-center justify-between px-4 pb-3">
<span className="text-[10px] text-gray-600">
{replyContent.length}/5000
</span>
<button
type="submit"
disabled={submitting || !replyContent.trim()}
className="flex items-center gap-1.5 bg-[#D4AF37] text-[#0a0a0f] rounded-xl px-4 py-2 text-xs font-semibold transition active:bg-[#c5a233] disabled:opacity-50"
>
{submitting ? (
<>
<Loader2 size={12} className="animate-spin" />
Posting...
</>
) : (
<>
<Send size={12} />
Reply
</>
)}
</button>
</div>
</div>
{replyError && (
<div className="bg-red-900/10 border border-red-800/30 rounded-xl p-3">
<p className="text-xs text-red-300">{replyError}</p>
</div>
)}
</form>
)}
</div>
</>
)}
</div>
);
}
function MessageCircle(props: any) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={props.size || 24}
height={props.size || 24}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
);
}
+560
View File
@@ -0,0 +1,560 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useAuth } from "@/lib/AuthContext";
import { useRouter } from "next/navigation";
import {
MessageSquare,
Search,
Plus,
X,
Crown,
Star,
Pin,
Clock,
AlertTriangle,
CheckCircle,
Loader2,
Lock,
MessageCircle,
FileText,
} from "lucide-react";
interface Category {
id: string;
name: string;
description?: string;
icon?: string;
order?: number;
}
interface Thread {
id: string;
title: string;
content: string;
categoryId: string;
pinned: boolean;
shariahStatus: "pending" | "approved" | "rejected";
shariahFlags?: string;
createdAt: string;
author: {
id: string;
name: string;
isPremium: boolean;
isPro: boolean;
};
category: {
id: string;
name: string;
icon?: string;
};
_count: {
posts: number;
};
}
const SHARIAH_LABELS: Record<string, { label: string; color: string; icon: any }> = {
pending: { label: "Pending Review", color: "text-amber-400", icon: Clock },
approved: { label: "Shariah Compliant", color: "text-emerald-400", icon: CheckCircle },
rejected: { label: "Flagged", color: "text-red-400", icon: AlertTriangle },
};
export default function ForumPage() {
const { user, token, loading } = useAuth();
const router = useRouter();
const [categories, setCategories] = useState<Category[]>([]);
const [threads, setThreads] = useState<Thread[]>([]);
const [loadingData, setLoadingData] = useState(true);
const [error, setError] = useState<string | null>(null);
// Filters
const [activeCategory, setActiveCategory] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
// Create thread modal
const [showCreateModal, setShowCreateModal] = useState(false);
const [createForm, setCreateForm] = useState({
title: "",
content: "",
categoryId: "",
});
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
const canPost = user?.isPremium || user?.isPro;
const fetchCategories = useCallback(async () => {
try {
const res = await fetch("/api/forum/categories");
const data = await res.json();
if (res.ok) {
setCategories(data.categories);
// Auto-select first category in modal
if (data.categories.length > 0) {
setCreateForm((f) => ({
...f,
categoryId: data.categories[0].id,
}));
}
}
} catch {
// ignore
}
}, []);
const fetchThreads = useCallback(async () => {
setLoadingData(true);
setError(null);
try {
const params = new URLSearchParams();
if (activeCategory) params.set("categoryId", activeCategory);
if (searchQuery.trim()) params.set("search", searchQuery.trim());
const res = await fetch(`/api/forum/threads?${params.toString()}`);
const data = await res.json();
if (res.ok) {
setThreads(data.threads);
} else {
setError(data.error || "Failed to load threads");
}
} catch {
setError("Network error. Please try again.");
} finally {
setLoadingData(false);
}
}, [activeCategory, searchQuery]);
useEffect(() => {
if (!loading && !token) {
router.push("/login");
return;
}
}, [loading, token, router]);
useEffect(() => {
if (token) {
fetchCategories();
}
}, [token, fetchCategories]);
useEffect(() => {
if (token) {
fetchThreads();
}
}, [token, fetchThreads]);
// Debounce search
useEffect(() => {
const timer = setTimeout(() => {
if (token) fetchThreads();
}, 400);
return () => clearTimeout(timer);
}, [searchQuery]);
const handleCreateThread = async (e: React.FormEvent) => {
e.preventDefault();
setCreateError(null);
setCreating(true);
try {
const res = await fetch("/api/forum/threads", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
title: createForm.title,
content: createForm.content,
categoryId: createForm.categoryId,
}),
});
const data = await res.json();
if (res.ok) {
setShowCreateModal(false);
setCreateForm({ title: "", content: "", categoryId: categories[0]?.id || "" });
fetchThreads();
} else {
setCreateError(data.error || "Failed to create thread");
}
} catch {
setCreateError("Network error. Please try again.");
} finally {
setCreating(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 (
<div className="min-h-dvh bg-[#0a0a0f] flex items-center justify-center">
<div className="w-8 h-8 rounded-full border-2 border-[#D4AF37]/30 border-t-[#D4AF37] animate-spin" />
</div>
);
}
if (!user) return null;
return (
<div className="min-h-dvh bg-[#0a0a0f] pb-28">
{/* Header */}
<div className="px-4 pt-6 pb-4">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<div className="w-9 h-9 rounded-xl bg-[#D4AF37]/15 flex items-center justify-center">
<MessageSquare size={18} className="text-[#D4AF37]" />
</div>
<div>
<h1 className="text-lg font-bold text-white">Forum</h1>
<p className="text-[10px] text-gray-500">Community Discussions</p>
</div>
</div>
<div className="flex items-center gap-2">
{canPost && (
<button
onClick={() => {
setCreateForm({
title: "",
content: "",
categoryId: categories[0]?.id || "",
});
setCreateError(null);
setShowCreateModal(true);
}}
className="flex items-center gap-1.5 bg-[#D4AF37]/15 border border-[#D4AF37]/30 rounded-xl px-3 py-2 active:bg-[#D4AF37]/25 transition"
>
<Plus size={14} className="text-[#D4AF37]" />
<span className="text-xs font-medium text-[#D4AF37]">New Thread</span>
</button>
)}
</div>
</div>
</div>
{/* Premium gating banner for free users */}
{!canPost && (
<div className="mx-4 mb-3 bg-gradient-to-r from-amber-900/15 to-gray-900/50 border border-amber-800/20 rounded-xl px-3 py-2.5">
<div className="flex items-center gap-2">
<Lock size={14} className="text-[#D4AF37] shrink-0" />
<div className="flex-1">
<p className="text-[11px] text-amber-300/90">
<span className="font-semibold">Free</span> members can browse.{" "}
<span className="text-[#D4AF37] font-semibold">Premium</span> or{" "}
<span className="text-purple-400 font-semibold">Pro</span> required to post.
</p>
</div>
<button
onClick={() => router.push("/profile")}
className="text-[10px] font-semibold text-[#D4AF37] bg-[#D4AF37]/15 border border-[#D4AF37]/30 rounded-lg px-2.5 py-1.5 active:bg-[#D4AF37]/25 transition"
>
Upgrade
</button>
</div>
</div>
)}
{/* Search bar */}
<div className="px-4 mb-3">
<div className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
<input
type="text"
placeholder="Search threads..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full bg-[#111118] border border-gray-800/60 rounded-xl pl-10 pr-4 py-2.5 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/40 transition"
/>
</div>
</div>
{/* Category pills */}
<div className="px-4 mb-4 overflow-x-auto">
<div className="flex gap-2 pb-1" style={{ minWidth: "max-content" }}>
<button
onClick={() => setActiveCategory(null)}
className={`flex items-center gap-1.5 px-3.5 py-2 rounded-xl text-xs font-medium whitespace-nowrap transition-all ${
activeCategory === null
? "bg-[#D4AF37]/20 text-[#D4AF37] border border-[#D4AF37]/30"
: "bg-[#111118] text-gray-500 border border-gray-800/60 active:bg-gray-800/60"
}`}
>
<MessageSquare size={13} />
All
</button>
{categories.map((cat) => {
const isActive = activeCategory === cat.id;
return (
<button
key={cat.id}
onClick={() => setActiveCategory(cat.id)}
className={`flex items-center gap-1.5 px-3.5 py-2 rounded-xl text-xs font-medium whitespace-nowrap transition-all ${
isActive
? "bg-[#D4AF37]/20 text-[#D4AF37] border border-[#D4AF37]/30"
: "bg-[#111118] text-gray-500 border border-gray-800/60 active:bg-gray-800/60"
}`}
>
{cat.icon || <MessageCircle size={13} />}
{cat.name}
</button>
);
})}
</div>
</div>
{/* Threads list */}
<div className="px-4">
{loadingData ? (
<div className="flex flex-col items-center justify-center py-20">
<Loader2 size={32} className="text-[#D4AF37] animate-spin mb-3" />
<p className="text-sm text-gray-500">Loading threads...</p>
</div>
) : error ? (
<div className="bg-red-900/10 border border-red-800/30 rounded-2xl p-6 text-center">
<AlertTriangle size={32} className="mx-auto text-red-400 mb-2" />
<p className="text-sm text-red-300">{error}</p>
<button
onClick={fetchThreads}
className="mt-3 text-xs text-[#D4AF37] underline"
>
Try again
</button>
</div>
) : threads.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20">
<div className="w-16 h-16 rounded-2xl bg-gray-800/40 flex items-center justify-center mb-4">
<MessageSquare size={28} className="text-gray-600" />
</div>
<h3 className="text-base font-semibold text-gray-400 mb-1">
{searchQuery || activeCategory
? "No threads found"
: "No discussions yet"}
</h3>
<p className="text-xs text-gray-600 text-center max-w-xs">
{searchQuery || activeCategory
? "Try a different search or category"
: canPost
? "Be the first to start a discussion!"
: "No discussions yet. Check back soon."}
</p>
{canPost && !searchQuery && !activeCategory && (
<button
onClick={() => {
setCreateForm({
title: "",
content: "",
categoryId: categories[0]?.id || "",
});
setCreateError(null);
setShowCreateModal(true);
}}
className="mt-4 flex items-center gap-2 bg-[#D4AF37]/15 border border-[#D4AF37]/30 rounded-xl px-4 py-2.5 active:bg-[#D4AF37]/25 transition"
>
<Plus size={16} className="text-[#D4AF37]" />
<span className="text-sm font-medium text-[#D4AF37]">Start Discussion</span>
</button>
)}
</div>
) : (
<div className="space-y-2.5">
{threads.map((thread) => {
const shariahInfo = SHARIAH_LABELS[thread.shariahStatus];
const ShariahIcon = shariahInfo.icon;
return (
<button
key={thread.id}
onClick={() => router.push(`/forum/${thread.id}`)}
className="w-full text-left bg-[#111118] border border-gray-800/60 rounded-2xl p-4 active:scale-[0.99] transition-transform"
>
<div className="flex items-start gap-3">
<div className="flex-1 min-w-0">
{/* Title row */}
<div className="flex items-center gap-1.5 mb-1">
{thread.pinned && (
<Pin size={12} className="text-[#D4AF37] shrink-0" />
)}
<h3 className="text-sm font-semibold text-white truncate">
{thread.title}
</h3>
</div>
{/* Meta row */}
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[10px] text-gray-600">
<div className="flex items-center gap-1">
<span className="text-gray-500">by</span>
<span className="font-medium text-gray-400">
{thread.author.name}
</span>
{thread.author.isPro && (
<Crown size={9} className="text-purple-400" />
)}
{thread.author.isPremium && !thread.author.isPro && (
<Star size={9} className="text-[#D4AF37]" />
)}
</div>
<span className="text-gray-700">·</span>
<span className="text-gray-500">{thread.category.name}</span>
<span className="text-gray-700">·</span>
<div className="flex items-center gap-1">
<MessageCircle size={9} />
<span>{thread._count.posts}</span>
</div>
</div>
{/* Shariah status + time */}
<div className="flex items-center gap-2 mt-1.5">
<div className={`flex items-center gap-0.5 ${shariahInfo.color}`}>
<ShariahIcon size={9} />
<span className="text-[9px]">{shariahInfo.label}</span>
</div>
<span className="text-gray-700">·</span>
<div className="flex items-center gap-0.5 text-gray-600">
<Clock size={9} />
<span className="text-[9px]">{timeAgo(thread.createdAt)}</span>
</div>
</div>
</div>
</div>
</button>
);
})}
</div>
)}
</div>
{/* Create thread modal */}
{showCreateModal && (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={() => setShowCreateModal(false)}
/>
<div className="relative w-full sm:max-w-md bg-[#111118] border border-gray-800/60 rounded-t-2xl sm:rounded-2xl p-5 animate-fade-in max-h-[90dvh] overflow-y-auto">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Plus size={18} className="text-[#D4AF37]" />
<h2 className="text-base font-bold text-white">New Thread</h2>
</div>
<button
onClick={() => setShowCreateModal(false)}
className="w-8 h-8 rounded-full bg-gray-800/60 flex items-center justify-center active:bg-gray-700/60 transition"
>
<X size={16} className="text-gray-500" />
</button>
</div>
<form onSubmit={handleCreateThread} className="space-y-4">
<div>
<label className="block text-xs font-medium text-gray-500 mb-1.5">
Category *
</label>
<select
value={createForm.categoryId}
onChange={(e) =>
setCreateForm((f) => ({ ...f, categoryId: e.target.value }))
}
required
className="w-full bg-[#0a0a0f] border border-gray-800/60 rounded-xl px-3.5 py-2.5 text-sm text-white focus:outline-none focus:border-[#D4AF37]/40 transition appearance-none"
style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E")`,
backgroundRepeat: "no-repeat",
backgroundPosition: "right 12px center",
}}
>
{categories.map((cat) => (
<option key={cat.id} value={cat.id}>
{cat.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 mb-1.5">
Title *
</label>
<input
type="text"
value={createForm.title}
onChange={(e) =>
setCreateForm((f) => ({ ...f, title: e.target.value }))
}
placeholder="What's on your mind?"
required
maxLength={200}
className="w-full bg-[#0a0a0f] border border-gray-800/60 rounded-xl px-3.5 py-2.5 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/40 transition"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 mb-1.5">
Content *
</label>
<textarea
value={createForm.content}
onChange={(e) =>
setCreateForm((f) => ({ ...f, content: e.target.value }))
}
placeholder="Write your thoughts..."
required
maxLength={10000}
rows={5}
className="w-full bg-[#0a0a0f] border border-gray-800/60 rounded-xl px-3.5 py-2.5 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/40 transition resize-none"
/>
</div>
{createError && (
<div className="bg-red-900/10 border border-red-800/30 rounded-xl p-3">
<p className="text-xs text-red-300">{createError}</p>
</div>
)}
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={() => setShowCreateModal(false)}
className="flex-1 py-2.5 rounded-xl text-sm font-medium text-gray-500 bg-gray-800/40 border border-gray-800/60 active:bg-gray-700/40 transition"
>
Cancel
</button>
<button
type="submit"
disabled={creating}
className="flex-1 py-2.5 rounded-xl text-sm font-semibold text-[#0a0a0f] bg-[#D4AF37] active:bg-[#c5a233] transition flex items-center justify-center gap-2 disabled:opacity-60"
>
{creating ? (
<>
<Loader2 size={14} className="animate-spin" />
Creating...
</>
) : (
"Create Thread"
)}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}
+74 -17
View File
@@ -1,26 +1,83 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
--bg-primary: #0a0a0f;
--bg-card: #111118;
--bg-card2: #1a1a24;
--gold: #D4AF37;
--gold-light: #f0d060;
--gold-dark: #b8962e;
--purple: #a855f7;
--purple-dark: #7c3aed;
--text-primary: #ffffff;
--text-secondary: #9ca3af;
--text-muted: #6b7280;
--border: #1f2937;
--border-gold: rgba(212, 175, 55, 0.4);
--danger: #ef4444;
--success: #22c55e;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
* {
-webkit-tap-highlight-color: transparent;
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
background-color: var(--bg-primary);
color: var(--text-primary);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
margin: 0;
padding: 0;
padding-bottom: 80px;
min-height: 100dvh;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 4px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #2a2a3a;
border-radius: 4px;
}
/* Animations */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-fade-in {
animation: fadeIn 0.3s ease-out;
}
@keyframes pulse-gold {
0%, 100% { box-shadow: 0 0 0 0 rgba(212, 175, 55, 0.4); }
50% { box-shadow: 0 0 0 8px rgba(212, 175, 55, 0); }
}
.animate-premium-glow {
animation: pulse-gold 2s infinite;
}
.glass {
background: rgba(17, 17, 24, 0.8);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
.glass-gold {
background: rgba(212, 175, 55, 0.08);
border: 1px solid var(--border-gold);
}
/* Streak flame */
@keyframes flicker {
0%, 100% { transform: scaleY(1); }
50% { transform: scaleY(1.05); }
}
.flame {
animation: flicker 0.5s ease-in-out infinite;
}
+628
View File
@@ -0,0 +1,628 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { useAuth } from "@/lib/AuthContext";
import { useRouter } from "next/navigation";
import {
MapPin,
Search,
Heart,
X,
Star,
ChevronRight,
Sparkles,
Crown,
Loader2,
} from "lucide-react";
// ─── Types ────────────────────────────────────────────────────────────────
interface Place {
id: string;
name: string;
address: string;
lat: number;
lng: number;
rating: number;
type: "mosque" | "restaurant" | "cafe";
halal_certified: boolean;
}
interface UsageInfo {
queriesUsed: number;
queriesLimit: number;
remaining: number;
tier: string;
scope: string;
}
interface Bookmark {
id: string;
itemId: string;
itemType: string;
label: string | null;
createdAt: string;
place: Place | null;
}
// ─── Constants ────────────────────────────────────────────────────────────
const TYPES = ["all", "mosque", "restaurant", "cafe"] as const;
const TYPE_LABELS: Record<string, string> = {
all: "All",
mosque: "Mosques",
restaurant: "Restaurants",
cafe: "Cafes",
};
// ─── Page ─────────────────────────────────────────────────────────────────
export default function HalalMonitorPage() {
const { user, token, loading } = useAuth();
const router = useRouter();
const [places, setPlaces] = useState<Place[]>([]);
const [usage, setUsage] = useState<UsageInfo | null>(null);
const [bookmarks, setBookmarks] = useState<Bookmark[]>([]);
const [search, setSearch] = useState("");
const [typeFilter, setTypeFilter] = useState<string>("all");
const [selectedPlace, setSelectedPlace] = useState<Place | null>(null);
const [pageLoading, setPageLoading] = useState(true);
const [usageLoading, setUsageLoading] = useState(false);
const [mapReady, setMapReady] = useState(false);
const mapRef = useRef<HTMLDivElement>(null);
const mapInstanceRef = useRef<any>(null);
const markersRef = useRef<any[]>([]);
const isPremium = user?.isPremium || user?.isPro || false;
const isFree = !isPremium;
// ── Redirect if not logged in ──────────────────────────────────────────
useEffect(() => {
if (!loading && !token) router.push("/login");
}, [loading, token, router]);
// ── Fetch places ───────────────────────────────────────────────────────
const fetchPlaces = useCallback(async (q = search, type = typeFilter) => {
try {
const params = new URLSearchParams();
if (q) params.set("q", q);
if (type && type !== "all") params.set("type", type);
const res = await fetch(`/api/halal/places?${params.toString()}`);
if (res.ok) {
const data = await res.json();
setPlaces(data.places);
}
} catch {
// ignore
}
}, [search, typeFilter]);
// ── Fetch usage ────────────────────────────────────────────────────────
const fetchUsage = useCallback(async () => {
if (!token) return;
try {
const res = await fetch("/api/halal/usage", {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
const data = await res.json();
setUsage(data);
}
} catch {
// ignore
}
}, [token]);
// ── Fetch bookmarks ────────────────────────────────────────────────────
const fetchBookmarks = useCallback(async () => {
if (!token) return;
try {
const res = await fetch("/api/halal/bookmarks", {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
const data = await res.json();
setBookmarks(data.bookmarks);
}
} catch {
// ignore
}
}, [token]);
// ── Track usage query ──────────────────────────────────────────────────
const trackQuery = useCallback(async () => {
if (!token) return;
setUsageLoading(true);
try {
const res = await fetch("/api/halal/usage", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
});
if (res.ok) {
const data = await res.json();
setUsage((prev) =>
prev
? { ...prev, ...data }
: data
);
} else if (res.status === 429) {
// Rate limited — update usage from error response
const data = await res.json();
setUsage((prev) => prev ? { ...prev, remaining: 0 } : prev);
}
} catch {
// ignore
} finally {
setUsageLoading(false);
}
}, [token]);
// ── Initial load ───────────────────────────────────────────────────────
useEffect(() => {
if (loading || !token) return;
const init = async () => {
setPageLoading(true);
await Promise.all([fetchPlaces(), fetchUsage(), fetchBookmarks()]);
setPageLoading(false);
};
init();
}, [loading, token, fetchPlaces, fetchUsage, fetchBookmarks]);
// ── Search / filter triggers ───────────────────────────────────────────
const handleSearch = useCallback(() => {
fetchPlaces(search, typeFilter);
if (!isFree) trackQuery();
}, [search, typeFilter, fetchPlaces, isFree, trackQuery]);
const handleTypeFilter = useCallback(
(type: string) => {
setTypeFilter(type);
fetchPlaces(search, type);
if (!isFree) trackQuery();
},
[search, fetchPlaces, isFree, trackQuery]
);
// ── Bookmark toggle ────────────────────────────────────────────────────
const toggleBookmark = useCallback(
async (place: Place) => {
if (!token) return;
const existing = bookmarks.find((b) => b.itemId === place.id);
if (existing) {
const res = await fetch(`/api/halal/bookmarks?id=${existing.id}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) fetchBookmarks();
} else {
const res = await fetch("/api/halal/bookmarks", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
itemId: place.id,
itemType: place.type,
label: place.name,
}),
});
if (res.ok) fetchBookmarks();
}
},
[token, bookmarks, fetchBookmarks]
);
const isBookmarked = useCallback(
(placeId: string) => bookmarks.some((b) => b.itemId === placeId),
[bookmarks]
);
// ── Filter visible places (premium gating) ─────────────────────────────
const visiblePlaces = isFree
? places.filter((p) => p.type === "mosque")
: places;
// ── Render stars ───────────────────────────────────────────────────────
const renderStars = (rating: number) => {
const full = Math.floor(rating);
const half = rating - full >= 0.5;
const empty = 5 - full - (half ? 1 : 0);
return (
<span className="inline-flex items-center gap-0.5">
{Array.from({ length: full }, (_, i) => (
<Star key={`full-${i}`} size={12} className="fill-amber-400 text-amber-400" />
))}
{half && (
<div className="relative">
<Star size={12} className="text-gray-600" />
<Star
size={12}
className="absolute inset-0 fill-amber-400 text-amber-400"
style={{ clipPath: "inset(0 50% 0 0)" }}
/>
</div>
)}
{Array.from({ length: empty }, (_, i) => (
<Star key={`empty-${i}`} size={12} className="text-gray-600" />
))}
<span className="text-xs text-gray-400 ml-1">{rating.toFixed(1)}</span>
</span>
);
};
// ── Premium Gating Overlay ─────────────────────────────────────────────
if (loading) {
return (
<div className="min-h-dvh bg-[#0a0a0f] flex items-center justify-center">
<div className="w-8 h-8 rounded-full border-2 border-emerald-500/30 border-t-emerald-500 animate-spin" />
</div>
);
}
if (!user) return null;
return (
<div className="min-h-dvh bg-[#0a0a0f] pb-24">
{/* ── Header ─────────────────────────────────────────────────────── */}
<div className="px-4 pt-6 pb-4">
<div className="flex items-center justify-between mb-2">
<h1 className="text-xl font-bold text-white flex items-center gap-2">
<MapPin size={20} className="text-emerald-400" />
Halal Monitor
</h1>
{usage && (
<div className="flex items-center gap-2">
<div className="text-right">
<p className="text-[10px] text-gray-500">Today</p>
<p className="text-xs font-semibold text-gray-300">
{usage.remaining}/{usage.queriesLimit}
</p>
</div>
<div className="w-16 h-1.5 bg-gray-800 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${Math.min(100, ((usage?.queriesUsed ?? 0) / (usage?.queriesLimit ?? 10)) * 100)}%`,
backgroundColor:
(usage?.remaining ?? 0) <= 2 ? "#ef4444" : "#10b981",
}}
/>
</div>
</div>
)}
</div>
<p className="text-xs text-gray-500">
Discover halal-certified places around you
</p>
{/* ── Premium Upgrade Banner ──────────────────────────────────── */}
{isFree && (
<button
onClick={() => router.push("/upgrade")}
className="mt-3 flex items-center justify-between glass-gold rounded-2xl px-4 py-3 w-full active:opacity-80 transition"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-emerald-500/20 flex items-center justify-center">
<Sparkles size={16} className="text-emerald-400" />
</div>
<div className="text-left">
<p className="text-sm font-semibold text-emerald-400">
Upgrade for Unlimited
</p>
<p className="text-[11px] text-gray-500">
Unlock restaurants + cafes & unlimited queries
</p>
</div>
</div>
<ChevronRight size={16} className="text-emerald-400" />
</button>
)}
{/* Free user scope notice */}
{isFree && (
<div className="mt-2 flex items-center gap-1.5 text-[11px] text-amber-400/70">
<Crown size={12} />
Free tier: mosques only &bull; 10 queries/day
</div>
)}
</div>
{/* ── Search Bar ─────────────────────────────────────────────────── */}
<div className="px-4 mb-3">
<div className="flex items-center gap-2 bg-[#111118] border border-gray-800/60 rounded-2xl px-4 py-2.5">
<Search size={16} className="text-gray-500 shrink-0" />
<input
type="text"
placeholder="Search halal places..."
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
className="flex-1 bg-transparent text-sm text-white placeholder-gray-600 outline-none"
/>
{search && (
<button onClick={() => { setSearch(""); fetchPlaces("", typeFilter); }}>
<X size={14} className="text-gray-500" />
</button>
)}
</div>
</div>
{/* ── Category Filter ────────────────────────────────────────────── */}
<div className="px-4 mb-4 overflow-x-auto">
<div className="flex gap-2 min-w-max pb-1">
{TYPES.map((t) => {
const active = typeFilter === t;
const isDisabled = isFree && t !== "all" && t !== "mosque";
return (
<button
key={t}
disabled={isDisabled}
onClick={() => handleTypeFilter(t)}
className={`px-3.5 py-1.5 rounded-full text-xs font-medium transition-all shrink-0 ${
active
? "bg-emerald-500/20 text-emerald-300 border border-emerald-500/40"
: isDisabled
? "bg-gray-900 text-gray-600 border border-gray-800/40 cursor-not-allowed opacity-50"
: "bg-[#111118] text-gray-400 border border-gray-800/60 hover:border-gray-700"
}`}
>
{TYPE_LABELS[t]}
</button>
);
})}
</div>
</div>
{/* ── Map Placeholder ────────────────────────────────────────────── */}
<div className="mx-4 mb-4">
<div
ref={mapRef}
className="w-full h-48 rounded-2xl overflow-hidden relative bg-gradient-to-br from-emerald-900/40 via-[#111118] to-gray-900 border border-gray-800/60"
>
{/* Decorative grid lines */}
<div className="absolute inset-0 opacity-10"
style={{
backgroundImage:
"linear-gradient(rgba(16, 185, 129, 0.3) 1px, transparent 1px), linear-gradient(90deg, rgba(16, 185, 129, 0.3) 1px, transparent 1px)",
backgroundSize: "40px 40px",
}}
/>
{/* Center pin */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="flex flex-col items-center gap-1">
<div className="w-10 h-10 rounded-full bg-emerald-500/20 border-2 border-emerald-500/40 flex items-center justify-center animate-pulse">
<MapPin size={18} className="text-emerald-400" />
</div>
<span className="text-[10px] text-emerald-500/60 font-medium">
{visiblePlaces.length} places found
</span>
</div>
</div>
{/* Map labels */}
{visiblePlaces.slice(0, 5).map((place) => (
<div
key={place.id}
className="absolute w-2 h-2 rounded-full bg-emerald-400 shadow-lg shadow-emerald-500/40 animate-pulse"
style={{
left: `${((place.lng - 101.6) / 0.2) * 50 + 25}%`,
top: `${((3.2 - place.lat) / 0.3) * 50 + 15}%`,
}}
/>
))}
</div>
</div>
{/* ── Results List ───────────────────────────────────────────────── */}
<div className="px-4">
<div className="flex items-center justify-between mb-3">
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wider">
{visiblePlaces.length} {visiblePlaces.length === 1 ? "place" : "places"}
</h2>
{usageLoading && (
<Loader2 size={12} className="animate-spin text-gray-600" />
)}
</div>
{pageLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 size={24} className="animate-spin text-emerald-500/50" />
</div>
) : visiblePlaces.length === 0 ? (
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-8 text-center">
<MapPin size={32} className="mx-auto text-gray-700 mb-2" />
<p className="text-sm text-gray-500">No places found</p>
<p className="text-xs text-gray-700 mt-1">
{isFree
? "Free tier shows mosques only. Try a different search."
: "Try adjusting your search or filters."}
</p>
</div>
) : (
<div className="space-y-3">
{visiblePlaces.map((place) => {
const bookmarked = isBookmarked(place.id);
return (
<div
key={place.id}
onClick={() => setSelectedPlace(place)}
className="bg-[#111118] border border-gray-800/60 rounded-2xl p-4 active:scale-[0.99] transition cursor-pointer"
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
{/* Place name + type badge */}
<div className="flex items-center gap-2 mb-1">
<h3 className="text-sm font-semibold text-white truncate">
{place.name}
</h3>
<span
className={`text-[10px] font-medium px-2 py-0.5 rounded-full shrink-0 ${
place.type === "mosque"
? "bg-emerald-500/15 text-emerald-300 border border-emerald-500/30"
: "bg-amber-500/15 text-amber-300 border border-amber-500/30"
}`}
>
{place.type === "mosque" ? "Mosque" : "Restaurant"}
</span>
</div>
{/* Address */}
<p className="text-xs text-gray-500 mb-1.5 truncate">
{place.address}
</p>
{/* Rating */}
<div className="mb-1.5">{renderStars(place.rating)}</div>
{/* Halal badge */}
{place.halal_certified && (
<span className="inline-flex items-center gap-1 text-[10px] font-medium text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
Halal Certified
</span>
)}
</div>
{/* Bookmark heart */}
<button
onClick={(e) => {
e.stopPropagation();
toggleBookmark(place);
}}
className={`shrink-0 w-8 h-8 rounded-full flex items-center justify-center transition ${
bookmarked
? "bg-emerald-500/20 text-emerald-400"
: "bg-gray-800/60 text-gray-600 hover:text-gray-400"
}`}
>
<Heart
size={14}
className={bookmarked ? "fill-emerald-400" : ""}
/>
</button>
</div>
</div>
);
})}
</div>
)}
</div>
{/* ── Detail Modal ────────────────────────────────────────────────── */}
{selectedPlace && (
<div
className="fixed inset-0 z-50 bg-black/70 flex items-end sm:items-center justify-center"
onClick={() => setSelectedPlace(null)}
>
<div
onClick={(e) => e.stopPropagation()}
className="bg-[#111118] border border-gray-800/60 rounded-t-3xl sm:rounded-3xl w-full max-w-md max-h-[85vh] overflow-y-auto animate-fade-in"
>
{/* Handle */}
<div className="pt-3 pb-1 flex justify-center sm:hidden">
<div className="w-10 h-1 rounded-full bg-gray-700" />
</div>
<div className="p-5">
{/* Close button */}
<div className="flex justify-end mb-2">
<button
onClick={() => setSelectedPlace(null)}
className="w-8 h-8 rounded-full bg-gray-800/80 flex items-center justify-center"
>
<X size={14} className="text-gray-400" />
</button>
</div>
{/* Type badge */}
<div className="mb-3">
<span
className={`text-xs font-medium px-3 py-1 rounded-full ${
selectedPlace.type === "mosque"
? "bg-emerald-500/15 text-emerald-300 border border-emerald-500/30"
: "bg-amber-500/15 text-amber-300 border border-amber-500/30"
}`}
>
{selectedPlace.type === "mosque" ? "🕌 Mosque" : "🍽️ Restaurant"}
</span>
</div>
{/* Name */}
<h2 className="text-lg font-bold text-white mb-1">
{selectedPlace.name}
</h2>
{/* Halal certified */}
{selectedPlace.halal_certified && (
<div className="flex items-center gap-1.5 mb-3">
<span className="w-2 h-2 rounded-full bg-emerald-400" />
<span className="text-xs font-medium text-emerald-400">
Halal Certified
</span>
</div>
)}
{/* Address */}
<div className="flex items-start gap-2 mb-3">
<MapPin size={14} className="text-gray-500 mt-0.5 shrink-0" />
<p className="text-sm text-gray-400">{selectedPlace.address}</p>
</div>
{/* Rating */}
<div className="flex items-center gap-2 mb-4">
<Star size={14} className="fill-amber-400 text-amber-400" />
<span className="text-sm font-semibold text-white">
{selectedPlace.rating.toFixed(1)}
</span>
<span className="text-xs text-gray-600">/ 5.0</span>
</div>
{/* Coords */}
<div className="grid grid-cols-2 gap-3 mb-5">
<div className="bg-[#0a0a0f] rounded-xl p-3 text-center">
<p className="text-[10px] text-gray-600">Latitude</p>
<p className="text-xs text-gray-300 font-mono">
{selectedPlace.lat.toFixed(4)}
</p>
</div>
<div className="bg-[#0a0a0f] rounded-xl p-3 text-center">
<p className="text-[10px] text-gray-600">Longitude</p>
<p className="text-xs text-gray-300 font-mono">
{selectedPlace.lng.toFixed(4)}
</p>
</div>
</div>
{/* Bookmark button */}
<button
onClick={() => {
toggleBookmark(selectedPlace);
setSelectedPlace(null);
}}
className={`w-full py-3 rounded-2xl text-sm font-semibold transition flex items-center justify-center gap-2 ${
isBookmarked(selectedPlace.id)
? "bg-gray-800 text-gray-400 border border-gray-700"
: "bg-emerald-500/20 text-emerald-300 border border-emerald-500/40"
}`}
>
<Heart
size={16}
className={
isBookmarked(selectedPlace.id) ? "fill-emerald-400" : ""
}
/>
{isBookmarked(selectedPlace.id)
? "Remove Bookmark"
: "Add to Bookmarks"}
</button>
</div>
</div>
</div>
)}
</div>
);
}
+23 -20
View File
@@ -1,33 +1,36 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
import { AuthProvider } from "@/lib/AuthContext";
import BottomNav from "@/components/BottomNav";
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Falah — Islamic Lifestyle",
description: "Nur AI coaching, Halal marketplace, community & wallet",
viewport: "width=device-width, initial-scale=1, viewport-fit=cover",
themeColor: "#0a0a0f",
appleWebApp: { capable: true, statusBarStyle: "black-translucent" },
};
export default function RootLayout({
children,
}: Readonly<{
}: {
children: React.ReactNode;
}>) {
}) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
<html lang="en" className="dark">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#0a0a0f" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
</head>
<body>
<AuthProvider>
<main>{children}</main>
<BottomNav />
</AuthProvider>
</body>
</html>
);
}
+151
View File
@@ -0,0 +1,151 @@
"use client";
import { useState, FormEvent } from "react";
import { useAuth } from "@/lib/AuthContext";
import { useRouter } from "next/navigation";
import Link from "next/link";
export default function LoginPage() {
const { login } = useAuth();
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError("");
if (!email.trim() || !password.trim()) {
setError("Please fill in all fields");
return;
}
setLoading(true);
try {
await login(email.trim(), password);
router.push("/");
} catch (err: unknown) {
const message =
err instanceof Error ? err.message : "Login failed. Please try again.";
setError(message);
} finally {
setLoading(false);
}
};
return (
<div className="min-h-dvh bg-[#0a0a0f] flex flex-col items-center justify-center px-6">
<div className="w-full max-w-sm">
{/* Logo / Brand */}
<div className="text-center mb-8">
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-[#D4AF37]/20 to-[#D4AF37]/5 border border-[#D4AF37]/30 flex items-center justify-center mx-auto mb-4">
<span className="text-3xl"></span>
</div>
<h1 className="text-2xl font-bold text-white">Welcome Back</h1>
<p className="text-sm text-gray-500 mt-1">
Sign in to continue your journey
</p>
</div>
{/* Error */}
{error && (
<div className="mb-4 p-3 rounded-xl bg-red-900/20 border border-red-800/40 text-red-400 text-sm text-center">
{error}
</div>
)}
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor="email"
className="block text-xs font-medium text-gray-500 mb-1.5 uppercase tracking-wider"
>
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
autoComplete="email"
autoFocus
className="w-full bg-[#111118] border border-gray-800/60 rounded-xl px-4 py-3 text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/50 focus:ring-1 focus:ring-[#D4AF37]/30 transition"
/>
</div>
<div>
<label
htmlFor="password"
className="block text-xs font-medium text-gray-500 mb-1.5 uppercase tracking-wider"
>
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
autoComplete="current-password"
className="w-full bg-[#111118] border border-gray-800/60 rounded-xl px-4 py-3 text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/50 focus:ring-1 focus:ring-[#D4AF37]/30 transition"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-3 rounded-xl bg-[#D4AF37] text-[#0a0a0f] font-semibold text-sm active:opacity-80 disabled:opacity-50 transition"
>
{loading ? (
<span className="flex items-center justify-center gap-2">
<span className="w-4 h-4 rounded-full border-2 border-[#0a0a0f]/30 border-t-[#0a0a0f] animate-spin" />
Signing in...
</span>
) : (
"Sign In"
)}
</button>
</form>
{/* Divider */}
<div className="flex items-center gap-3 my-6">
<div className="flex-1 h-px bg-gray-800/60" />
<span className="text-xs text-gray-600">or</span>
<div className="flex-1 h-px bg-gray-800/60" />
</div>
{/* Demo hint */}
<div className="text-center mb-6">
<p className="text-xs text-gray-600">
Don&apos;t have an account?{" "}
<Link
href="/register"
className="text-[#D4AF37] font-medium hover:underline"
>
Create one
</Link>
</p>
</div>
{/* Demo credentials */}
<div className="rounded-xl bg-[#111118] border border-gray-800/60 p-3 text-center">
<p className="text-[10px] text-gray-600 uppercase tracking-wider mb-1">
Demo Account
</p>
<p className="text-xs text-gray-500">
Email:{" "}
<span className="text-gray-300 font-mono">demo@falah.app</span>
</p>
<p className="text-xs text-gray-500">
Password:{" "}
<span className="text-gray-300 font-mono">password123</span>
</p>
</div>
</div>
</div>
);
}
+521
View File
@@ -0,0 +1,521 @@
"use client";
import { useState, useEffect, useRef, Suspense, useCallback } from "react";
import { useAuth } from "@/lib/AuthContext";
import { PERSONAS, getPersona } from "@/lib/personas";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
Send,
Lock,
Sparkles,
Crown,
Bot,
ChevronLeft,
AlertTriangle,
Loader2,
CheckCircle2,
MessageSquare,
} from "lucide-react";
/* ── Types ── */
interface ChatMessage {
id?: string;
role: "user" | "assistant";
content: string;
createdAt?: string;
metadata?: { personaId?: string } | null;
}
/* ── Main Page ── */
export default function NurPage() {
return (
<Suspense
fallback={
<div className="min-h-dvh bg-[#0a0a0f] flex items-center justify-center">
<Loader2 size={24} className="text-[#D4AF37] animate-spin" />
</div>
}
>
<NurChat />
</Suspense>
);
}
/* ── Chat Component ── */
function NurChat() {
const { user, token, loading } = useAuth();
const router = useRouter();
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState("");
const [activePersona, setActivePersona] = useState("nurbuddy");
const [isTyping, setIsTyping] = useState(false);
const [dailyCount, setDailyCount] = useState(0);
const [dailyLimit, setDailyLimit] = useState(10);
const [error, setError] = useState<string | null>(null);
const [initialLoading, setInitialLoading] = useState(true);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
/* Redirect if not logged in */
useEffect(() => {
if (!loading && !token) {
router.push("/login");
}
}, [loading, token, router]);
/* Load chat history + daily stats */
useEffect(() => {
if (!token || !user) return;
const loadData = async () => {
try {
const [historyRes, dailyRes] = await Promise.all([
fetch(`/api/chat/history/${user.id}`, {
headers: { Authorization: `Bearer ${token}` },
}),
fetch("/api/chat/daily", {
headers: { Authorization: `Bearer ${token}` },
}),
]);
if (historyRes.ok) {
const hist = await historyRes.json();
setMessages(hist.messages || []);
}
if (dailyRes.ok) {
const daily = await dailyRes.json();
setDailyCount(daily.count || 0);
setDailyLimit(daily.limit === -1 ? Infinity : daily.limit);
}
} catch {
// silent
} finally {
setInitialLoading(false);
}
};
loadData();
}, [token, user]);
/* Auto-scroll on new messages */
const scrollToBottom = useCallback(() => {
setTimeout(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, 100);
}, []);
useEffect(() => {
scrollToBottom();
}, [messages, isTyping, scrollToBottom]);
/* Send message */
const sendMessage = async () => {
const trimmed = input.trim();
if (!trimmed || isTyping) return;
setError(null);
const userMsg: ChatMessage = { role: "user", content: trimmed };
setMessages((prev) => [...prev, userMsg]);
setInput("");
setIsTyping(true);
try {
const res = await fetch("/api/chat/send", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
messages: [{ role: "user", content: trimmed }],
personaId: activePersona,
}),
});
const data = await res.json();
if (!res.ok) {
if (res.status === 429) {
setError("Daily message limit reached. Upgrade to continue chatting!");
} else if (res.status === 403) {
setError(data.error || "This persona requires an upgrade.");
} else {
setError(data.error || "Failed to send message");
}
// Remove the optimistic user message on error
setMessages((prev) => prev.slice(0, -1));
return;
}
const aiMsg: ChatMessage = {
id: data.message.id,
role: "assistant",
content: data.message.content,
createdAt: data.message.createdAt,
};
setMessages((prev) => [...prev, aiMsg]);
// Update daily count
if (data.daily) {
setDailyCount(data.daily.count);
setDailyLimit(data.daily.limit === -1 ? Infinity : data.daily.limit);
}
} catch {
setError("Network error. Please try again.");
setMessages((prev) => prev.slice(0, -1));
} finally {
setIsTyping(false);
}
};
/* Keyboard shortcut */
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
};
if (loading || initialLoading) {
return (
<div className="min-h-dvh bg-[#0a0a0f] flex items-center justify-center">
<div className="w-8 h-8 rounded-full border-2 border-[#D4AF37]/30 border-t-[#D4AF37] animate-spin" />
</div>
);
}
if (!user || !token) return null;
const isPremium = user.isPremium || user.isPro;
const isPro = user.isPro;
const isUnlimited = dailyLimit === Infinity;
const atLimit = !isUnlimited && dailyCount >= dailyLimit;
const limitPercent = isUnlimited ? 0 : Math.min(100, (dailyCount / dailyLimit) * 100);
const remaining = isUnlimited ? -1 : dailyLimit - dailyCount;
return (
<div className="min-h-dvh bg-[#0a0a0f] flex flex-col pb-24">
{/* ── Header ── */}
<div className="sticky top-0 z-20 bg-[#0a0a0f]/95 backdrop-blur-md border-b border-gray-800/60">
<div className="flex items-center justify-between px-4 py-3">
<div className="flex items-center gap-2">
<Link href="/" className="text-gray-500 hover:text-white transition">
<ChevronLeft size={20} />
</Link>
<div>
<h1 className="text-base font-bold text-white flex items-center gap-1.5">
<Bot size={16} className="text-[#D4AF37]" />
Nur AI
</h1>
<p className="text-[10px] text-gray-600">
{isPro ? "Pro Plan" : isPremium ? "Premium Plan" : "Free Plan"}
</p>
</div>
</div>
{!isPremium && (
<Link
href="/upgrade"
className="flex items-center gap-1.5 bg-[#D4AF37]/15 border border-[#D4AF37]/30 rounded-full px-3 py-1.5 text-[11px] font-semibold text-[#D4AF37] active:bg-[#D4AF37]/25 transition"
>
<Sparkles size={12} />
Upgrade
</Link>
)}
</div>
</div>
{/* ── Persona Selector ── */}
<Suspense
fallback={
<div className="h-20 bg-[#111118] mx-4 mt-3 rounded-xl animate-pulse" />
}
>
<PersonaSelector
activePersona={activePersona}
onSelect={setActivePersona}
isPremium={isPremium}
isPro={isPro}
/>
</Suspense>
{/* ── Daily Cap Bar ── */}
<div className="px-4 mt-3">
<div className="flex items-center justify-between mb-1.5">
<span className="text-[11px] text-gray-500 font-medium">
{isUnlimited ? (
<span className="text-emerald-400 flex items-center gap-1">
<CheckCircle2 size={12} />
Unlimited messages
</span>
) : (
`${dailyCount}/${dailyLimit} messages today`
)}
</span>
{!isUnlimited && remaining <= 3 && remaining > 0 && (
<span className="text-[10px] text-amber-400 font-medium">
{remaining} left
</span>
)}
</div>
{!isUnlimited && (
<div className="w-full h-1.5 bg-gray-800 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${
limitPercent >= 80
? "bg-red-500"
: limitPercent >= 50
? "bg-amber-500"
: "bg-[#D4AF37]"
}`}
style={{ width: `${limitPercent}%` }}
/>
</div>
)}
</div>
{/* ── Upgrade Banner (hitting limit) ── */}
{!isPremium && atLimit && (
<div className="mx-4 mt-3 p-3 bg-gradient-to-r from-[#D4AF37]/10 to-purple-900/10 border border-[#D4AF37]/20 rounded-xl">
<div className="flex items-start gap-2.5">
<Crown size={18} className="text-[#D4AF37] shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-semibold text-white">
You've hit your daily limit
</p>
<p className="text-[11px] text-gray-500 mt-0.5">
Upgrade to Premium for 100 messages/day or Pro for unlimited access.
</p>
<Link
href="/upgrade"
className="inline-block mt-2 text-xs font-semibold text-[#D4AF37] border border-[#D4AF37]/40 rounded-full px-3 py-1 active:bg-[#D4AF37]/10 transition"
>
See Plans
</Link>
</div>
</div>
</div>
)}
{/* ── Messages Area ── */}
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-4">
{messages.length === 0 && !isTyping && (
<div className="flex flex-col items-center justify-center h-full text-center py-12">
<div className="w-16 h-16 rounded-full bg-[#D4AF37]/10 flex items-center justify-center mb-4">
<MessageSquare size={28} className="text-[#D4AF37]" />
</div>
<h3 className="text-sm font-semibold text-gray-300">
Start a conversation
</h3>
<p className="text-xs text-gray-600 mt-1 max-w-xs">
Choose a persona and send a message to get AI-powered Islamic guidance.
</p>
</div>
)}
{messages.map((msg, idx) => (
<MessageBubble key={msg.id || idx} message={msg} />
))}
{isTyping && (
<div className="flex items-start gap-2.5 animate-fade-in">
<div className="w-8 h-8 rounded-full bg-gray-800 flex items-center justify-center shrink-0">
<Bot size={14} className="text-[#D4AF37]" />
</div>
<div className="bg-[#1a1a24] border border-gray-700/40 rounded-2xl rounded-tl-sm px-4 py-3 max-w-[85%]">
<TypingIndicator />
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* ── Error Message ── */}
{error && (
<div className="mx-4 mb-2 flex items-start gap-2 bg-red-900/20 border border-red-800/30 rounded-xl px-3 py-2.5">
<AlertTriangle size={14} className="text-red-400 shrink-0 mt-0.5" />
<p className="text-xs text-red-300 flex-1">{error}</p>
<button
onClick={() => setError(null)}
className="text-red-400 hover:text-red-300 text-xs"
>
</button>
</div>
)}
{/* ── Input Area ── */}
<div className="sticky bottom-20 bg-[#0a0a0f]/95 backdrop-blur-md border-t border-gray-800/60 px-4 py-3">
<div className="flex items-end gap-2 max-w-2xl mx-auto">
<div className="flex-1 relative">
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={atLimit ? "Daily limit reached" : "Type a message..."}
disabled={atLimit || isTyping}
rows={1}
className="w-full bg-[#111118] border border-gray-700/50 rounded-xl px-4 py-3 text-sm text-white placeholder-gray-600 resize-none focus:outline-none focus:border-[#D4AF37]/40 transition disabled:opacity-50 disabled:cursor-not-allowed"
style={{ minHeight: "44px", maxHeight: "120px" }}
onInput={(e) => {
const el = e.currentTarget;
el.style.height = "auto";
el.style.height = `${Math.min(el.scrollHeight, 120)}px`;
}}
/>
</div>
<button
onClick={sendMessage}
disabled={!input.trim() || atLimit || isTyping}
className="w-11 h-11 rounded-xl bg-[#D4AF37] text-[#0a0a0f] flex items-center justify-center shrink-0 hover:bg-[#e0c040] active:scale-95 transition disabled:opacity-30 disabled:cursor-not-allowed"
>
{isTyping ? (
<Loader2 size={18} className="animate-spin" />
) : (
<Send size={18} />
)}
</button>
</div>
</div>
</div>
);
}
/* ── Persona Selector ── */
function PersonaSelector({
activePersona,
onSelect,
isPremium,
isPro,
}: {
activePersona: string;
onSelect: (id: string) => void;
isPremium: boolean;
isPro: boolean;
}) {
const scrollRef = useRef<HTMLDivElement>(null);
const canAccess = (personaId: string) => {
if (personaId === "nurbuddy") return true;
if (isPro) return true;
if (isPremium && ["alim", "hakim", "murabbi"].includes(personaId)) return true;
return false;
};
return (
<div className="px-4 mt-3">
<div
ref={scrollRef}
className="flex gap-2 overflow-x-auto scrollbar-none pb-1"
style={{ scrollbarWidth: "none", msOverflowStyle: "none" }}
>
{PERSONAS.map((persona) => {
const accessible = canAccess(persona.id);
const isActive = activePersona === persona.id;
return (
<button
key={persona.id}
onClick={() => {
if (accessible) onSelect(persona.id);
else window.location.href = "/upgrade";
}}
disabled={!accessible}
className={`flex-shrink-0 flex flex-col items-center gap-1.5 px-4 py-3 rounded-xl border transition-all active:scale-95 ${
isActive
? "bg-[#D4AF37]/15 border-[#D4AF37]/50 text-white"
: accessible
? "bg-[#111118] border-gray-800/60 text-gray-400 hover:border-gray-700"
: "bg-[#0a0a0f] border-gray-800/30 text-gray-700 opacity-60"
}`}
style={{
minWidth: "80px",
}}
>
<span className="text-xl">{persona.icon}</span>
<span
className={`text-xs font-semibold ${
isActive ? "text-[#D4AF37]" : ""
}`}
>
{persona.name}
</span>
<span className="text-[8px] text-center leading-tight text-gray-600 max-w-[72px]">
{persona.description}
</span>
{!accessible && (
<div className="flex items-center gap-1 bg-amber-900/30 border border-amber-700/30 rounded-full px-2 py-0.5 mt-1">
<Lock size={8} className="text-amber-400" />
<span className="text-[8px] font-medium text-amber-400">
Premium
</span>
</div>
)}
{isActive && (
<div className="w-1.5 h-1.5 rounded-full bg-[#D4AF37]" />
)}
</button>
);
})}
</div>
</div>
);
}
/* ── Message Bubble ── */
function MessageBubble({ message }: { message: ChatMessage }) {
const isUser = message.role === "user";
return (
<div
className={`flex items-start gap-2.5 animate-fade-in ${
isUser ? "flex-row-reverse" : ""
}`}
>
{!isUser && (
<div className="w-8 h-8 rounded-full bg-gray-800 flex items-center justify-center shrink-0">
<Bot size={14} className="text-[#D4AF37]" />
</div>
)}
<div
className={`max-w-[85%] rounded-2xl px-4 py-3 text-sm leading-relaxed whitespace-pre-wrap ${
isUser
? "bg-[#D4AF37] text-[#0a0a0f] rounded-tr-sm"
: "bg-[#1a1a24] border border-gray-700/40 text-gray-200 rounded-tl-sm"
}`}
>
{message.content}
{message.createdAt && (
<div
className={`text-[10px] mt-2 ${
isUser ? "text-[#0a0a0f]/50" : "text-gray-600"
}`}
>
{new Date(message.createdAt).toLocaleTimeString("en-MY", {
hour: "2-digit",
minute: "2-digit",
})}
</div>
)}
</div>
</div>
);
}
/* ── Typing Indicator ── */
function TypingIndicator() {
return (
<div className="flex items-center gap-1.5 py-1">
<span className="w-2 h-2 bg-gray-500 rounded-full animate-bounce" style={{ animationDelay: "0ms" }} />
<span className="w-2 h-2 bg-gray-500 rounded-full animate-bounce" style={{ animationDelay: "150ms" }} />
<span className="w-2 h-2 bg-gray-500 rounded-full animate-bounce" style={{ animationDelay: "300ms" }} />
</div>
);
}
+170 -58
View File
@@ -1,65 +1,177 @@
import Image from "next/image";
"use client";
import { useState, useEffect } from "react";
import { useAuth } from "@/lib/AuthContext";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
Bot, ShoppingBag, MessageCircle, Wallet,
Flame, Sparkles, Star, ChevronRight,
BookOpen, MapPin, Crown, Zap,
} from "lucide-react";
import { DAILY_VERSE, generateAIResponse } from "@/lib/ai";
export default function HomePage() {
const { user, token, loading } = useAuth();
const router = useRouter();
const [streak, setStreak] = useState(0);
const [verse, setVerse] = useState(DAILY_VERSE[Math.floor(Math.random() * DAILY_VERSE.length)]);
useEffect(() => {
if (!loading && !token) router.push("/login");
}, [loading, token, router]);
if (loading) return <LoadingScreen />;
if (!user) return null;
const isPremium = user.isPremium || user.isPro;
const isPro = user.isPro;
export default function Home() {
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
<div className="min-h-dvh bg-[#0a0a0f] pb-24">
{/* Header */}
<div className="px-4 pt-6 pb-4">
<div className="flex items-center justify-between mb-1">
<div>
<h1 className="text-xl font-bold text-white">
Assalamualaikum, {user.name.split(" ")[0]}
</h1>
<p className="text-xs text-gray-500 mt-0.5">
{new Date().toLocaleDateString("en-MY", { weekday: "long", day: "numeric", month: "long", year: "numeric" })}
</p>
</div>
{/* Streak */}
{streak > 0 && (
<div className="flex items-center gap-1.5 bg-gradient-to-r from-orange-900/40 to-orange-800/20 border border-orange-700/30 rounded-xl px-3 py-2">
<Flame size={16} className="text-orange-400 flame" />
<span className="text-sm font-bold text-orange-300">{streak}</span>
<span className="text-[10px] text-orange-400/70">day</span>
</div>
)}
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
{/* Premium banner */}
{!isPremium && (
<Link href="/upgrade"
className="mt-3 flex items-center justify-between glass-gold rounded-2xl px-4 py-3 active:opacity-80 transition">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-[#D4AF37]/20 flex items-center justify-center">
<Sparkles size={16} className="text-[#D4AF37]" />
</div>
<div>
<p className="text-sm font-semibold text-[#D4AF37]">Go Premium</p>
<p className="text-[11px] text-gray-500">Unlock unlimited AI & more</p>
</div>
</div>
<ChevronRight size={16} className="text-[#D4AF37]" />
</Link>
)}
{/* Pro badge */}
{isPro && (
<div className="mt-3 flex items-center gap-2 bg-purple-900/20 border border-purple-500/30 rounded-2xl px-4 py-2.5">
<Crown size={16} className="text-purple-400" />
<span className="text-sm font-medium text-purple-300">Pro Member Unlimited Everything</span>
</div>
)}
</div>
{/* Daily Verse */}
<div className="mx-4 mb-5 bg-gradient-to-br from-emerald-900/20 to-[#111118] border border-emerald-800/30 rounded-2xl p-4">
<div className="flex items-center gap-2 mb-2">
<BookOpen size={14} className="text-emerald-400" />
<span className="text-xs font-medium text-emerald-400">Daily Verse</span>
</div>
</main>
<p className="text-lg text-center font-arabic text-emerald-100 mb-2" dir="rtl">
{verse.arabic}
</p>
<p className="text-sm text-gray-400 text-center italic">
&ldquo;{verse.translation}&rdquo;
</p>
<p className="text-[10px] text-gray-600 text-center mt-1"> {verse.reference}</p>
</div>
{/* Quick Actions */}
<div className="px-4 mb-5">
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Quick Actions</h2>
<div className="grid grid-cols-4 gap-3">
<QuickAction href="/nur" icon={Bot} label="Nur AI" color="bg-amber-900/30 text-amber-300" />
<QuickAction href="/souq" icon={ShoppingBag} label="Souq" color="bg-[#D4AF37]/15 text-[#D4AF37]" />
<QuickAction href="/halal-monitor" icon={MapPin} label="Halal" color="bg-emerald-900/30 text-emerald-300" />
<QuickAction href="/wallet" icon={Wallet} label="Wallet" color="bg-blue-900/30 text-blue-300" />
</div>
</div>
{/* Stats Row */}
<div className="mx-4 mb-5">
<div className="grid grid-cols-3 gap-3">
<StatCard label="FLH Balance" value={user.flhBalance.toLocaleString()} icon={Zap} color="text-[#D4AF37]" />
<StatCard label="AI Today" value={`${user.dailyMsgCount}/10`} icon={Bot} color="text-amber-400" />
<StatCard label="Tier" value={isPro ? "Pro" : isPremium ? "Premium" : "Free"} icon={Crown}
color={isPro ? "text-purple-400" : isPremium ? "text-[#D4AF37]" : "text-gray-500"} />
</div>
</div>
{/* Activity Feed */}
<div className="px-4">
<div className="flex items-center justify-between mb-3">
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wider">Activity</h2>
<Link href="/forum" className="text-[11px] text-[#D4AF37]">View all</Link>
</div>
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-5 text-center">
<MessageCircle size={24} className="mx-auto text-gray-700 mb-2" />
<p className="text-sm text-gray-500">No recent activity</p>
<p className="text-xs text-gray-700 mt-1">Start a conversation with Nur AI</p>
</div>
</div>
{/* Premium upsell at bottom for free users */}
{!isPremium && (
<div className="mx-4 mt-5 bg-gradient-to-r from-[#D4AF37]/10 to-purple-900/10 border border-[#D4AF37]/20 rounded-2xl p-4">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-2xl bg-[#D4AF37]/15 flex items-center justify-center shrink-0">
<Star size={20} className="text-[#D4AF37]" />
</div>
<div className="flex-1">
<h3 className="font-semibold text-white text-sm">Unlock Your Full Potential</h3>
<p className="text-xs text-gray-500 mt-1">Premium gives you unlimited AI, halal monitor, and marketplace access.</p>
<Link href="/upgrade"
className="inline-block mt-3 text-xs font-semibold text-[#D4AF37] border border-[#D4AF37]/40 rounded-full px-4 py-1.5 active:bg-[#D4AF37]/10 transition">
See Plans
</Link>
</div>
</div>
</div>
)}
</div>
);
}
function QuickAction({ href, icon: Icon, label, color }: { href: string; icon: any; label: string; color: string }) {
return (
<Link href={href} className="flex flex-col items-center gap-1.5 active:scale-95 transition">
<div className={`w-14 h-14 rounded-2xl flex items-center justify-center ${color} bg-opacity-20`}>
<Icon size={22} />
</div>
<span className="text-[10px] text-gray-500 font-medium">{label}</span>
</Link>
);
}
function StatCard({ label, value, icon: Icon, color }: { label: string; value: string; icon: any; color: string }) {
return (
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-3 text-center">
<Icon size={14} className={`mx-auto mb-1 ${color}`} />
<p className="text-sm font-bold text-white">{value}</p>
<p className="text-[9px] text-gray-600 mt-0.5">{label}</p>
</div>
);
}
function LoadingScreen() {
return (
<div className="min-h-dvh bg-[#0a0a0f] flex items-center justify-center">
<div className="w-8 h-8 rounded-full border-2 border-[#D4AF37]/30 border-t-[#D4AF37] animate-spin" />
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
export default function ProfileLoading() {
return (
<div className="min-h-dvh bg-[#0a0a0f] flex items-center justify-center">
<div className="flex flex-col items-center gap-3">
<div className="w-8 h-8 rounded-full border-2 border-[#D4AF37]/30 border-t-[#D4AF37] animate-spin" />
<p className="text-xs text-gray-600">Loading profile...</p>
</div>
</div>
);
}
+343
View File
@@ -0,0 +1,343 @@
"use client";
import { useState, useEffect, FormEvent } from "react";
import { useAuth } from "@/lib/AuthContext";
import { useRouter } from "next/navigation";
import { getUserTier } from "@/lib/tiers";
import { PERSONAS } from "@/lib/personas";
import {
User,
Crown,
Zap,
LogOut,
ChevronRight,
Save,
Sparkles,
Shield,
} from "lucide-react";
const VALID_MADHABS = ["Hanafi", "Maliki", "Shafi'i", "Hanbali"];
export default function ProfilePage() {
const { user, token, loading, logout, refreshUser } = useAuth();
const router = useRouter();
// Editable fields
const [name, setName] = useState("");
const [preferredName, setPreferredName] = useState("");
const [coachPersona, setCoachPersona] = useState("");
const [madhab, setMadhab] = useState("");
const [coachingGoals, setCoachingGoals] = useState("");
const [saving, setSaving] = useState(false);
const [saveMessage, setSaveMessage] = useState("");
useEffect(() => {
if (!loading && !token) router.push("/login");
}, [loading, token, router]);
useEffect(() => {
if (user) {
setName(user.name || "");
setPreferredName(user.preferredName || "");
setCoachPersona(user.coachPersona || "");
setMadhab(user.madhab || "");
setCoachingGoals(user.coachingGoals || "");
}
}, [user]);
const tier = user ? getUserTier(user.isPremium, user.isPro) : "free";
const tierColors = {
free: { bg: "bg-gray-900/50", text: "text-gray-400", border: "border-gray-700/50", icon: User },
premium: { bg: "bg-[#D4AF37]/10", text: "text-[#D4AF37]", border: "border-[#D4AF37]/30", icon: Sparkles },
pro: { bg: "bg-purple-900/20", text: "text-purple-400", border: "border-purple-500/30", icon: Crown },
};
const tc = tierColors[tier];
const handleSave = async (e: FormEvent) => {
e.preventDefault();
if (!token) return;
setSaving(true);
setSaveMessage("");
try {
const res = await fetch("/api/auth/profile", {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
name: name.trim() || undefined,
preferredName: preferredName.trim() || undefined,
coachPersona: coachPersona || undefined,
madhab: madhab || undefined,
coachingGoals: coachingGoals.trim() || undefined,
}),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Save failed");
}
setSaveMessage("Profile updated successfully");
await refreshUser();
setTimeout(() => setSaveMessage(""), 3000);
} catch (err: unknown) {
const message =
err instanceof Error ? err.message : "Failed to save profile";
setSaveMessage(message);
} finally {
setSaving(false);
}
};
const handleLogout = () => {
logout();
router.push("/login");
};
if (loading) {
return (
<div className="min-h-dvh bg-[#0a0a0f] flex items-center justify-center">
<div className="w-8 h-8 rounded-full border-2 border-[#D4AF37]/30 border-t-[#D4AF37] animate-spin" />
</div>
);
}
if (!user) return null;
return (
<div className="min-h-dvh bg-[#0a0a0f] pb-24">
{/* Header */}
<div className="px-4 pt-6 pb-2">
<h1 className="text-xl font-bold text-white">Profile</h1>
</div>
<div className="px-4 space-y-5 animate-fade-in">
{/* User Card */}
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-5">
<div className="flex items-center gap-4">
<div className="w-14 h-14 rounded-full bg-[#D4AF37]/15 flex items-center justify-center">
<User size={24} className="text-[#D4AF37]" />
</div>
<div className="flex-1">
<h2 className="text-lg font-bold text-white">
{user.preferredName || user.name}
</h2>
<p className="text-sm text-gray-500">{user.email}</p>
</div>
</div>
{/* Tier Badge */}
<div
className={`mt-4 flex items-center justify-between ${tc.bg} ${tc.border} border rounded-xl px-4 py-2.5`}
>
<div className="flex items-center gap-2">
<tc.icon size={16} className={tc.text} />
<span className={`text-sm font-semibold ${tc.text}`}>
{tier === "free"
? "Free"
: tier === "premium"
? "Premium"
: "Pro"}
</span>
</div>
<div className="flex items-center gap-1.5">
<Zap size={14} className="text-gray-600" />
<span className="text-sm text-gray-400">
{tier === "pro"
? "Unlimited"
: `${user.dailyMsgCount}/10 AI today`}
</span>
</div>
</div>
{/* FLH Balance */}
<div className="mt-3 flex items-center justify-between bg-[#1a1a24] rounded-xl px-4 py-2.5">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-400">
FLH Balance
</span>
</div>
<span className="text-lg font-bold text-[#D4AF37]">
{user.flhBalance.toLocaleString()}
</span>
</div>
</div>
{/* Edit Profile Form */}
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-5">
<h3 className="text-sm font-semibold text-white mb-4">
Edit Profile
</h3>
{saveMessage && (
<div
className={`mb-4 p-3 rounded-xl text-sm text-center ${
saveMessage === "Profile updated successfully"
? "bg-emerald-900/20 border border-emerald-800/40 text-emerald-400"
: "bg-red-900/20 border border-red-800/40 text-red-400"
}`}
>
{saveMessage}
</div>
)}
<form onSubmit={handleSave} className="space-y-4">
{/* Name */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1.5 uppercase tracking-wider">
Name
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full bg-[#1a1a24] border border-gray-800/60 rounded-xl px-4 py-3 text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/50 focus:ring-1 focus:ring-[#D4AF37]/30 transition"
/>
</div>
{/* Preferred Name */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1.5 uppercase tracking-wider">
Preferred Name
</label>
<input
type="text"
value={preferredName}
onChange={(e) => setPreferredName(e.target.value)}
placeholder="How Nur AI should address you"
className="w-full bg-[#1a1a24] border border-gray-800/60 rounded-xl px-4 py-3 text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/50 focus:ring-1 focus:ring-[#D4AF37]/30 transition"
/>
</div>
{/* Coach Persona */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1.5 uppercase tracking-wider">
Coach Persona
</label>
<select
value={coachPersona}
onChange={(e) => setCoachPersona(e.target.value)}
className="w-full bg-[#1a1a24] border border-gray-800/60 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-[#D4AF37]/50 focus:ring-1 focus:ring-[#D4AF37]/30 transition appearance-none"
>
<option value="">Default</option>
{PERSONAS.map((p) => (
<option key={p.id} value={p.id}>
{p.icon} {p.name}
</option>
))}
</select>
</div>
{/* Madhab */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1.5 uppercase tracking-wider">
Madhab (School of Thought)
</label>
<select
value={madhab}
onChange={(e) => setMadhab(e.target.value)}
className="w-full bg-[#1a1a24] border border-gray-800/60 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-[#D4AF37]/50 focus:ring-1 focus:ring-[#D4AF37]/30 transition appearance-none"
>
<option value="">Not specified</option>
{VALID_MADHABS.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
</div>
{/* Coaching Goals */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1.5 uppercase tracking-wider">
Coaching Goals
</label>
<textarea
value={coachingGoals}
onChange={(e) => setCoachingGoals(e.target.value)}
placeholder="What would you like to work on?"
rows={3}
className="w-full bg-[#1a1a24] border border-gray-800/60 rounded-xl px-4 py-3 text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/50 focus:ring-1 focus:ring-[#D4AF37]/30 transition resize-none"
/>
</div>
<button
type="submit"
disabled={saving}
className="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-[#D4AF37] text-[#0a0a0f] font-semibold text-sm active:opacity-80 disabled:opacity-50 transition"
>
{saving ? (
<>
<span className="w-4 h-4 rounded-full border-2 border-[#0a0a0f]/30 border-t-[#0a0a0f] animate-spin" />
Saving...
</>
) : (
<>
<Save size={16} />
Save Changes
</>
)}
</button>
</form>
</div>
{/* Account Info */}
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-5">
<h3 className="text-sm font-semibold text-white mb-3">
Account Details
</h3>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-500">Member since</span>
<span className="text-gray-300">
{new Date(user.createdAt ?? "").toLocaleDateString("en-MY", {
year: "numeric",
month: "long",
day: "numeric",
})}
</span>
</div>
{user.trialEndsAt && (
<div className="flex justify-between">
<span className="text-gray-500">Trial ends</span>
<span className="text-gray-300">
{new Date(user.trialEndsAt).toLocaleDateString("en-MY", {
year: "numeric",
month: "long",
day: "numeric",
})}
</span>
</div>
)}
{user.madhab && (
<div className="flex justify-between">
<span className="text-gray-500">Madhab</span>
<span className="text-gray-300">{user.madhab}</span>
</div>
)}
</div>
</div>
{/* Logout */}
<button
onClick={handleLogout}
className="w-full flex items-center justify-between py-3.5 px-5 rounded-xl bg-red-900/10 border border-red-800/30 text-red-400 active:bg-red-900/20 transition"
>
<div className="flex items-center gap-2">
<LogOut size={16} />
<span className="text-sm font-medium">Sign Out</span>
</div>
<ChevronRight size={16} />
</button>
<div className="h-4" />
</div>
</div>
);
}
+166
View File
@@ -0,0 +1,166 @@
"use client";
import { useState, FormEvent } from "react";
import { useAuth } from "@/lib/AuthContext";
import { useRouter } from "next/navigation";
import Link from "next/link";
export default function RegisterPage() {
const { register } = useAuth();
const router = useRouter();
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError("");
if (!name.trim() || !email.trim() || !password.trim()) {
setError("Please fill in all fields");
return;
}
if (password.length < 6) {
setError("Password must be at least 6 characters");
return;
}
setLoading(true);
try {
await register(email.trim(), name.trim(), password);
router.push("/");
} catch (err: unknown) {
const message =
err instanceof Error
? err.message
: "Registration failed. Please try again.";
setError(message);
} finally {
setLoading(false);
}
};
return (
<div className="min-h-dvh bg-[#0a0a0f] flex flex-col items-center justify-center px-6">
<div className="w-full max-w-sm">
{/* Logo / Brand */}
<div className="text-center mb-8">
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-[#D4AF37]/20 to-[#D4AF37]/5 border border-[#D4AF37]/30 flex items-center justify-center mx-auto mb-4">
<span className="text-3xl"></span>
</div>
<h1 className="text-2xl font-bold text-white">Join Falah</h1>
<p className="text-sm text-gray-500 mt-1">
Start your Islamic lifestyle journey
</p>
</div>
{/* Error */}
{error && (
<div className="mb-4 p-3 rounded-xl bg-red-900/20 border border-red-800/40 text-red-400 text-sm text-center">
{error}
</div>
)}
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor="name"
className="block text-xs font-medium text-gray-500 mb-1.5 uppercase tracking-wider"
>
Name
</label>
<input
id="name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Your name"
autoComplete="name"
autoFocus
className="w-full bg-[#111118] border border-gray-800/60 rounded-xl px-4 py-3 text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/50 focus:ring-1 focus:ring-[#D4AF37]/30 transition"
/>
</div>
<div>
<label
htmlFor="email"
className="block text-xs font-medium text-gray-500 mb-1.5 uppercase tracking-wider"
>
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
autoComplete="email"
className="w-full bg-[#111118] border border-gray-800/60 rounded-xl px-4 py-3 text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/50 focus:ring-1 focus:ring-[#D4AF37]/30 transition"
/>
</div>
<div>
<label
htmlFor="password"
className="block text-xs font-medium text-gray-500 mb-1.5 uppercase tracking-wider"
>
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="At least 6 characters"
autoComplete="new-password"
className="w-full bg-[#111118] border border-gray-800/60 rounded-xl px-4 py-3 text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/50 focus:ring-1 focus:ring-[#D4AF37]/30 transition"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-3 rounded-xl bg-[#D4AF37] text-[#0a0a0f] font-semibold text-sm active:opacity-80 disabled:opacity-50 transition"
>
{loading ? (
<span className="flex items-center justify-center gap-2">
<span className="w-4 h-4 rounded-full border-2 border-[#0a0a0f]/30 border-t-[#0a0a0f] animate-spin" />
Creating account...
</span>
) : (
"Create Account"
)}
</button>
</form>
{/* Sign in link */}
<div className="text-center mt-6">
<p className="text-xs text-gray-600">
Already have an account?{" "}
<Link
href="/login"
className="text-[#D4AF37] font-medium hover:underline"
>
Sign in
</Link>
</p>
</div>
{/* Bonus info */}
<div className="mt-6 rounded-xl bg-[#111118] border border-gray-800/60 p-3 text-center">
<p className="text-[10px] text-gray-600 uppercase tracking-wider mb-1">
New Member Bonus
</p>
<p className="text-xs text-gray-500">
Get <span className="text-[#D4AF37] font-semibold">5,000 FLH</span>{" "}
free on sign up + 7-day premium trial
</p>
</div>
</div>
</div>
);
}
+727
View File
@@ -0,0 +1,727 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useAuth } from "@/lib/AuthContext";
import { useRouter } from "next/navigation";
import {
ShoppingBag,
Search,
Plus,
X,
Crown,
Zap,
Star,
AlertTriangle,
CheckCircle,
Loader2,
FileText,
Image,
Music,
Video,
Code,
BookOpen,
Grid3X3,
ChevronDown,
} from "lucide-react";
const CATEGORIES = ["All", "E-Books", "Courses", "Design", "Audio", "Video", "Software", "Other"];
const CATEGORY_ICONS: Record<string, any> = {
"E-Books": BookOpen,
Courses: BookOpen,
Design: Image,
Audio: Music,
Video: Video,
Software: Code,
Other: Grid3X3,
};
interface Listing {
id: string;
title: string;
description: string;
category: string;
priceFlh: number;
featured: boolean;
featuredUntil: string | null;
status: string;
createdAt: string;
seller: {
id: string;
name: string;
isPremium: boolean;
isPro: boolean;
};
}
export default function SouqPage() {
const { user, token, loading } = useAuth();
const router = useRouter();
const [listings, setListings] = useState<Listing[]>([]);
const [loadingListings, setLoadingListings] = useState(true);
const [error, setError] = useState<string | null>(null);
// Filters
const [activeCategory, setActiveCategory] = useState("All");
const [searchQuery, setSearchQuery] = useState("");
// Create listing modal
const [showCreateModal, setShowCreateModal] = useState(false);
const [createForm, setCreateForm] = useState({
title: "",
description: "",
category: "Other",
priceFlh: "",
});
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
// Purchase flow
const [purchasingId, setPurchasingId] = useState<string | null>(null);
const [showConfirmDialog, setShowConfirmDialog] = useState<{
listing: Listing;
} | null>(null);
const [purchaseResult, setPurchaseResult] = useState<{
success: boolean;
message: string;
} | null>(null);
const fetchListings = useCallback(async () => {
setLoadingListings(true);
setError(null);
try {
const params = new URLSearchParams();
if (activeCategory !== "All") params.set("category", activeCategory);
if (searchQuery.trim()) params.set("search", searchQuery.trim());
const res = await fetch(`/api/marketplace/listings?${params.toString()}`);
const data = await res.json();
if (res.ok) {
setListings(data.listings);
} else {
setError(data.error || "Failed to load listings");
}
} catch {
setError("Network error. Please try again.");
} finally {
setLoadingListings(false);
}
}, [activeCategory, searchQuery]);
useEffect(() => {
if (!loading && !token) {
router.push("/login");
return;
}
}, [loading, token, router]);
useEffect(() => {
if (token) {
fetchListings();
}
}, [token, fetchListings]);
// Debounce search
useEffect(() => {
const timer = setTimeout(() => {
if (token) fetchListings();
}, 400);
return () => clearTimeout(timer);
}, [searchQuery]);
const handleCreateListing = async (e: React.FormEvent) => {
e.preventDefault();
setCreateError(null);
setCreating(true);
try {
const priceNum = parseInt(createForm.priceFlh, 10);
if (isNaN(priceNum) || priceNum < 1) {
setCreateError("Price must be a positive whole number");
setCreating(false);
return;
}
const res = await fetch("/api/marketplace/listings", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
title: createForm.title,
description: createForm.description,
category: createForm.category,
priceFlh: priceNum,
}),
});
const data = await res.json();
if (res.ok) {
setShowCreateModal(false);
setCreateForm({ title: "", description: "", category: "Other", priceFlh: "" });
fetchListings();
} else {
setCreateError(data.error || "Failed to create listing");
}
} catch {
setCreateError("Network error. Please try again.");
} finally {
setCreating(false);
}
};
const handlePurchaseClick = (listing: Listing) => {
setShowConfirmDialog({ listing });
setPurchaseResult(null);
};
const confirmPurchase = async () => {
if (!showConfirmDialog) return;
const listing = showConfirmDialog.listing;
setPurchasingId(listing.id);
setPurchaseResult(null);
try {
const res = await fetch("/api/marketplace/purchase", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ listingId: listing.id }),
});
const data = await res.json();
if (res.ok) {
setPurchaseResult({ success: true, message: data.message });
setListings((prev) => prev.filter((l) => l.id !== listing.id));
} else {
setPurchaseResult({ success: false, message: data.error || "Purchase failed" });
}
} catch {
setPurchaseResult({ success: false, message: "Network error. Please try again." });
} finally {
setPurchasingId(null);
}
};
const canSell = user?.isPremium || user?.isPro;
const CategoryIcon = activeCategory !== "All" && CATEGORY_ICONS[activeCategory]
? CATEGORY_ICONS[activeCategory]
: ShoppingBag;
if (loading) {
return (
<div className="min-h-dvh bg-[#0a0a0f] flex items-center justify-center">
<div className="w-8 h-8 rounded-full border-2 border-[#D4AF37]/30 border-t-[#D4AF37] animate-spin" />
</div>
);
}
if (!user) return null;
return (
<div className="min-h-dvh bg-[#0a0a0f] pb-28">
{/* Header */}
<div className="px-4 pt-6 pb-4">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<div className="w-9 h-9 rounded-xl bg-[#D4AF37]/15 flex items-center justify-center">
<ShoppingBag size={18} className="text-[#D4AF37]" />
</div>
<div>
<h1 className="text-lg font-bold text-white">Souq</h1>
<p className="text-[10px] text-gray-500">Islamic Marketplace</p>
</div>
</div>
<div className="flex items-center gap-2">
{canSell && (
<button
onClick={() => {
setCreateForm({ title: "", description: "", category: "Other", priceFlh: "" });
setCreateError(null);
setShowCreateModal(true);
}}
className="flex items-center gap-1.5 bg-[#D4AF37]/15 border border-[#D4AF37]/30 rounded-xl px-3 py-2 active:bg-[#D4AF37]/25 transition"
>
<Plus size={14} className="text-[#D4AF37]" />
<span className="text-xs font-medium text-[#D4AF37]">Sell</span>
</button>
)}
<div className="flex items-center gap-1.5 bg-gray-900/60 border border-gray-800/60 rounded-xl px-3 py-2">
<Zap size={14} className="text-[#D4AF37]" />
<span className="text-xs font-bold text-white">
{user.flhBalance.toLocaleString()}
</span>
<span className="text-[9px] text-gray-600">FLH</span>
</div>
</div>
</div>
</div>
{/* Platform fee banner */}
<div className="mx-4 mb-3 bg-gradient-to-r from-amber-900/15 to-gray-900/50 border border-amber-800/20 rounded-xl px-3 py-2">
<div className="flex items-center gap-2">
<AlertTriangle size={12} className="text-amber-400 shrink-0" />
<p className="text-[10px] text-amber-300/80">
Platform fee: 1.5% for standard listings, <strong>0% for Pro sellers</strong>.
All prices in FLH (Falah Coins).
</p>
</div>
</div>
{/* Search bar */}
<div className="px-4 mb-3">
<div className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
<input
type="text"
placeholder="Search listings..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full bg-[#111118] border border-gray-800/60 rounded-xl pl-10 pr-4 py-2.5 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/40 transition"
/>
</div>
</div>
{/* Category pills */}
<div className="px-4 mb-4 overflow-x-auto">
<div className="flex gap-2 pb-1" style={{ minWidth: "max-content" }}>
{CATEGORIES.map((cat) => {
const Icon = cat !== "All" && CATEGORY_ICONS[cat] ? CATEGORY_ICONS[cat] : ShoppingBag;
const isActive = activeCategory === cat;
return (
<button
key={cat}
onClick={() => setActiveCategory(cat)}
className={`flex items-center gap-1.5 px-3.5 py-2 rounded-xl text-xs font-medium whitespace-nowrap transition-all ${
isActive
? "bg-[#D4AF37]/20 text-[#D4AF37] border border-[#D4AF37]/30"
: "bg-[#111118] text-gray-500 border border-gray-800/60 active:bg-gray-800/60"
}`}
>
<Icon size={13} />
{cat}
</button>
);
})}
</div>
</div>
{/* Listings grid */}
<div className="px-4">
{loadingListings ? (
<div className="flex flex-col items-center justify-center py-20">
<Loader2 size={32} className="text-[#D4AF37] animate-spin mb-3" />
<p className="text-sm text-gray-500">Loading listings...</p>
</div>
) : error ? (
<div className="bg-red-900/10 border border-red-800/30 rounded-2xl p-6 text-center">
<AlertTriangle size={32} className="mx-auto text-red-400 mb-2" />
<p className="text-sm text-red-300">{error}</p>
<button
onClick={fetchListings}
className="mt-3 text-xs text-[#D4AF37] underline"
>
Try again
</button>
</div>
) : listings.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20">
<div className="w-16 h-16 rounded-2xl bg-gray-800/40 flex items-center justify-center mb-4">
<ShoppingBag size={28} className="text-gray-600" />
</div>
<h3 className="text-base font-semibold text-gray-400 mb-1">
{searchQuery || activeCategory !== "All"
? "No listings found"
: "Marketplace is empty"}
</h3>
<p className="text-xs text-gray-600 text-center max-w-xs">
{searchQuery || activeCategory !== "All"
? "Try a different search or category"
: canSell
? "Be the first to create a listing!"
: "No listings available yet. Check back soon."}
</p>
{canSell && !searchQuery && activeCategory === "All" && (
<button
onClick={() => {
setCreateForm({ title: "", description: "", category: "Other", priceFlh: "" });
setCreateError(null);
setShowCreateModal(true);
}}
className="mt-4 flex items-center gap-2 bg-[#D4AF37]/15 border border-[#D4AF37]/30 rounded-xl px-4 py-2.5 active:bg-[#D4AF37]/25 transition"
>
<Plus size={16} className="text-[#D4AF37]" />
<span className="text-sm font-medium text-[#D4AF37]">Create Listing</span>
</button>
)}
</div>
) : (
<div className="grid grid-cols-2 gap-3">
{listings.map((listing) => (
<ListingCard
key={listing.id}
listing={listing}
onBuy={() => handlePurchaseClick(listing)}
userBalance={user.flhBalance}
/>
))}
</div>
)}
</div>
{/* Create listing modal */}
{showCreateModal && (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={() => setShowCreateModal(false)}
/>
<div className="relative w-full sm:max-w-md bg-[#111118] border border-gray-800/60 rounded-t-2xl sm:rounded-2xl p-5 animate-fade-in max-h-[90dvh] overflow-y-auto">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Plus size={18} className="text-[#D4AF37]" />
<h2 className="text-base font-bold text-white">New Listing</h2>
</div>
<button
onClick={() => setShowCreateModal(false)}
className="w-8 h-8 rounded-full bg-gray-800/60 flex items-center justify-center active:bg-gray-700/60 transition"
>
<X size={16} className="text-gray-500" />
</button>
</div>
<form onSubmit={handleCreateListing} className="space-y-4">
<div>
<label className="block text-xs font-medium text-gray-500 mb-1.5">
Title *
</label>
<input
type="text"
value={createForm.title}
onChange={(e) =>
setCreateForm((f) => ({ ...f, title: e.target.value }))
}
placeholder="e.g. Tafsir Ibn Kathir PDF"
required
maxLength={100}
className="w-full bg-[#0a0a0f] border border-gray-800/60 rounded-xl px-3.5 py-2.5 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/40 transition"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 mb-1.5">
Description *
</label>
<textarea
value={createForm.description}
onChange={(e) =>
setCreateForm((f) => ({ ...f, description: e.target.value }))
}
placeholder="Describe your listing..."
required
maxLength={2000}
rows={3}
className="w-full bg-[#0a0a0f] border border-gray-800/60 rounded-xl px-3.5 py-2.5 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/40 transition resize-none"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 mb-1.5">
Category *
</label>
<select
value={createForm.category}
onChange={(e) =>
setCreateForm((f) => ({ ...f, category: e.target.value }))
}
className="w-full bg-[#0a0a0f] border border-gray-800/60 rounded-xl px-3.5 py-2.5 text-sm text-white focus:outline-none focus:border-[#D4AF37]/40 transition appearance-none"
style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E")`,
backgroundRepeat: "no-repeat",
backgroundPosition: "right 12px center",
}}
>
{CATEGORIES.filter((c) => c !== "All").map((cat) => (
<option key={cat} value={cat}>
{cat}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 mb-1.5">
Price (FLH) *
</label>
<div className="relative">
<input
type="number"
value={createForm.priceFlh}
onChange={(e) =>
setCreateForm((f) => ({ ...f, priceFlh: e.target.value }))
}
placeholder="100"
required
min={1}
className="w-full bg-[#0a0a0f] border border-gray-800/60 rounded-xl pl-3.5 pr-10 py-2.5 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/40 transition"
/>
<span className="absolute right-3.5 top-1/2 -translate-y-1/2 text-xs font-medium text-[#D4AF37]">
FLH
</span>
</div>
</div>
{createError && (
<div className="bg-red-900/10 border border-red-800/30 rounded-xl p-3">
<p className="text-xs text-red-300">{createError}</p>
</div>
)}
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={() => setShowCreateModal(false)}
className="flex-1 py-2.5 rounded-xl text-sm font-medium text-gray-500 bg-gray-800/40 border border-gray-800/60 active:bg-gray-700/40 transition"
>
Cancel
</button>
<button
type="submit"
disabled={creating}
className="flex-1 py-2.5 rounded-xl text-sm font-semibold text-[#0a0a0f] bg-[#D4AF37] active:bg-[#c5a233] transition flex items-center justify-center gap-2 disabled:opacity-60"
>
{creating ? (
<>
<Loader2 size={14} className="animate-spin" />
Creating...
</>
) : (
"Create Listing"
)}
</button>
</div>
</form>
</div>
</div>
)}
{/* Purchase confirmation dialog */}
{showConfirmDialog && (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={() => {
if (!purchasingId) {
setShowConfirmDialog(null);
setPurchaseResult(null);
}
}}
/>
<div className="relative w-full sm:max-w-sm bg-[#111118] border border-gray-800/60 rounded-t-2xl sm:rounded-2xl p-5 animate-fade-in">
{purchaseResult ? (
<div className="text-center py-4">
{purchaseResult.success ? (
<>
<div className="w-14 h-14 rounded-full bg-emerald-900/30 flex items-center justify-center mx-auto mb-3">
<CheckCircle size={28} className="text-emerald-400" />
</div>
<h3 className="text-base font-bold text-white mb-2">Purchase Successful!</h3>
<p className="text-xs text-gray-400 mb-4">{purchaseResult.message}</p>
</>
) : (
<>
<div className="w-14 h-14 rounded-full bg-red-900/30 flex items-center justify-center mx-auto mb-3">
<AlertTriangle size={28} className="text-red-400" />
</div>
<h3 className="text-base font-bold text-white mb-2">Purchase Failed</h3>
<p className="text-xs text-gray-400 mb-4">{purchaseResult.message}</p>
</>
)}
<button
onClick={() => {
setShowConfirmDialog(null);
setPurchaseResult(null);
}}
className="w-full py-2.5 rounded-xl text-sm font-semibold text-[#0a0a0f] bg-[#D4AF37] active:bg-[#c5a233] transition"
>
Close
</button>
</div>
) : (
<>
<div className="flex items-center gap-2 mb-4">
<ShoppingBag size={18} className="text-[#D4AF37]" />
<h2 className="text-base font-bold text-white">Confirm Purchase</h2>
</div>
<div className="bg-[#0a0a0f] rounded-xl p-3 mb-4">
<p className="text-sm font-semibold text-white mb-1">
{showConfirmDialog.listing.title}
</p>
<div className="flex items-center gap-2">
<Zap size={14} className="text-[#D4AF37]" />
<span className="text-lg font-bold text-[#D4AF37]">
{showConfirmDialog.listing.priceFlh.toLocaleString()}
</span>
<span className="text-xs text-gray-600">FLH</span>
</div>
<div className="flex items-center gap-1.5 mt-2">
<span className="text-[10px] text-gray-500">From:</span>
<span className="text-[10px] font-medium text-gray-300">
{showConfirmDialog.listing.seller.name}
</span>
{showConfirmDialog.listing.seller.isPro && (
<span className="flex items-center gap-0.5 text-[9px] text-purple-400">
<Crown size={9} /> Pro
</span>
)}
</div>
</div>
<div className="bg-[#0a0a0f] rounded-xl p-3 mb-4">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-gray-500">Your balance</span>
<span className="text-xs font-medium text-white">
{user.flhBalance.toLocaleString()} FLH
</span>
</div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-gray-500">Cost</span>
<span className="text-xs font-medium text-red-400">
-{showConfirmDialog.listing.priceFlh.toLocaleString()} FLH
</span>
</div>
<div className="border-t border-gray-800/60 pt-1.5 mt-1.5 flex items-center justify-between">
<span className="text-xs font-medium text-gray-400">Remaining</span>
<span className="text-xs font-bold text-white">
{(user.flhBalance - showConfirmDialog.listing.priceFlh).toLocaleString()} FLH
</span>
</div>
</div>
{user.flhBalance < showConfirmDialog.listing.priceFlh && (
<div className="bg-red-900/10 border border-red-800/30 rounded-xl p-3 mb-4">
<p className="text-xs text-red-300">
Insufficient balance. You need {showConfirmDialog.listing.priceFlh.toLocaleString()} FLH
but only have {user.flhBalance.toLocaleString()} FLH.
</p>
</div>
)}
<div className="flex gap-3">
<button
onClick={() => {
setShowConfirmDialog(null);
setPurchaseResult(null);
}}
disabled={!!purchasingId}
className="flex-1 py-2.5 rounded-xl text-sm font-medium text-gray-500 bg-gray-800/40 border border-gray-800/60 active:bg-gray-700/40 transition disabled:opacity-40"
>
Cancel
</button>
<button
onClick={confirmPurchase}
disabled={
purchasingId === showConfirmDialog.listing.id ||
user.flhBalance < showConfirmDialog.listing.priceFlh
}
className="flex-1 py-2.5 rounded-xl text-sm font-semibold text-[#0a0a0f] bg-[#D4AF37] active:bg-[#c5a233] transition flex items-center justify-center gap-2 disabled:opacity-60"
>
{purchasingId === showConfirmDialog.listing.id ? (
<>
<Loader2 size={14} className="animate-spin" />
Processing...
</>
) : (
"Confirm Purchase"
)}
</button>
</div>
</>
)}
</div>
</div>
)}
</div>
);
}
function ListingCard({
listing,
onBuy,
userBalance,
}: {
listing: Listing;
onBuy: () => void;
userBalance: number;
}) {
const CategoryIcon = CATEGORY_ICONS[listing.category] || FileText;
const canAfford = userBalance >= listing.priceFlh;
return (
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl overflow-hidden active:scale-[0.98] transition-transform">
{/* Image placeholder */}
<div className="h-28 bg-gradient-to-br from-gray-800/60 to-gray-900/60 flex items-center justify-center relative">
<div className="w-10 h-10 rounded-xl bg-[#D4AF37]/10 flex items-center justify-center">
<CategoryIcon size={20} className="text-[#D4AF37]/60" />
</div>
{listing.featured && (
<div className="absolute top-2 right-2 flex items-center gap-1 bg-[#D4AF37]/20 border border-[#D4AF37]/30 rounded-full px-2 py-0.5">
<Star size={10} className="text-[#D4AF37]" />
<span className="text-[8px] font-bold text-[#D4AF37]">Featured</span>
</div>
)}
</div>
{/* Content */}
<div className="p-3">
<h3 className="text-sm font-semibold text-white truncate mb-1">
{listing.title}
</h3>
<p className="text-[10px] text-gray-600 line-clamp-2 mb-2 leading-relaxed">
{listing.description}
</p>
{/* Seller info */}
<div className="flex items-center gap-1 mb-2">
<span className="text-[9px] text-gray-600">by</span>
<span className="text-[9px] font-medium text-gray-400 truncate max-w-[80px]">
{listing.seller.name}
</span>
{listing.seller.isPro && (
<Crown size={8} className="text-purple-400 shrink-0" />
)}
{listing.seller.isPremium && !listing.seller.isPro && (
<Star size={8} className="text-[#D4AF37] shrink-0" />
)}
</div>
{/* Price and buy */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-1">
<Zap size={12} className="text-[#D4AF37]" />
<span className="text-sm font-bold text-[#D4AF37]">
{listing.priceFlh.toLocaleString()}
</span>
<span className="text-[8px] text-gray-600">FLH</span>
</div>
<button
onClick={onBuy}
disabled={!canAfford}
className={`text-[10px] font-semibold px-3 py-1.5 rounded-lg transition ${
canAfford
? "bg-[#D4AF37] text-[#0a0a0f] active:bg-[#c5a233]"
: "bg-gray-800/60 text-gray-600 cursor-not-allowed"
}`}
>
{canAfford ? "Buy" : "Insufficient"}
</button>
</div>
</div>
</div>
);
}
+369
View File
@@ -0,0 +1,369 @@
"use client";
import { useState, useEffect, Suspense } from "react";
import { useAuth } from "@/lib/AuthContext";
import { useRouter, useSearchParams } from "next/navigation";
import { Crown, Zap, Check, ChevronLeft, Loader2 } from "lucide-react";
const TIERS = [
{
id: "free",
name: "Free",
price: "$0",
period: "forever",
icon: Zap,
color: "text-gray-400",
bgColor: "bg-gray-900/50",
borderColor: "border-gray-700/50",
features: [
"10 AI messages per day",
"NurBuddy persona only",
"Basic halal monitor (10 queries/day)",
"Browse marketplace",
"Standard FLH earning rate",
],
cta: "Current Plan",
disabled: true,
},
{
id: "premium",
name: "Premium",
usd: 4.99,
price: "$4.99",
period: "per month",
priceId: "price_premium_monthly",
icon: Crown,
color: "text-[#D4AF37]",
bgColor: "bg-gradient-to-br from-[#D4AF37]/20 to-[#0a0a0f]",
borderColor: "border-[#D4AF37]/50",
features: [
"100 AI messages per day",
"All 4 coach personas",
"Unlimited halal monitor queries",
"Sell on marketplace (up to 20 items)",
"2x FLH earning rate",
"Forum posting access",
"Priority support",
],
cta: "Go Premium",
badge: "MOST POPULAR",
highlighted: true,
},
{
id: "pro",
name: "Pro",
usd: 9.99,
price: "$9.99",
period: "per month",
priceId: "price_pro_monthly",
icon: Crown,
color: "text-purple-400",
bgColor: "bg-purple-900/20",
borderColor: "border-purple-500/30",
features: [
"Unlimited AI messages",
"All 6 coach personas",
"Unlimited halal monitor queries",
"Sell on marketplace (unlimited items)",
"3x FLH earning rate",
"Forum posting access",
"Priority support",
"Early access to new features",
"Featured listings on marketplace",
],
cta: "Go Pro",
badge: "",
highlighted: false,
},
];
function UpgradeContent() {
const { user, token, loading } = useAuth();
const router = useRouter();
const searchParams = useSearchParams();
const [checkoutLoading, setCheckoutLoading] = useState<string | null>(null);
const [message, setMessage] = useState<{
type: "success" | "error" | "info";
text: string;
} | null>(null);
const isPremium = user?.isPremium || user?.isPro;
const isPro = user?.isPro;
useEffect(() => {
if (!loading && !token) router.push("/login");
}, [loading, token, router]);
useEffect(() => {
const upgrade = searchParams.get("upgrade");
const canceled = searchParams.get("canceled");
const tier = searchParams.get("tier");
if (upgrade === "success") {
setMessage({
type: "success",
text: tier
? `Welcome to ${tier === "pro" ? "Pro" : "Premium"}! 🎉`
: "Upgrade successful! Welcome aboard 🎉",
});
// Clean URL
const newUrl = window.location.pathname;
window.history.replaceState({}, "", newUrl);
// Refresh user data
if (token) {
fetch("/api/auth/me", {
headers: { Authorization: `Bearer ${token}` },
})
.then((r) => r.json())
.then((d) => {
if (d.user) {
// The AuthContext refreshUser will pick this up
window.location.reload();
}
})
.catch(() => {});
}
}
if (canceled === "true") {
setMessage({
type: "info",
text: "Checkout was canceled. No charges were made.",
});
const newUrl = window.location.pathname;
window.history.replaceState({}, "", newUrl);
}
}, [searchParams, token]);
const handleUpgrade = async (tierId: string, priceId: string) => {
if (!token) return;
setCheckoutLoading(tierId);
setMessage(null);
try {
const res = await fetch("/api/upgrade/create-checkout", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ priceId }),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Upgrade failed");
}
const data = await res.json();
if (data.url) {
router.push(data.url);
}
} catch (err: unknown) {
const message =
err instanceof Error ? err.message : "Upgrade failed";
setMessage({ type: "error", text: message });
setTimeout(() => setMessage(null), 4000);
} finally {
setCheckoutLoading(null);
}
};
if (loading) {
return (
<div className="min-h-dvh bg-[#0a0a0f] flex items-center justify-center">
<Loader2 size={24} className="animate-spin text-[#D4AF37]" />
</div>
);
}
if (!user) return null;
return (
<div className="min-h-dvh bg-[#0a0a0f] pb-24">
{/* Header */}
<div className="px-4 pt-6 pb-2 flex items-center gap-3">
<button
onClick={() => router.back()}
className="w-9 h-9 rounded-full bg-[#111118] border border-gray-800/60 flex items-center justify-center active:scale-90 transition"
>
<ChevronLeft size={18} className="text-gray-400" />
</button>
<div>
<h1 className="text-lg font-bold text-white">Choose your plan</h1>
<p className="text-xs text-gray-500">Cancel anytime</p>
</div>
</div>
{/* Trial Banner */}
<div className="mx-4 mt-2 mb-5 bg-gradient-to-r from-[#D4AF37]/10 to-purple-900/10 border border-[#D4AF37]/20 rounded-2xl p-3">
<div className="flex items-center gap-2">
<SparklesIcon />
<p className="text-sm text-[#D4AF37] font-medium">
New accounts include a 7-day Premium trial free
</p>
</div>
</div>
{/* Messages */}
{message && (
<div className="mx-4 mb-4">
<div
className={`p-3 rounded-xl text-sm text-center ${
message.type === "success"
? "bg-emerald-900/20 border border-emerald-800/40 text-emerald-400"
: message.type === "error"
? "bg-red-900/20 border border-red-800/40 text-red-400"
: "bg-blue-900/20 border border-blue-800/40 text-blue-400"
}`}
>
{message.text}
</div>
</div>
)}
{/* Tier Cards */}
<div className="px-4 space-y-4">
{TIERS.map((tier) => {
const Icon = tier.icon;
const isActive =
tier.id === "pro"
? isPro
: tier.id === "premium"
? isPremium
: true;
const isDisabled = tier.disabled || isActive;
return (
<div
key={tier.id}
className={`relative rounded-2xl p-5 ${tier.bgColor} ${tier.borderColor} border ${
tier.highlighted ? "ring-2 ring-[#D4AF37]/30" : ""
}`}
>
{/* Badge */}
{tier.badge && (
<div className="absolute -top-2.5 right-4 bg-[#D4AF37] text-[#0a0a0f] text-[10px] font-bold uppercase tracking-wider px-3 py-1 rounded-full">
{tier.badge}
</div>
)}
{/* Tier Header */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Icon size={20} className={tier.color} />
<span className={`text-lg font-bold text-white`}>
{tier.name}
</span>
</div>
<div className="text-right">
<p className="text-xl font-bold text-white">{tier.price}</p>
{tier.period && (
<p className="text-[11px] text-gray-500">{tier.period}</p>
)}
</div>
</div>
{/* Features */}
<ul className="space-y-2 mb-4">
{tier.features.map((feature, i) => (
<li
key={i}
className="flex items-start gap-2 text-sm text-gray-400"
>
<Check
size={14}
className={`mt-0.5 shrink-0 ${
tier.id === "free"
? "text-gray-600"
: tier.id === "pro"
? "text-purple-400"
: "text-[#D4AF37]"
}`}
/>
<span>{feature}</span>
</li>
))}
</ul>
{/* CTA Button */}
{tier.id !== "free" && (
<button
onClick={() =>
!isActive && handleUpgrade(tier.id, tier.priceId!)
}
disabled={isDisabled || checkoutLoading === tier.id}
className={`w-full py-3 rounded-xl font-semibold text-sm active:opacity-80 disabled:opacity-50 transition flex items-center justify-center gap-2 ${
tier.id === "premium"
? "bg-[#D4AF37] text-[#0a0a0f]"
: "bg-purple-600 text-white"
}`}
>
{checkoutLoading === tier.id ? (
<>
<Loader2 size={16} className="animate-spin" />
Processing...
</>
) : isActive ? (
"Current Plan"
) : (
tier.cta
)}
</button>
)}
{tier.id === "free" && (
<div className="w-full py-3 rounded-xl bg-gray-900/50 border border-gray-700/30 text-gray-500 font-semibold text-sm text-center">
{tier.cta}
</div>
)}
</div>
);
})}
</div>
{/* Footer */}
<div className="px-4 mt-6 text-center">
<p className="text-xs text-gray-600">
Secure payments via Polar. Cancel anytime from your account settings.
</p>
</div>
<div className="h-8" />
</div>
);
}
function SparklesIcon() {
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-[#D4AF37] shrink-0"
>
<path d="M12 3l1.5 5.5L19 10l-5.5 1.5L12 17l-1.5-5.5L5 10l5.5-1.5z" />
<path d="M19 17l-1.5-2.5L15 15l1.5 2.5L15 20l2.5-1.5L19 20l-1.5-2.5z" />
</svg>
);
}
export default function UpgradePage() {
return (
<Suspense
fallback={
<div className="min-h-dvh bg-[#0a0a0f] flex items-center justify-center">
<Loader2 size={24} className="animate-spin text-[#D4AF37]" />
</div>
}
>
<UpgradeContent />
</Suspense>
);
}
+410
View File
@@ -0,0 +1,410 @@
"use client";
import { useState, useEffect, FormEvent } from "react";
import { useAuth } from "@/lib/AuthContext";
import { useRouter } from "next/navigation";
import {
Wallet,
Zap,
Crown,
TrendingUp,
ArrowUpRight,
ArrowDownLeft,
Clock,
Check,
X,
Loader2,
Sparkles,
Info,
} from "lucide-react";
interface CashoutEntry {
id: string;
amountFlh: number;
fiatAmount: number;
status: string;
createdAt: string;
}
const TOP_UP_OPTIONS = [
{ flh: 500, usd: 4.99, bonus: "" },
{ flh: 1100, usd: 9.99, bonus: "+10% bonus" },
{ flh: 3000, usd: 24.99, bonus: "+20% bonus" },
];
const EARNING_TIPS = [
"Post in the forum — earn 50 FLH per approved post",
"List items on Souq marketplace",
"Complete daily AI coaching sessions",
"Refer friends — earn 200 FLH per referral",
"Participate in community challenges",
];
export default function WalletPage() {
const { user, token, loading } = useAuth();
const router = useRouter();
const [balance, setBalance] = useState(0);
const [cashoutHistory, setCashoutHistory] = useState<CashoutEntry[]>([]);
const [cashoutAmount, setCashoutAmount] = useState("");
const [cashoutLoading, setCashoutLoading] = useState(false);
const [cashoutMessage, setCashoutMessage] = useState<{
type: "success" | "error";
text: string;
} | null>(null);
const [topupLoading, setTopupLoading] = useState<number | null>(null);
useEffect(() => {
if (!loading && !token) router.push("/login");
}, [loading, token, router]);
useEffect(() => {
if (token) {
fetchWallet();
}
}, [token]);
const fetchWallet = async () => {
try {
const res = await fetch("/api/wallet", {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
const data = await res.json();
setBalance(data.balance);
setCashoutHistory(data.cashoutHistory || []);
}
} catch {
// ignore
}
};
const handleTopUp = async (amount: number) => {
if (!token) return;
setTopupLoading(amount);
try {
const res = await fetch("/api/wallet/top-up/create-checkout", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ amount }),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Top-up failed");
}
const data = await res.json();
if (data.url) {
router.push(data.url);
}
} catch (err: unknown) {
const message =
err instanceof Error ? err.message : "Top-up failed";
setCashoutMessage({ type: "error", text: message });
setTimeout(() => setCashoutMessage(null), 3000);
} finally {
setTopupLoading(null);
}
};
const handleCashout = async (e: FormEvent) => {
e.preventDefault();
if (!token) return;
const amountNum = parseInt(cashoutAmount, 10);
if (isNaN(amountNum) || amountNum < 1000) {
setCashoutMessage({
type: "error",
text: "Minimum cashout is 1,000 FLH",
});
setTimeout(() => setCashoutMessage(null), 3000);
return;
}
if (amountNum > balance) {
setCashoutMessage({
type: "error",
text: "Insufficient balance",
});
setTimeout(() => setCashoutMessage(null), 3000);
return;
}
setCashoutLoading(true);
setCashoutMessage(null);
try {
const res = await fetch("/api/wallet", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ amountFlh: amountNum }),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Cashout failed");
}
const data = await res.json();
setCashoutMessage({
type: "success",
text: `Cashout request for ${data.cashout.amountFlh} FLH submitted!`,
});
setCashoutAmount("");
await fetchWallet();
setTimeout(() => setCashoutMessage(null), 4000);
} catch (err: unknown) {
const message =
err instanceof Error ? err.message : "Cashout failed";
setCashoutMessage({ type: "error", text: message });
setTimeout(() => setCashoutMessage(null), 3000);
} finally {
setCashoutLoading(false);
}
};
const isPremium = user?.isPremium || user?.isPro;
if (loading) {
return (
<div className="min-h-dvh bg-[#0a0a0f] flex items-center justify-center">
<div className="w-8 h-8 rounded-full border-2 border-[#D4AF37]/30 border-t-[#D4AF37] animate-spin" />
</div>
);
}
if (!user) return null;
return (
<div className="min-h-dvh bg-[#0a0a0f] pb-24">
<div className="px-4 pt-6 pb-2">
<h1 className="text-xl font-bold text-white">Wallet</h1>
</div>
<div className="px-4 space-y-5 animate-fade-in">
{/* Balance Card */}
<div className="bg-gradient-to-br from-[#D4AF37]/20 to-[#0a0a0f] border border-[#D4AF37]/30 rounded-2xl p-6 text-center">
<div className="w-14 h-14 rounded-full bg-[#D4AF37]/20 flex items-center justify-center mx-auto mb-3">
<Wallet size={24} className="text-[#D4AF37]" />
</div>
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">
FLH Balance
</p>
<p className="text-4xl font-bold text-[#D4AF37]">
{balance.toLocaleString()}
</p>
<p className="text-sm text-gray-500 mt-1">
${(balance / 1000).toFixed(2)} USD
</p>
{!isPremium && (
<div className="mt-4 bg-[#D4AF37]/10 border border-[#D4AF37]/20 rounded-xl px-4 py-3">
<div className="flex items-center justify-center gap-2">
<Crown size={14} className="text-[#D4AF37]" />
<p className="text-xs text-[#D4AF37] font-medium">
Premium members earn 2x FLH!
</p>
</div>
<button
onClick={() => router.push("/upgrade")}
className="mt-2 text-[11px] text-[#D4AF37] underline underline-offset-2"
>
Upgrade now
</button>
</div>
)}
</div>
{/* Messages */}
{cashoutMessage && (
<div
className={`flex items-center gap-2 p-3 rounded-xl text-sm ${
cashoutMessage.type === "success"
? "bg-emerald-900/20 border border-emerald-800/40 text-emerald-400"
: "bg-red-900/20 border border-red-800/40 text-red-400"
}`}
>
{cashoutMessage.type === "success" ? (
<Check size={16} />
) : (
<X size={16} />
)}
{cashoutMessage.text}
</div>
)}
{/* Top-Up Section */}
<div>
<h2 className="text-sm font-semibold text-white mb-3 flex items-center gap-2">
<Zap size={16} className="text-[#D4AF37]" />
Top Up FLH
</h2>
<div className="grid grid-cols-3 gap-3">
{TOP_UP_OPTIONS.map((opt) => (
<button
key={opt.flh}
onClick={() => handleTopUp(opt.flh)}
disabled={topupLoading === opt.flh}
className="bg-[#111118] border border-gray-800/60 rounded-2xl p-4 text-center active:scale-95 transition hover:border-[#D4AF37]/30 disabled:opacity-60"
>
{topupLoading === opt.flh ? (
<Loader2 size={20} className="animate-spin mx-auto text-[#D4AF37]" />
) : (
<>
<p className="text-lg font-bold text-white">
{opt.flh.toLocaleString()}
</p>
<p className="text-xs text-gray-500 mt-0.5">
${opt.usd.toFixed(2)}
</p>
{opt.bonus && (
<p className="text-[10px] text-emerald-400 mt-1 font-medium">
{opt.bonus}
</p>
)}
</>
)}
</button>
))}
</div>
</div>
{/* Cashout Form */}
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-5">
<h3 className="text-sm font-semibold text-white mb-3 flex items-center gap-2">
<ArrowUpRight size={16} className="text-[#D4AF37]" />
Cash Out FLH
</h3>
<p className="text-xs text-gray-500 mb-3">
Convert your FLH to USD. Minimum 1,000 FLH (1,000 FLH = $1.00).
</p>
<form onSubmit={handleCashout} className="flex gap-3">
<div className="flex-1">
<input
type="number"
min={1000}
step={100}
value={cashoutAmount}
onChange={(e) => setCashoutAmount(e.target.value)}
placeholder="Amount FLH"
className="w-full bg-[#1a1a24] border border-gray-800/60 rounded-xl px-4 py-3 text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/50 focus:ring-1 focus:ring-[#D4AF37]/30 transition"
/>
</div>
<button
type="submit"
disabled={cashoutLoading}
className="px-5 py-3 rounded-xl bg-[#D4AF37] text-[#0a0a0f] font-semibold text-sm disabled:opacity-50 active:opacity-80 transition flex items-center gap-2"
>
{cashoutLoading ? (
<Loader2 size={16} className="animate-spin" />
) : (
"Cash Out"
)}
</button>
</form>
{cashoutAmount && parseInt(cashoutAmount) >= 1000 && (
<p className="text-xs text-gray-500 mt-2">
You will receive ~$
{(parseInt(cashoutAmount) / 1000).toFixed(2)} USD
</p>
)}
</div>
{/* Transaction History */}
<div>
<h2 className="text-sm font-semibold text-white mb-3 flex items-center gap-2">
<Clock size={16} className="text-gray-500" />
Cashout History
</h2>
{cashoutHistory.length === 0 ? (
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-5 text-center">
<TrendingUp size={20} className="mx-auto text-gray-700 mb-2" />
<p className="text-sm text-gray-500">No cashouts yet</p>
<p className="text-xs text-gray-700 mt-1">
Your cashout requests will appear here
</p>
</div>
) : (
<div className="space-y-2">
{cashoutHistory.map((entry) => (
<div
key={entry.id}
className="bg-[#111118] border border-gray-800/60 rounded-xl px-4 py-3 flex items-center justify-between"
>
<div className="flex items-center gap-3">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center ${
entry.status === "approved"
? "bg-emerald-900/20"
: entry.status === "rejected"
? "bg-red-900/20"
: "bg-amber-900/20"
}`}
>
{entry.status === "approved" ? (
<ArrowDownLeft
size={14}
className="text-emerald-400"
/>
) : entry.status === "rejected" ? (
<X size={14} className="text-red-400" />
) : (
<Clock size={14} className="text-amber-400" />
)}
</div>
<div>
<p className="text-sm font-medium text-white">
{entry.amountFlh.toLocaleString()} FLH
</p>
<p className="text-[11px] text-gray-500">
${entry.fiatAmount.toFixed(2)} {" "}
{new Date(entry.createdAt).toLocaleDateString()}
</p>
</div>
</div>
<span
className={`text-xs font-medium capitalize ${
entry.status === "approved"
? "text-emerald-400"
: entry.status === "rejected"
? "text-red-400"
: "text-amber-400"
}`}
>
{entry.status}
</span>
</div>
))}
</div>
)}
</div>
{/* Earning Tips */}
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-5">
<h3 className="text-sm font-semibold text-white mb-3 flex items-center gap-2">
<Sparkles size={16} className="text-[#D4AF37]" />
Earn More FLH
</h3>
<ul className="space-y-2">
{EARNING_TIPS.map((tip, i) => (
<li key={i} className="flex items-start gap-2 text-sm text-gray-400">
<Info size={12} className="text-[#D4AF37] mt-0.5 shrink-0" />
<span>{tip}</span>
</li>
))}
</ul>
</div>
<div className="h-4" />
</div>
</div>
);
}
+78
View File
@@ -0,0 +1,78 @@
"use client";
import { useAuth } from "@/lib/AuthContext";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
Bot,
ShoppingBag,
MessageCircle,
Wallet,
User,
} from "lucide-react";
const navItems = [
{ href: "/nur", label: "Nur", icon: Bot },
{ href: "/souq", label: "Souq", icon: ShoppingBag },
{ href: "/", label: "Home", icon: null, isHome: true },
{ href: "/forum", label: "Forum", icon: MessageCircle },
{ href: "/wallet", label: "Wallet", icon: Wallet },
{ href: "/profile", label: "Profile", icon: User },
];
export default function BottomNav() {
const pathname = usePathname();
return (
<nav className="fixed bottom-0 left-0 right-0 z-50 bg-[#0a0a0f]/95 backdrop-blur-md border-t border-gray-800/60"
style={{ paddingBottom: "env(safe-area-inset-bottom, 0px)", height: "calc(64px + env(safe-area-inset-bottom, 0px))" }}>
<div className="flex items-stretch h-16 max-w-lg mx-auto">
{navItems.map((item) => {
const isActive = pathname === item.href || pathname.startsWith(item.href + "/");
const Icon = item.icon;
const isHome = item.isHome;
if (isHome) {
return (
<Link
key={item.href}
href="/"
className="flex-1 flex items-center justify-center"
>
<div className={`w-10 h-10 rounded-full flex items-center justify-center transition-all ${
pathname === "/"
? "bg-[#D4AF37] text-[#0a0a0f]"
: "bg-gray-800/60 text-gray-500"
}`}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
</svg>
</div>
</Link>
);
}
return (
<Link
key={item.href}
href={item.href}
className="flex-1 flex flex-col items-center justify-center gap-0.5 transition-opacity active:opacity-60"
>
{Icon && (
<Icon
size={20}
className={isActive ? "text-[#D4AF37]" : "text-gray-600"}
/>
)}
<span className={`text-[10px] font-medium ${
isActive ? "text-[#D4AF37]" : "text-gray-600"
}`}>
{item.label}
</span>
</Link>
);
})}
</div>
</nav>
);
}
+121
View File
@@ -0,0 +1,121 @@
"use client";
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from "react";
interface User {
id: string;
email: string;
name: string;
isPremium: boolean;
isPro: boolean;
flhBalance: number;
dailyMsgCount: number;
preferredName?: string;
coachPersona?: string;
madhab?: string;
coachingGoals?: string;
createdAt?: string;
trialEndsAt?: string;
}
interface AuthContextType {
user: User | null;
token: string | null;
loading: boolean;
login: (email: string, password: string) => Promise<void>;
register: (email: string, name: string, password: string) => Promise<void>;
logout: () => void;
refreshUser: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const stored = localStorage.getItem("flh_token");
if (stored) {
setToken(stored);
fetchUser(stored);
} else {
setLoading(false);
}
}, []);
const fetchUser = async (t: string) => {
try {
const res = await fetch("/api/auth/me", {
headers: { Authorization: `Bearer ${t}` },
});
if (res.ok) {
const data = await res.json();
setUser(data.user);
} else {
localStorage.removeItem("flh_token");
setToken(null);
}
} catch {
// ignore
} finally {
setLoading(false);
}
};
const login = useCallback(async (email: string, password: string) => {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Login failed");
}
const data = await res.json();
localStorage.setItem("flh_token", data.token);
setToken(data.token);
setUser(data.user);
}, []);
const register = useCallback(async (email: string, name: string, password: string) => {
const res = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, name, password }),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Registration failed");
}
const data = await res.json();
localStorage.setItem("flh_token", data.token);
setToken(data.token);
setUser(data.user);
}, []);
const logout = useCallback(() => {
localStorage.removeItem("flh_token");
setToken(null);
setUser(null);
}, []);
const refreshUser = useCallback(async () => {
if (!token) return;
await fetchUser(token);
}, [token]);
return (
<AuthContext.Provider value={{ user, token, loading, login, register, logout, refreshUser }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
}
+192
View File
@@ -0,0 +1,192 @@
/**
* AI Reply Generator — Persona-specific responses
* Uses conversation history for context and generates on-brand replies.
*/
type Message = { role: string; content: string };
const INTRO_RESPONSES: Record<string, string> = {
nurbuddy:
"Assalamualaikum! 🌟 I'm NurBuddy, your friendly Islamic lifestyle companion. How can I help you today? Feel free to ask about daily duas, prayer times, or just have a chat about faith and life.",
alim:
"Assalamualaikum warahmatullahi wabarakatuh. I am Alim, a student of Islamic knowledge. I draw upon the Qur'an and authentic hadith to provide scholarly insights. How may I assist you in your search for understanding?",
hakim:
"Greetings, seeker of wisdom. I am Hakim, and I offer counsel drawn from the wellspring of Islamic tradition and timeless parables. Tell me what weighs upon your heart, and together we shall find clarity.",
murabbi:
"Assalamualaikum, dear soul. I am Murabbi, your spiritual mentor. My role is to help you reflect, grow, and draw closer to the Divine. What aspect of your journey shall we explore today?",
mufti:
"Assalamualaikum. I am Mufti, and I provide detailed rulings based on the Qur'an, Sunnah, and the established schools of jurisprudence. Please present your question clearly, and I will respond with evidence and reasoning.",
daie:
"Assalamualaikum. I am Daie, and my mission is dawah — calling to the beauty of Islam with wisdom and gentle persuasion. Whether you're curious about Islam or seeking comparative insights, I'm here to help.",
};
function getRandomItem<T>(arr: T[]): T {
return arr[Math.floor(Math.random() * arr.length)];
}
function getLastMessages(
history: Message[],
userMessage: string,
count: number = 4
): string {
const recent = history.slice(-count);
if (recent.length === 0) return `User asks: "${userMessage}"`;
const ctx = recent
.map((m) => `${m.role === "user" ? "User" : "Assistant"}: ${m.content}`)
.join("\n");
return `${ctx}\nUser: "${userMessage}"`;
}
/* ── Persona response generators ── */
function nurbuddyReply(userMessage: string, context: string): string {
const lower = userMessage.toLowerCase();
if (lower.includes("assalamu") || lower.includes("salam") || lower.includes("hello") || lower.includes("hi"))
return "Waalaikumsalam warahmatullahi wabarakatuh! 😊 So happy to chat with you today. What's on your mind?";
if (lower.includes("dua") || lower.includes("prayer") || lower.includes("supplication"))
return "Alhamdulillah, making dua is a beautiful act of worship! Here's a simple yet powerful dua for you:\n\n**رَبَّنَا آتِنَا فِي الدُّنْيَا حَسَنَةً وَفِي الْآخِرَةِ حَسَنَةً وَقِنَا عَذَابَ النَّارِ**\n\n\"Our Lord, give us good in this world and good in the Hereafter, and protect us from the punishment of the Fire.\" (Quran 2:201)\n\nSay it with sincerity and watch how Allah transforms your life! 🌿";
if (lower.includes("stress") || lower.includes("anxious") || lower.includes("worried") || lower.includes("sad"))
return "I hear you, and it's okay to feel this way. 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• Go for wudu (ablution) — it's surprisingly calming!\n• Talk to someone you trust\n\nYou're not alone, and this too shall pass, insha'Allah. 💛";
if (lower.includes("thank"))
return "You're most welcome! 😊 It's always a pleasure to help. Remember, the Prophet ﷺ said: \"He who does not thank people, does not thank Allah.\" So JazakAllah khair for your kind words!";
return `That's a great question! Here's my friendly advice:\n\n${getRandomItem([
"In Islam, every small action can become an act of worship if done with the right intention (niyyah). So even your daily tasks can earn you rewards, insha'Allah!",
"A good rule of thumb is to ask yourself: 'Would this please Allah?' If the answer is yes, go for it with full confidence!",
"The Prophet ﷺ taught us to seek knowledge from the cradle to the grave. Never stop learning about your faith — it's a journey, not a destination!",
"Remember that Allah's mercy encompasses everything. No matter what you're going through, His door of repentance is always open. 🌅",
])}\n\nIs there anything else I can help you with?`;
}
function alimReply(userMessage: string, context: string): string {
const lower = userMessage.toLowerCase();
if (lower.includes("salah") || lower.includes("prayer"))
return "Regarding the obligation of salah, Allah commands in Surah Al-Baqarah (2:43): **\"And establish prayer and give zakah and bow with those who bow [in worship and obedience].\"**\n\nThe five daily prayers are a fundamental pillar of Islam. The Prophet ﷺ said: \"The covenant between us and them is prayer, so whoever abandons it has committed disbelief\" (Sunan al-Tirmidhi).\n\nIf you have questions about specific rulings related to prayer (like combining prayers during travel or the conditions of valid wudu), please specify and I'll provide detailed evidence.";
if (lower.includes("zakat") || lower.includes("charity"))
return "Zakat is one of the five pillars of Islam, mentioned alongside prayer in numerous Qur'anic verses. Allah says: **\"And establish prayer and give zakah, and whatever good you put forward for yourselves — you will find it with Allah\"** (Quran 2:110).\n\nThe general ruling is that 2.5% of one's accumulated wealth (above the nisab threshold) should be given annually to the eight categories specified in Surah At-Tawbah (9:60). The nisab is equivalent to the value of 85 grams of gold or 595 grams of silver.\n\nWould you like me to elaborate on any specific aspect of zakat calculation or distribution?";
if (lower.includes("halal") || lower.includes("haram") || lower.includes("permissible"))
return "The general principle in Islamic jurisprudence is that all things are permissible (halal) unless explicitly prohibited (haram) in the Qur'an or authentic Sunnah. Allah says: **\"O mankind, eat from whatever is on earth [that is] lawful and good\"** (Quran 2:168).\n\nThe categories of haram include: intoxicants, pork and its by-products, carrion, blood, and anything dedicated to other than Allah. For matters where clear texts are absent, scholars use ijtihad (independent reasoning) based on the maqasid (objectives) of Islamic law.\n\nCould you specify which area you're asking about so I can provide a more targeted answer with proper evidence?";
return `In the name of Allah, the Most Gracious, the Most Merciful.\n\nThank you for your inquiry. Let me draw upon the sources of Islamic knowledge to address this:\n\n${getRandomItem([
"The Qur'an says: **\"And We have certainly made the Qur'an easy to remember. So is there any who will remember?\"** (Quran 54:17). This reflects the accessibility of divine guidance for those who seek it.",
"Imam Al-Shafi'i (may Allah have mercy on him) said: \"Knowledge is not what is memorized, but what benefits.\" The ultimate purpose of knowledge is action and taqwa (God-consciousness).",
"The Prophet ﷺ said: **\"Whoever Allah wishes good for, He grants them understanding of the religion\"** (Sahih al-Bukhari). May Allah grant us all beneficial knowledge.",
"A principle established by the scholars is that **\"certainty is not removed by doubt\"** — a foundational maxim in Islamic jurisprudence (Qawa'id Fiqhiyyah).",
])}\n\nIs there a specific scholarly viewpoint or madhab you'd like me to elaborate on?`;
}
function hakimReply(userMessage: string, context: string): string {
const lower = userMessage.toLowerCase();
if (lower.includes("decision") || lower.includes("choice") || lower.includes("confused") || lower.includes("dilemma"))
return "A difficult decision, you say? Let me share a parable:\n\nA traveler once came to a crossroads. One path was smooth but led to a barren land; the other was rocky but led to a lush garden. He asked a wise man which to take. The wise man replied: **\"Judge the path not by its comfort, but by its destination.\"**\n\nIn making your choice, consider:\n1️⃣ **Istikhara** — Pray the Salat al-Istikhara and ask Allah for guidance\n2️⃣ **Consultation (Shura)** — Speak with trusted, knowledgeable people\n3️⃣ **Consequences** — Weigh the outcomes against your values and deen\n\nWhat does your heart lean toward after seeking Allah's guidance?";
if (lower.includes("forgive") || lower.includes("anger") || lower.includes("hurt") || lower.includes("grudge"))
return "Ah, the weight of carrying a grudge — it is like drinking poison and expecting the other person to suffer.\n\nAllah reminds us: **\"And let them pardon and overlook. Would you not like that Allah should forgive you? And Allah is Forgiving and Merciful\"** (Quran 24:22).\n\nThe Prophet ﷺ, despite being wronged in countless ways, was the most forgiving of people. When he conquered Mecca, he asked the Quraysh: \"What do you think I will do to you?\" They replied: \"You are a noble brother, son of a noble brother.\" He said: **\"Go, for you are free.\"**\n\nForgiveness is not weakness — it is the strength of a soul that trusts Allah's justice. 🕊️";
if (lower.includes("patience") || lower.includes("sabr") || lower.includes("waiting") || lower.includes("trial"))
return "Patience (sabr) is indeed a light. The trials you face are not punishments — they are opportunities for elevation. \n\nThere's a beautiful story of Prophet Ayyub (Job, peace be upon him), who lost his wealth, children, and health, yet never stopped praising Allah. When asked why, he said: **\"Allah gave, and Allah has taken. Blessed be Allah in both.\"**\n\nAnd what was his reward? Allah says: **\"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 is not in vain. Every moment of endurance is recorded, and the reward is immense. 🌱";
return `A profound question. Let me reflect with you:\n\n${getRandomItem([
"Life is like a river — it flows whether we resist or not. The wise one learns to navigate with the current of divine will, not against it.",
"There is a Persian saying: 'This too shall pass.' Both joy and sorrow are visitors. Welcome them with grace, for neither stays forever.",
"The Prophet ﷺ said: **\"How remarkable is the affair of the believer! All of it is good.\"** If you can find the hidden good in your present situation, you've already won half the battle.",
"Often we search far and wide for answers, when the truth was always within us — like the fish searching for the ocean.",
])}\n\nWhat resonates most with your heart right now?`;
}
function murabbiReply(userMessage: string, context: string): string {
const lower = userMessage.toLowerCase();
if (lower.includes("iman") || lower.includes("faith") || lower.includes("spiritual") || lower.includes("connection"))
return "Masha'Allah, your concern for your iman is itself a sign of its presence. The fact that you're asking tells me your heart is alive. 🌙\n\nLet me ask you some reflective questions:\n• When did you last feel truly connected in your salah?\n• What aspect of Allah's names (Asma'ul Husna) resonates most with you right now?\n• If your iman were a garden, what would it need — water, sunlight, or perhaps the removal of weeds?\n\nTake a moment to sit with these questions. Remember, iman fluctuates — that's natural. The Prophet ﷺ said: **\"Renew your faith!\"** They asked: \"How do we renew it?\" He said: **\"Say La ilaha illallah\"** (Ahmad).";
if (lower.includes("repent") || lower.includes("taubah") || lower.includes("sin") || lower.includes("regret"))
return "Dear soul, let me tell you something beautiful: the fact that you feel remorse is itself a gift from Allah. It means your heart is not sealed, and the door of repentance is wide open for you.\n\nAllah says: **\"Say, O My servants who have transgressed against themselves, do not despair of the mercy of Allah. Indeed, Allah forgives all sins. Indeed, it is He who is the Forgiving, the Merciful\"** (Quran 39:53).\n\nNotice He says \"O My servants\" — even when we sin, He still claims us as His own. That's the depth of His love. ❤️\n\nFor sincere repentance (tawbah an-nasuh), reflect on these three steps:\n1️⃣ Stop the sin immediately\n2️⃣ Feel genuine regret\n3️⃣ Resolve never to return to it\n\nAnd if your sin involves another person's rights, make amends with them too.\n\nYou are not defined by your worst moment. You are defined by your return to Him.";
if (lower.includes("purpose") || lower.includes("meaning") || lower.includes("why") || lower.includes("life"))
return "The question of purpose is perhaps the most profound question a soul can ask. And you're asking it — that's beautiful.\n\nAllah gives us the answer directly: **\"And I did not create the jinn and mankind except to worship Me\"** (Quran 51:56).\n\nBut 'worship' here (ibadah) means far more than rituals. It means living a life of consciousness of Allah in every moment — your work, your relationships, your kindness, even your sleep can be worship with the right intention.\n\nLet me ask you: What makes you feel most alive and most at peace? Often, the answer to that question is connected to your purpose. When your natural talents meet the world's needs, with Allah at the center — that's your purpose in action. 🌟";
return `Thank you for trusting me with your reflections. Let me offer you some spiritual nourishment:\n\n${getRandomItem([
"Take a moment right now. Close your eyes, take three deep breaths, and say *Alhamdulillah* for each breath. Gratitude is the key that unlocks more blessings.",
"The Prophet ﷺ said: **\"The most beloved of deeds to Allah are those that are most consistent, even if they are small.\"** Don't underestimate the power of small, consistent good deeds.",
"Your relationship with Allah is like a mirror — when you turn toward Him, He turns toward you. When you take one step toward Him, He comes running to you (as mentioned in hadith qudsi).",
"Every trial is a teacher. Every difficulty carries a divine whisper. The question isn't 'Why me?' but 'What is this teaching me?'",
])}\n\nHow does this land on your heart?`;
}
function muftiReply(userMessage: string, context: string): string {
const lower = userMessage.toLowerCase();
if (lower.includes("interest") || lower.includes("riba") || lower.includes("bank") || lower.includes("loan"))
return "**Issue:** Riba (interest) in financial transactions\n**Ruling:** Riba is categorically prohibited in Islam.\n\n**Evidence:**\n1. Allah says: **\"Those who consume interest will stand on the Day of Resurrection like one driven mad by Satan's touch. That is because they say, 'Trade is like interest.' But Allah has permitted trade and forbidden interest\"** (Quran 2:275).\n\n2. The Prophet ﷺ cursed the one who consumes riba, the one who pays it, the one who writes it, and the one who witnesses it, saying: **\"They are all the same\"** (Sahih Muslim).\n\n**Application:** All forms of conventional interest-based loans, credit cards with interest, and savings accounts that pay interest fall under this prohibition. Muslims should seek Shariah-compliant alternatives such as:\n• Murabahah (cost-plus financing)\n• Musharakah (profit-sharing)\n• Ijara (leasing)\n\n**Note:** In cases of dire necessity (darura), scholars may permit exceptions under strict conditions. Please consult a local scholar for your specific situation.";
if (lower.includes("prayer") || lower.includes("salah") || lower.includes("fajr") || lower.includes("qada") || lower.includes("missed"))
return "**Issue:** Making up missed prayers (Qada' al-Salah)\n\n**Evidence:** The majority of scholars (Hanafi, Shafi'i, Maliki) hold that making up missed obligatory prayers is compulsory (wajib). The Prophet ﷺ said: **\"Whoever forgets a prayer, let him pray it when he remembers it. There is no expiation for it except that\"** (Sahih al-Bukhari).\n\n**Ruling:**\n- **Intentionally missed prayers:** The predominant view (Hanbali, some Malikis) is that one must repent and make them up. Some scholars (particularly the Hanbali school) hold that intentional abandonment of prayer is major kufr — thus one must first repent and renew their shahadah.\n- **Unintentionally missed** (forgetfulness, sleep): Make up as soon as remembered.\n\n**Recommended sequence:** Make up missed prayers alongside your current ones, prioritizing the current prayer time but making up the missed ones as soon as possible.\n\nWallahu a'lam (Allah knows best). I recommend consulting a local scholar of your madhab for personalized guidance.";
if (lower.includes("marriage") || lower.includes("nikah") || lower.includes("divorce") || lower.includes("talaq"))
return "**Issue:** Rules of marriage and divorce in Islam\n\nMarriage (nikah) is a sacred contract (mithaq ghalidh) in Islam. Allah describes it as: **\"And He placed between you affection and mercy\"** (Quran 30:21).\n\n**Essential conditions for a valid nikah:**\n1. Mutual consent of both parties (the bride's consent, through her wali/guardian)\n2. Mahr (dower) — a gift from the husband to the wife\n3. Two upright Muslim witnesses\n4. The marriage contract (ijab and qabul)\n\n**Regarding divorce (talaq):**\n- It is the most disliked permissible act (abghad al-halal ila Allah)\n- A triple talaq uttered in one sitting: the majority view is that this counts as three divorces and the marriage is irrevocably dissolved (the Hanbali position differs, counting it as one)\n- After the first or second talaq, there is a waiting period (iddah) of three menstrual cycles, during which reconciliation is encouraged\n\nPlease provide more specifics about your situation for a detailed ruling. Wallahu a'lam.";
return `**Request for a Ruling (Ifta')**\n\nThank you for your question. I will address it based on the primary sources of Islamic law and the established methodologies of the four Sunni schools of thought.\n\n**Context:** ${userMessage}\n\n**Analysis:**\n${getRandomItem([
"The general principle (al-asl) in this matter is permissibility (ibahah), as there is no clear text prohibiting it. Allah says: **\"He has explained to you what He has made unlawful for you\"** (Quran 6:119).",
"The legal maxim states: 'Harm is to be eliminated' (al-darar yuzal). Any action causing significant harm to oneself or others would be impermissible based on this principle.\n\n**Evidence:** The Prophet ﷺ said: **\"There should be neither harming nor reciprocating harm\"** (Ibn Majah).",
"The objectives of Islamic law (Maqasid al-Shariah) aim to preserve five essentials: faith, life, intellect, lineage, and property. Any ruling on this matter should be evaluated against these higher objectives.",
])}\n\n**Conclusion:**\nTo provide a definitive ruling, I need to consider the specifics more carefully. Could you please clarify:\n1. Your madhab (school of thought)?\n2. Any relevant circumstances?\n\nWallahu a'lam (Allah knows best).`;
}
function daieReply(userMessage: string, context: string): string {
const lower = userMessage.toLowerCase();
if (lower.includes("jesus") || lower.includes("isa") || lower.includes("christian") || lower.includes("bible"))
return "This is a beautiful area of discussion! 🌿\n\nIn Islam, Prophet Isa (Jesus, peace be upon him) holds a very special position. We love and honor him as one of the greatest messengers of Allah. Here's what Muslims believe:\n\n• He was born miraculously to Maryam (Mary) without a father — **\"And We made the son of Mary and his mother a sign\"** (Quran 23:50)\n• He spoke as a baby in the cradle, defending his mother's honor\n• He performed miracles — healing the blind and leper, raising the dead — all by Allah's permission\n• He preached the worship of One God (Tawheed)\n• He was not crucified but was raised to the heavens (Quran 4:157-158)\n• He will return before the Day of Judgment\n\nThe key difference is that Islam upholds the absolute oneness of God (Tawheed) and considers it the highest sin to associate partners with Allah. Muslims believe Isa was a human prophet, not divine.\n\nWould you like to explore more similarities or differences between Islamic and Christian teachings? ☪️✝️";
if (lower.includes("hindu") || lower.includes("buddhist") || lower.includes("other religion") || lower.includes("jewish") || lower.includes("judaism"))
return "Assalamualaikum. Let me approach this with the wisdom that dawah requires — gentleness and respect. Allah commands: **\"Invite to the way of your Lord with wisdom and good instruction, and argue with them in a way that is best\"** (Quran 16:125).\n\nEvery faith tradition contains elements of truth, and Islam affirms that Allah sent messengers to every nation. The Qur'an says: **\"And We certainly sent into every nation a messenger, [saying], 'Worship Allah and avoid false gods'\"** (Quran 16:36).\n\nWhat makes Islam unique is its:\n• **Finality of revelation** — The Qur'an, preserved in its original language for over 1,400 years\n• **Comprehensive guidance** — Covering spirituality, law, economics, and governance\n• **Universal message** — For all of humanity, not just one people or time\n\nI'd be happy to discuss specific beliefs or answer questions you have. The goal is understanding, not winning arguments. 🤝";
if (lower.includes("convert") || lower.includes("revert") || lower.includes("become muslim") || lower.includes("shahadah"))
return "Masha'Allah! Your interest in Islam fills my heart with joy. 🌟\n\nLet me share what embracing Islam involves:\n\n**The Shahadah (Declaration of Faith):**\n**\"Ashhadu an la ilaha illallah, wa ashhadu anna Muhammadan rasulullah\"**\n(I bear witness that there is no god worthy of worship except Allah, and I bear witness that Muhammad is the Messenger of Allah)\n\nThat's it — utter these words with sincerity and understanding, and you become a Muslim. Everything else (prayer, fasting, etc.) builds on this foundation.\n\n**What changes?**\n• Your past sins are forgiven — you start fresh like a newborn\n• Your relationship with God becomes direct — no intermediaries needed\n• You join the global family of 1.8 billion Muslims\n\nI encourage you to:\n1️⃣ Visit a local mosque — the community will welcome you warmly\n2️⃣ Get a copy of the Qur'an with translation\n3️⃣ Learn how to pray (salat) at your own pace\n\nWould you like to know more about what life as a Muslim looks like day-to-day? 🕌";
return `Assalamualaikum! Thank you for your question. Let me respond with the spirit of dawah — inviting through wisdom and beauty.\n\n${getRandomItem([
"The Qur'an challenges us: **\"Do they not reflect upon the Qur'an, or are there locks upon their hearts?\"** (Quran 47:24). Reflection (tadabbur) is key to understanding divine guidance.",
"One of the most beautiful aspects of Islam is its balance. It teaches us to be spiritual without being monastic, worldly without being materialistic. You can be a successful professional and a devoted servant of Allah.",
"The Prophet ﷺ said: **\"The best of you are those who are best to their families, and I am the best of you to my family\"** (Sunan al-Tirmidhi). This shows how Islam elevates kindness and family values.",
"Islam doesn't ask you to abandon the world — it teaches you to engage with it mindfully. Every lawful action, done with the right intention, becomes worship. Imagine that!",
])}\n\nIs there a particular aspect of Islam you'd like to explore further? I'm here to help with patience and clarity. ☪️`;
}
export function generateAIReply(
personaId: string,
userMessage: string,
history: { role: string; content: string }[]
): string {
const context = getLastMessages(history, userMessage);
// If this is the first message or history is empty, return intro
if (history.length === 0 || history.every((m) => m.role === "user")) {
return INTRO_RESPONSES[personaId] || INTRO_RESPONSES.nurbuddy;
}
switch (personaId) {
case "nurbuddy":
return nurbuddyReply(userMessage, context);
case "alim":
return alimReply(userMessage, context);
case "hakim":
return hakimReply(userMessage, context);
case "murabbi":
return murabbiReply(userMessage, context);
case "mufti":
return muftiReply(userMessage, context);
case "daie":
return daieReply(userMessage, context);
default:
return nurbuddyReply(userMessage, context);
}
}
+22
View File
@@ -0,0 +1,22 @@
export async function generateAIResponse(
messages: { role: string; content: string }[],
personaId: string,
token: string
): Promise<Response> {
return fetch("/api/chat/send", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ messages, personaId }),
});
}
export const DAILY_VERSE = [
{ arabic: "إِنَّ مَعَ الْعُسْرِ يُسْرًا", reference: "Quran 94:6", translation: "Indeed, with hardship comes ease." },
{ arabic: "فَاذْكُرُونِي أَذْكُرْكُمْ", reference: "Quran 2:152", translation: "So remember Me; I will remember you." },
{ arabic: "وَمَن يَتَوَكَّلْ عَلَى اللَّهِ فَهُوَ حَسْبُهُ", reference: "Quran 65:3", translation: "And whoever relies upon Allah — He is sufficient for him." },
{ arabic: "رَبَّنَا آتِنَا فِي الدُّنْيَا حَسَنَةً وَفِي الْآخِرَةِ حَسَنَةً", reference: "Quran 2:201", translation: "Our Lord, give us good in this world and good in the Hereafter." },
{ arabic: "إِنَّ اللَّهَ لَا يُغَيِّرُ مَا بِقَوْمٍ حَتَّىٰ يُغَيِّرُوا مَا بِأَنفُسِهِمْ", reference: "Quran 13:11", translation: "Indeed, Allah will not change the condition of a people until they change what is in themselves." },
];
+41
View File
@@ -0,0 +1,41 @@
import { SignJWT, jwtVerify, type JWTPayload } from "jose";
const JWT_SECRET = new TextEncoder().encode(
process.env.JWT_SECRET || "dev-jwt-secret-change-in-production"
);
export interface JWTContents extends JWTPayload {
userId: string;
email: string;
isPremium: boolean;
isPro: boolean;
}
export async function signJWT(payload: Omit<JWTContents, "exp" | "iat">) {
return new SignJWT(payload as unknown as JWTPayload)
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("7d")
.sign(JWT_SECRET);
}
export async function verifyJWT(token: string): Promise<JWTContents | null> {
try {
const { payload } = await jwtVerify(token, JWT_SECRET);
return payload as unknown as JWTContents;
} catch {
return null;
}
}
export function getTokenFromHeader(request: Request): string | null {
const auth = request.headers.get("Authorization");
if (!auth?.startsWith("Bearer ")) return null;
return auth.slice(7);
}
export async function requireAuth(request: Request) {
const token = getTokenFromHeader(request);
if (!token) return null;
return verifyJWT(token);
}
+48
View File
@@ -0,0 +1,48 @@
export const PERSONAS = [
{
id: "nurbuddy",
name: "NurBuddy",
description: "Friendly everyday AI companion",
icon: "🤖",
free: true,
},
{
id: "alim",
name: "Alim",
description: "Scholar of Islamic jurisprudence",
icon: "📖",
free: false,
},
{
id: "hakim",
name: "Hakim",
description: "Wise counselor for life decisions",
icon: "⚖️",
free: false,
},
{
id: "murabbi",
name: "Murabbi",
description: "Spiritual mentor and coach",
icon: "🌙",
free: false,
},
{
id: "mufti",
name: "Mufti",
description: "Advanced fatwa & rulings specialist",
icon: "🕌",
free: false,
},
{
id: "daie",
name: "Daie",
description: "Caller to Islam — dawah & comparative religion",
icon: "☪️",
free: false,
},
];
export function getPersona(id: string) {
return PERSONAS.find((p) => p.id === id) || PERSONAS[0];
}
+9
View File
@@ -0,0 +1,9 @@
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
+34
View File
@@ -0,0 +1,34 @@
export const DAILY_LIMITS = {
free: { msgs: 10, personas: ["nurbuddy"] },
premium: { msgs: 100, personas: ["nurbuddy", "alim", "hakim", "murabbi"] },
pro: { msgs: Infinity, personas: ["nurbuddy", "alim", "hakim", "murabbi", "mufti", "daie"] },
};
export const HALAL_LIMITS = {
free: { queriesPerDay: 10, scope: "mosques" },
premium: { queriesPerDay: Infinity, scope: "all" },
pro: { queriesPerDay: Infinity, scope: "all" },
};
export const MARKETPLACE_LIMITS = {
free: { canSell: false, maxListings: 0 },
premium: { canSell: true, maxListings: 20 },
pro: { canSell: true, maxListings: Infinity },
};
export const FORUM_LIMITS = {
free: { canPost: false },
premium: { canPost: true },
pro: { canPost: true },
};
export const PREMIUM_PRICES = {
premium: { usd: 4.99, priceId: "price_premium_monthly" },
pro: { usd: 9.99, priceId: "price_pro_monthly" },
};
export function getUserTier(isPremium: boolean, isPro: boolean): "free" | "premium" | "pro" {
if (isPro) return "pro";
if (isPremium) return "premium";
return "free";
}