Initial Falah Mobile rebuild — all 7 blocks complete

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

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