HextorUI
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-detail

Source

"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