feat: Souq native integration + auth simplification + CE-wide enhancements

Souq Marketplace:
- Native souq pages: browse+filters, create listing, detail+seller profile, orders+chat
- New API: reviews (POST), sellers/[id], upload (multipart), polar-checkout webhook
- Listings API enhanced: minPrice, maxPrice, sortBy, deliveryDays filters
- Seller workflow: start order, mark delivered, upload files, confirm delivery
- Wallet: 5 Polar.sh FLH tiers, production checkout, mock fallback, success banner
- Fix: order redirect /souq/history → /souq/orders

Auth System:
- Unified auth page, removed OAuth provider routing (2 files deleted)
- Deleted oauth.ts (319 lines) — simplified auth chain
- Streamlined login/register API routes

Prisma Schema (+179 lines):
- New models: Listing, Purchase, Review, Group/GroupMember
- Gamification: DailyStreak, XpTransaction, Achievement, UserLevel
- Learning: LearnCourse/Module/Enrollment/Certificate
- Notifications, Referrals, Dhikr, PremiumBenefits

Deleted legacy code:
- src/app/api/marketplace/* (3 files) — replaced by /api/souq/*
- src/app/api/files/[listingId]/route.ts — replaced by /api/souq/upload
- src/app/api/seller/[sellerId]/route.ts — replaced by /api/souq/sellers/[id]

Infrastructure:
- Dockerfile: standalone output, Prisma runtime migration
- docker-compose.yml: Polar env vars, healthcheck fix
- docker-compose.staging.yml: staging on port 4014
- .gitea/workflows/ci.yml: CI pipeline
This commit is contained in:
root
2026-06-24 07:02:03 +02:00
parent 913fa94fb9
commit cfff74e2db
81 changed files with 12132 additions and 2101 deletions
@@ -1,144 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
import {
type Provider,
exchangeCode,
findOrCreateOAuthUser,
verifyState,
} from "@/lib/oauth";
import { signJWT } from "@/lib/auth";
const VALID_PROVIDERS = ["google", "github"];
async function handleCallback(
req: NextRequest,
provider: string
): Promise<NextResponse> {
try {
// Grab code and state from query params or POST body
const url = new URL(req.url);
let code = url.searchParams.get("code");
let stateParam = url.searchParams.get("state");
// Try reading POST body if code wasn't in query params
if (!code) {
try {
const contentType = req.headers.get("content-type") || "";
if (contentType.includes("form")) {
const formData = await req.formData();
code = formData.get("code") as string | null;
stateParam = formData.get("state") as string | null;
}
} catch {
// not a form POST, that's fine
}
}
if (!code) {
return NextResponse.json(
{ error: "Missing authorization code" },
{ status: 400 }
);
}
if (!stateParam) {
return NextResponse.json(
{ error: "Missing state parameter" },
{ status: 400 }
);
}
// Verify state from cookie (CSRF protection)
const cookieStore = await cookies();
const storedState = cookieStore.get("oauth_state")?.value;
if (!storedState) {
return NextResponse.json(
{
error: "Missing state cookie. Please start the OAuth flow again.",
},
{ status: 400 }
);
}
const statePayload = await verifyState(stateParam);
if (!statePayload) {
return NextResponse.json(
{
error:
"Invalid or expired state. Please start the OAuth flow again.",
},
{ status: 400 }
);
}
// Verify the state and provider match
if (statePayload.provider !== provider) {
return NextResponse.json(
{ error: "State/provider mismatch" },
{ status: 400 }
);
}
// Clear the state cookie
cookieStore.set("oauth_state", "", {
httpOnly: true,
path: "/",
maxAge: 0,
});
// Build the redirect URI (must match the one used in the authorization request)
const forwardedProto = req.headers.get("x-forwarded-proto") || "https";
const rawHost = req.headers.get("host") || "";
const isDockerHost = /^[a-f0-9]{12,}$/i.test(rawHost.split(":")[0]);
const forwardedHost = req.headers.get("x-forwarded-host") || (isDockerHost ? "falahos.my" : rawHost);
const baseUrl = `${forwardedProto}://${forwardedHost}`;
const redirectUri = `${baseUrl}/mobile/api/auth/${provider}/callback`;
// Exchange code for user info
const providerUser = await exchangeCode(
provider as Provider,
code,
redirectUri
);
// Find or create user in database
const user = await findOrCreateOAuthUser(
provider as Provider,
providerUser
);
// Generate JWT
const token = await signJWT({
userId: user.id,
email: user.email,
isPremium: user.isPremium,
isPro: user.isPro,
});
// Redirect to frontend callback page with token
const frontendUrl = `${baseUrl}/mobile/auth/callback?token=${encodeURIComponent(token)}`;
return NextResponse.redirect(frontendUrl);
} catch (error) {
console.error(`${provider} OAuth callback error:`, error);
const message =
error instanceof Error ? error.message : "OAuth callback failed";
return NextResponse.json({ error: message }, { status: 500 });
}
}
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ provider: string }> }
) {
const { provider } = await params;
if (!VALID_PROVIDERS.includes(provider)) {
return NextResponse.json(
{
error: `Unsupported provider. Must be one of: ${VALID_PROVIDERS.join(", ")}`,
},
{ status: 400 }
);
}
return handleCallback(req, provider);
}
-68
View File
@@ -1,68 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
import {
type Provider,
PROVIDER_CONFIGS,
generateState,
buildAuthorizationUrl,
} from "@/lib/oauth";
const VALID_PROVIDERS = ["google", "github"];
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ provider: string }> }
) {
const { provider } = await params;
if (!VALID_PROVIDERS.includes(provider)) {
return NextResponse.json(
{ error: `Unsupported provider. Must be one of: ${VALID_PROVIDERS.join(", ")}` },
{ status: 400 }
);
}
const config = PROVIDER_CONFIGS[provider as Provider];
const clientId = process.env[config.clientIdEnv];
if (!clientId) {
return NextResponse.json(
{
error: `${provider} OAuth is not configured. Missing ${config.clientIdEnv} environment variable.`,
},
{ status: 503 }
);
}
// Build the redirect URI — the callback URL for this provider
// Use forwarded headers (Traefik/Cloudflare) or the original Host header
// to ensure the public domain is used, not the Docker container hostname.
const forwardedProto = _req.headers.get("x-forwarded-proto") || "https";
const rawHost = _req.headers.get("host") || "";
const isDockerHost = /^[a-f0-9]{12,}$/i.test(rawHost.split(":")[0]);
const forwardedHost = _req.headers.get("x-forwarded-host") || (isDockerHost ? "falahos.my" : rawHost);
const baseUrl = `${forwardedProto}://${forwardedHost}`;
const redirectUri = `${baseUrl}/mobile/api/auth/${provider}/callback`;
// Generate state for CSRF protection
const state = await generateState(provider as Provider);
// Store state in a cookie
const cookieStore = await cookies();
cookieStore.set("oauth_state", state, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 60 * 10, // 10 minutes
});
// Build the authorization URL and redirect
const authUrl = buildAuthorizationUrl(
provider as Provider,
state,
redirectUri
);
return NextResponse.redirect(authUrl);
}
+38 -30
View File
@@ -1,7 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/prisma";
import { signJWT } from "@/lib/auth";
import { UMMAHID_URL } from "@/lib/auth";
export async function POST(req: NextRequest) {
try {
@@ -14,39 +13,48 @@ export async function POST(req: NextRequest) {
);
}
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,
// Proxy to Ummah ID
const ummahRes = await fetch(`${UMMAHID_URL}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
const ummahData = await ummahRes.json();
if (!ummahRes.ok) {
return NextResponse.json(
{ error: ummahData.error || "Authentication failed" },
{ status: ummahRes.status }
);
}
const { token, user: ummahUser } = ummahData;
// Find or create local user by email
let localUser = await prisma.user.findUnique({ where: { email } });
if (!localUser) {
localUser = await prisma.user.create({
data: {
email,
name: ummahUser?.name || email?.split("@")[0] || "User",
provider: "ummahid",
providerId: ummahUser.id,
walletBalance: 5000,
},
});
}
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,
id: localUser.id,
email: localUser.email,
name: localUser.name,
isPremium: localUser.isPremium,
isPro: localUser.isPro,
flhBalance: localUser.flhBalance,
dailyMsgCount: localUser.dailyMsgCount,
},
});
} catch (error) {
+33 -85
View File
@@ -1,15 +1,10 @@
import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/prisma";
import { signJWT } from "@/lib/auth";
import { findReferrerByCode } from "@/lib/referral";
const REFERRER_REWARD_FLH = 200;
const REFERRED_BONUS_FLH = 1000;
import { UMMAHID_URL } from "@/lib/auth";
export async function POST(req: NextRequest) {
try {
const { email, name, password, referralCode } = await req.json();
const { email, name, password } = await req.json();
if (!email || !name || !password) {
return NextResponse.json(
@@ -18,96 +13,49 @@ export async function POST(req: NextRequest) {
);
}
const existing = await prisma.user.findUnique({ where: { email } });
if (existing) {
// Proxy to Ummah ID
const ummahRes = await fetch(`${UMMAHID_URL}/api/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, name, password }),
});
const ummahData = await ummahRes.json();
if (!ummahRes.ok) {
return NextResponse.json(
{ error: "An account with this email already exists" },
{ status: 409 }
{ error: ummahData.error || "Registration failed" },
{ status: ummahRes.status }
);
}
const passwordHash = await bcrypt.hash(password, 12);
const { token, user: ummahUser } = ummahData;
// Look up referrer if a referral code was provided
let referrerId: string | null = null;
if (referralCode && typeof referralCode === "string") {
const users = await prisma.user.findMany({
select: { id: true },
// Create or find local user (find first to avoid duplicates)
let localUser = await prisma.user.findUnique({ where: { email } });
if (!localUser) {
localUser = await prisma.user.create({
data: {
email,
name: ummahUser?.name || name,
provider: "ummahid",
flhBalance: 5000,
trialEndsAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
},
});
referrerId = await findReferrerByCode(users, referralCode);
}
// Calculate starting balance
let startingBalance = 5000; // default sign-up bonus
if (referrerId) {
startingBalance += REFERRED_BONUS_FLH; // +1,000 FLH for using a referral code
}
const user = await prisma.user.create({
data: {
email,
name,
passwordHash,
provider: "email",
flhBalance: startingBalance,
trialEndsAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
},
});
// If referral code was valid, create the referral record and credit the referrer
if (referrerId) {
await prisma.$transaction([
// Create the referral record
prisma.referral.create({
data: {
referrerId,
referredId: user.id,
status: "joined",
rewardFlh: REFERRER_REWARD_FLH,
},
}),
// Credit the referrer
prisma.user.update({
where: { id: referrerId },
data: { flhBalance: { increment: REFERRER_REWARD_FLH } },
}),
// Notify the referrer
prisma.notification.create({
data: {
userId: referrerId,
type: "referral_bonus",
title: "Referral Bonus Earned!",
body: `${name} joined Falah using your referral code — you earned ${REFERRER_REWARD_FLH} FLH!`,
data: JSON.stringify({
referredUserId: user.id,
referredName: name,
rewardFlh: REFERRER_REWARD_FLH,
}),
},
}),
]);
}
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,
id: localUser.id,
email: localUser.email,
name: localUser.name,
isPremium: localUser.isPremium,
isPro: localUser.isPro,
flhBalance: localUser.flhBalance,
dailyMsgCount: localUser.dailyMsgCount,
},
referralApplied: referrerId ? true : false,
referralReward: referrerId ? REFERRED_BONUS_FLH : 0,
});
} catch (error) {
console.error("Register error:", error);
-85
View File
@@ -1,85 +0,0 @@
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 }
);
}
}
+17
View File
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
import { getUserTier, FORUM_LIMITS } from "@/lib/tiers";
import { moderateContent } from "@/lib/shariah-moderation";
export async function GET(request: NextRequest) {
try {
@@ -104,6 +105,22 @@ export async function POST(request: NextRequest) {
},
});
// ── Auto-moderation for post content ──────────────────────────────
const result = moderateContent(content);
if (result.status === "approved") {
await prisma.forumPost.update({
where: { id: post.id },
data: { shariahStatus: "approved" },
});
post.shariahStatus = "approved";
} else {
await prisma.forumPost.update({
where: { id: post.id },
data: { shariahFlags: JSON.stringify(result.flags) },
});
post.shariahFlags = JSON.stringify(result.flags);
}
return NextResponse.json({ post }, { status: 201 });
} catch (error) {
console.error("POST /api/forum/posts error:", error);
+19
View File
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
import { getUserTier, FORUM_LIMITS } from "@/lib/tiers";
import { moderateContent } from "@/lib/shariah-moderation";
export async function GET(request: NextRequest) {
try {
@@ -150,6 +151,24 @@ export async function POST(request: NextRequest) {
},
});
// ── Auto-moderation for public threads ───────────────────────────
if (!groupId) {
const result = moderateContent(content, title);
if (result.status === "approved") {
await prisma.forumThread.update({
where: { id: thread.id },
data: { shariahStatus: "approved" },
});
thread.shariahStatus = "approved";
} else {
await prisma.forumThread.update({
where: { id: thread.id },
data: { shariahFlags: JSON.stringify(result.flags) },
});
thread.shariahFlags = JSON.stringify(result.flags);
}
}
return NextResponse.json({ thread }, { status: 201 });
} catch (error) {
console.error("POST /api/forum/threads error:", error);
+75
View File
@@ -0,0 +1,75 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
// POST /api/groups/join — join by invite code (no group ID needed)
export async function POST(request: NextRequest) {
try {
const auth = await requireAuth(request);
if (!auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { inviteCode } = await request.json();
if (!inviteCode || typeof inviteCode !== "string") {
return NextResponse.json(
{ error: "Missing required field: inviteCode" },
{ status: 400 }
);
}
// Find the group by its unique invite code
const group = await prisma.group.findUnique({
where: { inviteCode },
include: {
members: {
where: { userId: auth.userId },
},
},
});
if (!group) {
return NextResponse.json(
{ error: "Invalid invite code — group not found" },
{ status: 404 }
);
}
// Check if user is already a member
if (group.members.length > 0) {
return NextResponse.json(
{ error: "You are already a member of this group" },
{ status: 409 }
);
}
// Add user as member
const membership = await prisma.groupMember.create({
data: {
groupId: group.id,
userId: auth.userId,
role: "member",
},
include: {
user: {
select: { id: true, name: true, isPremium: true, isPro: true },
},
group: {
select: { id: true, name: true },
},
},
});
return NextResponse.json(
{ membership, group: { id: group.id, name: group.name } },
{ status: 201 }
);
} catch (error) {
console.error("POST /api/groups/join error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
@@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
import fs from "fs";
// GET /api/learn/certificates/[serial] — download certificate PDF
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ serial: string }> }
) {
try {
const auth = await requireAuth(request);
if (!auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { serial } = await params;
const certificate = await prisma.learnCertificate.findUnique({
where: { serialNumber: serial },
});
if (!certificate) {
return NextResponse.json(
{ error: "Certificate not found" },
{ status: 404 }
);
}
if (!certificate.pdfPath) {
return NextResponse.json(
{ error: "Certificate PDF not available" },
{ status: 404 }
);
}
const pdfBuffer = fs.readFileSync(certificate.pdfPath);
return new NextResponse(pdfBuffer, {
status: 200,
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="${serial}.pdf"`,
},
});
} catch (error) {
console.error("GET /api/learn/certificates/[serial] error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
+26
View File
@@ -0,0 +1,26 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
// GET /api/learn/certificates — list authenticated user's certificates
export async function GET(request: NextRequest) {
try {
const auth = await requireAuth(request);
if (!auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const certificates = await prisma.learnCertificate.findMany({
where: { userId: auth.userId },
orderBy: { issuedAt: "desc" },
});
return NextResponse.json({ certificates });
} catch (error) {
console.error("GET /api/learn/certificates error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
// GET /api/learn/certificates/verify/[serial] — public verification
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ serial: string }> }
) {
try {
const { serial } = await params;
const certificate = await prisma.learnCertificate.findUnique({
where: { serialNumber: serial },
include: {
course: {
select: { title: true },
},
},
});
if (!certificate) {
return NextResponse.json({ valid: false });
}
return NextResponse.json({
valid: !certificate.revoked,
serialNumber: certificate.serialNumber,
userName: certificate.userName,
courseTitle: certificate.course.title,
moduleCount: certificate.moduleCount,
score: certificate.score,
issuedAt: certificate.issuedAt.toISOString(),
revoked: certificate.revoked,
});
} catch (error) {
console.error("GET /api/learn/certificates/verify/[serial] error:", error);
return NextResponse.json({ valid: false });
}
}
+120
View File
@@ -0,0 +1,120 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
// GET /api/learn/courses/[slug] — course detail with modules and user progress
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ slug: string }> }
) {
try {
const auth = await requireAuth(request);
if (!auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { slug } = await params;
const course = await prisma.learnCourse.findUnique({
where: { slug },
include: {
modules: {
orderBy: { order: "asc" },
},
},
});
if (!course) {
return NextResponse.json(
{ error: "Course not found" },
{ status: 404 }
);
}
// Fetch user's enrollment for this course (if any)
let enrollment = await prisma.learnEnrollment.findUnique({
where: {
userId_courseId: {
userId: auth.userId,
courseId: course.id,
},
},
});
// Auto-enroll premium users (check local user record, not JWT claims)
// The JWT's licenseTier may not match the local user's isPremium flag
if (!enrollment) {
const localUser = await prisma.user.findUnique({
where: { id: auth.userId },
select: { isPremium: true },
});
if (localUser?.isPremium) {
enrollment = await prisma.learnEnrollment.create({
data: {
userId: auth.userId,
courseId: course.id,
purchaseType: "free",
},
});
}
}
// Fetch module progress for this enrollment (if enrolled)
let moduleProgressMap: Record<string, { completed: boolean; score: number | null; listened: boolean }> = {};
if (enrollment) {
const progresses = await prisma.learnModuleProgress.findMany({
where: { enrollmentId: enrollment.id },
});
for (const p of progresses) {
moduleProgressMap[p.moduleId] = {
completed: p.completed,
score: p.score,
listened: p.listened,
};
}
}
// Build response
const modules = course.modules.map((mod) => ({
id: mod.id,
order: mod.order,
title: mod.title,
slug: mod.slug,
keyTakeaway: mod.keyTakeaway,
duration: mod.duration,
content: mod.content,
quizData: mod.quizData ? JSON.parse(mod.quizData) : null,
progress: moduleProgressMap[mod.id] ?? null,
}));
return NextResponse.json({
course: {
id: course.id,
slug: course.slug,
title: course.title,
author: course.author,
description: course.description,
imageUrl: course.imageUrl,
moduleCount: course.moduleCount,
totalMinutes: course.totalMinutes,
difficulty: course.difficulty,
giteaPath: course.giteaPath,
},
modules,
enrollment: enrollment
? {
id: enrollment.id,
purchaseType: enrollment.purchaseType,
completed: enrollment.completed,
progress: enrollment.progress,
}
: null,
});
} catch (error) {
console.error("GET /api/learn/courses/[slug] error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
+58
View File
@@ -0,0 +1,58 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
export async function GET(request: NextRequest) {
try {
const auth = await requireAuth(request);
const userId = auth?.userId;
const courses = await prisma.learnCourse.findMany({
where: { published: true },
orderBy: { createdAt: "desc" },
});
// If authenticated, fetch the user's enrollments for the returned courses
let enrollmentsMap = new Map<string, { progress: number; completed: boolean }>();
if (userId && courses.length > 0) {
const enrollments = await prisma.learnEnrollment.findMany({
where: {
userId,
courseId: { in: courses.map((c) => c.id) },
},
select: { courseId: true, progress: true, completed: true },
});
for (const e of enrollments) {
enrollmentsMap.set(e.courseId, { progress: e.progress, completed: e.completed });
}
}
const result = courses.map((course) => {
const enrollment = enrollmentsMap.get(course.id);
return {
id: course.id,
slug: course.slug,
title: course.title,
author: course.author,
description: course.description,
imageUrl: course.imageUrl,
moduleCount: course.moduleCount,
totalMinutes: course.totalMinutes,
difficulty: course.difficulty,
priceFlh: course.priceFlh,
priceUsd: course.priceUsd,
subscriptionOnly: course.subscriptionOnly,
enrolled: userId ? !!enrollment : null,
...(enrollment ? { enrollment: { progress: enrollment.progress, completed: enrollment.completed } } : {}),
};
});
return NextResponse.json({ courses: result });
} catch (error) {
console.error("GET /api/learn/courses error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
+215
View File
@@ -0,0 +1,215 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
import {
generateCertificatePdf,
generateSerialNumber,
buildVerifyUrl,
} from "@/lib/learn";
export async function POST(request: Request) {
const jwt = await requireAuth(request);
if (!jwt) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const body = await request.json();
const { moduleId, completed, score, listened } = body;
if (!moduleId || typeof completed !== "boolean") {
return NextResponse.json(
{ error: "moduleId (string) and completed (boolean) are required" },
{ status: 400 }
);
}
// 1. Find the module with its course relation
const module = await prisma.learnModule.findUnique({
where: { id: moduleId },
include: { course: true },
});
if (!module) {
return NextResponse.json({ error: "Module not found" }, { status: 404 });
}
if (!module.course.published) {
return NextResponse.json(
{ error: "Course is not published" },
{ status: 403 }
);
}
// 2. Find the enrollment for this user + course
let enrollment = await prisma.learnEnrollment.findUnique({
where: {
userId_courseId: {
userId: jwt.userId,
courseId: module.courseId,
},
},
});
if (!enrollment) {
return NextResponse.json(
{ error: "You are not enrolled in this course" },
{ status: 403 }
);
}
// 3. Upsert LearnModuleProgress
await prisma.learnModuleProgress.upsert({
where: {
enrollmentId_moduleId: {
enrollmentId: enrollment.id,
moduleId: module.id,
},
},
update: {
completed,
...(score !== undefined ? { score } : {}),
...(listened !== undefined ? { listened } : {}),
...(completed ? { completedAt: new Date() } : {}),
},
create: {
enrollmentId: enrollment.id,
moduleId: module.id,
completed,
...(score !== undefined ? { score } : {}),
...(listened !== undefined ? { listened } : {}),
...(completed ? { completedAt: new Date() } : {}),
},
});
// 4. Count completed modules vs total to compute progress
const totalModules = await prisma.learnModule.count({
where: { courseId: module.courseId },
});
const completedProgressRecords = await prisma.learnModuleProgress.count({
where: {
enrollmentId: enrollment.id,
completed: true,
},
});
const progress =
totalModules > 0 ? completedProgressRecords / totalModules : 0;
// 5. If completing (progress >= 1), handle certificate + enrollment atomically
let certificate: {
serialNumber: string;
verifyUrl: string;
pdfPath: string;
} | undefined;
if (progress >= 1 && !enrollment.completed) {
const now = new Date();
// Fetch user info for certificate (before transaction)
const user = await prisma.user.findUnique({
where: { id: jwt.userId },
select: { name: true },
});
const course = module.course;
const serialNumber = generateSerialNumber(
jwt.userId,
course.id,
course.slug,
now
);
const certificateData = {
serialNumber,
userName: user?.name ?? "Student",
courseTitle: course.title,
courseAuthor: course.author,
moduleCount: totalModules,
score: score ?? undefined,
issuedAt: now,
verifyUrl: buildVerifyUrl(serialNumber),
};
// Generate PDF before transaction — if it fails, transaction never runs
const pdfPath = await generateCertificatePdf(certificateData);
// All DB mutations inside a single Prisma transaction for atomicity
const result = await prisma.$transaction(async (tx) => {
// Race condition guard: check no existing certificate for this user + course
const existingCert = await tx.learnCertificate.findFirst({
where: { userId: jwt.userId, courseId: course.id },
});
if (existingCert) {
// Already issued — still update enrollment but don't create another
const updatedEnrollment = await tx.learnEnrollment.update({
where: { id: enrollment.id },
data: {
progress,
completed: true,
completedAt: now,
},
});
return { enrollment: updatedEnrollment, certificate: null };
}
// Update enrollment (progress + completion) and create certificate atomically
const updatedEnrollment = await tx.learnEnrollment.update({
where: { id: enrollment.id },
data: {
progress,
completed: true,
completedAt: now,
},
});
await tx.learnCertificate.create({
data: {
serialNumber,
userId: jwt.userId,
userName: user?.name ?? "Student",
courseId: course.id,
moduleCount: totalModules,
score: score ?? null,
pdfPath,
issuedAt: now,
},
});
return {
enrollment: updatedEnrollment,
certificate: {
serialNumber,
verifyUrl: certificateData.verifyUrl,
pdfPath,
},
};
});
enrollment = result.enrollment;
if (result.certificate) {
certificate = result.certificate;
}
} else {
// No completion — just update progress outside transaction
enrollment = await prisma.learnEnrollment.update({
where: { id: enrollment.id },
data: { progress },
});
}
return NextResponse.json({
progress,
completed: enrollment.completed,
...(certificate ? { certificate } : {}),
});
} catch (error) {
console.error("Learn progress error:", error);
return NextResponse.json(
{ error: "Failed to update progress" },
{ status: 500 }
);
}
}
+530
View File
@@ -0,0 +1,530 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
// ─── Simple YAML parser for the subset of YAML we expect ───
/** Strip surrounding quotes from a string */
function stripQuotes(s: string): string {
if (
(s.startsWith('"') && s.endsWith('"')) ||
(s.startsWith("'") && s.endsWith("'"))
) {
return s.slice(1, -1);
}
return s;
}
/** Parse a scalar value (string, number, boolean, null) */
function parseScalar(raw: string): string | number | boolean | null {
const trimmed = raw.trim();
if (trimmed === "null" || trimmed === "~") return null;
if (trimmed === "true") return true;
if (trimmed === "false") return false;
// Try number
const num = Number(trimmed);
if (!isNaN(num) && trimmed !== "") return num;
return stripQuotes(trimmed);
}
interface ParseResult {
value: any;
nextLine: number;
}
/**
* Parse a block of YAML lines starting at `startLine`.
* Detects whether the block is a list (lines start with `- ` at base indent)
* or an object (key: value pairs).
*/
function parseBlock(lines: string[], startLine: number, baseIndent: number): ParseResult {
// Skip empty lines
let i = startLine;
while (i < lines.length && lines[i].trim() === "") i++;
if (i >= lines.length) return { value: null, nextLine: i };
const firstNonEmpty = lines[i];
// Detect leading whitespace of the first content line
const firstIndent = firstNonEmpty.search(/\S/);
// Check if this is a list (first non-empty line starts with "- " at base indent)
if (firstNonEmpty.trimStart().startsWith("- ")) {
return parseList(lines, i, firstIndent);
}
// Otherwise treat as an object (key: value)
return parseMapping(lines, i, firstIndent);
}
/** Parse a YAML sequence (list) */
function parseList(lines: string[], startLine: number, indent: number): ParseResult {
const items: any[] = [];
let i = startLine;
while (i < lines.length) {
const trimmed = lines[i].trim();
if (trimmed === "") {
i++;
continue;
}
// Check current indent
const currentIndent = lines[i].search(/\S/);
if (currentIndent < indent) break; // outdented, we're done
if (currentIndent !== indent) {
// If it's indented more, it might be part of the previous item's value
// For our use case, this shouldn't happen at the top level of a list
i++;
continue;
}
if (!trimmed.startsWith("- ")) {
// Not a list item anymore
break;
}
const rest = trimmed.slice(2).trim();
// Check if the item has inline value or is a sub-block
if (rest === "") {
// Empty list item - could have sub-items indented below
// Peek ahead for sub-block
if (i + 1 < lines.length) {
const nextIndent = lines[i + 1].search(/\S/);
if (nextIndent > indent) {
const sub = parseBlock(lines, i + 1, nextIndent);
items.push(sub.value);
i = sub.nextLine;
continue;
}
}
items.push(null);
i++;
} else if (rest.includes(":") && !rest.startsWith('"') && !rest.startsWith("'")) {
// The item might be an inline mapping start: "key: value" or just "key:"
// Actually, for our format, modules have: `- slug: "value"` which is inline
// Let's check if there are sub-properties on subsequent lines
if (i + 1 < lines.length) {
const nextIndent = lines[i + 1].search(/\S/);
if (nextIndent > indent) {
// This list item has sub-properties; parse the rest inline as a mapping
// Treat this line as the first key:value of the sub-mapping
const subResult = parseMappingFromLine(lines, i, indent);
items.push(subResult.value);
i = subResult.nextLine;
continue;
}
}
// No sub-properties, treat the whole line as a mapping key:value
const colonIdx = rest.indexOf(":");
const key = stripQuotes(rest.slice(0, colonIdx).trim());
const valStr = rest.slice(colonIdx + 1).trim();
const val = valStr ? parseScalar(valStr) : null;
const obj: Record<string, any> = {};
obj[key] = val;
items.push(obj);
i++;
} else {
// Simple scalar list item
items.push(parseScalar(rest));
i++;
}
}
return { value: items, nextLine: i };
}
/**
* Parse a mapping (key: value) starting from a specific line.
* The mapping ends when indentation returns to or above baseIndent.
*/
function parseMapping(lines: string[], startLine: number, baseIndent: number): ParseResult {
const result: Record<string, any> = {};
let i = startLine;
while (i < lines.length) {
const line = lines[i];
const trimmedLine = line.trim();
if (trimmedLine === "") {
i++;
continue;
}
const currentIndent = line.search(/\S/);
if (currentIndent < baseIndent) break; // outdented
if (currentIndent !== baseIndent) {
// This shouldn't happen at the mapping level, skip
i++;
continue;
}
// Must be a key: value pair
if (!trimmedLine.includes(":")) {
i++;
continue;
}
const colonIdx = trimmedLine.indexOf(":");
const key = stripQuotes(trimmedLine.slice(0, colonIdx).trim());
let rest = trimmedLine.slice(colonIdx + 1).trim();
if (rest === "") {
// Value might be a nested block on subsequent lines
// Check next line
if (i + 1 < lines.length) {
const nextIndent = lines[i + 1].search(/\S/);
if (nextIndent > currentIndent) {
const subResult = parseBlock(lines, i + 1, nextIndent);
result[key] = subResult.value;
i = subResult.nextLine;
continue;
}
}
// No value or empty value
result[key] = null;
i++;
} else {
// Inline scalar value
result[key] = parseScalar(rest);
i++;
}
}
return { value: result, nextLine: i };
}
/**
* Special case: when a list item like `- slug: "value"` has sub-properties
* on subsequent indented lines, parse it as a mapping starting with the current
* line's content.
*/
function parseMappingFromLine(lines: string[], startLine: number, parentIndent: number): ParseResult {
const result: Record<string, any> = {};
let i = startLine;
// Parse the first line which already has content like "- slug: value"
const firstLine = lines[i];
const afterDash = firstLine.trim().slice(2).trim();
const colonIdx = afterDash.indexOf(":");
const firstKey = stripQuotes(afterDash.slice(0, colonIdx).trim());
const firstValStr = afterDash.slice(colonIdx + 1).trim();
result[firstKey] = firstValStr ? parseScalar(firstValStr) : null;
i++;
// Now parse subsequent lines at a higher indent
const subIndent = parentIndent + 2; // typical YAML indent
while (i < lines.length) {
const line = lines[i];
const trimmedLine = line.trim();
if (trimmedLine === "") {
i++;
continue;
}
const currentIndent = line.search(/\S/);
if (currentIndent <= parentIndent) break; // back to parent level
if (!trimmedLine.includes(":")) {
i++;
continue;
}
const ci = trimmedLine.indexOf(":");
const key = stripQuotes(trimmedLine.slice(0, ci).trim());
const rest = trimmedLine.slice(ci + 1).trim();
if (rest === "") {
// Could have nested value below
if (i + 1 < lines.length) {
const nextIndent = lines[i + 1].search(/\S/);
if (nextIndent > currentIndent) {
const subResult = parseBlock(lines, i + 1, nextIndent);
result[key] = subResult.value;
i = subResult.nextLine;
continue;
}
}
result[key] = null;
i++;
} else {
result[key] = parseScalar(rest);
i++;
}
}
return { value: result, nextLine: i };
}
// ─── Gitea API helpers ───
const GITEA_BASE = "https://git.falahos.my/api/v1/repos/maifors/courses/contents";
function getGiteaToken(): string {
const token = process.env.GITEA_TOKEN;
if (!token) {
throw new Error("GITEA_TOKEN environment variable is not set");
}
return token;
}
interface GiteaFileResponse {
name: string;
path: string;
content: string;
encoding: string;
}
async function fetchGiteaFile(filePath: string): Promise<string> {
const url = `${GITEA_BASE}/${filePath}`;
const token = getGiteaToken();
const res = await fetch(url, {
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/json",
},
});
if (!res.ok) {
throw new Error(
`Gitea API error: ${res.status} ${res.statusText} for ${filePath}`
);
}
const data: GiteaFileResponse = await res.json();
if (data.encoding !== "base64" || !data.content) {
throw new Error(`Unexpected Gitea response format for ${filePath}`);
}
return Buffer.from(data.content, "base64").toString("utf-8");
}
// ─── Course sync logic ───
interface CourseYaml {
title?: string;
author?: string;
description?: string;
imageUrl?: string;
difficulty?: string;
priceFlh?: number;
priceUsd?: number;
subscriptionOnly?: boolean;
published?: boolean;
modules?: Array<{
slug?: string;
title?: string;
keyTakeaway?: string;
duration?: number;
}>;
}
interface QuizQuestion {
question?: string;
options?: string[];
correctIndex?: number;
}
interface QuizYaml {
questions?: QuizQuestion[];
}
async function syncCourse(slug: string): Promise<void> {
// Fetch course.yaml
const courseYamlText = await fetchGiteaFile(`courses/${slug}/course.yaml`);
const parsed = parseBlock(courseYamlText.split("\n"), 0, 0);
const courseData = parsed.value as CourseYaml;
if (!courseData || typeof courseData !== "object") {
throw new Error(`Invalid course.yaml for slug: ${slug}`);
}
const giteaPath = `courses/${slug}`;
// Upsert the course
const course = await prisma.learnCourse.upsert({
where: { slug },
create: {
slug,
title: courseData.title || slug,
author: courseData.author || "Unknown",
description: courseData.description || "",
imageUrl: courseData.imageUrl || null,
difficulty: courseData.difficulty || "beginner",
giteaPath,
priceFlh: courseData.priceFlh ?? null,
priceUsd: courseData.priceUsd ?? null,
subscriptionOnly: courseData.subscriptionOnly ?? false,
published: courseData.published ?? false,
moduleCount: 0,
totalMinutes: 0,
},
update: {
title: courseData.title || slug,
author: courseData.author || "Unknown",
description: courseData.description || "",
imageUrl: courseData.imageUrl || null,
difficulty: courseData.difficulty || "beginner",
giteaPath,
priceFlh: courseData.priceFlh ?? null,
priceUsd: courseData.priceUsd ?? null,
subscriptionOnly: courseData.subscriptionOnly ?? false,
published: courseData.published ?? false,
},
});
// Sync modules
let totalMinutes = 0;
const modules = courseData.modules || [];
for (let order = 0; order < modules.length; order++) {
const modData = modules[order];
if (!modData || !modData.slug) continue;
const modSlug = modData.slug;
const modTitle = modData.title || modSlug;
// Fetch lesson.md
let lessonContent: string | null = null;
try {
lessonContent = await fetchGiteaFile(
`courses/${slug}/modules/${modSlug}/lesson.md`
);
} catch {
// lesson.md might not exist yet
lessonContent = null;
}
// Fetch quiz.yaml
let quizData: string | null = null;
try {
const quizYamlText = await fetchGiteaFile(
`courses/${slug}/modules/${modSlug}/quiz.yaml`
);
const quizParsed = parseBlock(quizYamlText.split("\n"), 0, 0);
const quiz = quizParsed.value as QuizYaml;
if (quiz && Array.isArray(quiz.questions)) {
quizData = JSON.stringify(quiz.questions);
}
} catch {
// quiz.yaml might not exist yet
quizData = null;
}
const duration = modData.duration || 5;
totalMinutes += duration;
await prisma.learnModule.upsert({
where: {
courseId_slug: {
courseId: course.id,
slug: modSlug,
},
},
create: {
courseId: course.id,
order,
title: modTitle,
slug: modSlug,
keyTakeaway: modData.keyTakeaway || null,
duration,
content: lessonContent,
quizData,
},
update: {
order,
title: modTitle,
keyTakeaway: modData.keyTakeaway || null,
duration,
content: lessonContent ?? undefined,
quizData: quizData ?? undefined,
},
});
}
// Update course module count and total minutes
await prisma.learnCourse.update({
where: { id: course.id },
data: {
moduleCount: modules.length,
totalMinutes,
},
});
}
// ─── Route handlers ───
/**
* GET /api/learn/sync — health check
*/
export async function GET() {
return NextResponse.json({ status: "ok", lastSync: null });
}
/**
* POST /api/learn/sync — Gitea webhook receiver
*
* Accepts a secret token via ?token= query param or Authorization: Bearer <token> header.
* Compares against SYNC_SECRET env var.
* On success, fetches courses from Gitea and syncs to local DB.
*/
export async function POST(req: NextRequest) {
try {
// 1. Validate secret token
const url = new URL(req.url);
const token =
url.searchParams.get("token") ||
req.headers.get("Authorization")?.replace(/^Bearer\s+/i, "");
if (!token || token !== process.env.SYNC_SECRET) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// 2. Fetch course index from Gitea
const indexYamlText = await fetchGiteaFile("courses/course-index.yaml");
const indexParsed = parseBlock(indexYamlText.split("\n"), 0, 0);
const indexValue = indexParsed.value;
// The index should be a simple list of slugs
let courseSlugs: string[] = [];
if (Array.isArray(indexValue)) {
courseSlugs = indexValue
.map((item: any) => (typeof item === "string" ? item.trim() : null))
.filter((s: string | null): s is string => !!s);
} else if (typeof indexValue === "object" && indexValue !== null) {
// Fallback: object keys as slugs
courseSlugs = Object.keys(indexValue);
}
if (courseSlugs.length === 0) {
return NextResponse.json(
{ error: "No courses found in course-index.yaml" },
{ status: 400 }
);
}
// 3. Sync each course
const syncedCourses: string[] = [];
for (const slug of courseSlugs) {
try {
await syncCourse(slug);
syncedCourses.push(slug);
} catch (err) {
console.error(`Failed to sync course "${slug}":`, err);
// Continue with other courses
}
}
return NextResponse.json({
synced: syncedCourses.length,
courses: syncedCourses,
});
} catch (error) {
console.error("Gitea sync error:", error);
return NextResponse.json(
{ error: "Sync failed: " + (error instanceof Error ? error.message : "Unknown error") },
{ status: 500 }
);
}
}
+132
View File
@@ -0,0 +1,132 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
import { existsSync } from "fs";
import { join } from "path";
/**
* GET /api/learn/tts?moduleId=xxx
* Returns module text content and whether audio is cached on disk.
* The client uses Web Speech API as the baseline — no server-side TTS yet.
*/
export async function GET(request: NextRequest) {
const jwt = await requireAuth(request);
if (!jwt) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const { searchParams } = new URL(request.url);
const moduleId = searchParams.get("moduleId");
if (!moduleId) {
return NextResponse.json(
{ error: "moduleId query parameter is required" },
{ status: 400 }
);
}
const module = await prisma.learnModule.findUnique({
where: { id: moduleId },
select: { id: true, content: true, audioPath: true },
});
if (!module) {
return NextResponse.json({ error: "Module not found" }, { status: 404 });
}
let hasAudio = false;
if (module.audioPath) {
const fullPath = join(process.cwd(), "public", module.audioPath);
hasAudio = existsSync(fullPath);
}
return NextResponse.json({
moduleId: module.id,
text: module.content ?? "",
hasAudio,
});
} catch (error) {
console.error("GET /api/learn/tts error:", error);
return NextResponse.json(
{ error: "Failed to retrieve TTS data" },
{ status: 500 }
);
}
}
/**
* POST /api/learn/tts
* Body: { moduleId }
* 1) Verifies auth, 2) Finds the LearnModule, 3) Checks if audio is cached on disk,
* 4) Returns text content so the client can use Web Speech API (SpeechSynthesis).
* Server-side TTS generation can be added later.
*/
export async function POST(request: Request) {
const jwt = await requireAuth(request);
if (!jwt) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const body = await request.json();
const { moduleId } = body;
if (!moduleId) {
return NextResponse.json(
{ error: "moduleId is required" },
{ status: 400 }
);
}
const module = await prisma.learnModule.findUnique({
where: { id: moduleId },
select: { id: true, content: true, audioPath: true },
});
if (!module) {
return NextResponse.json({ error: "Module not found" }, { status: 404 });
}
const text = module.content ?? "";
// Check if audio is already cached on disk
if (module.audioPath) {
const fullPath = join(process.cwd(), "public", module.audioPath);
if (existsSync(fullPath)) {
return NextResponse.json({
audioUrl: module.audioPath.startsWith("/")
? module.audioPath
: `/audio/${module.audioPath}`,
cached: true,
text,
});
}
}
// No cached audio — return text for client-side Web Speech API
// If we don't have an audioPath yet, set a pending marker so future
// server-side TTS knows this module is queued.
if (!module.audioPath) {
await prisma.learnModule.update({
where: { id: module.id },
data: { audioPath: "__pending__" },
});
}
return NextResponse.json({
text,
moduleId: module.id,
ttsUrl: null,
cached: false,
message:
"Audio not yet generated. Use Web Speech API (SpeechSynthesis) on the client to speak this text.",
});
} catch (error) {
console.error("POST /api/learn/tts error:", error);
return NextResponse.json(
{ error: "Failed to process TTS request" },
{ status: 500 }
);
}
}
-98
View File
@@ -1,98 +0,0 @@
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
@@ -1,131 +0,0 @@
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 }
);
}
}
-136
View File
@@ -1,136 +0,0 @@
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, isPremium: 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(listing.seller.isPremium, listing.seller.isPro);
const platformFeeRate = sellerTier === "pro" ? 0 : 0.015;
const platformFee = Math.round(listing.priceFlh * platformFeeRate);
// FLH Earning Multiplier: Premium/Pro sellers earn 2x their payout
const sellerMultiplier = listing.seller.isPremium || listing.seller.isPro ? 2 : 1;
const basePayout = listing.priceFlh - platformFee;
const sellerPayout = basePayout * sellerMultiplier;
// 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 (with multiplier applied)
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" },
}),
]);
const multiplierNote = sellerMultiplier > 1
? ` Seller earned ${sellerMultiplier}x FLH multiplier (Premium/Pro bonus).`
: "";
return NextResponse.json(
{
purchase,
message: `Successfully purchased "${listing.title}" for ${listing.priceFlh} FLH. Platform fee: ${platformFee} FLH. Seller receives: ${sellerPayout} FLH.${multiplierNote}`,
},
{ status: 201 }
);
} catch (error) {
console.error("POST /api/marketplace/purchase error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
-54
View File
@@ -1,54 +0,0 @@
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 }
);
}
}
+21
View File
@@ -0,0 +1,21 @@
import { NextResponse } from "next/server";
const CATEGORIES = [
{ id: "graphics-design", name: "Graphics & Design", icon: "🎨", color: "bg-pink-900/30 text-pink-400", subcategories: ["Logo & Brand Identity", "Art & Illustration", "Web & App Design", "Product & Gaming", "Print Design", "Visual Design", "Marketing Design", "Packaging & Covers", "Architecture & Building Design", "Fashion & Merchandise", "3D Design"] },
{ id: "digital-marketing", name: "Digital Marketing", icon: "📈", color: "bg-emerald-900/30 text-emerald-400", subcategories: ["Search", "Social", "Methods & Techniques", "Analytics & Strategy", "Channel Specific", "Industry & Purpose-Specific"] },
{ id: "writing-translation", name: "Writing & Translation", icon: "✍️", color: "bg-amber-900/30 text-amber-400", subcategories: ["Content Writing", "Editing & Critique", "Book & eBook Publishing", "Career Writing", "Business & Marketing Copy", "Translation & Transcription", "Industry Specific Content"] },
{ id: "video-animation", name: "Video & Animation", icon: "🎬", color: "bg-red-900/30 text-red-400", subcategories: ["Editing & Post-Production", "Social & Marketing Videos", "Animation", "Motion Graphics", "Filmed Production", "Explainer Videos", "AI Video"] },
{ id: "music-audio", name: "Music & Audio", icon: "🎵", color: "bg-purple-900/30 text-purple-400", subcategories: ["Production & Composition", "Engineering & Mixing", "Voice Over & Narration", "Streaming & Distribution", "DJing & Remixing", "Sound Design", "Lessons & Coaching"] },
{ id: "programming-tech", name: "Programming & Tech", icon: "💻", color: "bg-blue-900/30 text-blue-400", subcategories: ["Website Development", "Mobile App Development", "AI Development", "Game Development", "Cloud & Cybersecurity", "Data Science & Analytics", "Blockchain & Crypto", "DevOps & Infrastructure", "Chatbots & Automation", "API Development", "E-Commerce Development", "Support & IT"] },
{ id: "ai-services", name: "AI Services", icon: "🤖", color: "bg-cyan-900/30 text-cyan-400", subcategories: ["AI Development", "AI Artists", "AI Video", "AI Audio", "AI Content", "AI for Business", "AI Consulting"] },
{ id: "consulting", name: "Consulting", icon: "💼", color: "bg-slate-700/30 text-slate-400", subcategories: ["Business Consulting", "Marketing Strategy", "Data Consulting", "Coaching & Mentoring", "Tech Consulting", "Management Consulting"] },
{ id: "data", name: "Data", icon: "📊", color: "bg-teal-900/30 text-teal-400", subcategories: ["Data Entry", "Data Processing", "Data Analytics", "Data Visualization", "Data Science", "Data Mining", "Databases"] },
{ id: "business", name: "Business", icon: "🏢", color: "bg-indigo-900/30 text-indigo-400", subcategories: ["Financial Services", "Legal", "Management", "E-Commerce", "Sales", "Admin Support", "Project Management", "Customer Service", "HR & Recruiting"] },
{ id: "personal-growth", name: "Personal Growth", icon: "🌱", color: "bg-lime-900/30 text-lime-400", subcategories: ["Self Improvement", "Fashion & Style", "Wellness & Fitness", "Gaming", "Leisure", "Life Coaching", "Spiritual Guidance"] },
{ id: "photography", name: "Photography", icon: "📷", color: "bg-orange-900/30 text-orange-400", subcategories: ["Portrait Photography", "Product Photography", "Lifestyle Photography", "Real Estate Photography", "Event Photography", "Food Photography", "Drone Photography"] },
{ id: "finance", name: "Finance", icon: "💰", color: "bg-yellow-900/30 text-yellow-400", subcategories: ["Accounting", "Tax Services", "Corporate Finance", "FP&A", "Fundraising", "Wealth Management", "Financial Planning"] },
];
export async function GET() {
return NextResponse.json({ categories: CATEGORIES });
}
+56
View File
@@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
// GET /api/souq/listings/[id] — single listing with seller info + reviews
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const listing = await prisma.listing.findUnique({
where: { id },
include: {
seller: {
select: { id: true, name: true, email: true, avatar: true },
},
reviews: {
include: {
reviewer: {
select: { id: true, name: true, avatar: true },
},
},
orderBy: { createdAt: "desc" },
},
_count: {
select: { purchases: true },
},
},
});
if (!listing) {
return NextResponse.json(
{ error: "Listing not found" },
{ status: 404 }
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = listing as any;
return NextResponse.json({
listing: {
...data,
packages: data.packages ? JSON.parse(data.packages) : null,
tags: data.tags ? JSON.parse(data.tags) : null,
images: data.images ? JSON.parse(data.images) : null,
},
});
} catch (error) {
console.error("GET /api/souq/listings/[id] error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
+182
View File
@@ -0,0 +1,182 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const category = searchParams.get("category");
const search = searchParams.get("search");
const featured = searchParams.get("featured");
const sellerId = searchParams.get("sellerId");
const minPrice = searchParams.get("minPrice");
const maxPrice = searchParams.get("maxPrice");
const sortBy = searchParams.get("sortBy");
const deliveryDays = searchParams.get("deliveryDays");
const page = parseInt(searchParams.get("page") || "1", 10);
const limit = parseInt(searchParams.get("limit") || "20", 10);
const skip = (page - 1) * limit;
const where: Record<string, unknown> = {};
// Only return active listings by default
where.status = "active";
if (category) {
where.category = category;
}
if (search) {
where.OR = [
{ title: { contains: search } },
{ description: { contains: search } },
];
}
if (featured === "true") {
where.featured = true;
}
if (sellerId) {
where.sellerId = sellerId;
}
// Price range filter
if (minPrice || maxPrice) {
const priceFilter: Record<string, number> = {};
if (minPrice) priceFilter.gte = parseInt(minPrice, 10);
if (maxPrice) priceFilter.lte = parseInt(maxPrice, 10);
where.priceFlh = priceFilter;
}
// Delivery days filter
if (deliveryDays) {
where.deliveryDays = { lte: parseInt(deliveryDays, 10) };
}
// Build orderBy based on sortBy
let orderBy: Record<string, unknown>[];
switch (sortBy) {
case "price_asc":
orderBy = [{ priceFlh: "asc" }, { featured: "desc" }, { createdAt: "desc" }];
break;
case "price_desc":
orderBy = [{ priceFlh: "desc" }, { featured: "desc" }, { createdAt: "desc" }];
break;
case "rating":
orderBy = [{ rating: "desc" }, { featured: "desc" }, { createdAt: "desc" }];
break;
case "popular":
orderBy = [{ salesCount: "desc" }, { featured: "desc" }, { createdAt: "desc" }];
break;
case "newest":
orderBy = [{ createdAt: "desc" }, { featured: "desc" }];
break;
default:
orderBy = [{ featured: "desc" }, { createdAt: "desc" }];
break;
}
const [listings, total] = await Promise.all([
prisma.listing.findMany({
where,
include: {
seller: {
select: { id: true, name: true, email: true, avatar: true },
},
},
orderBy,
skip,
take: limit,
}),
prisma.listing.count({ where }),
]);
return NextResponse.json({
listings,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
});
} catch (error) {
console.error("GET /api/souq/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 {
title,
description,
category,
subcategory,
priceFlh,
packages,
tags,
images,
deliveryDays,
fileType,
} = await request.json();
if (!title || !description || !category || !priceFlh) {
return NextResponse.json(
{ error: "Missing required fields: title, description, category, priceFlh" },
{ status: 400 }
);
}
if (typeof priceFlh !== "number" || priceFlh < 0) {
return NextResponse.json(
{ error: "priceFlh must be a non-negative number" },
{ status: 400 }
);
}
const listing = await prisma.listing.create({
data: {
title,
description,
category,
subcategory: subcategory || null,
priceFlh,
packages: packages ? JSON.stringify(packages) : null,
tags: tags ? JSON.stringify(tags) : null,
images: images ? JSON.stringify(images) : null,
deliveryDays: deliveryDays || null,
fileType: fileType || null,
sellerId: user.id,
status: "active",
},
include: {
seller: {
select: { id: true, name: true, email: true, avatar: true },
},
},
});
return NextResponse.json({ listing }, { status: 201 });
} catch (error) {
console.error("POST /api/souq/listings error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
+250
View File
@@ -0,0 +1,250 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
// GET /api/souq/orders/[id] — order detail
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const auth = await requireAuth(request);
if (!auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
const order = await prisma.purchase.findUnique({
where: { id },
include: {
listing: {
select: { id: true, title: true, category: true, subcategory: true, priceFlh: true, description: true, deliveryDays: true },
},
buyer: {
select: { id: true, name: true, email: true, avatar: true },
},
seller: {
select: { id: true, name: true, email: true, avatar: true },
},
},
});
if (!order) {
return NextResponse.json(
{ error: "Order not found" },
{ status: 404 }
);
}
// Verify user is either the buyer or seller
if (order.buyerId !== auth.userId && order.sellerId !== auth.userId) {
return NextResponse.json(
{ error: "Forbidden: you are not a party to this order" },
{ status: 403 }
);
}
// Check if the current user (buyer) has already reviewed this order
let hasReviewed = false;
if (order.status === "completed" && order.buyerId === auth.userId) {
const existingReview = await prisma.review.findFirst({
where: { purchaseId: order.id, reviewerId: auth.userId },
select: { id: true },
});
hasReviewed = !!existingReview;
}
// Parse JSON fields
const data = order as any;
return NextResponse.json({
order: {
...data,
messages: data.messages ? JSON.parse(data.messages) : [],
deliveryFiles: data.deliveryFiles ? JSON.parse(data.deliveryFiles) : [],
},
hasReviewed,
});
} catch (error) {
console.error("GET /api/souq/orders/[id] error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
// PATCH /api/souq/orders/[id] — update order status or add messages
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const auth = await requireAuth(request);
if (!auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
const order = await prisma.purchase.findUnique({
where: { id },
});
if (!order) {
return NextResponse.json(
{ error: "Order not found" },
{ status: 404 }
);
}
// Verify user is either the buyer or seller
if (order.buyerId !== auth.userId && order.sellerId !== auth.userId) {
return NextResponse.json(
{ error: "Forbidden: you are not a party to this order" },
{ status: 403 }
);
}
const body = await request.json();
const { status: newStatus, message, deliveryFiles } = body;
const updateData: Record<string, unknown> = {};
// Update status if provided
if (newStatus) {
const validStatuses = [
"pending",
"paid",
"in_progress",
"delivered",
"completed",
"disputed",
"cancelled",
];
if (!validStatuses.includes(newStatus)) {
return NextResponse.json(
{
error: `Invalid status. Must be one of: ${validStatuses.join(", ")}`,
},
{ status: 400 }
);
}
// Validate status transitions
const validTransitions: Record<string, string[]> = {
paid: ["in_progress", "cancelled"],
in_progress: ["delivered", "disputed"],
delivered: ["completed", "disputed"],
completed: [],
disputed: ["completed", "cancelled"],
cancelled: [],
pending: ["paid", "cancelled"],
};
const allowed = validTransitions[order.status] || [];
if (!allowed.includes(newStatus)) {
return NextResponse.json(
{
error: `Cannot transition from "${order.status}" to "${newStatus}"`,
},
{ status: 400 }
);
}
// Only seller can mark as in_progress or delivered
if (
(newStatus === "in_progress" || newStatus === "delivered") &&
auth.userId !== order.sellerId
) {
return NextResponse.json(
{ error: "Only the seller can update to this status" },
{ status: 403 }
);
}
// Only buyer can mark as completed
if (newStatus === "completed" && auth.userId !== order.buyerId) {
return NextResponse.json(
{ error: "Only the buyer can mark an order as completed" },
{ status: 403 }
);
}
updateData.status = newStatus;
}
// Add a message if provided
if (message) {
if (typeof message !== "string" || message.trim().length === 0) {
return NextResponse.json(
{ error: "Message must be a non-empty string" },
{ status: 400 }
);
}
const existingMessages = order.messages
? (JSON.parse(order.messages) as Array<Record<string, unknown>>)
: [];
const newMessage = {
from: auth.userId,
text: message.trim(),
timestamp: new Date().toISOString(),
};
existingMessages.push(newMessage);
updateData.messages = JSON.stringify(existingMessages);
}
// Update delivery files if provided
if (deliveryFiles) {
if (!Array.isArray(deliveryFiles)) {
return NextResponse.json(
{ error: "deliveryFiles must be an array" },
{ status: 400 }
);
}
updateData.deliveryFiles = JSON.stringify(deliveryFiles);
}
if (Object.keys(updateData).length === 0) {
return NextResponse.json(
{ error: "No fields to update. Provide status, message, or deliveryFiles." },
{ status: 400 }
);
}
const updated = await prisma.purchase.update({
where: { id },
data: updateData,
include: {
listing: {
select: { id: true, title: true, category: true },
},
buyer: {
select: { id: true, name: true, email: true, avatar: true },
},
seller: {
select: { id: true, name: true, email: true, avatar: true },
},
},
});
const data = updated as any;
return NextResponse.json({
order: {
...data,
messages: data.messages ? JSON.parse(data.messages) : [],
deliveryFiles: data.deliveryFiles ? JSON.parse(data.deliveryFiles) : [],
},
});
} catch (error) {
console.error("PATCH /api/souq/orders/[id] error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
+213
View File
@@ -0,0 +1,213 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
// GET /api/souq/orders — my orders (as buyer or seller)
export async function GET(request: NextRequest) {
try {
const auth = await requireAuth(request);
if (!auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const role = searchParams.get("role"); // "buyer" | "seller" | null (both)
const page = parseInt(searchParams.get("page") || "1", 10);
const limit = parseInt(searchParams.get("limit") || "20", 10);
const skip = (page - 1) * limit;
const where: Record<string, unknown> = {};
if (role === "buyer") {
where.buyerId = auth.userId;
} else if (role === "seller") {
where.sellerId = auth.userId;
} else {
where.OR = [
{ buyerId: auth.userId },
{ sellerId: auth.userId },
];
}
const [orders, total] = await Promise.all([
prisma.purchase.findMany({
where,
include: {
listing: {
select: { id: true, title: true, category: true, priceFlh: true },
},
buyer: {
select: { id: true, name: true, email: true, avatar: true },
},
seller: {
select: { id: true, name: true, email: true, avatar: true },
},
},
orderBy: { createdAt: "desc" },
skip,
take: limit,
}),
prisma.purchase.count({ where }),
]);
// Parse messages JSON for each order
const parsed = orders.map((order) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const o = order as any;
return {
...o,
messages: o.messages ? JSON.parse(o.messages) : null,
deliveryFiles: o.deliveryFiles ? JSON.parse(o.deliveryFiles) : null,
};
});
return NextResponse.json({
orders: parsed,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
});
} catch (error) {
console.error("GET /api/souq/orders error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
// POST /api/souq/orders — place a new order
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 { listingId, packageName } = await request.json();
if (!listingId) {
return NextResponse.json(
{ error: "Missing required field: listingId" },
{ status: 400 }
);
}
// Validate listing exists
const listing = await prisma.listing.findUnique({
where: { id: listingId },
});
if (!listing) {
return NextResponse.json(
{ error: "Listing not found" },
{ status: 404 }
);
}
if (listing.status !== "active") {
return NextResponse.json(
{ error: "Listing is not available for purchase" },
{ status: 400 }
);
}
// Prevent buying own listing
if (listing.sellerId === user.id) {
return NextResponse.json(
{ error: "You cannot purchase your own listing" },
{ status: 400 }
);
}
// Determine amount: if packageName provided, look it up in packages JSON
let amountFlh = listing.priceFlh;
if (packageName) {
if (!listing.packages) {
return NextResponse.json(
{ error: "This listing has no packages" },
{ status: 400 }
);
}
const packages = JSON.parse(listing.packages) as Array<{
name: string;
price?: number;
}>;
const selectedPackage = packages.find((p) => p.name === packageName);
if (!selectedPackage) {
return NextResponse.json(
{ error: `Package "${packageName}" not found` },
{ status: 400 }
);
}
amountFlh = selectedPackage.price ?? listing.priceFlh;
}
// Check buyer balance
if (user.flhBalance < amountFlh) {
return NextResponse.json(
{ error: "Insufficient FLH balance" },
{ status: 400 }
);
}
// Calculate fees
const platformFee = Math.round(amountFlh * 0.015);
const sellerPayout = amountFlh - platformFee;
// Create purchase and deduct balance in a transaction
const [purchase] = await prisma.$transaction([
prisma.purchase.create({
data: {
listingId,
buyerId: user.id,
sellerId: listing.sellerId,
packageName: packageName || null,
amountFlh,
platformFee,
sellerPayout,
status: "paid",
},
include: {
listing: {
select: { id: true, title: true, category: true, priceFlh: true },
},
buyer: {
select: { id: true, name: true, email: true, avatar: true },
},
seller: {
select: { id: true, name: true, email: true, avatar: true },
},
},
}),
prisma.user.update({
where: { id: user.id },
data: { flhBalance: { decrement: amountFlh } },
}),
// Increment sales count on the listing
prisma.listing.update({
where: { id: listingId },
data: { salesCount: { increment: 1 } },
}),
]);
return NextResponse.json({ order: purchase }, { status: 201 });
} catch (error) {
console.error("POST /api/souq/orders error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
+113
View File
@@ -0,0 +1,113 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
// POST /api/souq/reviews — create a review
export async function POST(request: NextRequest) {
try {
const auth = await requireAuth(request);
if (!auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { listingId, orderId, rating, title, text } = await request.json();
// ── Validate fields ──
if (!listingId || !orderId) {
return NextResponse.json(
{ error: "Missing required fields: listingId, orderId" },
{ status: 400 }
);
}
if (!rating || typeof rating !== "number" || rating < 1 || rating > 5 || !Number.isInteger(rating)) {
return NextResponse.json(
{ error: "Rating must be an integer between 1 and 5" },
{ status: 400 }
);
}
// ── Ensure the order exists and belongs to this user ──
const order = await prisma.purchase.findUnique({
where: { id: orderId },
include: { listing: { select: { id: true, sellerId: true } } },
});
if (!order) {
return NextResponse.json({ error: "Order not found" }, { status: 404 });
}
if (order.buyerId !== auth.userId) {
return NextResponse.json(
{ error: "Only the buyer can review this order" },
{ status: 403 }
);
}
if (order.status !== "completed") {
return NextResponse.json(
{ error: "Can only review completed orders" },
{ status: 400 }
);
}
if (order.listingId !== listingId) {
return NextResponse.json(
{ error: "Listing does not match this order" },
{ status: 400 }
);
}
// ── Check if user already reviewed this purchase ──
const existing = await prisma.review.findFirst({
where: { purchaseId: orderId, reviewerId: auth.userId },
});
if (existing) {
return NextResponse.json(
{ error: "You have already reviewed this order" },
{ status: 409 }
);
}
// ── Create the review and update listing stats in a transaction ──
const [review] = await prisma.$transaction(async (tx) => {
const created = await tx.review.create({
data: {
listingId,
purchaseId: orderId,
reviewerId: auth.userId,
sellerId: order.listing.sellerId,
rating,
title: title || null,
text: text ?? "",
},
});
// Recalculate listing average rating and count
const agg = await tx.review.aggregate({
where: { listingId },
_avg: { rating: true },
_count: { rating: true },
});
await tx.listing.update({
where: { id: listingId },
data: {
rating: agg._avg.rating ?? 0,
reviewCount: agg._count.rating,
},
});
return [created];
});
return NextResponse.json({ review }, { status: 201 });
} catch (error) {
console.error("POST /api/souq/reviews error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
+84
View File
@@ -0,0 +1,84 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
// GET /api/souq/sellers/[id] — seller profile data
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
// Get user info
const user = await prisma.user.findUnique({
where: { id },
select: {
id: true,
name: true,
email: true,
avatar: true,
createdAt: true,
},
});
if (!user) {
return NextResponse.json(
{ error: "Seller not found" },
{ status: 404 }
);
}
// Get review stats
const reviewAgg = await prisma.review.aggregate({
where: { sellerId: id },
_avg: { rating: true },
_count: true,
});
// Get sales count
const salesCount = await prisma.purchase.count({
where: { sellerId: id },
});
// Get active listings
const listings = await prisma.listing.findMany({
where: { sellerId: id, status: "active" },
orderBy: { createdAt: "desc" },
include: {
seller: {
select: { id: true, name: true, email: true, avatar: true },
},
},
});
// Parse JSON fields
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const parsedListings = listings.map((l: any) => ({
...l,
packages: l.packages ? JSON.parse(l.packages) : null,
tags: l.tags ? JSON.parse(l.tags) : null,
images: l.images ? JSON.parse(l.images) : null,
}));
return NextResponse.json({
seller: {
id: user.id,
name: user.name,
email: user.email,
avatar: user.avatar,
memberSince: user.createdAt,
listingsCount: parsedListings.length,
averageRating: reviewAgg._avg.rating ?? 0,
reviewCount: reviewAgg._count,
salesCount,
listings: parsedListings,
},
});
} catch (error) {
console.error("GET /api/souq/sellers/[id] error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
+93
View File
@@ -0,0 +1,93 @@
import { NextRequest, NextResponse } from "next/server";
import { requireAuth } from "@/lib/auth";
import { writeFile } from "fs/promises";
import path from "path";
const MAX_SIZE = 10 * 1024 * 1024; // 10 MB
const ALLOWED_MIME = [
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"application/pdf",
"application/zip",
"application/x-zip-compressed",
"application/x-rar-compressed",
"application/x-7z-compressed",
"application/gzip",
];
// POST /api/souq/upload — upload a delivery file
export async function POST(request: NextRequest) {
try {
const auth = await requireAuth(request);
if (!auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const formData = await request.formData();
const file = formData.get("file") as File | null;
const orderId = formData.get("orderId") as string | null;
if (!file) {
return NextResponse.json({ error: "No file provided" }, { status: 400 });
}
if (!orderId) {
return NextResponse.json(
{ error: "orderId is required" },
{ status: 400 }
);
}
// Check file size
if (file.size > MAX_SIZE) {
return NextResponse.json(
{ error: "File too large. Maximum 10 MB." },
{ status: 413 }
);
}
// Only check mime type if available; allow fallback for unknown types
if (file.type && !ALLOWED_MIME.includes(file.type)) {
return NextResponse.json(
{
error:
"Invalid file type. Allowed: images, PDFs, ZIP, RAR, 7z, GZIP.",
},
{ status: 400 }
);
}
// Generate unique filename
const timestamp = Date.now();
const safeName = file.name.replace(/[^a-zA-Z0-9._-]/g, "_");
const fileName = `${orderId}-${timestamp}-${safeName}`;
// Save to disk
const uploadDir = path.join(
process.cwd(),
"public",
"uploads",
"deliveries"
);
const filePath = path.join(uploadDir, fileName);
const buffer = Buffer.from(await file.arrayBuffer());
await writeFile(filePath, buffer);
// Return the public URL path
const url = `/uploads/deliveries/${fileName}`;
return NextResponse.json({
url,
name: file.name,
size: file.size,
});
} catch (error) {
console.error("POST /api/souq/upload error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
@@ -3,9 +3,11 @@ 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 },
500: { usd: 0.99, bonus: 0, priceEnv: "POLAR_FLH_500" },
1000: { usd: 0.99, bonus: 0, priceEnv: "POLAR_FLH_1000" },
5000: { usd: 4.99, bonus: 500, priceEnv: "POLAR_FLH_5000" },
10000: { usd: 9.99, bonus: 2000, priceEnv: "POLAR_FLH_10000" },
50000: { usd: 49.99, bonus: 15000, priceEnv: "POLAR_FLH_50000" },
} as const;
export async function POST(req: NextRequest) {
@@ -20,22 +22,19 @@ export async function POST(req: NextRequest) {
if (!amount || !(amount in TOP_UP_AMOUNTS)) {
return NextResponse.json(
{ error: "Invalid amount. Choose 500, 1100, or 3000 FLH." },
{ error: "Invalid amount. Choose 500, 1000, 5000, 10000, or 50000 FLH." },
{ status: 400 }
);
}
const pricing = TOP_UP_AMOUNTS[amount as keyof typeof TOP_UP_AMOUNTS];
const totalFlh = amount + (pricing.bonus || 0);
// 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) {
// Mock mode: use when POLAR_ACCESS_TOKEN is not configured
if (!process.env.POLAR_ACCESS_TOKEN) {
await prisma.user.update({
where: { id: jwtPayload.userId },
data: { flhBalance: { increment: amount } },
data: { flhBalance: { increment: totalFlh } },
});
return NextResponse.json({
@@ -43,27 +42,67 @@ export async function POST(req: NextRequest) {
url: "/wallet?topup=success",
mock: true,
amount,
bonus: pricing.bonus || 0,
amountUsd: pricing.usd,
});
}
// Production path — Polar checkout creation
// const polarSession = await createPolarCheckout({
// amount,
// customerId: user.stripeCustomerId,
// metadata: { userId: jwtPayload.userId, type: "top-up", amount },
// });
// Production — Polar.sh checkout session
const productPriceId = process.env[pricing.priceEnv];
if (!productPriceId) {
return NextResponse.json(
{ error: `Polar price not configured for ${amount} FLH. Set ${pricing.priceEnv} env var.` },
{ status: 500 }
);
}
const origin = req.headers.get("origin") || req.headers.get("host") || "";
const baseUrl = origin.startsWith("http") ? origin : `https://${origin}`;
const polarResponse = await fetch("https://api.polar.sh/v1/checkouts/", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.POLAR_ACCESS_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
product_price_id: productPriceId,
success_url: `${baseUrl}/wallet?topup=success&amount=${amount}`,
customer_email: jwtPayload.email,
return_url: `${baseUrl}/wallet`,
metadata: {
type: "flh_purchase",
flh_amount: totalFlh,
user_id: jwtPayload.userId,
user_email: jwtPayload.email,
},
allow_trial: false,
}),
});
if (!polarResponse.ok) {
const errorBody = await polarResponse.text();
console.error("Polar API error:", polarResponse.status, errorBody);
return NextResponse.json(
{ error: `Checkout creation failed (HTTP ${polarResponse.status})` },
{ status: 502 }
);
}
const polarData = await polarResponse.json();
return NextResponse.json({
success: true,
url: "/wallet?topup=success", // would be polarSession.url
url: polarData.url,
checkout_id: polarData.id,
amount,
amountUsd: pricing.usd,
bonus: pricing.bonus || 0,
});
} catch (error) {
console.error("Top-up checkout error:", error);
return NextResponse.json(
{ error: "Failed to create checkout" },
{ error: error instanceof Error ? error.message : "Failed to create checkout" },
{ status: 500 }
);
}
@@ -0,0 +1,114 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
// Polar.sh webhook for FLH purchase checkouts
// Receives checkout.completed events and credits the buyer's FLH balance
export async function POST(request: NextRequest) {
try {
// Log the incoming webhook for debugging
const body = await request.json();
const eventType = body.type || "";
console.log("[polar-webhook] Received:", JSON.stringify({ type: eventType, id: body.data?.id }));
// Only process checkout events
if (!eventType.startsWith("checkout.")) {
return NextResponse.json({ received: true, ignored: true }, { status: 200 });
}
// Verify webhook secret if configured
const webhookSecret = process.env.POLAR_WEBHOOK_SECRET;
if (webhookSecret) {
const signature = request.headers.get("polar-signature") || "";
// In production, verify the signature using crypto.timingSafeEqual
// For now, use the secret as a simple check
if (signature !== webhookSecret) {
console.warn("[polar-webhook] Invalid signature");
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
}
}
const checkout = body.data || {};
const metadata = checkout.metadata || {};
// Only process FLH purchase events
if (metadata.type !== "flh_purchase") {
return NextResponse.json({ received: true, ignored: true }, { status: 200 });
}
// Only process completed/paid checkouts
if (checkout.status !== "succeeded" && checkout.status !== "paid") {
return NextResponse.json(
{ received: true, status: checkout.status },
{ status: 200 }
);
}
const userId = metadata.user_id;
const flhAmount = parseInt(metadata.flh_amount, 10);
if (!userId || !flhAmount || flhAmount < 1) {
console.error("[polar-webhook] Invalid metadata:", { userId, flhAmount });
return NextResponse.json(
{ error: "Invalid metadata: userId and flh_amount required" },
{ status: 400 }
);
}
// Check if this checkout was already processed (idempotency)
const checkoutId = checkout.id || body.id;
if (checkoutId) {
const existing = await prisma.xpTransaction.findFirst({
where: {
userId,
reason: `flh_purchase_${checkoutId}`,
},
});
if (existing) {
console.log(`[polar-webhook] Checkout ${checkoutId} already processed, skipping`);
return NextResponse.json({ received: true, duplicate: true }, { status: 200 });
}
}
// Credit the user's FLH balance
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) {
console.error(`[polar-webhook] User not found: ${userId}`);
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
await prisma.user.update({
where: { id: userId },
data: { flhBalance: { increment: flhAmount } },
});
// Record the transaction for idempotency and audit
if (checkoutId) {
await prisma.xpTransaction.create({
data: {
userId,
amount: flhAmount,
reason: `flh_purchase_${checkoutId}`,
},
});
}
console.log(`[polar-webhook] Credited ${flhAmount} FLH to user ${userId}. New balance: ${user.flhBalance + flhAmount}`);
return NextResponse.json(
{
received: true,
success: true,
flhCredited: flhAmount,
userId,
newBalance: user.flhBalance + flhAmount,
},
{ status: 200 }
);
} catch (error) {
console.error("[polar-webhook] Error:", error);
return NextResponse.json(
{ error: "Webhook processing failed" },
{ status: 500 }
);
}
}
+273 -1
View File
@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import crypto from "crypto";
import { prisma } from "@/lib/prisma";
/**
@@ -20,7 +21,38 @@ export async function POST(req: NextRequest) {
}
// Production path: Parse Polar webhook event
const body = await req.json();
const rawBody = await req.text();
// Verify webhook signature
const signature = req.headers.get("webhook-id") || req.headers.get("polar-signature");
const webhookSecret = process.env.POLAR_WEBHOOK_SECRET;
if (webhookSecret) {
if (!signature) {
console.error("Polar webhook signature missing");
return NextResponse.json(
{ error: "Missing webhook signature" },
{ status: 401 }
);
}
const expectedSignature = crypto
.createHmac("sha256", webhookSecret)
.update(rawBody)
.digest("hex");
if (signature !== expectedSignature) {
console.error("Polar webhook signature mismatch");
return NextResponse.json(
{ error: "Invalid webhook signature" },
{ status: 401 }
);
}
} else {
console.warn("POLAR_WEBHOOK_SECRET not set — skipping signature verification (dev mode)");
}
const body = JSON.parse(rawBody);
const event = body.type || body.event;
if (!event) {
@@ -43,6 +75,39 @@ export async function POST(req: NextRequest) {
);
}
const metadataType = checkout.metadata?.type;
const customerEmail = checkout.customer?.email;
// --- Course purchase flow ---
if (metadataType === "course_purchase") {
const courseSlug = checkout.metadata?.course_slug;
if (!courseSlug) {
return NextResponse.json(
{ error: "Missing course_slug in metadata for course_purchase" },
{ status: 400 }
);
}
if (!customerEmail) {
return NextResponse.json(
{ error: "Missing customer email for course_purchase" },
{ status: 400 }
);
}
return handleCoursePurchase(customerEmail, courseSlug);
}
// --- Learn subscription flow ---
if (metadataType === "learn_subscription") {
if (!customerEmail) {
return NextResponse.json(
{ error: "Missing customer email for learn_subscription" },
{ status: 400 }
);
}
return handleLearnSubscription(customerEmail, checkout);
}
// --- Fallback: premium/pro upgrade (existing logic) ---
const userId = checkout.metadata?.userId;
const priceId = checkout.product?.priceId ||
checkout.productPrice?.id ||
@@ -64,6 +129,11 @@ export async function POST(req: NextRequest) {
return applyUpgrade(userId, tier);
}
// Handle subscription.cancelled and subscription.revoked events
if (event === "subscription.cancelled" || event === "subscription.revoked") {
return handleSubscriptionCancelled(body.data);
}
// Acknowledge other events silently
return NextResponse.json({ received: true });
} catch (error) {
@@ -116,3 +186,205 @@ async function applyUpgrade(userId: string, tier: "premium" | "pro") {
trialEndsAt,
});
}
/**
* Handle a course_purchase checkout.
* Looks up the user by email, finds the LearnCourse by slug,
* and creates (or re-activates) a LearnEnrollment with purchaseType='one_time'.
*/
async function handleCoursePurchase(customerEmail: string, courseSlug: string) {
// Find the user by email
const user = await prisma.user.findUnique({ where: { email: customerEmail } });
if (!user) {
return NextResponse.json(
{ error: "User not found for email" },
{ status: 404 }
);
}
// Find the course by slug
const course = await prisma.learnCourse.findUnique({ where: { slug: courseSlug } });
if (!course) {
return NextResponse.json(
{ error: "Course not found" },
{ status: 404 }
);
}
// Upsert enrollment: create or re-activate on re-purchase
await prisma.learnEnrollment.upsert({
where: {
userId_courseId: { userId: user.id, courseId: course.id },
},
create: {
userId: user.id,
courseId: course.id,
purchaseType: "one_time",
progress: 0,
startedAt: new Date(),
},
update: {
purchaseType: "one_time",
completed: false,
progress: 0,
completedAt: null,
startedAt: new Date(),
},
});
return NextResponse.json({
success: true,
type: "course_purchase",
userId: user.id,
courseId: course.id,
courseSlug,
});
}
/**
* Handle a learn_subscription checkout.
* Looks up the user by email and creates/updates a LearnSubscription record.
* Uses Polar subscription data for period management.
*/
async function handleLearnSubscription(customerEmail: string, checkout: any) {
// Find the user by email
const user = await prisma.user.findUnique({ where: { email: customerEmail } });
if (!user) {
return NextResponse.json(
{ error: "User not found for email" },
{ status: 404 }
);
}
// Extract subscription details from checkout data
const polarSubId =
checkout.subscription?.id ||
checkout.metadata?.subscriptionId ||
null;
const now = new Date();
const currentPeriodStart = checkout.subscription?.currentPeriodStart
? new Date(checkout.subscription.currentPeriodStart)
: now;
const currentPeriodEnd = checkout.subscription?.currentPeriodEnd
? new Date(checkout.subscription.currentPeriodEnd)
: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000); // Default 30 days
// Upsert subscription record per user (one active subscription per user)
if (polarSubId) {
await prisma.learnSubscription.upsert({
where: { polarSubId },
create: {
userId: user.id,
polarSubId,
status: "active",
currentPeriodStart,
currentPeriodEnd,
},
update: {
userId: user.id,
status: "active",
currentPeriodStart,
currentPeriodEnd,
cancelledAt: null,
},
});
} else {
// No polarSubId: check if user already has a subscription and update it
const existing = await prisma.learnSubscription.findFirst({
where: { userId: user.id },
orderBy: { createdAt: "desc" },
});
if (existing) {
await prisma.learnSubscription.update({
where: { id: existing.id },
data: {
status: "active",
currentPeriodStart,
currentPeriodEnd,
cancelledAt: null,
},
});
} else {
await prisma.learnSubscription.create({
data: {
userId: user.id,
status: "active",
currentPeriodStart,
currentPeriodEnd,
},
});
}
}
// Also update the User's premium status to match the active subscription
await prisma.user.update({
where: { id: user.id },
data: {
isPremium: true,
trialEndsAt: currentPeriodEnd,
},
});
return NextResponse.json({
success: true,
type: "learn_subscription",
userId: user.id,
});
}
/**
* Handle a subscription.cancelled or subscription.revoked event.
* Sets the LearnSubscription status to 'cancelled' and removes the user's isPremium flag.
*/
async function handleSubscriptionCancelled(data: any) {
// Extract the Polar subscription ID from the event data
const polarSubId =
data?.id ||
data?.subscription?.id ||
data?.metadata?.subscriptionId ||
null;
if (!polarSubId) {
return NextResponse.json(
{ error: "Missing subscription ID in event data" },
{ status: 400 }
);
}
// Find the LearnSubscription by the Polar subscription ID
const subscription = await prisma.learnSubscription.findUnique({
where: { polarSubId },
});
if (!subscription) {
return NextResponse.json(
{ error: "LearnSubscription not found for polarSubId" },
{ status: 404 }
);
}
// Mark the subscription as cancelled
await prisma.learnSubscription.update({
where: { id: subscription.id },
data: {
status: "cancelled",
cancelledAt: new Date(),
},
});
// Remove the user's premium flag
await prisma.user.update({
where: { id: subscription.userId },
data: {
isPremium: false,
},
});
return NextResponse.json({
success: true,
type: "subscription_cancelled",
userId: subscription.userId,
});
}
+5 -107
View File
@@ -9,44 +9,10 @@ import ErrorFeedback from "@/components/ErrorFeedback";
type AuthMode = "login" | "register";
type PageState = "select" | "email-form";
/** Inline Google SVG logo */
function GoogleLogo({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none">
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
);
}
/** Inline GitHub SVG logo */
function GitHubLogo({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z" />
</svg>
);
}
function AuthPageInner() {
const { user, login, register, oauthLogin, loading: authLoading } = useAuth();
const { user, login, register, loading: authLoading } = useAuth();
const router = useRouter();
const searchParams = useSearchParams();
const refCode = searchParams.get("ref");
// Redirect to main page if already logged in (e.g. after OAuth popup completes)
useEffect(() => {
@@ -61,11 +27,6 @@ function AuthPageInner() {
const [error, setError] = useState("");
const [submitting, setSubmitting] = useState(false);
const handleProviderClick = (provider: string) => {
setError("");
oauthLogin(provider);
};
const handleEmailClick = () => {
setError("");
setPageState("email-form");
@@ -98,7 +59,7 @@ function AuthPageInner() {
if (mode === "login") {
await login(email.trim(), password);
} else {
await register(email.trim(), name.trim(), password, refCode || undefined);
await register(email.trim(), name.trim(), password);
}
router.push("/");
} catch (err: unknown) {
@@ -120,45 +81,14 @@ function AuthPageInner() {
<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 to Falah</h1>
<h1 className="text-2xl font-bold text-white">Sign in with Ummah ID</h1>
<p className="text-sm text-gray-500 mt-1">
Your Islamic lifestyle companion
Your Ummah ID works across all Falah apps
</p>
</div>
{/* SSO Buttons */}
{/* Sign-in Methods */}
<div className="space-y-3">
{/* Google */}
<button
onClick={() => handleProviderClick("google")}
disabled={authLoading}
className="w-full flex items-center gap-4 px-5 py-4 rounded-2xl bg-[#111118] border border-gray-800/60 active:bg-[#1a1a24] disabled:opacity-50 transition-all touch-manipulation"
>
<GoogleLogo className="w-6 h-6 shrink-0" />
<span className="text-sm font-medium text-white">
Continue with Google
</span>
</button>
{/* GitHub */}
<button
onClick={() => handleProviderClick("github")}
disabled={authLoading}
className="w-full flex items-center gap-4 px-5 py-4 rounded-2xl bg-[#111118] border border-gray-800/60 active:bg-[#1a1a24] disabled:opacity-50 transition-all touch-manipulation"
>
<GitHubLogo className="w-6 h-6 shrink-0 text-white" />
<span className="text-sm font-medium text-white">
Continue with GitHub
</span>
</button>
{/* Divider */}
<div className="flex items-center gap-3 py-2">
<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>
{/* Email */}
<button
onClick={handleEmailClick}
@@ -173,21 +103,6 @@ function AuthPageInner() {
</span>
</button>
</div>
{/* Demo Credentials */}
<div className="mt-8 rounded-2xl bg-[#111118] border border-gray-800/60 p-4 text-center">
<p className="text-xs text-gray-600 uppercase tracking-wider mb-1.5">
Demo Account
</p>
<p className="text-xs text-gray-500">
Email:{" "}
<span className="text-gray-300 font-mono">demo@falahos.my</span>
</p>
<p className="text-xs text-gray-500">
Password:{" "}
<span className="text-gray-300 font-mono">password123</span>
</p>
</div>
</div>
</div>
);
@@ -345,26 +260,9 @@ function AuthPageInner() {
<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
{refCode && (
<>
{" "}+{" "}
<span className="text-emerald-400 font-semibold">1,000 FLH</span>{" "}
referral bonus
</>
)}
</p>
</div>
)}
{/* Demo credentials (subtle) */}
<div className="mt-6 rounded-xl bg-[#111118]/60 border border-gray-800/40 p-3 text-center">
<p className="text-xs text-gray-600">
Demo:{" "}
<span className="text-gray-500 font-mono">demo@falahos.my</span>{" "}
<span className="text-gray-600">/</span>{" "}
<span className="text-gray-500 font-mono">password123</span>
</p>
</div>
</div>
</div>
);
+1 -1
View File
@@ -92,7 +92,7 @@ export default function GroupDetailPage() {
});
const data = await res.json();
if (res.ok) {
setGroup(data);
setGroup(data.group);
} else {
setError(data.error || "Failed to load group");
}
+25
View File
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import "./globals.css";
import { AuthProvider } from "@/lib/AuthContext";
import BottomNav from "@/components/BottomNav";
import PWARegister from "@/components/PWARegister";
export const metadata: Metadata = {
title: "Falah — Islamic Lifestyle",
@@ -9,6 +10,27 @@ export const metadata: Metadata = {
viewport: "width=device-width, initial-scale=1, viewport-fit=cover",
themeColor: "#0a0a0f",
appleWebApp: { capable: true, statusBarStyle: "black-translucent" },
manifest: "/mobile/manifest.json",
icons: {
icon: [
{ url: "/mobile/icons/icon-48x48.png", sizes: "48x48", type: "image/png" },
{ url: "/mobile/icons/icon-72x72.png", sizes: "72x72", type: "image/png" },
{ url: "/mobile/icons/icon-96x96.png", sizes: "96x96", type: "image/png" },
{ url: "/mobile/icons/icon-128x128.png", sizes: "128x128", type: "image/png" },
{ url: "/mobile/icons/icon-144x144.png", sizes: "144x144", type: "image/png" },
{ url: "/mobile/icons/icon-152x152.png", sizes: "152x152", type: "image/png" },
{ url: "/mobile/icons/icon-192x192.png", sizes: "192x192", type: "image/png" },
{ url: "/mobile/icons/icon-384x384.png", sizes: "384x384", type: "image/png" },
{ url: "/mobile/icons/icon-512x512.png", sizes: "512x512", type: "image/png" },
],
apple: [
{ url: "/mobile/apple-touch-icon.png", sizes: "152x152", type: "image/png" },
],
shortcut: [{ url: "/mobile/icons/icon-48x48.png", type: "image/png" }],
},
other: {
"apple-mobile-web-app-title": "Falah",
},
};
export default function RootLayout({
@@ -24,8 +46,11 @@ export default function RootLayout({
<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" />
<meta name="application-name" content="Falah" />
<link rel="manifest" href="/mobile/manifest.json" />
</head>
<body>
<PWARegister />
<AuthProvider>
<div className="mobile-container relative min-h-dvh">
<main className="pb-4">{children}</main>
+775
View File
@@ -0,0 +1,775 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { useAuth } from "@/lib/AuthContext";
import { useRouter, useParams } from "next/navigation";
import {
ChevronLeft,
ChevronRight,
Play,
Pause,
CheckCircle2,
RotateCcw,
Loader2,
} from "lucide-react";
import ErrorFeedback from "@/components/ErrorFeedback";
// ── Types ──
interface ModuleProgress {
completed: boolean;
score: number | null;
listened: boolean;
}
interface Module {
id: string;
order: number;
title: string;
slug: string;
keyTakeaway: string | null;
duration: number;
content: string | null;
quizData: Record<string, unknown> | null;
progress: ModuleProgress | null;
}
interface Course {
id: string;
slug: string;
title: string;
author: string;
description: string;
imageUrl: string | null;
moduleCount: number;
totalMinutes: number;
difficulty: string;
giteaPath: string;
}
interface Enrollment {
id: string;
purchaseType: string;
completed: boolean;
progress: number;
}
interface CourseData {
course: Course;
modules: Module[];
enrollment: Enrollment | null;
}
interface QuizQuestion {
question: string;
options: string[];
correctIndex: number;
}
interface QuizData {
questions: QuizQuestion[];
}
// ── Helpers ──
function parseQuizData(raw: Record<string, unknown> | null): QuizData | null {
if (!raw) return null;
const q = raw as QuizData;
if (!Array.isArray(q.questions) || q.questions.length === 0) return null;
// Validate each question has required fields
const valid = q.questions.every(
(qq) =>
typeof qq.question === "string" &&
Array.isArray(qq.options) &&
qq.options.length >= 2 &&
typeof qq.correctIndex === "number"
);
return valid ? q : null;
}
// ── Component ──
export default function LessonReaderPage() {
const { user, token, loading: authLoading } = useAuth();
const router = useRouter();
const params = useParams();
const slug = params.slug as string;
const moduleId = params.moduleId as string; // This is the module slug
const [data, setData] = useState<CourseData | null>(null);
const [loadingData, setLoadingData] = useState(true);
const [error, setError] = useState<string | null>(null);
// Audio state
const [isPlaying, setIsPlaying] = useState(false);
const [audioLoading, setAudioLoading] = useState(false);
const [audioError, setAudioError] = useState<string | null>(null);
const synthRef = useRef<SpeechSynthesisUtterance | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
// Quiz state
const [quizAnswers, setQuizAnswers] = useState<Record<number, number>>({});
const [quizSubmitted, setQuizSubmitted] = useState(false);
const [quizScore, setQuizScore] = useState<number | null>(null);
// Mark complete state
const [completing, setCompleting] = useState(false);
const [completed, setCompleted] = useState(false);
// Listen query param detection (avoid useSearchParams for simplicity)
const [listenParam, setListenParam] = useState(false);
const [autoStarted, setAutoStarted] = useState(false);
// Current module and navigation
const currentModule = data?.modules.find((m) => m.slug === moduleId) ?? null;
const moduleIndex =
data?.modules.findIndex((m) => m.slug === moduleId) ?? -1;
const prevModule =
moduleIndex > 0 ? data?.modules[moduleIndex - 1] : null;
const nextModule =
moduleIndex < (data?.modules.length ?? 1) - 1
? data?.modules[moduleIndex + 1]
: null;
// Parsed quiz data
const quizData = parseQuizData(currentModule?.quizData ?? null);
// ── Fetch ──
const fetchCourse = useCallback(async () => {
if (!token) return;
setLoadingData(true);
setError(null);
try {
const res = await fetch(`/mobile/api/learn/courses/${slug}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
const d: CourseData = await res.json();
setData(d);
} else {
const d = await res.json().catch(() => ({}));
setError(d.error || "Failed to load course");
}
} catch {
setError("Network error. Please try again.");
} finally {
setLoadingData(false);
}
}, [token, slug]);
// ── Auth guard ──
useEffect(() => {
if (!authLoading && !token) {
router.push("/auth");
}
}, [authLoading, token, router]);
// ── Fetch on mount ──
useEffect(() => {
if (token && slug) {
fetchCourse();
}
}, [token, slug, fetchCourse]);
// ── Detect listen query param ──
useEffect(() => {
if (typeof window !== "undefined") {
const sp = new URLSearchParams(window.location.search);
if (sp.get("listen") === "true") {
setListenParam(true);
}
}
}, []);
// ── Set completed / score from server data ──
useEffect(() => {
if (!currentModule) return;
if (currentModule.progress?.completed) {
setCompleted(true);
}
if (
currentModule.progress?.score !== null &&
currentModule.progress?.score !== undefined
) {
setQuizSubmitted(true);
setQuizScore(currentModule.progress.score);
}
}, [currentModule]);
// ── Auto-start audio when listen=true param AND data loaded ──
useEffect(() => {
if (listenParam && currentModule && !autoStarted && !audioLoading && !isPlaying) {
setAutoStarted(true);
// Small delay to let the render settle
const t = setTimeout(() => handleListen(), 300);
return () => clearTimeout(t);
}
}, [listenParam, currentModule, autoStarted]);
// ── Cleanup on unmount ──
useEffect(() => {
return () => {
window.speechSynthesis?.cancel();
if (audioRef.current) {
audioRef.current.pause();
audioRef.current = null;
}
};
}, []);
// ── Audio handlers ──
const useSpeechSynthesis = useCallback(() => {
if (!currentModule?.content) {
setAudioError("No content to read aloud.");
return;
}
if (typeof window === "undefined" || !window.speechSynthesis) {
setAudioError("Speech synthesis is not supported in this browser.");
return;
}
window.speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(currentModule.content);
utterance.lang = "en-US";
utterance.rate = 0.9;
utterance.pitch = 1;
utterance.onstart = () => setIsPlaying(true);
utterance.onend = () => setIsPlaying(false);
utterance.onerror = (e) => {
console.error("SpeechSynthesis error:", e);
setIsPlaying(false);
setAudioError("Failed to play audio via speech synthesis.");
};
synthRef.current = utterance;
window.speechSynthesis.speak(utterance);
}, [currentModule]);
const handleListen = useCallback(async () => {
if (!currentModule) return;
// Stop any current playback
stopAudio();
setAudioLoading(true);
setAudioError(null);
try {
const res = await fetch(
`/mobile/api/learn/tts?moduleId=${currentModule.id}`,
{
headers: { Authorization: `Bearer ${token}` },
}
);
if (res.ok) {
const ttsData = await res.json();
if (ttsData.audioUrl) {
// Server-provided audio URL
const audio = new Audio(ttsData.audioUrl);
audioRef.current = audio;
audio.play().catch(() => {
// Autoplay blocked — fallback to speech synthesis
useSpeechSynthesis();
});
setIsPlaying(true);
audio.onended = () => setIsPlaying(false);
audio.onerror = () => {
// Fallback on audio error
useSpeechSynthesis();
};
} else {
// No audio URL in response — use speech synthesis
useSpeechSynthesis();
}
} else {
// API error — fallback to speech synthesis
useSpeechSynthesis();
}
} catch {
// Network error — fallback to speech synthesis
useSpeechSynthesis();
} finally {
setAudioLoading(false);
}
}, [currentModule, token, useSpeechSynthesis]);
const stopAudio = useCallback(() => {
if (synthRef.current) {
window.speechSynthesis?.cancel();
synthRef.current = null;
}
if (audioRef.current) {
audioRef.current.pause();
audioRef.current = null;
}
setIsPlaying(false);
}, []);
const togglePlayPause = useCallback(() => {
if (isPlaying) {
stopAudio();
} else {
handleListen();
}
}, [isPlaying, stopAudio, handleListen]);
// ── Quiz handlers ──
const handleQuizAnswer = (questionIndex: number, optionIndex: number) => {
if (quizSubmitted) return;
setQuizAnswers((prev) => ({ ...prev, [questionIndex]: optionIndex }));
};
const handleQuizSubmit = () => {
if (!quizData) return;
let correct = 0;
quizData.questions.forEach((q, i) => {
if (quizAnswers[i] === q.correctIndex) {
correct++;
}
});
const score = Math.round((correct / quizData.questions.length) * 100);
setQuizScore(score);
setQuizSubmitted(true);
};
// ── Mark Complete handler ──
const handleMarkComplete = async () => {
if (!currentModule || completing || completed) return;
setCompleting(true);
try {
const res = await fetch("/mobile/api/learn/progress", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
moduleId: currentModule.id,
completed: true,
}),
});
if (res.ok) {
setCompleted(true);
} else {
const d = await res.json().catch(() => ({}));
setError(d.error || "Failed to update progress");
}
} catch {
setError("Network error. Please try again.");
} finally {
setCompleting(false);
}
};
// ── Loading screen (auth) ──
if (authLoading) {
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;
// ── Loading screen (data) ──
if (loadingData) {
return (
<div className="min-h-dvh bg-[#0a0a0f]">
<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(`/learn/${slug}`)}
className="w-8 h-8 rounded-full bg-gray-800/60 flex items-center justify-center active:bg-gray-700/60 transition"
>
<ChevronLeft size={16} className="text-gray-400" />
</button>
<div className="h-4 bg-gray-800/40 rounded w-40 animate-pulse" />
</div>
</div>
<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 lesson...</p>
</div>
</div>
);
}
// ── Error state ──
if (error) {
return (
<div className="min-h-dvh bg-[#0a0a0f]">
<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(`/learn/${slug}`)}
className="w-8 h-8 rounded-full bg-gray-800/60 flex items-center justify-center active:bg-gray-700/60 transition"
>
<ChevronLeft size={16} className="text-gray-400" />
</button>
</div>
</div>
<ErrorFeedback
error={error}
kind="network"
onRetry={fetchCourse}
context="lesson reader"
/>
</div>
);
}
// ── Module not found ──
if (!currentModule) {
return (
<div className="min-h-dvh bg-[#0a0a0f]">
<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(`/learn/${slug}`)}
className="w-8 h-8 rounded-full bg-gray-800/60 flex items-center justify-center active:bg-gray-700/60 transition"
>
<ChevronLeft size={16} className="text-gray-400" />
</button>
</div>
</div>
<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">Module not found</p>
<button
onClick={() => router.push(`/learn/${slug}`)}
className="mt-3 text-xs text-[#D4AF37] underline"
>
Back to course
</button>
</div>
</div>
);
}
// ── Render ──
return (
<div className="min-h-dvh bg-[#0a0a0f] pb-28">
{/* ── Sticky 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(`/learn/${slug}`)}
className="w-8 h-8 rounded-full bg-gray-800/60 flex items-center justify-center active:bg-gray-700/60 transition"
>
<ChevronLeft size={16} className="text-gray-400" />
</button>
<h1 className="text-sm font-semibold text-white truncate flex-1">
{currentModule.title}
</h1>
{completed && (
<CheckCircle2 size={16} className="text-emerald-400 shrink-0" />
)}
</div>
</div>
{/* ── Content ── */}
<div className="px-4 pt-4 pb-6">
{/* Module header */}
<div className="mb-4">
<span className="text-[10px] text-gray-600 font-medium uppercase tracking-wider">
Module {currentModule.order}
</span>
<h2 className="text-lg font-bold text-white mt-1">
{currentModule.title}
</h2>
{currentModule.keyTakeaway && (
<p className="text-xs text-gray-500 mt-2 leading-relaxed italic border-l-2 border-[#D4AF37]/30 pl-3">
{currentModule.keyTakeaway}
</p>
)}
</div>
{/* ── Listen button ── */}
<div className="flex items-center gap-3 mb-5">
<button
onClick={togglePlayPause}
disabled={audioLoading}
className="flex items-center gap-2 bg-[#D4AF37]/15 border border-[#D4AF37]/30 rounded-xl px-4 py-3 active:bg-[#D4AF37]/25 transition disabled:opacity-60"
>
{audioLoading ? (
<Loader2 size={16} className="text-[#D4AF37] animate-spin" />
) : isPlaying ? (
<Pause size={16} className="text-[#D4AF37]" />
) : (
<Play size={16} className="text-[#D4AF37]" />
)}
<span className="text-sm font-medium text-[#D4AF37]">
{audioLoading
? "Loading..."
: isPlaying
? "Pause"
: "Listen 🎧"}
</span>
</button>
{isPlaying && (
<button
onClick={stopAudio}
className="flex items-center gap-1 bg-gray-800/60 border border-gray-700/40 rounded-xl px-3 py-3 active:bg-gray-700/60 transition"
>
<RotateCcw size={14} className="text-gray-400" />
<span className="text-xs font-medium text-gray-400">Stop</span>
</button>
)}
</div>
{/* Audio error */}
{audioError && (
<div className="mb-4 bg-red-900/10 border border-red-800/30 rounded-xl p-3">
<p className="text-xs text-red-400">{audioError}</p>
</div>
)}
{/* ── Module content ── */}
{currentModule.content ? (
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-5 mb-6">
<div className="text-gray-300 leading-relaxed whitespace-pre-wrap text-sm">
{currentModule.content}
</div>
</div>
) : (
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-6 mb-6 text-center">
<p className="text-sm text-gray-500">
No content available for this module.
</p>
</div>
)}
{/* ── Quiz section ── */}
{quizData && (
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-5 mb-6">
<h3 className="text-sm font-bold text-white mb-4 flex items-center gap-2">
<span className="w-6 h-6 rounded-lg bg-[#D4AF37]/15 flex items-center justify-center">
<span className="text-xs text-[#D4AF37] font-bold">?</span>
</span>
Quiz
</h3>
{/* Score banner */}
{quizSubmitted && quizScore !== null && (
<div
className={`mb-4 rounded-xl p-3 ${
quizScore >= 80
? "bg-emerald-900/10 border border-emerald-800/30"
: quizScore >= 50
? "bg-amber-900/10 border border-amber-800/30"
: "bg-red-900/10 border border-red-800/30"
}`}
>
<p
className={`text-xs font-medium ${
quizScore >= 80
? "text-emerald-300"
: quizScore >= 50
? "text-amber-300"
: "text-red-300"
}`}
>
Score: {quizScore}% (
{Math.round(
(quizScore * quizData.questions.length) / 100
)}
/{quizData.questions.length} correct)
</p>
</div>
)}
{/* Questions */}
<div className="space-y-5">
{quizData.questions.map((q, qIdx) => (
<div key={qIdx}>
<p className="text-sm text-white font-medium mb-2">
{qIdx + 1}. {q.question}
</p>
<div className="space-y-1.5">
{q.options.map((opt, oIdx) => {
const isSelected = quizAnswers[qIdx] === oIdx;
const isCorrect =
quizSubmitted && oIdx === q.correctIndex;
const isWrong =
quizSubmitted && isSelected && oIdx !== q.correctIndex;
let optionStyle =
"bg-gray-800/40 border-gray-700/40 text-gray-400";
if (isSelected && !quizSubmitted) {
optionStyle =
"bg-[#D4AF37]/10 border-[#D4AF37]/40 text-[#D4AF37]";
}
if (quizSubmitted) {
if (isCorrect) {
optionStyle =
"bg-emerald-900/20 border-emerald-500/40 text-emerald-300";
} else if (isWrong) {
optionStyle =
"bg-red-900/20 border-red-500/40 text-red-300";
} else {
optionStyle =
"bg-gray-800/20 border-gray-700/30 text-gray-500";
}
}
return (
<button
key={oIdx}
onClick={() => handleQuizAnswer(qIdx, oIdx)}
disabled={quizSubmitted}
className={`w-full text-left rounded-xl px-3.5 py-2.5 text-xs border transition ${
quizSubmitted
? "cursor-default"
: "active:scale-[0.98]"
} ${optionStyle}`}
>
<span className="inline-flex items-center justify-center w-5 h-5 rounded-full border border-current text-center text-[10px] mr-2 shrink-0">
{String.fromCharCode(65 + oIdx)}
</span>
{opt}
{quizSubmitted && isCorrect && (
<span className="float-right text-emerald-400">
</span>
)}
{quizSubmitted && isWrong && (
<span className="float-right text-red-400">
</span>
)}
</button>
);
})}
</div>
</div>
))}
</div>
{/* Submit / Retry buttons */}
{!quizSubmitted ? (
<button
onClick={handleQuizSubmit}
disabled={
Object.keys(quizAnswers).length < quizData.questions.length
}
className="w-full mt-4 bg-[#D4AF37] text-[#0a0a0f] text-sm font-semibold rounded-xl px-4 py-3 active:bg-[#c5a233] transition disabled:opacity-40"
>
Submit Answers
</button>
) : (
<button
onClick={() => {
setQuizAnswers({});
setQuizSubmitted(false);
setQuizScore(null);
}}
className="w-full mt-3 bg-gray-800/60 border border-gray-700/40 text-gray-300 text-sm font-medium rounded-xl px-4 py-3 active:bg-gray-700/60 transition"
>
Retry Quiz
</button>
)}
</div>
)}
{/* ── Mark Complete button ── */}
<button
onClick={handleMarkComplete}
disabled={completing || completed}
className={`w-full flex items-center justify-center gap-2 rounded-xl px-4 py-3.5 text-sm font-semibold transition ${
completed
? "bg-emerald-500/15 border border-emerald-500/30 text-emerald-400"
: "bg-[#D4AF37] text-[#0a0a0f] active:bg-[#c5a233] disabled:opacity-40"
}`}
>
{completing ? (
<>
<Loader2 size={16} className="animate-spin" />
Saving...
</>
) : completed ? (
<>
<CheckCircle2 size={16} />
Completed
</>
) : (
<>
<CheckCircle2 size={16} />
Mark Complete
</>
)}
</button>
{/* ── Previous / Next navigation ── */}
{(prevModule || nextModule) && (
<div className="flex items-center gap-3 mt-4">
{prevModule ? (
<button
onClick={() =>
router.push(`/learn/${slug}/${prevModule.slug}`)
}
className="flex-1 flex items-center gap-2 bg-gray-800/60 border border-gray-700/40 rounded-xl px-4 py-3 active:bg-gray-700/60 transition min-w-0"
>
<ChevronLeft size={16} className="text-gray-400 shrink-0" />
<div className="text-left flex-1 min-w-0">
<span className="text-[10px] text-gray-600 block">
Previous
</span>
<span className="text-xs text-gray-300 truncate block">
{prevModule.title}
</span>
</div>
</button>
) : (
<div className="flex-1" />
)}
{nextModule ? (
<button
onClick={() =>
router.push(`/learn/${slug}/${nextModule.slug}`)
}
className="flex-1 flex items-center gap-2 bg-gray-800/60 border border-gray-700/40 rounded-xl px-4 py-3 active:bg-gray-700/60 transition min-w-0"
>
<div className="text-right flex-1 min-w-0">
<span className="text-[10px] text-gray-600 block">
Next
</span>
<span className="text-xs text-gray-300 truncate block">
{nextModule.title}
</span>
</div>
<ChevronRight size={16} className="text-gray-400 shrink-0" />
</button>
) : (
<div className="flex-1" />
)}
</div>
)}
</div>
</div>
);
}
+455
View File
@@ -0,0 +1,455 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useAuth } from "@/lib/AuthContext";
import { useRouter, useParams } from "next/navigation";
import {
ChevronLeft,
CheckCircle2,
Circle,
Headphones,
Award,
Lock,
Loader2,
} from "lucide-react";
import ErrorFeedback from "@/components/ErrorFeedback";
// ── Types ──
interface ModuleProgress {
completed: boolean;
score: number | null;
listened: boolean;
}
interface Module {
id: string;
order: number;
title: string;
slug: string;
keyTakeaway: string | null;
duration: number;
content: string | null;
quizData: Record<string, unknown> | null;
progress: ModuleProgress | null;
}
interface Course {
id: string;
slug: string;
title: string;
author: string;
description: string;
imageUrl: string | null;
moduleCount: number;
totalMinutes: number;
difficulty: string;
giteaPath: string;
}
interface Enrollment {
id: string;
purchaseType: string;
completed: boolean;
progress: number;
}
interface CourseData {
course: Course;
modules: Module[];
enrollment: Enrollment | null;
}
interface Certificate {
id: string;
serialNumber: string;
courseId: string;
score: number | null;
issuedAt: string;
pdfPath: string | null;
}
// ── Helpers ──
function getDifficultyColor(difficulty: string): string {
switch (difficulty) {
case "beginner":
return "text-emerald-400 bg-emerald-400/10 border-emerald-400/20";
case "intermediate":
return "text-amber-400 bg-amber-400/10 border-amber-400/20";
case "advanced":
return "text-red-400 bg-red-400/10 border-red-400/20";
default:
return "text-gray-400 bg-gray-400/10 border-gray-400/20";
}
}
function formatDuration(totalMinutes: number): string {
if (totalMinutes < 60) return `${totalMinutes} min`;
const hours = Math.floor(totalMinutes / 60);
const mins = totalMinutes % 60;
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
}
// ── Component ──
export default function LearnCoursePage() {
const { user, token, loading: authLoading } = useAuth();
const router = useRouter();
const params = useParams();
const slug = params.slug as string;
const [data, setData] = useState<CourseData | null>(null);
const [loadingData, setLoadingData] = useState(true);
const [error, setError] = useState<string | null>(null);
const [certificates, setCertificates] = useState<Certificate[]>([]);
// ── Fetch course + certificates ──
const fetchCourse = useCallback(async () => {
if (!token) return;
setLoadingData(true);
setError(null);
try {
const [courseRes, certRes] = await Promise.all([
fetch(`/mobile/api/learn/courses/${slug}`, {
headers: { Authorization: `Bearer ${token}` },
}),
fetch(`/mobile/api/learn/certificates`, {
headers: { Authorization: `Bearer ${token}` },
}),
]);
if (courseRes.ok) {
const d = await courseRes.json();
setData(d);
} else {
const d = await courseRes.json().catch(() => ({}));
setError(d.error || "Failed to load course");
}
if (certRes.ok) {
const d = await certRes.json();
setCertificates(d.certificates || []);
}
} catch {
setError("Network error. Please try again.");
} finally {
setLoadingData(false);
}
}, [token, slug]);
// ── Auth guard ──
useEffect(() => {
if (!authLoading && !token) {
router.push("/auth");
}
}, [authLoading, token, router]);
// ── Fetch on mount ──
useEffect(() => {
if (token && slug) {
fetchCourse();
}
}, [token, slug, fetchCourse]);
// ── Derived state ──
const isEnrolled = !!data?.enrollment;
const progress = data?.enrollment?.progress ?? 0;
const progressPercent = Math.round(progress * 100);
const completedModules =
data?.modules.filter((m) => m.progress?.completed).length ?? 0;
const certificate = certificates.find(
(c) => c.courseId === data?.course?.id
);
const canViewCertificate =
data?.enrollment?.completed && !!certificate?.serialNumber;
// ── Loading state ──
if (authLoading) {
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">
{/* ── Sticky 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("/learn")}
className="w-8 h-8 rounded-full bg-gray-800/60 flex items-center justify-center active:bg-gray-700/60 transition"
>
<ChevronLeft size={16} className="text-gray-400" />
</button>
<h1 className="text-sm font-semibold text-white truncate">
{data ? data.course.title : "Course"}
</h1>
</div>
</div>
{/* ── Loading / Error / Empty ── */}
{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 course...</p>
</div>
) : error ? (
<ErrorFeedback
error={error}
kind="network"
onRetry={fetchCourse}
context="learn course detail"
/>
) : !data ? (
<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">Course not found</p>
<button
onClick={() => router.push("/learn")}
className="mt-3 text-xs text-[#D4AF37] underline"
>
Back to courses
</button>
</div>
) : (
<>
{/* ── Course header card ── */}
<div className="px-4 pt-4 pb-2">
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-5">
{/* Title + author */}
<h2 className="text-lg font-bold text-white">
{data.course.title}
</h2>
<p className="text-xs text-gray-400 mt-1">
by{" "}
<span className="text-[#D4AF37]">{data.course.author}</span>
</p>
{/* Tags row */}
<div className="flex flex-wrap items-center gap-2 mt-3">
<span
className={`text-[10px] font-medium px-2 py-0.5 rounded-full border ${getDifficultyColor(
data.course.difficulty
)}`}
>
{data.course.difficulty}
</span>
<span className="text-[10px] text-gray-500 bg-gray-800/60 px-2 py-0.5 rounded-full border border-gray-700/40">
{data.course.moduleCount} module
{data.course.moduleCount !== 1 ? "s" : ""}
</span>
<span className="text-[10px] text-gray-500 bg-gray-800/60 px-2 py-0.5 rounded-full border border-gray-700/40">
{formatDuration(data.course.totalMinutes)}
</span>
</div>
{/* Description */}
{data.course.description && (
<p className="text-xs text-gray-500 mt-3 leading-relaxed">
{data.course.description}
</p>
)}
{/* ── Progress bar (only if enrolled) ── */}
{isEnrolled && (
<div className="mt-4">
<div className="flex items-center justify-between mb-1.5">
<span className="text-[11px] text-gray-500">
Progress
</span>
<span className="text-[11px] font-medium text-gray-400">
{completedModules}/{data.modules.length} modules
</span>
</div>
<div className="w-full h-2 bg-gray-800/80 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-[#D4AF37] to-amber-400 rounded-full transition-all duration-500"
style={{ width: `${progressPercent}%` }}
/>
</div>
<span className="text-[10px] text-gray-600 mt-1 block">
{progressPercent}% complete
</span>
</div>
)}
</div>
</div>
{/* ── Purchase / Certificate call-to-action ── */}
<div className="px-4 mb-3">
{!isEnrolled ? (
<button
onClick={() =>
window.open(
`https://istore.falahos.my/courses/${slug}`,
"_blank"
)
}
className="w-full flex items-center justify-center gap-2 bg-amber-500/15 border border-amber-500/30 rounded-xl px-4 py-3.5 active:bg-amber-500/25 transition"
>
<Lock size={16} className="text-amber-400" />
<span className="text-sm font-medium text-amber-400">
Purchase on iStore
</span>
</button>
) : canViewCertificate ? (
<a
href={`/mobile/api/learn/certificates/${certificate!.serialNumber}`}
target="_blank"
rel="noopener noreferrer"
className="w-full flex items-center justify-center gap-2 bg-emerald-500/15 border border-emerald-500/30 rounded-xl px-4 py-3.5 active:bg-emerald-500/25 transition"
>
<Award size={16} className="text-emerald-400" />
<span className="text-sm font-medium text-emerald-400">
🎓 View Certificate
</span>
</a>
) : null}
</div>
{/* ── Module list ── */}
<div className="px-4">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3 px-1">
Course Modules
</h3>
<div className="relative space-y-0">
{/* Vertical timeline line */}
<div className="absolute left-[17px] top-2 bottom-2 w-px bg-gray-800/60" />
{data.modules.map((mod) => {
const isCompleted = mod.progress?.completed ?? false;
const hasQuiz = !!mod.quizData;
const quizScore = mod.progress?.score ?? null;
const hasListened = mod.progress?.listened ?? false;
return (
<div key={mod.id} className="relative flex gap-3 pb-4">
{/* Timeline dot */}
<div className="relative z-10 flex-shrink-0 mt-1">
{isCompleted ? (
<CheckCircle2
size={20}
className="text-emerald-400"
/>
) : (
<Circle
size={20}
className="text-gray-600"
/>
)}
</div>
{/* Module card */}
<div className="flex-1 min-w-0">
<button
onClick={() => {
router.push(`/learn/${slug}/${mod.slug}`);
}}
className="w-full text-left bg-[#111118] border border-gray-800/60 rounded-xl p-3.5 active:bg-[#1a1a24] transition"
>
{/* Header row */}
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<span className="text-[10px] text-gray-600 font-medium">
Module {mod.order}
</span>
<h4 className="text-sm font-semibold text-white mt-0.5 leading-snug">
{mod.title}
</h4>
</div>
{/* Duration badge */}
<span className="shrink-0 text-[10px] text-gray-500 bg-gray-800/60 px-2 py-0.5 rounded-full border border-gray-700/40 whitespace-nowrap">
{mod.duration} min
</span>
</div>
{/* Key takeaway */}
{mod.keyTakeaway && (
<p className="text-xs text-gray-500 mt-2 leading-relaxed border-t border-gray-800/60 pt-2">
{mod.keyTakeaway}
</p>
)}
{/* Action buttons row */}
<div className="flex items-center gap-2 mt-3">
{/* Listen button */}
<button
onClick={(e) => {
e.stopPropagation();
router.push(
`/learn/${slug}/${mod.slug}?listen=true`
);
}}
className={`flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-[10px] font-medium transition ${
hasListened
? "bg-emerald-500/10 border border-emerald-500/20 text-emerald-400"
: "bg-gray-800/60 border border-gray-700/40 text-gray-400 hover:border-gray-600/60"
}`}
>
<Headphones size={12} />
Listen
</button>
{/* Quiz status */}
{hasQuiz && (
<span
className={`flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-[10px] font-medium border ${
quizScore !== null
? "bg-emerald-500/10 border-emerald-500/20 text-emerald-400"
: "bg-gray-800/60 border-gray-700/40 text-gray-500"
}`}
>
{quizScore !== null
? `Quiz: ${Math.round(quizScore)}%`
: "Quiz"}
</span>
)}
</div>
</button>
</div>
</div>
);
})}
</div>
</div>
{/* ── Course footer stats ── */}
<div className="px-4 mt-4">
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl px-4 py-3">
<div className="flex items-center justify-between text-xs text-gray-500">
<span>Total duration</span>
<span className="text-gray-400 font-medium">
{formatDuration(data.course.totalMinutes)}
</span>
</div>
{isEnrolled && (
<div className="flex items-center justify-between text-xs text-gray-500 mt-2 pt-2 border-t border-gray-800/40">
<span>Enrolled</span>
<span className="text-emerald-400 font-medium">
{data.enrollment!.purchaseType === "free"
? "Free"
: data.enrollment!.purchaseType === "subscription"
? "Subscription"
: "Purchased"}
</span>
</div>
)}
</div>
</div>
</>
)}
</div>
);
}
+456
View File
@@ -0,0 +1,456 @@
"use client";
import { useState, useEffect } from "react";
import { useAuth } from "@/lib/AuthContext";
import { useRouter } from "next/navigation";
import {
Lock,
Book,
Headphones,
Award,
Sparkles,
} from "lucide-react";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface CourseEnrollment {
progress: number;
completed: boolean;
}
interface LearnCourse {
id: string;
slug: string;
title: string;
author: string;
description: string;
imageUrl: string | null;
moduleCount: number;
totalMinutes: number;
difficulty: string;
priceFlh: number | null;
priceUsd: number | null;
subscriptionOnly: boolean;
enrolled: boolean | null;
enrollment?: CourseEnrollment;
}
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
const DIFFICULTY_STYLES: Record<string, { label: string; classes: string }> = {
beginner: {
label: "Beginner",
classes: "bg-green-900/30 text-[#00C48C] border border-green-800/30",
},
intermediate: {
label: "Intermediate",
classes: "bg-amber-900/30 text-amber-400 border border-amber-800/30",
},
advanced: {
label: "Advanced",
classes: "bg-red-900/30 text-red-400 border border-red-800/30",
},
};
function difficultyLabel(diff: string): { label: string; classes: string } {
return DIFFICULTY_STYLES[diff] ?? {
label: diff,
classes: "bg-gray-800/40 text-gray-400 border border-gray-700/30",
};
}
function formatPrice(course: LearnCourse): string {
if (course.subscriptionOnly) return "Learn Pass";
if (course.priceFlh === null && course.priceUsd === null) return "FREE";
if (course.priceUsd !== null) return `$${course.priceUsd.toFixed(2)}`;
if (course.priceFlh !== null) return `FLH ${course.priceFlh.toLocaleString()}`;
return "FREE";
}
function isFree(course: LearnCourse): boolean {
if (course.subscriptionOnly) return false;
return course.priceFlh === null && course.priceUsd === null;
}
/* ------------------------------------------------------------------ */
/* Placeholder Thumbnail */
/* ------------------------------------------------------------------ */
const COURSE_VISUALS: Record<string, { icon: string; bg: string }> = {
// A few known slugs get themed visuals
default: { icon: "📖", bg: "from-[#C9A84C]/20 to-[#C9A84C]/5" },
};
function courseVisual(slug: string): { icon: string; bg: string } {
return COURSE_VISUALS[slug] ?? COURSE_VISUALS.default;
}
/* ------------------------------------------------------------------ */
/* Page Component */
/* ------------------------------------------------------------------ */
export default function LearnCatalogPage() {
const { user, token, loading } = useAuth();
const router = useRouter();
const [courses, setCourses] = useState<LearnCourse[]>([]);
const [fetching, setFetching] = useState(true);
const [error, setError] = useState<string | null>(null);
// Redirect to auth if not logged in
useEffect(() => {
if (!loading && !token) router.push("/auth");
}, [loading, token, router]);
// Fetch courses
useEffect(() => {
if (!token) return;
setFetching(true);
setError(null);
fetch("/mobile/api/learn/courses", {
headers: { Authorization: `Bearer ${token}` },
})
.then((r) => {
if (!r.ok) throw new Error("Failed to load courses");
return r.json();
})
.then((data: { courses: LearnCourse[] }) =>
setCourses(data.courses || [])
)
.catch((err: Error) => setError(err.message))
.finally(() => setFetching(false));
}, [token]);
// ── Loading state ──
if (loading) {
return (
<div className="min-h-dvh bg-[#07090C] flex items-center justify-center">
<div className="w-8 h-8 rounded-full border-2 border-[#C9A84C]/30 border-t-[#C9A84C] animate-spin" />
</div>
);
}
if (!user) return null;
// ── Guard: token present but no user yet ──
if (!token) return null;
// ── Enrolled / locked separation ──
const enrolled = courses.filter((c) => c.enrolled === true);
const locked = courses.filter((c) => c.enrolled !== true);
return (
<div className="min-h-dvh bg-[#07090C] pb-24">
{/* ── Header ── */}
<div className="px-0 pt-6 pb-2">
<div className="flex items-center justify-between mb-1">
<div>
<h1 className="text-xl font-bold text-white">Falah Learn</h1>
<p className="text-xs text-gray-500 mt-0.5">
Micro learning courses. 5 minutes at a time.
</p>
</div>
<div className="w-10 h-10 rounded-xl bg-[#C9A84C]/15 flex items-center justify-center">
<Book size={20} className="text-[#C9A84C]" />
</div>
</div>
</div>
{/* ── Error state ── */}
{error && !fetching && (
<div className="mx-0 mt-4 bg-[#111118] border border-red-900/40 rounded-2xl p-5 text-center">
<p className="text-sm text-red-400">{error}</p>
<button
onClick={() => {
setFetching(true);
setError(null);
fetch("/mobile/api/learn/courses", {
headers: { Authorization: `Bearer ${token}` },
})
.then((r) => {
if (!r.ok) throw new Error("Failed to load courses");
return r.json();
})
.then((data: { courses: LearnCourse[] }) =>
setCourses(data.courses || [])
)
.catch((err: Error) => setError(err.message))
.finally(() => setFetching(false));
}}
className="mt-3 text-xs text-[#C9A84C] underline underline-offset-2"
>
Try again
</button>
</div>
)}
{/* ── Loading skeleton ── */}
{fetching && (
<div className="mt-6 space-y-4">
{[1, 2, 3].map((i) => (
<div
key={i}
className="bg-[#0C1017] border border-gray-800/40 rounded-2xl overflow-hidden animate-pulse"
>
<div className="h-32 bg-gray-800/30" />
<div className="p-4 space-y-3">
<div className="h-4 bg-gray-800/40 rounded w-3/4" />
<div className="h-3 bg-gray-800/30 rounded w-1/2" />
<div className="flex gap-2">
<div className="h-5 bg-gray-800/30 rounded-full w-16" />
<div className="h-5 bg-gray-800/30 rounded-full w-12" />
</div>
</div>
</div>
))}
</div>
)}
{/* ── Course grid ── */}
{!fetching && !error && courses.length === 0 && (
<div className="mx-0 mt-8 bg-[#111118] border border-gray-800/60 rounded-2xl p-8 text-center">
<Book size={32} className="mx-auto text-gray-700 mb-3" />
<p className="text-sm text-gray-500">No courses available yet</p>
<p className="text-xs text-gray-700 mt-1">
Check back soon for new micro learning content
</p>
</div>
)}
{!fetching && !error && courses.length > 0 && (
<>
{/* ── Enrolled courses section ── */}
{enrolled.length > 0 && (
<div className="mt-6">
<h2 className="text-sm font-semibold text-white flex items-center gap-2 mb-3">
<Book size={14} className="text-[#00C48C]" />
My Courses
</h2>
<div className="grid grid-cols-1 gap-4">
{enrolled.map((course) => (
<CourseCard
key={course.id}
course={course}
enrolled
router={router}
/>
))}
</div>
</div>
)}
{/* ── All / locked courses section ── */}
<div className="mt-6">
<h2 className="text-sm font-semibold text-white flex items-center gap-2 mb-3">
<Award size={14} className="text-[#C9A84C]" />
{enrolled.length > 0 ? "More Courses" : "All Courses"}
</h2>
<div className="grid grid-cols-1 gap-4">
{locked.map((course) => (
<CourseCard
key={course.id}
course={course}
enrolled={false}
router={router}
/>
))}
</div>
</div>
</>
)}
{/* ── Learn Pass subscription card ── */}
<div className="mt-8 mx-0">
<div className="bg-gradient-to-br from-[#C9A84C]/10 to-[#C9A84C]/5 border border-[#C9A84C]/20 rounded-2xl p-5 relative overflow-hidden">
{/* Decorative glow */}
<div className="absolute -top-10 -right-10 w-32 h-32 bg-[#C9A84C]/10 rounded-full blur-3xl pointer-events-none" />
<div className="absolute -bottom-8 -left-8 w-28 h-28 bg-[#00C48C]/5 rounded-full blur-3xl pointer-events-none" />
<div className="relative z-10">
<div className="flex items-center gap-2 mb-2">
<Sparkles size={18} className="text-[#C9A84C]" />
<h3 className="text-base font-bold text-white">Learn Pass</h3>
</div>
<p className="text-xs text-gray-400 mb-4 leading-relaxed">
Get unlimited access to all courses, audio lessons, and
certificates with a Learn Pass subscription.
</p>
<div className="flex items-center gap-4 mb-4">
<div className="flex items-center gap-1.5 text-[11px] text-gray-500">
<Headphones size={13} className="text-[#C9A84C]/70" />
Audio lessons
</div>
<div className="flex items-center gap-1.5 text-[11px] text-gray-500">
<Award size={13} className="text-[#C9A84C]/70" />
Certificates
</div>
<div className="flex items-center gap-1.5 text-[11px] text-gray-500">
<Book size={13} className="text-[#C9A84C]/70" />
All courses
</div>
</div>
<a
href="https://istore.falahos.my"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 bg-[#C9A84C] text-[#07090C] text-sm font-semibold px-5 py-2.5 rounded-xl hover:bg-[#C9A84C]/90 active:scale-[0.97] transition min-h-[44px]"
>
<Sparkles size={16} />
Get Learn Pass
</a>
</div>
</div>
</div>
<div className="h-6" />
</div>
);
}
/* ------------------------------------------------------------------ */
/* Course Card */
/* ------------------------------------------------------------------ */
function CourseCard({
course,
enrolled,
router,
}: {
course: LearnCourse;
enrolled: boolean;
router: ReturnType<typeof useRouter>;
}) {
const vis = courseVisual(course.slug);
const diff = difficultyLabel(course.difficulty);
const handleClick = () => {
if (enrolled) {
router.push(`/learn/${course.slug}`);
}
};
return (
<div
className={`bg-[#0C1017] border rounded-2xl overflow-hidden transition ${
enrolled
? "border-gray-800/60 hover:border-gray-700/60 active:scale-[0.98] cursor-pointer"
: "border-gray-800/40"
}`}
onClick={handleClick}
role={enrolled ? "button" : "article"}
tabIndex={enrolled ? 0 : undefined}
onKeyDown={
enrolled
? (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
router.push(`/learn/${course.slug}`);
}
}
: undefined
}
>
{/* Thumbnail placeholder */}
<div
className={`h-32 bg-gradient-to-br ${vis.bg} flex items-center justify-center relative`}
>
<span className="text-4xl">{vis.icon}</span>
{/* Lock overlay for locked courses */}
{!enrolled && (
<div className="absolute inset-0 bg-[#07090C]/40 flex items-center justify-center backdrop-blur-[1px]">
<div className="w-9 h-9 rounded-full bg-[#07090C]/70 flex items-center justify-center">
<Lock size={16} className="text-gray-400" />
</div>
</div>
)}
{/* Progress bar for enrolled courses */}
{enrolled && course.enrollment && (
<div className="absolute bottom-0 left-0 right-0 h-1 bg-gray-800/60">
<div
className="h-full bg-[#00C48C] transition-all duration-500"
style={{
width: `${Math.min(100, Math.round((course.enrollment.progress ?? 0) * 100))}%`,
}}
/>
</div>
)}
</div>
{/* Content */}
<div className="p-4 space-y-2">
{/* Title */}
<h3 className="text-sm font-semibold text-white leading-tight line-clamp-2 min-h-[2.5rem]">
{course.title}
</h3>
{/* Author */}
<p className="text-[11px] text-gray-500 truncate">{course.author}</p>
{/* Meta row: difficulty badge + modules */}
<div className="flex items-center gap-2 flex-wrap">
<span
className={`text-[10px] font-medium px-2 py-0.5 rounded-full ${diff.classes}`}
>
{diff.label}
</span>
<span className="text-[10px] text-gray-500">
{course.moduleCount} module{course.moduleCount !== 1 ? "s" : ""}
</span>
{course.totalMinutes > 0 && (
<span className="text-[10px] text-gray-500">
~{course.totalMinutes} min
</span>
)}
</div>
{/* Price / CTA row */}
<div className="flex items-center justify-between pt-1">
<span
className={`text-sm font-bold ${
isFree(course) ? "text-[#00C48C]" : "text-[#C9A84C]"
}`}
>
{formatPrice(course)}
</span>
{!enrolled && (
<a
href="https://istore.falahos.my"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-[11px] font-medium text-[#C9A84C] bg-[#C9A84C]/10 border border-[#C9A84C]/20 rounded-lg px-3 py-1.5 hover:bg-[#C9A84C]/20 active:scale-95 transition min-h-[36px]"
onClick={(e) => e.stopPropagation()}
>
Get on iStore
</a>
)}
{enrolled && (
<span className="flex items-center gap-1 text-[11px] text-[#00C48C]">
{course.enrollment?.completed ? (
<>
<Award size={12} /> Completed
</>
) : course.enrollment && (course.enrollment.progress ?? 0) > 0 ? (
<>
<Book size={12} />
{Math.round((course.enrollment.progress ?? 0) * 100)}%
</>
) : (
<>
<Headphones size={12} /> Start
</>
)}
</span>
)}
</div>
</div>
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
"use client";
export default function OfflinePage() {
return (
<div className="flex min-h-dvh flex-col items-center justify-center p-6 text-center">
<div className="mb-4 text-6xl">📡</div>
<h1 className="mb-2 text-xl font-semibold text-emerald-400">You&apos;re Offline</h1>
<p className="mb-6 max-w-xs text-sm text-gray-400">
Falah needs an internet connection for most features.
Check your connection and try again.
</p>
<button
onClick={() => window.location.reload()}
className="rounded-xl bg-emerald-500 px-6 py-3 text-sm font-medium text-white"
>
Try Again
</button>
</div>
);
}
+14 -4
View File
@@ -384,15 +384,25 @@ export default function HomePage() {
);
}
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">
function QuickAction({ href, icon: Icon, label, color, external }: { href: string; icon: any; label: string; color: string; external?: boolean }) {
const content = (
<div 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-xs text-gray-500 font-medium">{label}</span>
</Link>
</div>
);
if (external) {
return (
<a href={href} target="_blank" rel="noopener noreferrer" className="active:scale-95 transition">
{content}
</a>
);
}
return <Link href={href} className="active:scale-95 transition">{content}</Link>;
}
function StatCard({ label, value, icon: Icon, color }: { label: string; value: string; icon: any; color: string }) {
+719
View File
@@ -0,0 +1,719 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useAuth } from "@/lib/AuthContext";
import { useRouter, useParams } from "next/navigation";
import {
ArrowLeft,
Star,
Loader2,
Check,
Clock,
RefreshCw,
ShoppingCart,
User,
MessageCircle,
ChevronRight,
} from "lucide-react";
import ErrorFeedback from "@/components/ErrorFeedback";
// ── Types ─────────────────────────────────────────────────────────────────────
interface Package {
name: string;
description: string;
price: number;
deliveryDays: number;
revisions: number;
}
interface Seller {
id: string;
name: string;
email: string;
avatar: string | null;
}
interface Reviewer {
id: string;
name: string;
avatar: string | null;
}
interface Review {
id: string;
rating: number;
title: string | null;
text: string;
createdAt: string;
reviewer: Reviewer;
}
interface ListingDetail {
id: string;
title: string;
description: string;
category: string;
subcategory: string | null;
priceFlh: number;
packages: Package[] | null;
tags: string[] | null;
images: string[] | null;
deliveryDays: number | null;
rating: number;
reviewCount: number;
salesCount: number;
seller: Seller;
reviews: Review[];
_count: { purchases: number };
status: string;
createdAt: string;
}
// ── Helpers ────────────────────────────────────────────────────────────────────
const CATEGORY_EMOJI: Record<string, string> = {
"Web Development": "🌐",
"Mobile Development": "📱",
"Content Writing": "✍️",
"Brand Identity": "🎨",
"SEO/Marketing": "📈",
"Video Production": "🎬",
default: "📦",
};
const CATEGORY_COLORS: Record<string, string> = {
"Web Development": "from-blue-600/40 to-blue-900/30",
"Mobile Development": "from-purple-600/40 to-purple-900/30",
"Content Writing": "from-emerald-600/40 to-emerald-900/30",
"Brand Identity": "from-amber-600/40 to-amber-900/30",
"SEO/Marketing": "from-red-600/40 to-red-900/30",
"Video Production": "from-pink-600/40 to-pink-900/30",
default: "from-gray-600/40 to-gray-900/30",
};
function getInitials(name: string): string {
return name
.split(" ")
.map((n) => n.charAt(0).toUpperCase())
.slice(0, 2)
.join("");
}
function formatFlh(amount: number): string {
return amount.toLocaleString() + " FLH";
}
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();
}
// ── Component ─────────────────────────────────────────────────────────────────
export default function ListingDetailPage() {
const { user, token, loading } = useAuth();
const router = useRouter();
const params = useParams();
const id = params.id as string;
const [listing, setListing] = useState<ListingDetail | null>(null);
const [loadingData, setLoadingData] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedPackage, setSelectedPackage] = useState<Package | null>(null);
const [ordering, setOrdering] = useState(false);
const [orderSuccess, setOrderSuccess] = useState(false);
const [orderError, setOrderError] = useState<string | null>(null);
// ── Auth redirect ───────────────────────────────────────────────────────
useEffect(() => {
if (!loading && !token) {
router.push("/auth");
}
}, [loading, token, router]);
// ── Fetch listing ────────────────────────────────────────────────────────
const fetchListing = useCallback(async () => {
if (!id) return;
setLoadingData(true);
setError(null);
try {
const res = await fetch(`/mobile/api/souq/listings/${id}`);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `Failed to load listing (${res.status})`);
}
const data = await res.json();
setListing(data.listing);
} catch (err: unknown) {
const message =
err instanceof Error ? err.message : "Failed to load listing";
setError(message);
} finally {
setLoadingData(false);
}
}, [id]);
useEffect(() => {
if (id) {
fetchListing();
}
}, [id, fetchListing]);
// ── Auto-select first package ───────────────────────────────────────────
useEffect(() => {
if (listing?.packages && listing.packages.length > 0 && !selectedPackage) {
setSelectedPackage(listing.packages[0]);
}
}, [listing, selectedPackage]);
// ── Place order ──────────────────────────────────────────────────────────
const handleContinue = async () => {
if (!token) {
router.push("/auth");
return;
}
if (!selectedPackage || !listing) return;
setOrdering(true);
setOrderError(null);
try {
const res = await fetch("/mobile/api/souq/orders", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
listingId: listing.id,
packageName: selectedPackage.name,
}),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || "Failed to place order");
}
setOrderSuccess(true);
setTimeout(() => {
router.push("/souq/orders");
}, 2000);
} catch (err: unknown) {
const message =
err instanceof Error ? err.message : "Failed to place order";
setOrderError(message);
setTimeout(() => setOrderError(null), 5000);
} finally {
setOrdering(false);
}
};
// ── Loading (auth check) ────────────────────────────────────────────────
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;
// ── Data loading state ──────────────────────────────────────────────────
if (loadingData) {
return (
<div className="min-h-dvh bg-[#0a0a0f]">
{/* Header */}
<div className="flex items-center gap-3 px-4 h-12 border-b border-gray-800/40">
<button
onClick={() => router.back()}
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>
</div>
<div className="flex flex-col items-center justify-center py-32">
<Loader2 size={32} className="text-[#D4AF37] animate-spin mb-3" />
<p className="text-sm text-gray-500">Loading listing...</p>
</div>
</div>
);
}
// ── Error state ─────────────────────────────────────────────────────────
if (error) {
return (
<div className="min-h-dvh bg-[#0a0a0f]">
<div className="flex items-center gap-3 px-4 h-12 border-b border-gray-800/40">
<button
onClick={() => router.back()}
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">Listing</h1>
</div>
<div className="pt-4">
<ErrorFeedback
error={error}
kind="network"
onRetry={fetchListing}
context="listing detail"
/>
</div>
</div>
);
}
// ── Not found state ──────────────────────────────────────────────────────
if (!listing) {
return (
<div className="min-h-dvh bg-[#0a0a0f]">
<div className="flex items-center gap-3 px-4 h-12 border-b border-gray-800/40">
<button
onClick={() => router.back()}
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">Listing</h1>
</div>
<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">Listing not found</p>
<button
onClick={() => router.push("/souq")}
className="mt-3 text-xs text-[#D4AF37] underline"
>
Back to Souq
</button>
</div>
</div>
);
}
// ── Derived data ────────────────────────────────────────────────────────
const packages = listing.packages || [];
const firstImage = listing.images?.[0] || null;
const categoryEmoji = CATEGORY_EMOJI[listing.category] || CATEGORY_EMOJI.default;
const categoryColor = CATEGORY_COLORS[listing.category] || CATEGORY_COLORS.default;
const totalPrice = selectedPackage
? Math.round(selectedPackage.price * 1.015)
: 0;
const sellerInitials = getInitials(listing.seller.name);
return (
<div className="min-h-dvh bg-[#0a0a0f] pb-36">
{/* ── 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.back()}
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">
{listing.title}
</h1>
</div>
</div>
{/* ── Content ─────────────────────────────────────────────────────── */}
<div className="space-y-5 animate-fade-in px-4 pt-4">
{/* ── Image Area ────────────────────────────────────────────────── */}
{firstImage ? (
<div className="rounded-2xl overflow-hidden bg-[#111118] border border-gray-800/60">
<img
src={firstImage}
alt={listing.title}
className="w-full aspect-[3/2] object-cover"
onError={(e) => {
// Hide broken image, show fallback
(e.target as HTMLElement).style.display = "none";
const fallback = (e.target as HTMLElement)
.nextElementSibling as HTMLElement | null;
if (fallback) fallback.style.display = "flex";
}}
/>
<div
className={`hidden aspect-[3/2] bg-gradient-to-br ${categoryColor} flex items-center justify-center`}
>
<span className="text-6xl">{categoryEmoji}</span>
</div>
</div>
) : (
<div
className={`rounded-2xl bg-gradient-to-br ${categoryColor} border border-gray-800/60 aspect-[3/2] flex items-center justify-center`}
>
<span className="text-6xl">{categoryEmoji}</span>
</div>
)}
{/* ── Title & Meta ───────────────────────────────────────────────── */}
<div>
<h2 className="text-xl font-bold text-white leading-tight mb-2">
{listing.title}
</h2>
{/* Category badge */}
<div className="flex items-center gap-2 mb-3">
<span className="text-xs bg-[#D4AF37]/10 border border-[#D4AF37]/20 text-[#D4AF37] rounded-full px-3 py-1">
{listing.category}
</span>
{listing.subcategory && (
<span className="text-xs text-gray-500">{listing.subcategory}</span>
)}
</div>
{/* Rating row */}
<div className="flex items-center gap-1.5 mb-3">
<div className="flex items-center gap-0.5">
<Star size={14} className="text-[#D4AF37] fill-[#D4AF37]" />
<span className="text-sm font-semibold text-white">
{listing.rating.toFixed(1)}
</span>
</div>
<span className="text-xs text-gray-500">
({listing.reviewCount} {listing.reviewCount === 1 ? "review" : "reviews"})
</span>
<span className="text-gray-700">·</span>
<span className="text-xs text-gray-500">
{listing.salesCount} {listing.salesCount === 1 ? "sale" : "sales"}
</span>
</div>
{/* Seller row */}
<button
onClick={() => router.push(`/souq/seller/${listing.seller.id}`)}
className="w-full flex items-center gap-3 py-3 px-4 bg-[#111118] border border-gray-800/60 rounded-xl active:scale-[0.99] transition"
>
{listing.seller.avatar ? (
<img
src={listing.seller.avatar}
alt={listing.seller.name}
className="w-10 h-10 rounded-full object-cover"
/>
) : (
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-[#D4AF37]/20 to-gray-800/60 flex items-center justify-center text-sm font-bold text-[#D4AF37]">
{sellerInitials}
</div>
)}
<div className="flex-1 min-w-0 text-left">
<p className="text-sm font-semibold text-white truncate">
{listing.seller.name}
</p>
<p className="text-xs text-gray-500">Seller</p>
</div>
<ChevronRight size={16} className="text-gray-600 shrink-0" />
</button>
</div>
{/* ── Description ────────────────────────────────────────────────── */}
<div>
<h3 className="text-sm font-semibold text-white mb-2">About This Listing</h3>
<p className="text-sm text-gray-300 leading-relaxed whitespace-pre-wrap">
{listing.description}
</p>
</div>
{/* ── Packages ────────────────────────────────────────────────────── */}
{packages.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-white mb-3">
Select a Package
</h3>
<div className="space-y-3">
{packages.map((pkg) => {
const isSelected = selectedPackage?.name === pkg.name;
const feeAmount = Math.round(pkg.price * 0.015);
return (
<button
key={pkg.name}
onClick={() => setSelectedPackage(pkg)}
className={`w-full text-left bg-[#111118] border rounded-2xl p-4 transition-all active:scale-[0.98] ${
isSelected
? "border-[#D4AF37] ring-1 ring-[#D4AF37]/30"
: "border-gray-800/60 hover:border-gray-700/60"
}`}
>
{/* Package header */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<div
className={`w-2 h-2 rounded-full ${
isSelected ? "bg-[#D4AF37]" : "bg-gray-600"
}`}
/>
<span className="text-sm font-bold text-white">
{pkg.name}
</span>
</div>
<span className="text-base font-bold text-[#D4AF37]">
{formatFlh(pkg.price)}
</span>
</div>
{/* Description */}
<p className="text-xs text-gray-400 mb-3 leading-relaxed">
{pkg.description}
</p>
{/* Details row */}
<div className="flex items-center gap-4 text-xs text-gray-500">
<div className="flex items-center gap-1">
<Clock size={12} />
<span>{pkg.deliveryDays} {pkg.deliveryDays === 1 ? "day" : "days"}</span>
</div>
<div className="flex items-center gap-1">
<RefreshCw size={12} />
<span>{pkg.revisions} {pkg.revisions === 1 ? "revision" : "revisions"}</span>
</div>
{isSelected && (
<span className="text-[10px] text-[#D4AF37] ml-auto">
Selected
</span>
)}
</div>
{/* Fee breakdown (only when selected) */}
{isSelected && (
<div className="mt-3 pt-3 border-t border-gray-800/40 text-xs text-gray-500 space-y-1">
<div className="flex justify-between">
<span>Package price</span>
<span>{formatFlh(pkg.price)}</span>
</div>
<div className="flex justify-between">
<span>Service fee (1.5%)</span>
<span>{formatFlh(feeAmount)}</span>
</div>
</div>
)}
</button>
);
})}
</div>
</div>
)}
{/* ── Seller Info Section ─────────────────────────────────────────── */}
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-4">
<h3 className="text-sm font-semibold text-white mb-3 flex items-center gap-2">
<User size={14} className="text-[#D4AF37]" />
About the Seller
</h3>
<div className="flex items-center gap-3 mb-4">
{listing.seller.avatar ? (
<img
src={listing.seller.avatar}
alt={listing.seller.name}
className="w-12 h-12 rounded-full object-cover"
/>
) : (
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-[#D4AF37]/20 to-gray-800/60 flex items-center justify-center text-base font-bold text-[#D4AF37]">
{sellerInitials}
</div>
)}
<div>
<p className="text-sm font-bold text-white">{listing.seller.name}</p>
<p className="text-xs text-gray-500">Member since{" "}
{new Date(listing.createdAt).toLocaleDateString("en-US", {
month: "long",
year: "numeric",
})}
</p>
</div>
</div>
{/* Seller stats */}
<div className="grid grid-cols-3 gap-3">
<div className="bg-gray-800/30 rounded-xl px-3 py-2.5 text-center">
<div className="flex items-center justify-center gap-1 text-[#D4AF37] mb-0.5">
<Star size={12} className="fill-[#D4AF37]" />
<span className="text-sm font-bold text-white">{listing.rating.toFixed(1)}</span>
</div>
<p className="text-[10px] text-gray-500">Rating</p>
</div>
<div className="bg-gray-800/30 rounded-xl px-3 py-2.5 text-center">
<p className="text-sm font-bold text-white">{listing.reviewCount}</p>
<p className="text-[10px] text-gray-500">
{listing.reviewCount === 1 ? "Review" : "Reviews"}
</p>
</div>
<div className="bg-gray-800/30 rounded-xl px-3 py-2.5 text-center">
<p className="text-sm font-bold text-white">{listing.salesCount}</p>
<p className="text-[10px] text-gray-500">
{listing.salesCount === 1 ? "Sale" : "Sales"}
</p>
</div>
</div>
<button
onClick={() => router.push(`/souq/seller/${listing.seller.id}`)}
className="w-full mt-3 py-2.5 rounded-xl bg-[#D4AF37]/10 border border-[#D4AF37]/20 text-xs font-semibold text-[#D4AF37] active:scale-[0.98] transition"
>
View Profile
</button>
</div>
{/* ── Reviews ─────────────────────────────────────────────────────── */}
{listing.reviews && listing.reviews.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-white mb-3 flex items-center gap-2">
<MessageCircle size={14} className="text-[#D4AF37]" />
Reviews ({listing.reviews.length})
</h3>
<div className="space-y-3">
{listing.reviews.map((review) => (
<div
key={review.id}
className="bg-[#111118] border border-gray-800/60 rounded-2xl p-4"
>
{/* Reviewer info */}
<div className="flex items-center gap-2 mb-2.5">
{review.reviewer.avatar ? (
<img
src={review.reviewer.avatar}
alt={review.reviewer.name}
className="w-8 h-8 rounded-full object-cover"
/>
) : (
<div className="w-8 h-8 rounded-full bg-gray-800/80 flex items-center justify-center text-[10px] font-bold text-gray-400">
{getInitials(review.reviewer.name)}
</div>
)}
<div className="flex-1 min-w-0">
<p className="text-xs font-semibold text-white truncate">
{review.reviewer.name}
</p>
<div className="flex items-center gap-1">
<div className="flex items-center gap-0.5">
{Array.from({ length: 5 }).map((_, i) => (
<Star
key={i}
size={10}
className={
i < review.rating
? "text-[#D4AF37] fill-[#D4AF37]"
: "text-gray-700"
}
/>
))}
</div>
<span className="text-[10px] text-gray-600">
{timeAgo(review.createdAt)}
</span>
</div>
</div>
</div>
{/* Review title */}
{review.title && (
<p className="text-sm font-semibold text-white mb-1">
{review.title}
</p>
)}
{/* Review text */}
<p className="text-xs text-gray-300 leading-relaxed">
{review.text}
</p>
</div>
))}
</div>
</div>
)}
{/* Bottom spacer */}
<div className="h-4" />
</div>
{/* ── Fixed Bottom Order Bar ───────────────────────────────────────── */}
<div className="fixed bottom-0 left-0 right-0 z-50 bg-[#0a0a0f]/95 backdrop-blur-md border-t border-gray-800/60">
{/* Safe area inset wrapper */}
<div
className="bg-[#111118] border-t border-gray-800/60 px-4 py-3"
style={{ paddingBottom: "calc(env(safe-area-inset-bottom, 0px) + 56px)" }}
>
{/* Success toast */}
{orderSuccess && (
<div className="absolute bottom-full left-0 right-0 mx-4 mb-2 flex items-center gap-2 p-3 rounded-xl text-sm bg-emerald-900/20 border border-emerald-800/40 text-emerald-400 animate-fade-in">
<Check size={16} />
Order placed! Redirecting...
</div>
)}
{/* Error toast */}
{orderError && (
<div className="absolute bottom-full left-0 right-0 mx-4 mb-2">
<ErrorFeedback
error={orderError}
kind="default"
onRetry={() => setOrderError(null)}
context="order"
/>
</div>
)}
<div className="flex items-center gap-3">
{/* Price info */}
<div className="flex-1 min-w-0">
<p className="text-xs text-gray-500">
{selectedPackage ? selectedPackage.name : "No package selected"}
</p>
<p className="text-lg font-bold text-[#D4AF37]">
{selectedPackage ? formatFlh(totalPrice) : "—"}
</p>
{selectedPackage && (
<p className="text-[10px] text-gray-600">
{formatFlh(selectedPackage.price)} + 1.5% fee
</p>
)}
</div>
{/* Continue button */}
<button
onClick={handleContinue}
disabled={ordering || !selectedPackage}
className="px-6 py-3.5 rounded-xl bg-[#D4AF37] text-[#0a0a0f] font-semibold text-sm transition active:bg-[#c5a233] disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 min-h-[48px]"
>
{ordering ? (
<>
<Loader2 size={16} className="animate-spin" />
Placing...
</>
) : (
<>
<ShoppingCart size={16} />
Continue
</>
)}
</button>
</div>
</div>
</div>
</div>
);
}
+523
View File
@@ -0,0 +1,523 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useAuth } from "@/lib/AuthContext";
import { useRouter } from "next/navigation";
import {
ArrowLeft,
Plus,
X,
Loader2,
Check,
Package,
Image as ImageIcon,
Tags,
Clock,
} from "lucide-react";
/* ------------------------------------------------------------------ */
/* Categories */
/* ------------------------------------------------------------------ */
const CATEGORIES = [
{ id: "graphics-design", name: "Graphics & Design", icon: "🎨" },
{ id: "digital-marketing", name: "Digital Marketing", icon: "📈" },
{ id: "writing-translation", name: "Writing & Translation", icon: "✍️" },
{ id: "video-animation", name: "Video & Animation", icon: "🎬" },
{ id: "music-audio", name: "Music & Audio", icon: "🎵" },
{ id: "programming-tech", name: "Programming & Tech", icon: "💻" },
{ id: "ai-services", name: "AI Services", icon: "🤖" },
{ id: "consulting", name: "Consulting", icon: "💼" },
{ id: "data", name: "Data", icon: "📊" },
{ id: "business", name: "Business", icon: "🏢" },
{ id: "personal-growth", name: "Personal Growth", icon: "🌱" },
{ id: "photography", name: "Photography", icon: "📷" },
{ id: "finance", name: "Finance", icon: "💰" },
];
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface PackageField {
id: string;
name: string;
description: string;
price: string;
deliveryDays: string;
revisions: string;
}
interface FormErrors {
title?: string;
category?: string;
description?: string;
priceFlh?: string;
}
/* ------------------------------------------------------------------ */
/* Page Component */
/* ------------------------------------------------------------------ */
export default function SouqCreatePage() {
const { user, token, loading } = useAuth();
const router = useRouter();
/* ---- form fields ---- */
const [title, setTitle] = useState("");
const [category, setCategory] = useState("");
const [description, setDescription] = useState("");
const [priceFlh, setPriceFlh] = useState("");
const [deliveryDays, setDeliveryDays] = useState("");
const [tagsInput, setTagsInput] = useState("");
const [imageUrl, setImageUrl] = useState("");
/* ---- packages ---- */
const [packages, setPackages] = useState<PackageField[]>([]);
/* ---- ui state ---- */
const [errors, setErrors] = useState<FormErrors>({});
const [submitting, setSubmitting] = useState(false);
const [success, setSuccess] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
/* ---- auth guard ---- */
useEffect(() => {
if (!loading && !token) {
router.push("/auth");
}
}, [loading, token, router]);
/* ---- auto-redirect on success ---- */
useEffect(() => {
if (success) {
const timer = setTimeout(() => router.push("/souq"), 2000);
return () => clearTimeout(timer);
}
}, [success, router]);
/* ---- helpers ---- */
const generateId = useCallback(
() => Math.random().toString(36).substring(2, 10),
[]
);
const addPackage = () => {
setPackages((prev) => [
...prev,
{
id: generateId(),
name: "",
description: "",
price: "",
deliveryDays: "",
revisions: "",
},
]);
};
const removePackage = (id: string) => {
setPackages((prev) => prev.filter((p) => p.id !== id));
};
const updatePackage = (id: string, field: keyof PackageField, value: string) => {
setPackages((prev) =>
prev.map((p) => (p.id === id ? { ...p, [field]: value } : p))
);
};
/* ---- validation ---- */
const validate = (): boolean => {
const errs: FormErrors = {};
if (!title.trim()) errs.title = "Title is required";
if (!category) errs.category = "Please select a category";
if (!description.trim()) errs.description = "Description is required";
if (!priceFlh || isNaN(Number(priceFlh)) || Number(priceFlh) < 0) {
errs.priceFlh = "Enter a valid price (0 or more)";
}
setErrors(errs);
return Object.keys(errs).length === 0;
};
/* ---- submit ---- */
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validate()) return;
setSubmitting(true);
setErrorMessage("");
try {
// Parse tags from comma-separated string
const tagsArr = tagsInput
.split(",")
.map((t) => t.trim())
.filter(Boolean);
// Build images array
const imagesArr = imageUrl.trim() ? [imageUrl.trim()] : [];
// Build packages array (only if user added any)
const packagesArr = packages
.filter((p) => p.name.trim())
.map((p) => ({
name: p.name.trim(),
description: p.description.trim(),
price: Number(p.price) || 0,
deliveryDays: p.deliveryDays ? Number(p.deliveryDays) : undefined,
revisions: p.revisions ? Number(p.revisions) : undefined,
}));
const body: Record<string, unknown> = {
title: title.trim(),
description: description.trim(),
category,
priceFlh: Number(priceFlh),
deliveryDays: deliveryDays ? Number(deliveryDays) : undefined,
tags: tagsArr.length > 0 ? tagsArr : undefined,
images: imagesArr.length > 0 ? imagesArr : undefined,
packages: packagesArr.length > 0 ? packagesArr : undefined,
};
const res = await fetch("/mobile/api/souq/listings", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || "Failed to create listing");
}
setSuccess(true);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "Something went wrong";
setErrorMessage(msg);
} finally {
setSubmitting(false);
}
};
/* ---- early returns ---- */
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;
/* ---- success state ---- */
if (success) {
return (
<div className="min-h-dvh bg-[#0a0a0f] flex flex-col items-center justify-center px-6">
<div className="w-16 h-16 rounded-full bg-emerald-900/30 border border-emerald-500/30 flex items-center justify-center mb-5">
<Check size={28} className="text-emerald-400" />
</div>
<h2 className="text-xl font-bold text-white mb-2">Listing Created!</h2>
<p className="text-sm text-gray-500 text-center mb-2">
Your service has been published on Souq.
</p>
<p className="text-xs text-gray-600">Redirecting to Souq</p>
<div className="mt-6 w-6 h-6 rounded-full border-2 border-[#D4AF37]/30 border-t-[#D4AF37] animate-spin" />
<button
onClick={() => router.push("/souq")}
className="mt-6 text-xs text-[#D4AF37] underline underline-offset-2"
>
Go now
</button>
</div>
);
}
/* ---- main render ---- */
return (
<div className="min-h-dvh bg-[#0a0a0f] pb-24">
{/* Header */}
<div className="px-4 pt-6 pb-4 flex items-center gap-3">
<button
onClick={() => router.push("/souq")}
className="w-9 h-9 rounded-xl bg-[#111118] border border-gray-800/60 flex items-center justify-center text-gray-400 hover:text-white transition shrink-0 active:scale-95"
>
<ArrowLeft size={18} />
</button>
<div>
<h1 className="text-xl font-bold text-white">Create Listing</h1>
<p className="text-xs text-gray-500 mt-0.5">Sell your service on Souq</p>
</div>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="px-4 space-y-5">
{/* Title */}
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-4 space-y-4">
<div>
<label className="text-xs font-medium text-gray-500 mb-1.5 block">
Title <span className="text-red-400">*</span>
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g. I will build a modern website"
className="w-full bg-[#0a0a0f] border border-gray-800/60 rounded-xl px-4 py-3 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/50 focus:ring-1 focus:ring-[#D4AF37]/30 transition min-h-[48px]"
/>
{errors.title && (
<p className="text-xs text-red-400 mt-1.5">{errors.title}</p>
)}
</div>
</div>
{/* Category */}
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-4">
<label className="text-xs font-medium text-gray-500 mb-3 block">
Category <span className="text-red-400">*</span>
</label>
<div className="grid grid-cols-3 gap-2.5">
{CATEGORIES.map((cat) => {
const isActive = category === cat.id;
return (
<button
key={cat.id}
type="button"
onClick={() => setCategory(isActive ? "" : cat.id)}
className={`relative bg-[#0a0a0f] border rounded-xl p-3 text-center active:scale-95 transition min-h-[72px] ${
isActive
? "border-[#D4AF37]/50 ring-1 ring-[#D4AF37]/30"
: "border-gray-800/60 hover:border-gray-700/60"
}`}
>
<span className="text-lg block mb-1">{cat.icon}</span>
<p className="text-[10px] font-medium text-white leading-tight">
{cat.name}
</p>
{isActive && (
<span className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-[#D4AF37] flex items-center justify-center">
<Check size={10} className="text-black" />
</span>
)}
</button>
);
})}
</div>
{errors.category && (
<p className="text-xs text-red-400 mt-2">{errors.category}</p>
)}
</div>
{/* Description */}
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-4">
<label className="text-xs font-medium text-gray-500 mb-1.5 block">
Description <span className="text-red-400">*</span>
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Describe your service in detail..."
rows={5}
className="w-full bg-[#0a0a0f] border border-gray-800/60 rounded-xl px-4 py-3 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/50 focus:ring-1 focus:ring-[#D4AF37]/30 transition resize-none min-h-[120px]"
/>
{errors.description && (
<p className="text-xs text-red-400 mt-1.5">{errors.description}</p>
)}
</div>
{/* Price + Delivery Days */}
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-4 space-y-4">
<div>
<label className="text-xs font-medium text-gray-500 mb-1.5 block">
Price in FLH <span className="text-red-400">*</span>
</label>
<input
type="number"
value={priceFlh}
onChange={(e) => setPriceFlh(e.target.value)}
placeholder="0"
min={0}
className="w-full bg-[#0a0a0f] border border-gray-800/60 rounded-xl px-4 py-3 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/50 focus:ring-1 focus:ring-[#D4AF37]/30 transition min-h-[48px]"
/>
{errors.priceFlh && (
<p className="text-xs text-red-400 mt-1.5">{errors.priceFlh}</p>
)}
</div>
<div>
<label className="text-xs font-medium text-gray-500 mb-1.5 flex items-center gap-1.5">
<Clock size={12} /> Delivery Days <span className="text-gray-700">(optional)</span>
</label>
<input
type="number"
value={deliveryDays}
onChange={(e) => setDeliveryDays(e.target.value)}
placeholder="e.g. 7"
min={1}
className="w-full bg-[#0a0a0f] border border-gray-800/60 rounded-xl px-4 py-3 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/50 focus:ring-1 focus:ring-[#D4AF37]/30 transition min-h-[48px]"
/>
</div>
</div>
{/* Packages (optional) */}
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-4">
<div className="flex items-center justify-between mb-3">
<label className="text-xs font-medium text-gray-500 flex items-center gap-1.5">
<Package size={12} /> Packages <span className="text-gray-700">(optional)</span>
</label>
<button
type="button"
onClick={addPackage}
className="flex items-center gap-1 text-xs font-medium text-[#D4AF37] bg-[#D4AF37]/10 border border-[#D4AF37]/20 rounded-xl px-3 py-1.5 hover:bg-[#D4AF37]/20 transition active:scale-95"
>
<Plus size={12} /> Add Package
</button>
</div>
{packages.length === 0 && (
<p className="text-xs text-gray-600 text-center py-4">
No packages yet. Offer multiple tiers to attract more buyers.
</p>
)}
<div className="space-y-3">
{packages.map((pkg, idx) => (
<div
key={pkg.id}
className="bg-[#0a0a0f] border border-gray-800/60 rounded-xl p-4 relative"
>
<div className="flex items-center justify-between mb-3">
<span className="text-[11px] font-semibold text-gray-400 uppercase tracking-wider">
Package {idx + 1}
</span>
<button
type="button"
onClick={() => removePackage(pkg.id)}
className="w-6 h-6 rounded-full bg-red-900/20 border border-red-800/30 flex items-center justify-center text-red-400 hover:bg-red-900/40 transition active:scale-90"
>
<X size={12} />
</button>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2">
<input
type="text"
value={pkg.name}
onChange={(e) => updatePackage(pkg.id, "name", e.target.value)}
placeholder="Package name (e.g. Basic, Standard, Premium)"
className="w-full bg-[#111118] border border-gray-800/60 rounded-xl px-3 py-2.5 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/50 focus:ring-1 focus:ring-[#D4AF37]/30 transition"
/>
</div>
<div className="col-span-2">
<input
type="text"
value={pkg.description}
onChange={(e) =>
updatePackage(pkg.id, "description", e.target.value)
}
placeholder="Brief description of this package"
className="w-full bg-[#111118] border border-gray-800/60 rounded-xl px-3 py-2.5 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/50 focus:ring-1 focus:ring-[#D4AF37]/30 transition"
/>
</div>
<div>
<input
type="number"
value={pkg.price}
onChange={(e) => updatePackage(pkg.id, "price", e.target.value)}
placeholder="Price (FLH)"
min={0}
className="w-full bg-[#111118] border border-gray-800/60 rounded-xl px-3 py-2.5 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/50 focus:ring-1 focus:ring-[#D4AF37]/30 transition"
/>
</div>
<div>
<input
type="number"
value={pkg.deliveryDays}
onChange={(e) =>
updatePackage(pkg.id, "deliveryDays", e.target.value)
}
placeholder="Delivery (days)"
min={1}
className="w-full bg-[#111118] border border-gray-800/60 rounded-xl px-3 py-2.5 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/50 focus:ring-1 focus:ring-[#D4AF37]/30 transition"
/>
</div>
<div className="col-span-2">
<input
type="number"
value={pkg.revisions}
onChange={(e) =>
updatePackage(pkg.id, "revisions", e.target.value)
}
placeholder="Revisions included"
min={0}
className="w-full bg-[#111118] border border-gray-800/60 rounded-xl px-3 py-2.5 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/50 focus:ring-1 focus:ring-[#D4AF37]/30 transition"
/>
</div>
</div>
</div>
))}
</div>
</div>
{/* Tags */}
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-4">
<label className="text-xs font-medium text-gray-500 mb-1.5 flex items-center gap-1.5">
<Tags size={12} /> Tags <span className="text-gray-700">(optional, comma-separated)</span>
</label>
<input
type="text"
value={tagsInput}
onChange={(e) => setTagsInput(e.target.value)}
placeholder="e.g. web development, react, responsive"
className="w-full bg-[#0a0a0f] border border-gray-800/60 rounded-xl px-4 py-3 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/50 focus:ring-1 focus:ring-[#D4AF37]/30 transition min-h-[48px]"
/>
</div>
{/* Image URL */}
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-4">
<label className="text-xs font-medium text-gray-500 mb-1.5 flex items-center gap-1.5">
<ImageIcon size={12} /> Image URL <span className="text-gray-700">(optional)</span>
</label>
<input
type="url"
value={imageUrl}
onChange={(e) => setImageUrl(e.target.value)}
placeholder="https://example.com/image.jpg"
className="w-full bg-[#0a0a0f] border border-gray-800/60 rounded-xl px-4 py-3 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/50 focus:ring-1 focus:ring-[#D4AF37]/30 transition min-h-[48px]"
/>
</div>
{/* Error message */}
{errorMessage && (
<div className="bg-red-900/20 border border-red-800/30 rounded-2xl p-4 flex items-start gap-3">
<X size={16} className="text-red-400 shrink-0 mt-0.5" />
<p className="text-sm text-red-300">{errorMessage}</p>
</div>
)}
{/* Submit button */}
<button
type="submit"
disabled={submitting}
className="w-full bg-[#D4AF37] text-black text-sm font-semibold rounded-2xl py-4 hover:bg-[#D4AF37]/90 transition active:scale-[0.97] disabled:opacity-50 disabled:cursor-not-allowed disabled:active:scale-100 flex items-center justify-center gap-2 min-h-[56px]"
>
{submitting ? (
<>
<Loader2 size={16} className="animate-spin" />
Creating
</>
) : (
"Publish Listing"
)}
</button>
<div className="h-4" />
</form>
</div>
);
}
File diff suppressed because it is too large Load Diff
+254
View File
@@ -0,0 +1,254 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useAuth } from "@/lib/AuthContext";
import { useRouter } from "next/navigation";
import {
ShoppingBag,
Package,
User,
Clock,
ChevronRight,
Loader2,
Inbox,
} from "lucide-react";
import ErrorFeedback from "@/components/ErrorFeedback";
// ── Types ──
interface OrderListing {
id: string;
title: string;
category: string;
priceFlh: number;
}
interface OrderParty {
id: string;
name: string;
email: string;
avatar?: string | null;
}
interface Order {
id: string;
listingId: string;
buyerId: string;
sellerId: string;
status: string;
amountFlh: number;
platformFee: number;
packageName?: string | null;
createdAt: string;
listing: OrderListing;
buyer: OrderParty;
seller: OrderParty;
}
// ── Status helpers ──
const STATUS_COLORS: Record<string, { bg: string; text: string; label: string }> = {
pending: { bg: "bg-gray-800/60", text: "text-gray-400", label: "Pending" },
paid: { bg: "bg-blue-900/20 border border-blue-700/30", text: "text-blue-400", label: "Paid" },
in_progress: { bg: "bg-amber-900/20 border border-amber-700/30", text: "text-amber-400", label: "In Progress" },
delivered: { bg: "bg-purple-900/20 border border-purple-700/30", text: "text-purple-400", label: "Delivered" },
completed: { bg: "bg-emerald-900/20 border border-emerald-700/30", text: "text-emerald-400", label: "Completed" },
disputed: { bg: "bg-red-900/20 border border-red-700/30", text: "text-red-400", label: "Disputed" },
cancelled: { bg: "bg-gray-800/40 border border-gray-700/30", text: "text-gray-500", label: "Cancelled" },
};
function getStatusStyle(status: string) {
return STATUS_COLORS[status] || STATUS_COLORS.pending;
}
function formatDate(dateStr: string) {
const d = new Date(dateStr);
return d.toLocaleDateString("en-MY", {
year: "numeric",
month: "short",
day: "numeric",
});
}
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 formatDate(dateStr);
}
export default function OrdersPage() {
const { user, token, loading } = useAuth();
const router = useRouter();
const [tab, setTab] = useState<"buyer" | "seller">("buyer");
const [orders, setOrders] = useState<Order[]>([]);
const [loadingOrders, setLoadingOrders] = useState(true);
const [error, setError] = useState<string | null>(null);
// ── Auth redirect ──
useEffect(() => {
if (!loading && !token) {
router.push("/auth");
}
}, [loading, token, router]);
// ── Fetch orders ──
const fetchOrders = useCallback(async () => {
if (!token) return;
setLoadingOrders(true);
setError(null);
try {
const res = await fetch(`/mobile/api/souq/orders?role=${tab}&limit=50`, {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();
if (res.ok) {
setOrders(data.orders || []);
} else {
setError(data.error || "Failed to load orders");
}
} catch {
setError("Network error. Please try again.");
} finally {
setLoadingOrders(false);
}
}, [token, tab]);
useEffect(() => {
if (token) fetchOrders();
}, [token, fetchOrders]);
// ── Loading (auth) ──
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">My Orders</h1>
</div>
{/* Tabs */}
<div className="px-4 pb-4">
<div className="flex bg-[#111118] border border-gray-800/60 rounded-xl p-1">
<button
onClick={() => setTab("buyer")}
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-lg text-sm font-medium transition ${
tab === "buyer"
? "bg-[#D4AF37] text-[#0a0a0f]"
: "text-gray-500 active:text-gray-300"
}`}
>
<ShoppingBag size={14} />
As Buyer
</button>
<button
onClick={() => setTab("seller")}
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-lg text-sm font-medium transition ${
tab === "seller"
? "bg-[#D4AF37] text-[#0a0a0f]"
: "text-gray-500 active:text-gray-300"
}`}
>
<Package size={14} />
As Seller
</button>
</div>
</div>
{/* Content */}
<div className="px-4 space-y-3">
{loadingOrders ? (
<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 orders...</p>
</div>
) : error ? (
<ErrorFeedback error={error} kind="network" onRetry={fetchOrders} context="orders" />
) : orders.length === 0 ? (
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-8 text-center">
<div className="w-14 h-14 rounded-2xl bg-gray-800/40 flex items-center justify-center mx-auto mb-4">
<Inbox size={28} className="text-gray-600" />
</div>
<h3 className="text-base font-semibold text-white mb-1">
No orders yet
</h3>
<p className="text-sm text-gray-500 max-w-xs mx-auto">
{tab === "buyer"
? "You haven't placed any orders yet. Browse the marketplace to get started."
: "No one has ordered from you yet. List your services to start selling."}
</p>
<button
onClick={() => router.push("/souq")}
className="mt-4 inline-flex items-center gap-1.5 bg-[#D4AF37]/15 border border-[#D4AF37]/30 rounded-xl px-4 py-3 text-xs font-medium text-[#D4AF37] active:bg-[#D4AF37]/25 transition"
>
<ShoppingBag size={12} />
{tab === "buyer" ? "Browse Marketplace" : "Manage Listings"}
</button>
</div>
) : (
orders.map((order) => {
const sc = getStatusStyle(order.status);
return (
<button
key={order.id}
onClick={() => router.push(`/souq/orders/${order.id}`)}
className="w-full text-left bg-[#111118] border border-gray-800/60 rounded-2xl p-4 active:bg-[#1a1a24] transition animate-fade-in"
>
{/* Listing title + status */}
<div className="flex items-start justify-between gap-3 mb-2">
<h3 className="text-sm font-semibold text-white leading-tight line-clamp-2 flex-1">
{order.listing.title}
</h3>
<span
className={`shrink-0 text-[10px] font-medium px-2.5 py-1 rounded-full ${sc.bg} ${sc.text}`}
>
{sc.label}
</span>
</div>
{/* Buyer/Seller + Amount */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 min-w-0">
<User size={11} className="text-gray-600 shrink-0" />
<span className="text-xs text-gray-400 truncate">
{tab === "buyer" ? order.seller.name : order.buyer.name}
</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-sm font-bold text-[#D4AF37]">
{order.amountFlh.toLocaleString()} FLH
</span>
<ChevronRight size={14} className="text-gray-600" />
</div>
</div>
{/* Date */}
<div className="flex items-center gap-1 mt-1.5">
<Clock size={10} className="text-gray-600" />
<span className="text-[10px] text-gray-600">
{timeAgo(order.createdAt)}
</span>
</div>
</button>
);
})
)}
</div>
</div>
);
}
+437 -639
View File
File diff suppressed because it is too large Load Diff
+319
View File
@@ -0,0 +1,319 @@
"use client";
import { useState, useEffect } from "react";
import { useAuth } from "@/lib/AuthContext";
import { useRouter, useParams } from "next/navigation";
import {
ArrowLeft,
Star,
User,
ShoppingBag,
Clock,
Loader2,
} from "lucide-react";
// ── Types ─────────────────────────────────────────────────────────────────────
interface SellerListing {
id: string;
title: string;
description: string;
category: string;
subcategory: string | null;
priceFlh: number;
packages: string | null;
tags: string | null;
images: string | null;
deliveryDays: number | null;
rating: number;
reviewCount: number;
salesCount: number;
status: string;
createdAt: string;
seller: {
id: string;
name: string;
email: string;
avatar: string | null;
};
}
interface SellerProfile {
id: string;
name: string;
email: string;
avatar: string | null;
memberSince: string;
listingsCount: number;
averageRating: number;
reviewCount: number;
salesCount: number;
listings: SellerListing[];
}
// ── Helpers ────────────────────────────────────────────────────────────────────
function getInitials(name: string): string {
return name
.split(" ")
.map((n) => n.charAt(0).toUpperCase())
.slice(0, 2)
.join("");
}
function 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">
{"★".repeat(full)}
{half && "½"}
{"☆".repeat(empty)}
</span>
);
}
const CATEGORY_VISUALS: Record<string, { emoji: string; bg: string }> = {
"graphics-design": { emoji: "🎨", bg: "from-pink-900/40 to-pink-800/20" },
"digital-marketing": { emoji: "📈", bg: "from-emerald-900/40 to-emerald-800/20" },
"writing-translation": { emoji: "✍️", bg: "from-amber-900/40 to-amber-800/20" },
"video-animation": { emoji: "🎬", bg: "from-red-900/40 to-red-800/20" },
"music-audio": { emoji: "🎵", bg: "from-purple-900/40 to-purple-800/20" },
"programming-tech": { emoji: "💻", bg: "from-blue-900/40 to-blue-800/20" },
"ai-services": { emoji: "🤖", bg: "from-cyan-900/40 to-cyan-800/20" },
"consulting": { emoji: "💼", bg: "from-slate-700/40 to-slate-600/20" },
"data": { emoji: "📊", bg: "from-teal-900/40 to-teal-800/20" },
"business": { emoji: "🏢", bg: "from-indigo-900/40 to-indigo-800/20" },
"personal-growth": { emoji: "🌱", bg: "from-lime-900/40 to-lime-800/20" },
"photography": { emoji: "📷", bg: "from-orange-900/40 to-orange-800/20" },
"finance": { emoji: "💰", bg: "from-yellow-900/40 to-yellow-800/20" },
};
function listingVisual(cat?: string): { emoji: string; bg: string } {
if (cat && CATEGORY_VISUALS[cat]) return CATEGORY_VISUALS[cat];
return { emoji: "🛍️", bg: "from-gray-800/40 to-gray-700/20" };
}
function formatFlh(amount: number): string {
if (amount >= 1_000_000) return `FLH ${(amount / 1_000_000).toFixed(1)}M`;
if (amount >= 1_000) return `FLH ${(amount / 1_000).toFixed(1)}K`;
return `FLH ${amount.toLocaleString()}`;
}
// ── Listing Card (same style as browse page) ──────────────────────────────────
function ListingCard({
listing,
router,
}: {
listing: SellerListing;
router: ReturnType<typeof useRouter>;
}) {
const vis = listingVisual(listing.category);
const rating = listing.rating ?? 0;
return (
<button
onClick={() => router.push(`/souq/${listing.id}`)}
className="bg-[#111118] border border-gray-800/60 rounded-2xl overflow-hidden text-left active:scale-[0.97] transition"
>
<div className={`h-24 bg-gradient-to-br ${vis.bg} flex items-center justify-center`}>
<span className="text-3xl">{vis.emoji}</span>
</div>
<div className="p-3 space-y-1">
<p className="text-sm font-semibold text-white leading-tight line-clamp-2 min-h-[2.5rem]">
{listing.title}
</p>
<div className="flex items-center gap-1">
<span className="text-[11px] text-amber-400">{renderStars(rating)}</span>
{rating > 0 && <span className="text-[10px] text-gray-600">{rating.toFixed(1)}</span>}
</div>
<div className="flex items-center justify-between pt-0.5">
<p className="text-sm font-bold text-[#D4AF37]">
{formatFlh(listing.priceFlh)}
</p>
{listing.deliveryDays ? (
<span className="flex items-center gap-0.5 text-[10px] text-gray-500">
<Clock size={9} /> {listing.deliveryDays}d
</span>
) : null}
</div>
</div>
</button>
);
}
// ── Page Component ────────────────────────────────────────────────────────────
export default function SellerProfilePage() {
const { user, token, loading } = useAuth();
const router = useRouter();
const params = useParams();
const sellerId = params?.id as string;
const [seller, setSeller] = useState<SellerProfile | null>(null);
const [fetching, setFetching] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!loading && !token) router.push("/auth");
}, [loading, token, router]);
useEffect(() => {
if (!token || !sellerId) return;
setFetching(true);
setError(null);
fetch(`/mobile/api/souq/sellers/${sellerId}`, {
headers: { Authorization: `Bearer ${token}` },
})
.then((r) => {
if (!r.ok) throw new Error("Seller not found");
return r.json();
})
.then((data) => setSeller(data.seller))
.catch((err) => setError(err.message))
.finally(() => setFetching(false));
}, [token, sellerId]);
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-36">
{/* ── 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-14">
<button
onClick={() => router.back()}
className="w-9 h-9 rounded-xl bg-[#111118] border border-gray-800/60 flex items-center justify-center active:scale-90 transition"
>
<ArrowLeft size={16} className="text-gray-400" />
</button>
<h1 className="text-sm font-semibold text-white">Seller Profile</h1>
</div>
</div>
<div className="px-4 pt-5 space-y-5">
{fetching ? (
<div className="flex flex-col items-center justify-center py-20">
<div className="w-8 h-8 rounded-full border-2 border-[#D4AF37]/30 border-t-[#D4AF37] animate-spin mb-3" />
<p className="text-xs text-gray-600">Loading seller</p>
</div>
) : error || !seller ? (
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-8 text-center">
<User size={32} className="mx-auto text-gray-700 mb-3" />
<p className="text-sm text-gray-500">{error || "Seller not found"}</p>
<button
onClick={() => router.back()}
className="mt-4 text-xs text-[#D4AF37] underline underline-offset-2"
>
Go back
</button>
</div>
) : (
<>
{/* ── Seller Info Card ────────────────────────────────────────── */}
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-5">
<div className="flex items-center gap-4 mb-5">
{seller.avatar ? (
<img
src={seller.avatar}
alt={seller.name}
className="w-16 h-16 rounded-full object-cover"
/>
) : (
<div className="w-16 h-16 rounded-full bg-gradient-to-br from-[#D4AF37]/20 to-gray-800/60 flex items-center justify-center text-xl font-bold text-[#D4AF37]">
{getInitials(seller.name)}
</div>
)}
<div className="flex-1 min-w-0">
<h2 className="text-lg font-bold text-white truncate">
{seller.name}
</h2>
<p className="text-xs text-gray-500 mt-0.5 flex items-center gap-1.5">
<Clock size={11} className="text-gray-600" />
Member since{" "}
{new Date(seller.memberSince).toLocaleDateString("en-US", {
month: "long",
year: "numeric",
})}
</p>
</div>
</div>
{/* Stats grid */}
<div className="grid grid-cols-3 gap-3">
<div className="bg-gray-800/30 rounded-xl px-3 py-3 text-center">
<div className="flex items-center justify-center gap-1 text-[#D4AF37] mb-0.5">
<Star size={13} className="fill-[#D4AF37]" />
<span className="text-lg font-bold text-white">
{seller.averageRating.toFixed(1)}
</span>
</div>
<p className="text-[10px] text-gray-500">Rating</p>
</div>
<div className="bg-gray-800/30 rounded-xl px-3 py-3 text-center">
<p className="text-lg font-bold text-white">
{seller.reviewCount}
</p>
<p className="text-[10px] text-gray-500">
{seller.reviewCount === 1 ? "Review" : "Reviews"}
</p>
</div>
<div className="bg-gray-800/30 rounded-xl px-3 py-3 text-center">
<p className="text-lg font-bold text-white">
{seller.salesCount}
</p>
<p className="text-[10px] text-gray-500">
{seller.salesCount === 1 ? "Sale" : "Sales"}
</p>
</div>
</div>
</div>
{/* ── Listings Section ─────────────────────────────────────────── */}
<div>
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
<ShoppingBag size={14} className="text-[#D4AF37]" />
Services by this seller
</h3>
<span className="text-xs text-gray-600">
{seller.listingsCount} item
{seller.listingsCount !== 1 ? "s" : ""}
</span>
</div>
{seller.listings.length === 0 ? (
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-8 text-center">
<ShoppingBag size={28} className="mx-auto text-gray-700 mb-2" />
<p className="text-sm text-gray-500">No active services yet</p>
</div>
) : (
<div className="grid grid-cols-2 gap-3">
{seller.listings.map((listing) => (
<ListingCard
key={listing.id}
listing={listing}
router={router}
/>
))}
</div>
)}
</div>
</>
)}
</div>
</div>
);
}
+310
View File
@@ -0,0 +1,310 @@
import { prisma } from "@/lib/prisma";
import type { Metadata } from "next";
type Props = {
params: Promise<{ serial: string }>;
};
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { serial } = await params;
return {
title: `Certificate Verification — ${serial}`,
description: `Verify a Falah Academy (UK) course certificate with serial number ${serial}.`,
};
}
export default async function VerifyCertificatePage({ params }: Props) {
const { serial } = await params;
const certificate = await prisma.learnCertificate.findUnique({
where: { serialNumber: serial },
include: {
course: {
select: { title: true },
},
},
});
// ── Not found state ──────────────────────────────────
if (!certificate) {
return (
<div className="min-h-dvh bg-[#0a0a0f] flex flex-col items-center justify-center px-6">
<div className="bg-[#111118] border border-gray-800/60 rounded-3xl p-8 max-w-md w-full text-center">
{/* Shield cross icon */}
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-red-900/20 border-2 border-red-800/40 flex items-center justify-center">
<svg
className="w-10 h-10 text-red-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
/>
</svg>
</div>
<h1 className="text-2xl font-bold text-white mb-2">
Certificate Not Found
</h1>
<p className="text-gray-500 text-sm mb-6">
No certificate with serial number{" "}
<span className="text-gray-300 font-mono">{serial}</span> exists in
our records.
</p>
<div className="bg-gray-900/40 border border-gray-800/40 rounded-xl px-4 py-3">
<p className="text-xs text-gray-600">
If you believe this is an error, please contact{" "}
<span className="text-gray-400">support@falahacademy.uk</span>
</p>
</div>
</div>
</div>
);
}
const isValid = !certificate.revoked;
// ── Certificate found ────────────────────────────────
return (
<div className="min-h-dvh bg-[#0a0a0f] flex flex-col items-center justify-center px-6 py-8">
<div className="max-w-md w-full">
{/* ── Branding header ─────────────────────────────── */}
<div className="text-center mb-6">
<h1 className="text-lg font-bold text-white tracking-tight">
Falah Academy <span className="text-[#D4AF37]">(UK)</span>
</h1>
<p className="text-xs text-gray-600 mt-0.5">
Certificate Verification
</p>
</div>
{/* ── Certificate card ────────────────────────────── */}
<div
className={`bg-[#111118] border-2 rounded-3xl p-6 ${
isValid
? "border-[#D4AF37]/40 shadow-[0_0_30px_-5px_rgba(212,175,55,0.15)]"
: "border-red-800/40"
}`}
>
{/* VALID / INVALID badge */}
<div className="flex justify-center mb-6">
{isValid ? (
<div className="inline-flex items-center gap-2 bg-emerald-900/30 border border-emerald-700/40 rounded-full px-5 py-1.5">
<svg
className="w-5 h-5 text-emerald-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span className="text-sm font-bold text-emerald-400 tracking-wider uppercase">
Valid
</span>
</div>
) : (
<div className="inline-flex items-center gap-2 bg-red-900/30 border border-red-700/40 rounded-full px-5 py-1.5">
<svg
className="w-5 h-5 text-red-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
<span className="text-sm font-bold text-red-400 tracking-wider uppercase">
Invalid
</span>
</div>
)}
</div>
{/* Serial number */}
<div className="text-center mb-6">
<p className="text-[10px] text-gray-600 uppercase tracking-widest mb-1">
Serial Number
</p>
<p className="text-sm font-mono font-semibold text-gray-200 bg-gray-900/50 rounded-lg px-3 py-2 inline-block border border-gray-800/40">
{certificate.serialNumber}
</p>
</div>
{/* Divider */}
<div className="border-t border-gray-800/60 mb-5" />
{/* Details grid */}
<div className="space-y-3">
{/* Recipient name */}
<div className="flex items-start gap-3">
<svg
className="w-5 h-5 text-gray-600 shrink-0 mt-0.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
<div>
<p className="text-[10px] text-gray-600 uppercase tracking-widest">
Recipient
</p>
<p className="text-sm font-semibold text-white">
{certificate.userName}
</p>
</div>
</div>
{/* Course title */}
<div className="flex items-start gap-3">
<svg
className="w-5 h-5 text-gray-600 shrink-0 mt-0.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
/>
</svg>
<div>
<p className="text-[10px] text-gray-600 uppercase tracking-widest">
Course
</p>
<p className="text-sm font-semibold text-white">
{certificate.course.title}
</p>
</div>
</div>
{/* Issue date */}
<div className="flex items-start gap-3">
<svg
className="w-5 h-5 text-gray-600 shrink-0 mt-0.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
<div>
<p className="text-[10px] text-gray-600 uppercase tracking-widest">
Issue Date
</p>
<p className="text-sm font-semibold text-white">
{new Date(certificate.issuedAt).toLocaleDateString("en-GB", {
day: "numeric",
month: "long",
year: "numeric",
})}
</p>
</div>
</div>
{/* Modules completed */}
<div className="flex items-start gap-3">
<svg
className="w-5 h-5 text-gray-600 shrink-0 mt-0.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
/>
</svg>
<div>
<p className="text-[10px] text-gray-600 uppercase tracking-widest">
Modules Completed
</p>
<p className="text-sm font-semibold text-white">
{certificate.moduleCount}
</p>
</div>
</div>
{/* Score (optional) */}
{certificate.score !== null && (
<div className="flex items-start gap-3">
<svg
className="w-5 h-5 text-gray-600 shrink-0 mt-0.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
</svg>
<div>
<p className="text-[10px] text-gray-600 uppercase tracking-widest">
Score
</p>
<p className="text-sm font-semibold text-white">
{certificate.score}%
</p>
</div>
</div>
)}
</div>
{/* Revocation notice */}
{certificate.revoked && certificate.revokedAt && (
<div className="mt-5 bg-red-900/15 border border-red-800/30 rounded-xl px-4 py-3">
<p className="text-xs text-red-400">
This certificate was revoked on{" "}
{new Date(certificate.revokedAt).toLocaleDateString("en-GB", {
day: "numeric",
month: "long",
year: "numeric",
})}
.
</p>
</div>
)}
</div>
{/* ── Footer ──────────────────────────────────────── */}
<div className="text-center mt-6">
<p className="text-[10px] text-gray-700">
&copy; {new Date().getFullYear()} Falah Academy (UK). All rights
reserved.
</p>
</div>
</div>
</div>
);
}
+36 -5
View File
@@ -28,9 +28,11 @@ interface CashoutEntry {
}
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" },
{ flh: 500, usd: 0.99, bonus: "", bestValue: false },
{ flh: 1000, usd: 0.99, bonus: "", bestValue: true },
{ flh: 5000, usd: 4.99, bonus: "+500 bonus", bestValue: false },
{ flh: 10000, usd: 9.99, bonus: "+2,000 bonus", bestValue: false },
{ flh: 50000, usd: 49.99, bonus: "+15,000 bonus", bestValue: false },
];
const EARNING_TIPS = [
@@ -54,6 +56,7 @@ export default function WalletPage() {
text: string;
} | null>(null);
const [topupLoading, setTopupLoading] = useState<number | null>(null);
const [topupSuccess, setTopupSuccess] = useState(false);
useEffect(() => {
if (!loading && !token) router.push("/auth");
@@ -65,6 +68,21 @@ export default function WalletPage() {
}
}, [token]);
// Check for ?topup=success from Polar.sh or mock redirect
useEffect(() => {
const params = new URLSearchParams(window.location.search);
if (params.get("topup") === "success") {
setTopupSuccess(true);
fetchWallet();
// Clean URL param without reload
const url = new URL(window.location.href);
url.searchParams.delete("topup");
window.history.replaceState({}, "", url.toString());
const timer = setTimeout(() => setTopupSuccess(false), 6000);
return () => clearTimeout(timer);
}
}, []);
const fetchWallet = async () => {
try {
const res = await fetch("/mobile/api/wallet", {
@@ -235,6 +253,14 @@ export default function WalletPage() {
<ErrorFeedback error={cashoutMessage.text} kind="default" onRetry={() => setCashoutMessage(null)} context="wallet" />
)}
{/* Top-up Success Banner */}
{topupSuccess && (
<div className="flex items-center gap-2 p-3 rounded-xl text-sm bg-emerald-900/20 border border-emerald-800/40 text-emerald-400">
<Check size={16} />
Top-up successful! FLH has been added to your balance.
</div>
)}
{/* Top-Up Section */}
<div>
<h2 className="text-sm font-semibold text-white mb-3 flex items-center gap-2">
@@ -247,8 +273,13 @@ export default function WalletPage() {
key={opt.flh}
onClick={() => handleTopUp(opt.flh)}
disabled={topupLoading === opt.flh}
className="bg-[#111118] border border-gray-800/60 rounded-2xl p-5 text-center active:scale-95 transition hover:border-[#D4AF37]/30 disabled:opacity-60 min-h-[100px]"
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 relative"
>
{opt.bestValue && (
<span className="absolute -top-2.5 inset-x-0 mx-auto w-fit px-2 py-0.5 bg-[#D4AF37] text-[#0a0a0f] text-[10px] font-bold rounded-full uppercase tracking-wider">
Best Value
</span>
)}
{topupLoading === opt.flh ? (
<Loader2 size={20} className="animate-spin mx-auto text-[#D4AF37]" />
) : (
@@ -260,7 +291,7 @@ export default function WalletPage() {
${opt.usd.toFixed(2)}
</p>
{opt.bonus && (
<p className="text-xs text-emerald-400 mt-1 font-medium">
<p className="text-[11px] text-emerald-400 mt-1.5 font-semibold">
{opt.bonus}
</p>
)}