255 lines
8.8 KiB
TypeScript
255 lines
8.8 KiB
TypeScript
|
|
"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>
|
||
|
|
);
|
||
|
|
}
|