commit e2365e29fe85ed700d7109f30580caaeb94e0a40 Author: FalahMobile Date: Mon Jun 15 08:05:51 2026 +0100 Add Prayer Times, Dhikr counter, Quran tracker with mobile UI overhaul - Prayer times page: arc progress ring, next-prayer countdown, clean prayer list - Dhikr counter: SVG ring with glow, 3-phase sequence (SubhanAllah/Alhamdulillah/AllahuAkbar), streak tracking - Daily Quran reading tracker API routes (GET/POST) - Bottom nav updated: Prayer + Dhikr tabs, Repeat2 icon for dhikr, Moon for prayer - Home page redirects to /prayer as primary entry point - Dockerfile: openssl added to builder stage, explicit prisma schema path fixes build - Schema: dhikrStreak, quranStreak on User; DhikrSession and QuranReading models - Stub wallet top-up checkout to fix TypeScript build error Co-Authored-By: Claude Sonnet 4.6 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b15a70f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +FROM node:20-slim AS base + +FROM base AS deps +RUN apt-get update && apt-get install -y --no-install-recommends openssl && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm ci + +FROM base AS builder +RUN apt-get update && apt-get install -y --no-install-recommends openssl && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npx prisma generate --schema=./prisma/schema.prisma && npm run build + +FROM base AS runner +WORKDIR /app +ENV NODE_ENV=production +RUN apt-get update && apt-get install -y --no-install-recommends openssl ca-certificates && rm -rf /var/lib/apt/lists/* +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs +RUN mkdir -p /app/data +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +COPY --from=builder /app/prisma ./prisma +RUN mkdir -p /app/public +USER nextjs +EXPOSE 3000 +ENV PORT=3000 +CMD ["node", "server.js"] diff --git a/next.config.ts b/next.config.ts new file mode 100644 index 0000000..cd8f14f --- /dev/null +++ b/next.config.ts @@ -0,0 +1,10 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + output: "standalone", + turbopack: { + root: process.cwd(), + }, +}; + +export default nextConfig; diff --git a/package.json b/package.json new file mode 100644 index 0000000..f38721e --- /dev/null +++ b/package.json @@ -0,0 +1,35 @@ +{ + "name": "flh-app", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "eslint" + }, + "dependencies": { + "@prisma/client": "^5.22.0", + "bcryptjs": "^3.0.3", + "jose": "^6.2.3", + "lucide-react": "^1.17.0", + "next": "16.2.7", + "prisma": "^5.22.0", + "react": "19.2.4", + "react-dom": "19.2.4", + "stripe": "^17.0.0", + "leaflet": "^1.9.4", + "react-leaflet": "^5.0.0", + "@types/leaflet": "^1.9.14" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "16.2.7", + "tailwindcss": "^4", + "typescript": "^5" + } +} diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..3d16039 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,189 @@ +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(0) + 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? + dhikrStreak Int @default(0) + dhikrStreakDate DateTime? + quranStreak Int @default(0) + quranStreakDate 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 +} + +model DhikrSession { + id String @id @default(cuid()) + userId String + date String + count Int @default(0) + completed Boolean @default(false) + createdAt DateTime @default(now()) + + @@unique([userId, date]) + @@index([userId]) +} + +model QuranReading { + id String @id @default(cuid()) + userId String + date String + pagesRead Int @default(0) + goalPages Int @default(1) + createdAt DateTime @default(now()) + + @@unique([userId, date]) + @@index([userId]) +} diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts new file mode 100644 index 0000000..1518dbd --- /dev/null +++ b/src/app/api/auth/login/route.ts @@ -0,0 +1,14 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { verifyPassword, signJWT } from '@/lib/auth' + +export async function POST(req: NextRequest) { + try { + const { email, password } = await req.json() + if (!email || !password) return NextResponse.json({ error: 'Email and password required' }, { status: 400 }) + const user = await prisma.user.findUnique({ where: { email } }) + if (!user || !user.passwordHash || !verifyPassword(password, user.passwordHash)) return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 }) + const token = await signJWT({ id: user.id, email: user.email }) + return NextResponse.json({ user: { id: user.id, email: user.email, name: user.name, isPremium: user.isPremium, isPro: user.isPro, flhBalance: user.flhBalance, experienceLevel: user.experienceLevel, madhab: user.madhab, coachPersona: user.coachPersona, preferredName: user.preferredName, coachingGoals: user.coachingGoals, lastCoachedAt: user.lastCoachedAt, createdAt: user.createdAt }, token }) + } catch (e) { console.error(e); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } +} diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts new file mode 100644 index 0000000..83456ea --- /dev/null +++ b/src/app/api/auth/me/route.ts @@ -0,0 +1,17 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { verifyJWT, isActivePremium } from '@/lib/auth' + +export async function GET(req: NextRequest) { + const auth = req.headers.get('authorization') + if (!auth?.startsWith('Bearer ')) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const payload = await verifyJWT(auth.slice(7)) + if (!payload) return NextResponse.json({ error: 'Invalid token' }, { status: 401 }) + let user = await prisma.user.findUnique({ where: { id: payload.id }, select: { id: true, email: true, name: true, isPremium: true, isPro: true, trialEndsAt: true, flhBalance: true, experienceLevel: true, madhab: true, coachPersona: true, preferredName: true, coachingGoals: true, lastCoachedAt: true, createdAt: true } }) + if (!user) return NextResponse.json({ error: 'User not found' }, { status: 404 }) + // Auto-downgrade expired trials + if (user.isPremium && !user.isPro && user.trialEndsAt && user.trialEndsAt < new Date()) { + user = await prisma.user.update({ where: { id: payload.id }, data: { isPremium: false }, select: { id: true, email: true, name: true, isPremium: true, isPro: true, trialEndsAt: true, flhBalance: true, experienceLevel: true, madhab: true, coachPersona: true, preferredName: true, coachingGoals: true, lastCoachedAt: true, createdAt: true } }) + } + return NextResponse.json({ user: { ...user, isPremium: isActivePremium(user) } }) +} diff --git a/src/app/api/auth/profile/route.ts b/src/app/api/auth/profile/route.ts new file mode 100644 index 0000000..5ad2e32 --- /dev/null +++ b/src/app/api/auth/profile/route.ts @@ -0,0 +1,77 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { verifyJWT, isActivePremium } from '@/lib/auth' + +export async function PATCH(req: NextRequest) { + try { + const auth = req.headers.get('authorization') + if (!auth?.startsWith('Bearer ')) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const payload = await verifyJWT(auth.slice(7)) + if (!payload) return NextResponse.json({ error: 'Invalid token' }, { status: 401 }) + + const body = await req.json() + const { + preferredName, + experienceLevel, + madhab, + coachPersona, + coachingGoals, + } = body + + // Validate experienceLevel + if (experienceLevel && !['new', 'growing', 'seasoned'].includes(experienceLevel)) { + return NextResponse.json({ error: 'Invalid experienceLevel' }, { status: 400 }) + } + + // Validate madhab + if (madhab && !['hanafi', 'shafii', 'maliki', 'hanbali', 'unspecified'].includes(madhab)) { + return NextResponse.json({ error: 'Invalid madhab' }, { status: 400 }) + } + + // Validate coachPersona + if (coachPersona && !['nurbuddy', 'ghazali', 'ibnabbas', 'rabia'].includes(coachPersona)) { + return NextResponse.json({ error: 'Invalid coachPersona' }, { status: 400 }) + } + + // Gate scholar personas behind premium + const PREMIUM_PERSONAS = ['ghazali', 'ibnabbas', 'rabia'] + if (coachPersona && PREMIUM_PERSONAS.includes(coachPersona)) { + const currentUser = await prisma.user.findUnique({ + where: { id: payload.id }, + select: { isPremium: true, isPro: true, trialEndsAt: true }, + }) + if (!currentUser || !isActivePremium(currentUser)) { + return NextResponse.json( + { error: 'premium_required', feature: 'scholar_personas' }, + { status: 403 } + ) + } + } + + const updateData: Record = {} + if (preferredName !== undefined) updateData.preferredName = preferredName + if (experienceLevel !== undefined) updateData.experienceLevel = experienceLevel + if (madhab !== undefined) updateData.madhab = madhab + if (coachPersona !== undefined) updateData.coachPersona = coachPersona + if (coachingGoals !== undefined) updateData.coachingGoals = JSON.stringify(coachingGoals) + + const user = await prisma.user.update({ + where: { id: payload.id }, + data: updateData, + select: { + id: true, email: true, name: true, + preferredName: true, experienceLevel: true, madhab: true, coachPersona: true, + isPremium: true, isPro: true, flhBalance: true, + coachingGoals: true, lastCoachedAt: true, createdAt: true, + }, + }) + + return NextResponse.json({ user }) + } catch (e) { + console.error(e) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts new file mode 100644 index 0000000..7e1ba65 --- /dev/null +++ b/src/app/api/auth/register/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { hashPassword, signJWT } from '@/lib/auth' + +export async function POST(req: NextRequest) { + try { + const { email, name, password } = await req.json() + if (!email || !name || !password) return NextResponse.json({ error: 'All fields required' }, { status: 400 }) + const exists = await prisma.user.findUnique({ where: { email } }) + if (exists) return NextResponse.json({ error: 'Email already registered' }, { status: 400 }) + const trialEndsAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) + const user = await prisma.user.create({ + data: { email, name, passwordHash: hashPassword(password), isPremium: true, trialEndsAt }, + }) + const token = await signJWT({ id: user.id, email: user.email }) + return NextResponse.json({ user: { id: user.id, email: user.email, name: user.name, isPremium: user.isPremium, isPro: user.isPro, flhBalance: user.flhBalance, experienceLevel: user.experienceLevel, madhab: user.madhab, coachPersona: user.coachPersona, preferredName: user.preferredName, coachingGoals: user.coachingGoals, lastCoachedAt: user.lastCoachedAt, createdAt: user.createdAt }, token }, { status: 201 }) + } catch (e) { console.error(e); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } +} diff --git a/src/app/api/chat/daily/route.ts b/src/app/api/chat/daily/route.ts new file mode 100644 index 0000000..f7353a7 --- /dev/null +++ b/src/app/api/chat/daily/route.ts @@ -0,0 +1,140 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' + +// ───────────────────────────────────────────── +// Greeting pools — tiered by engagement level +// ───────────────────────────────────────────── + +const DAILY_GREETINGS = [ + `Assalamu alaikum! I was thinking of you this morning. How is your heart today?`, + `Assalamu alaikum! A new day, a new opportunity to draw closer to Allah. How are you, my friend?`, + `Peace be upon you. I felt a gentle nudge to check in with you. How has your journey been?`, + `Assalamu alaikum! The sun rises again, and so does Allah's mercy. How are you doing today?`, + `Peace be with you. I was wondering how you've been — we don't need to talk about anything heavy. Just checking in.`, + `Assalamu alaikum! Every breath is a gift we didn't earn. How are you spending yours today?`, + `Peace upon you. No pressure, no expectations — just wanted you to know someone is here when you're ready.`, +] + +// For users who haven't checked in for 2+ days — gentler, warmer +const NURTURE_GREETINGS = [ + `Assalamu alaikum. I noticed it's been a little while. Just wanted you to know — there's no guilt here, no judgment. Only welcome. How have you been, truly?`, + `Peace be upon you. Life gets busy, I know. But I wanted to check in because you matter. How is your heart these days?`, + `Assalamu alaikum. Whether you've been distant from Allah or just busy with the world — His door is always open. So is this conversation. How are you?`, + `Peace be with you. They say the journey back begins with a single step. You're here — that's your step. How can I lighten your load today?`, + `Assalamu alaikum. No matter how many days have passed, you are always welcome here. The Prophet ﷺ said the one who returns to good is like one who never left. How can I support you today?`, + `Peace be upon you. Silence between friends is not a wall — it's a pause. I'm still here. What's on your mind?`, +] + +// For first-time users with no history — the warmest welcome +const FIRST_GREETINGS = [ + `Assalamu alaikum! I'm so glad you're here. This is a space for you — for your questions, your struggles, your quiet thoughts. There is nothing too small or too big to bring here. How are you feeling today?`, + `Peace be upon you. Welcome. Think of me as a friend who loves the Quran and the Sunnah, and who is simply here to walk alongside you. Where would you like to start?`, + `Assalamu alaikum! The Prophet ﷺ said, "Whoever Allah desires good for, He gives him understanding of the religion." You seeking knowledge is a sign of goodness. How can I help you grow today?`, +] + +function daysSince(date: Date): number { + const now = new Date() + const diff = now.getTime() - date.getTime() + return Math.floor(diff / (1000 * 60 * 60 * 24)) +} + +function pickRandom(pool: T[]): T { + return pool[Math.floor(Math.random() * pool.length)] +} + +export async function POST(req: NextRequest) { + try { + const { userId } = await req.json() + if (!userId) { + return NextResponse.json({ error: 'userId required' }, { status: 400 }) + } + + const user = await prisma.user.findUnique({ where: { id: userId } }) + if (!user) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }) + } + + // Check last bot message + const lastBotMessage = await prisma.chatHistory.findFirst({ + where: { userId, role: 'assistant' }, + orderBy: { createdAt: 'desc' }, + }) + + // If bot already messaged today, skip + if (lastBotMessage) { + const lastDate = new Date(lastBotMessage.createdAt) + const now = new Date() + const sameDay = lastDate.getDate() === now.getDate() + && lastDate.getMonth() === now.getMonth() + && lastDate.getFullYear() === now.getFullYear() + if (sameDay) { + return NextResponse.json({ + needsGreeting: false, + message: null, + lastSeen: lastBotMessage.createdAt, + }) + } + } + + // Check last user message for idle detection + const lastUserMessage = await prisma.chatHistory.findFirst({ + where: { userId, role: 'user' }, + orderBy: { createdAt: 'desc' }, + }) + + // Check if this is a first-time user (no history at all) + const totalMessages = await prisma.chatHistory.count({ where: { userId } }) + + // Determine greeting tier + let greeting: string + + if (totalMessages === 0) { + // First visit ever — warm welcome + greeting = pickRandom(FIRST_GREETINGS) + } else if (lastUserMessage && daysSince(new Date(lastUserMessage.createdAt)) >= 2) { + // Been away 2+ days — nurture/re-engagement message + greeting = pickRandom(NURTURE_GREETINGS) + } else { + // Regular daily check-in + let lastTopic: string | undefined + if (lastUserMessage) { + const words = lastUserMessage.content.split(/\s+/).slice(0, 8).join(' ') + lastTopic = words.length > 60 ? words.slice(0, 60) + '...' : words + } + const base = pickRandom(DAILY_GREETINGS) + greeting = lastTopic + ? `${base} Last time we spoke about ${lastTopic}. Want to continue, or something new?` + : base + } + + // Save the greeting + await prisma.chatHistory.create({ + data: { + userId, + role: 'assistant', + content: greeting, + metadata: JSON.stringify({ + command: '_daily', + summary: 'Daily check-in', + actionItems: [], + topics: ['daily-check-in'], + }), + }, + }) + + // Update lastCoachedAt + await prisma.user.update({ + where: { id: userId }, + data: { lastCoachedAt: new Date() }, + }) + + return NextResponse.json({ + needsGreeting: true, + message: greeting, + lastSeen: lastBotMessage?.createdAt || null, + }) + } catch (e) { + console.error('Daily check-in error:', e) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/src/app/api/chat/demo/route.ts b/src/app/api/chat/demo/route.ts new file mode 100644 index 0000000..08bb0ab --- /dev/null +++ b/src/app/api/chat/demo/route.ts @@ -0,0 +1,14 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' + +export async function GET() { + const user = await prisma.user.upsert({ + where: { email: 'demo@nurbuddy.ai' }, + update: {}, + create: { email: 'demo@nurbuddy.ai', name: 'Demo User', isPremium: true }, + }) + if (!user.isPremium) { + await prisma.user.update({ where: { id: user.id }, data: { isPremium: true } }) + } + return NextResponse.json({ user }) +} diff --git a/src/app/api/chat/history/[userId]/route.ts b/src/app/api/chat/history/[userId]/route.ts new file mode 100644 index 0000000..66299e7 --- /dev/null +++ b/src/app/api/chat/history/[userId]/route.ts @@ -0,0 +1,14 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' + +export async function GET(req: NextRequest) { + const { pathname } = new URL(req.url) + const userId = pathname.split('/').pop() + if (!userId) return NextResponse.json({ error: 'userId required' }, { status: 400 }) + const user = await prisma.user.findUnique({ where: { id: userId } }) + if (!user) return NextResponse.json({ error: 'User not found' }, { status: 404 }) + const history = await prisma.chatHistory.findMany({ + where: { userId }, orderBy: { createdAt: 'asc' }, take: 50, + }) + return NextResponse.json({ history }) +} diff --git a/src/app/api/chat/send/route.ts b/src/app/api/chat/send/route.ts new file mode 100644 index 0000000..8d26e2f --- /dev/null +++ b/src/app/api/chat/send/route.ts @@ -0,0 +1,203 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { askNur, moderateContent } from '@/lib/ai' +import type { UserContext, CoachingMemory } from '@/lib/ai' +import { parseCommand, handleCommand } from '@/lib/commands' +import type { SlashCommand } from '@/lib/commands' +import { SCHOLAR_PERSONAS } from '@/lib/personas' +import { isActivePremium } from '@/lib/auth' + +// Per-user seen-content tracking (in-memory, resets on server restart) +const userSeenContent = new Map>>() + +function getUserSeen(userId: string): Map> { + if (!userSeenContent.has(userId)) { + userSeenContent.set(userId, new Map()) + } + return userSeenContent.get(userId)! +} + +function getSeenSet(userId: string, command: SlashCommand): Set { + const userMap = getUserSeen(userId) + if (!userMap.has(command)) { + userMap.set(command, new Set()) + } + return userMap.get(command)! +} + +export async function POST(req: NextRequest) { + try { + const { userId, message } = await req.json() + if (!userId || !message) { + return NextResponse.json({ error: 'userId and message required' }, { status: 400 }) + } + + let user = await prisma.user.findUnique({ where: { id: userId } }) + if (!user) return NextResponse.json({ error: 'User not found' }, { status: 404 }) + + // Daily message limit for free users + if (!isActivePremium(user)) { + const todayStart = new Date() + todayStart.setHours(0, 0, 0, 0) + if (!user.dailyMsgResetAt || user.dailyMsgResetAt < todayStart) { + user = await prisma.user.update({ + where: { id: userId }, + data: { dailyMsgCount: 0, dailyMsgResetAt: new Date() }, + }) + } + if (user.dailyMsgCount >= 10) { + return NextResponse.json( + { error: 'daily_limit_reached', limit: 10, used: user.dailyMsgCount }, + { status: 429 } + ) + } + } + + // Moderate user message + const mod = moderateContent(message) + if (!mod.approved) { + return NextResponse.json( + { error: `Message flagged: ${mod.reason}`, severity: mod.severity }, + { status: 400 } + ) + } + + // Parse slash commands + const { command, rest } = parseCommand(message) + + // Handle /new and /fresh by clearing history + if (command === 'new' || command === 'fresh') { + await prisma.chatHistory.deleteMany({ where: { userId } }) + const persona = SCHOLAR_PERSONAS[(user.coachPersona || 'nurbuddy') as keyof typeof SCHOLAR_PERSONAS] || SCHOLAR_PERSONAS.nurbuddy + return NextResponse.json({ + response: persona.greeting, + metadata: { command: 'new', summary: 'Chat history cleared', actionItems: [], topics: [] }, + cleared: true, + }) + } + + // Load recent conversation history + const history = await prisma.chatHistory.findMany({ + where: { userId }, + orderBy: { createdAt: 'asc' }, + take: 12, + }) + + // Load previous session summaries from metadata + const summaries: string[] = [] + const actionItems: string[] = [] + for (const h of history) { + if (h.metadata) { + try { + const meta = JSON.parse(h.metadata) + if (meta.summary) summaries.push(meta.summary) + if (meta.actionItems) actionItems.push(...meta.actionItems) + } catch { /* ignore bad JSON */ } + } + } + + // Calculate days since joined + const daysSinceJoined = Math.max( + 1, + Math.floor((Date.now() - new Date(user.createdAt).getTime()) / (1000 * 60 * 60 * 24)) + ) + + // Calculate coaching streak + let streakDays = 1 + if (user.lastCoachedAt) { + const hoursSince = (Date.now() - new Date(user.lastCoachedAt).getTime()) / (1000 * 60 * 60) + if (hoursSince < 48) streakDays = 2 + } + + const userContext: UserContext = { + id: user.id, + name: user.name, + preferredName: user.preferredName, + experienceLevel: user.experienceLevel || '', + madhab: user.madhab || '', + coachPersona: user.coachPersona as any, + isPremium: user.isPremium, + isPro: user.isPro, + flhBalance: user.flhBalance, + coachingGoals: user.coachingGoals, + daysSinceJoined, + } + + const memory: CoachingMemory = { + summaries: summaries.slice(-5), + lastTopics: [], + actionItems: actionItems.slice(-8), + streakDays, + } + + // Save user message + await prisma.chatHistory.create({ + data: { userId, role: 'user', content: message }, + }) + + let response: string + let metadata: any = {} + + // Route slash commands + if (command) { + const result = handleCommand( + command, + rest, + userContext.coachPersona, + userContext.preferredName || userContext.name || 'my friend', + getSeenSet(userId, 'hadith'), + getSeenSet(userId, 'quran'), + getSeenSet(userId, 'zikr'), + getSeenSet(userId, 'reminder'), + getSeenSet(userId, 'faraid'), + getSeenSet(userId, 'infaq'), + getSeenSet(userId, 'prayer'), + ) + response = result.response + metadata = result.metadata + } else { + // Normal AI conversation + const aiResult = await askNur( + message, + userContext, + memory, + history.map((h: { role: string; content: string }) => ({ role: h.role, content: h.content })) + ) + response = aiResult.response + metadata = aiResult.metadata + } + + // Save assistant response with metadata + await prisma.chatHistory.create({ + data: { + userId, + role: 'assistant', + content: response, + metadata: metadata ? JSON.stringify(metadata) : null, + }, + }) + + // Update lastCoachedAt and increment daily message counter for free users + await prisma.user.update({ + where: { id: userId }, + data: { + lastCoachedAt: new Date(), + ...(!isActivePremium(user) ? { dailyMsgCount: { increment: 1 } } : {}), + }, + }) + + return NextResponse.json({ + response, + metadata: metadata || {}, + userContext: { + experienceLevel: user.experienceLevel || '', + madhab: user.madhab, + streakDays, + dailyMsgCount: isActivePremium(user) ? null : user.dailyMsgCount + 1, + }, + }) + } catch (e) { + console.error(e) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/src/app/api/daily/verse/route.ts b/src/app/api/daily/verse/route.ts new file mode 100644 index 0000000..6e87efe --- /dev/null +++ b/src/app/api/daily/verse/route.ts @@ -0,0 +1,10 @@ +import { NextResponse } from 'next/server' +import verses from '@/lib/daily-quran.json' + +export async function GET() { + const dayOfYear = Math.floor( + (Date.now() - new Date(new Date().getFullYear(), 0, 0).getTime()) / 86400000 + ) + const verse = verses[dayOfYear % verses.length] + return NextResponse.json(verse) +} diff --git a/src/app/api/dhikr/complete/route.ts b/src/app/api/dhikr/complete/route.ts new file mode 100644 index 0000000..b50c825 --- /dev/null +++ b/src/app/api/dhikr/complete/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { verifyJWT } from '@/lib/auth' + +export async function POST(req: NextRequest) { + const auth = req.headers.get('authorization') + if (!auth?.startsWith('Bearer ')) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const payload = await verifyJWT(auth.slice(7)) + if (!payload) return NextResponse.json({ error: 'Invalid token' }, { status: 401 }) + + const { count } = await req.json() + const today = new Date().toISOString().slice(0, 10) + + const session = await prisma.dhikrSession.upsert({ + where: { userId_date: { userId: payload.id, date: today } }, + create: { userId: payload.id, date: today, count: count ?? 0, completed: true }, + update: { count: count ?? 0, completed: true }, + }) + + // Update streak + const user = await prisma.user.findUnique({ + where: { id: payload.id }, + select: { dhikrStreak: true, dhikrStreakDate: true }, + }) + + const yesterday = new Date() + yesterday.setDate(yesterday.getDate() - 1) + const yesterdayStr = yesterday.toISOString().slice(0, 10) + const lastDate = user?.dhikrStreakDate?.toISOString().slice(0, 10) + + let newStreak = 1 + if (lastDate === yesterdayStr) { + newStreak = (user?.dhikrStreak ?? 0) + 1 + } else if (lastDate === today) { + newStreak = user?.dhikrStreak ?? 1 + } + + await prisma.user.update({ + where: { id: payload.id }, + data: { dhikrStreak: newStreak, dhikrStreakDate: new Date() }, + }) + + return NextResponse.json({ streak: newStreak, completed: true }) +} diff --git a/src/app/api/dhikr/status/route.ts b/src/app/api/dhikr/status/route.ts new file mode 100644 index 0000000..f5d6aec --- /dev/null +++ b/src/app/api/dhikr/status/route.ts @@ -0,0 +1,23 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { verifyJWT } from '@/lib/auth' + +export async function GET(req: NextRequest) { + const auth = req.headers.get('authorization') + if (!auth?.startsWith('Bearer ')) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const payload = await verifyJWT(auth.slice(7)) + if (!payload) return NextResponse.json({ error: 'Invalid token' }, { status: 401 }) + + const today = new Date().toISOString().slice(0, 10) + const [user, session] = await Promise.all([ + prisma.user.findUnique({ where: { id: payload.id }, select: { dhikrStreak: true, dhikrStreakDate: true } }), + prisma.dhikrSession.findUnique({ where: { userId_date: { userId: payload.id, date: today } } }), + ]) + + return NextResponse.json({ + streak: user?.dhikrStreak ?? 0, + todayCount: session?.count ?? 0, + todayCompleted: session?.completed ?? false, + date: today, + }) +} diff --git a/src/app/api/files/[listingId]/route.ts b/src/app/api/files/[listingId]/route.ts new file mode 100644 index 0000000..66de9dc --- /dev/null +++ b/src/app/api/files/[listingId]/route.ts @@ -0,0 +1,7 @@ +import { NextRequest, NextResponse } from 'next/server' + +export async function GET(req: NextRequest) { + const { pathname } = new URL(req.url) + const listingId = pathname.split('/').pop() + return NextResponse.json({ listingId, message: 'File serving endpoint' }) +} diff --git a/src/app/api/forum/categories/route.ts b/src/app/api/forum/categories/route.ts new file mode 100644 index 0000000..c57e7dd --- /dev/null +++ b/src/app/api/forum/categories/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' + +export async function GET() { + let categories = await prisma.forumCategory.findMany({ orderBy: { order: 'asc' } }) + if (categories.length === 0) { + const defaults = await prisma.$transaction([ + prisma.forumCategory.create({ data: { name: 'General Discussion', description: 'General Islamic discussions', icon: '\u{1F4AC}', order: 1 } }), + prisma.forumCategory.create({ data: { name: 'Fiqh & Rulings', description: 'Questions about Islamic jurisprudence', icon: '\u{1F4D6}', order: 2 } }), + prisma.forumCategory.create({ data: { name: 'Quran & Hadith', description: 'Study and reflection', icon: '\u{1F4FF}', order: 3 } }), + prisma.forumCategory.create({ data: { name: 'Family & Marriage', description: 'Family life in Islam', icon: '\u{1F468}\u200D\u{1F469}\u200D\u{1F467}\u200D\u{1F466}', order: 4 } }), + prisma.forumCategory.create({ data: { name: 'Business & Finance', description: 'Halal earning and Islamic finance', icon: '\u{1F4B0}', order: 5 } }), + prisma.forumCategory.create({ data: { name: 'Support & Duas', description: 'Ask for support and share duas', icon: '\u{1F64C}', order: 6 } }), + ]) + categories = defaults + } + return NextResponse.json({ categories }) +} diff --git a/src/app/api/forum/posts/route.ts b/src/app/api/forum/posts/route.ts new file mode 100644 index 0000000..d692179 --- /dev/null +++ b/src/app/api/forum/posts/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { verifyJWT } from '@/lib/auth' +import { moderateContent } from '@/lib/ai' + +export async function GET(req: NextRequest) { + const { searchParams } = new URL(req.url) + const threadId = searchParams.get('threadId') + if (!threadId) return NextResponse.json({ error: 'threadId required' }, { status: 400 }) + const posts = await prisma.forumPost.findMany({ + where: { threadId, shariahStatus: 'approved' }, + include: { author: { select: { id: true, name: true, isPremium: true, isPro: true } } }, + orderBy: { createdAt: 'asc' }, + }) + return NextResponse.json({ posts }) +} + +export async function POST(req: NextRequest) { + const auth = req.headers.get('authorization') + if (!auth?.startsWith('Bearer ')) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const payload = await verifyJWT(auth.slice(7)) + if (!payload) return NextResponse.json({ error: 'Invalid token' }, { status: 401 }) + const { threadId, content } = await req.json() + if (!threadId || !content) return NextResponse.json({ error: 'threadId and content required' }, { status: 400 }) + const moderation = moderateContent(content) + const post = await prisma.forumPost.create({ + data: { + threadId, content, authorId: payload.id, + shariahStatus: moderation.approved ? 'approved' : 'flagged', + shariahFlags: moderation.reason || null, + }, + include: { author: { select: { id: true, name: true } } }, + }) + return NextResponse.json({ post }, { status: 201 }) +} diff --git a/src/app/api/forum/threads/route.ts b/src/app/api/forum/threads/route.ts new file mode 100644 index 0000000..381a0d4 --- /dev/null +++ b/src/app/api/forum/threads/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { verifyJWT } from '@/lib/auth' +import { moderateContent } from '@/lib/ai' + +export async function GET(req: NextRequest) { + const { searchParams } = new URL(req.url) + const categoryId = searchParams.get('categoryId') + const where = categoryId ? { categoryId } : {} + const threads = await prisma.forumThread.findMany({ + where: { ...where, shariahStatus: 'approved' }, + include: { + author: { select: { id: true, name: true, isPremium: true, isPro: true } }, + category: { select: { id: true, name: true } }, + _count: { select: { posts: true } }, + }, + orderBy: [{ pinned: 'desc' }, { createdAt: 'desc' }], + }) + return NextResponse.json({ threads }) +} + +export async function POST(req: NextRequest) { + const auth = req.headers.get('authorization') + if (!auth?.startsWith('Bearer ')) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const payload = await verifyJWT(auth.slice(7)) + if (!payload) return NextResponse.json({ error: 'Invalid token' }, { status: 401 }) + const { title, content, categoryId } = await req.json() + if (!title || !content || !categoryId) return NextResponse.json({ error: 'title, content, and categoryId required' }, { status: 400 }) + const user = await prisma.user.findUnique({ where: { id: payload.id } }) + if (!user || (!user.isPremium && !user.isPro)) return NextResponse.json({ error: 'Premium subscription required to create threads' }, { status: 403 }) + const moderation = moderateContent(title + ' ' + content) + const thread = await prisma.forumThread.create({ + data: { + title, content, categoryId, authorId: payload.id, + shariahStatus: moderation.approved ? 'approved' : 'flagged', + shariahFlags: moderation.reason || null, + }, + include: { author: { select: { id: true, name: true } }, category: { select: { name: true } } }, + }) + return NextResponse.json({ thread }, { status: 201 }) +} diff --git a/src/app/api/halal/bookmarks/route.ts b/src/app/api/halal/bookmarks/route.ts new file mode 100644 index 0000000..2c25a0d --- /dev/null +++ b/src/app/api/halal/bookmarks/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { verifyJWT } from '@/lib/auth' + +export async function GET(req: NextRequest) { + const auth = req.headers.get('authorization') + if (!auth?.startsWith('Bearer ')) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const payload = await verifyJWT(auth.slice(7)) + if (!payload) return NextResponse.json({ error: 'Invalid token' }, { status: 401 }) + const bookmarks = await prisma.halalBookmark.findMany({ + where: { userId: payload.id }, + orderBy: { createdAt: 'desc' }, + }) + return NextResponse.json({ bookmarks }) +} + +export async function POST(req: NextRequest) { + const auth = req.headers.get('authorization') + if (!auth?.startsWith('Bearer ')) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const payload = await verifyJWT(auth.slice(7)) + if (!payload) return NextResponse.json({ error: 'Invalid token' }, { status: 401 }) + const { itemId, itemType, label } = await req.json() + if (!itemId || !itemType) return NextResponse.json({ error: 'itemId and itemType required' }, { status: 400 }) + const bookmark = await prisma.halalBookmark.create({ + data: { userId: payload.id, itemId, itemType, label: label || null }, + }) + return NextResponse.json({ bookmark }, { status: 201 }) +} + +export async function DELETE(req: NextRequest) { + const auth = req.headers.get('authorization') + if (!auth?.startsWith('Bearer ')) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const payload = await verifyJWT(auth.slice(7)) + if (!payload) return NextResponse.json({ error: 'Invalid token' }, { status: 401 }) + + // Support deletion by internal id (body) or by itemId (query param) + const itemIdParam = new URL(req.url).searchParams.get('itemId') + if (itemIdParam) { + await prisma.halalBookmark.deleteMany({ where: { itemId: itemIdParam, userId: payload.id } }) + } else { + const { id } = await req.json() + await prisma.halalBookmark.deleteMany({ where: { id, userId: payload.id } }) + } + return NextResponse.json({ success: true }) +} diff --git a/src/app/api/halal/places/route.ts b/src/app/api/halal/places/route.ts new file mode 100644 index 0000000..9da2742 --- /dev/null +++ b/src/app/api/halal/places/route.ts @@ -0,0 +1,157 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { verifyJWT } from '@/lib/auth' + +const FREE_DAILY_LIMIT = 20 +const CACHE_TTL_MS = 24 * 60 * 60 * 1000 + +interface OverpassElement { + id: number + lat?: number + lon?: number + center?: { lat: number; lon: number } + tags?: Record +} + +interface Place { + id: string + lat: number + lon: number + name: string + type: 'mosque' | 'restaurant' + address?: string +} + +const OVERPASS_ENDPOINTS = [ + 'https://overpass-api.de/api/interpreter', + 'https://maps.mail.ru/osm/tools/overpass/api/interpreter', + 'https://overpass.kumi.systems/api/interpreter', +] + +async function queryOverpass(query: string): Promise { + let lastError: Error | null = null + for (const endpoint of OVERPASS_ENDPOINTS) { + try { + const res = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json', + }, + body: `data=${encodeURIComponent(query)}`, + signal: AbortSignal.timeout(15000), + }) + if (!res.ok) { lastError = new Error(`Overpass error ${res.status} from ${endpoint}`); continue } + const data = await res.json() + return data.elements || [] + } catch (e) { + lastError = e as Error + } + } + throw lastError ?? new Error('All Overpass endpoints failed') +} + +function buildMosqueQuery(lat: number, lon: number, radius: number): string { + return `[out:json][timeout:10];(node["amenity"="place_of_worship"]["religion"="muslim"](around:${radius},${lat},${lon});way["amenity"="place_of_worship"]["religion"="muslim"](around:${radius},${lat},${lon}););out center;` +} + +function buildRestaurantQuery(lat: number, lon: number, radius: number): string { + return `[out:json][timeout:10];(node["diet:halal"="yes"](around:${radius},${lat},${lon});node["cuisine"~"halal"](around:${radius},${lat},${lon});way["diet:halal"="yes"](around:${radius},${lat},${lon}););out center;` +} + +function elementsToPlaces(elements: OverpassElement[], type: 'mosque' | 'restaurant'): Place[] { + return elements + .filter(e => { + const lat = e.lat ?? e.center?.lat + const lon = e.lon ?? e.center?.lon + return lat !== undefined && lon !== undefined + }) + .map(e => ({ + id: String(e.id), + lat: (e.lat ?? e.center?.lat)!, + lon: (e.lon ?? e.center?.lon)!, + name: e.tags?.name || e.tags?.['name:en'] || (type === 'mosque' ? 'Mosque' : 'Halal Restaurant'), + type, + address: [e.tags?.['addr:street'], e.tags?.['addr:city']].filter(Boolean).join(', ') || undefined, + })) +} + +async function getUserFromRequest(req: NextRequest) { + const auth = req.headers.get('authorization') + if (!auth?.startsWith('Bearer ')) return null + const payload = await verifyJWT(auth.slice(7)) + if (!payload) return null + return prisma.user.findUnique({ where: { id: payload.id } }) +} + +export async function GET(req: NextRequest) { + try { + const { searchParams } = new URL(req.url) + const lat = parseFloat(searchParams.get('lat') || '') + const lon = parseFloat(searchParams.get('lng') || '') + const radius = Math.min(parseInt(searchParams.get('radius') || '5000'), 10000) + + if (isNaN(lat) || isNaN(lon)) { + return NextResponse.json({ error: 'lat and lng required' }, { status: 400 }) + } + + const user = await getUserFromRequest(req) + const isPremium = user?.isPremium || user?.isPro || false + + // Rate limit free users + if (user && !isPremium) { + const today = new Date() + today.setHours(0, 0, 0, 0) + let usage = await prisma.halalUsage.findUnique({ where: { userId: user.id } }) + if (!usage || !usage.periodEnd || usage.periodEnd < today) { + usage = await prisma.halalUsage.upsert({ + where: { userId: user.id }, + create: { userId: user.id, queriesUsed: 0, queriesLimit: FREE_DAILY_LIMIT, periodEnd: new Date(today.getTime() + 86400000) }, + update: { queriesUsed: 0, queriesLimit: FREE_DAILY_LIMIT, periodEnd: new Date(today.getTime() + 86400000) }, + }) + } + if (usage.queriesUsed >= FREE_DAILY_LIMIT) { + return NextResponse.json({ error: 'daily_limit_reached', limit: FREE_DAILY_LIMIT }, { status: 429 }) + } + } + + // Check cache + const cacheKey = `${isPremium ? 'premium' : 'free'}:${lat.toFixed(2)}:${lon.toFixed(2)}:${radius}` + const cached = await prisma.halalPlaceCache.findUnique({ where: { cacheKey } }) + if (cached && cached.expiresAt > new Date()) { + return NextResponse.json(JSON.parse(cached.data)) + } + + // Fetch from Overpass + const mosqueElements = await queryOverpass(buildMosqueQuery(lat, lon, radius)) + const mosques = elementsToPlaces(mosqueElements, 'mosque') + + let restaurants: Place[] = [] + if (isPremium) { + const restaurantElements = await queryOverpass(buildRestaurantQuery(lat, lon, radius)) + restaurants = elementsToPlaces(restaurantElements, 'restaurant') + } + + const result = { mosques, restaurants } + + // Cache result + await prisma.halalPlaceCache.upsert({ + where: { cacheKey }, + create: { cacheKey, data: JSON.stringify(result), expiresAt: new Date(Date.now() + CACHE_TTL_MS) }, + update: { data: JSON.stringify(result), expiresAt: new Date(Date.now() + CACHE_TTL_MS) }, + }) + + // Increment usage for free users + if (user && !isPremium) { + await prisma.halalUsage.update({ + where: { userId: user.id }, + data: { queriesUsed: { increment: 1 } }, + }) + } + + return NextResponse.json(result) + } catch (e) { + console.error('Halal places error:', e) + return NextResponse.json({ error: 'Failed to fetch places' }, { status: 500 }) + } +} diff --git a/src/app/api/halal/usage/route.ts b/src/app/api/halal/usage/route.ts new file mode 100644 index 0000000..f05ea3f --- /dev/null +++ b/src/app/api/halal/usage/route.ts @@ -0,0 +1,25 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { verifyJWT } from '@/lib/auth' + +export async function GET(req: NextRequest) { + const auth = req.headers.get('authorization') + if (!auth?.startsWith('Bearer ')) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const payload = await verifyJWT(auth.slice(7)) + if (!payload) return NextResponse.json({ error: 'Invalid token' }, { status: 401 }) + const usage = await prisma.halalUsage.findUnique({ where: { userId: payload.id } }) + return NextResponse.json({ usage: usage || { userId: payload.id, queriesUsed: 0, queriesLimit: 100, periodEnd: null } }) +} + +export async function POST(req: NextRequest) { + const auth = req.headers.get('authorization') + if (!auth?.startsWith('Bearer ')) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const payload = await verifyJWT(auth.slice(7)) + if (!payload) return NextResponse.json({ error: 'Invalid token' }, { status: 401 }) + const usage = await prisma.halalUsage.upsert({ + where: { userId: payload.id }, + update: { queriesUsed: { increment: 1 } }, + create: { userId: payload.id, queriesUsed: 1, queriesLimit: 100, periodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) }, + }) + return NextResponse.json({ usage }) +} diff --git a/src/app/api/marketplace/feature/route.ts b/src/app/api/marketplace/feature/route.ts new file mode 100644 index 0000000..615969c --- /dev/null +++ b/src/app/api/marketplace/feature/route.ts @@ -0,0 +1,20 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { verifyJWT } from '@/lib/auth' + +export async function PUT(req: NextRequest) { + const auth = req.headers.get('authorization') + if (!auth?.startsWith('Bearer ')) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const payload = await verifyJWT(auth.slice(7)) + if (!payload) return NextResponse.json({ error: 'Invalid token' }, { status: 401 }) + const { listingId } = await req.json() + const user = await prisma.user.findUnique({ where: { id: payload.id } }) + if (!user?.isPro) return NextResponse.json({ error: 'Pro subscription required to feature listings' }, { status: 403 }) + const listing = await prisma.listing.findFirst({ where: { id: listingId, sellerId: payload.id } }) + if (!listing) return NextResponse.json({ error: 'Listing not found' }, { status: 404 }) + const updated = await prisma.listing.update({ + where: { id: listingId }, + data: { featured: true, featuredUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) }, + }) + return NextResponse.json({ listing: updated }) +} diff --git a/src/app/api/marketplace/listings/route.ts b/src/app/api/marketplace/listings/route.ts new file mode 100644 index 0000000..ddc0e69 --- /dev/null +++ b/src/app/api/marketplace/listings/route.ts @@ -0,0 +1,37 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { verifyJWT } from '@/lib/auth' + +export async function GET() { + const listings = await prisma.listing.findMany({ + where: { status: 'active' }, + include: { seller: { select: { id: true, name: true } } }, + orderBy: [{ featured: 'desc' }, { createdAt: 'desc' }], + }) + return NextResponse.json({ + listings: listings.map((l: any) => ({ + id: l.id, sellerId: l.seller.id, title: l.title, category: l.category, + price_flh: l.priceFlh, seller: l.seller.name, description: l.description, + status: l.status, featured: l.featured, featuredUntil: l.featuredUntil, + fileType: l.fileType, createdAt: l.createdAt, + })), + }) +} + +export async function POST(req: NextRequest) { + const auth = req.headers.get('authorization') + if (!auth?.startsWith('Bearer ')) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const payload = await verifyJWT(auth.slice(7)) + if (!payload) return NextResponse.json({ error: 'Invalid token' }, { status: 401 }) + const { title, description, category, priceFlh } = await req.json() + if (!title || !description || !category || !priceFlh) return NextResponse.json({ error: 'All fields required' }, { status: 400 }) + const user = await prisma.user.findUnique({ where: { id: payload.id } }) + const maxListings = user?.isPro ? 9999 : user?.isPremium ? 50 : 5 + const count = await prisma.listing.count({ where: { sellerId: payload.id, status: 'active' } }) + if (count >= maxListings!) return NextResponse.json({ error: 'Listing limit reached. Upgrade to Premium or Pro.' }, { status: 400 }) + const listing = await prisma.listing.create({ + data: { title, description, category, priceFlh: parseInt(priceFlh), sellerId: payload.id }, + include: { seller: { select: { name: true } } }, + }) + return NextResponse.json({ listing }, { status: 201 }) +} diff --git a/src/app/api/marketplace/purchase/route.ts b/src/app/api/marketplace/purchase/route.ts new file mode 100644 index 0000000..63eecdc --- /dev/null +++ b/src/app/api/marketplace/purchase/route.ts @@ -0,0 +1,28 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { verifyJWT } from '@/lib/auth' + +export async function POST(req: NextRequest) { + const auth = req.headers.get('authorization') + if (!auth?.startsWith('Bearer ')) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const payload = await verifyJWT(auth.slice(7)) + if (!payload) return NextResponse.json({ error: 'Invalid token' }, { status: 401 }) + const { listingId } = await req.json() + if (!listingId) return NextResponse.json({ error: 'listingId required' }, { status: 400 }) + const listing = await prisma.listing.findUnique({ where: { id: listingId } }) + if (!listing || listing.status !== 'active') return NextResponse.json({ error: 'Listing not available' }, { status: 400 }) + if (listing.sellerId === payload.id) return NextResponse.json({ error: 'Cannot purchase your own listing' }, { status: 400 }) + const buyer = await prisma.user.findUnique({ where: { id: payload.id } }) + if (!buyer || buyer.flhBalance < listing.priceFlh) return NextResponse.json({ error: 'Insufficient balance' }, { status: 400 }) + const platformFee = Math.floor(listing.priceFlh * 0.1) + const sellerPayout = listing.priceFlh - platformFee + const autoConfirmAt = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000) + const [purchase] = await prisma.$transaction([ + prisma.purchase.create({ + data: { listingId, buyerId: payload.id, sellerId: listing.sellerId, amountFlh: listing.priceFlh, platformFee, sellerPayout, autoConfirmAt }, + include: { listing: true }, + }), + prisma.user.update({ where: { id: payload.id }, data: { flhBalance: { decrement: listing.priceFlh } } }), + ]) + return NextResponse.json({ purchase }, { status: 201 }) +} diff --git a/src/app/api/prayer/times/route.ts b/src/app/api/prayer/times/route.ts new file mode 100644 index 0000000..e407e94 --- /dev/null +++ b/src/app/api/prayer/times/route.ts @@ -0,0 +1,37 @@ +import { NextRequest, NextResponse } from 'next/server' + +// Proxies aladhan.com — free, no API key required +export async function GET(req: NextRequest) { + const { searchParams } = new URL(req.url) + const lat = searchParams.get('lat') + const lng = searchParams.get('lng') + const method = searchParams.get('method') || '3' // MWL + + if (!lat || !lng) return NextResponse.json({ error: 'lat and lng required' }, { status: 400 }) + + try { + const res = await fetch( + `https://api.aladhan.com/v1/timings?latitude=${lat}&longitude=${lng}&method=${method}`, + { signal: AbortSignal.timeout(8000) } + ) + if (!res.ok) return NextResponse.json({ error: 'Prayer times unavailable' }, { status: 502 }) + const data = await res.json() + const timings = data.data?.timings + const date = data.data?.date + + return NextResponse.json({ + timings: { + Fajr: timings?.Fajr, + Sunrise: timings?.Sunrise, + Dhuhr: timings?.Dhuhr, + Asr: timings?.Asr, + Maghrib: timings?.Maghrib, + Isha: timings?.Isha, + }, + hijri: date?.hijri, + gregorian: date?.gregorian, + }) + } catch { + return NextResponse.json({ error: 'Prayer times unavailable' }, { status: 502 }) + } +} diff --git a/src/app/api/quran/read/route.ts b/src/app/api/quran/read/route.ts new file mode 100644 index 0000000..aca2228 --- /dev/null +++ b/src/app/api/quran/read/route.ts @@ -0,0 +1,65 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { verifyJWT } from '@/lib/auth' + +export async function GET(req: NextRequest) { + const auth = req.headers.get('authorization') + if (!auth?.startsWith('Bearer ')) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const payload = await verifyJWT(auth.slice(7)) + if (!payload) return NextResponse.json({ error: 'Invalid token' }, { status: 401 }) + + const today = new Date().toISOString().slice(0, 10) + const [user, reading] = await Promise.all([ + prisma.user.findUnique({ where: { id: payload.id }, select: { quranStreak: true, quranStreakDate: true } }), + prisma.quranReading.findUnique({ where: { userId_date: { userId: payload.id, date: today } } }), + ]) + + return NextResponse.json({ + streak: user?.quranStreak ?? 0, + todayRead: reading?.pagesRead ?? 0, + todayGoal: reading?.goalPages ?? 1, + date: today, + }) +} + +export async function POST(req: NextRequest) { + const auth = req.headers.get('authorization') + if (!auth?.startsWith('Bearer ')) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const payload = await verifyJWT(auth.slice(7)) + if (!payload) return NextResponse.json({ error: 'Invalid token' }, { status: 401 }) + + const { pages = 1, goal = 1 } = await req.json() + const today = new Date().toISOString().slice(0, 10) + + const reading = await prisma.quranReading.upsert({ + where: { userId_date: { userId: payload.id, date: today } }, + create: { userId: payload.id, date: today, pagesRead: pages, goalPages: goal }, + update: { pagesRead: pages, goalPages: goal }, + }) + + const goalMet = reading.pagesRead >= reading.goalPages + + if (goalMet) { + const user = await prisma.user.findUnique({ + where: { id: payload.id }, + select: { quranStreak: true, quranStreakDate: true }, + }) + const yesterday = new Date() + yesterday.setDate(yesterday.getDate() - 1) + const yesterdayStr = yesterday.toISOString().slice(0, 10) + const lastDate = user?.quranStreakDate?.toISOString().slice(0, 10) + + let newStreak = 1 + if (lastDate === yesterdayStr) newStreak = (user?.quranStreak ?? 0) + 1 + else if (lastDate === today) newStreak = user?.quranStreak ?? 1 + + await prisma.user.update({ + where: { id: payload.id }, + data: { quranStreak: newStreak, quranStreakDate: new Date() }, + }) + + return NextResponse.json({ streak: newStreak, goalMet: true, pagesRead: reading.pagesRead }) + } + + return NextResponse.json({ streak: null, goalMet: false, pagesRead: reading.pagesRead }) +} diff --git a/src/app/api/seed/route.ts b/src/app/api/seed/route.ts new file mode 100644 index 0000000..c30b9fc --- /dev/null +++ b/src/app/api/seed/route.ts @@ -0,0 +1,54 @@ +import { NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import bcrypt from 'bcryptjs' + +export async function POST() { + try { + const password = await bcrypt.hash('password123', 10) + + const userData = [ + { email: 'ghazali@falahos.my', name: 'Abu Hamid Al-Ghazali', password, flhBalance: 5000 }, + { email: 'ibnabbas@falahos.my', name: 'Abdullah Ibn Abbas', password, flhBalance: 3000 }, + { email: 'rabia@falahos.my', name: "Rabi'a Al-Adawiyya", password, flhBalance: 2000 }, + { email: 'demo@falahos.my', name: 'Demo User', password, flhBalance: 500 }, + ] + + const users: any[] = [] + for (const u of userData) { + let user = await prisma.user.findUnique({ where: { email: u.email } }) + if (!user) { + user = await prisma.user.create({ data: u }) + } + users.push(user) + } + + const listings = [ + { title: 'Digital Quran Study Planner', description: 'Comprehensive digital planner for Quran memorization tracking with daily logs and revision schedules.', category: 'E-Books', priceFlh: 150, sellerId: users[0].id }, + { title: 'Islamic Art Calligraphy Print', description: 'Beautiful "Bismillah" calligraphy print, handmade. High-quality 300gsm paper, A3 size.', category: 'Design', priceFlh: 250, sellerId: users[1].id }, + { title: 'Online Arabic Course - Beginner', description: '12-week structured Arabic course with weekly live sessions, worksheets, and community support.', category: 'Courses', priceFlh: 500, sellerId: users[2].id }, + { title: 'Halal Snack Box - Monthly Subscription', description: 'Curated box of halal-certified snacks delivered monthly. International treats included.', category: 'Other', priceFlh: 80, sellerId: users[3].id }, + { title: 'Digital Dhikr Counter App', description: 'Beautiful dhikr counter with daily adhkar tracking, goals, and badges.', category: 'Software', priceFlh: 30, sellerId: users[0].id }, + { title: 'Handcrafted Tasbih (Prayer Beads)', description: 'Premium olive wood tasbih, 33 beads with tassel. Handcrafted by artisans. Gift box included.', category: 'Other', priceFlh: 120, sellerId: users[1].id }, + { title: 'Islamic Parenting E-Book Bundle', description: '5 e-books on raising righteous children: discipline, faith, education, screen time, character.', category: 'E-Books', priceFlh: 45, sellerId: users[2].id }, + { title: 'Tajweed Mastery Video Course', description: 'Complete Tajweed rules with 20 video lessons, practice exercises, and progress quizzes.', category: 'Courses', priceFlh: 350, sellerId: users[0].id }, + ] + + let created = 0 + for (const l of listings) { + const existing = await prisma.listing.findFirst({ where: { title: l.title } }) + if (!existing) { + await prisma.listing.create({ data: l }) + created++ + } + } + + return NextResponse.json({ + success: true, + message: `Seeded: ${users.length} users, ${created} new listings`, + users: users.length, + listingsCreated: created, + }) + } catch (e: any) { + return NextResponse.json({ error: e.message }, { status: 500 }) + } +} diff --git a/src/app/api/seller/[sellerId]/route.ts b/src/app/api/seller/[sellerId]/route.ts new file mode 100644 index 0000000..9d6adda --- /dev/null +++ b/src/app/api/seller/[sellerId]/route.ts @@ -0,0 +1,14 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' + +export async function GET(req: NextRequest) { + const { pathname } = new URL(req.url) + const sellerId = pathname.split('/').pop() + if (!sellerId) return NextResponse.json({ error: 'sellerId required' }, { status: 400 }) + const listings = await prisma.listing.findMany({ + where: { sellerId, status: 'active' }, + select: { id: true, title: true, category: true, priceFlh: true, createdAt: true }, + orderBy: { createdAt: 'desc' }, + }) + return NextResponse.json({ listings }) +} diff --git a/src/app/api/upgrade/create-checkout/route.ts b/src/app/api/upgrade/create-checkout/route.ts new file mode 100644 index 0000000..84bcc1f --- /dev/null +++ b/src/app/api/upgrade/create-checkout/route.ts @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { verifyJWT } from '@/lib/auth' + +// Map the legacy priceId keys the upgrade page sends → Polar product IDs +const PRICE_ID_MAP: Record = { + price_premium_monthly: 'a291a3d9-fa82-4e88-ba89-c6005401398f', + price_pro_monthly: '7f983017-e1f3-440e-be2a-5a5d19c9c7b0', +} + +export async function POST(req: NextRequest) { + if (!process.env.POLAR_ACCESS_TOKEN) { + return NextResponse.json({ error: 'Payment not configured' }, { status: 503 }) + } + + const auth = req.headers.get('authorization') + if (!auth?.startsWith('Bearer ')) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + const payload = await verifyJWT(auth.slice(7)) + if (!payload) return NextResponse.json({ error: 'Invalid token' }, { status: 401 }) + + const { priceId } = await req.json() + if (!priceId) return NextResponse.json({ error: 'priceId required' }, { status: 400 }) + + const productId = PRICE_ID_MAP[priceId] || priceId + if (!productId) return NextResponse.json({ error: 'Invalid product' }, { status: 400 }) + + const user = await prisma.user.findUnique({ where: { id: payload.id } }) + if (!user) return NextResponse.json({ error: 'User not found' }, { status: 404 }) + + const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000' + + const res = await fetch('https://api.polar.sh/v1/checkouts', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${process.env.POLAR_ACCESS_TOKEN}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + products: [{ product_id: productId }], + success_url: `${appUrl}/upgrade?upgrade=success`, + customer_email: user.email, + metadata: { userId: user.id }, + }), + }) + + if (!res.ok) { + const err = await res.text() + console.error('Polar checkout error:', err) + return NextResponse.json({ error: 'Failed to create checkout' }, { status: 500 }) + } + + const data = await res.json() + return NextResponse.json({ url: data.url }) +} diff --git a/src/app/api/wallet/route.ts b/src/app/api/wallet/route.ts new file mode 100644 index 0000000..971cd1c --- /dev/null +++ b/src/app/api/wallet/route.ts @@ -0,0 +1,28 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { verifyJWT } from '@/lib/auth' + +export async function POST(req: NextRequest) { + const auth = req.headers.get('authorization') + if (!auth?.startsWith('Bearer ')) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const payload = await verifyJWT(auth.slice(7)) + if (!payload) return NextResponse.json({ error: 'Invalid token' }, { status: 401 }) + const { amountFlh } = await req.json() + if (!amountFlh || amountFlh < 100) return NextResponse.json({ error: 'Minimum cashout is 100 FLH' }, { status: 400 }) + const user = await prisma.user.findUnique({ where: { id: payload.id } }) + if (!user || user.flhBalance < amountFlh) return NextResponse.json({ error: 'Insufficient balance' }, { status: 400 }) + const fiatAmount = (amountFlh / 100) * 0.8 + const cashout = await prisma.cashoutRequest.create({ data: { userId: payload.id, amountFlh, fiatAmount } }) + return NextResponse.json({ cashout }) +} + +export async function GET(req: NextRequest) { + const auth = req.headers.get('authorization') + if (!auth?.startsWith('Bearer ')) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const payload = await verifyJWT(auth.slice(7)) + if (!payload) return NextResponse.json({ error: 'Invalid token' }, { status: 401 }) + const requests = await prisma.cashoutRequest.findMany({ + where: { userId: payload.id }, orderBy: { createdAt: 'desc' }, + }) + return NextResponse.json({ requests }) +} diff --git a/src/app/api/wallet/top-up/create-checkout/route.ts b/src/app/api/wallet/top-up/create-checkout/route.ts new file mode 100644 index 0000000..4012e54 --- /dev/null +++ b/src/app/api/wallet/top-up/create-checkout/route.ts @@ -0,0 +1,5 @@ +import { NextResponse } from 'next/server' + +export async function POST() { + return NextResponse.json({ error: 'Not yet implemented' }, { status: 501 }) +} diff --git a/src/app/api/webhooks/polar/route.ts b/src/app/api/webhooks/polar/route.ts new file mode 100644 index 0000000..b65b20e --- /dev/null +++ b/src/app/api/webhooks/polar/route.ts @@ -0,0 +1,87 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { createHmac } from 'crypto' + +// Polar product IDs → tier flags +const PRODUCT_TIERS: Record = { + 'a291a3d9-fa82-4e88-ba89-c6005401398f': { isPremium: true, isPro: false }, // Premium + '7f983017-e1f3-440e-be2a-5a5d19c9c7b0': { isPremium: true, isPro: true }, // Pro +} + +function verifySignature(body: string, headers: Headers): boolean { + const secret = process.env.POLAR_WEBHOOK_SECRET + if (!secret) return false + + // Polar uses Svix: signature is over "id.timestamp.body" + const webhookId = headers.get('webhook-id') + const timestamp = headers.get('webhook-timestamp') + const signature = headers.get('webhook-signature') + if (!webhookId || !timestamp || !signature) return false + + const signedContent = `${webhookId}.${timestamp}.${body}` + const secretBytes = Buffer.from(secret.replace('whsec_', ''), 'base64') + const computed = createHmac('sha256', secretBytes).update(signedContent).digest('base64') + + // Signature header may contain multiple: "v1, v1," + return signature.split(' ').some(s => s === `v1,${computed}`) +} + +export async function POST(req: NextRequest) { + const body = await req.text() + + if (process.env.POLAR_WEBHOOK_SECRET && !verifySignature(body, req.headers)) { + return NextResponse.json({ error: 'Invalid signature' }, { status: 400 }) + } + + let event: { type: string; data: any } + try { + event = JSON.parse(body) + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }) + } + + const { type, data } = event + + try { + switch (type) { + case 'subscription.created': + case 'subscription.updated': { + const userId = data.metadata?.userId || data.customer?.metadata?.userId + if (!userId) break + + const productId = data.product_id || data.product?.id + const tier = productId ? PRODUCT_TIERS[productId] : null + const isActive = ['active', 'trialing'].includes(data.status) + + if (isActive && tier) { + await prisma.user.update({ + where: { id: userId }, + data: tier, + }) + } else if (!isActive) { + await prisma.user.update({ + where: { id: userId }, + data: { isPremium: false, isPro: false }, + }) + } + break + } + + case 'subscription.revoked': + case 'subscription.canceled': { + const userId = data.metadata?.userId || data.customer?.metadata?.userId + if (!userId) break + await prisma.user.update({ + where: { id: userId }, + data: { isPremium: false, isPro: false }, + }) + break + } + } + } catch (e) { + console.error('Polar webhook handler error:', e) + return NextResponse.json({ error: 'Handler error' }, { status: 500 }) + } + + return NextResponse.json({ received: true }) +} diff --git a/src/app/dhikr/page.tsx b/src/app/dhikr/page.tsx new file mode 100644 index 0000000..28904d3 --- /dev/null +++ b/src/app/dhikr/page.tsx @@ -0,0 +1,280 @@ +'use client' + +import { useState, useEffect, useCallback } from 'react' +import { useAuth } from '@/lib/AuthContext' +import { useRouter } from 'next/navigation' +import { Flame, CheckCircle2, RotateCcw } from 'lucide-react' + +const DHIKR_SEQUENCE = [ + { + arabic: 'سُبْحَانَ اللَّهِ', + transliteration: 'SubhanAllah', + meaning: 'Glory be to Allah', + target: 33, + ring: '#10b981', // emerald + glow: 'rgba(16,185,129,0.25)', + bg: 'rgba(16,185,129,0.08)', + }, + { + arabic: 'الْحَمْدُ لِلَّهِ', + transliteration: 'Alhamdulillah', + meaning: 'All praise is for Allah', + target: 33, + ring: '#3b82f6', // blue + glow: 'rgba(59,130,246,0.25)', + bg: 'rgba(59,130,246,0.08)', + }, + { + arabic: 'اللَّهُ أَكْبَرُ', + transliteration: 'Allahu Akbar', + meaning: 'Allah is the Greatest', + target: 34, + ring: '#D4AF37', // gold + glow: 'rgba(212,175,55,0.3)', + bg: 'rgba(212,175,55,0.08)', + }, +] + +const TOTAL = DHIKR_SEQUENCE.reduce((s, d) => s + d.target, 0) + +export default function DhikrPage() { + const { user, token } = useAuth() + const router = useRouter() + const [phase, setPhase] = useState(0) + const [count, setCount] = useState(0) + const [totalCount, setTotalCount] = useState(0) + const [streak, setStreak] = useState(0) + const [todayCompleted, setTodayCompleted] = useState(false) + const [justFinished, setJustFinished] = useState(false) + const [loading, setLoading] = useState(true) + const [ripple, setRipple] = useState(false) + + const current = DHIKR_SEQUENCE[phase] + + useEffect(() => { + if (!token) { setLoading(false); return } + fetch('/api/dhikr/status', { headers: { Authorization: `Bearer ${token}` } }) + .then(r => r.json()) + .then(d => { + setStreak(d.streak ?? 0) + setTodayCompleted(d.todayCompleted ?? false) + if (d.todayCompleted) setTotalCount(TOTAL) + }) + .catch(() => { }) + .finally(() => setLoading(false)) + }, [token]) + + const handleTap = useCallback(async () => { + if (todayCompleted || justFinished) return + if (typeof navigator !== 'undefined' && 'vibrate' in navigator) navigator.vibrate(10) + + setRipple(true) + setTimeout(() => setRipple(false), 300) + + const newCount = count + 1 + const newTotal = totalCount + 1 + setCount(newCount) + setTotalCount(newTotal) + + if (newCount >= current.target) { + if (phase < DHIKR_SEQUENCE.length - 1) { + setTimeout(() => { setPhase(p => p + 1); setCount(0) }, 350) + } else { + setJustFinished(true) + setTodayCompleted(true) + if (token) { + try { + const res = await fetch('/api/dhikr/complete', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({ count: TOTAL }), + }) + const data = await res.json() + setStreak(data.streak ?? streak + 1) + } catch { } + } + } + } + }, [count, totalCount, phase, current, token, todayCompleted, justFinished, streak]) + + const handleReset = () => { + setPhase(0); setCount(0); setTotalCount(0) + setJustFinished(false); setTodayCompleted(false) + } + + const progress = current ? count / current.target : 1 + const R = 58 + const C = 2 * Math.PI * R + const strokeDashoffset = C * (1 - progress) + + if (!user) { + return ( +
+
📿
+

Daily Dhikr

+

+ Build a daily habit of remembrance. Sign in to track your streak. +

+ +
+ ) + } + + if (loading) { + return ( +
+
+
+ ) + } + + return ( +
+ + {/* Header */} +
+
+

Daily Dhikr

+

100 remembrances · after prayer

+
+
+ + {streak} +
+
+ + {/* Phase progress dots */} +
+ {DHIKR_SEQUENCE.map((d, i) => ( +
+ ))} +
+ + {/* Completed today (not just-finished) */} + {todayCompleted && !justFinished ? ( +
+
+ +
+

MashaAllah!

+

You've completed your daily dhikr

+
+ + {streak} day streak +
+ +
+ + ) : justFinished ? ( + /* Completion screen */ +
+
+

SubhanAllah!

+

100 remembrances complete

+
+ + {streak} day streak +
+
+

+ “Whoever says SubhanAllah 33 times, Alhamdulillah 33 times, and Allahu Akbar 34 times after each prayer…” +

+

— Sahih Muslim

+
+ +
+ + ) : ( + /* Main counter */ +
+ {/* Ring counter */} + + + {/* Dhikr text */} +
+

{current.arabic}

+

{current.transliteration}

+

{current.meaning}

+
+ +

Tap to count

+
+ )} + + {/* Bottom progress */} + {!justFinished && ( +
+
+ Total today + {Math.min(totalCount, TOTAL)} / {TOTAL} +
+
+
+
+
+ )} +
+ ) +} diff --git a/src/app/forum/[threadId]/page.tsx b/src/app/forum/[threadId]/page.tsx new file mode 100644 index 0000000..c701464 --- /dev/null +++ b/src/app/forum/[threadId]/page.tsx @@ -0,0 +1,177 @@ +'use client' + +import { useState, useEffect } from 'react' +import { useParams, useRouter } from 'next/navigation' +import { useAuth } from '@/lib/AuthContext' +import { ArrowLeft, ShieldCheck, ShieldAlert, Send, MessageCircle } from 'lucide-react' + +interface Thread { + id: string + title: string + content: string + author: { id: string; name: string } + category: { id: string; name: string } + shariahStatus: string + shariahFlags: string | null + pinned: boolean + createdAt: string +} + +interface Post { + id: string + content: string + author: { id: string; name: string } + shariahStatus: string + shariahFlags: string | null + createdAt: string +} + +export default function ThreadDetailPage() { + const { threadId } = useParams<{ threadId: string }>() + const router = useRouter() + const { token, user } = useAuth() + const [thread, setThread] = useState(null) + const [posts, setPosts] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [reply, setReply] = useState('') + const [submitting, setSubmitting] = useState(false) + const [replyError, setReplyError] = useState(null) + + useEffect(() => { + if (!threadId) return + setLoading(true) + Promise.all([ + fetch(`/api/forum/threads?id=${threadId}`).then(r => r.json()), + fetch(`/api/forum/posts?threadId=${threadId}`).then(r => r.json()), + ]) + .then(([threadData, postsData]) => { + const t = threadData.threads?.[0] || threadData.thread + if (!t) { setError('Thread not found'); return } + setThread(t) + setPosts(postsData.posts || []) + }) + .catch(() => setError('Failed to load thread')) + .finally(() => setLoading(false)) + }, [threadId]) + + const handleReply = async (e: React.FormEvent) => { + e.preventDefault() + if (!token || !reply.trim()) return + setSubmitting(true) + setReplyError(null) + const res = await fetch('/api/forum/posts', { + method: 'POST', + headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ threadId, content: reply.trim() }), + }) + const data = await res.json() + setSubmitting(false) + if (!res.ok) { setReplyError(data.error || 'Failed to post reply'); return } + setPosts(prev => [...prev, data.post]) + setReply('') + } + + if (loading) { + return ( +
+
+
+
+
+
+
+
+ ) + } + + if (error || !thread) { + return ( +
+ +
+ +

{error || 'Thread not found'}

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

{thread.title}

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

{thread.content}

+
+ +
+

+ + Replies ({posts.length}) +

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

No replies yet. Be the first to respond.

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

{post.content}

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