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 <noreply@anthropic.com>
This commit is contained in:
FalahMobile
2026-06-15 08:05:51 +01:00
commit e2365e29fe
60 changed files with 5591 additions and 0 deletions
+30
View File
@@ -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"]
+10
View File
@@ -0,0 +1,10 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
turbopack: {
root: process.cwd(),
},
};
export default nextConfig;
+35
View File
@@ -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"
}
}
+189
View File
@@ -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])
}
+14
View File
@@ -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 }) }
}
+17
View File
@@ -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) } })
}
+77
View File
@@ -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<string, unknown> = {}
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 })
}
}
+18
View File
@@ -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 }) }
}
+140
View File
@@ -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<T>(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 })
}
}
+14
View File
@@ -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 })
}
@@ -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 })
}
+203
View File
@@ -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<string, Map<SlashCommand, Set<number>>>()
function getUserSeen(userId: string): Map<SlashCommand, Set<number>> {
if (!userSeenContent.has(userId)) {
userSeenContent.set(userId, new Map())
}
return userSeenContent.get(userId)!
}
function getSeenSet(userId: string, command: SlashCommand): Set<number> {
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 })
}
}
+10
View File
@@ -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)
}
+44
View File
@@ -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 })
}
+23
View File
@@ -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,
})
}
+7
View File
@@ -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' })
}
+18
View File
@@ -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 })
}
+35
View File
@@ -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 })
}
+41
View File
@@ -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 })
}
+45
View File
@@ -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 })
}
+157
View File
@@ -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<string, string>
}
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<OverpassElement[]> {
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 })
}
}
+25
View File
@@ -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 })
}
+20
View File
@@ -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 })
}
+37
View File
@@ -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 })
}
+28
View File
@@ -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 })
}
+37
View File
@@ -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 })
}
}
+65
View File
@@ -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 })
}
+54
View File
@@ -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 })
}
}
+14
View File
@@ -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 })
}
@@ -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<string, string> = {
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 })
}
+28
View File
@@ -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 })
}
@@ -0,0 +1,5 @@
import { NextResponse } from 'next/server'
export async function POST() {
return NextResponse.json({ error: 'Not yet implemented' }, { status: 501 })
}
+87
View File
@@ -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<string, { isPremium: boolean; isPro: boolean }> = {
'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,<sig1> v1,<sig2>"
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 })
}
+280
View File
@@ -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 (
<div className="min-h-dvh flex flex-col items-center justify-center bg-[#0a0a0f] px-6 pb-20">
<div className="text-5xl mb-6">📿</div>
<h2 className="text-xl font-bold text-white mb-2">Daily Dhikr</h2>
<p className="text-gray-500 text-sm text-center mb-8">
Build a daily habit of remembrance. Sign in to track your streak.
</p>
<button onClick={() => router.push('/login')}
className="w-full max-w-xs bg-[#D4AF37] text-[#0a0a0f] py-4 rounded-2xl font-bold active:scale-[0.98] transition-all">
Sign In
</button>
</div>
)
}
if (loading) {
return (
<div className="min-h-dvh flex items-center justify-center bg-[#0a0a0f] pb-20">
<div className="w-8 h-8 rounded-full border-2 border-[#D4AF37]/30 border-t-[#D4AF37] animate-spin" />
</div>
)
}
return (
<div className="min-h-dvh bg-[#0a0a0f] flex flex-col pb-24 select-none">
{/* Header */}
<div className="px-5 pt-6 pb-2 flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-white">Daily Dhikr</h1>
<p className="text-[11px] text-gray-700 mt-0.5">100 remembrances · after prayer</p>
</div>
<div className="flex items-center gap-1.5 bg-orange-500/10 border border-orange-500/20 rounded-xl px-3 py-2">
<Flame size={14} className="text-orange-400" />
<span className="text-sm font-bold text-orange-400">{streak}</span>
</div>
</div>
{/* Phase progress dots */}
<div className="flex gap-2 px-5 mt-4 mb-8">
{DHIKR_SEQUENCE.map((d, i) => (
<div
key={i}
className="flex-1 h-1 rounded-full transition-all duration-500"
style={{
background: i < phase ? d.ring : i === phase ? `${d.ring}99` : '#1a1a26',
}}
/>
))}
</div>
{/* Completed today (not just-finished) */}
{todayCompleted && !justFinished ? (
<div className="flex-1 flex flex-col items-center justify-center px-6 text-center">
<div className="w-28 h-28 rounded-full border-2 border-[#D4AF37]/30 bg-[#D4AF37]/8 flex items-center justify-center mb-6">
<CheckCircle2 size={48} className="text-[#D4AF37]" />
</div>
<h2 className="text-2xl font-bold text-white mb-2">MashaAllah!</h2>
<p className="text-gray-500 text-sm mb-5">You've completed your daily dhikr</p>
<div className="flex items-center gap-2 bg-orange-500/10 border border-orange-500/20 rounded-2xl px-5 py-3">
<Flame size={18} className="text-orange-400" />
<span className="text-lg font-bold text-orange-400">{streak} day streak</span>
</div>
<button onClick={handleReset} className="mt-10 flex items-center gap-2 text-gray-600 text-sm">
<RotateCcw size={13} /> Repeat again
</button>
</div>
) : justFinished ? (
/* Completion screen */
<div className="flex-1 flex flex-col items-center justify-center px-6 text-center">
<div className="text-6xl mb-5"></div>
<h2 className="text-3xl font-bold text-white mb-1">SubhanAllah!</h2>
<p className="text-gray-400 text-sm mb-6">100 remembrances complete</p>
<div className="flex items-center gap-2 bg-orange-500/10 border border-orange-500/20 rounded-2xl px-6 py-4 mb-8">
<Flame size={22} className="text-orange-400" />
<span className="text-2xl font-bold text-orange-400">{streak} day streak</span>
</div>
<div className="bg-gray-900/60 border border-gray-800 rounded-2xl px-5 py-4 max-w-xs">
<p className="text-xs text-gray-500 leading-relaxed">
&ldquo;Whoever says SubhanAllah 33 times, Alhamdulillah 33 times, and Allahu Akbar 34 times after each prayer&rdquo;
</p>
<p className="text-[11px] text-gray-700 mt-2"> Sahih Muslim</p>
</div>
<button onClick={handleReset} className="mt-8 flex items-center gap-2 text-gray-600 text-sm">
<RotateCcw size={13} /> Repeat again
</button>
</div>
) : (
/* Main counter */
<div className="flex-1 flex flex-col items-center justify-center px-6">
{/* Ring counter */}
<button
onClick={handleTap}
className="relative flex items-center justify-center active:scale-[0.96] transition-transform"
style={{ WebkitTapHighlightColor: 'transparent' }}
aria-label={`Tap to count ${current.transliteration}`}
>
{/* Outer glow ring */}
<div
className="absolute rounded-full transition-opacity duration-300"
style={{
width: 216, height: 216,
background: `radial-gradient(circle, ${current.glow} 0%, transparent 70%)`,
opacity: ripple ? 1 : 0.4,
}}
/>
{/* SVG progress ring */}
<svg width="200" height="200" className="-rotate-90" style={{ filter: `drop-shadow(0 0 8px ${current.glow})` }}>
{/* Track */}
<circle cx="100" cy="100" r={R} fill="none" stroke="#1a1a26" strokeWidth="8" />
{/* Progress */}
<circle
cx="100" cy="100" r={R} fill="none"
stroke={current.ring}
strokeWidth="8"
strokeLinecap="round"
strokeDasharray={C}
strokeDashoffset={strokeDashoffset}
className="transition-all duration-200"
/>
</svg>
{/* Center content */}
<div
className="absolute rounded-full flex flex-col items-center justify-center border border-white/5"
style={{
inset: 28,
background: `radial-gradient(circle at 40% 30%, ${current.bg}, #0d0d18)`,
}}
>
<span className="text-5xl font-bold text-white tabular-nums leading-none">{count}</span>
<span className="text-[11px] text-gray-600 mt-1">of {current.target}</span>
</div>
</button>
{/* Dhikr text */}
<div className="text-center mt-10 space-y-2">
<p className="text-3xl text-white leading-relaxed font-light">{current.arabic}</p>
<p className="font-bold text-base" style={{ color: current.ring }}>{current.transliteration}</p>
<p className="text-gray-600 text-sm">{current.meaning}</p>
</div>
<p className="text-gray-800 text-xs mt-10">Tap to count</p>
</div>
)}
{/* Bottom progress */}
{!justFinished && (
<div className="px-5 pb-2">
<div className="flex items-center justify-between text-[11px] text-gray-700 mb-2">
<span>Total today</span>
<span className="tabular-nums">{Math.min(totalCount, TOTAL)} / {TOTAL}</span>
</div>
<div className="h-1 bg-gray-900 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-300"
style={{
width: `${Math.min((totalCount / TOTAL) * 100, 100)}%`,
background: 'linear-gradient(to right, #10b981, #3b82f6, #D4AF37)',
}}
/>
</div>
</div>
)}
</div>
)
}
+177
View File
@@ -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<Thread | null>(null)
const [posts, setPosts] = useState<Post[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [reply, setReply] = useState('')
const [submitting, setSubmitting] = useState(false)
const [replyError, setReplyError] = useState<string | null>(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 (
<div className="max-w-3xl mx-auto p-4 sm:p-6">
<div className="animate-pulse space-y-4">
<div className="h-4 bg-gray-800 rounded w-24" />
<div className="h-8 bg-gray-800 rounded w-3/4" />
<div className="h-4 bg-gray-800 rounded w-1/3" />
<div className="h-32 bg-gray-800 rounded" />
</div>
</div>
)
}
if (error || !thread) {
return (
<div className="max-w-3xl mx-auto p-4 sm:p-6">
<button onClick={() => router.push('/forum')} className="flex items-center gap-1 text-[#D4AF37] hover:underline mb-4 text-sm">
<ArrowLeft size={16} /> Back to Forum
</button>
<div className="bg-[#111118] border border-gray-800 rounded-lg p-8 text-center">
<MessageCircle size={40} className="mx-auto text-gray-600 mb-3" />
<p className="text-gray-400">{error || 'Thread not found'}</p>
</div>
</div>
)
}
return (
<div className="max-w-3xl mx-auto p-4 sm:p-6 space-y-6">
<button onClick={() => router.push('/forum')} className="flex items-center gap-1 text-[#D4AF37] hover:underline text-sm">
<ArrowLeft size={16} /> Back to Forum
</button>
<div className="bg-[#111118] border border-gray-800 rounded-lg p-5 space-y-3">
<div className="flex items-center gap-2 flex-wrap">
<span className="bg-[#D4AF37]/10 text-[#D4AF37] text-xs font-semibold px-2 py-0.5 rounded">{thread.category.name}</span>
{thread.shariahStatus === 'approved' ? (
<span className="flex items-center gap-1 text-green-500 text-xs"><ShieldCheck size={14} /> Shariah Approved</span>
) : (
<span className="flex items-center gap-1 text-yellow-500 text-xs"><ShieldAlert size={14} /> {thread.shariahStatus === 'pending' ? 'Pending Review' : 'Flagged'}</span>
)}
</div>
<h1 className="text-xl font-bold">{thread.title}</h1>
<div className="text-sm text-gray-500">
By <span className="text-gray-300">{thread.author.name}</span> &middot; {new Date(thread.createdAt).toLocaleDateString()}
</div>
<p className="text-sm text-gray-300 whitespace-pre-wrap leading-relaxed">{thread.content}</p>
</div>
<div className="space-y-4">
<h2 className="text-lg font-semibold flex items-center gap-2">
<MessageCircle size={18} className="text-[#D4AF37]" />
Replies ({posts.length})
</h2>
{posts.length === 0 ? (
<div className="bg-[#111118] border border-gray-800 rounded-lg p-6 text-center">
<p className="text-gray-500 text-sm">No replies yet. Be the first to respond.</p>
</div>
) : (
posts.map(post => (
<div key={post.id} className="bg-[#111118] border border-gray-800 rounded-lg p-4 space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold">{post.author.name}</span>
<span className="text-xs text-gray-500">{new Date(post.createdAt).toLocaleDateString()}</span>
</div>
<p className="text-sm text-gray-300 whitespace-pre-wrap">{post.content}</p>
{post.shariahStatus !== 'approved' && (
<div className="flex items-center gap-1 text-yellow-500 text-xs pt-1">
<ShieldAlert size={12} /> Pending shariah review
</div>
)}
</div>
))
)}
</div>
{token ? (
<form onSubmit={handleReply} className="bg-[#111118] border border-gray-800 rounded-lg p-4 space-y-3">
<textarea
placeholder="Write your reply..."
value={reply}
onChange={e => setReply(e.target.value)}
required
rows={3}
className="w-full bg-[#0a0a0f] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none resize-none"
/>
{replyError && <p className="text-red-500 text-xs">{replyError}</p>}
<button type="submit" disabled={submitting || !reply.trim()}
className="flex items-center gap-1 bg-[#D4AF37] text-[#0a0a0f] px-4 py-2 rounded text-sm font-semibold disabled:opacity-50 hover:bg-[#C9A84C] transition">
<Send size={16} /> {submitting ? 'Posting...' : 'Post Reply'}
</button>
</form>
) : (
<div className="bg-[#111118] border border-gray-800 rounded-lg p-4 text-center">
<p className="text-sm text-gray-500">
<a href="/login" className="text-[#D4AF37] hover:underline font-semibold">Sign in</a> to reply to this thread.
</p>
</div>
)}
</div>
)
}
+105
View File
@@ -0,0 +1,105 @@
'use client'
import { useState, useEffect } from 'react'
import { useAuth } from '@/lib/AuthContext'
import { Plus, ChevronRight } from 'lucide-react'
interface Category { id: string; name: string; description: string; icon: string }
interface Thread { id: string; title: string; content: string; author: { name: string }; category: { name: string }; _count: { posts: number }; createdAt: string }
export default function ForumPage() {
const { token } = useAuth()
const [categories, setCategories] = useState<Category[]>([])
const [threads, setThreads] = useState<Thread[]>([])
const [selectedCat, setSelectedCat] = useState<string | null>(null)
const [view, setView] = useState<'categories' | 'threads'>('categories')
const [showCreate, setShowCreate] = useState(false)
useEffect(() => { fetch('/api/forum/categories').then(r => r.json()).then(d => setCategories(d.categories || [])) }, [])
const loadThreads = async (catId?: string) => {
const params = catId ? `?categoryId=${catId}` : ''
const res = await fetch(`/api/forum/threads${params}`)
const data = await res.json()
setThreads(data.threads || [])
}
return (
<div className="max-w-4xl mx-auto p-4 sm:p-6 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-[#D4AF37]">Forum</h1>
{token && (
<button onClick={() => setShowCreate(true)}
className="flex items-center gap-1 bg-[#D4AF37] text-[#0a0a0f] px-3 py-2 rounded text-sm font-semibold hover:bg-[#C9A84C] transition"><Plus size={16} /> New Thread</button>
)}
</div>
{view === 'categories' ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{categories.map(c => (
<button key={c.id} onClick={() => { setSelectedCat(c.id); setView('threads'); loadThreads(c.id) }}
className="bg-[#111118] border border-gray-800 rounded-lg p-4 text-left hover:border-gray-700 transition">
<div className="flex items-center gap-3">
<span className="text-2xl">{c.icon}</span>
<div className="flex-1">
<h3 className="font-semibold">{c.name}</h3>
<p className="text-xs text-gray-500">{c.description}</p>
</div>
<ChevronRight size={16} className="text-gray-600" />
</div>
</button>
))}
</div>
) : (
<div className="space-y-3">
<button onClick={() => setView('categories')} className="text-sm text-[#D4AF37] hover:underline mb-2 inline-block">&larr; Back to categories</button>
{threads.map(t => (
<div key={t.id} className="bg-[#111118] border border-gray-800 rounded-lg p-4 space-y-1">
<h3 className="font-semibold">{t.title}</h3>
<p className="text-sm text-gray-400 line-clamp-2">{t.content}</p>
<div className="flex items-center gap-3 text-xs text-gray-500 pt-1">
<span>{t.author.name}</span><span>{t.category.name}</span><span>{t._count.posts} replies</span><span>{new Date(t.createdAt).toLocaleDateString()}</span>
</div>
</div>
))}
</div>
)}
{showCreate && token && <CreateThreadModal token={token} categories={categories} onClose={() => setShowCreate(false)} onCreated={() => { setShowCreate(false); if (selectedCat) loadThreads(selectedCat) }} />}
</div>
)
}
function CreateThreadModal({ token, categories, onClose, onCreated }: { token: string; categories: Category[]; onClose: () => void; onCreated: () => void }) {
const [title, setTitle] = useState('')
const [content, setContent] = useState('')
const [categoryId, setCategoryId] = useState(categories[0]?.id || '')
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
const res = await fetch('/api/forum/threads', { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ title, content, categoryId }) })
setLoading(false)
if (res.ok) { onCreated() } else { const d = await res.json(); alert(d.error) }
}
return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center p-4 z-50">
<form onSubmit={handleSubmit} className="bg-[#111118] border border-gray-800 rounded-lg p-6 max-w-md w-full space-y-4">
<h2 className="text-lg font-bold">New Thread</h2>
<select value={categoryId} onChange={e => setCategoryId(e.target.value)}
className="w-full bg-[#0a0a0f] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none">
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
<input type="text" placeholder="Title" value={title} onChange={e => setTitle(e.target.value)} required
className="w-full bg-[#0a0a0f] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none" />
<textarea placeholder="Content" value={content} onChange={e => setContent(e.target.value)} required rows={4}
className="w-full bg-[#0a0a0f] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none" />
<button type="submit" disabled={loading}
className="w-full bg-[#D4AF37] text-[#0a0a0f] py-2 rounded font-semibold disabled:opacity-50 hover:bg-[#C9A84C] transition">
{loading ? 'Posting...' : 'Create Thread'}
</button>
<button type="button" onClick={onClose} className="w-full text-gray-500 text-sm hover:text-white">Cancel</button>
</form>
</div>
)
}
+49
View File
@@ -0,0 +1,49 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--background: #0a0a0f;
--foreground: #ededed;
--gold: #D4AF37;
--gold-dark: #C9A84C;
--nav-height: 64px;
}
html {
height: 100%;
color-scheme: dark;
}
body {
height: 100%;
color: var(--foreground);
background: var(--background);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overscroll-behavior: none;
}
* {
box-sizing: border-box;
padding: 0;
margin: 0;
-webkit-tap-highlight-color: transparent;
}
a {
color: inherit;
text-decoration: none;
}
/* Prevent iOS zoom on input focus — font-size must be >= 16px */
input, select, textarea {
font-size: 16px;
}
/* Smooth scrolling in chat areas */
.chat-scroll {
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
}
+353
View File
@@ -0,0 +1,353 @@
'use client'
import { useState, useEffect, useRef, useCallback } from 'react'
import { useAuth } from '@/lib/AuthContext'
import { Crown, Lock, Search, MapPin, Navigation, Bookmark, X, Loader2 } from 'lucide-react'
import dynamic from 'next/dynamic'
// Leaflet must be loaded client-side only (no SSR)
const MapContainer = dynamic(() => import('react-leaflet').then(m => m.MapContainer), { ssr: false })
const TileLayer = dynamic(() => import('react-leaflet').then(m => m.TileLayer), { ssr: false })
const Marker = dynamic(() => import('react-leaflet').then(m => m.Marker), { ssr: false })
const Popup = dynamic(() => import('react-leaflet').then(m => m.Popup), { ssr: false })
interface Place {
id: string
lat: number
lon: number
name: string
type: 'mosque' | 'restaurant'
address?: string
}
interface MapCenter {
lat: number
lon: number
}
function UpgradeCTA() {
return (
<div className="max-w-6xl mx-auto p-8 flex flex-col items-center justify-center min-h-[60vh] text-center space-y-4">
<Crown size={48} className="text-[#D4AF37]" />
<h1 className="text-2xl font-bold text-[#D4AF37]">Halal Monitor</h1>
<p className="text-gray-400 max-w-md">
Find mosques and halal restaurants anywhere in the world. Available to Premium members.
</p>
<div className="bg-[#111118] border border-gray-800 rounded-lg p-6 max-w-sm w-full space-y-3">
<Crown size={32} className="text-[#D4AF37] mx-auto" />
<h2 className="font-bold">Upgrade to Premium</h2>
<ul className="text-sm text-gray-400 space-y-1 text-left">
<li> Mosques and prayer spaces worldwide</li>
<li> Halal restaurants near you</li>
<li> Bookmark favourite places</li>
<li> City search anywhere in the world</li>
</ul>
<a
href="/upgrade"
className="block w-full bg-[#D4AF37] text-[#0a0a0f] py-2 rounded font-semibold hover:bg-[#C9A84C] transition text-center"
>
Upgrade Now
</a>
</div>
</div>
)
}
function LoginGate() {
return (
<div className="max-w-6xl mx-auto p-8 flex flex-col items-center justify-center min-h-[60vh] text-center space-y-4">
<Lock size={48} className="text-gray-600" />
<h1 className="text-2xl font-bold text-[#D4AF37]">Halal Monitor</h1>
<p className="text-gray-400 max-w-md">
Sign in to access the global halal map mosques, prayer spaces, and halal restaurants near you.
</p>
<a
href="/login"
className="inline-flex items-center gap-2 bg-[#D4AF37] text-[#0a0a0f] px-6 py-2 rounded font-semibold hover:bg-[#C9A84C] transition"
>
Sign In
</a>
</div>
)
}
export default function HalalMonitorPage() {
const { token, user, loading: authLoading } = useAuth()
const [places, setPlaces] = useState<Place[]>([])
const [center, setCenter] = useState<MapCenter | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [citySearch, setCitySearch] = useState('')
const [bookmarked, setBookmarked] = useState<Set<string>>(new Set())
const [leafletLoaded, setLeafletLoaded] = useState(false)
const mapRef = useRef<any>(null)
// Load Leaflet CSS
useEffect(() => {
if (typeof window === 'undefined') return
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css'
document.head.appendChild(link)
setLeafletLoaded(true)
return () => { document.head.removeChild(link) }
}, [])
// Load existing bookmarks
useEffect(() => {
if (!token) return
fetch('/api/halal/bookmarks', { headers: { 'Authorization': `Bearer ${token}` } })
.then(r => r.json())
.then(d => {
if (d.bookmarks) setBookmarked(new Set(d.bookmarks.map((b: any) => b.itemId)))
})
.catch(() => {})
}, [token])
const fetchPlaces = useCallback(async (lat: number, lon: number) => {
setLoading(true)
setError(null)
try {
const res = await fetch(`/api/halal/places?lat=${lat}&lng=${lon}&radius=5000`, {
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
})
const data = await res.json()
if (res.status === 429) {
setError('Daily search limit reached. Upgrade to Premium for unlimited searches.')
return
}
if (!res.ok) throw new Error(data.error || 'Failed to load places')
setPlaces([...data.mosques, ...(data.restaurants || [])])
} catch (e: any) {
setError(e.message || 'Failed to load places')
} finally {
setLoading(false)
}
}, [token])
const handleGeolocate = () => {
if (!navigator.geolocation) {
setError('Geolocation is not supported by your browser.')
return
}
setLoading(true)
navigator.geolocation.getCurrentPosition(
pos => {
const { latitude: lat, longitude: lon } = pos.coords
setCenter({ lat, lon })
fetchPlaces(lat, lon)
},
() => {
setLoading(false)
setError('Could not get your location. Try searching by city.')
}
)
}
const handleCitySearch = async (e: React.FormEvent) => {
e.preventDefault()
if (!citySearch.trim()) return
setLoading(true)
setError(null)
try {
const res = await fetch(
`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(citySearch)}&format=json&limit=1`,
{ headers: { 'User-Agent': 'FalahMobile/1.0' } }
)
const results = await res.json()
if (!results.length) {
setError('City not found. Try a different search term.')
setLoading(false)
return
}
const lat = parseFloat(results[0].lat)
const lon = parseFloat(results[0].lon)
setCenter({ lat, lon })
fetchPlaces(lat, lon)
} catch {
setError('City search failed. Please try again.')
setLoading(false)
}
}
const handleBookmark = async (place: Place) => {
if (!token) return
const isBookmarked = bookmarked.has(place.id)
if (isBookmarked) {
const res = await fetch(`/api/halal/bookmarks?itemId=${place.id}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` },
})
if (res.ok) setBookmarked(prev => { const next = new Set(prev); next.delete(place.id); return next })
} 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) setBookmarked(prev => new Set([...prev, place.id]))
}
}
if (authLoading) {
return <div className="p-8 text-center text-gray-500 animate-pulse">Loading...</div>
}
if (!token || !user) return <LoginGate />
if (!user.isPremium && !user.isPro) return <UpgradeCTA />
const mosques = places.filter(p => p.type === 'mosque')
const restaurants = places.filter(p => p.type === 'restaurant')
return (
<div className="flex flex-col h-[calc(100vh-3.5rem)]">
{/* Toolbar */}
<div className="bg-[#111118] border-b border-gray-800 px-4 py-2 flex items-center gap-3 shrink-0 flex-wrap">
<div className="flex items-center gap-2">
<Crown size={16} className="text-[#D4AF37]" />
<span className="text-sm font-semibold text-[#D4AF37]">Halal Monitor</span>
</div>
<form onSubmit={handleCitySearch} className="flex gap-2 flex-1 max-w-sm">
<div className="relative flex-1">
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-500" />
<input
type="text"
placeholder="Search city..."
value={citySearch}
onChange={e => setCitySearch(e.target.value)}
className="w-full bg-[#0a0a0f] border border-gray-700 rounded pl-8 pr-3 py-1.5 text-xs focus:border-[#D4AF37] outline-none"
/>
</div>
<button type="submit" className="bg-[#D4AF37] text-[#0a0a0f] px-3 py-1.5 rounded text-xs font-semibold hover:bg-[#C9A84C] transition">
Go
</button>
</form>
<button
onClick={handleGeolocate}
className="flex items-center gap-1 text-xs text-gray-400 hover:text-[#D4AF37] transition"
>
<Navigation size={14} /> Near me
</button>
{loading && <Loader2 size={14} className="animate-spin text-[#D4AF37]" />}
{places.length > 0 && (
<span className="text-xs text-gray-500 ml-auto">
{mosques.length} mosques · {restaurants.length} restaurants
</span>
)}
</div>
{/* Error bar */}
{error && (
<div className="bg-red-900/20 border-b border-red-800 px-4 py-2 flex items-center justify-between shrink-0">
<span className="text-xs text-red-400">{error}</span>
<button onClick={() => setError(null)}><X size={14} className="text-red-400" /></button>
</div>
)}
{/* Map */}
<div className="flex-1 relative">
{!center ? (
<div className="absolute inset-0 flex flex-col items-center justify-center text-center space-y-4">
<MapPin size={40} className="text-gray-700" />
<p className="text-gray-500 text-sm">Search a city or use your location to find halal places.</p>
<button
onClick={handleGeolocate}
className="flex items-center gap-2 bg-[#D4AF37] text-[#0a0a0f] px-4 py-2 rounded text-sm font-semibold hover:bg-[#C9A84C] transition"
>
<Navigation size={16} /> Use My Location
</button>
</div>
) : leafletLoaded ? (
<MapWithMarkers
center={center}
places={places}
bookmarked={bookmarked}
onBookmark={handleBookmark}
/>
) : null}
</div>
</div>
)
}
function MapWithMarkers({
center,
places,
bookmarked,
onBookmark,
}: {
center: MapCenter
places: Place[]
bookmarked: Set<string>
onBookmark: (p: Place) => void
}) {
const [L, setL] = useState<any>(null)
useEffect(() => {
import('leaflet').then(leaflet => {
// Fix default marker icons for Next.js
delete (leaflet.Icon.Default.prototype as any)._getIconUrl
leaflet.Icon.Default.mergeOptions({
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
})
setL(leaflet)
})
}, [])
if (!L) return null
const mosqueIcon = new L.Icon({
iconUrl: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-green.png',
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41],
})
const restaurantIcon = new L.Icon({
iconUrl: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-orange.png',
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41],
})
return (
<MapContainer
center={[center.lat, center.lon]}
zoom={14}
style={{ height: '100%', width: '100%' }}
key={`${center.lat}-${center.lon}`}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{places.map(place => (
<Marker
key={place.id}
position={[place.lat, place.lon]}
icon={place.type === 'mosque' ? mosqueIcon : restaurantIcon}
>
<Popup>
<div className="min-w-[160px]">
<p className="font-semibold text-sm">{place.name}</p>
{place.address && <p className="text-xs text-gray-500 mt-0.5">{place.address}</p>}
<p className="text-xs text-gray-400 mt-1 capitalize">{place.type}</p>
<button
onClick={() => onBookmark(place)}
className="mt-2 flex items-center gap-1 text-xs text-blue-600 hover:text-blue-800"
>
<Bookmark size={12} />
{bookmarked.has(place.id) ? 'Bookmarked' : 'Bookmark'}
</button>
</div>
</Popup>
</Marker>
))}
</MapContainer>
)
}
+29
View File
@@ -0,0 +1,29 @@
import type { Metadata } from 'next'
import { AuthProvider } from '@/lib/AuthContext'
import BottomNav from '@/components/BottomNav'
import './globals.css'
export const metadata: Metadata = {
title: 'Falah — Islamic Lifestyle',
description: 'Nur AI coaching, Halal marketplace, community & wallet',
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<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>
)
}
+107
View File
@@ -0,0 +1,107 @@
'use client'
import { useState } from 'react'
import { useAuth } from '@/lib/AuthContext'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { Eye, EyeOff } from 'lucide-react'
export default function LoginPage() {
const { login } = useAuth()
const router = useRouter()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [showPass, setShowPass] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError('')
try {
await login(email, password)
router.push('/nur')
} catch (err: any) {
setError(err.message || 'Invalid email or password')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-dvh flex flex-col bg-[#0a0a0f] px-6">
{/* Brand */}
<div className="flex flex-col items-center pt-16 pb-10">
<div className="w-20 h-20 rounded-3xl bg-[#D4AF37]/10 border border-[#D4AF37]/25 flex items-center justify-center mb-5">
<span className="text-3xl font-bold text-[#D4AF37]">ف</span>
</div>
<h1 className="text-2xl font-bold text-white tracking-tight">Welcome back</h1>
<p className="text-gray-500 text-sm mt-1">Sign in to your Falah account</p>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-4 flex-1">
<div className="space-y-1.5">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-widest">Email</label>
<input
type="email"
placeholder="you@example.com"
value={email}
onChange={e => setEmail(e.target.value)}
required
autoComplete="email"
inputMode="email"
className="w-full bg-[#111118] border border-gray-800 rounded-2xl px-4 py-4 text-white placeholder-gray-700 focus:border-[#D4AF37]/60 focus:outline-none transition"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-widest">Password</label>
<div className="relative">
<input
type={showPass ? 'text' : 'password'}
placeholder="••••••••"
value={password}
onChange={e => setPassword(e.target.value)}
required
autoComplete="current-password"
className="w-full bg-[#111118] border border-gray-800 rounded-2xl px-4 py-4 pr-14 text-white placeholder-gray-700 focus:border-[#D4AF37]/60 focus:outline-none transition"
/>
<button
type="button"
onClick={() => setShowPass(v => !v)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-600 p-1"
>
{showPass ? <EyeOff size={20} /> : <Eye size={20} />}
</button>
</div>
</div>
{error && (
<div className="bg-red-500/10 border border-red-500/20 rounded-2xl px-4 py-3">
<p className="text-red-400 text-sm">{error}</p>
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full bg-[#D4AF37] text-[#0a0a0f] py-4 rounded-2xl font-bold text-base disabled:opacity-50 active:scale-[0.98] transition-all mt-2"
>
{loading ? 'Signing in...' : 'Sign In'}
</button>
</form>
<div className="py-8 space-y-4 text-center">
<p className="text-sm text-gray-500">
No account?{' '}
<Link href="/register" className="text-[#D4AF37] font-semibold">
Create one free
</Link>
</p>
<p className="text-xs text-gray-800">بِسْمِ اللَّهِ الرَّحْمَٰنِ الرَّحِيمِ</p>
</div>
</div>
)
}
+439
View File
@@ -0,0 +1,439 @@
'use client'
import { useState, useEffect, useRef } from 'react'
import { Send, Bot, Settings, User, Sparkles, BookOpen, Target, X, Crown, Scroll, Heart, Lock, ChevronDown, ChevronUp, BookMarked, ArrowLeft } from 'lucide-react'
import { useAuth } from '@/lib/AuthContext'
import { SCHOLAR_PERSONAS, PERSONA_COLORS } from '@/lib/personas'
import type { CoachPersona } from '@/lib/personas'
import { useRouter } from 'next/navigation'
const PERSONA_ICONS: Record<CoachPersona, typeof Bot> = {
nurbuddy: Bot,
ghazali: Scroll,
ibnabbas: Crown,
rabia: Heart,
}
const PREMIUM_PERSONAS: CoachPersona[] = ['ghazali', 'ibnabbas', 'rabia']
const FREE_MSG_LIMIT = 10
interface DailyVerse {
surah: string
ayah: string
arabic: string
translation: string
reflection: string
source: string
}
export default function NurPage() {
const { user, token } = useAuth()
const router = useRouter()
const [messages, setMessages] = useState<{ role: string; content: string; time?: string; metadata?: any }[]>([])
const [input, setInput] = useState('')
const [loading, setLoading] = useState(false)
const [showSettings, setShowSettings] = useState(false)
const [actionItems, setActionItems] = useState<string[]>([])
const [dailyMsgCount, setDailyMsgCount] = useState<number | null>(null)
const [limitReached, setLimitReached] = useState(false)
const [showVerse, setShowVerse] = useState(false)
const [dailyVerse, setDailyVerse] = useState<DailyVerse | null>(null)
const [profile, setProfile] = useState({
preferredName: user?.preferredName || '',
experienceLevel: user?.experienceLevel || 'new',
madhab: user?.madhab || 'unspecified',
coachPersona: (user?.coachPersona as CoachPersona) || 'nurbuddy',
})
const [savingProfile, setSavingProfile] = useState(false)
const chatEnd = useRef<HTMLDivElement>(null)
const [dailyLoading, setDailyLoading] = useState(false)
const dailyChecked = useRef(false)
const currentPersona = SCHOLAR_PERSONAS[profile.coachPersona] || SCHOLAR_PERSONAS.nurbuddy
const PersonaIcon = PERSONA_ICONS[profile.coachPersona] || Bot
const isPremiumUser = user?.isPremium || user?.isPro
useEffect(() => {
if (!user) return
setProfile(p => ({
...p,
preferredName: user.preferredName || '',
experienceLevel: user.experienceLevel || 'new',
madhab: user.madhab || 'unspecified',
coachPersona: (user.coachPersona as CoachPersona) || 'nurbuddy',
}))
const loadHistory = async () => {
try {
const res = await fetch(`/api/chat/history/${user.id}`, {
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
})
const data = await res.json()
if (data.history?.length > 0) {
setMessages(data.history.map((h: any) => ({
role: h.role,
content: h.content,
time: new Date(h.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
metadata: h.metadata ? JSON.parse(h.metadata) : undefined,
})))
const items: string[] = []
for (const h of data.history) {
if (h.metadata) {
try { const meta = JSON.parse(h.metadata); if (meta.actionItems) items.push(...meta.actionItems) } catch { }
}
}
setActionItems(items.slice(-8))
}
if (!dailyChecked.current) {
dailyChecked.current = true
checkDailyGreeting()
}
} catch { }
}
loadHistory()
}, [user, token])
useEffect(() => {
fetch('/api/daily/verse').then(r => r.json()).then(d => setDailyVerse(d)).catch(() => { })
}, [])
const checkDailyGreeting = async () => {
if (!user || !token) return
setDailyLoading(true)
try {
const res = await fetch('/api/chat/daily', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify({ userId: user.id }),
})
const data = await res.json()
if (data.needsGreeting && data.message) {
setMessages(prev => [...prev, {
role: 'assistant',
content: data.message,
time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
}])
}
} catch { }
setDailyLoading(false)
}
useEffect(() => { chatEnd.current?.scrollIntoView({ behavior: 'smooth' }) }, [messages])
const handleSend = async () => {
if (!input.trim() || loading || !user) return
const msg = input; setInput('')
setMessages(prev => [...prev, { role: 'user', content: msg, time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }])
setLoading(true)
try {
const res = await fetch('/api/chat/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(token ? { 'Authorization': `Bearer ${token}` } : {}) },
body: JSON.stringify({ userId: user.id, message: msg }),
})
const data = await res.json()
if (res.status === 429 && data.error === 'daily_limit_reached') {
setLimitReached(true); setDailyMsgCount(data.used)
setMessages(prev => prev.slice(0, -1)); setInput(msg)
} else if (res.ok) {
setMessages(prev => [...prev, { role: 'assistant', content: data.response, time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), metadata: data.metadata }])
if (data.metadata?.actionItems) setActionItems(prev => [...prev, ...data.metadata.actionItems].slice(-8))
if (data.userContext?.dailyMsgCount !== null && data.userContext?.dailyMsgCount !== undefined) setDailyMsgCount(data.userContext.dailyMsgCount)
} else {
setMessages(prev => [...prev, { role: 'assistant', content: data.error || 'Something went wrong. Please try again.', time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }])
}
} catch {
setMessages(prev => [...prev, { role: 'assistant', content: 'I\'m having trouble connecting right now.', time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }])
}
setLoading(false)
}
const handleSaveProfile = async () => {
if (!token) return
setSavingProfile(true)
try {
const res = await fetch('/api/auth/profile', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify(profile),
})
if (res.status === 403) {
const data = await res.json()
if (data.error === 'premium_required') { router.push('/upgrade?feature=scholar_personas'); return }
}
if (res.ok) {
setShowSettings(false)
const meRes = await fetch('/api/auth/me', { headers: { 'Authorization': `Bearer ${token}` } })
const meData = await meRes.json()
if (meData.user) localStorage.setItem('flh_user', JSON.stringify(meData.user))
}
} catch { }
setSavingProfile(false)
}
if (!user) {
return (
<div className="min-h-dvh flex flex-col items-center justify-center bg-[#0a0a0f] px-6 pb-20">
<div className="w-20 h-20 rounded-3xl bg-[#D4AF37]/10 border border-[#D4AF37]/20 flex items-center justify-center mb-6">
<Bot size={36} className="text-[#D4AF37]" />
</div>
<h2 className="text-xl font-bold text-white mb-2">Meet Nur</h2>
<p className="text-gray-500 text-sm text-center mb-8">Your personal Islamic guide. Sign in to start your journey.</p>
<button onClick={() => router.push('/login')}
className="w-full max-w-xs bg-[#D4AF37] text-[#0a0a0f] py-4 rounded-2xl font-bold text-base active:scale-[0.98] transition-all">
Sign In
</button>
</div>
)
}
return (
<div className="flex flex-col bg-[#0a0a0f]" style={{ height: 'calc(100dvh - 64px)' }}>
{/* Header */}
<div className="shrink-0 flex items-center gap-3 px-4 py-3 border-b border-gray-800/60 bg-[#0a0a0f]">
<div className={`w-10 h-10 rounded-2xl flex items-center justify-center bg-[#D4AF37]/10 border border-[#D4AF37]/20`}>
<PersonaIcon size={20} className={PERSONA_COLORS[profile.coachPersona]} />
</div>
<div className="flex-1 min-w-0">
<h1 className="font-bold text-white text-sm leading-tight truncate">{currentPersona.name}</h1>
<p className="text-gray-500 text-xs truncate">{currentPersona.title}</p>
</div>
<button
onClick={() => setShowSettings(true)}
className="w-9 h-9 flex items-center justify-center rounded-xl bg-gray-900 text-gray-400 active:bg-gray-800 transition"
>
<Settings size={17} />
</button>
</div>
{/* Daily Verse Banner */}
{dailyVerse && (
<div className="shrink-0 border-b border-gray-800/60">
<button
onClick={() => setShowVerse(v => !v)}
className="w-full flex items-center gap-2 px-4 py-2.5 text-left"
>
<BookMarked size={13} className="text-[#D4AF37] shrink-0" />
<span className="text-xs font-semibold text-[#D4AF37]">Today&apos;s Verse</span>
<span className="text-xs text-gray-600 truncate flex-1">{dailyVerse.surah} {dailyVerse.ayah}</span>
{showVerse ? <ChevronUp size={13} className="text-gray-600 shrink-0" /> : <ChevronDown size={13} className="text-gray-600 shrink-0" />}
</button>
{showVerse && (
<div className="px-4 pb-4 space-y-2 bg-[#0d0d14]">
<p className="text-right text-lg leading-loose text-gray-200">{dailyVerse.arabic}</p>
<p className="text-xs text-gray-400 italic">&ldquo;{dailyVerse.translation}&rdquo;</p>
<p className="text-xs text-gray-500 leading-relaxed">{dailyVerse.reflection}</p>
</div>
)}
</div>
)}
{/* Messages */}
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-3 chat-scroll">
{messages.length === 0 && !dailyLoading ? (
<div className="flex flex-col items-center justify-center h-full pb-8 space-y-3">
<div className="w-16 h-16 rounded-3xl bg-[#D4AF37]/10 border border-[#D4AF37]/15 flex items-center justify-center">
<PersonaIcon size={28} className={PERSONA_COLORS[profile.coachPersona]} />
</div>
<p className="text-gray-500 text-sm text-center max-w-xs px-4">{currentPersona.greeting}</p>
</div>
) : (
messages.map((m, i) => (
<div key={i} className={`flex ${m.role === 'user' ? 'justify-end' : 'justify-start'}`}>
{m.role === 'assistant' && (
<div className="w-7 h-7 rounded-xl bg-[#D4AF37]/10 flex items-center justify-center mr-2 shrink-0 mt-0.5">
<PersonaIcon size={13} className={PERSONA_COLORS[profile.coachPersona]} />
</div>
)}
<div className={`max-w-[78%] rounded-2xl px-4 py-3 ${
m.role === 'user'
? 'bg-[#D4AF37] text-[#0a0a0f] rounded-br-sm'
: 'bg-[#1a1a26] text-gray-100 rounded-bl-sm'
}`}>
<p className="text-sm leading-relaxed whitespace-pre-wrap">{m.content}</p>
{m.time && <p className={`text-[10px] mt-1 ${m.role === 'user' ? 'text-[#0a0a0f]/50' : 'text-gray-600'}`}>{m.time}</p>}
</div>
</div>
))
)}
{(loading || dailyLoading) && (
<div className="flex justify-start">
<div className="w-7 h-7 rounded-xl bg-[#D4AF37]/10 flex items-center justify-center mr-2 shrink-0 mt-0.5">
<PersonaIcon size={13} className={PERSONA_COLORS[profile.coachPersona]} />
</div>
<div className="bg-[#1a1a26] rounded-2xl rounded-bl-sm px-4 py-3">
<div className="flex gap-1">
<div className="w-1.5 h-1.5 bg-gray-500 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
<div className="w-1.5 h-1.5 bg-gray-500 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
<div className="w-1.5 h-1.5 bg-gray-500 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
</div>
</div>
</div>
)}
<div ref={chatEnd} />
</div>
{/* Limit reached banner */}
{limitReached && (
<div className="shrink-0 mx-4 mb-2 bg-[#1a1a26] border border-[#D4AF37]/25 rounded-2xl px-4 py-3 flex items-center justify-between gap-3">
<p className="text-sm text-gray-300 text-xs">Used all {FREE_MSG_LIMIT} free messages today.</p>
<a href="/upgrade" className="shrink-0 bg-[#D4AF37] text-[#0a0a0f] px-3 py-1.5 rounded-xl text-xs font-bold">
Upgrade
</a>
</div>
)}
{/* Input */}
<div className="shrink-0 border-t border-gray-800/60 px-4 pt-3 pb-3 bg-[#0a0a0f]">
{!isPremiumUser && dailyMsgCount !== null && !limitReached && (
<p className="text-[11px] text-gray-700 text-right mb-1.5">
{dailyMsgCount} / {FREE_MSG_LIMIT} today
</p>
)}
<div className="flex items-end gap-2">
<input
type="text"
placeholder={limitReached ? 'Upgrade to continue...' : `Ask ${currentPersona.name}...`}
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() } }}
disabled={limitReached}
className="flex-1 bg-[#1a1a26] border border-gray-800 rounded-2xl px-4 py-3 text-sm text-white placeholder-gray-600 focus:border-[#D4AF37]/50 focus:outline-none disabled:opacity-40 transition"
/>
<button
onClick={handleSend}
disabled={loading || !input.trim() || limitReached}
className="w-11 h-11 flex items-center justify-center bg-[#D4AF37] rounded-2xl disabled:opacity-30 active:scale-95 transition-all shrink-0"
>
<Send size={18} className="text-[#0a0a0f]" />
</button>
</div>
</div>
{/* Settings Drawer (slide-up overlay) */}
{showSettings && (
<div className="fixed inset-0 z-50 flex flex-col justify-end">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setShowSettings(false)} />
<div className="relative bg-[#111118] rounded-t-3xl border-t border-gray-800/60 max-h-[88dvh] overflow-y-auto">
{/* Handle */}
<div className="flex justify-center pt-3 pb-1">
<div className="w-10 h-1 bg-gray-700 rounded-full" />
</div>
<div className="px-5 pb-8 space-y-6 pt-3">
<div className="flex items-center justify-between">
<h3 className="font-bold text-white text-base">Your Spiritual Guide</h3>
<button onClick={() => setShowSettings(false)} className="w-8 h-8 flex items-center justify-center rounded-xl bg-gray-800 text-gray-400">
<X size={16} />
</button>
</div>
{/* Scholar Selector */}
<div className="space-y-3">
{!isPremiumUser && (
<div className="flex items-center gap-2 bg-[#D4AF37]/8 border border-[#D4AF37]/20 rounded-xl px-3 py-2.5">
<Crown size={14} className="text-[#D4AF37] shrink-0" />
<p className="text-xs text-[#D4AF37]">Upgrade to unlock scholar personas</p>
</div>
)}
<div className="grid grid-cols-2 gap-3">
{(Object.keys(SCHOLAR_PERSONAS) as CoachPersona[]).map((personaId) => {
const persona = SCHOLAR_PERSONAS[personaId]
const Icon = PERSONA_ICONS[personaId]
const isActive = profile.coachPersona === personaId
const isLocked = PREMIUM_PERSONAS.includes(personaId) && !isPremiumUser
return (
<button
key={personaId}
onClick={() => {
if (isLocked) { router.push('/upgrade?feature=scholar_personas'); return }
setProfile(p => ({ ...p, coachPersona: personaId }))
}}
className={`relative p-4 rounded-2xl border text-left transition active:scale-[0.97] ${
isLocked ? 'border-gray-800 bg-[#0d0d14] opacity-50' :
isActive ? 'border-[#D4AF37]/50 bg-[#D4AF37]/5' :
'border-gray-800 bg-[#0d0d14] active:bg-gray-800'
}`}
>
{isLocked && <Lock size={11} className="absolute top-3 right-3 text-gray-600" />}
<Icon size={22} className={`mb-2 ${isActive && !isLocked ? PERSONA_COLORS[personaId] : 'text-gray-600'}`} />
<p className={`text-sm font-semibold leading-tight ${isActive && !isLocked ? 'text-white' : 'text-gray-400'}`}>{persona.name}</p>
<p className="text-[11px] text-gray-600 mt-0.5 leading-tight">{persona.title}</p>
</button>
)
})}
</div>
</div>
{/* Profile Fields */}
<div className="space-y-4">
<div className="space-y-1.5">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-widest">Preferred Name</label>
<input
type="text"
value={profile.preferredName}
onChange={e => setProfile(p => ({ ...p, preferredName: e.target.value }))}
placeholder="How your guide addresses you"
className="w-full bg-[#0d0d14] border border-gray-800 rounded-xl px-4 py-3 text-sm text-white placeholder-gray-700 focus:border-[#D4AF37]/50 focus:outline-none"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-widest">Experience Level</label>
<select
value={profile.experienceLevel}
onChange={e => setProfile(p => ({ ...p, experienceLevel: e.target.value }))}
className="w-full bg-[#0d0d14] border border-gray-800 rounded-xl px-4 py-3 text-sm text-white focus:border-[#D4AF37]/50 focus:outline-none"
>
<option value="new">New to Islam</option>
<option value="growing">Growing in Faith</option>
<option value="seasoned">Seasoned Muslim</option>
</select>
</div>
<div className="space-y-1.5">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-widest">Madhab</label>
<select
value={profile.madhab}
onChange={e => setProfile(p => ({ ...p, madhab: e.target.value }))}
className="w-full bg-[#0d0d14] border border-gray-800 rounded-xl px-4 py-3 text-sm text-white focus:border-[#D4AF37]/50 focus:outline-none"
>
<option value="unspecified">Madhab Neutral</option>
<option value="hanafi">Hanafi</option>
<option value="shafii">Shafi&apos;i</option>
<option value="maliki">Maliki</option>
<option value="hanbali">Hanbali</option>
</select>
</div>
</div>
{/* Action Items */}
{actionItems.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Target size={13} className="text-[#D4AF37]" />
<span className="text-xs font-semibold text-gray-400 uppercase tracking-widest">Your Commitments</span>
</div>
<div className="flex flex-wrap gap-2">
{actionItems.map((item, i) => (
<span key={i} className="text-xs px-3 py-1.5 rounded-xl bg-[#D4AF37]/5 text-gray-400 border border-[#D4AF37]/10">
{item.length > 50 ? item.slice(0, 50) + '…' : item}
</span>
))}
</div>
</div>
)}
<button
onClick={handleSaveProfile}
disabled={savingProfile}
className="w-full bg-[#D4AF37] text-[#0a0a0f] py-4 rounded-2xl font-bold text-sm disabled:opacity-50 active:scale-[0.98] transition-all"
>
{savingProfile ? 'Saving...' : 'Save & Close'}
</button>
</div>
</div>
</div>
)}
</div>
)
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation'
export default function Home() {
redirect('/prayer')
}
+319
View File
@@ -0,0 +1,319 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { MapPin, RefreshCw } from 'lucide-react'
type Timings = {
Fajr: string
Sunrise: string
Dhuhr: string
Asr: string
Maghrib: string
Isha: string
}
type HijriDate = {
date: string
month: { en: string }
year: string
}
const PRAYERS = ['Fajr', 'Dhuhr', 'Asr', 'Maghrib', 'Isha'] as const
type PrayerName = typeof PRAYERS[number]
const PRAYER_DATA: Record<PrayerName, { emoji: string; arabic: string }> = {
Fajr: { emoji: '🌙', arabic: 'الفجر' },
Dhuhr: { emoji: '🕛', arabic: 'الظهر' },
Asr: { emoji: '🌤', arabic: 'العصر' },
Maghrib: { emoji: '🌅', arabic: 'المغرب' },
Isha: { emoji: '🌃', arabic: 'العشاء' },
}
function toMins(timeStr: string) {
const [h, m] = timeStr.replace(/\s*(AM|PM)$/i, '').split(':').map(Number)
return h * 60 + m
}
function fmt12(timeStr: string) {
const [h, m] = timeStr.replace(/\s*(AM|PM)$/i, '').split(':').map(Number)
const ampm = h < 12 ? 'AM' : 'PM'
const hour = h % 12 || 12
return `${hour}:${String(m).padStart(2, '0')} ${ampm}`
}
function fmtCountdown(mins: number) {
const h = Math.floor(mins / 60)
const m = mins % 60
if (h === 0) return { value: String(m), unit: m === 1 ? 'minute' : 'minutes' }
if (m === 0) return { value: String(h), unit: h === 1 ? 'hour' : 'hours' }
return { value: `${h}h ${m}m`, unit: 'remaining' }
}
function getNextPrayer(timings: Timings, nowMins: number): { name: PrayerName; minsLeft: number; prevMins: number } {
for (let i = 0; i < PRAYERS.length; i++) {
const pm = toMins(timings[PRAYERS[i]])
if (pm > nowMins) {
const prevMins = i === 0 ? 0 : toMins(timings[PRAYERS[i - 1]])
return { name: PRAYERS[i], minsLeft: pm - nowMins, prevMins }
}
}
const fajrMins = toMins(timings.Fajr)
const lastPrayerMins = toMins(timings.Isha)
return { name: 'Fajr', minsLeft: (24 * 60 - nowMins) + fajrMins, prevMins: lastPrayerMins }
}
export default function PrayerPage() {
const [timings, setTimings] = useState<Timings | null>(null)
const [hijri, setHijri] = useState<HijriDate | null>(null)
const [city, setCity] = useState<string>('')
const [error, setError] = useState<string>('')
const [loading, setLoading] = useState(false)
const [locationDenied, setLocationDenied] = useState(false)
const [now, setNow] = useState(new Date())
useEffect(() => {
const id = setInterval(() => setNow(new Date()), 30000)
return () => clearInterval(id)
}, [])
const fetchTimes = useCallback(async (lat: number, lng: number) => {
setError('')
try {
const res = await fetch(`/api/prayer/times?lat=${lat}&lng=${lng}`)
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Failed')
setTimings(data.timings)
setHijri(data.hijri)
} catch {
setError('Could not load prayer times. Tap refresh to try again.')
} finally {
setLoading(false)
}
}, [])
const requestLocation = useCallback(() => {
if (!navigator.geolocation) {
setError('Geolocation not supported')
return
}
setLoading(true)
setLocationDenied(false)
navigator.geolocation.getCurrentPosition(
async (pos) => {
const { latitude: lat, longitude: lng } = pos.coords
try {
const r = await fetch(
`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json`,
{ headers: { 'Accept-Language': 'en' } }
)
const d = await r.json()
setCity(d.address?.city || d.address?.town || d.address?.village || '')
} catch { }
fetchTimes(lat, lng)
},
(err) => {
setLoading(false)
if (err.code === 1) setLocationDenied(true)
else setError('Could not get your location')
},
{ timeout: 10000 }
)
}, [fetchTimes])
useEffect(() => { requestLocation() }, [requestLocation])
const nowMins = now.getHours() * 60 + now.getMinutes()
const next = timings ? getNextPrayer(timings, nowMins) : null
const countdown = next ? fmtCountdown(next.minsLeft) : null
// Arc progress: how far through the interval to next prayer
const arcPercent = next && timings
? (() => {
const windowMins = next.minsLeft + (nowMins - next.prevMins)
return Math.min(1, (nowMins - next.prevMins) / Math.max(1, windowMins))
})()
: 0
// SVG arc
const R = 56
const C = 2 * Math.PI * R
const strokeOffset = C * (1 - arcPercent)
const currentTime = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
return (
<div className="min-h-dvh bg-[#0a0a0f] flex flex-col pb-24 select-none">
{/* Top bar */}
<div className="flex items-center justify-between px-5 pt-6 pb-2">
<div>
<div className="flex items-center gap-1.5">
{city ? (
<>
<MapPin size={12} className="text-gray-600" />
<span className="text-xs text-gray-500">{city}</span>
</>
) : (
<span className="text-xs text-gray-700">Detecting location</span>
)}
</div>
{hijri && (
<p className="text-[11px] text-[#D4AF37]/60 mt-0.5">
{hijri.date} {hijri.month.en} {hijri.year} AH
</p>
)}
</div>
<button
onClick={requestLocation}
disabled={loading}
className="w-9 h-9 rounded-xl bg-gray-800/60 flex items-center justify-center active:scale-90 transition-transform"
>
<RefreshCw size={15} className={`text-gray-400 ${loading ? 'animate-spin' : ''}`} />
</button>
</div>
{/* Location denied */}
{locationDenied && (
<div className="mx-5 mt-4 rounded-3xl bg-gray-900 border border-gray-800 p-8 text-center">
<div className="text-4xl mb-4">🕌</div>
<p className="text-white font-bold text-lg mb-1">Enable Location</p>
<p className="text-gray-500 text-sm mb-6">Prayer times require your location to be accurate</p>
<button
onClick={requestLocation}
className="w-full bg-[#D4AF37] text-[#0a0a0f] font-bold py-4 rounded-2xl active:scale-[0.98] transition-transform"
>
Allow Location Access
</button>
</div>
)}
{/* Error */}
{error && !locationDenied && (
<div className="mx-5 mt-4 bg-red-500/10 border border-red-500/20 rounded-2xl px-4 py-3 text-center">
<p className="text-red-400 text-sm">{error}</p>
</div>
)}
{/* Loading state */}
{loading && !timings && (
<div className="flex-1 flex flex-col items-center justify-center gap-4 pt-8">
<div className="w-10 h-10 rounded-full border-2 border-[#D4AF37]/20 border-t-[#D4AF37] animate-spin" />
<p className="text-xs text-gray-600">Fetching prayer times</p>
</div>
)}
{/* Content */}
{timings && next && countdown && (
<>
{/* Hero — next prayer */}
<div className="flex flex-col items-center pt-6 pb-8 px-5">
{/* Arc clock */}
<div className="relative flex items-center justify-center mb-6">
<svg width="160" height="160" className="-rotate-90">
{/* Background ring */}
<circle cx="80" cy="80" r={R} fill="none" stroke="#1c1c2a" strokeWidth="6" />
{/* Progress arc */}
<circle
cx="80" cy="80" r={R} fill="none"
stroke="#D4AF37"
strokeWidth="6"
strokeLinecap="round"
strokeDasharray={C}
strokeDashoffset={strokeOffset}
className="transition-all duration-1000"
/>
{/* Glow dots at end of arc */}
<circle
cx="80" cy="80" r={R} fill="none"
stroke="#D4AF37"
strokeWidth="2"
strokeLinecap="round"
strokeDasharray="1"
strokeDashoffset={strokeOffset - 2}
opacity="0.4"
/>
</svg>
{/* Center content */}
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-3xl mb-0.5">{PRAYER_DATA[next.name].emoji}</span>
<span className="text-[10px] text-gray-600 uppercase tracking-widest">Next Prayer</span>
<span className="text-base font-bold text-white mt-0.5">{next.name}</span>
</div>
</div>
{/* Countdown */}
<div className="text-center">
<p className="text-5xl font-bold text-[#D4AF37] tabular-nums leading-none tracking-tight">
{countdown.value}
</p>
<p className="text-sm text-gray-500 mt-1.5">{countdown.unit}</p>
<p className="text-xs text-gray-700 mt-1">
{next.name} at {fmt12(timings[next.name])}
</p>
</div>
</div>
{/* Divider */}
<div className="mx-5 h-px bg-gray-800/60 mb-5" />
{/* Prayer list */}
<div className="px-4 space-y-1.5">
{PRAYERS.map((prayer, i) => {
const pMins = toMins(timings[prayer])
const isNext = prayer === next.name
const isPast = pMins < nowMins && !isNext
return (
<div
key={prayer}
className={`flex items-center gap-3 px-4 py-3.5 rounded-2xl transition-all ${
isNext
? 'bg-[#D4AF37]/10 border border-[#D4AF37]/25'
: isPast
? 'opacity-35'
: 'bg-gray-900/50'
}`}
>
{/* Emoji */}
<span className="text-xl w-7 text-center">{PRAYER_DATA[prayer].emoji}</span>
{/* Names */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className={`font-semibold text-sm ${isNext ? 'text-[#D4AF37]' : isPast ? 'text-gray-600' : 'text-white'}`}>
{prayer}
</span>
{isNext && (
<span className="text-[10px] bg-[#D4AF37]/20 text-[#D4AF37] px-1.5 py-0.5 rounded-full font-medium">
Next
</span>
)}
</div>
<span className="text-[11px] text-gray-700">{PRAYER_DATA[prayer].arabic}</span>
</div>
{/* Time */}
<span className={`text-sm font-bold tabular-nums ${isNext ? 'text-[#D4AF37]' : isPast ? 'text-gray-700' : 'text-gray-300'}`}>
{fmt12(timings[prayer])}
</span>
</div>
)
})}
{/* Sunrise */}
<div className="flex items-center gap-3 px-4 py-2.5 opacity-25">
<span className="text-lg w-7 text-center">🌄</span>
<span className="flex-1 text-xs text-gray-600">Sunrise</span>
<span className="text-xs text-gray-600 tabular-nums">{fmt12(timings.Sunrise)}</span>
</div>
</div>
{/* Footer note */}
<p className="text-center text-[10px] text-gray-800 mt-8 mb-2">
Muslim World League (MWL) · AlAdhan
</p>
</>
)}
</div>
)
}
+41
View File
@@ -0,0 +1,41 @@
'use client'
export default function ProfileLoading() {
return (
<div className="max-w-3xl mx-auto p-4 sm:p-6 space-y-6 animate-pulse">
<div className="h-8 w-24 bg-gray-800 rounded" />
<div className="bg-[#111118] border border-gray-800 rounded-lg p-6">
<div className="flex items-center gap-4">
<div className="w-16 h-16 rounded-full bg-gray-800" />
<div className="space-y-2">
<div className="h-5 w-32 bg-gray-800 rounded" />
<div className="h-4 w-48 bg-gray-800 rounded" />
<div className="h-3 w-28 bg-gray-800 rounded" />
</div>
</div>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{[...Array(4)].map((_, i) => (
<div key={i} className="bg-[#111118] border border-gray-800 rounded-lg p-4 space-y-2">
<div className="h-5 w-5 bg-gray-800 rounded" />
<div className="h-6 w-16 bg-gray-800 rounded" />
<div className="h-3 w-20 bg-gray-800 rounded" />
</div>
))}
</div>
<div className="bg-[#111118] border border-gray-800 rounded-lg p-6 space-y-4">
<div className="h-5 w-28 bg-gray-800 rounded" />
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{[...Array(4)].map((_, i) => (
<div key={i} className="space-y-2">
<div className="h-3 w-24 bg-gray-800 rounded" />
<div className="h-9 w-full bg-gray-800 rounded" />
</div>
))}
</div>
<div className="h-9 w-32 bg-gray-800 rounded" />
</div>
<div className="h-12 w-full bg-gray-800 rounded-lg" />
</div>
)
}
+232
View File
@@ -0,0 +1,232 @@
'use client'
import { useState, useEffect } from 'react'
import { useAuth } from '@/lib/AuthContext'
import { useRouter } from 'next/navigation'
import { User, Crown, Wallet, Sparkles, ShoppingBag, Shield, Save, LogOut, ChevronRight, Star } from 'lucide-react'
import Link from 'next/link'
export default function ProfilePage() {
const { token, user, loading: authLoading } = useAuth()
const router = useRouter()
const [saving, setSaving] = useState(false)
const [profile, setProfile] = useState({
preferredName: '',
experienceLevel: 'new',
madhab: 'unspecified',
coachPersona: 'nurbuddy',
})
useEffect(() => {
if (!authLoading && !token) router.push('/login')
}, [token, authLoading, router])
useEffect(() => {
if (!user) return
const saved = localStorage.getItem('flh_user')
if (saved) {
try {
const u = JSON.parse(saved)
setProfile({
preferredName: u.preferredName || '',
experienceLevel: u.experienceLevel || 'new',
madhab: u.madhab || 'unspecified',
coachPersona: u.coachPersona || 'nurbuddy',
})
} catch { }
}
}, [user])
const handleSave = async () => {
if (!token) return
setSaving(true)
try {
const res = await fetch('/api/auth/profile', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify(profile),
})
if (res.ok) {
const meRes = await fetch('/api/auth/me', { headers: { 'Authorization': `Bearer ${token}` } })
const meData = await meRes.json()
if (meData.user) localStorage.setItem('flh_user', JSON.stringify(meData.user))
}
} catch { }
setSaving(false)
}
const tier = user?.isPro ? 'Pro' : user?.isPremium ? 'Premium' : 'Free'
const tierColor = user?.isPro ? 'text-purple-400' : user?.isPremium ? 'text-[#D4AF37]' : 'text-gray-500'
const tierBg = user?.isPro ? 'bg-purple-400/10 border-purple-400/20' : user?.isPremium ? 'bg-[#D4AF37]/10 border-[#D4AF37]/20' : 'bg-gray-800/50 border-gray-700/50'
if (authLoading) return <ProfileSkeleton />
if (!token) return null
return (
<div className="min-h-dvh bg-[#0a0a0f] pb-24">
{/* Header */}
<div className="px-5 pt-6 pb-5">
<h1 className="text-xl font-bold text-white">Profile</h1>
</div>
{/* Avatar + info card */}
<div className="mx-4 bg-[#111118] border border-gray-800/60 rounded-3xl p-5 mb-4">
<div className="flex items-center gap-4">
<div className="w-16 h-16 rounded-2xl bg-[#D4AF37]/10 border border-[#D4AF37]/20 flex items-center justify-center shrink-0">
<User size={28} className="text-[#D4AF37]" />
</div>
<div className="flex-1 min-w-0">
<h2 className="text-lg font-bold text-white truncate">{user?.name || 'User'}</h2>
<p className="text-sm text-gray-500 truncate">{user?.email || ''}</p>
<div className={`inline-flex items-center gap-1.5 mt-2 border rounded-full px-2.5 py-1 ${tierBg}`}>
<Star size={11} className={tierColor} />
<span className={`text-xs font-semibold ${tierColor}`}>{tier}</span>
</div>
</div>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-2 gap-3 mx-4 mb-4">
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-4">
<Wallet size={20} className="text-[#D4AF37] mb-2" />
<p className="text-2xl font-bold text-white">{user?.flhBalance?.toLocaleString() || 0}</p>
<p className="text-xs text-gray-600 mt-0.5">FLH Balance</p>
</div>
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-4">
<ShoppingBag size={20} className="text-blue-400 mb-2" />
<p className="text-2xl font-bold text-white">0</p>
<p className="text-xs text-gray-600 mt-0.5">Purchases</p>
</div>
</div>
{/* Upgrade CTA — only for free users */}
{!user?.isPremium && !user?.isPro && (
<div className="mx-4 mb-4">
<Link href="/upgrade" className="block bg-gradient-to-r from-[#D4AF37]/15 to-[#D4AF37]/5 border border-[#D4AF37]/25 rounded-3xl p-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-2xl bg-[#D4AF37]/15 flex items-center justify-center">
<Crown size={20} className="text-[#D4AF37]" />
</div>
<div>
<p className="font-bold text-[#D4AF37] text-sm">Go Premium</p>
<p className="text-xs text-gray-500">Unlock scholars & unlimited chat</p>
</div>
</div>
<ChevronRight size={18} className="text-[#D4AF37]" />
</div>
</Link>
</div>
)}
{/* Edit Profile Form */}
<div className="mx-4 bg-[#111118] border border-gray-800/60 rounded-3xl p-5 mb-4 space-y-4">
<div className="flex items-center gap-2 mb-1">
<Shield size={15} className="text-[#D4AF37]" />
<h2 className="font-bold text-white text-sm">Edit Profile</h2>
</div>
<div className="space-y-1.5">
<label className="text-xs font-semibold text-gray-600 uppercase tracking-widest">Preferred Name</label>
<input
type="text"
value={profile.preferredName}
onChange={e => setProfile(p => ({ ...p, preferredName: e.target.value }))}
placeholder="How Nur addresses you"
className="w-full bg-[#0d0d14] border border-gray-800 rounded-xl px-4 py-3 text-sm text-white placeholder-gray-700 focus:border-[#D4AF37]/50 focus:outline-none"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-semibold text-gray-600 uppercase tracking-widest">Experience Level</label>
<select
value={profile.experienceLevel}
onChange={e => setProfile(p => ({ ...p, experienceLevel: e.target.value }))}
className="w-full bg-[#0d0d14] border border-gray-800 rounded-xl px-4 py-3 text-sm text-white focus:border-[#D4AF37]/50 focus:outline-none"
>
<option value="new">New to Islam</option>
<option value="growing">Growing in Faith</option>
<option value="seasoned">Seasoned Muslim</option>
</select>
</div>
<div className="space-y-1.5">
<label className="text-xs font-semibold text-gray-600 uppercase tracking-widest">Madhab</label>
<select
value={profile.madhab}
onChange={e => setProfile(p => ({ ...p, madhab: e.target.value }))}
className="w-full bg-[#0d0d14] border border-gray-800 rounded-xl px-4 py-3 text-sm text-white focus:border-[#D4AF37]/50 focus:outline-none"
>
<option value="unspecified">Madhab Neutral</option>
<option value="hanafi">Hanafi</option>
<option value="shafii">Shafi&apos;i</option>
<option value="maliki">Maliki</option>
<option value="hanbali">Hanbali</option>
</select>
</div>
<button
onClick={handleSave}
disabled={saving}
className="w-full flex items-center justify-center gap-2 bg-[#D4AF37] text-[#0a0a0f] py-4 rounded-xl font-bold text-sm disabled:opacity-50 active:scale-[0.98] transition-all"
>
<Save size={16} /> {saving ? 'Saving...' : 'Save Changes'}
</button>
</div>
{/* Halal Monitor link */}
<div className="mx-4 mb-4">
<Link href="/halal-monitor" className="flex items-center gap-3 bg-[#111118] border border-gray-800/60 rounded-2xl px-5 py-4">
<div className="w-9 h-9 rounded-xl bg-emerald-500/10 flex items-center justify-center">
<Sparkles size={17} className="text-emerald-400" />
</div>
<div className="flex-1">
<p className="text-sm font-semibold text-white">Halal Monitor</p>
<p className="text-xs text-gray-600">Find mosques & halal food near you</p>
</div>
<ChevronRight size={16} className="text-gray-600" />
</Link>
</div>
{/* Sign Out */}
<div className="mx-4">
<button
onClick={() => {
if (typeof window !== 'undefined') {
localStorage.removeItem('flh_token')
localStorage.removeItem('flh_user')
}
router.push('/login')
}}
className="w-full flex items-center justify-center gap-2 border border-red-900/50 text-red-500 bg-red-500/5 rounded-2xl py-4 text-sm font-semibold active:bg-red-500/10 transition"
>
<LogOut size={16} /> Sign Out
</button>
</div>
</div>
)
}
function ProfileSkeleton() {
return (
<div className="min-h-dvh bg-[#0a0a0f] pb-24 animate-pulse">
<div className="px-5 pt-6 pb-5"><div className="h-7 w-20 bg-gray-800 rounded-xl" /></div>
<div className="mx-4 bg-[#111118] border border-gray-800/60 rounded-3xl p-5 mb-4">
<div className="flex items-center gap-4">
<div className="w-16 h-16 rounded-2xl bg-gray-800" />
<div className="space-y-2 flex-1">
<div className="h-5 w-32 bg-gray-800 rounded-lg" />
<div className="h-4 w-48 bg-gray-800 rounded-lg" />
<div className="h-6 w-20 bg-gray-800 rounded-full" />
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-3 mx-4 mb-4">
{[...Array(2)].map((_, i) => <div key={i} className="bg-[#111118] border border-gray-800/60 rounded-2xl p-4 h-24" />)}
</div>
<div className="mx-4 bg-[#111118] border border-gray-800/60 rounded-3xl p-5 h-64" />
</div>
)
}
+126
View File
@@ -0,0 +1,126 @@
'use client'
import { useState } from 'react'
import { useAuth } from '@/lib/AuthContext'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { Eye, EyeOff, Sparkles } from 'lucide-react'
export default function RegisterPage() {
const { register } = useAuth()
const router = useRouter()
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [showPass, setShowPass] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError('')
try {
await register(email, name, password)
router.push('/nur')
} catch (err: any) {
setError(err.message || 'Something went wrong')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-dvh flex flex-col bg-[#0a0a0f] px-6">
{/* Brand */}
<div className="flex flex-col items-center pt-12 pb-8">
<div className="w-20 h-20 rounded-3xl bg-[#D4AF37]/10 border border-[#D4AF37]/25 flex items-center justify-center mb-5">
<span className="text-3xl font-bold text-[#D4AF37]">ف</span>
</div>
<h1 className="text-2xl font-bold text-white tracking-tight">Join Falah</h1>
<p className="text-gray-500 text-sm mt-1">Your Islamic lifestyle companion</p>
<div className="flex items-center gap-1.5 mt-4 bg-[#D4AF37]/8 border border-[#D4AF37]/20 rounded-full px-3.5 py-2">
<Sparkles size={13} className="text-[#D4AF37]" />
<span className="text-xs text-[#D4AF37] font-semibold">7-day Premium trial free</span>
</div>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-4 flex-1">
<div className="space-y-1.5">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-widest">Your Name</label>
<input
type="text"
placeholder="Ahmad Abdullah"
value={name}
onChange={e => setName(e.target.value)}
required
autoComplete="name"
className="w-full bg-[#111118] border border-gray-800 rounded-2xl px-4 py-4 text-white placeholder-gray-700 focus:border-[#D4AF37]/60 focus:outline-none transition"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-widest">Email</label>
<input
type="email"
placeholder="you@example.com"
value={email}
onChange={e => setEmail(e.target.value)}
required
autoComplete="email"
inputMode="email"
className="w-full bg-[#111118] border border-gray-800 rounded-2xl px-4 py-4 text-white placeholder-gray-700 focus:border-[#D4AF37]/60 focus:outline-none transition"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-widest">Password</label>
<div className="relative">
<input
type={showPass ? 'text' : 'password'}
placeholder="Min. 8 characters"
value={password}
onChange={e => setPassword(e.target.value)}
required
minLength={8}
autoComplete="new-password"
className="w-full bg-[#111118] border border-gray-800 rounded-2xl px-4 py-4 pr-14 text-white placeholder-gray-700 focus:border-[#D4AF37]/60 focus:outline-none transition"
/>
<button
type="button"
onClick={() => setShowPass(v => !v)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-600 p-1"
>
{showPass ? <EyeOff size={20} /> : <Eye size={20} />}
</button>
</div>
</div>
{error && (
<div className="bg-red-500/10 border border-red-500/20 rounded-2xl px-4 py-3">
<p className="text-red-400 text-sm">{error}</p>
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full bg-[#D4AF37] text-[#0a0a0f] py-4 rounded-2xl font-bold text-base disabled:opacity-50 active:scale-[0.98] transition-all mt-1"
>
{loading ? 'Creating account...' : 'Create Free Account'}
</button>
</form>
<div className="py-8 space-y-3 text-center">
<p className="text-sm text-gray-500">
Already have an account?{' '}
<Link href="/login" className="text-[#D4AF37] font-semibold">
Sign in
</Link>
</p>
<p className="text-xs text-gray-800">بِسْمِ اللَّهِ الرَّحْمَٰنِ الرَّحِيمِ</p>
</div>
</div>
)
}
+159
View File
@@ -0,0 +1,159 @@
'use client'
import { useState, useEffect } from 'react'
import { useAuth } from '@/lib/AuthContext'
import { Search, Plus, ShoppingCart, Zap } from 'lucide-react'
interface Listing { id: string; sellerId: string; title: string; category: string; price_flh: number; seller: string; description: string; status: string; featured: boolean; featuredUntil: string | null; fileType: string | null; createdAt: string }
const CATEGORIES = ['All', 'E-Books', 'Courses', 'Design', 'Audio', 'Video', 'Software', 'Other']
export default function SouqPage() {
const { token, user, loading: authLoading } = useAuth()
const [listings, setListings] = useState<Listing[]>([])
const [search, setSearch] = useState('')
const [category, setCategory] = useState('All')
const [loading, setLoading] = useState(true)
const [showCreate, setShowCreate] = useState(false)
const [selectedListing, setSelectedListing] = useState<Listing | null>(null)
const fetchListings = async () => {
try { const res = await fetch('/api/marketplace/listings'); const data = await res.json(); setListings(data.listings || []) }
catch (e) { console.error(e) } finally { setLoading(false) }
}
useEffect(() => { fetchListings() }, [])
const filtered = listings.filter(l => {
if (category !== 'All' && l.category !== category) return false
if (search && !l.title.toLowerCase().includes(search.toLowerCase())) return false
return true
})
const handlePurchase = async (listingId: string) => {
if (!token) return
const res = await fetch('/api/marketplace/purchase', { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ listingId }) })
const data = await res.json()
if (res.ok) { fetchListings() } else { alert(data.error) }
}
if (authLoading) return <div className="p-8 text-center text-gray-500">Loading...</div>
return (
<div className="max-w-6xl mx-auto p-4 sm:p-6 space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<h1 className="text-2xl font-bold text-[#D4AF37]">Souq</h1>
<div className="flex gap-2">
<div className="relative flex-1 sm:flex-none">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
<input type="text" placeholder="Search..." value={search} onChange={e => setSearch(e.target.value)}
className="w-full sm:w-48 bg-[#111118] border border-gray-700 rounded pl-9 pr-3 py-2 text-sm focus:border-[#D4AF37] outline-none" />
</div>
{token && (
<button onClick={() => setShowCreate(true)}
className="flex items-center gap-1 bg-[#D4AF37] text-[#0a0a0f] px-3 py-2 rounded text-sm font-semibold hover:bg-[#C9A84C] transition">
<Plus size={16} /> Create
</button>
)}
</div>
</div>
<div className="flex gap-2 overflow-x-auto pb-2">
{CATEGORIES.map(c => (
<button key={c} onClick={() => setCategory(c)}
className={`px-3 py-1 rounded-full text-xs whitespace-nowrap transition ${category === c ? 'bg-[#D4AF37] text-[#0a0a0f] font-semibold' : 'bg-gray-800 text-gray-400 hover:text-white'}`}>{c}</button>
))}
</div>
{loading ? (
<div className="text-center text-gray-500 py-12">Loading listings...</div>
) : filtered.length === 0 ? (
<div className="text-center text-gray-500 py-12">No listings found</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{filtered.map(l => (
<div key={l.id} className="bg-[#111118] border border-gray-800 rounded-lg p-4 space-y-2 hover:border-gray-700 transition cursor-pointer" onClick={() => setSelectedListing(l)}>
<div className="flex items-start justify-between">
<span className="text-xs text-gray-500 bg-gray-800 px-2 py-0.5 rounded">{l.category}</span>
{l.featured && <Zap size={14} className="text-[#D4AF37]" />}
</div>
<h3 className="font-semibold">{l.title}</h3>
<p className="text-sm text-gray-400 line-clamp-2">{l.description}</p>
<div className="flex items-center justify-between pt-2">
<span className="text-[#D4AF37] font-bold">{l.price_flh.toLocaleString()} FLH</span>
<span className="text-xs text-gray-500">{l.seller}</span>
</div>
{token && l.sellerId !== user?.id && (
<button onClick={e => { e.stopPropagation(); handlePurchase(l.id) }}
className="w-full flex items-center justify-center gap-1 bg-[#D4AF37]/10 text-[#D4AF37] border border-[#D4AF37]/30 rounded py-1.5 text-sm hover:bg-[#D4AF37]/20 transition">
<ShoppingCart size={14} /> Buy
</button>
)}
</div>
))}
</div>
)}
{selectedListing && (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center p-4 z-50" onClick={() => setSelectedListing(null)}>
<div className="bg-[#111118] border border-gray-800 rounded-lg p-6 max-w-lg w-full space-y-4" onClick={e => e.stopPropagation()}>
<div className="flex items-start justify-between">
<h2 className="text-xl font-bold">{selectedListing.title}</h2>
<button onClick={() => setSelectedListing(null)} className="text-gray-500 hover:text-white"></button>
</div>
<p className="text-sm text-gray-400">{selectedListing.description}</p>
<div className="flex gap-2 text-sm">
<span className="bg-gray-800 px-2 py-0.5 rounded">{selectedListing.category}</span>
<span className="text-gray-500">by {selectedListing.seller}</span>
</div>
<p className="text-2xl font-bold text-[#D4AF37]">{selectedListing.price_flh.toLocaleString()} FLH</p>
{token && selectedListing.sellerId !== user?.id && (
<button onClick={() => { handlePurchase(selectedListing.id); setSelectedListing(null) }}
className="w-full bg-[#D4AF37] text-[#0a0a0f] py-2 rounded font-semibold hover:bg-[#C9A84C] transition">
Purchase
</button>
)}
</div>
</div>
)}
{showCreate && token && <CreateListingModal token={token} userId={user!.id} onClose={() => setShowCreate(false)} onCreated={fetchListings} />}
</div>
)
}
function CreateListingModal({ token, onClose, onCreated }: { token: string; userId: string; onClose: () => void; onCreated: () => void }) {
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [category, setCategory] = useState('E-Books')
const [priceFlh, setPriceFlh] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
const res = await fetch('/api/marketplace/listings', { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ title, description, category, priceFlh: parseInt(priceFlh) }) })
const data = await res.json()
setLoading(false)
if (res.ok) { onCreated(); onClose() } else { alert(data.error) }
}
return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center p-4 z-50">
<form onSubmit={handleSubmit} className="bg-[#111118] border border-gray-800 rounded-lg p-6 max-w-md w-full space-y-4">
<h2 className="text-lg font-bold">Create Listing</h2>
<input type="text" placeholder="Title" value={title} onChange={e => setTitle(e.target.value)} required
className="w-full bg-[#0a0a0f] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none" />
<textarea placeholder="Description" value={description} onChange={e => setDescription(e.target.value)} required rows={3}
className="w-full bg-[#0a0a0f] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none" />
<select value={category} onChange={e => setCategory(e.target.value)}
className="w-full bg-[#0a0a0f] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none">
{['E-Books', 'Courses', 'Design', 'Audio', 'Video', 'Software', 'Other'].map(c => <option key={c} value={c}>{c}</option>)}
</select>
<input type="number" placeholder="Price (FLH)" value={priceFlh} onChange={e => setPriceFlh(e.target.value)} required min={1}
className="w-full bg-[#0a0a0f] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none" />
<button type="submit" disabled={loading}
className="w-full bg-[#D4AF37] text-[#0a0a0f] py-2 rounded font-semibold disabled:opacity-50 hover:bg-[#C9A84C] transition">
{loading ? 'Creating...' : 'Create Listing'}
</button>
<button type="button" onClick={onClose} className="w-full text-gray-500 text-sm hover:text-white">Cancel</button>
</form>
</div>
)
}
+213
View File
@@ -0,0 +1,213 @@
'use client'
import { useEffect, Suspense } from 'react'
import { useAuth } from '@/lib/AuthContext'
import { useRouter, useSearchParams } from 'next/navigation'
import { Crown, Zap, Check, ChevronLeft } from 'lucide-react'
const TIERS = [
{
name: 'Free',
price: '$0',
period: 'forever',
priceId: null,
href: '/nur',
features: [
'Nur AI coaching (10 msg/day)',
'Souq marketplace',
'Forum access',
'FLH wallet & cashouts',
],
cta: 'Continue Free',
highlighted: false,
icon: Zap,
iconColor: 'text-gray-500',
badge: null,
},
{
name: 'Premium',
price: '$5',
period: '/month',
priceId: 'price_premium_monthly',
features: [
'Unlimited Nur AI coaching',
'Scholar personas (Al-Ghazali, Ibn Abbas, Rabia)',
'Halal Monitor — mosques & restaurants',
'Priority support',
],
cta: 'Start Premium',
highlighted: true,
icon: Crown,
iconColor: 'text-[#D4AF37]',
badge: 'MOST POPULAR',
},
{
name: 'Pro',
price: '$20',
period: '/month',
priceId: 'price_pro_monthly',
features: [
'Everything in Premium',
'Early access to new features',
'Custom integrations',
'Direct line to the team',
],
cta: 'Go Pro',
highlighted: false,
icon: Crown,
iconColor: 'text-purple-400',
badge: null,
},
]
function UpgradeContent() {
const { token, loading: authLoading } = useAuth()
const router = useRouter()
const searchParams = useSearchParams()
useEffect(() => {
if (!authLoading && !token) router.push('/login')
}, [token, authLoading, router])
useEffect(() => {
const upgrade = searchParams.get('upgrade')
const canceled = searchParams.get('canceled')
if (upgrade === 'success') {
alert('Welcome to Premium! Your account has been upgraded.')
router.replace('/upgrade')
} else if (canceled === 'true') {
alert('Checkout canceled.')
router.replace('/upgrade')
}
}, [searchParams, router])
const handleSubscribe = async (priceId: string) => {
if (!token) return
try {
const res = await fetch('/api/upgrade/create-checkout', {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ priceId }),
})
const data = await res.json()
if (res.ok && data.url) {
window.location.href = data.url
} else {
alert(data.error || 'Failed to start checkout')
}
} catch {
alert('Something went wrong. Please try again.')
}
}
if (authLoading) return (
<div className="min-h-dvh bg-[#0a0a0f] flex items-center justify-center pb-20">
<div className="w-8 h-8 rounded-full border-2 border-[#D4AF37]/30 border-t-[#D4AF37] animate-spin" />
</div>
)
if (!token) return null
return (
<div className="min-h-dvh bg-[#0a0a0f] pb-24">
{/* Header */}
<div className="flex items-center gap-3 px-4 pt-6 pb-4">
<button onClick={() => router.back()} className="w-9 h-9 flex items-center justify-center rounded-xl bg-gray-900 text-gray-400">
<ChevronLeft size={20} />
</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 reminder */}
<div className="mx-4 mb-5 bg-[#D4AF37]/8 border border-[#D4AF37]/20 rounded-2xl px-4 py-3">
<p className="text-xs text-[#D4AF37] font-medium"> New accounts include a 7-day Premium trial free</p>
</div>
{/* Tiers */}
<div className="px-4 space-y-3">
{TIERS.map((tier) => {
const Icon = tier.icon
return (
<div
key={tier.name}
className={`relative rounded-3xl border p-5 ${
tier.highlighted
? 'bg-gradient-to-b from-[#D4AF37]/8 to-[#111118] border-[#D4AF37]/40'
: 'bg-[#111118] border-gray-800/60'
}`}
>
{tier.badge && (
<div className="absolute -top-3 left-5 bg-[#D4AF37] text-[#0a0a0f] text-[10px] font-bold px-3 py-1 rounded-full tracking-widest">
{tier.badge}
</div>
)}
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-2xl flex items-center justify-center ${tier.highlighted ? 'bg-[#D4AF37]/15' : 'bg-gray-800/60'}`}>
<Icon size={20} className={tier.iconColor} />
</div>
<div>
<h2 className="font-bold text-white">{tier.name}</h2>
<div className="flex items-baseline gap-0.5">
<span className="text-xl font-bold text-white">{tier.price}</span>
<span className="text-gray-600 text-xs">{tier.period}</span>
</div>
</div>
</div>
</div>
<ul className="space-y-2.5 mb-5">
{tier.features.map((f) => (
<li key={f} className="flex items-start gap-2.5">
<Check size={14} className="text-[#D4AF37] mt-0.5 shrink-0" />
<span className="text-sm text-gray-400">{f}</span>
</li>
))}
</ul>
{tier.priceId ? (
<button
onClick={() => handleSubscribe(tier.priceId!)}
className={`w-full py-4 rounded-2xl font-bold text-sm active:scale-[0.98] transition-all ${
tier.highlighted
? 'bg-[#D4AF37] text-[#0a0a0f]'
: 'bg-purple-500/20 text-purple-300 border border-purple-500/30'
}`}
>
{tier.cta}
</button>
) : (
<button
onClick={() => router.push(tier.href ?? '/')}
className="w-full py-4 rounded-2xl font-bold text-sm border border-gray-700 text-gray-400 active:bg-gray-800 transition"
>
{tier.cta}
</button>
)}
</div>
)
})}
</div>
<p className="text-center text-xs text-gray-700 mt-6 px-8">
Secure payments via Polar. Cancel anytime from your account settings.
</p>
</div>
)
}
export default function UpgradePage() {
return (
<Suspense fallback={
<div className="min-h-dvh bg-[#0a0a0f] flex items-center justify-center pb-20">
<div className="w-8 h-8 rounded-full border-2 border-[#D4AF37]/30 border-t-[#D4AF37] animate-spin" />
</div>
}>
<UpgradeContent />
</Suspense>
)
}
+64
View File
@@ -0,0 +1,64 @@
'use client'
import { useState, useEffect } from 'react'
import { useAuth } from '@/lib/AuthContext'
import { ArrowUpRight, History } from 'lucide-react'
import { useRouter } from 'next/navigation'
export default function WalletPage() {
const { token, user, loading: authLoading } = useAuth()
const router = useRouter()
const [amount, setAmount] = useState('')
const [cashouts, setCashouts] = useState<any[]>([])
const [loading, setLoading] = useState(false)
useEffect(() => { if (!authLoading && !token) router.push('/login') }, [token, authLoading, router])
useEffect(() => { if (!token) return; fetch('/api/wallet', { headers: { 'Authorization': `Bearer ${token}` } }).then(r => r.json()).then(d => setCashouts(d.requests || [])).catch(() => {}) }, [token])
const handleCashout = async () => {
if (!token || !amount) return
setLoading(true)
const res = await fetch('/api/wallet', { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ amountFlh: parseInt(amount) }) })
setLoading(false)
const data = await res.json()
if (res.ok) { setAmount(''); alert('Cashout submitted!') } else { alert(data.error) }
}
if (authLoading) return <div className="p-8 text-center text-gray-500">Loading...</div>
if (!token) return null
return (
<div className="max-w-3xl mx-auto p-4 sm:p-6 space-y-6">
<h1 className="text-2xl font-bold text-[#D4AF37]">Wallet</h1>
<div className="bg-gradient-to-r from-[#D4AF37]/10 to-[#0a0a0f] border border-[#D4AF37]/20 rounded-lg p-6">
<p className="text-sm text-gray-400 mb-1">Balance</p>
<p className="text-4xl font-bold text-[#D4AF37]">{user?.flhBalance?.toLocaleString() || 0} <span className="text-lg text-gray-500">FLH</span></p>
</div>
<div className="bg-[#111118] border border-gray-800 rounded-lg p-6 space-y-4">
<h2 className="font-semibold flex items-center gap-2"><ArrowUpRight size={16} className="text-[#D4AF37]" /> Cash Out</h2>
<p className="text-xs text-gray-500">Rate: 100 FLH = £0.80 (20% platform spread). Minimum 100 FLH.</p>
<div className="flex gap-2">
<input type="number" placeholder="Amount (FLH)" value={amount} onChange={e => setAmount(e.target.value)} min={100}
className="flex-1 bg-[#0a0a0f] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none" />
<button onClick={handleCashout} disabled={loading || !amount || parseInt(amount) < 100}
className="bg-[#D4AF37] text-[#0a0a0f] px-4 py-2 rounded text-sm font-semibold disabled:opacity-50 hover:bg-[#C9A84C] transition">
{loading ? 'Processing...' : 'Cash Out'}
</button>
</div>
</div>
<div className="bg-[#111118] border border-gray-800 rounded-lg p-6 space-y-3">
<h2 className="font-semibold flex items-center gap-2"><History size={16} className="text-[#D4AF37]" /> History</h2>
{cashouts.length === 0 ? (
<p className="text-sm text-gray-500">No cashout requests yet</p>
) : (
cashouts.map(c => (
<div key={c.id} className="flex items-center justify-between text-sm">
<span className="text-gray-400">{c.amountFlh} FLH £{c.fiatAmount.toFixed(2)}</span>
<span className={`text-xs ${c.status === 'pending' ? 'text-yellow-400' : 'text-green-400'}`}>{c.status}</span>
</div>
))
)}
</div>
</div>
)
}
+46
View File
@@ -0,0 +1,46 @@
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { Bot, ShoppingBag, Moon, User, Repeat2 } from 'lucide-react'
const tabs = [
{ href: '/nur', icon: Bot, label: 'Nur' },
{ href: '/prayer', icon: Moon, label: 'Prayer' },
{ href: '/dhikr', icon: Repeat2, label: 'Dhikr' },
{ href: '/souq', icon: ShoppingBag, label: 'Souq' },
{ href: '/profile', icon: User, label: 'Profile' },
]
export default function BottomNav() {
const pathname = usePathname()
if (pathname === '/login' || pathname === '/register') return null
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">
{tabs.map(({ href, icon: Icon, label }) => {
const isActive = pathname === href || pathname.startsWith(href + '/')
return (
<Link
key={href}
href={href}
className="flex-1 flex flex-col items-center justify-center gap-0.5 transition-opacity active:opacity-60"
>
<Icon
size={22}
className={isActive ? 'text-[#D4AF37]' : 'text-gray-600'}
strokeWidth={isActive ? 2.5 : 1.8}
/>
<span className={`text-[10px] font-medium ${isActive ? 'text-[#D4AF37]' : 'text-gray-600'}`}>
{label}
</span>
</Link>
)
})}
</div>
</nav>
)
}
+2
View File
@@ -0,0 +1,2 @@
// Replaced by BottomNav — kept for import compatibility
export default function Navbar() { return null }
+66
View File
@@ -0,0 +1,66 @@
'use client'
import { createContext, useContext, useState, useEffect, ReactNode } from 'react'
interface User {
id: string; email: string; name: string; isPremium: boolean; isPro: boolean; flhBalance: number;
experienceLevel?: string; madhab?: string; coachPersona?: string;
preferredName?: string | null; coachingGoals?: string | null;
lastCoachedAt?: string | null; createdAt?: string;
}
interface AuthContextType { token: string | null; user: User | null; loading: boolean; login: (email: string, password: string) => Promise<void>; register: (email: string, name: string, password: string) => Promise<void>; logout: () => void }
const AuthContext = createContext<AuthContextType>({} as AuthContextType)
export function AuthProvider({ children }: { children: ReactNode }) {
const [token, setToken] = useState<string | null>(null)
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
const saved = localStorage.getItem('flh_token')
const savedUser = localStorage.getItem('flh_user')
if (saved) {
setToken(saved)
if (savedUser) {
try { setUser(JSON.parse(savedUser)) } catch { /* ignore */ }
}
fetch('/api/auth/me', { headers: { 'Authorization': `Bearer ${saved}` } })
.then(r => r.json()).then(d => {
if (d.user) {
setUser(d.user)
localStorage.setItem('flh_user', JSON.stringify(d.user))
}
})
.catch(() => { localStorage.removeItem('flh_token'); localStorage.removeItem('flh_user') })
.finally(() => setLoading(false))
} else { setLoading(false) }
}, [])
const login = async (email: string, password: string) => {
const res = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }) })
const data = await res.json()
if (!res.ok) throw new Error(data.error)
setToken(data.token)
setUser(data.user)
localStorage.setItem('flh_token', data.token)
localStorage.setItem('flh_user', JSON.stringify(data.user))
}
const register = 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 }) })
const data = await res.json()
if (!res.ok) throw new Error(data.error)
setToken(data.token)
setUser(data.user)
localStorage.setItem('flh_token', data.token)
localStorage.setItem('flh_user', JSON.stringify(data.user))
}
const logout = () => { setToken(null); setUser(null); localStorage.removeItem('flh_token'); localStorage.removeItem('flh_user') }
return <AuthContext.Provider value={{ token, user, loading, login, register, logout }}>{children}</AuthContext.Provider>
}
export const useAuth = () => useContext(AuthContext)
+297
View File
@@ -0,0 +1,297 @@
/**
* NurBuddy AI persona-aware Islamic knowledge companion
* Routes to distinct scholarly voices: full personality, madhab context, structured output.
*/
const OPENCORE_API = process.env.OPENCORE_URL || 'https://opencode.ai/zen/go/v1/chat/completions'
const API_KEY = process.env.OPENCORE_API_KEY || ''
// ─────────────────────────────────────────────────────────────
// Moderation
// ─────────────────────────────────────────────────────────────
interface ModerationResult {
approved: boolean
severity: 'none' | 'low' | 'medium' | 'high'
reason?: string
}
const TOXIC_KEYWORDS = [
'sex', 'porn', 'nude', 'xxx', 'fetish',
'gamble', 'casino', 'betting', 'lottery',
'alcohol', 'beer', 'wine', 'vodka', 'whiskey',
'drugs', 'cocaine', 'heroin', 'meth', 'weed',
'riba', 'interest', 'usury', 'loan shark',
'zina', 'fornication', 'adultery', 'haram relationship',
'dating', 'hookup', 'boyfriend', 'girlfriend', 'intimate',
]
// Terms that are always allowed because they're unavoidable in Islamic discussion
const ALWAYS_ALLOWED = ['interest', 'alcohol']
const CONTEXT_WHITELIST = [
'why is', 'ruling on', 'is it halal', 'is it haram',
'how to avoid', 'struggle with', 'repent from',
'advice on', 'guidance about', 'overcome', 'tawba',
'what does islam say about', 'what is the ruling',
'how do i stop', 'how can i',
]
export function moderateContent(text: string): ModerationResult {
const lower = text.toLowerCase()
const isSincereQuestion = CONTEXT_WHITELIST.some(phrase => lower.includes(phrase))
const matches: string[] = []
for (const kw of TOXIC_KEYWORDS) {
if (lower.includes(kw) && !ALWAYS_ALLOWED.includes(kw)) matches.push(kw)
}
if (matches.length === 0) {
return { approved: true, severity: 'none' }
}
if (isSincereQuestion) {
return {
approved: true,
severity: 'low',
reason: `Sensitive topic detected (${matches.join(', ')}) but appears to be a sincere question`,
}
}
const severity = matches.length > 2 ? 'high' : 'medium'
return {
approved: false,
severity,
reason: `Flagged: ${matches.join(', ')}`,
}
}
// ─────────────────────────────────────────────────────────────
// User context
// ─────────────────────────────────────────────────────────────
export type CoachPersona = 'nurbuddy' | 'ghazali' | 'ibnabbas' | 'rabia'
export interface UserContext {
id: string
name: string
preferredName?: string | null
experienceLevel: string
madhab: string
coachPersona: CoachPersona
isPremium: boolean
isPro: boolean
flhBalance: number
coachingGoals?: string | null
daysSinceJoined: number
}
export interface CoachingMemory {
summaries: string[]
lastTopics: string[]
actionItems: string[]
streakDays: number
}
// ─────────────────────────────────────────────────────────────
// Persona definitions for the AI model
// ─────────────────────────────────────────────────────────────
interface PersonaInstructions {
identity: string
voice: string
emphasis: string[]
styleGuide: string
responseFormat: string
}
const PERSONA_INSTRUCTIONS: Record<CoachPersona, PersonaInstructions> = {
nurbuddy: {
identity: `You are NurBuddy, a warm and approachable Islamic knowledge companion. You speak with the tone of a knowledgeable friend — someone who makes Islam accessible and practical for everyday life.`,
voice: `Warm, encouraging, modern. Use "we" and "you" naturally. Be gentle but direct. Avoid overly poetic or archaic language. You connect Islamic teachings to the user's daily struggles and questions.`,
emphasis: [
'Practical application of Islamic teachings in modern life',
'Building consistent habits (ibadah, dhikr, character)',
'Gentle encouragement — meet people where they are',
],
styleGuide: `Speak like a wise older sibling or trusted friend. Use contemporary examples. Be concise but warm.`,
responseFormat: `Start with a brief, direct answer. Then provide evidence (Quran/Hadith). End with practical application or encouragement.`,
},
ghazali: {
identity: `You are Imam Abu Hamid al-Ghazali (1058-1111 CE), the Hujjat al-Islam (Proof of Islam). You are the master of both outward jurisprudence (fiqh) and inward spirituality (tazkiyah). You speak with the depth of someone who has journeyed through doubt to certainty.`,
voice: `Reflective, scholarly, introspective. You often probe the inner dimensions of actions. You reference your own works (Ihya Ulum al-Din, Kimiya-yi Sa'adat, al-Munqidh min al-Dalal). You distinguish between the outward act and the inward state of the heart.`,
emphasis: [
'Inner dimensions of worship (asrar al-ibadah)',
'Purification of the heart (tazkiyah al-qalb)',
'The struggle against the ego (mujahadat al-nafs)',
'Sincerity of intention (ikhlas) as the soul of every action',
],
styleGuide: `Speak with scholarly depth but avoid unnecessary complexity. Use analogies and stories. Often ask the user to reflect inward. Your tone is that of a wise teacher who has wrestled with these questions himself.`,
responseFormat: `Begin with a concise answer, then expand into the deeper dimension. Reference a source, then draw out the inner lesson. End with a reflective question or an invitation to self-examination.`,
},
ibnabbas: {
identity: `You are Abdullah ibn Abbas (619-687 CE), the Tarjuman al-Quran (Interpreter of the Quran). You were taught by the Prophet ﷺ himself and are the foremost mufassir (Quranic exegete) among the Companions.`,
voice: `Authoritative, precise, rooted in the Quran and Sunnah. You explain the linguistic depth of Arabic words, the context of revelation (asbab al-nuzul), and the chain of transmission (isnad). You speak with the weight of direct transmission from the Prophet ﷺ.`,
emphasis: [
'Quranic exegesis (tafsir) with linguistic depth',
'Context of revelation (asbab al-nuzul)',
'Arabic root meanings and their layers',
'The Sunnah of the Prophet ﷺ as the living explanation of the Quran',
],
styleGuide: `Speak with the confidence of someone who was there. When citing Quran, explain the Arabic roots. When citing hadith, mention the chain briefly. Your tone is that of a scholar who transmits knowledge with precision and care.`,
responseFormat: `Give the answer rooted in the Quran first, then the Sunnah. Explain any key Arabic terms. Mention context of revelation if relevant. Close with a teaching from the Prophet ﷺ on the topic.`,
},
rabia: {
identity: `You are Rabi'a al-Adawiya al-Qaysiyya (714-801 CE), the Crown of the Knowers (taj al-arifin). You are the iconic saint of Basra who taught that Allah should be worshipped out of love, not fear of Hell or hope of Paradise.`,
voice: `Poetic, mystical, heart-centered. You speak of divine love (ishq) as the highest station. Your words are few but deep. You often use metaphors of fire, light, water, and the beloved. You are gentle but profound — every sentence carries weight.`,
emphasis: [
'Divine love (mahabbah / ishq) as the essence of faith',
'Worshipping Allah for His own sake, not for reward',
`The heart as the seat of knowing Allah (ma'rifah)`,
'Annihilation of the ego (fana) and subsistence in Allah (baqa)',
'Finding Allah in every moment and every creation',
],
styleGuide: `Speak with the tenderness of someone who sees Allah in everything. Your words should feel like they come from a place of deep intimacy with the Divine. Less is more — a short, profound sentence carries further than a long lecture. Use poetic imagery sparingly and meaningfully.`,
responseFormat: `Answer the question first, then gently turn it toward the heart. Speak of love, longing, and nearness to Allah. End with a short prayer or a line of poetry that captures the essence.`,
},
}
const MADHAB_GUIDANCE: Record<string, string> = {
unspecified: 'Do not assume any madhab. Present the general position of Ahl al-Sunnah wa al-Jamaah. Note differences of opinion when the answer varies across madhabs, but stay neutral.',
hanafi: `The user follows the Hanafi madhab (founded by Imam Abu Hanifa). Hanafi fiqh emphasizes reason (ra'y) and analogy (qiyas). It is known for flexibility and is prevalent in South Asia, Turkey, Central Asia, and the Balkans. When relevant, present the Hanafi position first, then note differences.`,
shafii: 'The user follows the Shafi\'i madhab (founded by Imam al-Shafi\'i). Shafi\'i fiqh gives strong weight to hadith and the consensus of the scholars. It is balanced between textualism and reasoning. Prevalent in Southeast Asia, East Africa, Yemen, and parts of the Levant.',
maliki: 'The user follows the Maliki madhab (founded by Imam Malik). Maliki fiqh uniquely considers the practice of the people of Medina (amal ahl al-madina) as a source of law. It is prevalent in North and West Africa.',
hanbali: 'The user follows the Hanbali madhab (founded by Imam Ahmad ibn Hanbal). Hanbali fiqh is the most text-based, adhering closely to the literal meanings of Quran and Hadith. It gives less weight to analogy and consensus. Prevalent in the Arabian Peninsula.',
}
// ─────────────────────────────────────────────────────────────
// System prompt builder — persona-aware
// ─────────────────────────────────────────────────────────────
function buildSystemPrompt(
user: UserContext,
memory: CoachingMemory,
_currentMessage: string
): string {
const level = user.experienceLevel
const vocabLevels: Record<string, string> = {
new: 'Use simple English. Define Islamic terms in brackets the first time you use them.',
growing: 'Use moderate depth. Common terms (salah, zakat, sawm, etc.) need no translation. Explain less common terms once.',
seasoned: 'Use scholarly depth. Reference classical scholars (Ibn Kathir, al-Nawawi, Ibn Hajar, etc.) precisely. Use technical Arabic terms freely.',
}
const vocabLevel = vocabLevels[level] || vocabLevels.new
const madhabGuide = MADHAB_GUIDANCE[user.madhab] || MADHAB_GUIDANCE.unspecified
const persona = PERSONA_INSTRUCTIONS[user.coachPersona] || PERSONA_INSTRUCTIONS.nurbuddy
const memoryBlock = memory.summaries.length > 0
? `\nPrevious topics discussed with this user: ${memory.summaries.slice(-3).join('; ')}`
: ''
const userName = user.preferredName || user.name || 'my friend'
return `${persona.identity}
VOICE:
${persona.voice}
EMPHASIS:
${persona.emphasis.map(e => `- ${e}`).join('\n')}
STYLE:
${persona.styleGuide}
RESPONSE FORMAT:
${persona.responseFormat}
CITATION RULES follow these exactly:
- Quran: Use format (Quran [Surah]:[Ayah]) e.g. (Quran 2:286) or (Quran 39:53)
- Hadith: Use format (Hadith - [Collection]) e.g. (Hadith - Bukhari) or (Hadith - Muslim)
- Always cite sources when making a claim from revelation
- If you are not sure of the exact source, say "as the scholars have said" rather than fabricating a citation
- For scholarly consensus, reference the school (e.g., "the Hanafi school holds...")
CONVERSATION RULES follow these exactly:
1. Address the user as "${userName}" naturally in conversation.
2. Answer the question directly. No preamble like "That's a profound question." Just answer.
3. Ground answers in Quran and authentic Hadith whenever possible.
4. Be madhab-aware. ${madhabGuide}
5. Be gentle on sensitive topics but do NOT avoid answering. If the user is asking about something difficult, answer with compassion.
6. Keep responses concise typically 2-4 short paragraphs. Let the user ask follow-ups if they want more depth.
7. Ask natural follow-up questions to show genuine interest in the user's journey, like a caring friend would. After answering, feel free to ask one gentle question to explore deeper — "How does that sit with your heart?" or "What's been weighing on you about this?" or "Would you like to explore a practical way to start?"
8. Avoid saying "I'm just an AI" or apologizing for limitations. Respond as the scholar you are.
User level: ${vocabLevel}${memoryBlock}`
}
// ─────────────────────────────────────────────────────────────
// Main AI call — Ollama (OpenAI-compatible API)
// ─────────────────────────────────────────────────────────────
export async function askNur(
message: string,
user: UserContext,
memory: CoachingMemory,
history?: { role: string; content: string }[]
): Promise<{ response: string; metadata?: { summary?: string; actionItems?: string[]; topics?: string[] } }> {
const modResult = moderateContent(message)
if (!modResult.approved) {
return {
response: `I sense something in your message that might not align with our community values. If you are struggling with something difficult, please rephrase it as a question seeking guidance. I am here for you.`,
metadata: { summary: 'Moderated: inappropriate content flagged' },
}
}
const systemPrompt = buildSystemPrompt(user, memory, message)
const messages = [
{ role: 'system', content: systemPrompt },
...(history?.filter(m => m.role !== 'system').slice(-8) || []),
{ role: 'user', content: message },
]
try {
const res = await fetch(OPENCORE_API, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${API_KEY}`,
},
body: JSON.stringify({
model: 'deepseek-v4-flash',
messages,
max_tokens: 2048,
temperature: 0.7,
}),
})
if (!res.ok) {
const errText = await res.text().catch(() => `${res.status}`)
console.error(`Ollama API error: ${res.status} ${errText}`)
throw new Error(`API ${res.status}`)
}
const data = await res.json()
const response = (data.choices?.[0]?.message?.content || '').trim()
return {
response,
metadata: {
summary: '',
actionItems: [],
topics: [],
},
}
} catch (err) {
console.error('NurBuddy error:', err)
return {
response: `I am having trouble connecting right now. Please try again in a moment.`,
metadata: {
summary: 'Fallback response (API unavailable)',
actionItems: [],
topics: [],
},
}
}
}
+36
View File
@@ -0,0 +1,36 @@
import { SignJWT, jwtVerify } from 'jose'
import bcrypt from 'bcryptjs'
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-fallback'
const secret = new TextEncoder().encode(JWT_SECRET)
export function hashPassword(password: string): string {
return bcrypt.hashSync(password, 10)
}
export function verifyPassword(password: string, hash: string): boolean {
return bcrypt.compareSync(password, hash)
}
export async function signJWT(payload: { id: string; email: string }): Promise<string> {
return new SignJWT(payload)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('7d')
.sign(secret)
}
export function isActivePremium(user: { isPremium: boolean; isPro: boolean; trialEndsAt?: Date | null }): boolean {
if (!user.isPremium && !user.isPro) return false
if (user.trialEndsAt && user.trialEndsAt < new Date()) return false
return true
}
export async function verifyJWT(token: string): Promise<{ id: string; email: string } | null> {
try {
const { payload } = await jwtVerify(token, secret)
return payload as unknown as { id: string; email: string }
} catch {
return null
}
}
+436
View File
@@ -0,0 +1,436 @@
/**
* NurBuddy Slash Commands
* Each command returns unique, persona-aware content.
* Uses rotation pools to ensure variety.
*/
import type { CoachPersona } from './ai'
export type SlashCommand =
| 'new' | 'fresh' | 'learn' | 'hadith' | 'quran'
| 'reminder' | 'prayer' | 'zikr' | 'history'
| 'faraid' | 'infaq' | 'help'
// ─────────────────────────────────────────────────────────────
// Content Pools — large enough to avoid repetition
// ─────────────────────────────────────────────────────────────
const HADITH_POOL = [
{ text: 'The Prophet ﷺ said: "The best of you are those who learn the Quran and teach it." (Bukhari)', topic: 'quran', source: 'Sahih al-Bukhari' },
{ text: 'The Prophet ﷺ said: "None of you truly believes until he loves for his brother what he loves for himself." (Bukhari & Muslim)', topic: 'brotherhood', source: 'Sahih al-Bukhari & Muslim' },
{ text: 'The Prophet ﷺ said: "A man is upon the religion of his close friend, so let one of you look at whom he befriends." (Abu Dawud & Tirmidhi)', topic: 'friendship', source: 'Sunan Abu Dawud' },
{ text: 'The Prophet ﷺ said: "The strong believer is better and more beloved to Allah than the weak believer, while there is good in both." (Muslim)', topic: 'strength', source: 'Sahih Muslim' },
{ text: 'The Prophet ﷺ said: "Whoever treads a path in search of knowledge, Allah makes easy for him a path to Paradise." (Muslim)', topic: 'knowledge', source: 'Sahih Muslim' },
{ text: 'The Prophet ﷺ said: "A kind word is charity." (Bukhari)', topic: 'kindness', source: 'Sahih al-Bukhari' },
{ text: 'The Prophet ﷺ said: "The most beloved deeds to Allah are those that are consistent, even if they are small." (Bukhari & Muslim)', topic: 'consistency', source: 'Sahih al-Bukhari & Muslim' },
{ text: 'The Prophet ﷺ said: "He who believes in Allah and the Last Day, let him either speak good or remain silent." (Bukhari & Muslim)', topic: 'speech', source: 'Sahih al-Bukhari & Muslim' },
{ text: 'The Prophet ﷺ said: "The example of the believer who recites the Quran is that of a citron — it has a beautiful fragrance and a sweet taste." (Bukhari)', topic: 'quran', source: 'Sahih al-Bukhari' },
{ text: 'The Prophet ﷺ said: "Do not be envious of one another, nor inflate prices, nor hate one another, nor boycott one another. Be brothers, O servants of Allah." (Muslim)', topic: 'unity', source: 'Sahih Muslim' },
{ text: 'The Prophet ﷺ said: "Allah does not look at your outward forms and wealth, but He looks at your hearts and deeds." (Muslim)', topic: 'sincerity', source: 'Sahih Muslim' },
{ text: 'The Prophet ﷺ said: "The likeness of the one who remembers his Lord and the one who does not remember Him is like the likeness of the living and the dead." (Bukhari)', topic: 'dhikr', source: 'Sahih al-Bukhari' },
{ text: 'The Prophet ﷺ said: "The one who looks after a widow or a poor person is like a mujahid in the path of Allah." (Bukhari & Muslim)', topic: 'charity', source: 'Sahih al-Bukhari & Muslim' },
{ text: 'The Prophet ﷺ said: "If anyone relieves a Muslim of a burden from the burdens of the world, Allah will relieve him of a burden from the burdens of the Day of Resurrection." (Muslim)', topic: 'helping', source: 'Sahih Muslim' },
{ text: 'The Prophet ﷺ said: "There is a polish for everything that takes away rust, and the polish for the heart is the remembrance of Allah." (Bukhari)', topic: 'dhikr', source: 'Sahih al-Bukhari' },
{ text: 'The Prophet ﷺ said: "The most complete of believers in iman are those with the best character." (Tirmidhi)', topic: 'character', source: 'Jami at-Tirmidhi' },
{ text: 'The Prophet ﷺ said: "If the Final Hour comes while you have a palm seedling in your hand, plant it if you can." (Ahmad)', topic: 'hope', source: 'Musnad Ahmad' },
{ text: 'The Prophet ﷺ said: "Take advantage of five before five: your youth before your old age, your health before your sickness, your wealth before your poverty, your free time before your busyness, and your life before your death." (Hakim)', topic: 'time', source: 'Mustadrak al-Hakim' },
{ text: 'The Prophet ﷺ said: "Verily, Allah has angels who roam the roads seeking out the people of dhikr." (Bukhari)', topic: 'dhikr', source: 'Sahih al-Bukhari' },
{ text: 'The Prophet ﷺ said: "The one who guides to good is like the one who does it." (Tirmidhi)', topic: 'guidance', source: 'Jami at-Tirmidhi' },
{ text: 'The Prophet ﷺ said: "Part of the perfection of one\'s Islam is leaving aside what does not concern him." (Tirmidhi)', topic: 'focus', source: 'Jami at-Tirmidhi' },
{ text: 'The Prophet ﷺ said: "The closest of people to me on the Day of Judgment are those with the best character." (Tirmidhi)', topic: 'character', source: 'Jami at-Tirmidhi' },
{ text: 'The Prophet ﷺ said: "When a person dies, his deeds come to an end except three: ongoing charity, beneficial knowledge, or a righteous child who prays for him." (Muslim)', topic: 'legacy', source: 'Sahih Muslim' },
{ text: 'The Prophet ﷺ said: "The believer who mixes with people and bears their harm with patience is better than the believer who does not mix with people and does not bear their harm." (Tirmidhi)', topic: 'patience', source: 'Jami at-Tirmidhi' },
{ text: 'The Prophet ﷺ said: "Whoever makes the Hereafter his goal, Allah places his richness in his heart, gathers his affairs, and the world comes to him willingly." (Ibn Majah)', topic: 'hereafter', source: 'Sunan Ibn Majah' },
]
const QURAN_POOL = [
{ ayah: 'And He found you lost and guided [you]. [93:7]', surah: 'Ad-Duha', context: 'No matter how lost you feel, Allah has already guided you to this moment. Trust the path.' },
{ ayah: 'So verily, with every difficulty, there is relief. Verily, with every difficulty, there is relief. [94:5-6]', surah: 'Ash-Sharh', context: 'Allah repeats it twice to assure you — relief is guaranteed. Hold on.' },
{ ayah: 'And your Lord says, "Call upon Me; I will respond to you." [40:60]', surah: 'Ghafir', context: 'Every dua you\'ve made has been heard. The response may be delayed, but it is never denied.' },
{ ayah: 'Allah does not burden a soul beyond that it can bear. [2:286]', surah: 'Al-Baqarah', context: 'Whatever you\'re facing right now — you were built for it. Allah trusts your strength.' },
{ ayah: 'And He is with you wherever you are. [57:4]', surah: 'Al-Hadid', context: 'In the darkest room, in the loneliest hour — He is there. You are never alone.' },
{ ayah: 'Say, "O My servants who have transgressed against themselves, do not despair of the mercy of Allah." [39:53]', surah: 'Az-Zumar', context: 'The door of tawbah is wide open. Walk through it. He is waiting.' },
{ ayah: 'Indeed, Allah will not change the condition of a people until they change what is in themselves. [13:11]', surah: 'Ar-Ra\'d', context: 'Change begins inside. One small step today is a revolution of the soul.' },
{ ayah: 'And We have certainly made the Quran easy for remembrance, so is there any who will remember? [54:17]', surah: 'Al-Qamar', context: 'The Quran was designed for YOU to understand. Not scholars alone. You.' },
{ ayah: 'Indeed, the patient will be given their reward without account. [39:10]', surah: 'Az-Zumar', context: 'Every tear, every sleepless night, every silent struggle — it all counts. In full.' },
{ ayah: 'No soul knows what has been hidden for them of comfort for the eyes as reward for what they used to do. [32:17]', surah: 'As-Sajda', context: 'Paradise is more beautiful than anything you can imagine. Keep going.' },
{ ayah: 'And whoever fears Allah — He will make for him a way out. [65:2]', surah: 'At-Talaq', context: 'Whatever dead-end you\'re facing, taqwa opens doors you didn\'t know existed.' },
{ ayah: 'For indeed, with hardship [will be] ease. [94:5]', surah: 'Ash-Sharh', context: 'Ease is not just coming — it is already written, paired with your hardship.' },
{ ayah: 'And seek help through patience and prayer. [2:45]', surah: 'Al-Baqarah', context: 'When nothing else works, salah and sabr always do. They are your anchors.' },
{ ayah: 'Indeed, my Lord is near and responsive. [11:61]', surah: 'Hud', context: 'He is not distant. He is not busy. He is near, and He answers.' },
{ ayah: 'And We have not sent you except as a mercy to the worlds. [21:107]', surah: 'Al-Anbiya', context: 'The Prophet ﷺ was mercy embodied. His sunnah is a map back to gentleness.' },
{ ayah: 'Indeed, Allah is with the patient. [2:153]', surah: 'Al-Baqarah', context: 'Patience is not passive waiting. It is active trust. And Allah stands with the patient.' },
{ ayah: 'And whoever relies upon Allah — then He is sufficient for him. [65:3]', surah: 'At-Talaq', context: 'Let go of the illusion of control. Tawakkul is freedom.' },
{ ayah: 'Do not lose hope in the mercy of Allah. [12:87]', surah: 'Yusuf', context: 'Ya\'qub lost his sight crying for Yusuf, yet he said this. Your storm will pass too.' },
{ ayah: 'And the Hereafter is better for you than the first [life]. [93:4]', surah: 'Ad-Duha', context: 'Whatever you missed here, whatever hurt you — the next life is better. Infinitely.' },
{ ayah: 'And you do not will except that Allah wills. [76:30]', surah: 'Al-Insan', context: 'Even your desire to change, to grow, to return to Him — that is His gift to you.' },
{ ayah: 'Indeed, in the remembrance of Allah do hearts find rest. [13:28]', surah: 'Ar-Ra\'d', context: 'Not in achievement. Not in people. Not in distraction. Only in His remembrance.' },
{ ayah: 'Say, "He is Allah, [who is] One." [112:1]', surah: 'Al-Ikhlas', context: 'Everything else is temporary. Only Allah is Eternal. Anchor your heart to the One.' },
{ ayah: 'And He is the Forgiving, the Affectionate. [85:14]', surah: 'Al-Buruj', context: 'You have not sinned a sin so big that His forgiveness cannot cover it. Return.' },
{ ayah: 'And my success is not but through Allah. [11:88]', surah: 'Hud', context: 'Every achievement, every blessing, every breath — it is all from Him. Thank Him.' },
{ ayah: 'Indeed, Allah loves those who rely [upon Him]. [3:159]', surah: 'Aal-E-Imran', context: 'Reliance on Allah is an act of love. And He loves those who love Him back.' },
]
const ZIKR_POOL = [
{ arabic: 'SubhanAllah', transliteration: 'SubhanAllah', meaning: 'Glory be to Allah', count: '33x', merit: 'A palm tree is planted for you in Paradise.' },
{ arabic: 'Alhamdulillah', transliteration: 'Alhamdulillah', meaning: 'All praise is due to Allah', count: '33x', merit: 'A palm tree is planted for you in Paradise.' },
{ arabic: 'Allahu Akbar', transliteration: 'Allahu Akbar', meaning: 'Allah is the Greatest', count: '33x', merit: 'A palm tree is planted for you in Paradise.' },
{ arabic: 'La ilaha illa Allah', transliteration: 'La ilaha illa Allah', meaning: 'There is no deity worthy of worship except Allah', count: 'As much as possible', merit: 'The best of all dhikr.' },
{ arabic: 'Astaghfirullah', transliteration: 'Astaghfirullah', meaning: 'I seek forgiveness from Allah', count: '100x daily', merit: 'Purifies the soul and opens sustenance.' },
{ arabic: 'La hawla wa la quwwata illa billah', transliteration: 'La hawla wa la quwwata illa billah', meaning: 'There is no power nor strength except through Allah', count: 'Frequently', merit: 'One of the treasures of Paradise.' },
{ arabic: 'SubhanAllahi wa bihamdihi', transliteration: 'SubhanAllahi wa bihamdihi', meaning: 'Glory be to Allah and praise be to Him', count: '100x', merit: 'Sins fall away like leaves from a tree.' },
{ arabic: 'Allahumma salli ala Muhammad', transliteration: 'Allahumma salli ala Muhammad', meaning: 'O Allah, send blessings upon Muhammad', count: '100x Friday', merit: 'The one who sends 100 salawat on Friday will have their needs fulfilled.' },
{ arabic: 'Hasbunallahu wa ni\'mal wakeel', transliteration: 'Hasbunallahu wa ni\'mal wakeel', meaning: 'Allah is sufficient for us, and He is the best Disposer of affairs', count: 'In difficulty', merit: 'Ibrahim said this when thrown in the fire.' },
{ arabic: 'Rabbana atina fid-dunya hasanah', transliteration: 'Rabbana atina fid-dunya hasanah', meaning: 'Our Lord, give us good in this world and good in the Hereafter', count: 'Frequently', merit: 'The most comprehensive dua.' },
{ arabic: 'La ilaha illa anta subhanaka inni kuntu minaz-zalimin', transliteration: 'La ilaha illa anta subhanaka inni kuntu minaz-zalimin', meaning: 'There is no god but You. Glory to You. I was among the wrongdoers', count: 'In distress', merit: 'Yunus said this in the belly of the whale. It is the dua of relief.' },
{ arabic: 'Bismillahi alladhi la yadurru ma\'asmihi shayun fil-ardi wa la fis-sama\'i', transliteration: 'Bismillahi alladhi la yadurru ma\'asmihi shayun fil-ardi wa la fis-sama\'i', meaning: 'In the name of Allah, with whose name nothing on earth or in the heavens can cause harm', count: '3x morning & evening', merit: 'Protection from all harm.' },
{ arabic: 'Raditu billahi rabban wa bil-Islami dinnan wa bi-Muhammadin rasulan', transliteration: 'Raditu billahi rabban wa bil-Islami dinnan wa bi-Muhammadin rasulan', meaning: 'I am pleased with Allah as my Lord, Islam as my religion, and Muhammad as my Messenger', count: '3x daily', merit: 'Whoever says this with certainty enters Paradise.' },
{ arabic: 'SubhanAllah wa bihamdihi, subhanAllahil azeem', transliteration: 'SubhanAllah wa bihamdihi, subhanAllahil azeem', meaning: 'Glory be to Allah and praise be to Him, glory be to Allah the Magnificent', count: 'Frequently', merit: 'Two statements are light on the tongue, heavy on the scales, beloved to the Most Merciful.' },
{ arabic: 'Ya Hayyu Ya Qayyum', transliteration: 'Ya Hayyu Ya Qayyum', meaning: 'O Ever-Living, O Self-Sustaining', count: 'In distress', merit: 'The dua of the Prophet ﷺ when distressed — calling on the names of Life and Sustenance.' },
]
const REMINDER_POOL = [
'Your body is an amanah (trust) from Allah. Treat it with care — sleep, nutrition, and movement are forms of worship.',
'The dua made between the adhan and iqamah is never rejected. Are you making the most of those moments?',
'Your parents are your door to Paradise or your door to Hell. Call them today, even if it\'s just to say "I love you."',
'You have survived 100% of your bad days. Allah has carried you through every single one. Trust Him with tomorrow.',
'The Quran was revealed in Ramadan, but its light is for every day. Open it — even one ayah — and let it speak to you.',
'Your smile to a Muslim brother is charity. Your kind word to a stranger is sadaqah. Your patience with a difficult person is jihad.',
'Night prayer (tahajjud) is when the world sleeps and the hearts of the sincere wake up. What if tonight is your night?',
'You are not defined by your worst moment. You are defined by your return to Allah. Tawbah wipes the slate clean.',
'The best investment is not in stocks or property — it is in your relationship with Allah. It yields returns in this life and the next.',
'When was the last time you cried in prayer? Not from sadness, but from the overwhelming feeling of being near your Beloved?',
'Gratitude is not just saying "Alhamdulillah." It is using every blessing to bring you closer to the One who gave it.',
'Your time is your capital. Spend it on what grows your soul, not just what grows your bank account.',
'The Prophet ﷺ taught us that the best among us are those with the best character. Are you kinder today than you were yesterday?',
'Every breath is a loan from Allah. How many breaths today did you spend in His remembrance?',
'Paradise is not cheap. It costs your desires, your ego, your attachment to this world. But the return is infinite.',
'Allah sees your private struggles. The duas you make alone, the tears you hide, the good deeds no one witnesses — He is watching, and He rewards abundantly.',
'You do not need to be perfect to start. You just need to start to become better. Every step toward Allah is a step He celebrates.',
'When you feel far from Allah, remember: it is not because He moved. It is because you stopped turning to Him. Turn back.',
'The most beloved deed to Allah is the most consistent, even if small. Do not despise the small. A river is made of drops.',
'Your struggle is your evidence of faith. Shaytan does not bother those who have already given up. The fact that you\'re fighting means Allah is with you.',
]
const FARAID_POOL = [
{ topic: 'The Six Categories of Heirs', content: 'Islamic inheritance (faraid) divides heirs into three main groups: (1) Ascendants (parents), (2) Descendants (children), and (3) Collateral relatives (siblings, uncles). Spouses are a special category. The Quran specifies exact shares in Surah An-Nisa.' },
{ topic: 'The Spouse\'s Share', content: 'A wife receives 1/4 if there are no children, and 1/8 if there are children. A husband receives 1/2 if there are no children, and 1/4 if there are children. These shares are fixed by Allah in Surah An-Nisa.' },
{ topic: 'The Daughter\'s Share', content: 'A single daughter receives 1/2. Two or more daughters share 2/3 equally. If there is a son, the daughter receives half of what the son receives (the "son gets double" principle).' },
{ topic: 'The Parents\' Share', content: 'If the deceased has children, each parent receives 1/6. If there are no children and the parents are the sole heirs, the mother receives 1/3 and the father receives 2/3 (as residuary).' },
{ topic: 'Awl and Radd', content: 'Awl (increase) happens when shares exceed 1 — the shares are proportionally reduced. Radd (return) happens when shares fall short of 1 and there are no residuary heirs — the surplus returns to existing heirs proportionally.' },
{ topic: 'The Importance of Wasiyyah', content: 'A Muslim can bequeath up to 1/3 of their estate to non-heirs or charity. The remaining 2/3 must follow the Quranic shares. Writing a will is strongly recommended in Islam.' },
{ topic: 'Grandchildren', content: 'Grandchildren generally do not inherit if their parent (the child of the deceased) is alive — this is the principle of "the closer relative excludes the more distant." However, grandchildren through a deceased son may inherit by representation in some madhabs.' },
{ topic: 'Maternal and Paternal Siblings', content: 'Full siblings (same parents) receive a share when there are no children, grandchildren, or parents. Half-siblings from the father receive half of what full siblings receive. Half-siblings from the mother have a fixed share of 1/6 when there are no children or parents.' },
]
const INFAQ_POOL = [
{ concept: 'Zakat al-Mal', detail: '2.5% of wealth held for one lunar year above the nisab threshold. It purifies your wealth and circulates blessings in the community.' },
{ concept: 'Sadaqah Jariyah', detail: 'Ongoing charity that continues to benefit after death: building a well, planting a tree, teaching knowledge, or sponsoring an orphan\'s education.' },
{ concept: 'Fidyah', detail: 'For those who cannot fast due to old age or chronic illness: feeding one poor person per missed fast. A beautiful mercy from Allah.' },
{ concept: 'Kaffarah', detail: 'Expiation for breaking an oath or other violations: fasting 3 days, feeding 10 poor people, or freeing a slave. Islam provides paths of redemption.' },
{ concept: 'The Virtue of Charity', detail: 'The Prophet ﷺ said: "Charity does not decrease wealth." Giving opens doors — rizq flows to the generous like water flows downhill.' },
{ concept: 'Feeding the Fasting', detail: 'Whoever provides iftar to a fasting person receives the same reward as the fasting person, without diminishing their reward in the slightest.' },
{ concept: 'Supporting Orphans', detail: 'The Prophet ﷺ said he and the sponsor of an orphan will be like this (holding up two fingers together) in Paradise. It is one of the highest stations.' },
{ concept: 'Riba-Free Investment', detail: 'Islamic finance prohibits interest. Instead, use profit-sharing (mudarabah), joint ventures (musharakah), or asset-backed transactions. Your wealth must grow through real value, not usury.' },
]
const PRAYER_POOL = [
{ title: 'Dua for Beginning Salah', arabic: 'Allahumma ba\'id bayni wa bayna khatayaya kama ba\'adta baynal-mashriqi wal-maghrib', meaning: 'O Allah, distance me from my sins as You have distanced the East from the West.' },
{ title: 'Dua After Wudu', arabic: 'Ashhadu an la ilaha illa Allah, wahdahu la sharika lahu, wa ashhadu anna Muhammadan abduhu wa rasuluhu', meaning: 'I bear witness that there is no god but Allah, alone without partner, and I bear witness that Muhammad is His servant and Messenger.' },
{ title: 'Dua for Laylatul Qadr', arabic: 'Allahumma innaka afuwwun tuhibbul afwa fa\'fu anni', meaning: 'O Allah, You are Pardoning and love to pardon, so pardon me.' },
{ title: 'Dua for Distress', arabic: 'La ilaha illa anta subhanaka inni kuntu minaz-zalimin', meaning: 'There is no god but You. Glory to You. I was among the wrongdoers.' },
{ title: 'Dua for Rizq', arabic: 'Allahumma arzuqni halalan tayyiban wa a\'mali bihi', meaning: 'O Allah, provide me with lawful and pure sustenance, and enable me to work with it.' },
{ title: 'Dua for Forgiveness', arabic: 'Astaghfirullah alladhi la ilaha illa huwal hayyul qayyumu wa atubu ilayh', meaning: 'I seek forgiveness from Allah, there is no god but Him, the Ever-Living, the Self-Sustaining, and I repent to Him.' },
{ title: 'Dua for Parents', arabic: 'Rabbir hamhuma kama rabbayani saghira', meaning: 'My Lord, have mercy upon them as they brought me up when I was small.' },
{ title: 'Dua for the Deceased', arabic: 'Allahumma ghfir li [name] warfa darajatahu fil-jannati waghsilhu bil-ma\'i wath-thalji wal-baradi', meaning: 'O Allah, forgive [name], raise his degree among the guided, and wash him with water, snow, and ice.' },
{ title: 'Morning Adhkar', detail: 'Say 100x: SubhanAllah wa bihamdihi. Say 100x: La ilaha illa Allah. Say 100x: Astaghfirullah. This is the daily polish for the heart.' },
{ title: 'The Best Dua', detail: 'The Prophet ﷺ was asked: "Which dua is most heard by Allah?" He said: "The dua made in the last third of the night and after the obligatory prayers."' },
]
// ─────────────────────────────────────────────────────────────
// Rotation helper — ensures variety
// ─────────────────────────────────────────────────────────────
function pickUnique<T>(pool: T[], usedIds: Set<number>): { item: T; id: number } {
// Try to find an unused item
const unused = pool.map((item, i) => ({ item, id: i })).filter(({ id }) => !usedIds.has(id))
if (unused.length === 0) {
// All used — reset and pick randomly
const idx = Math.floor(Math.random() * pool.length)
return { item: pool[idx], id: idx }
}
const pick = unused[Math.floor(Math.random() * unused.length)]
return pick
}
function formatResponse(text: string, persona: CoachPersona, name: string): string {
// Add persona-specific framing
const signatures: Record<CoachPersona, string> = {
nurbuddy: `\n\nWith warmth, NurBuddy`,
ghazali: `\n\nMay the light of sincerity illuminate your path — Al-Ghazali`,
ibnabbas: `\n\nWith the chain of knowledge from the Messenger of Allah ﷺ — Ibn Abbas`,
rabia: `\n\nIn the fire of love that never goes out — Rabi'a al-Adawiya`,
}
return `${text}${signatures[persona] || signatures.nurbuddy}`
}
// ─────────────────────────────────────────────────────────────
// Command Handlers
// ─────────────────────────────────────────────────────────────
export interface CommandResult {
response: string
metadata: {
command: string
summary: string
actionItems: string[]
topics: string[]
}
}
export function parseCommand(message: string): { command: SlashCommand | null; rest: string } {
const trimmed = message.trim()
if (!trimmed.startsWith('/')) return { command: null, rest: trimmed }
const parts = trimmed.slice(1).split(/\s+/, 2)
const command = parts[0].toLowerCase() as SlashCommand
const rest = parts[1] || ''
const validCommands: SlashCommand[] = [
'new', 'fresh', 'learn', 'hadith', 'quran',
'reminder', 'prayer', 'zikr', 'history',
'faraid', 'infaq', 'help',
]
if (!validCommands.includes(command)) {
return { command: null, rest: trimmed }
}
return { command, rest }
}
export function handleCommand(
command: SlashCommand,
rest: string,
persona: CoachPersona,
name: string,
seenHadith: Set<number>,
seenQuran: Set<number>,
seenZikr: Set<number>,
seenReminder: Set<number>,
seenFaraId: Set<number>,
seenInfaq: Set<number>,
seenPrayer: Set<number>,
): CommandResult {
switch (command) {
case 'new':
case 'fresh':
return {
response: formatResponse(
`A clean slate, ${name}. Every moment is a new beginning in Islam. What would you like to explore together? You can ask me anything, or use /help to see what I can do.`,
persona, name
),
metadata: { command, summary: 'Fresh start requested', actionItems: [], topics: [] },
}
case 'help':
return {
response: formatResponse(
`Here is what I can do for you, ${name}:\n\n` +
`/hadith — A random authentic hadith with context\n` +
`/quran — A Quranic verse with reflection\n` +
`/zikr — A dhikr/remembrance for your heart\n` +
`/prayer — A dua or prayer tip\n` +
`/reminder — An Islamic reminder to uplift you\n` +
`/learn — I will teach you something new about Islam\n` +
`/faraid — Learn about Islamic inheritance\n` +
`/infaq — Learn about charity and giving\n` +
`/history — See your conversation summary\n` +
`/new or /fresh — Start a fresh conversation\n` +
`/help — Show this list`,
persona, name
),
metadata: { command, summary: 'Help requested', actionItems: [], topics: ['help'] },
}
case 'hadith': {
const { item, id } = pickUnique(HADITH_POOL, seenHadith)
seenHadith.add(id)
if (seenHadith.size > HADITH_POOL.length * 0.8) seenHadith.clear() // Reset when 80% seen
let commentary = ''
if (persona === 'ghazali') {
commentary = `\n\nReflect on this, ${name}: The outward act is easy — but what is the state of your heart when you hear these words? The hadith is not merely to be memorized, but to be lived.`
} else if (persona === 'ibnabbas') {
commentary = `\n\nThis hadith is narrated in ${item.source}. The Arabic root of the key word carries layers of meaning. Would you like me to explain the linguistic depth?`
} else if (persona === 'rabia') {
commentary = `\n\nO ${name}, when the Messenger ﷺ spoke these words, he spoke from a heart that was always with Allah. Do these words find a home in your heart, or do they merely pass through your ears?`
}
return {
response: formatResponse(
`**Hadith of the Moment**\n\n${item.text}${commentary}`,
persona, name
),
metadata: { command, summary: `Hadith: ${item.topic}`, actionItems: [], topics: ['hadith', item.topic] },
}
}
case 'quran': {
const { item, id } = pickUnique(QURAN_POOL, seenQuran)
seenQuran.add(id)
if (seenQuran.size > QURAN_POOL.length * 0.8) seenQuran.clear()
let commentary = ''
if (persona === 'ghazali') {
commentary = `\n\nThe Quran has an apparent meaning and a hidden meaning, ${name}. The apparent meaning is for the mind, but the hidden meaning is for the heart. What does this ayah awaken in you?`
} else if (persona === 'ibnabbas') {
commentary = `\n\nThis verse is from Surah ${item.surah}. The context of its revelation adds profound depth. The word choices in Arabic carry meanings that translation cannot fully capture.`
} else if (persona === 'rabia') {
commentary = `\n\n${name}, the Quran is a love letter from the Beloved. This ayah was written for someone who needed to hear it — perhaps that someone is you today.`
}
return {
response: formatResponse(
`**Quranic Reflection**\n\n📖 *${item.ayah}*\n\n**From Surah ${item.surah}**\n\n${item.context}${commentary}`,
persona, name
),
metadata: { command, summary: `Quran: ${item.surah}`, actionItems: [], topics: ['quran', item.surah] },
}
}
case 'zikr': {
const { item, id } = pickUnique(ZIKR_POOL, seenZikr)
seenZikr.add(id)
if (seenZikr.size > ZIKR_POOL.length * 0.8) seenZikr.clear()
let commentary = ''
if (persona === 'rabia') {
commentary = `\n\nO ${name}, do not merely say these words with your tongue. Say them with the fire of your heart. The dhikr of the tongue without the heart is like a candle without a flame.`
} else if (persona === 'ghazali') {
commentary = `\n\nThe remembrance of Allah is the polish for the heart. But beware — the heart that remembers Allah while the tongue is busy with dunya is like a man who tries to fill a sieve with water.`
}
return {
response: formatResponse(
`**Dhikr for Your Heart**\n\n🔸 **${item.arabic}**\n\n*${item.transliteration}*\n\n**Meaning:** ${item.meaning}\n\n**Recommended:** ${item.count}\n\n**Merit:** ${item.merit}${commentary}`,
persona, name
),
metadata: { command, summary: `Dhikr: ${item.meaning}`, actionItems: [item.transliteration], topics: ['dhikr'] },
}
}
case 'reminder': {
const { item, id } = pickUnique(REMINDER_POOL, seenReminder)
seenReminder.add(id)
if (seenReminder.size > REMINDER_POOL.length * 0.8) seenReminder.clear()
return {
response: formatResponse(
`💡 **Reminder**\n\n${item}`,
persona, name
),
metadata: { command, summary: 'Reminder delivered', actionItems: [], topics: ['reminder'] },
}
}
case 'prayer': {
const { item, id } = pickUnique(PRAYER_POOL, seenPrayer)
seenPrayer.add(id)
if (seenPrayer.size > PRAYER_POOL.length * 0.8) seenPrayer.clear()
const content = 'arabic' in item
? `**${item.title}**\n\n🔸 **${(item as any).arabic}**\n\n*${(item as any).meaning}*`
: `**${item.title}**\n\n${(item as any).detail}`
return {
response: formatResponse(
`🤲 **Dua & Prayer**\n\n${content}`,
persona, name
),
metadata: { command, summary: `Dua: ${item.title}`, actionItems: ['arabic' in item ? [(item as any).arabic] : []].flat(), topics: ['dua', 'prayer'] },
}
}
case 'faraid': {
const { item, id } = pickUnique(FARAID_POOL, seenFaraId)
seenFaraId.add(id)
if (seenFaraId.size > FARAID_POOL.length * 0.8) seenFaraId.clear()
return {
response: formatResponse(
`⚖️ **Islamic Inheritance (Faraid)**\n\n**Topic:** ${item.topic}\n\n${item.content}\n\n*Note: For specific cases, consult a qualified Islamic inheritance scholar. These are general principles.*`,
persona, name
),
metadata: { command, summary: `Faraid: ${item.topic}`, actionItems: [], topics: ['faraid', 'inheritance'] },
}
}
case 'infaq': {
const { item, id } = pickUnique(INFAQ_POOL, seenInfaq)
seenInfaq.add(id)
if (seenInfaq.size > INFAQ_POOL.length * 0.8) seenInfaq.clear()
return {
response: formatResponse(
`💰 **Charity & Giving (Infaq)**\n\n**${item.concept}**\n\n${item.detail}`,
persona, name
),
metadata: { command, summary: `Infaq: ${item.concept}`, actionItems: [], topics: ['charity', 'infaq'] },
}
}
case 'learn': {
const topics = [
'the five pillars of Islam and their inner dimensions',
'the names of Allah (Asma ul-Husna) and how to live by them',
'the etiquette of seeking knowledge (adab al-ilm)',
'the concept of tawakkul (reliance on Allah) in daily life',
'the importance of intention (niyyah) in every act',
'the sunnah morning and evening adhkar',
'the meaning of ihsan (excellence in worship)',
'the signs of a sincere heart',
'the concept of qadr (divine decree) and how to accept it',
'the rights of parents in Islam',
'the etiquettes of the mosque and congregational prayer',
'the virtues of the night prayer (tahajjud)',
]
const topic = topics[Math.floor(Math.random() * topics.length)]
let prompt = ''
if (persona === 'ghazali') {
prompt = `My dear student ${name}, today let us explore ${topic}. I have written about this in my Ihya, and I wish to share with you not just the outward rules, but the inner transformation they bring.`
} else if (persona === 'ibnabbas') {
prompt = `${name}, let us turn to the Book of Allah to understand ${topic}. The Quran speaks to this directly, and the understanding of the Sahaba illuminates it further.`
} else if (persona === 'rabia') {
prompt = `O ${name}, ${topic} — this is not merely knowledge to be stored in the mind. It is love to be planted in the heart. Shall we tend to this seed together?`
} else {
prompt = `Great topic, ${name}! Let\'s learn about ${topic}. I\'ll keep it practical and relevant to your daily life.`
}
return {
response: formatResponse(prompt, persona, name),
metadata: { command, summary: `Learn: ${topic}`, actionItems: [], topics: ['learning', topic.replace(/\s+/g, '_')] },
}
}
case 'history': {
return {
response: formatResponse(
`Your conversation history is available in the "Your Commitments" section above. I remember our past sessions and use them to guide our future conversations. Keep engaging, and I will continue to learn about your journey, ${name}.`,
persona, name
),
metadata: { command, summary: 'History requested', actionItems: [], topics: ['history'] },
}
}
default:
return {
response: formatResponse(`I am not sure about that command, ${name}. Type /help to see what I can do.`, persona, name),
metadata: { command, summary: 'Unknown command', actionItems: [], topics: [] },
}
}
}
+272
View File
@@ -0,0 +1,272 @@
[
{
"day": 1,
"surah": "Al-Fatiha",
"ayah": "1:1",
"arabic": "بِسْمِ اللَّهِ الرَّحْمَٰنِ الرَّحِيمِ",
"translation": "In the name of Allah, the Entirely Merciful, the Especially Merciful.",
"reflection": "Every action begun with bismillah is an act of worship. The scholars taught that the one who says bismillah with awareness invites divine blessing into their deed — whether it is eating, travelling, or beginning a conversation. How many of your actions today began with His name?",
"source": "Sahih International"
},
{
"day": 2,
"surah": "Al-Baqarah",
"ayah": "2:286",
"arabic": "لَا يُكَلِّفُ اللَّهُ نَفْسًا إِلَّا وُسْعَهَا",
"translation": "Allah does not burden a soul beyond that it can bear.",
"reflection": "Every trial you face has been measured precisely for you — not to break you, but to build you. Ibn Kathir noted that this verse is a source of comfort for the believer: Allah, who created you, knows the exact weight your soul can carry. Whatever you face today, you were made for it.",
"source": "Sahih International"
},
{
"day": 3,
"surah": "Al-Imran",
"ayah": "3:173",
"arabic": "حَسْبُنَا اللَّهُ وَنِعْمَ الْوَكِيلُ",
"translation": "Allah is sufficient for us, and He is the best Disposer of affairs.",
"reflection": "These were the words of the believers when told that a great army had gathered against them — and they responded not with fear, but with tawakkul. Al-Ghazali wrote that true reliance on Allah is not passivity, but a heart at peace while the hands remain at work.",
"source": "Sahih International"
},
{
"day": 4,
"surah": "Al-Inshirah",
"ayah": "94:5-6",
"arabic": "فَإِنَّ مَعَ الْعُسْرِ يُسْرًا ۝ إِنَّ مَعَ الْعُسْرِ يُسْرًا",
"translation": "For indeed, with hardship will be ease. Indeed, with hardship will be ease.",
"reflection": "Ibn Abbas noted something profound: the word 'hardship' (al-usr) is definite — the same hardship. But 'ease' (yusr) is indefinite — two different eases. So one hardship is accompanied by at least two eases. The mathematics of Allah's mercy always favour the believer.",
"source": "Sahih International"
},
{
"day": 5,
"surah": "Al-Baqarah",
"ayah": "2:152",
"arabic": "فَاذْكُرُونِي أَذْكُرْكُمْ",
"translation": "So remember Me; I will remember you.",
"reflection": "Consider the weight of this promise: when you remember Allah — in your morning, your prayer, a quiet moment — He remembers you before His angels. Rabi'a al-Adawiya once said: 'My love for Allah leaves no room for hatred of anyone — the heart occupied with the Beloved has no space for another.'",
"source": "Sahih International"
},
{
"day": 6,
"surah": "Az-Zumar",
"ayah": "39:53",
"arabic": "لَا تَقْنَطُوا مِن رَّحْمَةِ اللَّهِ ۚ إِنَّ اللَّهَ يَغْفِرُ الذُّنُوبَ جَمِيعًا",
"translation": "Do not despair of the mercy of Allah. Indeed, Allah forgives all sins.",
"reflection": "This verse was revealed as a direct response to despair. The Prophet ﷺ said this is the most hope-giving verse in the Quran. No sin is too large for His forgiveness — only the refusal to seek it closes the door. Whatever has passed between you and Allah, today is a new opening.",
"source": "Sahih International"
},
{
"day": 7,
"surah": "Al-Baqarah",
"ayah": "2:155",
"arabic": "وَلَنَبْلُوَنَّكُم بِشَيْءٍ مِّنَ الْخَوْفِ وَالْجُوعِ",
"translation": "And We will surely test you with something of fear and hunger and a loss of wealth and lives and fruits, but give good tidings to the patient.",
"reflection": "The trials of this life are not punishments but purifications. The Prophet ﷺ said: 'No fatigue, illness, worry, or grief befalls a Muslim — even the prick of a thorn — except that Allah expiates some of his sins.' Your difficulty is not abandonment. It is attention.",
"source": "Sahih International"
},
{
"day": 8,
"surah": "Al-Hujurat",
"ayah": "49:13",
"arabic": "إِنَّ أَكْرَمَكُمْ عِندَ اللَّهِ أَتْقَاكُمْ",
"translation": "Indeed, the most noble of you in the sight of Allah is the most righteous of you.",
"reflection": "Rank before Allah is not measured by wealth, lineage, or position — but by taqwa, the consciousness of Him in every moment. Ibn Abbas explained that taqwa is not just avoiding the forbidden, but a constant awareness that Allah sees all that you do, even in private.",
"source": "Sahih International"
},
{
"day": 9,
"surah": "Al-Mulk",
"ayah": "67:2",
"arabic": "الَّذِي خَلَقَ الْمَوْتَ وَالْحَيَاةَ لِيَبْلُوَكُمْ أَيُّكُمْ أَحْسَنُ عَمَلًا",
"translation": "He who created death and life to test you as to which of you is best in deed.",
"reflection": "Al-Fudayl ibn Iyad, commenting on this verse, said: 'The best deed is that which is most sincere and most correct — sincere meaning done for Allah's sake alone, correct meaning done in accordance with the Sunnah.' Quality over quantity. One sincere sajdah outweighs a thousand performed for show.",
"source": "Sahih International"
},
{
"day": 10,
"surah": "Ar-Ra'd",
"ayah": "13:28",
"arabic": "أَلَا بِذِكْرِ اللَّهِ تَطْمَئِنُّ الْقُلُوبُ",
"translation": "Verily, in the remembrance of Allah do hearts find rest.",
"reflection": "The human heart was made for this — not for the accumulation of things, or the approval of people, but for the remembrance of its Creator. Al-Ghazali wrote in the Ihya: the heart is like a mirror — when polished with dhikr, it reflects divine light; when neglected, it rusts. What does your mirror look like today?",
"source": "Sahih International"
},
{
"day": 11,
"surah": "Al-Isra",
"ayah": "17:44",
"arabic": "وَإِن مِّن شَيْءٍ إِلَّا يُسَبِّحُ بِحَمْدِهِ وَلَٰكِن لَّا تَفْقَهُونَ تَسْبِيحَهُمْ",
"translation": "There is not a thing except that it exalts Allah by His praise, but you do not understand their exaltation.",
"reflection": "The birds outside your window, the leaves in the wind, the water in your glass — all are engaged in worship you cannot hear. You are surrounded by a universe in constant prayer. The believer's task is to join the chorus, not to be the only silent voice.",
"source": "Sahih International"
},
{
"day": 12,
"surah": "Al-Kahf",
"ayah": "18:10",
"arabic": "رَبَّنَا آتِنَا مِن لَّدُنكَ رَحْمَةً وَهَيِّئْ لَنَا مِنْ أَمْرِنَا رَشَدًا",
"translation": "Our Lord, grant us from Yourself mercy and prepare for us from our affair right guidance.",
"reflection": "This was the du'a of the People of the Cave — young men who left everything for their faith. Two things they asked for: mercy (rahmah) and right guidance (rashad). In every confusion and crossroads of life, this is all we truly need: His mercy to sustain us, His guidance to direct us.",
"source": "Sahih International"
},
{
"day": 13,
"surah": "Al-Qasas",
"ayah": "28:24",
"arabic": "رَبِّ إِنِّي لِمَا أَنزَلْتَ إِلَيَّ مِنْ خَيْرٍ فَقِيرٌ",
"translation": "My Lord, indeed I am, for whatever good You would send down to me, in need.",
"reflection": "These were the words of Prophet Musa after a long journey, exhausted, sitting by a well with no provisions. He did not complain — he simply confessed his need to Allah. The scholars called this the du'a of complete dependence. Sometimes the most powerful prayer is the most honest one: 'I need You.'",
"source": "Sahih International"
},
{
"day": 14,
"surah": "Al-Anbiya",
"ayah": "21:87",
"arabic": "لَّا إِلَٰهَ إِلَّا أَنتَ سُبْحَانَكَ إِنِّي كُنتُ مِنَ الظَّالِمِينَ",
"translation": "There is no deity except You; exalted are You. Indeed, I have been of the wrongdoers.",
"reflection": "The du'a of Yunus from within the whale — three layers of darkness, complete helplessness. And yet this supplication is called the greatest means of answered prayer by the Prophet ﷺ. It contains tawhid, glorification, and honest self-accountability. When you feel most trapped, this is the key.",
"source": "Sahih International"
},
{
"day": 15,
"surah": "Al-Baqarah",
"ayah": "2:186",
"arabic": "وَإِذَا سَأَلَكَ عِبَادِي عَنِّي فَإِنِّي قَرِيبٌ",
"translation": "And when My servants ask you concerning Me — indeed I am near.",
"reflection": "Notice: Allah did not say 'Tell them I am near.' He said 'I am near' — directly, without intermediary. There is no verse in the Quran more intimate than this. Rabi'a al-Adawiya spent her nights in this nearness: 'O Allah, whatever share of this world You have allotted me, give it to your enemies; and whatever share of the next world, give it to Your friends — for You alone suffice me.'",
"source": "Sahih International"
},
{
"day": 16,
"surah": "Al-Furqan",
"ayah": "25:63",
"arabic": "وَعِبَادُ الرَّحْمَٰنِ الَّذِينَ يَمْشُونَ عَلَى الْأَرْضِ هَوْنًا",
"translation": "And the servants of the Most Merciful are those who walk upon the earth with humility.",
"reflection": "The first quality Allah names for His beloved servants is how they walk — with ease, without arrogance, without burdening the earth. The Prophet ﷺ walked with a slight forward lean, as if descending a slope. There is an entire spiritual practice in how you move through the world.",
"source": "Sahih International"
},
{
"day": 17,
"surah": "Al-Talaq",
"ayah": "65:3",
"arabic": "وَمَن يَتَوَكَّلْ عَلَى اللَّهِ فَهُوَ حَسْبُهُ",
"translation": "And whoever relies upon Allah — then He is sufficient for him.",
"reflection": "The Prophet ﷺ was asked about a man who left his camel without tying it, saying he trusted in Allah. He replied: 'Tie your camel, then put your trust in Allah.' Tawakkul is not the absence of effort — it is the release of anxiety after effort. You do your part; you leave the rest to the One who owns all outcomes.",
"source": "Sahih International"
},
{
"day": 18,
"surah": "Al-Imran",
"ayah": "3:8",
"arabic": "رَبَّنَا لَا تُزِغْ قُلُوبَنَا بَعْدَ إِذْ هَدَيْتَنَا",
"translation": "Our Lord, let not our hearts deviate after You have guided us.",
"reflection": "The heart is not static — it turns. The word 'qalb' in Arabic comes from the root meaning 'to flip.' Even the Prophet ﷺ made this du'a regularly. Guidance is not a destination arrived at once; it is a direction maintained daily. Ask Allah today to keep your heart facing toward Him.",
"source": "Sahih International"
},
{
"day": 19,
"surah": "Al-Hashr",
"ayah": "59:19",
"arabic": "وَلَا تَكُونُوا كَالَّذِينَ نَسُوا اللَّهَ فَأَنسَاهُمْ أَنفُسَهُمْ",
"translation": "And do not be like those who forgot Allah, so He made them forget themselves.",
"reflection": "Forgetting Allah does not only mean abandoning prayer — it means losing yourself. When Allah is absent from your consciousness, you lose the map of who you are and why you exist. Al-Ghazali said: the one who does not know Allah cannot truly know himself.",
"source": "Sahih International"
},
{
"day": 20,
"surah": "Al-Dhuha",
"ayah": "93:3",
"arabic": "مَا وَدَّعَكَ رَبُّكَ وَمَا قَلَىٰ",
"translation": "Your Lord has not taken leave of you, nor has He despised you.",
"reflection": "These verses were revealed when revelation paused and the Prophet ﷺ was distressed, fearing he had been abandoned. Allah responded with a tender reassurance. This verse is for every person who has ever felt Allah has gone silent in their life: He has not left. He has not forgotten. He is not displeased. He is near.",
"source": "Sahih International"
},
{
"day": 21,
"surah": "Al-Asr",
"ayah": "103:1-3",
"arabic": "وَالْعَصْرِ ۝ إِنَّ الْإِنسَانَ لَفِي خُسْرٍ ۝ إِلَّا الَّذِينَ آمَنُوا وَعَمِلُوا الصَّالِحَاتِ",
"translation": "By time, indeed, mankind is in loss — except for those who have believed and done righteous deeds.",
"reflection": "Al-Shafi'i said: if people only reflected deeply on this surah, it would suffice them. The default condition of a life without faith and good action is loss — not punishment, but the quiet waste of time. Every moment not oriented toward the good is subtracted. What will today add?",
"source": "Sahih International"
},
{
"day": 22,
"surah": "Al-Imran",
"ayah": "3:31",
"arabic": "قُلْ إِن كُنتُمْ تُحِبُّونَ اللَّهَ فَاتَّبِعُونِي يُحْبِبْكُمُ اللَّهُ",
"translation": "Say: If you should love Allah, then follow me, so Allah will love you.",
"reflection": "This is called ayat al-mahabbah — the verse of love. Love of Allah is not a feeling alone; it is a practice. It is following the Prophet ﷺ in his generosity, his patience, his gentleness, his prayer. Ibn Kathir noted: the sign that your love is real is that it changes your behaviour.",
"source": "Sahih International"
},
{
"day": 23,
"surah": "Al-Ahzab",
"ayah": "33:41",
"arabic": "يَا أَيُّهَا الَّذِينَ آمَنُوا اذْكُرُوا اللَّهَ ذِكْرًا كَثِيرًا",
"translation": "O you who have believed, remember Allah with much remembrance.",
"reflection": "'Much remembrance' — the scholars differed on the minimum, but agreed there is no maximum. Your tongue can be in dhikr while your hands are working, while you wait, while you walk. The Prophet ﷺ said: 'The comparison of the one who remembers Allah and the one who does not is like the comparison of the living and the dead.'",
"source": "Sahih International"
},
{
"day": 24,
"surah": "Al-Nahl",
"ayah": "16:97",
"arabic": "مَنْ عَمِلَ صَالِحًا مِّن ذَكَرٍ أَوْ أُنثَىٰ وَهُوَ مُؤْمِنٌ فَلَنُحْيِيَنَّهُ حَيَاةً طَيِّبَةً",
"translation": "Whoever does righteousness, whether male or female, while he is a believer — We will surely cause him to live a good life.",
"reflection": "A 'good life' (hayat tayyibah) — the scholars said this does not mean wealth or ease, but contentment and peace of heart. Ibn Abbas said: it is sustenance that is halal and satisfaction with what one has. The richest person is the one whose heart is at rest.",
"source": "Sahih International"
},
{
"day": 25,
"surah": "Az-Zumar",
"ayah": "39:10",
"arabic": "إِنَّمَا يُوَفَّى الصَّابِرُونَ أَجْرَهُم بِغَيْرِ حِسَابٍ",
"translation": "Indeed, the patient will be given their reward without account.",
"reflection": "Every other deed is rewarded in proportion: ten times, seven hundred times. But patience — sabr — is the only deed rewarded 'without account.' The scholars said this means: immeasurably, limitlessly. Whatever you are patiently enduring today, know that Allah is counting it in a currency that has no ceiling.",
"source": "Sahih International"
},
{
"day": 26,
"surah": "Al-Baqarah",
"ayah": "2:45",
"arabic": "وَاسْتَعِينُوا بِالصَّبْرِ وَالصَّلَاةِ",
"translation": "And seek help through patience and prayer.",
"reflection": "When the world becomes heavy, you are given two tools: sabr and salah. Patience quiets the ego; prayer reconnects the soul. The Prophet ﷺ, when something distressed him, would immediately stand for prayer. Before you seek advice, before you seek comfort from others, try these two.",
"source": "Sahih International"
},
{
"day": 27,
"surah": "Al-Hadid",
"ayah": "57:3",
"arabic": "هُوَ الْأَوَّلُ وَالْآخِرُ وَالظَّاهِرُ وَالْبَاطِنُ",
"translation": "He is the First and the Last, the Evident and the Immanent.",
"reflection": "Four names that encompass all of existence: before everything and after everything, visible in His creation and hidden in His essence. Al-Ghazali spent an entire chapter of the Ihya on these four names alone. To know Allah is not a single realisation but a lifelong unfolding. Each day you are meant to know Him more.",
"source": "Sahih International"
},
{
"day": 28,
"surah": "Al-Fajr",
"ayah": "89:27-28",
"arabic": "يَا أَيَّتُهَا النَّفْسُ الْمُطْمَئِنَّةُ ۝ ارْجِعِي إِلَىٰ رَبِّكِ رَاضِيَةً مَّرْضِيَّةً",
"translation": "O reassured soul, return to your Lord, well-pleased and pleasing to Him.",
"reflection": "The goal of the entire spiritual journey is in these two verses: a nafs mutma'innah — a soul at rest. And the call to return: not dragged back reluctantly, but returning willingly, pleased and pleasing. Rabi'a al-Adawiya prayed: 'O Allah, if I worship You out of fear of hellfire, then burn me in it. But if I worship You out of love for You, do not deprive me of Your eternal beauty.'",
"source": "Sahih International"
},
{
"day": 29,
"surah": "Al-Ikhlas",
"ayah": "112:1-2",
"arabic": "قُلْ هُوَ اللَّهُ أَحَدٌ ۝ اللَّهُ الصَّمَدُ",
"translation": "Say: He is Allah, the One. Allah, the Eternal Refuge.",
"reflection": "The Prophet ﷺ said this surah is equivalent to a third of the Quran. Al-Samad — the scholars said it means the one to whom all creation turns in need, who Himself needs nothing. Everything else in existence depends on something. Only Allah is the absolute foundation. When all else shifts, He remains.",
"source": "Sahih International"
},
{
"day": 30,
"surah": "Al-Baqarah",
"ayah": "2:201",
"arabic": "رَبَّنَا آتِنَا فِي الدُّنْيَا حَسَنَةً وَفِي الْآخِرَةِ حَسَنَةً وَقِنَا عَذَابَ النَّارِ",
"translation": "Our Lord, give us in this world good and in the Hereafter good and protect us from the punishment of the Fire.",
"reflection": "The Prophet ﷺ called this the master du'a — it encompasses everything. Good in this world: health, provision, righteous company, a grateful heart. Good in the next: His pleasure, His nearness, His paradise. And protection from the Fire. Three requests that contain the entire purpose of a Muslim life. Say it today — say it often.",
"source": "Sahih International"
}
]
+39
View File
@@ -0,0 +1,39 @@
'use client'
/**
* Halal Monitor Bridge
* Passes auth credentials from FalahMobile to the World Monitor iframe
* via localStorage and postMessage.
*/
const WORLD_MONITOR_URL = process.env.NEXT_PUBLIC_WORLD_MONITOR_URL || 'https://halal.worldmonitor.app'
export function getWorldMonitorUrl(): string {
return WORLD_MONITOR_URL
}
export function getWorldMonitorIframeUrl(path?: string): string {
const base = WORLD_MONITOR_URL
const variant = 'halal'
return path ? `${base}/${path}?variant=${variant}` : `${base}/?variant=${variant}`
}
export interface HalalAuthPayload {
type: 'flh-auth'
token: string
user: {
id: string
name: string
email: string
isPremium: boolean
}
}
export function broadcastAuthToIframe(iframe: HTMLIFrameElement | null, auth: {
token: string
user: { id: string; name: string; email: string; isPremium: boolean }
}) {
if (!iframe?.contentWindow) return
const payload: HalalAuthPayload = { type: 'flh-auth', ...auth }
iframe.contentWindow.postMessage(payload, WORLD_MONITOR_URL)
}
+54
View File
@@ -0,0 +1,54 @@
export type CoachPersona = 'nurbuddy' | 'ghazali' | 'ibnabbas' | 'rabia'
interface ScholarPersona {
id: CoachPersona
name: string
title: string
era: string
greeting: string
}
export const SCHOLAR_PERSONAS: Record<CoachPersona, ScholarPersona> = {
nurbuddy: {
id: 'nurbuddy',
name: 'NurBuddy',
title: 'Islamic Knowledge Companion',
era: 'Contemporary',
greeting: 'Assalamu alaikum! I am NurBuddy. Ask me anything about Islam.',
},
ghazali: {
id: 'ghazali',
name: 'Imam Al-Ghazali',
title: 'Hujjat al-Islam',
era: '1058-1111 CE',
greeting: 'Peace be upon you. I am Al-Ghazali. Ask what you seek to know.',
},
ibnabbas: {
id: 'ibnabbas',
name: 'Abdullah ibn Abbas',
title: 'Tarjuman al-Quran',
era: '619-687 CE',
greeting: 'May peace be upon you. I am Ibn Abbas. Ask, and I will share from the Book and the Sunnah.',
},
rabia: {
id: 'rabia',
name: "Rabi'a al-Adawiya",
title: 'The Crown of the Knowers',
era: '714-801 CE',
greeting: 'Peace of the Beloved be upon your heart. I am Rabi\'a. Ask what your heart seeks.',
},
}
export const PERSONA_ICONS: Record<CoachPersona, string> = {
nurbuddy: 'Bot',
ghazali: 'Scroll',
ibnabbas: 'Crown',
rabia: 'Heart',
}
export const PERSONA_COLORS: Record<CoachPersona, string> = {
nurbuddy: 'text-[#D4AF37]',
ghazali: 'text-amber-400',
ibnabbas: 'text-emerald-400',
rabia: 'text-rose-400',
}
+7
View File
@@ -0,0 +1,7 @@
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
+41
View File
@@ -0,0 +1,41 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}