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
+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 })
}