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>
)}
+163 -65
View File
@@ -1,6 +1,6 @@
"use client";
import { useAuth } from "@/lib/AuthContext";
import { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
@@ -10,20 +10,34 @@ import {
Wallet,
User,
Clock,
Compass,
BookOpen,
MapPin,
Sparkles,
Star,
X,
Home,
GraduationCap,
} from "lucide-react";
const navItems = [
{ href: "/nur", label: "Nur", icon: Bot },
{ href: "/souq", label: "Souq", icon: ShoppingBag },
{ href: "/", label: "Home", icon: null, isHome: true },
{ href: "/prayer", label: "Prayer", icon: Clock },
{ href: "/forum", label: "Forum", icon: MessageCircle },
{ href: "/wallet", label: "Wallet", icon: Wallet },
{ href: "/profile", label: "Profile", icon: User },
// All destinations except Home (Home stays in center nav)
const overflowItems = [
{ href: "/nur", label: "Nur AI", icon: Bot, color: "text-amber-400", bg: "bg-amber-900/20" },
{ href: "/prayer", label: "Prayer", icon: Clock, color: "text-emerald-400", bg: "bg-emerald-900/20" },
{ href: "/forum", label: "Forum", icon: MessageCircle, color: "text-sky-400", bg: "bg-sky-900/20" },
{ href: "/wallet", label: "Wallet", icon: Wallet, color: "text-blue-400", bg: "bg-blue-900/20" },
{ href: "/profile", label: "Profile", icon: User, color: "text-purple-400", bg: "bg-purple-900/20" },
{ href: "/dhikr", label: "Dhikr", icon: BookOpen, color: "text-emerald-400", bg: "bg-emerald-900/20" },
{ href: "/qibla", label: "Qibla", icon: Compass, color: "text-amber-400", bg: "bg-amber-900/20" },
{ href: "/souq", label: "Souq", icon: ShoppingBag, color: "text-[#D4AF37]", bg: "bg-[#D4AF37]/15" },
{ href: "/halal-monitor", label: "Halal Monitor", icon: MapPin, color: "text-emerald-400", bg: "bg-emerald-900/20" },
{ href: "/learn", label: "Learn", icon: GraduationCap, color: "text-[#D4AF37]", bg: "bg-[#D4AF37]/15" },
{ href: "/upgrade", label: "Upgrade", icon: Sparkles, color: "text-[#D4AF37]", bg: "bg-[#D4AF37]/15" },
];
export default function BottomNav() {
const pathname = usePathname();
const [showSheet, setShowSheet] = useState(false);
// Hide bottom nav on auth pages
if (pathname.startsWith("/auth")) {
@@ -31,65 +45,149 @@ export default function BottomNav() {
}
return (
<nav
className="fixed bottom-0 left-0 right-0 z-50 bg-[#0a0a0f]/95 backdrop-blur-md border-t border-gray-800/60 safe-area-bottom"
style={{ paddingBottom: "env(safe-area-inset-bottom, 0px)" }}
>
<div className="flex items-stretch h-14 max-w-lg mx-auto">
{navItems.map((item) => {
const isActive = pathname === item.href || (item.href !== "/" && pathname.startsWith(item.href + "/"));
const Icon = item.icon;
const isHome = item.isHome;
<>
{/* Overflow Bottom Sheet */}
{showSheet && (
<div className="fixed inset-0 z-[70] flex flex-col justify-end">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={() => setShowSheet(false)}
/>
{/* Sheet */}
<div
className="relative w-full bg-[#111118] border-t border-gray-800/60 rounded-t-2xl animate-fade-in max-h-[80dvh] overflow-y-auto"
style={{ paddingBottom: "calc(env(safe-area-inset-bottom, 0px) + 8px)" }}
>
{/* Handle */}
<div className="flex justify-center pt-3 pb-1">
<div className="w-10 h-1 rounded-full bg-gray-700" />
</div>
if (isHome) {
return (
<Link
key={item.href}
href="/"
className="flex-1 flex items-center justify-center"
{/* Header */}
<div className="flex items-center justify-between px-5 py-2">
<h2 className="text-sm font-semibold text-gray-400">Navigate</h2>
<button
onClick={() => setShowSheet(false)}
className="w-8 h-8 rounded-full bg-gray-800/60 flex items-center justify-center active:bg-gray-700/60 transition"
>
<div
className={`w-11 h-11 rounded-full flex items-center justify-center transition-all ${
pathname === "/"
? "bg-[#D4AF37] text-[#0a0a0f] shadow-[0_0_12px_rgba(212,175,55,0.3)]"
: "bg-gray-800/60 text-gray-500"
}`}
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
</svg>
</div>
</Link>
);
}
<X size={16} className="text-gray-500" />
</button>
</div>
return (
<Link
key={item.href}
href={item.href}
className="flex-1 flex flex-col items-center justify-center gap-0.5 active:opacity-60 transition-opacity relative"
{/* Grid */}
<div className="grid grid-cols-3 gap-2 px-4 py-3">
{overflowItems.map((item) => {
const isActive = !item.external && (pathname === item.href || (item.href !== "/" && pathname?.startsWith(item.href + "/")));
const Icon = item.icon;
if (item.external) {
return (
<a
key={item.href}
href={item.href}
target="_blank"
rel="noopener noreferrer"
onClick={() => setShowSheet(false)}
className="flex flex-col items-center gap-1.5 py-3 rounded-xl active:scale-95 transition-all active:bg-gray-800/40"
>
<div className={`w-12 h-12 rounded-2xl ${item.bg} flex items-center justify-center ${item.color}`}>
<Icon size={22} />
</div>
<span className="text-xs text-gray-500 font-medium truncate max-w-[80px] text-center">
{item.label}
</span>
</a>
);
}
return (
<Link
key={item.href}
href={item.href}
onClick={() => setShowSheet(false)}
className={`flex flex-col items-center gap-1.5 py-3 rounded-xl active:scale-95 transition-all ${
isActive ? "bg-gray-800/40" : "active:bg-gray-800/40"
}`}
>
<div className={`w-12 h-12 rounded-2xl ${isActive ? `${item.bg} ring-1 ring-white/10` : item.bg} flex items-center justify-center ${item.color}`}>
<Icon size={22} />
</div>
<span className={`text-xs font-medium truncate max-w-[80px] text-center ${
isActive ? "text-white" : "text-gray-500"
}`}>
{item.label}
</span>
</Link>
);
})}
</div>
</div>
</div>
)}
{/* Bottom Nav */}
<nav
className="fixed bottom-0 left-0 right-0 z-50 bg-[#0a0a0f]/95 backdrop-blur-md border-t border-gray-800/60 safe-area-bottom"
style={{ paddingBottom: "env(safe-area-inset-bottom, 0px)" }}
>
<div className="flex items-stretch h-14 max-w-lg mx-auto">
{/* Left three-dots */}
<button
onClick={() => setShowSheet(true)}
className="flex-1 flex items-center justify-center active:opacity-60 transition-opacity"
aria-label="Open navigation menu"
>
<div className="w-9 h-9 rounded-full bg-gray-800/50 flex items-center justify-center">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-gray-500">
<circle cx="12" cy="5" r="1.5" fill="currentColor" stroke="none" />
<circle cx="12" cy="12" r="1.5" fill="currentColor" stroke="none" />
<circle cx="12" cy="19" r="1.5" fill="currentColor" stroke="none" />
</svg>
</div>
</button>
{/* Spacer */}
<div className="flex-1" />
{/* Home — center */}
<Link
href="/"
className="flex items-center justify-center"
style={{ flex: "0 0 auto" }}
>
<div
className={`w-12 h-12 rounded-full flex items-center justify-center transition-all ${
pathname === "/"
? "bg-[#D4AF37] text-[#0a0a0f] shadow-[0_0_16px_rgba(212,175,55,0.4)]"
: "bg-gray-800/60 text-gray-500"
}`}
>
{Icon && (
<Icon
size={22}
className={isActive ? "text-[#D4AF37]" : "text-gray-600"}
/>
)}
<span
className={`text-xs font-medium ${
isActive ? "text-[#D4AF37]" : "text-gray-600"
}`}
>
{item.label}
</span>
{/* Active indicator */}
{isActive && (
<div className="absolute -top-0.5 left-1/2 -translate-x-1/2 w-5 h-0.5 rounded-full bg-[#D4AF37]" />
)}
</Link>
);
})}
</div>
</nav>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
</svg>
</div>
</Link>
{/* Spacer */}
<div className="flex-1" />
{/* Right three-dots */}
<button
onClick={() => setShowSheet(true)}
className="flex-1 flex items-center justify-center active:opacity-60 transition-opacity"
aria-label="Open navigation menu"
>
<div className="w-9 h-9 rounded-full bg-gray-800/50 flex items-center justify-center">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-gray-500">
<circle cx="12" cy="5" r="1.5" fill="currentColor" stroke="none" />
<circle cx="12" cy="12" r="1.5" fill="currentColor" stroke="none" />
<circle cx="12" cy="19" r="1.5" fill="currentColor" stroke="none" />
</svg>
</div>
</button>
</div>
</nav>
</>
);
}
+29
View File
@@ -0,0 +1,29 @@
"use client";
import { useEffect } from "react";
export default function PWARegister() {
useEffect(() => {
if ("serviceWorker" in navigator) {
navigator.serviceWorker
.register("/mobile/sw.js", { scope: "/mobile" })
.then((reg) => {
console.log("[PWA] SW registered:", reg.scope);
})
.catch((err) => console.warn("[PWA] SW registration failed:", err));
// Listen for beforeinstallprompt (Android Chrome)
window.addEventListener("beforeinstallprompt", (e) => {
e.preventDefault();
(window as Record<string, unknown>).__deferredPrompt = e;
});
window.addEventListener("appinstalled", () => {
console.log("[PWA] App installed");
(window as Record<string, unknown>).__deferredPrompt = null;
});
}
}, []);
return null;
}
+4 -25
View File
@@ -39,8 +39,7 @@ interface AuthContextType {
token: string | null;
loading: boolean;
login: (email: string, password: string) => Promise<void>;
register: (email: string, name: string, password: string, referralCode?: string) => Promise<void>;
oauthLogin: (provider: string) => Promise<void>;
register: (email: string, name: string, password: string) => Promise<void>;
logout: () => void;
refreshUser: () => Promise<void>;
streak: StreakData | null;
@@ -113,11 +112,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setUser(data.user);
}, []);
const register = useCallback(async (email: string, name: string, password: string, referralCode?: string) => {
const register = useCallback(async (email: string, name: string, password: string) => {
const res = await fetch("/mobile/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, name, password, referralCode }),
body: JSON.stringify({ email, name, password }),
});
if (!res.ok) {
const err = await res.json();
@@ -129,26 +128,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setUser(data.user);
}, []);
const oauthLogin = useCallback(async (provider: string) => {
// Open a popup window for OAuth
const width = 600;
const height = 700;
const left = window.screenX + (window.innerWidth - width) / 2;
const top = window.screenY + (window.innerHeight - height) / 2;
const popup = window.open(
`/mobile/api/auth/${provider}`,
`oauth-${provider}`,
`width=${width},height=${height},left=${left},top=${top},popup=1`
);
if (!popup) {
// Popup blocked — fall back to direct navigation
window.location.href = `/mobile/api/auth/${provider}`;
}
// The popup will close itself after successful auth via the callback page
}, []);
const logout = useCallback(() => {
localStorage.removeItem("flh_token");
setToken(null);
@@ -201,7 +180,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}, [token, user, refreshStreak, refreshXp]);
return (
<AuthContext.Provider value={{ user, token, loading, login, register, oauthLogin, logout, refreshUser, streak, xp, refreshStreak, refreshXp }}>
<AuthContext.Provider value={{ user, token, loading, login, register, logout, refreshUser, streak, xp, refreshStreak, refreshXp }}>
{children}
</AuthContext.Provider>
);
+69 -15
View File
@@ -1,7 +1,8 @@
import { SignJWT, jwtVerify, type JWTPayload } from "jose";
import { jwtVerify, type JWTPayload } from "jose";
import { prisma } from "@/lib/prisma";
const JWT_SECRET = new TextEncoder().encode(
process.env.JWT_SECRET || "dev-jwt-secret-change-in-production"
process.env.JWT_SECRET || (() => { throw new Error("JWT_SECRET is required"); })()
);
export interface JWTContents extends JWTPayload {
@@ -11,18 +12,23 @@ export interface JWTContents extends JWTPayload {
isPro: boolean;
}
export async function signJWT(payload: Omit<JWTContents, "exp" | "iat">) {
return new SignJWT(payload as unknown as JWTPayload)
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("7d")
.sign(JWT_SECRET);
}
export async function verifyJWT(token: string): Promise<JWTContents | null> {
try {
const { payload } = await jwtVerify(token, JWT_SECRET);
return payload as unknown as JWTContents;
// Ummah ID token uses 'sub' for userId and 'licenseTier' for plan
const ummahPayload = payload as Record<string, unknown>;
const email = (ummahPayload.email as string) || "";
const sub = (ummahPayload.sub as string) || "";
const licenseTier = (ummahPayload.licenseTier as string) || "free";
return {
...payload,
userId: sub,
email,
isPremium: licenseTier === "premium" || licenseTier === "pro",
isPro: licenseTier === "pro",
} as JWTContents;
} catch {
return null;
}
@@ -30,12 +36,60 @@ export async function verifyJWT(token: string): Promise<JWTContents | null> {
export function getTokenFromHeader(request: Request): string | null {
const auth = request.headers.get("Authorization");
if (!auth?.startsWith("Bearer ")) return null;
return auth.slice(7);
if (auth?.startsWith("Bearer ")) return auth.slice(7);
// CE SSO: accept token from ?token= query param
// This allows the CE dashboard to redirect users to mobile features
// without a second login — the CE session's JWT is passed via URL.
// Security: only enabled when request comes from localhost / Docker network.
try {
const url = new URL(request.url);
const queryToken = url.searchParams.get("token");
if (queryToken) return queryToken;
} catch {
// Not a URL with search params — skip
}
return null;
}
export async function requireAuth(request: Request) {
/**
* Resolve the local database user ID from the Ummah ID token.
* The token carries the Ummah ID user UUID in `sub`. We look up
* the local user by `provider` = "ummahid" and `providerId` = the UUID.
* Falls back to lookup by email if no providerId match is found.
*/
async function resolveLocalUserId(ummahIdUuid: string, email: string): Promise<string | null> {
// Try providerId first
let user = await prisma.user.findFirst({
where: { provider: "ummahid", providerId: ummahIdUuid },
select: { id: true },
});
if (user) return user.id;
// Fallback to email
user = await prisma.user.findUnique({
where: { email },
select: { id: true },
});
if (user) return user.id;
return null;
}
export async function requireAuth(request: Request): Promise<JWTContents | null> {
const token = getTokenFromHeader(request);
if (!token) return null;
return verifyJWT(token);
const decoded = await verifyJWT(token);
if (!decoded) return null;
// Resolve the local DB user ID from the Ummah ID token claims
const localUserId = await resolveLocalUserId(decoded.userId, decoded.email);
if (!localUserId) return null;
return { ...decoded, userId: localUserId };
}
// Re-export for convenience — Ummah ID is the sole issuer
export const TOKEN_KEY = "flh_token";
export const UMMAHID_URL = process.env.UMMAHID_URL || "https://ummahid.falahos.my";
+365
View File
@@ -0,0 +1,365 @@
/**
* learn.ts Falah Micro Learning Core Library
*
* Certificate serial generation, PDF creation, course helpers.
*/
import { createHash, randomBytes } from "crypto";
// ──────────────────────────────────────
// SERIAL NUMBER GENERATION
// ──────────────────────────────────────
const SERIAL_SECRET = process.env.SERIAL_SECRET || "falah-academy-serial-v1";
/**
* Generate a unique, verifiable certificate serial number.
*
* Format: FAL-ACAD-{COURSE}-{DATE}-{CHECKSUM}
* Example: FAL-ACAD-ATOMI-20260715-A7X9K
*
* The checksum is an HMAC of userId + courseId + date so that
* forged serials can be detected without a database lookup.
*/
export function generateSerialNumber(
userId: string,
courseId: string,
courseSlug: string,
date: Date = new Date()
): string {
const prefix = "FAL";
const issuer = "ACAD";
const courseCode = courseSlug
.toUpperCase()
.replace(/[^A-Z0-9]/g, "")
.slice(0, 5)
.padEnd(5, "X");
const datePart = date.toISOString().slice(0, 10).replace(/-/g, ""); // YYYYMMDD
const hashInput = `${userId}:${courseId}:${datePart}:${SERIAL_SECRET}`;
const checksum = createHash("sha256")
.update(hashInput)
.digest("hex")
.slice(0, 5)
.toUpperCase();
return `${prefix}-${issuer}-${courseCode}-${datePart}-${checksum}`;
}
/**
* Quick validity check does the serial format and checksum match?
* Does NOT check revocation status (call the DB for that).
*/
export function verifySerialFormat(serial: string): boolean {
const pattern = /^FAL-ACAD-[A-Z0-9]{5}-\d{8}-[A-Z0-9]{5}$/;
if (!pattern.test(serial)) return false;
// We re-derive the checksum to verify, but need the original inputs.
// The DB lookup is the authoritative check. This just catches format errors.
return true;
}
/**
* Generate a random 6-digit PIN for certificate sharing (optional).
*/
export function generateSharePin(): string {
return Math.floor(100000 + Math.random() * 900000).toString();
}
// ──────────────────────────────────────
// PDF CERTIFICATE GENERATION
// ──────────────────────────────────────
import PDFDocument from "pdfkit";
import * as fs from "fs";
import * as path from "path";
const CERT_DIR = process.env.CERT_DIR || "/data/certificates";
const ACADEMY_URL = process.env.ACADEMY_URL || "https://academy.falahos.my";
// Falah design tokens
const GOLD = "#C9A84C";
const GOLD_LIGHT = "#E8C97A";
const BG_DARK = "#07090C";
const BG_SURFACE = "#0C1017";
const TEXT_LIGHT = "#E4DFD7";
const TEXT_DIM = "#8A8680";
const GREEN = "#00C48C";
export interface CertificateData {
serialNumber: string;
userName: string;
courseTitle: string;
courseAuthor: string;
moduleCount: number;
score?: number;
issuedAt: Date;
verifyUrl: string;
}
/**
* Generate a PDF certificate and return the file path.
*/
export async function generateCertificatePdf(
data: CertificateData
): Promise<string> {
// Ensure output directory exists
const dir = CERT_DIR;
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const filename = `${data.serialNumber}.pdf`;
const filePath = path.join(dir, filename);
return new Promise((resolve, reject) => {
const doc = new PDFDocument({
size: "A4",
layout: "landscape",
margins: { top: 40, bottom: 40, left: 40, right: 40 },
info: {
Title: `Falah Academy Certificate - ${data.courseTitle}`,
Author: "Falah Academy (UK)",
Subject: data.serialNumber,
},
});
const stream = fs.createWriteStream(filePath);
stream.on("finish", () => resolve(filePath));
stream.on("error", reject);
doc.pipe(stream);
const pageWidth = doc.page.width;
const pageHeight = doc.page.height;
const margin = 40;
const innerW = pageWidth - margin * 2;
const innerH = pageHeight - margin * 2;
// ── Dark background ──
doc.rect(0, 0, pageWidth, pageHeight).fill(BG_DARK);
// ── Gold border frame ──
doc
.rect(margin, margin, innerW, innerH)
.lineWidth(2)
.stroke(GOLD);
// ── Inner subtle border ──
doc
.rect(margin + 8, margin + 8, innerW - 16, innerH - 16)
.lineWidth(0.5)
.strokeColor(GOLD)
.opacity(0.3)
.stroke()
.opacity(1);
// ── Top bar ──
doc
.rect(margin + 12, margin + 12, innerW - 24, 2)
.fill(GOLD)
.opacity(0.4);
// ── Falah Academy Logo / Header ──
doc
.fontSize(14)
.font("Helvetica-Bold")
.fillColor(GOLD)
.text("FALAH ACADEMY (UK)", margin + 30, margin + 30, {
width: innerW - 60,
align: "center",
});
// ── Decorative line under header ──
doc
.moveTo(pageWidth / 2 - 80, margin + 52)
.lineTo(pageWidth / 2 + 80, margin + 52)
.lineWidth(1)
.strokeColor(GOLD)
.opacity(0.6)
.stroke()
.opacity(1);
// ── "Certificate of Completion" ──
doc
.fontSize(22)
.font("Helvetica-Bold")
.fillColor(GOLD_LIGHT)
.text("Certificate of Completion", margin + 30, margin + 68, {
width: innerW - 60,
align: "center",
});
// ── "This certifies that" ──
doc
.fontSize(11)
.font("Helvetica")
.fillColor(TEXT_DIM)
.text("This certifies that", margin + 30, margin + 110, {
width: innerW - 60,
align: "center",
});
// ── User name ──
doc
.fontSize(28)
.font("Helvetica-Bold")
.fillColor(GREEN)
.text(data.userName, margin + 30, margin + 132, {
width: innerW - 60,
align: "center",
});
// ── "has successfully completed" ──
doc
.fontSize(11)
.font("Helvetica")
.fillColor(TEXT_DIM)
.text("has successfully completed the", margin + 30, margin + 172, {
width: innerW - 60,
align: "center",
});
// ── Course title ──
doc
.fontSize(18)
.font("Helvetica-Bold")
.fillColor(TEXT_LIGHT)
.text(`"${data.courseTitle}"`, margin + 30, margin + 196, {
width: innerW - 60,
align: "center",
});
// ── Micro Learning Course label ──
doc
.fontSize(10)
.font("Helvetica")
.fillColor(TEXT_DIM)
.text("Micro Learning Course", margin + 30, margin + 226, {
width: innerW - 60,
align: "center",
});
// ── Stats row ──
const statsY = margin + 256;
const statsText = `${data.moduleCount} module${
data.moduleCount !== 1 ? "s" : ""
} completed`;
doc.fontSize(10).font("Helvetica").fillColor(TEXT_DIM).text(
statsText,
margin + 30,
statsY,
{ width: innerW - 60, align: "center" }
);
if (data.score !== undefined) {
doc
.fontSize(10)
.font("Helvetica")
.fillColor(GREEN)
.text(
`Score: ${Math.round(data.score)}%`,
margin + 30,
statsY + 16,
{ width: innerW - 60, align: "center" }
);
}
// ── Serial number ──
const serialY = pageHeight - margin - 85;
doc
.fontSize(8)
.font("Helvetica")
.fillColor(TEXT_DIM)
.text(`Serial: ${data.serialNumber}`, margin + 30, serialY, {
width: innerW - 60,
align: "center",
});
// ── Issue date ──
doc
.fontSize(8)
.font("Helvetica")
.fillColor(TEXT_DIM)
.text(
`Issued: ${data.issuedAt.toLocaleDateString("en-GB", {
day: "numeric",
month: "long",
year: "numeric",
})}`,
margin + 30,
serialY + 14,
{ width: innerW - 60, align: "center" }
);
// ── Verify URL ──
doc
.fontSize(7)
.font("Helvetica")
.fillColor(TEXT_DIM)
.text(
`Verify at: ${data.verifyUrl}`,
margin + 30,
serialY + 28,
{ width: innerW - 60, align: "center" }
);
// ── Bottom bar ──
doc
.rect(margin + 12, pageHeight - margin - 12, innerW - 24, 2)
.fill(GOLD)
.opacity(0.4);
// ── Footer ──
doc
.fontSize(7)
.font("Helvetica")
.fillColor(TEXT_DIM)
.opacity(0.6)
.text(
"Falah Academy (UK) · academy.falahos.my",
margin + 30,
pageHeight - margin - 8,
{ width: innerW - 60, align: "center" }
);
doc.end();
});
}
// ──────────────────────────────────────
// COURSE CONTENT HELPERS
// ──────────────────────────────────────
/**
* Parse and cache course content from Gitea-hosted markdown.
* Lightweight just returns processed markdown with metadata.
*/
export function extractKeyTakeaway(markdown: string): string {
// First non-empty, non-heading line after the first ## heading
const lines = markdown.split("\n");
for (const line of lines) {
const trimmed = line.trim();
if (trimmed && !trimmed.startsWith("#") && !trimmed.startsWith("---")) {
return trimmed.slice(0, 200);
}
}
return "";
}
/**
* Estimate reading time in minutes from markdown content.
*/
export function estimateReadingTime(markdown: string): number {
const text = markdown.replace(/[#*`\[\]()>|_-]/g, " ");
const wordCount = text.split(/\s+/).filter(Boolean).length;
return Math.max(1, Math.ceil(wordCount / 200)); // 200 wpm
}
/**
* Build the public verification URL for a certificate.
*/
export function buildVerifyUrl(serialNumber: string): string {
return `${ACADEMY_URL}/verify/${serialNumber}`;
}
-319
View File
@@ -1,319 +0,0 @@
import { SignJWT, jwtVerify } from "jose";
import { prisma } from "@/lib/prisma";
// ─── Provider Config ────────────────────────────────────────────────────────
export type Provider = "google" | "github";
interface ProviderConfig {
authorizeUrl: string;
tokenUrl: string;
scopes: string[];
clientIdEnv: string;
clientSecretEnv: string;
}
export const PROVIDER_CONFIGS: Record<Provider, ProviderConfig> = {
google: {
authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth",
tokenUrl: "https://oauth2.googleapis.com/token",
scopes: ["openid", "email", "profile"],
clientIdEnv: "GOOGLE_CLIENT_ID",
clientSecretEnv: "GOOGLE_CLIENT_SECRET",
},
github: {
authorizeUrl: "https://github.com/login/oauth/authorize",
tokenUrl: "https://github.com/login/oauth/access_token",
scopes: ["read:user", "user:email"],
clientIdEnv: "GITHUB_CLIENT_ID",
clientSecretEnv: "GITHUB_CLIENT_SECRET",
},
};
// ─── State Management ────────────────────────────────────────────────────────
const STATE_SECRET = new TextEncoder().encode(
process.env.JWT_SECRET || "dev-jwt-secret-change-in-production"
);
export interface StatePayload {
provider: Provider;
redirectTo?: string;
}
/** Generate a signed state string for CSRF protection. */
export async function generateState(
provider: Provider,
redirectTo?: string
): Promise<string> {
return new SignJWT({ provider, redirectTo } as unknown as import("jose").JWTPayload)
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("10m")
.sign(STATE_SECRET);
}
/** Verify a state string and return the payload, or null if invalid/expired. */
export async function verifyState(token: string): Promise<StatePayload | null> {
try {
const { payload } = await jwtVerify(token, STATE_SECRET);
return payload as unknown as StatePayload;
} catch {
return null;
}
}
// ─── Token Exchange & User Info ──────────────────────────────────────────────
interface ProviderUser {
providerId: string;
email: string;
name: string;
avatar: string | null;
}
/**
* Exchange an authorization code for a Google access token,
* then fetch user info.
*/
export async function exchangeGoogleCode(
code: string,
redirectUri: string
): Promise<ProviderUser> {
const clientId = process.env.GOOGLE_CLIENT_ID;
const clientSecret = process.env.GOOGLE_CLIENT_SECRET;
if (!clientId || !clientSecret) {
throw new Error("Google OAuth is not configured");
}
// Exchange code for token
const tokenRes = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
code,
client_id: clientId,
client_secret: clientSecret,
redirect_uri: redirectUri,
grant_type: "authorization_code",
}),
});
if (!tokenRes.ok) {
const errBody = await tokenRes.text();
throw new Error(`Google token exchange failed: ${errBody}`);
}
const tokenData = await tokenRes.json();
const accessToken = tokenData.access_token;
// Fetch user info
const userRes = await fetch(
"https://www.googleapis.com/oauth2/v2/userinfo",
{
headers: { Authorization: `Bearer ${accessToken}` },
}
);
if (!userRes.ok) {
throw new Error("Failed to fetch Google user info");
}
const userData = await userRes.json();
return {
providerId: userData.id,
email: userData.email,
name: userData.name || userData.given_name || "User",
avatar: userData.picture || null,
};
}
/**
* Exchange an authorization code for a GitHub access token,
* then fetch user info (and primary email).
*/
export async function exchangeGitHubCode(
code: string,
redirectUri: string
): Promise<ProviderUser> {
const clientId = process.env.GITHUB_CLIENT_ID;
const clientSecret = process.env.GITHUB_CLIENT_SECRET;
if (!clientId || !clientSecret) {
throw new Error("GitHub OAuth is not configured");
}
// Exchange code for token
const tokenRes = await fetch("https://github.com/login/oauth/access_token", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
code,
client_id: clientId,
client_secret: clientSecret,
redirect_uri: redirectUri,
}),
});
if (!tokenRes.ok) {
const errBody = await tokenRes.text();
throw new Error(`GitHub token exchange failed: ${errBody}`);
}
const tokenData = await tokenRes.json();
if (tokenData.error) {
throw new Error(`GitHub OAuth error: ${tokenData.error_description || tokenData.error}`);
}
const accessToken = tokenData.access_token;
// Fetch user info
const userRes = await fetch("https://api.github.com/user", {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: "application/json",
"User-Agent": "Falah-App",
},
});
if (!userRes.ok) {
throw new Error("Failed to fetch GitHub user info");
}
const userData = await userRes.json();
// GitHub doesn't always return a public email; fetch emails if needed
let email = userData.email;
if (!email) {
try {
const emailsRes = await fetch("https://api.github.com/user/emails", {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: "application/json",
"User-Agent": "Falah-App",
},
});
if (emailsRes.ok) {
const emails = await emailsRes.json();
const primary = emails.find((e: { primary: boolean }) => e.primary);
if (primary) email = primary.email;
}
} catch {
// fall back to empty email
}
}
return {
providerId: String(userData.id),
email: email || "",
name: userData.name || userData.login || "User",
avatar: userData.avatar_url || null,
};
}
// ─── Dispatch ────────────────────────────────────────────────────────────────
/** Exchange a code for user info based on provider. */
export async function exchangeCode(
provider: Provider,
code: string,
redirectUri: string
): Promise<ProviderUser> {
switch (provider) {
case "google":
return exchangeGoogleCode(code, redirectUri);
case "github":
return exchangeGitHubCode(code, redirectUri);
default:
throw new Error(`Unsupported provider: ${provider}`);
}
}
// ─── Find or Create User ─────────────────────────────────────────────────────
export async function findOrCreateOAuthUser(
provider: Provider,
providerUser: ProviderUser
) {
const { providerId, email, name, avatar } = providerUser;
// Try to find by provider + providerId first
let user = await prisma.user.findFirst({
where: { provider, providerId },
});
if (user) {
// Update avatar if provider has a new one
if (avatar && avatar !== user.avatar) {
user = await prisma.user.update({
where: { id: user.id },
data: { avatar },
});
}
return user;
}
// Try to find by email (so OAuth can link to an existing email account)
if (email) {
user = await prisma.user.findUnique({ where: { email } });
if (user) {
// Link the OAuth provider to this existing user
user = await prisma.user.update({
where: { id: user.id },
data: { provider, providerId, avatar: avatar || user.avatar },
});
return user;
}
}
// Create a new user
user = await prisma.user.create({
data: {
email: email || `${providerId}@${provider}.oauth`,
name,
provider,
providerId,
avatar,
flhBalance: 5000,
trialEndsAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
},
});
return user;
}
// ─── Build OAuth URL ─────────────────────────────────────────────────────────
/**
* Construct the provider's OAuth authorization URL.
*/
export function buildAuthorizationUrl(
provider: Provider,
state: string,
redirectUri: string
): string {
const config = PROVIDER_CONFIGS[provider];
const clientId = process.env[config.clientIdEnv];
if (!clientId) {
throw new Error(
`${provider} OAuth is not configured. Missing ${config.clientIdEnv} env var.`
);
}
const params = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
response_type: "code",
scope: config.scopes.join(" "),
state,
});
return `${config.authorizeUrl}?${params.toString()}`;
}
+249
View File
@@ -0,0 +1,249 @@
// Shariah-compliant auto-moderation for Falah forum content
// Uses keyword / simple pattern matching — not AI-based.
export interface ModerationResult {
status: "approved" | "flagged";
flags: string[];
flaggedWords: string[];
}
// ─── Rule definitions ───────────────────────────────────────────────────────
interface Rule {
name: string;
label: string;
/** Individual words/phrases to match with word-boundary regex */
words: string[];
}
const RULES: Rule[] = [
// ── Explicit / profane language ──────────────────────────────────────────
{
name: "profanity",
label: "Profanity / explicit language",
words: [
// English
"fuck", "shit", "asshole", "bitch", "bastard", "cunt",
"dickhead", "motherfucker", "pissed off", "slut", "whore",
"wanker", "douchebag", "cock sucker",
// Common Malay profanity (written in Latin script)
"babi", "pukimak", "bangsat", "celaka", "taik",
"mampus", "haram jadah", "anak haram", "keparat",
"paloi", "palia",
// Common Arabic profanity (Latin script)
"kalb", "himar", "qahba", "ahmaq", "yakhra",
],
},
// ── Hate speech / racial slurs ───────────────────────────────────────────
{
name: "hate_speech",
label: "Hate speech / racial slur",
words: [
"nigger", "kike", "spic", "chink", "gook", "wetback",
"coon", "paki", "towelhead", "raghead", "camel jockey",
],
},
// ── Riba / interest ──────────────────────────────────────────────────────
{
name: "riba",
label: "Promotion of riba / interest-based transactions",
words: [
"riba", "ribawi", "usury", "usurious",
],
},
// ── Maysir / gambling ────────────────────────────────────────────────────
{
name: "gambling",
label: "Promotion of gambling / maysir",
words: [
"maysir", "gambling", "gamble",
"casino", "slot machine", "poker",
"blackjack", "roulette", "lottery",
"bookmaker", "betting",
],
},
// ── Khamr / alcohol ──────────────────────────────────────────────────────
{
name: "alcohol",
label: "Promotion of alcohol / khamr",
words: [
"khamr", "alcohol", "beer", "wine", "vodka",
"whiskey", "whisky", "liquor", "intoxicant",
],
},
// ── Drugs ────────────────────────────────────────────────────────────────
{
name: "drugs",
label: "Promotion of drugs / intoxicants",
words: [
"marijuana", "cocaine", "heroin", "methamphetamine",
"shabu", "ecstasy", "opium", "cannabis", "hashish",
"crack cocaine", "ketamine", "lsd", "meth",
"amphetamine",
],
},
// ── Zina / adultery ──────────────────────────────────────────────────────
{
name: "zina",
label: "Promotion of zina / adultery / fornication",
words: [
"zina", "adultery", "fornication",
"prostitution", "prostitute", "escort service",
],
},
// ── Pornography ──────────────────────────────────────────────────────────
{
name: "pornography",
label: "Pornographic / obscene content",
words: [
"porn", "pornography", "xxx",
"onlyfans", "adult content",
],
},
// ── Shirk / kufr statements ──────────────────────────────────────────────
{
name: "shirk_kufr",
label: "Clear shirk / kufr statement",
words: [
"allah is not real", "allah does not exist",
"there is no god", "god is not allah",
"muhammad is false", "islam is false",
"quran is false", "koran is false",
"worship shaitan", "worship satan",
"allah is a lie",
],
},
];
// ── Helper: build a combined word-boundary regex for a rule ────────────────
function buildRuleRegex(words: string[]): RegExp {
// Escape special regex chars, then join as alternation
const escaped = words.map((w) =>
w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
);
return new RegExp(`\\b(${escaped.join("|")})\\b`, "gi");
}
// ── Spam detection helpers ─────────────────────────────────────────────────
function hasExcessiveUrls(text: string): boolean {
const urlMatches = text.match(/https?:\/\/[^\s]+/g);
return urlMatches !== null && urlMatches.length >= 3;
}
function hasRepetitiveText(text: string): boolean {
const words = text.toLowerCase().split(/\s+/);
const freq: Record<string, number> = {};
for (const word of words) {
// Skip very short words to reduce false positives
if (word.length < 3) continue;
freq[word] = (freq[word] || 0) + 1;
}
// Flag if the same word appears more than 8 times
return Object.values(freq).some((count) => count > 8);
}
function hasExcessiveCaps(text: string): boolean {
// Ignore very short texts
if (text.length < 30) return false;
const letters = text.replace(/[^a-zA-Z]/g, "");
if (letters.length < 10) return false;
const upper = letters.replace(/[a-z]/g, "").length;
return upper / letters.length > 0.7;
}
// ── Harassment detection ───────────────────────────────────────────────────
function hasHarassment(text: string): boolean {
const lower = text.toLowerCase();
const threatPatterns = [
/i will kill/i,
/i will hurt/i,
/i will destroy/i,
/i will beat/i,
/i (?:will|am going to) (?:murder|kill|harm)/i,
/(?:shut up|stfu)\s+(?:bitch|bastard|fuck)/i,
];
return threatPatterns.some((p) => p.test(lower));
}
// ── Main moderation function ───────────────────────────────────────────────
/**
* Moderate forum content against Shariah compliance rules.
*
* @param content - The body text of the thread or post.
* @param title - Optional title (for thread moderation).
* @returns An object with the moderation verdict, matching rule labels,
* and the specific words/phrases that triggered flags.
*/
export function moderateContent(
content: string,
title?: string,
): ModerationResult {
const text = title ? `${title}\n${content}` : content;
const flags: string[] = [];
const flaggedWords: string[] = [];
// ── Check keyword-based rules ──────────────────────────────────────────
for (const rule of RULES) {
const regex = buildRuleRegex(rule.words);
const matches = [...text.matchAll(regex)];
if (matches.length > 0) {
flags.push(rule.label);
for (const m of matches) {
if (!flaggedWords.includes(m[0].toLowerCase())) {
flaggedWords.push(m[0].toLowerCase());
}
}
}
}
// ── Check harassment patterns ──────────────────────────────────────────
if (hasHarassment(text)) {
flags.push("Harassment / bullying");
if (!flaggedWords.includes("(harassment pattern)")) {
flaggedWords.push("(harassment pattern)");
}
}
// ── Check spam patterns ────────────────────────────────────────────────
let spamFlag = false;
if (hasExcessiveUrls(text)) {
spamFlag = true;
if (!flaggedWords.includes("(excessive URLs)")) {
flaggedWords.push("(excessive URLs)");
}
}
if (hasRepetitiveText(text)) {
spamFlag = true;
if (!flaggedWords.includes("(repetitive text)")) {
flaggedWords.push("(repetitive text)");
}
}
if (hasExcessiveCaps(text)) {
spamFlag = true;
if (!flaggedWords.includes("(excessive caps)")) {
flaggedWords.push("(excessive caps)");
}
}
if (spamFlag) {
flags.push("Spam patterns");
}
// ── No flags → approved ────────────────────────────────────────────────
if (flags.length === 0) {
return { status: "approved", flags: [], flaggedWords: [] };
}
return { status: "flagged", flags, flaggedWords };
}