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:
@@ -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 }) }
|
||||
}
|
||||
@@ -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) } })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 }) }
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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' })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
Reference in New Issue
Block a user