274 lines
9.1 KiB
TypeScript
274 lines
9.1 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import Image from "next/image";
|
|
import {
|
|
HiOutlineChevronDoubleLeft,
|
|
HiOutlineChevronLeft,
|
|
HiOutlineChevronRight,
|
|
HiOutlineChevronDoubleRight,
|
|
} from "react-icons/hi2";
|
|
import { IconButton } from "@/components/IconButton";
|
|
import { formatDisplayName } from "@/lib/variants";
|
|
|
|
const PAGE_SIZE = 5;
|
|
|
|
type OrderItem = {
|
|
id: string;
|
|
title: string;
|
|
product_title: string;
|
|
variant_title: string;
|
|
variant_sku?: string;
|
|
variant?: { sku?: string };
|
|
quantity: number;
|
|
unit_price: number;
|
|
total: number;
|
|
subtotal?: number;
|
|
tax_total?: number;
|
|
thumbnail: string | null;
|
|
};
|
|
|
|
type Order = {
|
|
id: string;
|
|
display_id: number;
|
|
created_at: string;
|
|
total: number;
|
|
subtotal?: number;
|
|
shipping_total?: number;
|
|
tax_total?: number;
|
|
currency_code: string;
|
|
items: OrderItem[];
|
|
};
|
|
|
|
function formatPrice(amount: number, currencyCode: string) {
|
|
return new Intl.NumberFormat("en-US", {
|
|
style: "currency",
|
|
currency: currencyCode,
|
|
minimumFractionDigits: 2,
|
|
}).format(amount);
|
|
}
|
|
|
|
function variantLabel(item: OrderItem): string {
|
|
const sku = item.variant_sku ?? item.variant?.sku;
|
|
if (sku) {
|
|
const friendly = formatDisplayName(sku);
|
|
if (friendly) return friendly;
|
|
}
|
|
return item.variant_title || item.title;
|
|
}
|
|
|
|
export function OrdersTab() {
|
|
const [orders, setOrders] = useState<Order[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [page, setPage] = useState(1);
|
|
|
|
useEffect(() => {
|
|
async function fetchOrders() {
|
|
try {
|
|
const res = await fetch("/api/account/orders");
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
// Sort newest first (fallback in case API doesn't sort)
|
|
const sorted = [...(data.orders ?? [])].sort(
|
|
(a: Order, b: Order) =>
|
|
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
|
|
);
|
|
setOrders(sorted);
|
|
}
|
|
} catch {
|
|
// Silently fail — empty state will show
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
fetchOrders();
|
|
}, []);
|
|
|
|
if (loading) {
|
|
return (
|
|
<p className="my-10 text-center text-lightsec md:my-12 lg:my-20 dark:text-darksec">
|
|
Loading orders…
|
|
</p>
|
|
);
|
|
}
|
|
|
|
if (orders.length === 0) {
|
|
return (
|
|
<p className="py-12 text-center text-lightsec dark:text-darksec">
|
|
You haven't placed any orders yet.
|
|
</p>
|
|
);
|
|
}
|
|
|
|
const totalPages = Math.max(1, Math.ceil(orders.length / PAGE_SIZE));
|
|
const safePage = Math.min(page, totalPages);
|
|
const start = (safePage - 1) * PAGE_SIZE;
|
|
const paginatedOrders = orders.slice(start, start + PAGE_SIZE);
|
|
|
|
return (
|
|
<div>
|
|
<div className="mb-4">
|
|
<p className="text-sm text-lightsec dark:text-darksec">
|
|
{orders.length} order{orders.length !== 1 ? "s" : ""}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="divide-y divide-lighttext/10 dark:divide-darktext/10">
|
|
{paginatedOrders.map((order) => {
|
|
const currency = order.currency_code ?? "eur";
|
|
|
|
const subtotal =
|
|
order.subtotal ?? order.items?.reduce((sum, i) => sum + (i.subtotal ?? 0), 0) ?? 0;
|
|
const taxTotal =
|
|
order.tax_total ?? order.items?.reduce((sum, i) => sum + (i.tax_total ?? 0), 0) ?? 0;
|
|
const shippingTotal =
|
|
order.shipping_total ?? Math.max(0, order.total - subtotal - taxTotal);
|
|
|
|
return (
|
|
<div key={order.id} className="py-12 first:pt-0 last:pb-0">
|
|
{/* Order header */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<span className="font-silkasb">Order #{order.display_id}</span>
|
|
<span className="ml-3 text-sm text-lightsec dark:text-darksec">
|
|
{new Date(order.created_at).toLocaleDateString("en-GB", {
|
|
day: "numeric",
|
|
month: "short",
|
|
year: "numeric",
|
|
})}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Item cards */}
|
|
{order.items?.length > 0 && (
|
|
<div className="mt-6">
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
{order.items.map((item) => (
|
|
<div
|
|
key={item.id}
|
|
className="relative z-10 flex items-center gap-3 overflow-hidden rounded-xl bg-lightbg shadow-lg ring-1 ring-lightline dark:bg-darkbg dark:ring-darkline"
|
|
>
|
|
{/* Thumbnail */}
|
|
<div className="relative aspect-square w-21 flex-shrink-0 overflow-hidden rounded-xl bg-lightline dark:bg-darkline">
|
|
{item.thumbnail ? (
|
|
<Image
|
|
src={item.thumbnail}
|
|
alt={item.product_title ?? item.title}
|
|
fill
|
|
sizes="64px"
|
|
className="object-cover"
|
|
/>
|
|
) : (
|
|
<div className="flex h-full w-full items-center justify-center text-xs text-lightsec dark:text-darksec">
|
|
No img
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Info */}
|
|
<div className="min-w-0 flex-1 py-3 pr-3">
|
|
<p className="truncate font-silkasb text-sm">
|
|
{item.product_title ?? item.title}
|
|
</p>
|
|
<p className="truncate text-xs text-lightsec dark:text-darksec">
|
|
{variantLabel(item)}
|
|
{item.quantity >= 2 && (
|
|
<span className="ml-1 font-silka text-lightsec dark:text-darksec">
|
|
x {item.quantity}
|
|
</span>
|
|
)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Price breakdown */}
|
|
<div className="mt-7 space-y-1">
|
|
<div className="flex justify-between text-sm">
|
|
<span className="text-lightsec dark:text-darksec">Subtotal</span>
|
|
<span>{formatPrice(subtotal, currency)}</span>
|
|
</div>
|
|
{shippingTotal > 0 && (
|
|
<div className="flex justify-between text-sm">
|
|
<span className="text-lightsec dark:text-darksec">Shipping</span>
|
|
<span>{formatPrice(shippingTotal, currency)}</span>
|
|
</div>
|
|
)}
|
|
{taxTotal > 0 && (
|
|
<div className="flex justify-between text-sm">
|
|
<span className="text-lightsec dark:text-darksec">Tax</span>
|
|
<span>{formatPrice(taxTotal, currency)}</span>
|
|
</div>
|
|
)}
|
|
{taxTotal > 0 && (
|
|
<div className="flex justify-between font-silkasb text-sm">
|
|
<span className="">Total</span>
|
|
<span className="">{formatPrice(order.total, currency)}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{totalPages > 1 && (
|
|
<Pagination page={safePage} totalPages={totalPages} onPageChange={setPage} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Pagination ──────────────────────────────────────────────────────
|
|
|
|
function Pagination({
|
|
page,
|
|
totalPages,
|
|
onPageChange,
|
|
}: {
|
|
page: number;
|
|
totalPages: number;
|
|
onPageChange: (p: number) => void;
|
|
}) {
|
|
const hasPrev = page > 1;
|
|
const hasNext = page < totalPages;
|
|
|
|
return (
|
|
<nav aria-label="Pagination" className="mt-12 flex items-center justify-between">
|
|
<div className="text-sm text-lightsec dark:text-darksec">
|
|
Page {page} of {totalPages}
|
|
</div>
|
|
|
|
<div className="flex gap-3 text-lg">
|
|
<IconButton disabled={!hasPrev} onClick={() => onPageChange(1)} aria-label="First page">
|
|
<HiOutlineChevronDoubleLeft />
|
|
</IconButton>
|
|
<IconButton
|
|
disabled={!hasPrev}
|
|
onClick={() => onPageChange(page - 1)}
|
|
aria-label="Previous page"
|
|
>
|
|
<HiOutlineChevronLeft />
|
|
</IconButton>
|
|
<IconButton
|
|
disabled={!hasNext}
|
|
onClick={() => onPageChange(page + 1)}
|
|
aria-label="Next page"
|
|
>
|
|
<HiOutlineChevronRight />
|
|
</IconButton>
|
|
<IconButton
|
|
disabled={!hasNext}
|
|
onClick={() => onPageChange(totalPages)}
|
|
aria-label="Last page"
|
|
>
|
|
<HiOutlineChevronDoubleRight />
|
|
</IconButton>
|
|
</div>
|
|
</nav>
|
|
);
|
|
}
|