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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user