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