block
Dashboard — Order detail
Line items with fulfilled quantities, customer, addresses, a timeline and the totals breakdown.
npx shadcn@latest add @hextor/store-dash-order-detailSource
"use client";
import type { ReactNode } from "react";
import Image from "next/image";
import { CreditCard, Package, ShoppingBag } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import {
DashboardShell,
DEFAULT_DASHBOARD_NAV,
type DashboardBrand,
type DashboardNavItem,
} from "@/registry/hextor/components/store/dashboard/dashboard-shell";
import { PageHeader } from "@/registry/hextor/components/store/dashboard/page-header";
import {
FulfillmentStatusBadge,
PaymentStatusBadge,
} from "@/registry/hextor/components/store/dashboard/order-status-badges";
import { OrderTimeline, type OrderTimelineEvent } from "@/registry/hextor/components/store/dashboard/order-timeline";
import { AddressCard } from "@/registry/hextor/components/store/dashboard/address-card";
import { TotalsSummary } from "@/registry/hextor/components/store/dashboard/totals-summary";
import { resolveLabels, type DashboardLabelsOverrides } from "@/registry/hextor/components/store/dashboard/labels";
import { formatMoney, lineTotal, type Customer, type Order } from "@/registry/hextor/lib/store";
/**
* A single order: line items with per-line fulfilment progress, the
* customer, both addresses, a derived timeline and the totals breakdown.
* `Order` has no event log of its own — see order-timeline.tsx — so the
* three timeline entries below are built here, from the fields the model
* actually has (`createdAt` plus the two current status axes).
*/
export default function OrderDetailPage({
order,
customer,
backHref = "/dashboard/orders",
nav = DEFAULT_DASHBOARD_NAV,
brand,
activeHref = "/dashboard/orders",
actions,
locale = "en-US",
labels,
}: {
order: Order;
customer?: Customer;
backHref?: string;
nav?: DashboardNavItem[];
brand?: DashboardBrand;
activeHref?: string;
actions?: ReactNode;
locale?: string;
labels?: DashboardLabelsOverrides;
}) {
const copy = resolveLabels(labels);
const timeline: OrderTimelineEvent[] = [
{
id: "placed",
title: copy.orderDetail.timeline.placed,
timestamp: new Date(order.createdAt).toLocaleString(locale),
icon: ShoppingBag,
},
{
id: "payment",
title: copy.orderDetail.timeline.payment(copy.statuses.payment[order.paymentStatus]),
icon: CreditCard,
},
{
id: "fulfillment",
title: copy.orderDetail.timeline.fulfillment(copy.statuses.fulfillment[order.fulfillmentStatus]),
icon: Package,
},
];
return (
<DashboardShell brand={brand} nav={nav} activeHref={activeHref} actions={actions}>
<PageHeader
title={`#${order.number}`}
description={new Date(order.createdAt).toLocaleString(locale)}
breadcrumb={[{ label: copy.orderDetail.back, href: backHref }, { label: `#${order.number}` }]}
actions={
<>
<PaymentStatusBadge status={order.paymentStatus} labels={copy.statuses.payment} />
<FulfillmentStatusBadge status={order.fulfillmentStatus} labels={copy.statuses.fulfillment} />
</>
}
/>
<div className="grid grid-cols-1 gap-4 p-4 md:p-6 lg:grid-cols-3">
<div className="space-y-4 lg:col-span-2">
<Card>
<CardHeader>
<CardTitle>{copy.orderDetail.lineItems}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{order.lines.map((line) => (
<div key={line.id} className="flex items-center gap-3">
<div className="relative size-14 shrink-0 overflow-hidden rounded-lg bg-muted ring-1 ring-foreground/10">
{line.image ? (
<Image src={line.image.url} alt={line.image.alt} fill sizes="56px" className="object-cover" />
) : null}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{line.title}</p>
<p className="truncate text-xs text-muted-foreground">
{line.variantTitle} · {copy.orderDetail.fulfilledOf(line.fulfilledQuantity, line.quantity)}
</p>
</div>
<div className="shrink-0 text-right">
<p className="tnum text-sm font-medium">{formatMoney(lineTotal(line), locale)}</p>
<p className="tnum text-xs text-muted-foreground">
{line.quantity} × {formatMoney(line.unitPrice, locale)}
</p>
</div>
</div>
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{copy.orderDetail.timeline.title}</CardTitle>
</CardHeader>
<CardContent>
<OrderTimeline events={timeline} />
</CardContent>
</Card>
</div>
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle>{copy.orderDetail.customer}</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<p className="font-medium">{customer?.name ?? copy.orderDetail.noCustomer}</p>
<p className="text-muted-foreground">{order.email}</p>
{customer?.phone ? <p className="text-muted-foreground">{customer.phone}</p> : null}
{order.note ? (
<>
<Separator />
<div>
<p className="text-xs font-medium text-muted-foreground">{copy.orderDetail.note}</p>
<p>{order.note}</p>
</div>
</>
) : null}
</CardContent>
</Card>
<Card>
<CardContent className="space-y-4">
<AddressCard
title={copy.orderDetail.shippingAddress}
address={order.shippingAddress}
emptyLabel={copy.orderDetail.noAddress}
/>
<Separator />
<AddressCard
title={copy.orderDetail.billingAddress}
address={order.billingAddress}
emptyLabel={copy.orderDetail.noAddress}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{copy.orderDetail.totals.title}</CardTitle>
</CardHeader>
<CardContent>
<TotalsSummary totals={order.totals} labels={copy.orderDetail.totals} locale={locale} />
</CardContent>
</Card>
</div>
</div>
</DashboardShell>
);
}
Docs
The route renders a partially-fulfilled order on purpose. A detail page that only ever shows a clean, fully-shipped order hides the states an operator actually opens it for.
Ships with sample data from @hextor/store-fixtures so it renders on install. The route file is the only place that import appears — swap it for your own query and the view is unchanged.
Dependencies
@hextor/store-dash-ui@hextor/store-fixtures