block
Dashboard — Customers
Customer table with orders count, total spent and marketing consent.
npx shadcn@latest add @hextor/store-dash-customersSource
"use client";
import { useMemo, useState } from "react";
import type { ReactNode } from "react";
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
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 { FilterBar } from "@/registry/hextor/components/store/dashboard/filter-bar";
import { DataTable, type DataTableColumn, type DataTableSort } from "@/registry/hextor/components/store/dashboard/data-table";
import { EmptyState } from "@/registry/hextor/components/store/dashboard/empty-state";
import { resolveLabels, type DashboardLabelsOverrides } from "@/registry/hextor/components/store/dashboard/labels";
import { formatMoney, type Customer } from "@/registry/hextor/lib/store";
type SortColumn = "name" | "orders" | "spent";
function compareCustomers(a: Customer, b: Customer, columnId: string): number {
switch (columnId as SortColumn) {
case "name":
return a.name.localeCompare(b.name);
case "orders":
return a.ordersCount - b.ordersCount;
case "spent":
return a.totalSpent.amount - b.totalSpent.amount;
default:
return 0;
}
}
/** The customer list: search by name/email, sortable columns, no filters —
* there is no status axis on `Customer` worth filtering, unlike orders and
* products. */
export default function CustomersPage({
customers,
customerHref,
nav = DEFAULT_DASHBOARD_NAV,
brand,
activeHref = "/dashboard/customers",
actions,
locale = "en-US",
labels,
}: {
customers: Customer[];
customerHref?: (customer: Customer) => string;
nav?: DashboardNavItem[];
brand?: DashboardBrand;
activeHref?: string;
actions?: ReactNode;
locale?: string;
labels?: DashboardLabelsOverrides;
}) {
const copy = resolveLabels(labels);
const [search, setSearch] = useState("");
const [sort, setSort] = useState<DataTableSort>({ columnId: "spent", direction: "desc" });
const filtered = useMemo(() => {
const query = search.trim().toLowerCase();
const rows = customers.filter(
(customer) =>
query.length === 0 ||
customer.name.toLowerCase().includes(query) ||
customer.email.toLowerCase().includes(query),
);
const sorted = [...rows].sort((a, b) => compareCustomers(a, b, sort.columnId));
return sort.direction === "asc" ? sorted : sorted.reverse();
}, [customers, search, sort]);
function handleSortChange(columnId: string) {
setSort((prev) =>
prev.columnId === columnId ? { columnId, direction: prev.direction === "asc" ? "desc" : "asc" } : { columnId, direction: "asc" },
);
}
const columns: DataTableColumn<Customer>[] = [
{
id: "name",
header: copy.customers.columns.customer,
sortable: true,
cell: (customer) => {
const content = <span className="text-sm font-medium">{customer.name}</span>;
return customerHref ? (
<Link href={customerHref(customer)} className="hover:underline">
{content}
</Link>
) : (
content
);
},
},
{
id: "email",
header: copy.customers.columns.email,
cell: (customer) => <span className="text-muted-foreground">{customer.email}</span>,
},
{
id: "orders",
header: copy.customers.columns.orders,
sortable: true,
align: "right",
className: "tnum",
cell: (customer) => customer.ordersCount,
},
{
id: "spent",
header: copy.customers.columns.totalSpent,
sortable: true,
align: "right",
className: "tnum",
cell: (customer) => formatMoney(customer.totalSpent, locale),
},
{
id: "marketing",
header: copy.customers.columns.marketing,
cell: (customer) => (
<Badge variant={customer.acceptsMarketing ? "secondary" : "outline"}>
{customer.acceptsMarketing ? copy.customers.marketing.subscribed : copy.customers.marketing.notSubscribed}
</Badge>
),
},
];
return (
<DashboardShell brand={brand} nav={nav} activeHref={activeHref} actions={actions}>
<PageHeader title={copy.customers.title} description={copy.customers.description} />
<div className="space-y-4 p-4 md:p-6">
<FilterBar searchValue={search} onSearchChange={setSearch} searchPlaceholder={copy.common.search} />
<div className="rounded-lg border">
<DataTable
columns={columns}
rows={filtered}
rowKey={(customer) => customer.id}
sort={sort}
onSortChange={handleSortChange}
emptyState={
<EmptyState
title={copy.emptyStates.customersTitle}
description={copy.emptyStates.customersDescription}
className="m-4 border-none"
/>
}
/>
</div>
</div>
</DashboardShell>
);
}
Docs
Marketing consent is shown as its own column because its absence is not consent. A customer list that does not surface it invites someone to export the lot.
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