Initial Falah Mobile rebuild — all 7 blocks complete

- Auth system (login/register/profile)
- Nur AI chat with persona system
- Souq marketplace with FLH economy
- Forum community with Shariah moderation
- Wallet & FLH top-up
- Premium/Pro tier upgrade with Polar.sh
- Halal Monitor with map & bookmarks
- Home dashboard with daily verse & streaks
- Health endpoints

Next.js 16.2.7, Prisma v5 SQLite, JWT auth, Tailwind CSS v4
This commit is contained in:
root
2026-06-15 09:28:22 +02:00
parent ab8a2053e1
commit 5483dd291e
56 changed files with 7619 additions and 100 deletions
+59
View File
@@ -0,0 +1,59 @@
import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/prisma";
import { signJWT } from "@/lib/auth";
export async function POST(req: NextRequest) {
try {
const { email, password } = await req.json();
if (!email || !password) {
return NextResponse.json(
{ error: "Email and password are required" },
{ status: 400 }
);
}
const user = await prisma.user.findUnique({ where: { email } });
if (!user || !user.passwordHash) {
return NextResponse.json(
{ error: "Invalid email or password" },
{ status: 401 }
);
}
const valid = await bcrypt.compare(password, user.passwordHash);
if (!valid) {
return NextResponse.json(
{ error: "Invalid email or password" },
{ status: 401 }
);
}
const token = await signJWT({
userId: user.id,
email: user.email,
isPremium: user.isPremium,
isPro: user.isPro,
});
return NextResponse.json({
token,
user: {
id: user.id,
email: user.email,
name: user.name,
isPremium: user.isPremium,
isPro: user.isPro,
flhBalance: user.flhBalance,
dailyMsgCount: user.dailyMsgCount,
},
});
} catch (error) {
console.error("Login error:", error);
return NextResponse.json(
{ error: "Login failed. Please try again." },
{ status: 500 }
);
}
}
+37
View File
@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
export async function GET(req: NextRequest) {
const jwtPayload = await requireAuth(req);
if (!jwtPayload) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const user = await prisma.user.findUnique({
where: { id: jwtPayload.userId },
});
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
return NextResponse.json({
user: {
id: user.id,
email: user.email,
name: user.name,
isPremium: user.isPremium,
isPro: user.isPro,
flhBalance: user.flhBalance,
dailyMsgCount: user.dailyMsgCount,
experienceLevel: user.experienceLevel,
madhab: user.madhab,
coachPersona: user.coachPersona,
preferredName: user.preferredName,
coachingGoals: user.coachingGoals,
trialEndsAt: user.trialEndsAt,
createdAt: user.createdAt,
},
});
}
+70
View File
@@ -0,0 +1,70 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
const VALID_MADHABS = ["Hanafi", "Maliki", "Shafi'i", "Hanbali"];
export async function PATCH(req: NextRequest) {
const jwtPayload = await requireAuth(req);
if (!jwtPayload) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const body = await req.json();
const { name, preferredName, coachPersona, madhab, coachingGoals } = body;
if (madhab && !VALID_MADHABS.includes(madhab)) {
return NextResponse.json(
{
error: `Invalid madhab. Must be one of: ${VALID_MADHABS.join(", ")}`,
},
{ status: 400 }
);
}
const updateData: Record<string, string | undefined> = {};
if (name !== undefined) updateData.name = name;
if (preferredName !== undefined) updateData.preferredName = preferredName;
if (coachPersona !== undefined) updateData.coachPersona = coachPersona;
if (madhab !== undefined) updateData.madhab = madhab;
if (coachingGoals !== undefined) updateData.coachingGoals = coachingGoals;
if (Object.keys(updateData).length === 0) {
return NextResponse.json(
{ error: "No fields to update" },
{ status: 400 }
);
}
const user = await prisma.user.update({
where: { id: jwtPayload.userId },
data: updateData,
});
return NextResponse.json({
user: {
id: user.id,
email: user.email,
name: user.name,
isPremium: user.isPremium,
isPro: user.isPro,
flhBalance: user.flhBalance,
dailyMsgCount: user.dailyMsgCount,
experienceLevel: user.experienceLevel,
madhab: user.madhab,
coachPersona: user.coachPersona,
preferredName: user.preferredName,
coachingGoals: user.coachingGoals,
trialEndsAt: user.trialEndsAt,
createdAt: user.createdAt,
},
});
} catch (error) {
console.error("Profile update error:", error);
return NextResponse.json(
{ error: "Profile update failed" },
{ status: 500 }
);
}
}
+63
View File
@@ -0,0 +1,63 @@
import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/prisma";
import { signJWT } from "@/lib/auth";
export async function POST(req: NextRequest) {
try {
const { email, name, password } = await req.json();
if (!email || !name || !password) {
return NextResponse.json(
{ error: "Email, name, and password are required" },
{ status: 400 }
);
}
const existing = await prisma.user.findUnique({ where: { email } });
if (existing) {
return NextResponse.json(
{ error: "An account with this email already exists" },
{ status: 409 }
);
}
const passwordHash = await bcrypt.hash(password, 12);
const user = await prisma.user.create({
data: {
email,
name,
passwordHash,
flhBalance: 5000,
trialEndsAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
},
});
const token = await signJWT({
userId: user.id,
email: user.email,
isPremium: user.isPremium,
isPro: user.isPro,
});
return NextResponse.json({
token,
user: {
id: user.id,
email: user.email,
name: user.name,
isPremium: user.isPremium,
isPro: user.isPro,
flhBalance: user.flhBalance,
dailyMsgCount: user.dailyMsgCount,
},
});
} catch (error) {
console.error("Register error:", error);
return NextResponse.json(
{ error: "Registration failed. Please try again." },
{ status: 500 }
);
}
}
+53
View File
@@ -0,0 +1,53 @@
import { requireAuth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { getUserTier, DAILY_LIMITS } from "@/lib/tiers";
export const runtime = "nodejs";
export async function GET(request: Request) {
try {
const user = await requireAuth(request);
if (!user) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const dbUser = await prisma.user.findUnique({
where: { id: user.userId },
select: {
dailyMsgCount: true,
dailyMsgResetAt: true,
isPremium: true,
isPro: true,
},
});
if (!dbUser) {
return Response.json({ error: "User not found" }, { status: 404 });
}
const tier = getUserTier(dbUser.isPremium, dbUser.isPro);
const limits = DAILY_LIMITS[tier];
const now = new Date();
let count = dbUser.dailyMsgCount;
let resetAt = dbUser.dailyMsgResetAt;
// Reset if new day
if (!resetAt || now.getTime() - resetAt.getTime() > 24 * 60 * 60 * 1000) {
count = 0;
resetAt = now;
}
return Response.json({
count,
limit: limits.msgs === Infinity ? -1 : limits.msgs,
resetAt: resetAt?.toISOString() || now.toISOString(),
tier,
remaining:
limits.msgs === Infinity ? -1 : Math.max(0, limits.msgs - count),
});
} catch (error) {
console.error("Daily chat error:", error);
return Response.json({ error: "Internal server error" }, { status: 500 });
}
}
+44
View File
@@ -0,0 +1,44 @@
export const runtime = "nodejs";
const DEMO_CONVERSATION: { role: string; content: string; personaId: string }[] = [
{
role: "user",
content: "Assalamualaikum! I've been feeling really stressed about work lately.",
personaId: "nurbuddy",
},
{
role: "assistant",
content:
"Waalaikumsalam warahmatullahi wabarakatuh! 😊 I'm sorry to hear you're feeling stressed. Remember what Allah says: **\"Verily, with hardship comes ease\"** (Quran 94:6). \n\nHere are a few things that might help:\n• Take a deep breath and recite *SubhanAllah* 33 times\n• Make wudu — it's surprisingly calming!\n• Step outside and appreciate Allah's creation for a few minutes\n\nYou're not alone, and this too shall pass, insha'Allah. 💛",
personaId: "nurbuddy",
},
{
role: "user",
content: "That helps, thank you. Can you tell me more about patience in Islam?",
personaId: "hakim",
},
{
role: "assistant",
content:
"Ah, sabr (patience) — one of the most beautiful virtues in Islam. Let me share a story:\n\nProphet Ayyub (Job, peace be upon him) lost everything — wealth, children, and health. Yet he never stopped praising Allah. When asked how, he said: **\"Allah gave, and Allah has taken. Blessed be Allah in both.\"**\n\nAllah rewarded him tremendously: **\"And We gave him back his family and the like thereof with them — a mercy from Us and a reminder for those who worship\"** (Quran 38:43).\n\nYour patience through work stress is a form of worship too. Every moment you endure with grace is recorded. 🌱",
personaId: "hakim",
},
{
role: "user",
content: "SubhanAllah, that's really powerful. JazakAllah khair!",
personaId: "nurbuddy",
},
{
role: "assistant",
content:
"Wa iyyak! 😊 So glad it resonated with you. Remember, the Prophet ﷺ said: **\"He who does not thank people, does not thank Allah.\"** \n\nMay Allah grant you ease in your work and bless your patience. Come back anytime you need a friend to talk to! 🌟",
personaId: "nurbuddy",
},
];
export async function GET() {
return Response.json({
conversation: DEMO_CONVERSATION,
personas: ["nurbuddy", "hakim"],
});
}
@@ -0,0 +1,45 @@
import { requireAuth, type JWTContents } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export const runtime = "nodejs";
export async function GET(
request: Request,
{ params }: { params: Promise<{ userId: string }> }
) {
try {
const user = await requireAuth(request);
if (!user) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const { userId } = await params;
// Verify the requested userId matches the authenticated user
if (user.userId !== userId) {
return Response.json({ error: "Forbidden" }, { status: 403 });
}
const messages = await prisma.chatHistory.findMany({
where: { userId },
orderBy: { createdAt: "desc" },
take: 50,
});
// Return in chronological order
const ordered = messages.reverse();
return Response.json({
messages: ordered.map((m) => ({
id: m.id,
role: m.role,
content: m.content,
metadata: m.metadata ? JSON.parse(m.metadata) : null,
createdAt: m.createdAt.toISOString(),
})),
});
} catch (error) {
console.error("Chat history error:", error);
return Response.json({ error: "Internal server error" }, { status: 500 });
}
}
+158
View File
@@ -0,0 +1,158 @@
import { requireAuth, type JWTContents } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { getPersona } from "@/lib/personas";
import { getUserTier, DAILY_LIMITS } from "@/lib/tiers";
import { generateAIReply } from "@/lib/ai-server";
export const runtime = "nodejs";
export async function POST(request: Request) {
try {
const user = await requireAuth(request);
if (!user) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json();
const { messages, personaId } = body as {
messages?: { role: string; content: string }[];
personaId?: string;
};
if (!messages || !Array.isArray(messages) || messages.length === 0) {
return Response.json({ error: "messages array is required" }, { status: 400 });
}
if (!personaId || typeof personaId !== "string") {
return Response.json({ error: "personaId is required" }, { status: 400 });
}
// Verify persona exists
const persona = getPersona(personaId);
if (!persona) {
return Response.json({ error: "Invalid persona" }, { status: 400 });
}
// Get user from DB to check limits
const dbUser = await prisma.user.findUnique({
where: { id: user.userId },
});
if (!dbUser) {
return Response.json({ error: "User not found" }, { status: 404 });
}
const tier = getUserTier(dbUser.isPremium, dbUser.isPro);
const limits = DAILY_LIMITS[tier];
// Check persona access for this tier
if (!limits.personas.includes(personaId)) {
const tierName = tier.charAt(0).toUpperCase() + tier.slice(1);
return Response.json(
{
error: `This persona requires ${tier === "free" ? "Premium" : "Pro"} subscription`,
tier,
requiredTier: tier === "free" ? "premium" : "pro",
},
{ status: 403 }
);
}
// Check daily message limit
const now = new Date();
let dailyMsgCount = dbUser.dailyMsgCount;
let dailyMsgResetAt = dbUser.dailyMsgResetAt;
// Reset count if a new day
if (!dailyMsgResetAt || now.getTime() - dailyMsgResetAt.getTime() > 24 * 60 * 60 * 1000) {
dailyMsgCount = 0;
dailyMsgResetAt = now;
}
if (limits.msgs !== Infinity && dailyMsgCount >= limits.msgs) {
return Response.json(
{
error: "Daily message limit reached",
count: dailyMsgCount,
limit: limits.msgs,
resetAt: dailyMsgResetAt.toISOString(),
},
{ status: 429 }
);
}
// Determine the user's last message for context
const lastUserMessage = messages[messages.length - 1]?.content || "";
// Get recent history from DB for context (up to 10 recent messages)
const recentHistory = await prisma.chatHistory.findMany({
where: { userId: user.userId },
orderBy: { createdAt: "desc" },
take: 10,
});
const historyContext = recentHistory
.reverse()
.map((h) => ({ role: h.role, content: h.content }));
// Save user message(s) to ChatHistory
for (const msg of messages) {
if (msg.role === "user") {
await prisma.chatHistory.create({
data: {
userId: user.userId,
role: "user",
content: msg.content,
metadata: JSON.stringify({ personaId }),
},
});
}
}
// Generate AI response
const aiContent = generateAIReply(personaId, lastUserMessage, historyContext);
// Save AI response to ChatHistory
const aiMessage = await prisma.chatHistory.create({
data: {
userId: user.userId,
role: "assistant",
content: aiContent,
metadata: JSON.stringify({ personaId }),
},
});
// Update daily message count
const newCount = dailyMsgCount + messages.filter((m) => m.role === "user").length;
await prisma.user.update({
where: { id: user.userId },
data: {
dailyMsgCount: newCount,
dailyMsgResetAt: dailyMsgResetAt,
},
});
let updatedUser: any = { dailyMsgCount: newCount };
if (newCount >= limits.msgs - 2 && limits.msgs !== Infinity) {
updatedUser.limitWarning = true;
updatedUser.limitRemaining = limits.msgs - newCount;
}
return Response.json({
message: {
role: "assistant",
content: aiContent,
createdAt: aiMessage.createdAt.toISOString(),
id: aiMessage.id,
},
daily: {
count: newCount,
limit: limits.msgs,
resetAt: dailyMsgResetAt.toISOString(),
},
user: updatedUser,
});
} catch (error) {
console.error("Chat send error:", error);
return Response.json({ error: "Internal server error" }, { status: 500 });
}
}
+12
View File
@@ -0,0 +1,12 @@
export const runtime = "nodejs";
import { DAILY_VERSE } from "@/lib/ai";
export async function GET() {
const verse = DAILY_VERSE[Math.floor(Math.random() * DAILY_VERSE.length)];
return Response.json({
verse,
fetchedAt: new Date().toISOString(),
});
}
+85
View File
@@ -0,0 +1,85 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ listingId: string }> }
) {
try {
const auth = await requireAuth(request);
if (!auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { listingId } = await params;
const listing = await prisma.listing.findUnique({
where: { id: listingId },
select: {
id: true,
title: true,
fileType: true,
sellerId: true,
priceFlh: true,
status: true,
},
});
if (!listing) {
return NextResponse.json({ error: "Listing not found" }, { status: 404 });
}
// Check if user is the seller
if (listing.sellerId === auth.userId) {
return NextResponse.json({
file: {
id: listing.id,
title: listing.title,
fileType: listing.fileType || "unknown",
url: null,
message: "You are the seller. File delivery is handled after purchase confirmation.",
},
});
}
// Check if user has purchased this listing
const purchase = await prisma.purchase.findFirst({
where: {
listingId,
buyerId: auth.userId,
},
select: {
id: true,
amountFlh: true,
createdAt: true,
autoConfirmAt: true,
},
});
if (!purchase) {
return NextResponse.json(
{ error: "You have not purchased this listing" },
{ status: 403 }
);
}
return NextResponse.json({
file: {
id: listing.id,
title: listing.title,
fileType: listing.fileType || "unknown",
url: null, // Placeholder — file storage will be implemented in a future update
purchaseId: purchase.id,
purchasedAt: purchase.createdAt,
message: "File delivery system coming soon. Your purchase is confirmed.",
},
});
} catch (error) {
console.error("GET /api/files/[listingId] error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
+18
View File
@@ -0,0 +1,18 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function GET(_request: NextRequest) {
try {
const categories = await prisma.forumCategory.findMany({
orderBy: { order: "asc" },
});
return NextResponse.json({ categories });
} catch (error) {
console.error("GET /api/forum/categories error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
+115
View File
@@ -0,0 +1,115 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
import { getUserTier, FORUM_LIMITS } from "@/lib/tiers";
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const threadId = searchParams.get("threadId");
if (!threadId) {
return NextResponse.json(
{ error: "threadId query parameter is required" },
{ status: 400 }
);
}
// Verify thread exists
const thread = await prisma.forumThread.findUnique({
where: { id: threadId },
});
if (!thread) {
return NextResponse.json(
{ error: "Thread not found" },
{ status: 404 }
);
}
const posts = await prisma.forumPost.findMany({
where: { threadId },
include: {
author: {
select: { id: true, name: true, isPremium: true, isPro: true },
},
},
orderBy: { createdAt: "asc" },
});
return NextResponse.json({ posts });
} catch (error) {
console.error("GET /api/forum/posts error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
try {
const auth = await requireAuth(request);
if (!auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const user = await prisma.user.findUnique({ where: { id: auth.userId } });
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
const tier = getUserTier(user.isPremium, user.isPro);
const limits = FORUM_LIMITS[tier];
if (!limits.canPost) {
return NextResponse.json(
{ error: "Your tier does not support posting. Upgrade to Premium or Pro to participate in discussions." },
{ status: 403 }
);
}
const { content, threadId } = await request.json();
if (!content || !threadId) {
return NextResponse.json(
{ error: "Missing required fields: content, threadId" },
{ status: 400 }
);
}
// Verify thread exists
const thread = await prisma.forumThread.findUnique({
where: { id: threadId },
});
if (!thread) {
return NextResponse.json(
{ error: "Thread not found" },
{ status: 404 }
);
}
const post = await prisma.forumPost.create({
data: {
content,
threadId,
authorId: user.id,
shariahStatus: "pending",
},
include: {
author: {
select: { id: true, name: true, isPremium: true, isPro: true },
},
},
});
return NextResponse.json({ post }, { status: 201 });
} catch (error) {
console.error("POST /api/forum/posts error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
+118
View File
@@ -0,0 +1,118 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
import { getUserTier, FORUM_LIMITS } from "@/lib/tiers";
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const categoryId = searchParams.get("categoryId");
const where: Record<string, unknown> = {};
if (categoryId) {
where.categoryId = categoryId;
}
const threads = await prisma.forumThread.findMany({
where,
include: {
author: {
select: { id: true, name: true, isPremium: true, isPro: true },
},
category: {
select: { id: true, name: true, icon: true },
},
_count: {
select: { posts: true },
},
},
orderBy: [
{ pinned: "desc" },
{ createdAt: "desc" },
],
});
return NextResponse.json({ threads });
} catch (error) {
console.error("GET /api/forum/threads error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
try {
const auth = await requireAuth(request);
if (!auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const user = await prisma.user.findUnique({ where: { id: auth.userId } });
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
const tier = getUserTier(user.isPremium, user.isPro);
const limits = FORUM_LIMITS[tier];
if (!limits.canPost) {
return NextResponse.json(
{ error: "Your tier does not support posting. Upgrade to Premium or Pro to create threads." },
{ status: 403 }
);
}
const { title, content, categoryId } = await request.json();
if (!title || !content || !categoryId) {
return NextResponse.json(
{ error: "Missing required fields: title, content, categoryId" },
{ status: 400 }
);
}
// Verify category exists
const category = await prisma.forumCategory.findUnique({
where: { id: categoryId },
});
if (!category) {
return NextResponse.json(
{ error: "Invalid category" },
{ status: 400 }
);
}
const thread = await prisma.forumThread.create({
data: {
title,
content,
categoryId,
authorId: user.id,
shariahStatus: "pending",
},
include: {
author: {
select: { id: true, name: true, isPremium: true, isPro: true },
},
category: {
select: { id: true, name: true, icon: true },
},
_count: {
select: { posts: true },
},
},
});
return NextResponse.json({ thread }, { status: 201 });
} catch (error) {
console.error("POST /api/forum/threads error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
+129
View File
@@ -0,0 +1,129 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
// Mock place data lookup for enriching bookmarks
const MOCK_PLACES: Record<string, any> = {
"mosque-1": { id: "mosque-1", name: "Masjid Negara", address: "Jalan Perdana, Tasik Perdana, 50480 Kuala Lumpur", lat: 3.1422, lng: 101.6929, rating: 4.7, type: "mosque", halal_certified: true },
"mosque-2": { id: "mosque-2", name: "Masjid Jamek Sultan Abdul Samad", address: "Jalan Tun Perak, 50050 Kuala Lumpur", lat: 3.1492, lng: 101.6958, rating: 4.5, type: "mosque", halal_certified: true },
"mosque-3": { id: "mosque-3", name: "Masjid Wilayah Persekutuan", address: "Jalan Masjid, 50546 Kuala Lumpur", lat: 3.1723, lng: 101.6887, rating: 4.8, type: "mosque", halal_certified: true },
"mosque-4": { id: "mosque-4", name: "Masjid Putra", address: "Persiaran Persekutuan, Presint 1, 62502 Putrajaya", lat: 2.9374, lng: 101.6888, rating: 4.9, type: "mosque", halal_certified: true },
"mosque-5": { id: "mosque-5", name: "Masjid Zahir", address: "Jalan Sultan Badlishah, 05400 Alor Setar, Kedah", lat: 6.1227, lng: 100.3663, rating: 4.6, type: "mosque", halal_certified: true },
"rest-1": { id: "rest-1", name: "Nasi Kandar Pelita", address: "No. 17, Jalan Telawi 3, Bangsar Baru, 59100 Kuala Lumpur", lat: 3.1295, lng: 101.6708, rating: 4.2, type: "restaurant", halal_certified: true },
"rest-2": { id: "rest-2", name: "Restoran Ana Ikan Bakar Petai", address: "Jalan Cempaka, Kampung Datuk Keramat, 54000 Kuala Lumpur", lat: 3.1625, lng: 101.7319, rating: 4.3, type: "restaurant", halal_certified: true },
"rest-3": { id: "rest-3", name: "Satey Zainab", address: "Jalan Tun Razak, 50400 Kuala Lumpur", lat: 3.1547, lng: 101.7112, rating: 4.1, type: "restaurant", halal_certified: true },
"rest-4": { id: "rest-4", name: "Murni Discovery", address: "No. 8, Jalan SS2/75, SS2, 47300 Petaling Jaya", lat: 3.1187, lng: 101.6263, rating: 4.0, type: "restaurant", halal_certified: true },
"rest-5": { id: "rest-5", name: "Nazeer's Banana Leaf", address: "No. 6, Jalan Kamuning, Off Jalan Imbi, 55100 Kuala Lumpur", lat: 3.1432, lng: 101.7115, rating: 4.4, type: "restaurant", halal_certified: true },
};
export async function GET(request: NextRequest) {
try {
const auth = await requireAuth(request);
if (!auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const bookmarks = await prisma.halalBookmark.findMany({
where: { userId: auth.userId },
orderBy: { createdAt: "desc" },
});
// Enrich bookmarks with place info
const enriched = bookmarks.map((bm) => ({
...bm,
place: MOCK_PLACES[bm.itemId] ?? null,
}));
return NextResponse.json({ bookmarks: enriched });
} catch (error) {
console.error("GET /api/halal/bookmarks error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
try {
const auth = await requireAuth(request);
if (!auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { itemId, itemType, label } = await request.json();
if (!itemId || !itemType) {
return NextResponse.json(
{ error: "Missing required fields: itemId, itemType" },
{ status: 400 }
);
}
// Check for duplicate
const existing = await prisma.halalBookmark.findFirst({
where: { userId: auth.userId, itemId, itemType },
});
if (existing) {
return NextResponse.json(
{ error: "Bookmark already exists", bookmark: existing },
{ status: 409 }
);
}
const bookmark = await prisma.halalBookmark.create({
data: {
userId: auth.userId,
itemId,
itemType,
label: label ?? null,
},
});
return NextResponse.json({ bookmark }, { status: 201 });
} catch (error) {
console.error("POST /api/halal/bookmarks error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
export async function DELETE(request: NextRequest) {
try {
const auth = await requireAuth(request);
if (!auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
if (!id) {
return NextResponse.json(
{ error: "Missing bookmark id" },
{ status: 400 }
);
}
// Verify ownership
const bookmark = await prisma.halalBookmark.findFirst({
where: { id, userId: auth.userId },
});
if (!bookmark) {
return NextResponse.json(
{ error: "Bookmark not found" },
{ status: 404 }
);
}
await prisma.halalBookmark.delete({ where: { id } });
return NextResponse.json({ success: true });
} catch (error) {
console.error("DELETE /api/halal/bookmarks error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
+47
View File
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
const MOCK_PLACES = [
// Mosques
{ id: "mosque-1", name: "Masjid Negara", address: "Jalan Perdana, Tasik Perdana, 50480 Kuala Lumpur", lat: 3.1422, lng: 101.6929, rating: 4.7, type: "mosque", halal_certified: true },
{ id: "mosque-2", name: "Masjid Jamek Sultan Abdul Samad", address: "Jalan Tun Perak, 50050 Kuala Lumpur", lat: 3.1492, lng: 101.6958, rating: 4.5, type: "mosque", halal_certified: true },
{ id: "mosque-3", name: "Masjid Wilayah Persekutuan", address: "Jalan Masjid, 50546 Kuala Lumpur", lat: 3.1723, lng: 101.6887, rating: 4.8, type: "mosque", halal_certified: true },
{ id: "mosque-4", name: "Masjid Putra", address: "Persiaran Persekutuan, Presint 1, 62502 Putrajaya", lat: 2.9374, lng: 101.6888, rating: 4.9, type: "mosque", halal_certified: true },
{ id: "mosque-5", name: "Masjid Zahir", address: "Jalan Sultan Badlishah, 05400 Alor Setar, Kedah", lat: 6.1227, lng: 100.3663, rating: 4.6, type: "mosque", halal_certified: true },
// Restaurants
{ id: "rest-1", name: "Nasi Kandar Pelita", address: "No. 17, Jalan Telawi 3, Bangsar Baru, 59100 Kuala Lumpur", lat: 3.1295, lng: 101.6708, rating: 4.2, type: "restaurant", halal_certified: true },
{ id: "rest-2", name: "Restoran Ana Ikan Bakar Petai", address: "Jalan Cempaka, Kampung Datuk Keramat, 54000 Kuala Lumpur", lat: 3.1625, lng: 101.7319, rating: 4.3, type: "restaurant", halal_certified: true },
{ id: "rest-3", name: "Satey Zainab", address: "Jalan Tun Razak, 50400 Kuala Lumpur", lat: 3.1547, lng: 101.7112, rating: 4.1, type: "restaurant", halal_certified: true },
{ id: "rest-4", name: "Murni Discovery", address: "No. 8, Jalan SS2/75, SS2, 47300 Petaling Jaya", lat: 3.1187, lng: 101.6263, rating: 4.0, type: "restaurant", halal_certified: true },
{ id: "rest-5", name: "Nazeer's Banana Leaf", address: "No. 6, Jalan Kamuning, Off Jalan Imbi, 55100 Kuala Lumpur", lat: 3.1432, lng: 101.7115, rating: 4.4, type: "restaurant", halal_certified: true },
];
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const q = searchParams.get("q")?.toLowerCase();
const type = searchParams.get("type")?.toLowerCase();
let filtered = [...MOCK_PLACES];
if (type && ["mosque", "restaurant", "cafe"].includes(type)) {
filtered = filtered.filter((p) => p.type === type);
}
if (q) {
filtered = filtered.filter(
(p) =>
p.name.toLowerCase().includes(q) ||
p.address.toLowerCase().includes(q)
);
}
return NextResponse.json({ places: filtered });
} catch (error) {
console.error("GET /api/halal/places error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
+129
View File
@@ -0,0 +1,129 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
import { getUserTier, HALAL_LIMITS } from "@/lib/tiers";
export async function GET(request: NextRequest) {
try {
const auth = await requireAuth(request);
if (!auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const user = await prisma.user.findUnique({
where: { id: auth.userId },
include: { halalUsage: true },
});
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
const tier = getUserTier(user.isPremium, user.isPro);
const limits = HALAL_LIMITS[tier];
// If no usage record exists yet, return defaults
const usage = user.halalUsage;
const queriesUsed = usage?.queriesUsed ?? 0;
const queriesLimit = usage?.queriesLimit ?? limits.queriesPerDay;
const periodEnd = usage?.periodEnd?.toISOString() ?? null;
const remaining = Math.max(0, queriesLimit - queriesUsed);
return NextResponse.json({
queriesUsed,
queriesLimit,
remaining,
periodEnd,
tier,
scope: limits.scope,
});
} catch (error) {
console.error("GET /api/halal/usage error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
try {
const auth = await requireAuth(request);
if (!auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const user = await prisma.user.findUnique({
where: { id: auth.userId },
include: { halalUsage: true },
});
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
const tier = getUserTier(user.isPremium, user.isPro);
const limits = HALAL_LIMITS[tier];
// Get or create usage record
let usage = user.halalUsage;
// Check if period has expired — reset if so
const now = new Date();
if (usage && usage.periodEnd && new Date(usage.periodEnd) < now) {
usage = await prisma.halalUsage.update({
where: { userId: user.id },
data: {
queriesUsed: 0,
queriesLimit: limits.queriesPerDay,
periodEnd: new Date(now.getTime() + 24 * 60 * 60 * 1000),
},
});
}
if (!usage) {
usage = await prisma.halalUsage.create({
data: {
userId: user.id,
queriesUsed: 0,
queriesLimit: limits.queriesPerDay,
periodEnd: new Date(now.getTime() + 24 * 60 * 60 * 1000),
},
});
}
// Check if allowed
if (usage.queriesUsed >= usage.queriesLimit && limits.queriesPerDay !== Infinity) {
return NextResponse.json(
{
allowed: false,
remaining: 0,
queriesUsed: usage.queriesUsed,
queriesLimit: usage.queriesLimit,
error: "Daily query limit reached. Upgrade to Premium for unlimited queries.",
},
{ status: 429 }
);
}
// Increment usage
const updated = await prisma.halalUsage.update({
where: { userId: user.id },
data: { queriesUsed: { increment: 1 } },
});
const remaining = Math.max(0, updated.queriesLimit - updated.queriesUsed);
return NextResponse.json({
allowed: true,
remaining,
queriesUsed: updated.queriesUsed,
queriesLimit: updated.queriesLimit,
});
} catch (error) {
console.error("POST /api/halal/usage error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
+19
View File
@@ -0,0 +1,19 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function GET() {
let dbOk = false;
try {
await prisma.$queryRaw`SELECT 1`;
dbOk = true;
} catch {
dbOk = false;
}
return NextResponse.json({
status: "ok",
timestamp: new Date().toISOString(),
db: dbOk,
uptime: process.uptime(),
});
}
+98
View File
@@ -0,0 +1,98 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
export async function POST(request: NextRequest) {
try {
const auth = await requireAuth(request);
if (!auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const user = await prisma.user.findUnique({ where: { id: auth.userId } });
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
if (!user.isPro) {
return NextResponse.json(
{ error: "Only Pro members can feature listings. Upgrade to Pro to access this feature." },
{ status: 403 }
);
}
const { listingId } = await request.json();
if (!listingId) {
return NextResponse.json(
{ error: "Missing required field: listingId" },
{ status: 400 }
);
}
// Verify listing exists and belongs to user
const listing = await prisma.listing.findUnique({
where: { id: listingId },
});
if (!listing) {
return NextResponse.json({ error: "Listing not found" }, { status: 404 });
}
if (listing.sellerId !== user.id) {
return NextResponse.json(
{ error: "You can only feature your own listings" },
{ status: 403 }
);
}
if (listing.status !== "active") {
return NextResponse.json(
{ error: "Cannot feature a sold or inactive listing" },
{ status: 400 }
);
}
if (listing.featured) {
return NextResponse.json(
{ error: "Listing is already featured" },
{ status: 400 }
);
}
// Check FLH balance
const FEATURE_FEE = 100;
if (user.flhBalance < FEATURE_FEE) {
return NextResponse.json(
{ error: `Insufficient FLH balance. Featuring costs ${FEATURE_FEE} FLH. Your balance: ${user.flhBalance} FLH.` },
{ status: 400 }
);
}
// Deduct fee and set featured
const [updatedListing] = await prisma.$transaction([
prisma.listing.update({
where: { id: listingId },
data: {
featured: true,
featuredUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days
},
}),
prisma.user.update({
where: { id: user.id },
data: { flhBalance: { decrement: FEATURE_FEE } },
}),
]);
return NextResponse.json({
listing: updatedListing,
message: `Listing featured for 30 days. ${FEATURE_FEE} FLH deducted.`,
});
} catch (error) {
console.error("POST /api/marketplace/feature error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
+131
View File
@@ -0,0 +1,131 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
import { getUserTier, MARKETPLACE_LIMITS } from "@/lib/tiers";
const CATEGORIES = ["E-Books", "Courses", "Design", "Audio", "Video", "Software", "Other"];
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const category = searchParams.get("category");
const search = searchParams.get("search");
const where: any = { status: "active" };
if (category && CATEGORIES.includes(category)) {
where.category = category;
}
if (search) {
where.OR = [
{ title: { contains: search } },
{ description: { contains: search } },
];
}
const listings = await prisma.listing.findMany({
where,
include: {
seller: {
select: { id: true, name: true, isPremium: true, isPro: true },
},
},
orderBy: [
{ featured: "desc" },
{ createdAt: "desc" },
],
});
return NextResponse.json({ listings });
} catch (error) {
console.error("GET /api/marketplace/listings error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
try {
const auth = await requireAuth(request);
if (!auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const user = await prisma.user.findUnique({ where: { id: auth.userId } });
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
const tier = getUserTier(user.isPremium, user.isPro);
const limits = MARKETPLACE_LIMITS[tier];
if (!limits.canSell) {
return NextResponse.json(
{ error: "Your tier does not support selling. Upgrade to Premium or Pro to create listings." },
{ status: 403 }
);
}
const { title, description, category, priceFlh } = await request.json();
if (!title || !description || !category || priceFlh == null) {
return NextResponse.json(
{ error: "Missing required fields: title, description, category, priceFlh" },
{ status: 400 }
);
}
if (!CATEGORIES.includes(category)) {
return NextResponse.json(
{ error: `Invalid category. Must be one of: ${CATEGORIES.join(", ")}` },
{ status: 400 }
);
}
if (typeof priceFlh !== "number" || priceFlh < 1 || !Number.isInteger(priceFlh)) {
return NextResponse.json(
{ error: "priceFlh must be a positive integer" },
{ status: 400 }
);
}
// Check listing count limit
const activeListingsCount = await prisma.listing.count({
where: { sellerId: user.id, status: "active" },
});
if (activeListingsCount >= limits.maxListings) {
return NextResponse.json(
{ error: `You have reached the maximum of ${limits.maxListings} active listings for your tier.` },
{ status: 403 }
);
}
const listing = await prisma.listing.create({
data: {
title,
description,
category,
priceFlh,
sellerId: user.id,
status: "active",
},
include: {
seller: {
select: { id: true, name: true, isPremium: true, isPro: true },
},
},
});
return NextResponse.json({ listing }, { status: 201 });
} catch (error) {
console.error("POST /api/marketplace/listings error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
+128
View File
@@ -0,0 +1,128 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
import { getUserTier } from "@/lib/tiers";
export async function POST(request: NextRequest) {
try {
const auth = await requireAuth(request);
if (!auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const buyer = await prisma.user.findUnique({ where: { id: auth.userId } });
if (!buyer) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
const { listingId } = await request.json();
if (!listingId) {
return NextResponse.json(
{ error: "Missing required field: listingId" },
{ status: 400 }
);
}
// Get listing with seller info
const listing = await prisma.listing.findUnique({
where: { id: listingId },
include: {
seller: {
select: { id: true, isPro: true },
},
},
});
if (!listing) {
return NextResponse.json({ error: "Listing not found" }, { status: 404 });
}
if (listing.status !== "active") {
return NextResponse.json(
{ error: "This listing is no longer available" },
{ status: 400 }
);
}
if (listing.sellerId === buyer.id) {
return NextResponse.json(
{ error: "You cannot purchase your own listing" },
{ status: 400 }
);
}
// Check buyer balance
if (buyer.flhBalance < listing.priceFlh) {
return NextResponse.json(
{
error: `Insufficient FLH balance. You need ${listing.priceFlh} FLH but only have ${buyer.flhBalance} FLH.`,
},
{ status: 400 }
);
}
// Calculate platform fee
// Pro sellers pay 0% platform fee, free/premium pay 1.5%
const sellerTier = getUserTier(false, listing.seller.isPro);
const platformFeeRate = sellerTier === "pro" ? 0 : 0.015;
const platformFee = Math.round(listing.priceFlh * platformFeeRate);
const sellerPayout = listing.priceFlh - platformFee;
// Auto-confirm in 7 days
const autoConfirmAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
// Execute purchase in transaction
const [purchase] = await prisma.$transaction([
prisma.purchase.create({
data: {
listingId: listing.id,
buyerId: buyer.id,
sellerId: listing.sellerId,
amountFlh: listing.priceFlh,
platformFee,
sellerPayout,
autoConfirmAt,
},
include: {
listing: {
select: {
id: true,
title: true,
priceFlh: true,
},
},
},
}),
// Deduct from buyer
prisma.user.update({
where: { id: buyer.id },
data: { flhBalance: { decrement: listing.priceFlh } },
}),
// Add payout to seller
prisma.user.update({
where: { id: listing.sellerId },
data: { flhBalance: { increment: sellerPayout } },
}),
// Mark listing as sold
prisma.listing.update({
where: { id: listing.id },
data: { status: "sold" },
}),
]);
return NextResponse.json(
{
purchase,
message: `Successfully purchased "${listing.title}" for ${listing.priceFlh} FLH. Platform fee: ${platformFee} FLH. Seller receives: ${sellerPayout} FLH.`,
},
{ status: 201 }
);
} catch (error) {
console.error("POST /api/marketplace/purchase error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json({
status: "online",
model: "nurbuddy-v1",
personas_available: 6,
uptime: process.uptime(),
});
}
+48
View File
@@ -0,0 +1,48 @@
import { NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/prisma";
export async function POST() {
try {
const existing = await prisma.user.findUnique({
where: { email: "demo@falah.app" },
});
if (existing) {
return NextResponse.json(
{ message: "Demo user already exists", userId: existing.id },
{ status: 200 }
);
}
const demoPasswordHash = await bcrypt.hash("password123", 12);
const demoUser = await prisma.user.create({
data: {
email: "demo@falah.app",
name: "Demo User",
passwordHash: demoPasswordHash,
flhBalance: 10000,
isPremium: true,
trialEndsAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
preferredName: "Demo",
coachingGoals: "Learn more about Islam and improve my daily prayers",
},
});
return NextResponse.json(
{
message: "Demo user created successfully",
userId: demoUser.id,
credentials: { email: "demo@falah.app", password: "password123" },
},
{ status: 201 }
);
} catch (error) {
console.error("Seed error:", error);
return NextResponse.json(
{ error: "Seeding failed. Please try again." },
{ status: 500 }
);
}
}
+54
View File
@@ -0,0 +1,54 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ sellerId: string }> }
) {
try {
const { sellerId } = await params;
const seller = await prisma.user.findUnique({
where: { id: sellerId },
select: {
id: true,
name: true,
isPremium: true,
isPro: true,
createdAt: true,
},
});
if (!seller) {
return NextResponse.json({ error: "Seller not found" }, { status: 404 });
}
const listings = await prisma.listing.findMany({
where: {
sellerId,
status: "active",
},
select: {
id: true,
title: true,
description: true,
category: true,
priceFlh: true,
featured: true,
createdAt: true,
},
orderBy: [
{ featured: "desc" },
{ createdAt: "desc" },
],
});
return NextResponse.json({ seller, listings });
} catch (error) {
console.error("GET /api/seller/[sellerId] error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
@@ -0,0 +1,108 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
import { PREMIUM_PRICES } from "@/lib/tiers";
export async function POST(req: NextRequest) {
const jwtPayload = await requireAuth(req);
if (!jwtPayload) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const body = await req.json();
const { priceId } = body;
if (!priceId) {
return NextResponse.json(
{ error: "priceId is required" },
{ status: 400 }
);
}
// Determine which tier this priceId maps to
let tier: "premium" | "pro" | null = null;
for (const [key, val] of Object.entries(PREMIUM_PRICES)) {
if (val.priceId === priceId) {
tier = key as "premium" | "pro";
break;
}
}
if (!tier) {
return NextResponse.json(
{ error: "Invalid priceId" },
{ status: 400 }
);
}
// Check if user already has this tier (or higher)
const user = await prisma.user.findUnique({
where: { id: jwtPayload.userId },
});
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
if (tier === "pro" && user.isPro) {
return NextResponse.json(
{ error: "You are already a Pro member" },
{ status: 400 }
);
}
if (tier === "premium" && (user.isPremium || user.isPro)) {
return NextResponse.json(
{ error: "You already have Premium or higher" },
{ status: 400 }
);
}
// In production, this would create a Polar.sh checkout session.
// For development, we mock the flow by marking the user as premium/pro.
const useMock = process.env.MOCK_PAYMENTS !== "false";
if (useMock) {
const trialEndsAt = new Date();
trialEndsAt.setDate(trialEndsAt.getDate() + 7);
const updateData: Record<string, unknown> = {
trialEndsAt,
};
if (tier === "pro") {
updateData.isPro = true;
} else {
updateData.isPremium = true;
}
await prisma.user.update({
where: { id: jwtPayload.userId },
data: updateData,
});
return NextResponse.json({
success: true,
url: `/upgrade?upgrade=success&tier=${tier}`,
mock: true,
tier,
});
}
// Production path — would create a real Polar checkout session
// const polarSession = await createPolarCheckout({ priceId, userId: jwtPayload.userId });
return NextResponse.json({
success: true,
url: `/upgrade?upgrade=success&tier=${tier}`,
tier,
});
} catch (error) {
console.error("Upgrade checkout error:", error);
return NextResponse.json(
{ error: "Failed to create checkout" },
{ status: 500 }
);
}
}
+104
View File
@@ -0,0 +1,104 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
export async function GET(req: NextRequest) {
const jwtPayload = await requireAuth(req);
if (!jwtPayload) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const user = await prisma.user.findUnique({
where: { id: jwtPayload.userId },
include: {
cashouts: {
orderBy: { createdAt: "desc" },
take: 50,
},
},
});
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
return NextResponse.json({
balance: user.flhBalance,
cashoutHistory: user.cashouts.map((c) => ({
id: c.id,
amountFlh: c.amountFlh,
fiatAmount: c.fiatAmount,
status: c.status,
createdAt: c.createdAt,
})),
});
}
export async function POST(req: NextRequest) {
const jwtPayload = await requireAuth(req);
if (!jwtPayload) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const body = await req.json();
const { amountFlh } = body;
if (!amountFlh || typeof amountFlh !== "number" || amountFlh < 1000) {
return NextResponse.json(
{ error: "Minimum cashout is 1,000 FLH" },
{ status: 400 }
);
}
const user = await prisma.user.findUnique({
where: { id: jwtPayload.userId },
});
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
if (user.flhBalance < amountFlh) {
return NextResponse.json(
{ error: "Insufficient FLH balance" },
{ status: 400 }
);
}
// Fiat conversion: 1000 FLH = $1.00
const fiatAmount = Math.round((amountFlh / 1000) * 100) / 100;
const cashout = await prisma.cashoutRequest.create({
data: {
userId: user.id,
amountFlh,
fiatAmount,
status: "pending",
},
});
// Deduct balance immediately
await prisma.user.update({
where: { id: user.id },
data: { flhBalance: { decrement: amountFlh } },
});
return NextResponse.json({
success: true,
cashout: {
id: cashout.id,
amountFlh: cashout.amountFlh,
fiatAmount: cashout.fiatAmount,
status: cashout.status,
createdAt: cashout.createdAt,
},
});
} catch (error) {
console.error("Cashout error:", error);
return NextResponse.json(
{ error: "Cashout request failed" },
{ status: 500 }
);
}
}
@@ -0,0 +1,70 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
const TOP_UP_AMOUNTS = {
500: { usd: 4.99 },
1100: { usd: 9.99 },
3000: { usd: 24.99 },
} as const;
export async function POST(req: NextRequest) {
const jwtPayload = await requireAuth(req);
if (!jwtPayload) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const body = await req.json();
const { amount } = body;
if (!amount || !(amount in TOP_UP_AMOUNTS)) {
return NextResponse.json(
{ error: "Invalid amount. Choose 500, 1100, or 3000 FLH." },
{ status: 400 }
);
}
const pricing = TOP_UP_AMOUNTS[amount as keyof typeof TOP_UP_AMOUNTS];
// In production, this would create a Polar.sh checkout session.
// For development, we mock the flow by directly adding FLH balance.
// Replace with actual Polar API integration when ready.
const useMock = process.env.MOCK_PAYMENTS !== "false";
if (useMock) {
await prisma.user.update({
where: { id: jwtPayload.userId },
data: { flhBalance: { increment: amount } },
});
return NextResponse.json({
success: true,
url: "/wallet?topup=success",
mock: true,
amount,
amountUsd: pricing.usd,
});
}
// Production path — Polar checkout creation
// const polarSession = await createPolarCheckout({
// amount,
// customerId: user.stripeCustomerId,
// metadata: { userId: jwtPayload.userId, type: "top-up", amount },
// });
return NextResponse.json({
success: true,
url: "/wallet?topup=success", // would be polarSession.url
amount,
amountUsd: pricing.usd,
});
} catch (error) {
console.error("Top-up checkout error:", error);
return NextResponse.json(
{ error: "Failed to create checkout" },
{ status: 500 }
);
}
}
+118
View File
@@ -0,0 +1,118 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
/**
* Polar.sh webhook handler.
*
* In production, verify the webhook signature using Polar's webhook secret.
* For development, we accept a direct ?userId=&tier= fallback for testing.
*/
export async function POST(req: NextRequest) {
try {
// Check for direct fallback query params (dev/testing)
const url = new URL(req.url);
const directUserId = url.searchParams.get("userId");
const directTier = url.searchParams.get("tier");
if (directUserId && directTier) {
return handleDirectUpgrade(directUserId, directTier);
}
// Production path: Parse Polar webhook event
const body = await req.json();
const event = body.type || body.event;
if (!event) {
return NextResponse.json(
{ error: "Missing event type" },
{ status: 400 }
);
}
// Handle checkout.completed event
if (
event === "checkout.completed" ||
event === "checkout.updated"
) {
const checkout = body.data?.checkout || body.data;
if (!checkout) {
return NextResponse.json(
{ error: "Missing checkout data" },
{ status: 400 }
);
}
const userId = checkout.metadata?.userId;
const priceId = checkout.product?.priceId ||
checkout.productPrice?.id ||
checkout.metadata?.priceId;
if (!userId) {
return NextResponse.json(
{ error: "Missing userId in metadata" },
{ status: 400 }
);
}
// Determine tier from priceId or product metadata
let tier: "premium" | "pro" = "premium";
if (priceId?.includes("pro") || checkout.metadata?.tier === "pro") {
tier = "pro";
}
return applyUpgrade(userId, tier);
}
// Acknowledge other events silently
return NextResponse.json({ received: true });
} catch (error) {
console.error("Polar webhook error:", error);
return NextResponse.json(
{ error: "Webhook processing failed" },
{ status: 500 }
);
}
}
async function handleDirectUpgrade(userId: string, tier: string) {
if (tier !== "premium" && tier !== "pro") {
return NextResponse.json(
{ error: "Invalid tier. Must be 'premium' or 'pro'." },
{ status: 400 }
);
}
return applyUpgrade(userId, tier as "premium" | "pro");
}
async function applyUpgrade(userId: string, tier: "premium" | "pro") {
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
const trialEndsAt = new Date();
trialEndsAt.setDate(trialEndsAt.getDate() + 7);
const updateData: Record<string, unknown> = {
trialEndsAt,
};
if (tier === "pro") {
updateData.isPro = true;
updateData.isPremium = false; // Pro supersedes Premium
} else {
updateData.isPremium = true;
}
await prisma.user.update({
where: { id: userId },
data: updateData,
});
return NextResponse.json({
success: true,
tier,
trialEndsAt,
});
}