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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useAuth } from "@/lib/AuthContext";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Crown,
|
||||
Star,
|
||||
Clock,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Loader2,
|
||||
Lock,
|
||||
Send,
|
||||
ShieldCheck,
|
||||
Flag,
|
||||
} from "lucide-react";
|
||||
|
||||
interface AuthorInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
isPremium: boolean;
|
||||
isPro: boolean;
|
||||
}
|
||||
|
||||
interface ThreadDetail {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
categoryId: string;
|
||||
pinned: boolean;
|
||||
shariahStatus: "pending" | "approved" | "rejected";
|
||||
shariahFlags?: string;
|
||||
createdAt: string;
|
||||
author: AuthorInfo;
|
||||
category: {
|
||||
id: string;
|
||||
name: string;
|
||||
icon?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface Post {
|
||||
id: string;
|
||||
content: string;
|
||||
threadId: string;
|
||||
shariahStatus: "pending" | "approved" | "rejected";
|
||||
shariahFlags?: string;
|
||||
createdAt: string;
|
||||
author: AuthorInfo;
|
||||
}
|
||||
|
||||
const SHARIAH_LABELS: Record<string, { label: string; color: string; icon: any }> = {
|
||||
pending: { label: "Pending Review", color: "text-amber-400", icon: Clock },
|
||||
approved: { label: "Shariah Compliant", color: "text-emerald-400", icon: ShieldCheck },
|
||||
rejected: { label: "Flagged", color: "text-red-400", icon: Flag },
|
||||
};
|
||||
|
||||
export default function ThreadDetailPage() {
|
||||
const { user, token, loading } = useAuth();
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const threadId = params.threadId as string;
|
||||
|
||||
const [thread, setThread] = useState<ThreadDetail | null>(null);
|
||||
const [posts, setPosts] = useState<Post[]>([]);
|
||||
const [loadingData, setLoadingData] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Reply form
|
||||
const [replyContent, setReplyContent] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [replyError, setReplyError] = useState<string | null>(null);
|
||||
|
||||
const canPost = user?.isPremium || user?.isPro;
|
||||
|
||||
const fetchThread = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/forum/threads`);
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
const found = data.threads.find((t: ThreadDetail) => t.id === threadId);
|
||||
if (found) {
|
||||
setThread(found);
|
||||
} else {
|
||||
// Try fetching thread detail individually (use threads endpoint with the thread)
|
||||
// Since we don't have a single-thread endpoint, we'll get it from the list
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [threadId]);
|
||||
|
||||
const fetchPosts = useCallback(async () => {
|
||||
setLoadingData(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/forum/posts?threadId=${threadId}`);
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setPosts(data.posts);
|
||||
} else {
|
||||
setError(data.error || "Failed to load posts");
|
||||
}
|
||||
} catch {
|
||||
setError("Network error. Please try again.");
|
||||
} finally {
|
||||
setLoadingData(false);
|
||||
}
|
||||
}, [threadId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !token) {
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
}, [loading, token, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (token && threadId) {
|
||||
fetchThread();
|
||||
fetchPosts();
|
||||
}
|
||||
}, [token, threadId, fetchThread, fetchPosts]);
|
||||
|
||||
const handleReply = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!replyContent.trim()) return;
|
||||
|
||||
setReplyError(null);
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/forum/posts", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
content: replyContent.trim(),
|
||||
threadId,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setReplyContent("");
|
||||
fetchPosts();
|
||||
} else {
|
||||
setReplyError(data.error || "Failed to post reply");
|
||||
}
|
||||
} catch {
|
||||
setReplyError("Network error. Please try again.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
const now = Date.now();
|
||||
const then = new Date(dateStr).getTime();
|
||||
const diffSec = Math.floor((now - then) / 1000);
|
||||
if (diffSec < 60) return "just now";
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
if (diffMin < 60) return `${diffMin}m ago`;
|
||||
const diffHr = Math.floor(diffMin / 60);
|
||||
if (diffHr < 24) return `${diffHr}h ago`;
|
||||
const diffDay = Math.floor(diffHr / 24);
|
||||
if (diffDay < 7) return `${diffDay}d ago`;
|
||||
return new Date(dateStr).toLocaleDateString();
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-dvh bg-[#0a0a0f] flex items-center justify-center">
|
||||
<div className="w-8 h-8 rounded-full border-2 border-[#D4AF37]/30 border-t-[#D4AF37] animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh bg-[#0a0a0f] pb-28">
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 z-20 bg-[#0a0a0f]/90 backdrop-blur-lg border-b border-gray-800/40">
|
||||
<div className="flex items-center gap-3 px-4 h-12">
|
||||
<button
|
||||
onClick={() => router.push("/forum")}
|
||||
className="w-8 h-8 rounded-full bg-gray-800/60 flex items-center justify-center active:bg-gray-700/60 transition"
|
||||
>
|
||||
<ArrowLeft size={16} className="text-gray-400" />
|
||||
</button>
|
||||
<h1 className="text-sm font-semibold text-white truncate">
|
||||
{thread ? thread.title : "Thread"}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loadingData ? (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<Loader2 size={32} className="text-[#D4AF37] animate-spin mb-3" />
|
||||
<p className="text-sm text-gray-500">Loading thread...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="mx-4 mt-4 bg-red-900/10 border border-red-800/30 rounded-2xl p-6 text-center">
|
||||
<AlertTriangle size={32} className="mx-auto text-red-400 mb-2" />
|
||||
<p className="text-sm text-red-300">{error}</p>
|
||||
<button
|
||||
onClick={() => { fetchThread(); fetchPosts(); }}
|
||||
className="mt-3 text-xs text-[#D4AF37] underline"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
) : !thread ? (
|
||||
<div className="mx-4 mt-4 bg-gray-900/40 border border-gray-800/60 rounded-2xl p-6 text-center">
|
||||
<p className="text-sm text-gray-400">Thread not found</p>
|
||||
<button
|
||||
onClick={() => router.push("/forum")}
|
||||
className="mt-3 text-xs text-[#D4AF37] underline"
|
||||
>
|
||||
Back to forum
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Original thread post */}
|
||||
<div className="px-4 pt-4 pb-2">
|
||||
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-4">
|
||||
{/* Author + category */}
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-[#D4AF37]/20 to-gray-800/60 flex items-center justify-center text-xs font-bold text-[#D4AF37]">
|
||||
{thread.author.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-semibold text-white">
|
||||
{thread.author.name}
|
||||
</span>
|
||||
{thread.author.isPro && (
|
||||
<Crown size={12} className="text-purple-400" />
|
||||
)}
|
||||
{thread.author.isPremium && !thread.author.isPro && (
|
||||
<Star size={12} className="text-[#D4AF37]" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-[10px] text-gray-600">
|
||||
<span className="text-gray-500">{thread.category.name}</span>
|
||||
<span>·</span>
|
||||
<span>{timeAgo(thread.createdAt)}</span>
|
||||
{/* Shariah compliance */}
|
||||
<span>·</span>
|
||||
<div className={`flex items-center gap-0.5 ${SHARIAH_LABELS[thread.shariahStatus].color}`}>
|
||||
{(() => {
|
||||
const Icon = SHARIAH_LABELS[thread.shariahStatus].icon;
|
||||
return <Icon size={9} />;
|
||||
})()}
|
||||
<span className="text-[9px]">{SHARIAH_LABELS[thread.shariahStatus].label}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title & content */}
|
||||
<h2 className="text-base font-bold text-white mb-2">{thread.title}</h2>
|
||||
<p className="text-sm text-gray-300 leading-relaxed whitespace-pre-wrap">
|
||||
{thread.content}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Posts list */}
|
||||
<div className="px-4 space-y-2.5">
|
||||
{posts.length > 0 && (
|
||||
<div className="flex items-center gap-2 px-1 py-2">
|
||||
<MessageCircle size={14} className="text-gray-500" />
|
||||
<span className="text-xs font-medium text-gray-500">
|
||||
{posts.length} {posts.length === 1 ? "Reply" : "Replies"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{posts.map((post) => {
|
||||
const shariahInfo = SHARIAH_LABELS[post.shariahStatus];
|
||||
const ShariahIcon = shariahInfo.icon;
|
||||
return (
|
||||
<div
|
||||
key={post.id}
|
||||
className="bg-[#111118] border border-gray-800/60 rounded-2xl p-4 animate-fade-in"
|
||||
>
|
||||
{/* Author info */}
|
||||
<div className="flex items-center gap-2 mb-2.5">
|
||||
<div className="w-7 h-7 rounded-full bg-gradient-to-br from-gray-700 to-gray-800 flex items-center justify-center text-[10px] font-bold text-gray-300">
|
||||
{post.author.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs font-semibold text-white">
|
||||
{post.author.name}
|
||||
</span>
|
||||
{post.author.isPro && (
|
||||
<Crown size={10} className="text-purple-400" />
|
||||
)}
|
||||
{post.author.isPremium && !post.author.isPro && (
|
||||
<Star size={10} className="text-[#D4AF37]" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-[10px] text-gray-600">
|
||||
<Clock size={9} />
|
||||
<span>{timeAgo(post.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<p className="text-sm text-gray-300 leading-relaxed whitespace-pre-wrap mb-2">
|
||||
{post.content}
|
||||
</p>
|
||||
|
||||
{/* Shariah compliance label */}
|
||||
<div className={`flex items-center gap-0.5 ${shariahInfo.color}`}>
|
||||
<ShariahIcon size={9} />
|
||||
<span className="text-[9px]">{shariahInfo.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Reply form — premium gated for free users */}
|
||||
<div className="px-4 mt-4">
|
||||
{!canPost ? (
|
||||
<div className="bg-gradient-to-r from-amber-900/15 to-gray-900/50 border border-amber-800/20 rounded-2xl p-4 text-center">
|
||||
<Lock size={20} className="mx-auto text-[#D4AF37] mb-2" />
|
||||
<h3 className="text-sm font-semibold text-white mb-1">Premium Feature</h3>
|
||||
<p className="text-xs text-gray-400 mb-3 max-w-xs mx-auto">
|
||||
Upgrade to Premium or Pro to join the discussion and reply to threads.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.push("/profile")}
|
||||
className="inline-flex items-center gap-1.5 bg-[#D4AF37]/15 border border-[#D4AF37]/30 rounded-xl px-4 py-2 text-xs font-medium text-[#D4AF37] active:bg-[#D4AF37]/25 transition"
|
||||
>
|
||||
<Crown size={12} />
|
||||
Upgrade Now
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleReply} className="space-y-3">
|
||||
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl overflow-hidden">
|
||||
<textarea
|
||||
value={replyContent}
|
||||
onChange={(e) => setReplyContent(e.target.value)}
|
||||
placeholder="Write your reply..."
|
||||
maxLength={5000}
|
||||
rows={3}
|
||||
className="w-full bg-transparent px-4 py-3 text-sm text-white placeholder-gray-600 focus:outline-none resize-none"
|
||||
/>
|
||||
<div className="flex items-center justify-between px-4 pb-3">
|
||||
<span className="text-[10px] text-gray-600">
|
||||
{replyContent.length}/5000
|
||||
</span>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || !replyContent.trim()}
|
||||
className="flex items-center gap-1.5 bg-[#D4AF37] text-[#0a0a0f] rounded-xl px-4 py-2 text-xs font-semibold transition active:bg-[#c5a233] disabled:opacity-50"
|
||||
>
|
||||
{submitting ? (
|
||||
<>
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
Posting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send size={12} />
|
||||
Reply
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{replyError && (
|
||||
<div className="bg-red-900/10 border border-red-800/30 rounded-xl p-3">
|
||||
<p className="text-xs text-red-300">{replyError}</p>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageCircle(props: any) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={props.size || 24}
|
||||
height={props.size || 24}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
{...props}
|
||||
>
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,560 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useAuth } from "@/lib/AuthContext";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
MessageSquare,
|
||||
Search,
|
||||
Plus,
|
||||
X,
|
||||
Crown,
|
||||
Star,
|
||||
Pin,
|
||||
Clock,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Loader2,
|
||||
Lock,
|
||||
MessageCircle,
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
|
||||
interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
interface Thread {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
categoryId: string;
|
||||
pinned: boolean;
|
||||
shariahStatus: "pending" | "approved" | "rejected";
|
||||
shariahFlags?: string;
|
||||
createdAt: string;
|
||||
author: {
|
||||
id: string;
|
||||
name: string;
|
||||
isPremium: boolean;
|
||||
isPro: boolean;
|
||||
};
|
||||
category: {
|
||||
id: string;
|
||||
name: string;
|
||||
icon?: string;
|
||||
};
|
||||
_count: {
|
||||
posts: number;
|
||||
};
|
||||
}
|
||||
|
||||
const SHARIAH_LABELS: Record<string, { label: string; color: string; icon: any }> = {
|
||||
pending: { label: "Pending Review", color: "text-amber-400", icon: Clock },
|
||||
approved: { label: "Shariah Compliant", color: "text-emerald-400", icon: CheckCircle },
|
||||
rejected: { label: "Flagged", color: "text-red-400", icon: AlertTriangle },
|
||||
};
|
||||
|
||||
export default function ForumPage() {
|
||||
const { user, token, loading } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [threads, setThreads] = useState<Thread[]>([]);
|
||||
const [loadingData, setLoadingData] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Filters
|
||||
const [activeCategory, setActiveCategory] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
// Create thread modal
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [createForm, setCreateForm] = useState({
|
||||
title: "",
|
||||
content: "",
|
||||
categoryId: "",
|
||||
});
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
|
||||
const canPost = user?.isPremium || user?.isPro;
|
||||
|
||||
const fetchCategories = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/forum/categories");
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setCategories(data.categories);
|
||||
// Auto-select first category in modal
|
||||
if (data.categories.length > 0) {
|
||||
setCreateForm((f) => ({
|
||||
...f,
|
||||
categoryId: data.categories[0].id,
|
||||
}));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchThreads = useCallback(async () => {
|
||||
setLoadingData(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (activeCategory) params.set("categoryId", activeCategory);
|
||||
if (searchQuery.trim()) params.set("search", searchQuery.trim());
|
||||
|
||||
const res = await fetch(`/api/forum/threads?${params.toString()}`);
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setThreads(data.threads);
|
||||
} else {
|
||||
setError(data.error || "Failed to load threads");
|
||||
}
|
||||
} catch {
|
||||
setError("Network error. Please try again.");
|
||||
} finally {
|
||||
setLoadingData(false);
|
||||
}
|
||||
}, [activeCategory, searchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !token) {
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
}, [loading, token, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
fetchCategories();
|
||||
}
|
||||
}, [token, fetchCategories]);
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
fetchThreads();
|
||||
}
|
||||
}, [token, fetchThreads]);
|
||||
|
||||
// Debounce search
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
if (token) fetchThreads();
|
||||
}, 400);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery]);
|
||||
|
||||
const handleCreateThread = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setCreateError(null);
|
||||
setCreating(true);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/forum/threads", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: createForm.title,
|
||||
content: createForm.content,
|
||||
categoryId: createForm.categoryId,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setShowCreateModal(false);
|
||||
setCreateForm({ title: "", content: "", categoryId: categories[0]?.id || "" });
|
||||
fetchThreads();
|
||||
} else {
|
||||
setCreateError(data.error || "Failed to create thread");
|
||||
}
|
||||
} catch {
|
||||
setCreateError("Network error. Please try again.");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
const now = Date.now();
|
||||
const then = new Date(dateStr).getTime();
|
||||
const diffSec = Math.floor((now - then) / 1000);
|
||||
if (diffSec < 60) return "just now";
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
if (diffMin < 60) return `${diffMin}m ago`;
|
||||
const diffHr = Math.floor(diffMin / 60);
|
||||
if (diffHr < 24) return `${diffHr}h ago`;
|
||||
const diffDay = Math.floor(diffHr / 24);
|
||||
if (diffDay < 7) return `${diffDay}d ago`;
|
||||
return new Date(dateStr).toLocaleDateString();
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-dvh bg-[#0a0a0f] flex items-center justify-center">
|
||||
<div className="w-8 h-8 rounded-full border-2 border-[#D4AF37]/30 border-t-[#D4AF37] animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh bg-[#0a0a0f] pb-28">
|
||||
{/* Header */}
|
||||
<div className="px-4 pt-6 pb-4">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-9 h-9 rounded-xl bg-[#D4AF37]/15 flex items-center justify-center">
|
||||
<MessageSquare size={18} className="text-[#D4AF37]" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-white">Forum</h1>
|
||||
<p className="text-[10px] text-gray-500">Community Discussions</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{canPost && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setCreateForm({
|
||||
title: "",
|
||||
content: "",
|
||||
categoryId: categories[0]?.id || "",
|
||||
});
|
||||
setCreateError(null);
|
||||
setShowCreateModal(true);
|
||||
}}
|
||||
className="flex items-center gap-1.5 bg-[#D4AF37]/15 border border-[#D4AF37]/30 rounded-xl px-3 py-2 active:bg-[#D4AF37]/25 transition"
|
||||
>
|
||||
<Plus size={14} className="text-[#D4AF37]" />
|
||||
<span className="text-xs font-medium text-[#D4AF37]">New Thread</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Premium gating banner for free users */}
|
||||
{!canPost && (
|
||||
<div className="mx-4 mb-3 bg-gradient-to-r from-amber-900/15 to-gray-900/50 border border-amber-800/20 rounded-xl px-3 py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Lock size={14} className="text-[#D4AF37] shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="text-[11px] text-amber-300/90">
|
||||
<span className="font-semibold">Free</span> members can browse.{" "}
|
||||
<span className="text-[#D4AF37] font-semibold">Premium</span> or{" "}
|
||||
<span className="text-purple-400 font-semibold">Pro</span> required to post.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => router.push("/profile")}
|
||||
className="text-[10px] font-semibold text-[#D4AF37] bg-[#D4AF37]/15 border border-[#D4AF37]/30 rounded-lg px-2.5 py-1.5 active:bg-[#D4AF37]/25 transition"
|
||||
>
|
||||
Upgrade
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search bar */}
|
||||
<div className="px-4 mb-3">
|
||||
<div className="relative">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search threads..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full bg-[#111118] border border-gray-800/60 rounded-xl pl-10 pr-4 py-2.5 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/40 transition"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category pills */}
|
||||
<div className="px-4 mb-4 overflow-x-auto">
|
||||
<div className="flex gap-2 pb-1" style={{ minWidth: "max-content" }}>
|
||||
<button
|
||||
onClick={() => setActiveCategory(null)}
|
||||
className={`flex items-center gap-1.5 px-3.5 py-2 rounded-xl text-xs font-medium whitespace-nowrap transition-all ${
|
||||
activeCategory === null
|
||||
? "bg-[#D4AF37]/20 text-[#D4AF37] border border-[#D4AF37]/30"
|
||||
: "bg-[#111118] text-gray-500 border border-gray-800/60 active:bg-gray-800/60"
|
||||
}`}
|
||||
>
|
||||
<MessageSquare size={13} />
|
||||
All
|
||||
</button>
|
||||
{categories.map((cat) => {
|
||||
const isActive = activeCategory === cat.id;
|
||||
return (
|
||||
<button
|
||||
key={cat.id}
|
||||
onClick={() => setActiveCategory(cat.id)}
|
||||
className={`flex items-center gap-1.5 px-3.5 py-2 rounded-xl text-xs font-medium whitespace-nowrap transition-all ${
|
||||
isActive
|
||||
? "bg-[#D4AF37]/20 text-[#D4AF37] border border-[#D4AF37]/30"
|
||||
: "bg-[#111118] text-gray-500 border border-gray-800/60 active:bg-gray-800/60"
|
||||
}`}
|
||||
>
|
||||
{cat.icon || <MessageCircle size={13} />}
|
||||
{cat.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Threads list */}
|
||||
<div className="px-4">
|
||||
{loadingData ? (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<Loader2 size={32} className="text-[#D4AF37] animate-spin mb-3" />
|
||||
<p className="text-sm text-gray-500">Loading threads...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="bg-red-900/10 border border-red-800/30 rounded-2xl p-6 text-center">
|
||||
<AlertTriangle size={32} className="mx-auto text-red-400 mb-2" />
|
||||
<p className="text-sm text-red-300">{error}</p>
|
||||
<button
|
||||
onClick={fetchThreads}
|
||||
className="mt-3 text-xs text-[#D4AF37] underline"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
) : threads.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<div className="w-16 h-16 rounded-2xl bg-gray-800/40 flex items-center justify-center mb-4">
|
||||
<MessageSquare size={28} className="text-gray-600" />
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-gray-400 mb-1">
|
||||
{searchQuery || activeCategory
|
||||
? "No threads found"
|
||||
: "No discussions yet"}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-600 text-center max-w-xs">
|
||||
{searchQuery || activeCategory
|
||||
? "Try a different search or category"
|
||||
: canPost
|
||||
? "Be the first to start a discussion!"
|
||||
: "No discussions yet. Check back soon."}
|
||||
</p>
|
||||
{canPost && !searchQuery && !activeCategory && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setCreateForm({
|
||||
title: "",
|
||||
content: "",
|
||||
categoryId: categories[0]?.id || "",
|
||||
});
|
||||
setCreateError(null);
|
||||
setShowCreateModal(true);
|
||||
}}
|
||||
className="mt-4 flex items-center gap-2 bg-[#D4AF37]/15 border border-[#D4AF37]/30 rounded-xl px-4 py-2.5 active:bg-[#D4AF37]/25 transition"
|
||||
>
|
||||
<Plus size={16} className="text-[#D4AF37]" />
|
||||
<span className="text-sm font-medium text-[#D4AF37]">Start Discussion</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2.5">
|
||||
{threads.map((thread) => {
|
||||
const shariahInfo = SHARIAH_LABELS[thread.shariahStatus];
|
||||
const ShariahIcon = shariahInfo.icon;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={thread.id}
|
||||
onClick={() => router.push(`/forum/${thread.id}`)}
|
||||
className="w-full text-left bg-[#111118] border border-gray-800/60 rounded-2xl p-4 active:scale-[0.99] transition-transform"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Title row */}
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
{thread.pinned && (
|
||||
<Pin size={12} className="text-[#D4AF37] shrink-0" />
|
||||
)}
|
||||
<h3 className="text-sm font-semibold text-white truncate">
|
||||
{thread.title}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Meta row */}
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[10px] text-gray-600">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-gray-500">by</span>
|
||||
<span className="font-medium text-gray-400">
|
||||
{thread.author.name}
|
||||
</span>
|
||||
{thread.author.isPro && (
|
||||
<Crown size={9} className="text-purple-400" />
|
||||
)}
|
||||
{thread.author.isPremium && !thread.author.isPro && (
|
||||
<Star size={9} className="text-[#D4AF37]" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="text-gray-700">·</span>
|
||||
|
||||
<span className="text-gray-500">{thread.category.name}</span>
|
||||
|
||||
<span className="text-gray-700">·</span>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<MessageCircle size={9} />
|
||||
<span>{thread._count.posts}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Shariah status + time */}
|
||||
<div className="flex items-center gap-2 mt-1.5">
|
||||
<div className={`flex items-center gap-0.5 ${shariahInfo.color}`}>
|
||||
<ShariahIcon size={9} />
|
||||
<span className="text-[9px]">{shariahInfo.label}</span>
|
||||
</div>
|
||||
<span className="text-gray-700">·</span>
|
||||
<div className="flex items-center gap-0.5 text-gray-600">
|
||||
<Clock size={9} />
|
||||
<span className="text-[9px]">{timeAgo(thread.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create thread modal */}
|
||||
{showCreateModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
/>
|
||||
<div className="relative w-full sm:max-w-md bg-[#111118] border border-gray-800/60 rounded-t-2xl sm:rounded-2xl p-5 animate-fade-in max-h-[90dvh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Plus size={18} className="text-[#D4AF37]" />
|
||||
<h2 className="text-base font-bold text-white">New Thread</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
className="w-8 h-8 rounded-full bg-gray-800/60 flex items-center justify-center active:bg-gray-700/60 transition"
|
||||
>
|
||||
<X size={16} className="text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleCreateThread} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1.5">
|
||||
Category *
|
||||
</label>
|
||||
<select
|
||||
value={createForm.categoryId}
|
||||
onChange={(e) =>
|
||||
setCreateForm((f) => ({ ...f, categoryId: e.target.value }))
|
||||
}
|
||||
required
|
||||
className="w-full bg-[#0a0a0f] border border-gray-800/60 rounded-xl px-3.5 py-2.5 text-sm text-white focus:outline-none focus:border-[#D4AF37]/40 transition appearance-none"
|
||||
style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E")`,
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundPosition: "right 12px center",
|
||||
}}
|
||||
>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat.id} value={cat.id}>
|
||||
{cat.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1.5">
|
||||
Title *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={createForm.title}
|
||||
onChange={(e) =>
|
||||
setCreateForm((f) => ({ ...f, title: e.target.value }))
|
||||
}
|
||||
placeholder="What's on your mind?"
|
||||
required
|
||||
maxLength={200}
|
||||
className="w-full bg-[#0a0a0f] border border-gray-800/60 rounded-xl px-3.5 py-2.5 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/40 transition"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1.5">
|
||||
Content *
|
||||
</label>
|
||||
<textarea
|
||||
value={createForm.content}
|
||||
onChange={(e) =>
|
||||
setCreateForm((f) => ({ ...f, content: e.target.value }))
|
||||
}
|
||||
placeholder="Write your thoughts..."
|
||||
required
|
||||
maxLength={10000}
|
||||
rows={5}
|
||||
className="w-full bg-[#0a0a0f] border border-gray-800/60 rounded-xl px-3.5 py-2.5 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/40 transition resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{createError && (
|
||||
<div className="bg-red-900/10 border border-red-800/30 rounded-xl p-3">
|
||||
<p className="text-xs text-red-300">{createError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
className="flex-1 py-2.5 rounded-xl text-sm font-medium text-gray-500 bg-gray-800/40 border border-gray-800/60 active:bg-gray-700/40 transition"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={creating}
|
||||
className="flex-1 py-2.5 rounded-xl text-sm font-semibold text-[#0a0a0f] bg-[#D4AF37] active:bg-[#c5a233] transition flex items-center justify-center gap-2 disabled:opacity-60"
|
||||
>
|
||||
{creating ? (
|
||||
<>
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
"Create Thread"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+74
-17
@@ -1,26 +1,83 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
--bg-primary: #0a0a0f;
|
||||
--bg-card: #111118;
|
||||
--bg-card2: #1a1a24;
|
||||
--gold: #D4AF37;
|
||||
--gold-light: #f0d060;
|
||||
--gold-dark: #b8962e;
|
||||
--purple: #a855f7;
|
||||
--purple-dark: #7c3aed;
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #9ca3af;
|
||||
--text-muted: #6b7280;
|
||||
--border: #1f2937;
|
||||
--border-gold: rgba(212, 175, 55, 0.4);
|
||||
--danger: #ef4444;
|
||||
--success: #22c55e;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
* {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
padding-bottom: 80px;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #2a2a3a;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes pulse-gold {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(212, 175, 55, 0.4); }
|
||||
50% { box-shadow: 0 0 0 8px rgba(212, 175, 55, 0); }
|
||||
}
|
||||
.animate-premium-glow {
|
||||
animation: pulse-gold 2s infinite;
|
||||
}
|
||||
|
||||
.glass {
|
||||
background: rgba(17, 17, 24, 0.8);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.glass-gold {
|
||||
background: rgba(212, 175, 55, 0.08);
|
||||
border: 1px solid var(--border-gold);
|
||||
}
|
||||
|
||||
/* Streak flame */
|
||||
@keyframes flicker {
|
||||
0%, 100% { transform: scaleY(1); }
|
||||
50% { transform: scaleY(1.05); }
|
||||
}
|
||||
.flame {
|
||||
animation: flicker 0.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,628 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useAuth } from "@/lib/AuthContext";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
MapPin,
|
||||
Search,
|
||||
Heart,
|
||||
X,
|
||||
Star,
|
||||
ChevronRight,
|
||||
Sparkles,
|
||||
Crown,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────
|
||||
interface Place {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
rating: number;
|
||||
type: "mosque" | "restaurant" | "cafe";
|
||||
halal_certified: boolean;
|
||||
}
|
||||
|
||||
interface UsageInfo {
|
||||
queriesUsed: number;
|
||||
queriesLimit: number;
|
||||
remaining: number;
|
||||
tier: string;
|
||||
scope: string;
|
||||
}
|
||||
|
||||
interface Bookmark {
|
||||
id: string;
|
||||
itemId: string;
|
||||
itemType: string;
|
||||
label: string | null;
|
||||
createdAt: string;
|
||||
place: Place | null;
|
||||
}
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────
|
||||
const TYPES = ["all", "mosque", "restaurant", "cafe"] as const;
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
all: "All",
|
||||
mosque: "Mosques",
|
||||
restaurant: "Restaurants",
|
||||
cafe: "Cafes",
|
||||
};
|
||||
|
||||
// ─── Page ─────────────────────────────────────────────────────────────────
|
||||
export default function HalalMonitorPage() {
|
||||
const { user, token, loading } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const [places, setPlaces] = useState<Place[]>([]);
|
||||
const [usage, setUsage] = useState<UsageInfo | null>(null);
|
||||
const [bookmarks, setBookmarks] = useState<Bookmark[]>([]);
|
||||
const [search, setSearch] = useState("");
|
||||
const [typeFilter, setTypeFilter] = useState<string>("all");
|
||||
const [selectedPlace, setSelectedPlace] = useState<Place | null>(null);
|
||||
const [pageLoading, setPageLoading] = useState(true);
|
||||
const [usageLoading, setUsageLoading] = useState(false);
|
||||
const [mapReady, setMapReady] = useState(false);
|
||||
const mapRef = useRef<HTMLDivElement>(null);
|
||||
const mapInstanceRef = useRef<any>(null);
|
||||
const markersRef = useRef<any[]>([]);
|
||||
|
||||
const isPremium = user?.isPremium || user?.isPro || false;
|
||||
const isFree = !isPremium;
|
||||
|
||||
// ── Redirect if not logged in ──────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!loading && !token) router.push("/login");
|
||||
}, [loading, token, router]);
|
||||
|
||||
// ── Fetch places ───────────────────────────────────────────────────────
|
||||
const fetchPlaces = useCallback(async (q = search, type = typeFilter) => {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (q) params.set("q", q);
|
||||
if (type && type !== "all") params.set("type", type);
|
||||
|
||||
const res = await fetch(`/api/halal/places?${params.toString()}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setPlaces(data.places);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [search, typeFilter]);
|
||||
|
||||
// ── Fetch usage ────────────────────────────────────────────────────────
|
||||
const fetchUsage = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
const res = await fetch("/api/halal/usage", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setUsage(data);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
// ── Fetch bookmarks ────────────────────────────────────────────────────
|
||||
const fetchBookmarks = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
const res = await fetch("/api/halal/bookmarks", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setBookmarks(data.bookmarks);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
// ── Track usage query ──────────────────────────────────────────────────
|
||||
const trackQuery = useCallback(async () => {
|
||||
if (!token) return;
|
||||
setUsageLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/halal/usage", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setUsage((prev) =>
|
||||
prev
|
||||
? { ...prev, ...data }
|
||||
: data
|
||||
);
|
||||
} else if (res.status === 429) {
|
||||
// Rate limited — update usage from error response
|
||||
const data = await res.json();
|
||||
setUsage((prev) => prev ? { ...prev, remaining: 0 } : prev);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setUsageLoading(false);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
// ── Initial load ───────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (loading || !token) return;
|
||||
|
||||
const init = async () => {
|
||||
setPageLoading(true);
|
||||
await Promise.all([fetchPlaces(), fetchUsage(), fetchBookmarks()]);
|
||||
setPageLoading(false);
|
||||
};
|
||||
init();
|
||||
}, [loading, token, fetchPlaces, fetchUsage, fetchBookmarks]);
|
||||
|
||||
// ── Search / filter triggers ───────────────────────────────────────────
|
||||
const handleSearch = useCallback(() => {
|
||||
fetchPlaces(search, typeFilter);
|
||||
if (!isFree) trackQuery();
|
||||
}, [search, typeFilter, fetchPlaces, isFree, trackQuery]);
|
||||
|
||||
const handleTypeFilter = useCallback(
|
||||
(type: string) => {
|
||||
setTypeFilter(type);
|
||||
fetchPlaces(search, type);
|
||||
if (!isFree) trackQuery();
|
||||
},
|
||||
[search, fetchPlaces, isFree, trackQuery]
|
||||
);
|
||||
|
||||
// ── Bookmark toggle ────────────────────────────────────────────────────
|
||||
const toggleBookmark = useCallback(
|
||||
async (place: Place) => {
|
||||
if (!token) return;
|
||||
const existing = bookmarks.find((b) => b.itemId === place.id);
|
||||
if (existing) {
|
||||
const res = await fetch(`/api/halal/bookmarks?id=${existing.id}`, {
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (res.ok) fetchBookmarks();
|
||||
} else {
|
||||
const res = await fetch("/api/halal/bookmarks", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
itemId: place.id,
|
||||
itemType: place.type,
|
||||
label: place.name,
|
||||
}),
|
||||
});
|
||||
if (res.ok) fetchBookmarks();
|
||||
}
|
||||
},
|
||||
[token, bookmarks, fetchBookmarks]
|
||||
);
|
||||
|
||||
const isBookmarked = useCallback(
|
||||
(placeId: string) => bookmarks.some((b) => b.itemId === placeId),
|
||||
[bookmarks]
|
||||
);
|
||||
|
||||
// ── Filter visible places (premium gating) ─────────────────────────────
|
||||
const visiblePlaces = isFree
|
||||
? places.filter((p) => p.type === "mosque")
|
||||
: places;
|
||||
|
||||
// ── Render stars ───────────────────────────────────────────────────────
|
||||
const renderStars = (rating: number) => {
|
||||
const full = Math.floor(rating);
|
||||
const half = rating - full >= 0.5;
|
||||
const empty = 5 - full - (half ? 1 : 0);
|
||||
return (
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
{Array.from({ length: full }, (_, i) => (
|
||||
<Star key={`full-${i}`} size={12} className="fill-amber-400 text-amber-400" />
|
||||
))}
|
||||
{half && (
|
||||
<div className="relative">
|
||||
<Star size={12} className="text-gray-600" />
|
||||
<Star
|
||||
size={12}
|
||||
className="absolute inset-0 fill-amber-400 text-amber-400"
|
||||
style={{ clipPath: "inset(0 50% 0 0)" }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{Array.from({ length: empty }, (_, i) => (
|
||||
<Star key={`empty-${i}`} size={12} className="text-gray-600" />
|
||||
))}
|
||||
<span className="text-xs text-gray-400 ml-1">{rating.toFixed(1)}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Premium Gating Overlay ─────────────────────────────────────────────
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-dvh bg-[#0a0a0f] flex items-center justify-center">
|
||||
<div className="w-8 h-8 rounded-full border-2 border-emerald-500/30 border-t-emerald-500 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh bg-[#0a0a0f] pb-24">
|
||||
{/* ── Header ─────────────────────────────────────────────────────── */}
|
||||
<div className="px-4 pt-6 pb-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h1 className="text-xl font-bold text-white flex items-center gap-2">
|
||||
<MapPin size={20} className="text-emerald-400" />
|
||||
Halal Monitor
|
||||
</h1>
|
||||
{usage && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-right">
|
||||
<p className="text-[10px] text-gray-500">Today</p>
|
||||
<p className="text-xs font-semibold text-gray-300">
|
||||
{usage.remaining}/{usage.queriesLimit}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-16 h-1.5 bg-gray-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${Math.min(100, ((usage?.queriesUsed ?? 0) / (usage?.queriesLimit ?? 10)) * 100)}%`,
|
||||
backgroundColor:
|
||||
(usage?.remaining ?? 0) <= 2 ? "#ef4444" : "#10b981",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
Discover halal-certified places around you
|
||||
</p>
|
||||
|
||||
{/* ── Premium Upgrade Banner ──────────────────────────────────── */}
|
||||
{isFree && (
|
||||
<button
|
||||
onClick={() => router.push("/upgrade")}
|
||||
className="mt-3 flex items-center justify-between glass-gold rounded-2xl px-4 py-3 w-full active:opacity-80 transition"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-emerald-500/20 flex items-center justify-center">
|
||||
<Sparkles size={16} className="text-emerald-400" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="text-sm font-semibold text-emerald-400">
|
||||
Upgrade for Unlimited
|
||||
</p>
|
||||
<p className="text-[11px] text-gray-500">
|
||||
Unlock restaurants + cafes & unlimited queries
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight size={16} className="text-emerald-400" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Free user scope notice */}
|
||||
{isFree && (
|
||||
<div className="mt-2 flex items-center gap-1.5 text-[11px] text-amber-400/70">
|
||||
<Crown size={12} />
|
||||
Free tier: mosques only • 10 queries/day
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Search Bar ─────────────────────────────────────────────────── */}
|
||||
<div className="px-4 mb-3">
|
||||
<div className="flex items-center gap-2 bg-[#111118] border border-gray-800/60 rounded-2xl px-4 py-2.5">
|
||||
<Search size={16} className="text-gray-500 shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search halal places..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
className="flex-1 bg-transparent text-sm text-white placeholder-gray-600 outline-none"
|
||||
/>
|
||||
{search && (
|
||||
<button onClick={() => { setSearch(""); fetchPlaces("", typeFilter); }}>
|
||||
<X size={14} className="text-gray-500" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Category Filter ────────────────────────────────────────────── */}
|
||||
<div className="px-4 mb-4 overflow-x-auto">
|
||||
<div className="flex gap-2 min-w-max pb-1">
|
||||
{TYPES.map((t) => {
|
||||
const active = typeFilter === t;
|
||||
const isDisabled = isFree && t !== "all" && t !== "mosque";
|
||||
return (
|
||||
<button
|
||||
key={t}
|
||||
disabled={isDisabled}
|
||||
onClick={() => handleTypeFilter(t)}
|
||||
className={`px-3.5 py-1.5 rounded-full text-xs font-medium transition-all shrink-0 ${
|
||||
active
|
||||
? "bg-emerald-500/20 text-emerald-300 border border-emerald-500/40"
|
||||
: isDisabled
|
||||
? "bg-gray-900 text-gray-600 border border-gray-800/40 cursor-not-allowed opacity-50"
|
||||
: "bg-[#111118] text-gray-400 border border-gray-800/60 hover:border-gray-700"
|
||||
}`}
|
||||
>
|
||||
{TYPE_LABELS[t]}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Map Placeholder ────────────────────────────────────────────── */}
|
||||
<div className="mx-4 mb-4">
|
||||
<div
|
||||
ref={mapRef}
|
||||
className="w-full h-48 rounded-2xl overflow-hidden relative bg-gradient-to-br from-emerald-900/40 via-[#111118] to-gray-900 border border-gray-800/60"
|
||||
>
|
||||
{/* Decorative grid lines */}
|
||||
<div className="absolute inset-0 opacity-10"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"linear-gradient(rgba(16, 185, 129, 0.3) 1px, transparent 1px), linear-gradient(90deg, rgba(16, 185, 129, 0.3) 1px, transparent 1px)",
|
||||
backgroundSize: "40px 40px",
|
||||
}}
|
||||
/>
|
||||
{/* Center pin */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div className="w-10 h-10 rounded-full bg-emerald-500/20 border-2 border-emerald-500/40 flex items-center justify-center animate-pulse">
|
||||
<MapPin size={18} className="text-emerald-400" />
|
||||
</div>
|
||||
<span className="text-[10px] text-emerald-500/60 font-medium">
|
||||
{visiblePlaces.length} places found
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Map labels */}
|
||||
{visiblePlaces.slice(0, 5).map((place) => (
|
||||
<div
|
||||
key={place.id}
|
||||
className="absolute w-2 h-2 rounded-full bg-emerald-400 shadow-lg shadow-emerald-500/40 animate-pulse"
|
||||
style={{
|
||||
left: `${((place.lng - 101.6) / 0.2) * 50 + 25}%`,
|
||||
top: `${((3.2 - place.lat) / 0.3) * 50 + 15}%`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Results List ───────────────────────────────────────────────── */}
|
||||
<div className="px-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
||||
{visiblePlaces.length} {visiblePlaces.length === 1 ? "place" : "places"}
|
||||
</h2>
|
||||
{usageLoading && (
|
||||
<Loader2 size={12} className="animate-spin text-gray-600" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pageLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 size={24} className="animate-spin text-emerald-500/50" />
|
||||
</div>
|
||||
) : visiblePlaces.length === 0 ? (
|
||||
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-8 text-center">
|
||||
<MapPin size={32} className="mx-auto text-gray-700 mb-2" />
|
||||
<p className="text-sm text-gray-500">No places found</p>
|
||||
<p className="text-xs text-gray-700 mt-1">
|
||||
{isFree
|
||||
? "Free tier shows mosques only. Try a different search."
|
||||
: "Try adjusting your search or filters."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{visiblePlaces.map((place) => {
|
||||
const bookmarked = isBookmarked(place.id);
|
||||
return (
|
||||
<div
|
||||
key={place.id}
|
||||
onClick={() => setSelectedPlace(place)}
|
||||
className="bg-[#111118] border border-gray-800/60 rounded-2xl p-4 active:scale-[0.99] transition cursor-pointer"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Place name + type badge */}
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="text-sm font-semibold text-white truncate">
|
||||
{place.name}
|
||||
</h3>
|
||||
<span
|
||||
className={`text-[10px] font-medium px-2 py-0.5 rounded-full shrink-0 ${
|
||||
place.type === "mosque"
|
||||
? "bg-emerald-500/15 text-emerald-300 border border-emerald-500/30"
|
||||
: "bg-amber-500/15 text-amber-300 border border-amber-500/30"
|
||||
}`}
|
||||
>
|
||||
{place.type === "mosque" ? "Mosque" : "Restaurant"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Address */}
|
||||
<p className="text-xs text-gray-500 mb-1.5 truncate">
|
||||
{place.address}
|
||||
</p>
|
||||
|
||||
{/* Rating */}
|
||||
<div className="mb-1.5">{renderStars(place.rating)}</div>
|
||||
|
||||
{/* Halal badge */}
|
||||
{place.halal_certified && (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-medium text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded-full">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
|
||||
Halal Certified
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bookmark heart */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleBookmark(place);
|
||||
}}
|
||||
className={`shrink-0 w-8 h-8 rounded-full flex items-center justify-center transition ${
|
||||
bookmarked
|
||||
? "bg-emerald-500/20 text-emerald-400"
|
||||
: "bg-gray-800/60 text-gray-600 hover:text-gray-400"
|
||||
}`}
|
||||
>
|
||||
<Heart
|
||||
size={14}
|
||||
className={bookmarked ? "fill-emerald-400" : ""}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Detail Modal ────────────────────────────────────────────────── */}
|
||||
{selectedPlace && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/70 flex items-end sm:items-center justify-center"
|
||||
onClick={() => setSelectedPlace(null)}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="bg-[#111118] border border-gray-800/60 rounded-t-3xl sm:rounded-3xl w-full max-w-md max-h-[85vh] overflow-y-auto animate-fade-in"
|
||||
>
|
||||
{/* Handle */}
|
||||
<div className="pt-3 pb-1 flex justify-center sm:hidden">
|
||||
<div className="w-10 h-1 rounded-full bg-gray-700" />
|
||||
</div>
|
||||
|
||||
<div className="p-5">
|
||||
{/* Close button */}
|
||||
<div className="flex justify-end mb-2">
|
||||
<button
|
||||
onClick={() => setSelectedPlace(null)}
|
||||
className="w-8 h-8 rounded-full bg-gray-800/80 flex items-center justify-center"
|
||||
>
|
||||
<X size={14} className="text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Type badge */}
|
||||
<div className="mb-3">
|
||||
<span
|
||||
className={`text-xs font-medium px-3 py-1 rounded-full ${
|
||||
selectedPlace.type === "mosque"
|
||||
? "bg-emerald-500/15 text-emerald-300 border border-emerald-500/30"
|
||||
: "bg-amber-500/15 text-amber-300 border border-amber-500/30"
|
||||
}`}
|
||||
>
|
||||
{selectedPlace.type === "mosque" ? "🕌 Mosque" : "🍽️ Restaurant"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<h2 className="text-lg font-bold text-white mb-1">
|
||||
{selectedPlace.name}
|
||||
</h2>
|
||||
|
||||
{/* Halal certified */}
|
||||
{selectedPlace.halal_certified && (
|
||||
<div className="flex items-center gap-1.5 mb-3">
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-400" />
|
||||
<span className="text-xs font-medium text-emerald-400">
|
||||
Halal Certified
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Address */}
|
||||
<div className="flex items-start gap-2 mb-3">
|
||||
<MapPin size={14} className="text-gray-500 mt-0.5 shrink-0" />
|
||||
<p className="text-sm text-gray-400">{selectedPlace.address}</p>
|
||||
</div>
|
||||
|
||||
{/* Rating */}
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Star size={14} className="fill-amber-400 text-amber-400" />
|
||||
<span className="text-sm font-semibold text-white">
|
||||
{selectedPlace.rating.toFixed(1)}
|
||||
</span>
|
||||
<span className="text-xs text-gray-600">/ 5.0</span>
|
||||
</div>
|
||||
|
||||
{/* Coords */}
|
||||
<div className="grid grid-cols-2 gap-3 mb-5">
|
||||
<div className="bg-[#0a0a0f] rounded-xl p-3 text-center">
|
||||
<p className="text-[10px] text-gray-600">Latitude</p>
|
||||
<p className="text-xs text-gray-300 font-mono">
|
||||
{selectedPlace.lat.toFixed(4)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-[#0a0a0f] rounded-xl p-3 text-center">
|
||||
<p className="text-[10px] text-gray-600">Longitude</p>
|
||||
<p className="text-xs text-gray-300 font-mono">
|
||||
{selectedPlace.lng.toFixed(4)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bookmark button */}
|
||||
<button
|
||||
onClick={() => {
|
||||
toggleBookmark(selectedPlace);
|
||||
setSelectedPlace(null);
|
||||
}}
|
||||
className={`w-full py-3 rounded-2xl text-sm font-semibold transition flex items-center justify-center gap-2 ${
|
||||
isBookmarked(selectedPlace.id)
|
||||
? "bg-gray-800 text-gray-400 border border-gray-700"
|
||||
: "bg-emerald-500/20 text-emerald-300 border border-emerald-500/40"
|
||||
}`}
|
||||
>
|
||||
<Heart
|
||||
size={16}
|
||||
className={
|
||||
isBookmarked(selectedPlace.id) ? "fill-emerald-400" : ""
|
||||
}
|
||||
/>
|
||||
{isBookmarked(selectedPlace.id)
|
||||
? "Remove Bookmark"
|
||||
: "Add to Bookmarks"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+23
-20
@@ -1,33 +1,36 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
import { AuthProvider } from "@/lib/AuthContext";
|
||||
import BottomNav from "@/components/BottomNav";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "Falah — Islamic Lifestyle",
|
||||
description: "Nur AI coaching, Halal marketplace, community & wallet",
|
||||
viewport: "width=device-width, initial-scale=1, viewport-fit=cover",
|
||||
themeColor: "#0a0a0f",
|
||||
appleWebApp: { capable: true, statusBarStyle: "black-translucent" },
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
}) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
<html lang="en" className="dark">
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#0a0a0f" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
</head>
|
||||
<body>
|
||||
<AuthProvider>
|
||||
<main>{children}</main>
|
||||
<BottomNav />
|
||||
</AuthProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
import { useState, FormEvent } from "react";
|
||||
import { useAuth } from "@/lib/AuthContext";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function LoginPage() {
|
||||
const { login } = useAuth();
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
if (!email.trim() || !password.trim()) {
|
||||
setError("Please fill in all fields");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(email.trim(), password);
|
||||
router.push("/");
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Login failed. Please try again.";
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh bg-[#0a0a0f] flex flex-col items-center justify-center px-6">
|
||||
<div className="w-full max-w-sm">
|
||||
{/* Logo / Brand */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-[#D4AF37]/20 to-[#D4AF37]/5 border border-[#D4AF37]/30 flex items-center justify-center mx-auto mb-4">
|
||||
<span className="text-3xl">☪️</span>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white">Welcome Back</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Sign in to continue your journey
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="mb-4 p-3 rounded-xl bg-red-900/20 border border-red-800/40 text-red-400 text-sm text-center">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-xs font-medium text-gray-500 mb-1.5 uppercase tracking-wider"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
autoComplete="email"
|
||||
autoFocus
|
||||
className="w-full bg-[#111118] border border-gray-800/60 rounded-xl px-4 py-3 text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/50 focus:ring-1 focus:ring-[#D4AF37]/30 transition"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-xs font-medium text-gray-500 mb-1.5 uppercase tracking-wider"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
autoComplete="current-password"
|
||||
className="w-full bg-[#111118] border border-gray-800/60 rounded-xl px-4 py-3 text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/50 focus:ring-1 focus:ring-[#D4AF37]/30 transition"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-3 rounded-xl bg-[#D4AF37] text-[#0a0a0f] font-semibold text-sm active:opacity-80 disabled:opacity-50 transition"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<span className="w-4 h-4 rounded-full border-2 border-[#0a0a0f]/30 border-t-[#0a0a0f] animate-spin" />
|
||||
Signing in...
|
||||
</span>
|
||||
) : (
|
||||
"Sign In"
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="flex items-center gap-3 my-6">
|
||||
<div className="flex-1 h-px bg-gray-800/60" />
|
||||
<span className="text-xs text-gray-600">or</span>
|
||||
<div className="flex-1 h-px bg-gray-800/60" />
|
||||
</div>
|
||||
|
||||
{/* Demo hint */}
|
||||
<div className="text-center mb-6">
|
||||
<p className="text-xs text-gray-600">
|
||||
Don't have an account?{" "}
|
||||
<Link
|
||||
href="/register"
|
||||
className="text-[#D4AF37] font-medium hover:underline"
|
||||
>
|
||||
Create one
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Demo credentials */}
|
||||
<div className="rounded-xl bg-[#111118] border border-gray-800/60 p-3 text-center">
|
||||
<p className="text-[10px] text-gray-600 uppercase tracking-wider mb-1">
|
||||
Demo Account
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
Email:{" "}
|
||||
<span className="text-gray-300 font-mono">demo@falah.app</span>
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
Password:{" "}
|
||||
<span className="text-gray-300 font-mono">password123</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, Suspense, useCallback } from "react";
|
||||
import { useAuth } from "@/lib/AuthContext";
|
||||
import { PERSONAS, getPersona } from "@/lib/personas";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Send,
|
||||
Lock,
|
||||
Sparkles,
|
||||
Crown,
|
||||
Bot,
|
||||
ChevronLeft,
|
||||
AlertTriangle,
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
MessageSquare,
|
||||
} from "lucide-react";
|
||||
|
||||
/* ── Types ── */
|
||||
interface ChatMessage {
|
||||
id?: string;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
createdAt?: string;
|
||||
metadata?: { personaId?: string } | null;
|
||||
}
|
||||
|
||||
/* ── Main Page ── */
|
||||
export default function NurPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="min-h-dvh bg-[#0a0a0f] flex items-center justify-center">
|
||||
<Loader2 size={24} className="text-[#D4AF37] animate-spin" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<NurChat />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Chat Component ── */
|
||||
function NurChat() {
|
||||
const { user, token, loading } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [activePersona, setActivePersona] = useState("nurbuddy");
|
||||
const [isTyping, setIsTyping] = useState(false);
|
||||
const [dailyCount, setDailyCount] = useState(0);
|
||||
const [dailyLimit, setDailyLimit] = useState(10);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [initialLoading, setInitialLoading] = useState(true);
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
/* Redirect if not logged in */
|
||||
useEffect(() => {
|
||||
if (!loading && !token) {
|
||||
router.push("/login");
|
||||
}
|
||||
}, [loading, token, router]);
|
||||
|
||||
/* Load chat history + daily stats */
|
||||
useEffect(() => {
|
||||
if (!token || !user) return;
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [historyRes, dailyRes] = await Promise.all([
|
||||
fetch(`/api/chat/history/${user.id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
}),
|
||||
fetch("/api/chat/daily", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (historyRes.ok) {
|
||||
const hist = await historyRes.json();
|
||||
setMessages(hist.messages || []);
|
||||
}
|
||||
|
||||
if (dailyRes.ok) {
|
||||
const daily = await dailyRes.json();
|
||||
setDailyCount(daily.count || 0);
|
||||
setDailyLimit(daily.limit === -1 ? Infinity : daily.limit);
|
||||
}
|
||||
} catch {
|
||||
// silent
|
||||
} finally {
|
||||
setInitialLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
}, [token, user]);
|
||||
|
||||
/* Auto-scroll on new messages */
|
||||
const scrollToBottom = useCallback(() => {
|
||||
setTimeout(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, 100);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages, isTyping, scrollToBottom]);
|
||||
|
||||
/* Send message */
|
||||
const sendMessage = async () => {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed || isTyping) return;
|
||||
|
||||
setError(null);
|
||||
|
||||
const userMsg: ChatMessage = { role: "user", content: trimmed };
|
||||
setMessages((prev) => [...prev, userMsg]);
|
||||
setInput("");
|
||||
|
||||
setIsTyping(true);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/chat/send", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
messages: [{ role: "user", content: trimmed }],
|
||||
personaId: activePersona,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 429) {
|
||||
setError("Daily message limit reached. Upgrade to continue chatting!");
|
||||
} else if (res.status === 403) {
|
||||
setError(data.error || "This persona requires an upgrade.");
|
||||
} else {
|
||||
setError(data.error || "Failed to send message");
|
||||
}
|
||||
// Remove the optimistic user message on error
|
||||
setMessages((prev) => prev.slice(0, -1));
|
||||
return;
|
||||
}
|
||||
|
||||
const aiMsg: ChatMessage = {
|
||||
id: data.message.id,
|
||||
role: "assistant",
|
||||
content: data.message.content,
|
||||
createdAt: data.message.createdAt,
|
||||
};
|
||||
setMessages((prev) => [...prev, aiMsg]);
|
||||
|
||||
// Update daily count
|
||||
if (data.daily) {
|
||||
setDailyCount(data.daily.count);
|
||||
setDailyLimit(data.daily.limit === -1 ? Infinity : data.daily.limit);
|
||||
}
|
||||
} catch {
|
||||
setError("Network error. Please try again.");
|
||||
setMessages((prev) => prev.slice(0, -1));
|
||||
} finally {
|
||||
setIsTyping(false);
|
||||
}
|
||||
};
|
||||
|
||||
/* Keyboard shortcut */
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
};
|
||||
|
||||
if (loading || initialLoading) {
|
||||
return (
|
||||
<div className="min-h-dvh bg-[#0a0a0f] flex items-center justify-center">
|
||||
<div className="w-8 h-8 rounded-full border-2 border-[#D4AF37]/30 border-t-[#D4AF37] animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user || !token) return null;
|
||||
|
||||
const isPremium = user.isPremium || user.isPro;
|
||||
const isPro = user.isPro;
|
||||
const isUnlimited = dailyLimit === Infinity;
|
||||
const atLimit = !isUnlimited && dailyCount >= dailyLimit;
|
||||
const limitPercent = isUnlimited ? 0 : Math.min(100, (dailyCount / dailyLimit) * 100);
|
||||
const remaining = isUnlimited ? -1 : dailyLimit - dailyCount;
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh bg-[#0a0a0f] flex flex-col pb-24">
|
||||
{/* ── Header ── */}
|
||||
<div className="sticky top-0 z-20 bg-[#0a0a0f]/95 backdrop-blur-md border-b border-gray-800/60">
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href="/" className="text-gray-500 hover:text-white transition">
|
||||
<ChevronLeft size={20} />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-base font-bold text-white flex items-center gap-1.5">
|
||||
<Bot size={16} className="text-[#D4AF37]" />
|
||||
Nur AI
|
||||
</h1>
|
||||
<p className="text-[10px] text-gray-600">
|
||||
{isPro ? "Pro Plan" : isPremium ? "Premium Plan" : "Free Plan"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{!isPremium && (
|
||||
<Link
|
||||
href="/upgrade"
|
||||
className="flex items-center gap-1.5 bg-[#D4AF37]/15 border border-[#D4AF37]/30 rounded-full px-3 py-1.5 text-[11px] font-semibold text-[#D4AF37] active:bg-[#D4AF37]/25 transition"
|
||||
>
|
||||
<Sparkles size={12} />
|
||||
Upgrade
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Persona Selector ── */}
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="h-20 bg-[#111118] mx-4 mt-3 rounded-xl animate-pulse" />
|
||||
}
|
||||
>
|
||||
<PersonaSelector
|
||||
activePersona={activePersona}
|
||||
onSelect={setActivePersona}
|
||||
isPremium={isPremium}
|
||||
isPro={isPro}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
{/* ── Daily Cap Bar ── */}
|
||||
<div className="px-4 mt-3">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-[11px] text-gray-500 font-medium">
|
||||
{isUnlimited ? (
|
||||
<span className="text-emerald-400 flex items-center gap-1">
|
||||
<CheckCircle2 size={12} />
|
||||
Unlimited messages
|
||||
</span>
|
||||
) : (
|
||||
`${dailyCount}/${dailyLimit} messages today`
|
||||
)}
|
||||
</span>
|
||||
{!isUnlimited && remaining <= 3 && remaining > 0 && (
|
||||
<span className="text-[10px] text-amber-400 font-medium">
|
||||
{remaining} left
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!isUnlimited && (
|
||||
<div className="w-full h-1.5 bg-gray-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-500 ${
|
||||
limitPercent >= 80
|
||||
? "bg-red-500"
|
||||
: limitPercent >= 50
|
||||
? "bg-amber-500"
|
||||
: "bg-[#D4AF37]"
|
||||
}`}
|
||||
style={{ width: `${limitPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Upgrade Banner (hitting limit) ── */}
|
||||
{!isPremium && atLimit && (
|
||||
<div className="mx-4 mt-3 p-3 bg-gradient-to-r from-[#D4AF37]/10 to-purple-900/10 border border-[#D4AF37]/20 rounded-xl">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Crown size={18} className="text-[#D4AF37] shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-semibold text-white">
|
||||
You've hit your daily limit
|
||||
</p>
|
||||
<p className="text-[11px] text-gray-500 mt-0.5">
|
||||
Upgrade to Premium for 100 messages/day or Pro for unlimited access.
|
||||
</p>
|
||||
<Link
|
||||
href="/upgrade"
|
||||
className="inline-block mt-2 text-xs font-semibold text-[#D4AF37] border border-[#D4AF37]/40 rounded-full px-3 py-1 active:bg-[#D4AF37]/10 transition"
|
||||
>
|
||||
See Plans
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Messages Area ── */}
|
||||
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-4">
|
||||
{messages.length === 0 && !isTyping && (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center py-12">
|
||||
<div className="w-16 h-16 rounded-full bg-[#D4AF37]/10 flex items-center justify-center mb-4">
|
||||
<MessageSquare size={28} className="text-[#D4AF37]" />
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-gray-300">
|
||||
Start a conversation
|
||||
</h3>
|
||||
<p className="text-xs text-gray-600 mt-1 max-w-xs">
|
||||
Choose a persona and send a message to get AI-powered Islamic guidance.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg, idx) => (
|
||||
<MessageBubble key={msg.id || idx} message={msg} />
|
||||
))}
|
||||
|
||||
{isTyping && (
|
||||
<div className="flex items-start gap-2.5 animate-fade-in">
|
||||
<div className="w-8 h-8 rounded-full bg-gray-800 flex items-center justify-center shrink-0">
|
||||
<Bot size={14} className="text-[#D4AF37]" />
|
||||
</div>
|
||||
<div className="bg-[#1a1a24] border border-gray-700/40 rounded-2xl rounded-tl-sm px-4 py-3 max-w-[85%]">
|
||||
<TypingIndicator />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* ── Error Message ── */}
|
||||
{error && (
|
||||
<div className="mx-4 mb-2 flex items-start gap-2 bg-red-900/20 border border-red-800/30 rounded-xl px-3 py-2.5">
|
||||
<AlertTriangle size={14} className="text-red-400 shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-red-300 flex-1">{error}</p>
|
||||
<button
|
||||
onClick={() => setError(null)}
|
||||
className="text-red-400 hover:text-red-300 text-xs"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Input Area ── */}
|
||||
<div className="sticky bottom-20 bg-[#0a0a0f]/95 backdrop-blur-md border-t border-gray-800/60 px-4 py-3">
|
||||
<div className="flex items-end gap-2 max-w-2xl mx-auto">
|
||||
<div className="flex-1 relative">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={atLimit ? "Daily limit reached" : "Type a message..."}
|
||||
disabled={atLimit || isTyping}
|
||||
rows={1}
|
||||
className="w-full bg-[#111118] border border-gray-700/50 rounded-xl px-4 py-3 text-sm text-white placeholder-gray-600 resize-none focus:outline-none focus:border-[#D4AF37]/40 transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
style={{ minHeight: "44px", maxHeight: "120px" }}
|
||||
onInput={(e) => {
|
||||
const el = e.currentTarget;
|
||||
el.style.height = "auto";
|
||||
el.style.height = `${Math.min(el.scrollHeight, 120)}px`;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={sendMessage}
|
||||
disabled={!input.trim() || atLimit || isTyping}
|
||||
className="w-11 h-11 rounded-xl bg-[#D4AF37] text-[#0a0a0f] flex items-center justify-center shrink-0 hover:bg-[#e0c040] active:scale-95 transition disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isTyping ? (
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
) : (
|
||||
<Send size={18} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Persona Selector ── */
|
||||
function PersonaSelector({
|
||||
activePersona,
|
||||
onSelect,
|
||||
isPremium,
|
||||
isPro,
|
||||
}: {
|
||||
activePersona: string;
|
||||
onSelect: (id: string) => void;
|
||||
isPremium: boolean;
|
||||
isPro: boolean;
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const canAccess = (personaId: string) => {
|
||||
if (personaId === "nurbuddy") return true;
|
||||
if (isPro) return true;
|
||||
if (isPremium && ["alim", "hakim", "murabbi"].includes(personaId)) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-4 mt-3">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex gap-2 overflow-x-auto scrollbar-none pb-1"
|
||||
style={{ scrollbarWidth: "none", msOverflowStyle: "none" }}
|
||||
>
|
||||
{PERSONAS.map((persona) => {
|
||||
const accessible = canAccess(persona.id);
|
||||
const isActive = activePersona === persona.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={persona.id}
|
||||
onClick={() => {
|
||||
if (accessible) onSelect(persona.id);
|
||||
else window.location.href = "/upgrade";
|
||||
}}
|
||||
disabled={!accessible}
|
||||
className={`flex-shrink-0 flex flex-col items-center gap-1.5 px-4 py-3 rounded-xl border transition-all active:scale-95 ${
|
||||
isActive
|
||||
? "bg-[#D4AF37]/15 border-[#D4AF37]/50 text-white"
|
||||
: accessible
|
||||
? "bg-[#111118] border-gray-800/60 text-gray-400 hover:border-gray-700"
|
||||
: "bg-[#0a0a0f] border-gray-800/30 text-gray-700 opacity-60"
|
||||
}`}
|
||||
style={{
|
||||
minWidth: "80px",
|
||||
}}
|
||||
>
|
||||
<span className="text-xl">{persona.icon}</span>
|
||||
<span
|
||||
className={`text-xs font-semibold ${
|
||||
isActive ? "text-[#D4AF37]" : ""
|
||||
}`}
|
||||
>
|
||||
{persona.name}
|
||||
</span>
|
||||
<span className="text-[8px] text-center leading-tight text-gray-600 max-w-[72px]">
|
||||
{persona.description}
|
||||
</span>
|
||||
{!accessible && (
|
||||
<div className="flex items-center gap-1 bg-amber-900/30 border border-amber-700/30 rounded-full px-2 py-0.5 mt-1">
|
||||
<Lock size={8} className="text-amber-400" />
|
||||
<span className="text-[8px] font-medium text-amber-400">
|
||||
Premium
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{isActive && (
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-[#D4AF37]" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Message Bubble ── */
|
||||
function MessageBubble({ message }: { message: ChatMessage }) {
|
||||
const isUser = message.role === "user";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-start gap-2.5 animate-fade-in ${
|
||||
isUser ? "flex-row-reverse" : ""
|
||||
}`}
|
||||
>
|
||||
{!isUser && (
|
||||
<div className="w-8 h-8 rounded-full bg-gray-800 flex items-center justify-center shrink-0">
|
||||
<Bot size={14} className="text-[#D4AF37]" />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`max-w-[85%] rounded-2xl px-4 py-3 text-sm leading-relaxed whitespace-pre-wrap ${
|
||||
isUser
|
||||
? "bg-[#D4AF37] text-[#0a0a0f] rounded-tr-sm"
|
||||
: "bg-[#1a1a24] border border-gray-700/40 text-gray-200 rounded-tl-sm"
|
||||
}`}
|
||||
>
|
||||
{message.content}
|
||||
{message.createdAt && (
|
||||
<div
|
||||
className={`text-[10px] mt-2 ${
|
||||
isUser ? "text-[#0a0a0f]/50" : "text-gray-600"
|
||||
}`}
|
||||
>
|
||||
{new Date(message.createdAt).toLocaleTimeString("en-MY", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Typing Indicator ── */
|
||||
function TypingIndicator() {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 py-1">
|
||||
<span className="w-2 h-2 bg-gray-500 rounded-full animate-bounce" style={{ animationDelay: "0ms" }} />
|
||||
<span className="w-2 h-2 bg-gray-500 rounded-full animate-bounce" style={{ animationDelay: "150ms" }} />
|
||||
<span className="w-2 h-2 bg-gray-500 rounded-full animate-bounce" style={{ animationDelay: "300ms" }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+170
-58
@@ -1,65 +1,177 @@
|
||||
import Image from "next/image";
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useAuth } from "@/lib/AuthContext";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Bot, ShoppingBag, MessageCircle, Wallet,
|
||||
Flame, Sparkles, Star, ChevronRight,
|
||||
BookOpen, MapPin, Crown, Zap,
|
||||
} from "lucide-react";
|
||||
import { DAILY_VERSE, generateAIResponse } from "@/lib/ai";
|
||||
|
||||
export default function HomePage() {
|
||||
const { user, token, loading } = useAuth();
|
||||
const router = useRouter();
|
||||
const [streak, setStreak] = useState(0);
|
||||
const [verse, setVerse] = useState(DAILY_VERSE[Math.floor(Math.random() * DAILY_VERSE.length)]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !token) router.push("/login");
|
||||
}, [loading, token, router]);
|
||||
|
||||
if (loading) return <LoadingScreen />;
|
||||
if (!user) return null;
|
||||
|
||||
const isPremium = user.isPremium || user.isPro;
|
||||
const isPro = user.isPro;
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.tsx file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
<div className="min-h-dvh bg-[#0a0a0f] pb-24">
|
||||
{/* Header */}
|
||||
<div className="px-4 pt-6 pb-4">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white">
|
||||
Assalamualaikum, {user.name.split(" ")[0]} ☪️
|
||||
</h1>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
{new Date().toLocaleDateString("en-MY", { weekday: "long", day: "numeric", month: "long", year: "numeric" })}
|
||||
</p>
|
||||
</div>
|
||||
{/* Streak */}
|
||||
{streak > 0 && (
|
||||
<div className="flex items-center gap-1.5 bg-gradient-to-r from-orange-900/40 to-orange-800/20 border border-orange-700/30 rounded-xl px-3 py-2">
|
||||
<Flame size={16} className="text-orange-400 flame" />
|
||||
<span className="text-sm font-bold text-orange-300">{streak}</span>
|
||||
<span className="text-[10px] text-orange-400/70">day</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
|
||||
{/* Premium banner */}
|
||||
{!isPremium && (
|
||||
<Link href="/upgrade"
|
||||
className="mt-3 flex items-center justify-between glass-gold rounded-2xl px-4 py-3 active:opacity-80 transition">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-[#D4AF37]/20 flex items-center justify-center">
|
||||
<Sparkles size={16} className="text-[#D4AF37]" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-[#D4AF37]">Go Premium</p>
|
||||
<p className="text-[11px] text-gray-500">Unlock unlimited AI & more</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight size={16} className="text-[#D4AF37]" />
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Pro badge */}
|
||||
{isPro && (
|
||||
<div className="mt-3 flex items-center gap-2 bg-purple-900/20 border border-purple-500/30 rounded-2xl px-4 py-2.5">
|
||||
<Crown size={16} className="text-purple-400" />
|
||||
<span className="text-sm font-medium text-purple-300">Pro Member — Unlimited Everything</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Daily Verse */}
|
||||
<div className="mx-4 mb-5 bg-gradient-to-br from-emerald-900/20 to-[#111118] border border-emerald-800/30 rounded-2xl p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<BookOpen size={14} className="text-emerald-400" />
|
||||
<span className="text-xs font-medium text-emerald-400">Daily Verse</span>
|
||||
</div>
|
||||
</main>
|
||||
<p className="text-lg text-center font-arabic text-emerald-100 mb-2" dir="rtl">
|
||||
{verse.arabic}
|
||||
</p>
|
||||
<p className="text-sm text-gray-400 text-center italic">
|
||||
“{verse.translation}”
|
||||
</p>
|
||||
<p className="text-[10px] text-gray-600 text-center mt-1">— {verse.reference}</p>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="px-4 mb-5">
|
||||
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Quick Actions</h2>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<QuickAction href="/nur" icon={Bot} label="Nur AI" color="bg-amber-900/30 text-amber-300" />
|
||||
<QuickAction href="/souq" icon={ShoppingBag} label="Souq" color="bg-[#D4AF37]/15 text-[#D4AF37]" />
|
||||
<QuickAction href="/halal-monitor" icon={MapPin} label="Halal" color="bg-emerald-900/30 text-emerald-300" />
|
||||
<QuickAction href="/wallet" icon={Wallet} label="Wallet" color="bg-blue-900/30 text-blue-300" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Row */}
|
||||
<div className="mx-4 mb-5">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<StatCard label="FLH Balance" value={user.flhBalance.toLocaleString()} icon={Zap} color="text-[#D4AF37]" />
|
||||
<StatCard label="AI Today" value={`${user.dailyMsgCount}/10`} icon={Bot} color="text-amber-400" />
|
||||
<StatCard label="Tier" value={isPro ? "Pro" : isPremium ? "Premium" : "Free"} icon={Crown}
|
||||
color={isPro ? "text-purple-400" : isPremium ? "text-[#D4AF37]" : "text-gray-500"} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Activity Feed */}
|
||||
<div className="px-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wider">Activity</h2>
|
||||
<Link href="/forum" className="text-[11px] text-[#D4AF37]">View all</Link>
|
||||
</div>
|
||||
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-5 text-center">
|
||||
<MessageCircle size={24} className="mx-auto text-gray-700 mb-2" />
|
||||
<p className="text-sm text-gray-500">No recent activity</p>
|
||||
<p className="text-xs text-gray-700 mt-1">Start a conversation with Nur AI</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Premium upsell at bottom for free users */}
|
||||
{!isPremium && (
|
||||
<div className="mx-4 mt-5 bg-gradient-to-r from-[#D4AF37]/10 to-purple-900/10 border border-[#D4AF37]/20 rounded-2xl p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-2xl bg-[#D4AF37]/15 flex items-center justify-center shrink-0">
|
||||
<Star size={20} className="text-[#D4AF37]" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-white text-sm">Unlock Your Full Potential</h3>
|
||||
<p className="text-xs text-gray-500 mt-1">Premium gives you unlimited AI, halal monitor, and marketplace access.</p>
|
||||
<Link href="/upgrade"
|
||||
className="inline-block mt-3 text-xs font-semibold text-[#D4AF37] border border-[#D4AF37]/40 rounded-full px-4 py-1.5 active:bg-[#D4AF37]/10 transition">
|
||||
See Plans
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuickAction({ href, icon: Icon, label, color }: { href: string; icon: any; label: string; color: string }) {
|
||||
return (
|
||||
<Link href={href} className="flex flex-col items-center gap-1.5 active:scale-95 transition">
|
||||
<div className={`w-14 h-14 rounded-2xl flex items-center justify-center ${color} bg-opacity-20`}>
|
||||
<Icon size={22} />
|
||||
</div>
|
||||
<span className="text-[10px] text-gray-500 font-medium">{label}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ label, value, icon: Icon, color }: { label: string; value: string; icon: any; color: string }) {
|
||||
return (
|
||||
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-3 text-center">
|
||||
<Icon size={14} className={`mx-auto mb-1 ${color}`} />
|
||||
<p className="text-sm font-bold text-white">{value}</p>
|
||||
<p className="text-[9px] text-gray-600 mt-0.5">{label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingScreen() {
|
||||
return (
|
||||
<div className="min-h-dvh bg-[#0a0a0f] flex items-center justify-center">
|
||||
<div className="w-8 h-8 rounded-full border-2 border-[#D4AF37]/30 border-t-[#D4AF37] animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export default function ProfileLoading() {
|
||||
return (
|
||||
<div className="min-h-dvh bg-[#0a0a0f] flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full border-2 border-[#D4AF37]/30 border-t-[#D4AF37] animate-spin" />
|
||||
<p className="text-xs text-gray-600">Loading profile...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, FormEvent } from "react";
|
||||
import { useAuth } from "@/lib/AuthContext";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { getUserTier } from "@/lib/tiers";
|
||||
import { PERSONAS } from "@/lib/personas";
|
||||
import {
|
||||
User,
|
||||
Crown,
|
||||
Zap,
|
||||
LogOut,
|
||||
ChevronRight,
|
||||
Save,
|
||||
Sparkles,
|
||||
Shield,
|
||||
} from "lucide-react";
|
||||
|
||||
const VALID_MADHABS = ["Hanafi", "Maliki", "Shafi'i", "Hanbali"];
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { user, token, loading, logout, refreshUser } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
// Editable fields
|
||||
const [name, setName] = useState("");
|
||||
const [preferredName, setPreferredName] = useState("");
|
||||
const [coachPersona, setCoachPersona] = useState("");
|
||||
const [madhab, setMadhab] = useState("");
|
||||
const [coachingGoals, setCoachingGoals] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveMessage, setSaveMessage] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !token) router.push("/login");
|
||||
}, [loading, token, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setName(user.name || "");
|
||||
setPreferredName(user.preferredName || "");
|
||||
setCoachPersona(user.coachPersona || "");
|
||||
setMadhab(user.madhab || "");
|
||||
setCoachingGoals(user.coachingGoals || "");
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const tier = user ? getUserTier(user.isPremium, user.isPro) : "free";
|
||||
|
||||
const tierColors = {
|
||||
free: { bg: "bg-gray-900/50", text: "text-gray-400", border: "border-gray-700/50", icon: User },
|
||||
premium: { bg: "bg-[#D4AF37]/10", text: "text-[#D4AF37]", border: "border-[#D4AF37]/30", icon: Sparkles },
|
||||
pro: { bg: "bg-purple-900/20", text: "text-purple-400", border: "border-purple-500/30", icon: Crown },
|
||||
};
|
||||
|
||||
const tc = tierColors[tier];
|
||||
|
||||
const handleSave = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!token) return;
|
||||
setSaving(true);
|
||||
setSaveMessage("");
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/auth/profile", {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: name.trim() || undefined,
|
||||
preferredName: preferredName.trim() || undefined,
|
||||
coachPersona: coachPersona || undefined,
|
||||
madhab: madhab || undefined,
|
||||
coachingGoals: coachingGoals.trim() || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.error || "Save failed");
|
||||
}
|
||||
|
||||
setSaveMessage("Profile updated successfully");
|
||||
await refreshUser();
|
||||
|
||||
setTimeout(() => setSaveMessage(""), 3000);
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Failed to save profile";
|
||||
setSaveMessage(message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
router.push("/login");
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-dvh bg-[#0a0a0f] flex items-center justify-center">
|
||||
<div className="w-8 h-8 rounded-full border-2 border-[#D4AF37]/30 border-t-[#D4AF37] animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh bg-[#0a0a0f] pb-24">
|
||||
{/* Header */}
|
||||
<div className="px-4 pt-6 pb-2">
|
||||
<h1 className="text-xl font-bold text-white">Profile</h1>
|
||||
</div>
|
||||
|
||||
<div className="px-4 space-y-5 animate-fade-in">
|
||||
{/* User Card */}
|
||||
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-5">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-14 h-14 rounded-full bg-[#D4AF37]/15 flex items-center justify-center">
|
||||
<User size={24} className="text-[#D4AF37]" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-lg font-bold text-white">
|
||||
{user.preferredName || user.name}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tier Badge */}
|
||||
<div
|
||||
className={`mt-4 flex items-center justify-between ${tc.bg} ${tc.border} border rounded-xl px-4 py-2.5`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<tc.icon size={16} className={tc.text} />
|
||||
<span className={`text-sm font-semibold ${tc.text}`}>
|
||||
{tier === "free"
|
||||
? "Free"
|
||||
: tier === "premium"
|
||||
? "Premium"
|
||||
: "Pro"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Zap size={14} className="text-gray-600" />
|
||||
<span className="text-sm text-gray-400">
|
||||
{tier === "pro"
|
||||
? "Unlimited"
|
||||
: `${user.dailyMsgCount}/10 AI today`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* FLH Balance */}
|
||||
<div className="mt-3 flex items-center justify-between bg-[#1a1a24] rounded-xl px-4 py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-400">
|
||||
FLH Balance
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-lg font-bold text-[#D4AF37]">
|
||||
{user.flhBalance.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Edit Profile Form */}
|
||||
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-5">
|
||||
<h3 className="text-sm font-semibold text-white mb-4">
|
||||
Edit Profile
|
||||
</h3>
|
||||
|
||||
{saveMessage && (
|
||||
<div
|
||||
className={`mb-4 p-3 rounded-xl text-sm text-center ${
|
||||
saveMessage === "Profile updated successfully"
|
||||
? "bg-emerald-900/20 border border-emerald-800/40 text-emerald-400"
|
||||
: "bg-red-900/20 border border-red-800/40 text-red-400"
|
||||
}`}
|
||||
>
|
||||
{saveMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSave} className="space-y-4">
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1.5 uppercase tracking-wider">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full bg-[#1a1a24] border border-gray-800/60 rounded-xl px-4 py-3 text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/50 focus:ring-1 focus:ring-[#D4AF37]/30 transition"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Preferred Name */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1.5 uppercase tracking-wider">
|
||||
Preferred Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={preferredName}
|
||||
onChange={(e) => setPreferredName(e.target.value)}
|
||||
placeholder="How Nur AI should address you"
|
||||
className="w-full bg-[#1a1a24] border border-gray-800/60 rounded-xl px-4 py-3 text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/50 focus:ring-1 focus:ring-[#D4AF37]/30 transition"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Coach Persona */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1.5 uppercase tracking-wider">
|
||||
Coach Persona
|
||||
</label>
|
||||
<select
|
||||
value={coachPersona}
|
||||
onChange={(e) => setCoachPersona(e.target.value)}
|
||||
className="w-full bg-[#1a1a24] border border-gray-800/60 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-[#D4AF37]/50 focus:ring-1 focus:ring-[#D4AF37]/30 transition appearance-none"
|
||||
>
|
||||
<option value="">Default</option>
|
||||
{PERSONAS.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.icon} {p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Madhab */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1.5 uppercase tracking-wider">
|
||||
Madhab (School of Thought)
|
||||
</label>
|
||||
<select
|
||||
value={madhab}
|
||||
onChange={(e) => setMadhab(e.target.value)}
|
||||
className="w-full bg-[#1a1a24] border border-gray-800/60 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-[#D4AF37]/50 focus:ring-1 focus:ring-[#D4AF37]/30 transition appearance-none"
|
||||
>
|
||||
<option value="">Not specified</option>
|
||||
{VALID_MADHABS.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{m}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Coaching Goals */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1.5 uppercase tracking-wider">
|
||||
Coaching Goals
|
||||
</label>
|
||||
<textarea
|
||||
value={coachingGoals}
|
||||
onChange={(e) => setCoachingGoals(e.target.value)}
|
||||
placeholder="What would you like to work on?"
|
||||
rows={3}
|
||||
className="w-full bg-[#1a1a24] border border-gray-800/60 rounded-xl px-4 py-3 text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/50 focus:ring-1 focus:ring-[#D4AF37]/30 transition resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-[#D4AF37] text-[#0a0a0f] font-semibold text-sm active:opacity-80 disabled:opacity-50 transition"
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<span className="w-4 h-4 rounded-full border-2 border-[#0a0a0f]/30 border-t-[#0a0a0f] animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save size={16} />
|
||||
Save Changes
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Account Info */}
|
||||
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-5">
|
||||
<h3 className="text-sm font-semibold text-white mb-3">
|
||||
Account Details
|
||||
</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Member since</span>
|
||||
<span className="text-gray-300">
|
||||
{new Date(user.createdAt ?? "").toLocaleDateString("en-MY", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{user.trialEndsAt && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Trial ends</span>
|
||||
<span className="text-gray-300">
|
||||
{new Date(user.trialEndsAt).toLocaleDateString("en-MY", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{user.madhab && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Madhab</span>
|
||||
<span className="text-gray-300">{user.madhab}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logout */}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center justify-between py-3.5 px-5 rounded-xl bg-red-900/10 border border-red-800/30 text-red-400 active:bg-red-900/20 transition"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<LogOut size={16} />
|
||||
<span className="text-sm font-medium">Sign Out</span>
|
||||
</div>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
|
||||
<div className="h-4" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
"use client";
|
||||
|
||||
import { useState, FormEvent } from "react";
|
||||
import { useAuth } from "@/lib/AuthContext";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function RegisterPage() {
|
||||
const { register } = useAuth();
|
||||
const router = useRouter();
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
if (!name.trim() || !email.trim() || !password.trim()) {
|
||||
setError("Please fill in all fields");
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
setError("Password must be at least 6 characters");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await register(email.trim(), name.trim(), password);
|
||||
router.push("/");
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Registration failed. Please try again.";
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh bg-[#0a0a0f] flex flex-col items-center justify-center px-6">
|
||||
<div className="w-full max-w-sm">
|
||||
{/* Logo / Brand */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-[#D4AF37]/20 to-[#D4AF37]/5 border border-[#D4AF37]/30 flex items-center justify-center mx-auto mb-4">
|
||||
<span className="text-3xl">☪️</span>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white">Join Falah</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Start your Islamic lifestyle journey
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="mb-4 p-3 rounded-xl bg-red-900/20 border border-red-800/40 text-red-400 text-sm text-center">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="block text-xs font-medium text-gray-500 mb-1.5 uppercase tracking-wider"
|
||||
>
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Your name"
|
||||
autoComplete="name"
|
||||
autoFocus
|
||||
className="w-full bg-[#111118] border border-gray-800/60 rounded-xl px-4 py-3 text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/50 focus:ring-1 focus:ring-[#D4AF37]/30 transition"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-xs font-medium text-gray-500 mb-1.5 uppercase tracking-wider"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
autoComplete="email"
|
||||
className="w-full bg-[#111118] border border-gray-800/60 rounded-xl px-4 py-3 text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/50 focus:ring-1 focus:ring-[#D4AF37]/30 transition"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-xs font-medium text-gray-500 mb-1.5 uppercase tracking-wider"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="At least 6 characters"
|
||||
autoComplete="new-password"
|
||||
className="w-full bg-[#111118] border border-gray-800/60 rounded-xl px-4 py-3 text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/50 focus:ring-1 focus:ring-[#D4AF37]/30 transition"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-3 rounded-xl bg-[#D4AF37] text-[#0a0a0f] font-semibold text-sm active:opacity-80 disabled:opacity-50 transition"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<span className="w-4 h-4 rounded-full border-2 border-[#0a0a0f]/30 border-t-[#0a0a0f] animate-spin" />
|
||||
Creating account...
|
||||
</span>
|
||||
) : (
|
||||
"Create Account"
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Sign in link */}
|
||||
<div className="text-center mt-6">
|
||||
<p className="text-xs text-gray-600">
|
||||
Already have an account?{" "}
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-[#D4AF37] font-medium hover:underline"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Bonus info */}
|
||||
<div className="mt-6 rounded-xl bg-[#111118] border border-gray-800/60 p-3 text-center">
|
||||
<p className="text-[10px] text-gray-600 uppercase tracking-wider mb-1">
|
||||
New Member Bonus
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
Get <span className="text-[#D4AF37] font-semibold">5,000 FLH</span>{" "}
|
||||
free on sign up + 7-day premium trial
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,727 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useAuth } from "@/lib/AuthContext";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
ShoppingBag,
|
||||
Search,
|
||||
Plus,
|
||||
X,
|
||||
Crown,
|
||||
Zap,
|
||||
Star,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Loader2,
|
||||
FileText,
|
||||
Image,
|
||||
Music,
|
||||
Video,
|
||||
Code,
|
||||
BookOpen,
|
||||
Grid3X3,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
|
||||
const CATEGORIES = ["All", "E-Books", "Courses", "Design", "Audio", "Video", "Software", "Other"];
|
||||
|
||||
const CATEGORY_ICONS: Record<string, any> = {
|
||||
"E-Books": BookOpen,
|
||||
Courses: BookOpen,
|
||||
Design: Image,
|
||||
Audio: Music,
|
||||
Video: Video,
|
||||
Software: Code,
|
||||
Other: Grid3X3,
|
||||
};
|
||||
|
||||
interface Listing {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
category: string;
|
||||
priceFlh: number;
|
||||
featured: boolean;
|
||||
featuredUntil: string | null;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
seller: {
|
||||
id: string;
|
||||
name: string;
|
||||
isPremium: boolean;
|
||||
isPro: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export default function SouqPage() {
|
||||
const { user, token, loading } = useAuth();
|
||||
const router = useRouter();
|
||||
const [listings, setListings] = useState<Listing[]>([]);
|
||||
const [loadingListings, setLoadingListings] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Filters
|
||||
const [activeCategory, setActiveCategory] = useState("All");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
// Create listing modal
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [createForm, setCreateForm] = useState({
|
||||
title: "",
|
||||
description: "",
|
||||
category: "Other",
|
||||
priceFlh: "",
|
||||
});
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
|
||||
// Purchase flow
|
||||
const [purchasingId, setPurchasingId] = useState<string | null>(null);
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState<{
|
||||
listing: Listing;
|
||||
} | null>(null);
|
||||
const [purchaseResult, setPurchaseResult] = useState<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
} | null>(null);
|
||||
|
||||
const fetchListings = useCallback(async () => {
|
||||
setLoadingListings(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (activeCategory !== "All") params.set("category", activeCategory);
|
||||
if (searchQuery.trim()) params.set("search", searchQuery.trim());
|
||||
|
||||
const res = await fetch(`/api/marketplace/listings?${params.toString()}`);
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setListings(data.listings);
|
||||
} else {
|
||||
setError(data.error || "Failed to load listings");
|
||||
}
|
||||
} catch {
|
||||
setError("Network error. Please try again.");
|
||||
} finally {
|
||||
setLoadingListings(false);
|
||||
}
|
||||
}, [activeCategory, searchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !token) {
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
}, [loading, token, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
fetchListings();
|
||||
}
|
||||
}, [token, fetchListings]);
|
||||
|
||||
// Debounce search
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
if (token) fetchListings();
|
||||
}, 400);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery]);
|
||||
|
||||
const handleCreateListing = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setCreateError(null);
|
||||
setCreating(true);
|
||||
|
||||
try {
|
||||
const priceNum = parseInt(createForm.priceFlh, 10);
|
||||
if (isNaN(priceNum) || priceNum < 1) {
|
||||
setCreateError("Price must be a positive whole number");
|
||||
setCreating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch("/api/marketplace/listings", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: createForm.title,
|
||||
description: createForm.description,
|
||||
category: createForm.category,
|
||||
priceFlh: priceNum,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setShowCreateModal(false);
|
||||
setCreateForm({ title: "", description: "", category: "Other", priceFlh: "" });
|
||||
fetchListings();
|
||||
} else {
|
||||
setCreateError(data.error || "Failed to create listing");
|
||||
}
|
||||
} catch {
|
||||
setCreateError("Network error. Please try again.");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePurchaseClick = (listing: Listing) => {
|
||||
setShowConfirmDialog({ listing });
|
||||
setPurchaseResult(null);
|
||||
};
|
||||
|
||||
const confirmPurchase = async () => {
|
||||
if (!showConfirmDialog) return;
|
||||
const listing = showConfirmDialog.listing;
|
||||
setPurchasingId(listing.id);
|
||||
setPurchaseResult(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/marketplace/purchase", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ listingId: listing.id }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setPurchaseResult({ success: true, message: data.message });
|
||||
setListings((prev) => prev.filter((l) => l.id !== listing.id));
|
||||
} else {
|
||||
setPurchaseResult({ success: false, message: data.error || "Purchase failed" });
|
||||
}
|
||||
} catch {
|
||||
setPurchaseResult({ success: false, message: "Network error. Please try again." });
|
||||
} finally {
|
||||
setPurchasingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const canSell = user?.isPremium || user?.isPro;
|
||||
|
||||
const CategoryIcon = activeCategory !== "All" && CATEGORY_ICONS[activeCategory]
|
||||
? CATEGORY_ICONS[activeCategory]
|
||||
: ShoppingBag;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-dvh bg-[#0a0a0f] flex items-center justify-center">
|
||||
<div className="w-8 h-8 rounded-full border-2 border-[#D4AF37]/30 border-t-[#D4AF37] animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh bg-[#0a0a0f] pb-28">
|
||||
{/* Header */}
|
||||
<div className="px-4 pt-6 pb-4">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-9 h-9 rounded-xl bg-[#D4AF37]/15 flex items-center justify-center">
|
||||
<ShoppingBag size={18} className="text-[#D4AF37]" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-white">Souq</h1>
|
||||
<p className="text-[10px] text-gray-500">Islamic Marketplace</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{canSell && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setCreateForm({ title: "", description: "", category: "Other", priceFlh: "" });
|
||||
setCreateError(null);
|
||||
setShowCreateModal(true);
|
||||
}}
|
||||
className="flex items-center gap-1.5 bg-[#D4AF37]/15 border border-[#D4AF37]/30 rounded-xl px-3 py-2 active:bg-[#D4AF37]/25 transition"
|
||||
>
|
||||
<Plus size={14} className="text-[#D4AF37]" />
|
||||
<span className="text-xs font-medium text-[#D4AF37]">Sell</span>
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 bg-gray-900/60 border border-gray-800/60 rounded-xl px-3 py-2">
|
||||
<Zap size={14} className="text-[#D4AF37]" />
|
||||
<span className="text-xs font-bold text-white">
|
||||
{user.flhBalance.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-[9px] text-gray-600">FLH</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Platform fee banner */}
|
||||
<div className="mx-4 mb-3 bg-gradient-to-r from-amber-900/15 to-gray-900/50 border border-amber-800/20 rounded-xl px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle size={12} className="text-amber-400 shrink-0" />
|
||||
<p className="text-[10px] text-amber-300/80">
|
||||
Platform fee: 1.5% for standard listings, <strong>0% for Pro sellers</strong>.
|
||||
All prices in FLH (Falah Coins).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search bar */}
|
||||
<div className="px-4 mb-3">
|
||||
<div className="relative">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search listings..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full bg-[#111118] border border-gray-800/60 rounded-xl pl-10 pr-4 py-2.5 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/40 transition"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category pills */}
|
||||
<div className="px-4 mb-4 overflow-x-auto">
|
||||
<div className="flex gap-2 pb-1" style={{ minWidth: "max-content" }}>
|
||||
{CATEGORIES.map((cat) => {
|
||||
const Icon = cat !== "All" && CATEGORY_ICONS[cat] ? CATEGORY_ICONS[cat] : ShoppingBag;
|
||||
const isActive = activeCategory === cat;
|
||||
return (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => setActiveCategory(cat)}
|
||||
className={`flex items-center gap-1.5 px-3.5 py-2 rounded-xl text-xs font-medium whitespace-nowrap transition-all ${
|
||||
isActive
|
||||
? "bg-[#D4AF37]/20 text-[#D4AF37] border border-[#D4AF37]/30"
|
||||
: "bg-[#111118] text-gray-500 border border-gray-800/60 active:bg-gray-800/60"
|
||||
}`}
|
||||
>
|
||||
<Icon size={13} />
|
||||
{cat}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Listings grid */}
|
||||
<div className="px-4">
|
||||
{loadingListings ? (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<Loader2 size={32} className="text-[#D4AF37] animate-spin mb-3" />
|
||||
<p className="text-sm text-gray-500">Loading listings...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="bg-red-900/10 border border-red-800/30 rounded-2xl p-6 text-center">
|
||||
<AlertTriangle size={32} className="mx-auto text-red-400 mb-2" />
|
||||
<p className="text-sm text-red-300">{error}</p>
|
||||
<button
|
||||
onClick={fetchListings}
|
||||
className="mt-3 text-xs text-[#D4AF37] underline"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
) : listings.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<div className="w-16 h-16 rounded-2xl bg-gray-800/40 flex items-center justify-center mb-4">
|
||||
<ShoppingBag size={28} className="text-gray-600" />
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-gray-400 mb-1">
|
||||
{searchQuery || activeCategory !== "All"
|
||||
? "No listings found"
|
||||
: "Marketplace is empty"}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-600 text-center max-w-xs">
|
||||
{searchQuery || activeCategory !== "All"
|
||||
? "Try a different search or category"
|
||||
: canSell
|
||||
? "Be the first to create a listing!"
|
||||
: "No listings available yet. Check back soon."}
|
||||
</p>
|
||||
{canSell && !searchQuery && activeCategory === "All" && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setCreateForm({ title: "", description: "", category: "Other", priceFlh: "" });
|
||||
setCreateError(null);
|
||||
setShowCreateModal(true);
|
||||
}}
|
||||
className="mt-4 flex items-center gap-2 bg-[#D4AF37]/15 border border-[#D4AF37]/30 rounded-xl px-4 py-2.5 active:bg-[#D4AF37]/25 transition"
|
||||
>
|
||||
<Plus size={16} className="text-[#D4AF37]" />
|
||||
<span className="text-sm font-medium text-[#D4AF37]">Create Listing</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{listings.map((listing) => (
|
||||
<ListingCard
|
||||
key={listing.id}
|
||||
listing={listing}
|
||||
onBuy={() => handlePurchaseClick(listing)}
|
||||
userBalance={user.flhBalance}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create listing modal */}
|
||||
{showCreateModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
/>
|
||||
<div className="relative w-full sm:max-w-md bg-[#111118] border border-gray-800/60 rounded-t-2xl sm:rounded-2xl p-5 animate-fade-in max-h-[90dvh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Plus size={18} className="text-[#D4AF37]" />
|
||||
<h2 className="text-base font-bold text-white">New Listing</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
className="w-8 h-8 rounded-full bg-gray-800/60 flex items-center justify-center active:bg-gray-700/60 transition"
|
||||
>
|
||||
<X size={16} className="text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleCreateListing} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1.5">
|
||||
Title *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={createForm.title}
|
||||
onChange={(e) =>
|
||||
setCreateForm((f) => ({ ...f, title: e.target.value }))
|
||||
}
|
||||
placeholder="e.g. Tafsir Ibn Kathir PDF"
|
||||
required
|
||||
maxLength={100}
|
||||
className="w-full bg-[#0a0a0f] border border-gray-800/60 rounded-xl px-3.5 py-2.5 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/40 transition"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1.5">
|
||||
Description *
|
||||
</label>
|
||||
<textarea
|
||||
value={createForm.description}
|
||||
onChange={(e) =>
|
||||
setCreateForm((f) => ({ ...f, description: e.target.value }))
|
||||
}
|
||||
placeholder="Describe your listing..."
|
||||
required
|
||||
maxLength={2000}
|
||||
rows={3}
|
||||
className="w-full bg-[#0a0a0f] border border-gray-800/60 rounded-xl px-3.5 py-2.5 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/40 transition resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1.5">
|
||||
Category *
|
||||
</label>
|
||||
<select
|
||||
value={createForm.category}
|
||||
onChange={(e) =>
|
||||
setCreateForm((f) => ({ ...f, category: e.target.value }))
|
||||
}
|
||||
className="w-full bg-[#0a0a0f] border border-gray-800/60 rounded-xl px-3.5 py-2.5 text-sm text-white focus:outline-none focus:border-[#D4AF37]/40 transition appearance-none"
|
||||
style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E")`,
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundPosition: "right 12px center",
|
||||
}}
|
||||
>
|
||||
{CATEGORIES.filter((c) => c !== "All").map((cat) => (
|
||||
<option key={cat} value={cat}>
|
||||
{cat}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1.5">
|
||||
Price (FLH) *
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="number"
|
||||
value={createForm.priceFlh}
|
||||
onChange={(e) =>
|
||||
setCreateForm((f) => ({ ...f, priceFlh: e.target.value }))
|
||||
}
|
||||
placeholder="100"
|
||||
required
|
||||
min={1}
|
||||
className="w-full bg-[#0a0a0f] border border-gray-800/60 rounded-xl pl-3.5 pr-10 py-2.5 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/40 transition"
|
||||
/>
|
||||
<span className="absolute right-3.5 top-1/2 -translate-y-1/2 text-xs font-medium text-[#D4AF37]">
|
||||
FLH
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{createError && (
|
||||
<div className="bg-red-900/10 border border-red-800/30 rounded-xl p-3">
|
||||
<p className="text-xs text-red-300">{createError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
className="flex-1 py-2.5 rounded-xl text-sm font-medium text-gray-500 bg-gray-800/40 border border-gray-800/60 active:bg-gray-700/40 transition"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={creating}
|
||||
className="flex-1 py-2.5 rounded-xl text-sm font-semibold text-[#0a0a0f] bg-[#D4AF37] active:bg-[#c5a233] transition flex items-center justify-center gap-2 disabled:opacity-60"
|
||||
>
|
||||
{creating ? (
|
||||
<>
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
"Create Listing"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Purchase confirmation dialog */}
|
||||
{showConfirmDialog && (
|
||||
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={() => {
|
||||
if (!purchasingId) {
|
||||
setShowConfirmDialog(null);
|
||||
setPurchaseResult(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="relative w-full sm:max-w-sm bg-[#111118] border border-gray-800/60 rounded-t-2xl sm:rounded-2xl p-5 animate-fade-in">
|
||||
{purchaseResult ? (
|
||||
<div className="text-center py-4">
|
||||
{purchaseResult.success ? (
|
||||
<>
|
||||
<div className="w-14 h-14 rounded-full bg-emerald-900/30 flex items-center justify-center mx-auto mb-3">
|
||||
<CheckCircle size={28} className="text-emerald-400" />
|
||||
</div>
|
||||
<h3 className="text-base font-bold text-white mb-2">Purchase Successful!</h3>
|
||||
<p className="text-xs text-gray-400 mb-4">{purchaseResult.message}</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-14 h-14 rounded-full bg-red-900/30 flex items-center justify-center mx-auto mb-3">
|
||||
<AlertTriangle size={28} className="text-red-400" />
|
||||
</div>
|
||||
<h3 className="text-base font-bold text-white mb-2">Purchase Failed</h3>
|
||||
<p className="text-xs text-gray-400 mb-4">{purchaseResult.message}</p>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowConfirmDialog(null);
|
||||
setPurchaseResult(null);
|
||||
}}
|
||||
className="w-full py-2.5 rounded-xl text-sm font-semibold text-[#0a0a0f] bg-[#D4AF37] active:bg-[#c5a233] transition"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<ShoppingBag size={18} className="text-[#D4AF37]" />
|
||||
<h2 className="text-base font-bold text-white">Confirm Purchase</h2>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#0a0a0f] rounded-xl p-3 mb-4">
|
||||
<p className="text-sm font-semibold text-white mb-1">
|
||||
{showConfirmDialog.listing.title}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap size={14} className="text-[#D4AF37]" />
|
||||
<span className="text-lg font-bold text-[#D4AF37]">
|
||||
{showConfirmDialog.listing.priceFlh.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-xs text-gray-600">FLH</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-2">
|
||||
<span className="text-[10px] text-gray-500">From:</span>
|
||||
<span className="text-[10px] font-medium text-gray-300">
|
||||
{showConfirmDialog.listing.seller.name}
|
||||
</span>
|
||||
{showConfirmDialog.listing.seller.isPro && (
|
||||
<span className="flex items-center gap-0.5 text-[9px] text-purple-400">
|
||||
<Crown size={9} /> Pro
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#0a0a0f] rounded-xl p-3 mb-4">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-xs text-gray-500">Your balance</span>
|
||||
<span className="text-xs font-medium text-white">
|
||||
{user.flhBalance.toLocaleString()} FLH
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-xs text-gray-500">Cost</span>
|
||||
<span className="text-xs font-medium text-red-400">
|
||||
-{showConfirmDialog.listing.priceFlh.toLocaleString()} FLH
|
||||
</span>
|
||||
</div>
|
||||
<div className="border-t border-gray-800/60 pt-1.5 mt-1.5 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-gray-400">Remaining</span>
|
||||
<span className="text-xs font-bold text-white">
|
||||
{(user.flhBalance - showConfirmDialog.listing.priceFlh).toLocaleString()} FLH
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{user.flhBalance < showConfirmDialog.listing.priceFlh && (
|
||||
<div className="bg-red-900/10 border border-red-800/30 rounded-xl p-3 mb-4">
|
||||
<p className="text-xs text-red-300">
|
||||
Insufficient balance. You need {showConfirmDialog.listing.priceFlh.toLocaleString()} FLH
|
||||
but only have {user.flhBalance.toLocaleString()} FLH.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowConfirmDialog(null);
|
||||
setPurchaseResult(null);
|
||||
}}
|
||||
disabled={!!purchasingId}
|
||||
className="flex-1 py-2.5 rounded-xl text-sm font-medium text-gray-500 bg-gray-800/40 border border-gray-800/60 active:bg-gray-700/40 transition disabled:opacity-40"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={confirmPurchase}
|
||||
disabled={
|
||||
purchasingId === showConfirmDialog.listing.id ||
|
||||
user.flhBalance < showConfirmDialog.listing.priceFlh
|
||||
}
|
||||
className="flex-1 py-2.5 rounded-xl text-sm font-semibold text-[#0a0a0f] bg-[#D4AF37] active:bg-[#c5a233] transition flex items-center justify-center gap-2 disabled:opacity-60"
|
||||
>
|
||||
{purchasingId === showConfirmDialog.listing.id ? (
|
||||
<>
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
"Confirm Purchase"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ListingCard({
|
||||
listing,
|
||||
onBuy,
|
||||
userBalance,
|
||||
}: {
|
||||
listing: Listing;
|
||||
onBuy: () => void;
|
||||
userBalance: number;
|
||||
}) {
|
||||
const CategoryIcon = CATEGORY_ICONS[listing.category] || FileText;
|
||||
const canAfford = userBalance >= listing.priceFlh;
|
||||
|
||||
return (
|
||||
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl overflow-hidden active:scale-[0.98] transition-transform">
|
||||
{/* Image placeholder */}
|
||||
<div className="h-28 bg-gradient-to-br from-gray-800/60 to-gray-900/60 flex items-center justify-center relative">
|
||||
<div className="w-10 h-10 rounded-xl bg-[#D4AF37]/10 flex items-center justify-center">
|
||||
<CategoryIcon size={20} className="text-[#D4AF37]/60" />
|
||||
</div>
|
||||
{listing.featured && (
|
||||
<div className="absolute top-2 right-2 flex items-center gap-1 bg-[#D4AF37]/20 border border-[#D4AF37]/30 rounded-full px-2 py-0.5">
|
||||
<Star size={10} className="text-[#D4AF37]" />
|
||||
<span className="text-[8px] font-bold text-[#D4AF37]">Featured</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-3">
|
||||
<h3 className="text-sm font-semibold text-white truncate mb-1">
|
||||
{listing.title}
|
||||
</h3>
|
||||
<p className="text-[10px] text-gray-600 line-clamp-2 mb-2 leading-relaxed">
|
||||
{listing.description}
|
||||
</p>
|
||||
|
||||
{/* Seller info */}
|
||||
<div className="flex items-center gap-1 mb-2">
|
||||
<span className="text-[9px] text-gray-600">by</span>
|
||||
<span className="text-[9px] font-medium text-gray-400 truncate max-w-[80px]">
|
||||
{listing.seller.name}
|
||||
</span>
|
||||
{listing.seller.isPro && (
|
||||
<Crown size={8} className="text-purple-400 shrink-0" />
|
||||
)}
|
||||
{listing.seller.isPremium && !listing.seller.isPro && (
|
||||
<Star size={8} className="text-[#D4AF37] shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Price and buy */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<Zap size={12} className="text-[#D4AF37]" />
|
||||
<span className="text-sm font-bold text-[#D4AF37]">
|
||||
{listing.priceFlh.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-[8px] text-gray-600">FLH</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onBuy}
|
||||
disabled={!canAfford}
|
||||
className={`text-[10px] font-semibold px-3 py-1.5 rounded-lg transition ${
|
||||
canAfford
|
||||
? "bg-[#D4AF37] text-[#0a0a0f] active:bg-[#c5a233]"
|
||||
: "bg-gray-800/60 text-gray-600 cursor-not-allowed"
|
||||
}`}
|
||||
>
|
||||
{canAfford ? "Buy" : "Insufficient"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, Suspense } from "react";
|
||||
import { useAuth } from "@/lib/AuthContext";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Crown, Zap, Check, ChevronLeft, Loader2 } from "lucide-react";
|
||||
|
||||
const TIERS = [
|
||||
{
|
||||
id: "free",
|
||||
name: "Free",
|
||||
price: "$0",
|
||||
period: "forever",
|
||||
icon: Zap,
|
||||
color: "text-gray-400",
|
||||
bgColor: "bg-gray-900/50",
|
||||
borderColor: "border-gray-700/50",
|
||||
features: [
|
||||
"10 AI messages per day",
|
||||
"NurBuddy persona only",
|
||||
"Basic halal monitor (10 queries/day)",
|
||||
"Browse marketplace",
|
||||
"Standard FLH earning rate",
|
||||
],
|
||||
cta: "Current Plan",
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
id: "premium",
|
||||
name: "Premium",
|
||||
usd: 4.99,
|
||||
price: "$4.99",
|
||||
period: "per month",
|
||||
priceId: "price_premium_monthly",
|
||||
icon: Crown,
|
||||
color: "text-[#D4AF37]",
|
||||
bgColor: "bg-gradient-to-br from-[#D4AF37]/20 to-[#0a0a0f]",
|
||||
borderColor: "border-[#D4AF37]/50",
|
||||
features: [
|
||||
"100 AI messages per day",
|
||||
"All 4 coach personas",
|
||||
"Unlimited halal monitor queries",
|
||||
"Sell on marketplace (up to 20 items)",
|
||||
"2x FLH earning rate",
|
||||
"Forum posting access",
|
||||
"Priority support",
|
||||
],
|
||||
cta: "Go Premium",
|
||||
badge: "MOST POPULAR",
|
||||
highlighted: true,
|
||||
},
|
||||
{
|
||||
id: "pro",
|
||||
name: "Pro",
|
||||
usd: 9.99,
|
||||
price: "$9.99",
|
||||
period: "per month",
|
||||
priceId: "price_pro_monthly",
|
||||
icon: Crown,
|
||||
color: "text-purple-400",
|
||||
bgColor: "bg-purple-900/20",
|
||||
borderColor: "border-purple-500/30",
|
||||
features: [
|
||||
"Unlimited AI messages",
|
||||
"All 6 coach personas",
|
||||
"Unlimited halal monitor queries",
|
||||
"Sell on marketplace (unlimited items)",
|
||||
"3x FLH earning rate",
|
||||
"Forum posting access",
|
||||
"Priority support",
|
||||
"Early access to new features",
|
||||
"Featured listings on marketplace",
|
||||
],
|
||||
cta: "Go Pro",
|
||||
badge: "",
|
||||
highlighted: false,
|
||||
},
|
||||
];
|
||||
|
||||
function UpgradeContent() {
|
||||
const { user, token, loading } = useAuth();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [checkoutLoading, setCheckoutLoading] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<{
|
||||
type: "success" | "error" | "info";
|
||||
text: string;
|
||||
} | null>(null);
|
||||
|
||||
const isPremium = user?.isPremium || user?.isPro;
|
||||
const isPro = user?.isPro;
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !token) router.push("/login");
|
||||
}, [loading, token, router]);
|
||||
|
||||
useEffect(() => {
|
||||
const upgrade = searchParams.get("upgrade");
|
||||
const canceled = searchParams.get("canceled");
|
||||
const tier = searchParams.get("tier");
|
||||
|
||||
if (upgrade === "success") {
|
||||
setMessage({
|
||||
type: "success",
|
||||
text: tier
|
||||
? `Welcome to ${tier === "pro" ? "Pro" : "Premium"}! 🎉`
|
||||
: "Upgrade successful! Welcome aboard 🎉",
|
||||
});
|
||||
// Clean URL
|
||||
const newUrl = window.location.pathname;
|
||||
window.history.replaceState({}, "", newUrl);
|
||||
// Refresh user data
|
||||
if (token) {
|
||||
fetch("/api/auth/me", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
if (d.user) {
|
||||
// The AuthContext refreshUser will pick this up
|
||||
window.location.reload();
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
if (canceled === "true") {
|
||||
setMessage({
|
||||
type: "info",
|
||||
text: "Checkout was canceled. No charges were made.",
|
||||
});
|
||||
const newUrl = window.location.pathname;
|
||||
window.history.replaceState({}, "", newUrl);
|
||||
}
|
||||
}, [searchParams, token]);
|
||||
|
||||
const handleUpgrade = async (tierId: string, priceId: string) => {
|
||||
if (!token) return;
|
||||
setCheckoutLoading(tierId);
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/upgrade/create-checkout", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ priceId }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.error || "Upgrade failed");
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (data.url) {
|
||||
router.push(data.url);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Upgrade failed";
|
||||
setMessage({ type: "error", text: message });
|
||||
setTimeout(() => setMessage(null), 4000);
|
||||
} finally {
|
||||
setCheckoutLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-dvh bg-[#0a0a0f] flex items-center justify-center">
|
||||
<Loader2 size={24} className="animate-spin text-[#D4AF37]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh bg-[#0a0a0f] pb-24">
|
||||
{/* Header */}
|
||||
<div className="px-4 pt-6 pb-2 flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="w-9 h-9 rounded-full bg-[#111118] border border-gray-800/60 flex items-center justify-center active:scale-90 transition"
|
||||
>
|
||||
<ChevronLeft size={18} className="text-gray-400" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-white">Choose your plan</h1>
|
||||
<p className="text-xs text-gray-500">Cancel anytime</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trial Banner */}
|
||||
<div className="mx-4 mt-2 mb-5 bg-gradient-to-r from-[#D4AF37]/10 to-purple-900/10 border border-[#D4AF37]/20 rounded-2xl p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<SparklesIcon />
|
||||
<p className="text-sm text-[#D4AF37] font-medium">
|
||||
New accounts include a 7-day Premium trial — free
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
{message && (
|
||||
<div className="mx-4 mb-4">
|
||||
<div
|
||||
className={`p-3 rounded-xl text-sm text-center ${
|
||||
message.type === "success"
|
||||
? "bg-emerald-900/20 border border-emerald-800/40 text-emerald-400"
|
||||
: message.type === "error"
|
||||
? "bg-red-900/20 border border-red-800/40 text-red-400"
|
||||
: "bg-blue-900/20 border border-blue-800/40 text-blue-400"
|
||||
}`}
|
||||
>
|
||||
{message.text}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tier Cards */}
|
||||
<div className="px-4 space-y-4">
|
||||
{TIERS.map((tier) => {
|
||||
const Icon = tier.icon;
|
||||
const isActive =
|
||||
tier.id === "pro"
|
||||
? isPro
|
||||
: tier.id === "premium"
|
||||
? isPremium
|
||||
: true;
|
||||
const isDisabled = tier.disabled || isActive;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tier.id}
|
||||
className={`relative rounded-2xl p-5 ${tier.bgColor} ${tier.borderColor} border ${
|
||||
tier.highlighted ? "ring-2 ring-[#D4AF37]/30" : ""
|
||||
}`}
|
||||
>
|
||||
{/* Badge */}
|
||||
{tier.badge && (
|
||||
<div className="absolute -top-2.5 right-4 bg-[#D4AF37] text-[#0a0a0f] text-[10px] font-bold uppercase tracking-wider px-3 py-1 rounded-full">
|
||||
{tier.badge}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tier Header */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon size={20} className={tier.color} />
|
||||
<span className={`text-lg font-bold text-white`}>
|
||||
{tier.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xl font-bold text-white">{tier.price}</p>
|
||||
{tier.period && (
|
||||
<p className="text-[11px] text-gray-500">{tier.period}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
<ul className="space-y-2 mb-4">
|
||||
{tier.features.map((feature, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="flex items-start gap-2 text-sm text-gray-400"
|
||||
>
|
||||
<Check
|
||||
size={14}
|
||||
className={`mt-0.5 shrink-0 ${
|
||||
tier.id === "free"
|
||||
? "text-gray-600"
|
||||
: tier.id === "pro"
|
||||
? "text-purple-400"
|
||||
: "text-[#D4AF37]"
|
||||
}`}
|
||||
/>
|
||||
<span>{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* CTA Button */}
|
||||
{tier.id !== "free" && (
|
||||
<button
|
||||
onClick={() =>
|
||||
!isActive && handleUpgrade(tier.id, tier.priceId!)
|
||||
}
|
||||
disabled={isDisabled || checkoutLoading === tier.id}
|
||||
className={`w-full py-3 rounded-xl font-semibold text-sm active:opacity-80 disabled:opacity-50 transition flex items-center justify-center gap-2 ${
|
||||
tier.id === "premium"
|
||||
? "bg-[#D4AF37] text-[#0a0a0f]"
|
||||
: "bg-purple-600 text-white"
|
||||
}`}
|
||||
>
|
||||
{checkoutLoading === tier.id ? (
|
||||
<>
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
Processing...
|
||||
</>
|
||||
) : isActive ? (
|
||||
"Current Plan"
|
||||
) : (
|
||||
tier.cta
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{tier.id === "free" && (
|
||||
<div className="w-full py-3 rounded-xl bg-gray-900/50 border border-gray-700/30 text-gray-500 font-semibold text-sm text-center">
|
||||
{tier.cta}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-4 mt-6 text-center">
|
||||
<p className="text-xs text-gray-600">
|
||||
Secure payments via Polar. Cancel anytime from your account settings.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="h-8" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SparklesIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-[#D4AF37] shrink-0"
|
||||
>
|
||||
<path d="M12 3l1.5 5.5L19 10l-5.5 1.5L12 17l-1.5-5.5L5 10l5.5-1.5z" />
|
||||
<path d="M19 17l-1.5-2.5L15 15l1.5 2.5L15 20l2.5-1.5L19 20l-1.5-2.5z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function UpgradePage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="min-h-dvh bg-[#0a0a0f] flex items-center justify-center">
|
||||
<Loader2 size={24} className="animate-spin text-[#D4AF37]" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<UpgradeContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, FormEvent } from "react";
|
||||
import { useAuth } from "@/lib/AuthContext";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Wallet,
|
||||
Zap,
|
||||
Crown,
|
||||
TrendingUp,
|
||||
ArrowUpRight,
|
||||
ArrowDownLeft,
|
||||
Clock,
|
||||
Check,
|
||||
X,
|
||||
Loader2,
|
||||
Sparkles,
|
||||
Info,
|
||||
} from "lucide-react";
|
||||
|
||||
interface CashoutEntry {
|
||||
id: string;
|
||||
amountFlh: number;
|
||||
fiatAmount: number;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const TOP_UP_OPTIONS = [
|
||||
{ flh: 500, usd: 4.99, bonus: "" },
|
||||
{ flh: 1100, usd: 9.99, bonus: "+10% bonus" },
|
||||
{ flh: 3000, usd: 24.99, bonus: "+20% bonus" },
|
||||
];
|
||||
|
||||
const EARNING_TIPS = [
|
||||
"Post in the forum — earn 50 FLH per approved post",
|
||||
"List items on Souq marketplace",
|
||||
"Complete daily AI coaching sessions",
|
||||
"Refer friends — earn 200 FLH per referral",
|
||||
"Participate in community challenges",
|
||||
];
|
||||
|
||||
export default function WalletPage() {
|
||||
const { user, token, loading } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const [balance, setBalance] = useState(0);
|
||||
const [cashoutHistory, setCashoutHistory] = useState<CashoutEntry[]>([]);
|
||||
const [cashoutAmount, setCashoutAmount] = useState("");
|
||||
const [cashoutLoading, setCashoutLoading] = useState(false);
|
||||
const [cashoutMessage, setCashoutMessage] = useState<{
|
||||
type: "success" | "error";
|
||||
text: string;
|
||||
} | null>(null);
|
||||
const [topupLoading, setTopupLoading] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !token) router.push("/login");
|
||||
}, [loading, token, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
fetchWallet();
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
const fetchWallet = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/wallet", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setBalance(data.balance);
|
||||
setCashoutHistory(data.cashoutHistory || []);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const handleTopUp = async (amount: number) => {
|
||||
if (!token) return;
|
||||
setTopupLoading(amount);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/wallet/top-up/create-checkout", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ amount }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.error || "Top-up failed");
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (data.url) {
|
||||
router.push(data.url);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Top-up failed";
|
||||
setCashoutMessage({ type: "error", text: message });
|
||||
setTimeout(() => setCashoutMessage(null), 3000);
|
||||
} finally {
|
||||
setTopupLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCashout = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!token) return;
|
||||
|
||||
const amountNum = parseInt(cashoutAmount, 10);
|
||||
if (isNaN(amountNum) || amountNum < 1000) {
|
||||
setCashoutMessage({
|
||||
type: "error",
|
||||
text: "Minimum cashout is 1,000 FLH",
|
||||
});
|
||||
setTimeout(() => setCashoutMessage(null), 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
if (amountNum > balance) {
|
||||
setCashoutMessage({
|
||||
type: "error",
|
||||
text: "Insufficient balance",
|
||||
});
|
||||
setTimeout(() => setCashoutMessage(null), 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
setCashoutLoading(true);
|
||||
setCashoutMessage(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/wallet", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ amountFlh: amountNum }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.error || "Cashout failed");
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
setCashoutMessage({
|
||||
type: "success",
|
||||
text: `Cashout request for ${data.cashout.amountFlh} FLH submitted!`,
|
||||
});
|
||||
setCashoutAmount("");
|
||||
await fetchWallet();
|
||||
setTimeout(() => setCashoutMessage(null), 4000);
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Cashout failed";
|
||||
setCashoutMessage({ type: "error", text: message });
|
||||
setTimeout(() => setCashoutMessage(null), 3000);
|
||||
} finally {
|
||||
setCashoutLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isPremium = user?.isPremium || user?.isPro;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-dvh bg-[#0a0a0f] flex items-center justify-center">
|
||||
<div className="w-8 h-8 rounded-full border-2 border-[#D4AF37]/30 border-t-[#D4AF37] animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh bg-[#0a0a0f] pb-24">
|
||||
<div className="px-4 pt-6 pb-2">
|
||||
<h1 className="text-xl font-bold text-white">Wallet</h1>
|
||||
</div>
|
||||
|
||||
<div className="px-4 space-y-5 animate-fade-in">
|
||||
{/* Balance Card */}
|
||||
<div className="bg-gradient-to-br from-[#D4AF37]/20 to-[#0a0a0f] border border-[#D4AF37]/30 rounded-2xl p-6 text-center">
|
||||
<div className="w-14 h-14 rounded-full bg-[#D4AF37]/20 flex items-center justify-center mx-auto mb-3">
|
||||
<Wallet size={24} className="text-[#D4AF37]" />
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">
|
||||
FLH Balance
|
||||
</p>
|
||||
<p className="text-4xl font-bold text-[#D4AF37]">
|
||||
{balance.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
≈ ${(balance / 1000).toFixed(2)} USD
|
||||
</p>
|
||||
|
||||
{!isPremium && (
|
||||
<div className="mt-4 bg-[#D4AF37]/10 border border-[#D4AF37]/20 rounded-xl px-4 py-3">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Crown size={14} className="text-[#D4AF37]" />
|
||||
<p className="text-xs text-[#D4AF37] font-medium">
|
||||
Premium members earn 2x FLH!
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => router.push("/upgrade")}
|
||||
className="mt-2 text-[11px] text-[#D4AF37] underline underline-offset-2"
|
||||
>
|
||||
Upgrade now
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
{cashoutMessage && (
|
||||
<div
|
||||
className={`flex items-center gap-2 p-3 rounded-xl text-sm ${
|
||||
cashoutMessage.type === "success"
|
||||
? "bg-emerald-900/20 border border-emerald-800/40 text-emerald-400"
|
||||
: "bg-red-900/20 border border-red-800/40 text-red-400"
|
||||
}`}
|
||||
>
|
||||
{cashoutMessage.type === "success" ? (
|
||||
<Check size={16} />
|
||||
) : (
|
||||
<X size={16} />
|
||||
)}
|
||||
{cashoutMessage.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top-Up Section */}
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-white mb-3 flex items-center gap-2">
|
||||
<Zap size={16} className="text-[#D4AF37]" />
|
||||
Top Up FLH
|
||||
</h2>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{TOP_UP_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.flh}
|
||||
onClick={() => handleTopUp(opt.flh)}
|
||||
disabled={topupLoading === opt.flh}
|
||||
className="bg-[#111118] border border-gray-800/60 rounded-2xl p-4 text-center active:scale-95 transition hover:border-[#D4AF37]/30 disabled:opacity-60"
|
||||
>
|
||||
{topupLoading === opt.flh ? (
|
||||
<Loader2 size={20} className="animate-spin mx-auto text-[#D4AF37]" />
|
||||
) : (
|
||||
<>
|
||||
<p className="text-lg font-bold text-white">
|
||||
{opt.flh.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
${opt.usd.toFixed(2)}
|
||||
</p>
|
||||
{opt.bonus && (
|
||||
<p className="text-[10px] text-emerald-400 mt-1 font-medium">
|
||||
{opt.bonus}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cashout Form */}
|
||||
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-5">
|
||||
<h3 className="text-sm font-semibold text-white mb-3 flex items-center gap-2">
|
||||
<ArrowUpRight size={16} className="text-[#D4AF37]" />
|
||||
Cash Out FLH
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 mb-3">
|
||||
Convert your FLH to USD. Minimum 1,000 FLH (1,000 FLH = $1.00).
|
||||
</p>
|
||||
<form onSubmit={handleCashout} className="flex gap-3">
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="number"
|
||||
min={1000}
|
||||
step={100}
|
||||
value={cashoutAmount}
|
||||
onChange={(e) => setCashoutAmount(e.target.value)}
|
||||
placeholder="Amount FLH"
|
||||
className="w-full bg-[#1a1a24] border border-gray-800/60 rounded-xl px-4 py-3 text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/50 focus:ring-1 focus:ring-[#D4AF37]/30 transition"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={cashoutLoading}
|
||||
className="px-5 py-3 rounded-xl bg-[#D4AF37] text-[#0a0a0f] font-semibold text-sm disabled:opacity-50 active:opacity-80 transition flex items-center gap-2"
|
||||
>
|
||||
{cashoutLoading ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
"Cash Out"
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
{cashoutAmount && parseInt(cashoutAmount) >= 1000 && (
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
You will receive ~$
|
||||
{(parseInt(cashoutAmount) / 1000).toFixed(2)} USD
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Transaction History */}
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-white mb-3 flex items-center gap-2">
|
||||
<Clock size={16} className="text-gray-500" />
|
||||
Cashout History
|
||||
</h2>
|
||||
{cashoutHistory.length === 0 ? (
|
||||
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-5 text-center">
|
||||
<TrendingUp size={20} className="mx-auto text-gray-700 mb-2" />
|
||||
<p className="text-sm text-gray-500">No cashouts yet</p>
|
||||
<p className="text-xs text-gray-700 mt-1">
|
||||
Your cashout requests will appear here
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{cashoutHistory.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="bg-[#111118] border border-gray-800/60 rounded-xl px-4 py-3 flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center ${
|
||||
entry.status === "approved"
|
||||
? "bg-emerald-900/20"
|
||||
: entry.status === "rejected"
|
||||
? "bg-red-900/20"
|
||||
: "bg-amber-900/20"
|
||||
}`}
|
||||
>
|
||||
{entry.status === "approved" ? (
|
||||
<ArrowDownLeft
|
||||
size={14}
|
||||
className="text-emerald-400"
|
||||
/>
|
||||
) : entry.status === "rejected" ? (
|
||||
<X size={14} className="text-red-400" />
|
||||
) : (
|
||||
<Clock size={14} className="text-amber-400" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">
|
||||
{entry.amountFlh.toLocaleString()} FLH
|
||||
</p>
|
||||
<p className="text-[11px] text-gray-500">
|
||||
${entry.fiatAmount.toFixed(2)} —{" "}
|
||||
{new Date(entry.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`text-xs font-medium capitalize ${
|
||||
entry.status === "approved"
|
||||
? "text-emerald-400"
|
||||
: entry.status === "rejected"
|
||||
? "text-red-400"
|
||||
: "text-amber-400"
|
||||
}`}
|
||||
>
|
||||
{entry.status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Earning Tips */}
|
||||
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-5">
|
||||
<h3 className="text-sm font-semibold text-white mb-3 flex items-center gap-2">
|
||||
<Sparkles size={16} className="text-[#D4AF37]" />
|
||||
Earn More FLH
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
{EARNING_TIPS.map((tip, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm text-gray-400">
|
||||
<Info size={12} className="text-[#D4AF37] mt-0.5 shrink-0" />
|
||||
<span>{tip}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="h-4" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user