'use client'; import { useState, useEffect } from 'react'; import { CdnImage as Image } from '@/ui/shared/cdn-image'; import type { Order, OrderStatus, OrderDownloadLink } from 'brainerce'; import { formatPrice } from 'brainerce'; import { getClient } from '@/core/lib/brainerce'; import { useCurrency } from '@/core/lib/use-currency'; import { useTranslations } from '@/core/lib/translations'; import { cn } from '@/core/lib/utils'; import { OrderCustomizations } from './order-customizations'; import { OrderStatusTimeline, ORDER_STATUS_LABEL_KEYS } from './order-status-timeline'; import { OrderShippingBlock } from './order-shipping-block'; import { OrderPaymentBlock } from './order-payment-block'; /** * Badge colour per order status. The API sends the status UPPERCASE and * verbatim, so these keys are uppercase too. Do NOT lowercase `order.status` * before the lookup: every row would miss and render as "Pending". * Labels come from ORDER_STATUS_LABEL_KEYS so the two stay in step. */ const STATUS_STYLES: Record = { DRAFT: 'bg-muted text-muted-foreground', PENDING: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-950/30 dark:text-yellow-400', PROCESSING: 'bg-blue-100 text-blue-800 dark:bg-blue-950/30 dark:text-blue-400', ON_HOLD: 'bg-amber-100 text-amber-800 dark:bg-amber-950/30 dark:text-amber-400', PAID: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950/30 dark:text-emerald-400', SHIPPED: 'bg-purple-100 text-purple-800 dark:bg-purple-950/30 dark:text-purple-400', DELIVERED: 'bg-green-100 text-green-800 dark:bg-green-950/30 dark:text-green-400', COMPLETED: 'bg-green-100 text-green-800 dark:bg-green-950/30 dark:text-green-400', FULFILLED: 'bg-teal-100 text-teal-800 dark:bg-teal-950/30 dark:text-teal-400', CANCELLED: 'bg-red-100 text-red-800 dark:bg-red-950/30 dark:text-red-400', REFUNDED: 'bg-orange-100 text-orange-800 dark:bg-orange-950/30 dark:text-orange-400', PARTIALLY_REFUNDED: 'bg-orange-100 text-orange-800 dark:bg-orange-950/30 dark:text-orange-400', }; interface OrderHistoryProps { orders: Order[]; /** True while another page exists (`meta.page < meta.totalPages`). */ hasMore?: boolean; /** True while the next page is in flight. */ loadingMore?: boolean; /** Fetch and append the next page. Omit to render without pagination. */ onLoadMore?: () => void; className?: string; } export function OrderHistory({ orders, hasMore = false, loadingMore = false, onLoadMore, className, }: OrderHistoryProps) { const t = useTranslations('account'); const tp = useTranslations('products'); const tc = useTranslations('common'); // Only the genuinely-empty history hits this branch. Appending a page can // never shrink the list, so the empty state cannot flash mid-pagination. if (orders.length === 0) { return (

{t('noOrders')}

{t('noOrdersDesc')}

); } return (
{orders.map((order) => ( ))} {/* Auto-hides on the last page: `hasMore` is false when meta.page === meta.totalPages, which is also the single-page case. */} {hasMore && onLoadMore && ( )}
); } function OrderCard({ order }: { order: Order }) { const t = useTranslations('account'); const tc = useTranslations('common'); const [expanded, setExpanded] = useState(false); const statusLabelKey = ORDER_STATUS_LABEL_KEYS[order.status] || 'statusPending'; const statusClassName = STATUS_STYLES[order.status] || STATUS_STYLES.PENDING; const currency = useCurrency(order.currency); const totalAmount = order.totalAmount || order.total || '0'; return (
{/* Order header */} {/* Expanded order items */} {expanded && (
{order.items.map((item, index) => (
{item.image ? ( {item.name ) : (
)}

{item.name || t('productFallback')}

{tc('qty')} {item.quantity}

{formatPrice(parseFloat(item.price), { currency }) as string}
{item.customizations && }
))} {/* The shopper's own checkout note, read-only */} {order.notes && (

{t('orderNotes')}

{order.notes}

)} {/* Downloads section */} {order.hasDownloads && }
)}
); } function OrderDownloads({ orderId }: { orderId: string }) { const t = useTranslations('account'); const [downloads, setDownloads] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { let cancelled = false; async function fetch() { try { const client = getClient(); const links = await client.getOrderDownloads(orderId); if (!cancelled) setDownloads(links); } catch { if (!cancelled) setDownloads([]); } finally { if (!cancelled) setLoading(false); } } fetch(); return () => { cancelled = true; }; }, [orderId]); if (loading) { return (

{t('downloads')}...

); } if (!downloads || downloads.length === 0) return null; return (

{t('downloads')}

{downloads.map((link, idx) => (

{link.fileName}

{link.productName} {' · '} {link.downloadLimit != null ? `${link.downloadsUsed}/${link.downloadLimit} ${t('downloadsRemaining')}` : t('unlimitedDownloads')} {' · '} {link.expiresAt ? `${t('expiresAt')} ${new Date(link.expiresAt).toLocaleDateString()}` : t('noExpiry')}

{t('downloadFile')}
))}
); } function OrderFinancialSummary({ order, currency }: { order: Order; currency: string }) { const tc = useTranslations('common'); const totalAmount = order.totalAmount || order.total || '0'; const subtotal = order.subtotal ? parseFloat(order.subtotal) : null; const ruleAmt = order.ruleDiscountAmount ? parseFloat(order.ruleDiscountAmount) : 0; const couponAmt = order.couponDiscount ? parseFloat(order.couponDiscount) : 0; const shipping = order.shippingAmount ? parseFloat(order.shippingAmount) : 0; // Inclusive (VAT) orders persist taxAmount=0; the real VAT lives on the // breakdown. Surface whichever is present (mirror of TaxDisplay). const explicitTax = order.taxAmount ? parseFloat(order.taxAmount) : 0; const taxIncluded = !!order.taxBreakdown?.pricesIncludeTax; const tax = explicitTax > 0 ? explicitTax : typeof order.taxBreakdown?.totalTax === 'number' ? order.taxBreakdown.totalTax : 0; // One row per applied rate. Guard every field: this is a frozen snapshot and // an older order can carry rows without a name or an amount. const taxRows = (order.taxBreakdown?.breakdown ?? []).filter( (row): row is { name: string; rate: number; amount: number } => !!row && typeof row.name === 'string' && row.name.length > 0 && typeof row.amount === 'number' && Number.isFinite(row.amount) ); const rules = order.appliedDiscounts; const hasBreakdown = subtotal !== null && subtotal > 0; if (!hasBreakdown) { return (
{tc('total')} {formatPrice(parseFloat(totalAmount), { currency }) as string}
); } return (
{tc('subtotal')} {formatPrice(subtotal, { currency }) as string}
{rules && rules.length > 0 ? rules.map((rule) => (
{rule.ruleName} -{formatPrice(parseFloat(rule.discountAmount || '0'), { currency }) as string}
)) : ruleAmt > 0 && (
{tc('generalDiscount')} -{formatPrice(ruleAmt, { currency }) as string}
)} {order.couponCode && couponAmt > 0 && (
{tc('couponDiscount')} ({order.couponCode}) -{formatPrice(couponAmt, { currency }) as string}
)} {shipping > 0 && (
{tc('shipping')} {formatPrice(shipping, { currency }) as string}
)} {/* `breakdown` holds ONE ROW PER TAX. A Quebec order carries GST and QST and both have to appear. Name the tax whenever there is one to name — the checkout this shopper just paid through prints "GST", so printing "Tax" back to them here for the same order reads as a different figure. An order frozen before breakdowns existed carries no printable row and still falls back to the single combined line. */} {tax > 0 && (taxRows.length > 0 ? ( taxRows.map((row, i) => (
{row.name} {taxIncluded ? ` (${tc('inclSuffix')})` : ''} {formatPrice(row.amount, { currency }) as string}
)) ) : (
{taxIncluded ? tc('taxIncl') : tc('tax')} {formatPrice(tax, { currency }) as string}
))}
{tc('total')} {formatPrice(parseFloat(totalAmount), { currency }) as string}
{/* Order history is the record a customer checks a card statement against, months later. A total with no gift-card line is the one that makes them think they were charged twice. */} {order.tenders && order.tenders.length > 0 && ( <> {order.tenders.map((tender) => (
{tender.giftCard?.codeLast4 ? `${tc('giftCard')} ····${tender.giftCard.codeLast4}` : tc('giftCard')} -{formatPrice(parseFloat(tender.amountBase), { currency }) as string}
))}
{tc('amountCharged')} { formatPrice( order.tenders.reduce( (due, tn) => due - parseFloat(tn.amountBase), parseFloat(totalAmount) ), { currency } ) as string }
)}
); }