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:
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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
@@ -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 }) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
© {new Date().getFullYear()} Falah Academy (UK). All rights
|
||||
reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+36
-5
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user