block
Dashboard — Products
Catalogue table with inventory, status and price range, plus search and status filters.
npx shadcn@latest add @hextor/store-dash-productsSource
"use client";
import { useMemo, useState } from "react";
import type { ReactNode } from "react";
import Image from "next/image";
import Link from "next/link";
import { Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
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 { ProductStatusBadge } from "@/registry/hextor/components/store/dashboard/product-status-badge";
import { EmptyState } from "@/registry/hextor/components/store/dashboard/empty-state";
import { resolveLabels, type DashboardLabelsOverrides } from "@/registry/hextor/components/store/dashboard/labels";
import { formatMoney, priceRange, type Product, type ProductStatus } from "@/registry/hextor/lib/store";
type SortColumn = "title" | "status" | "inventory" | "price";
function totalInventory(product: Product): number {
return product.variants.reduce((sum, v) => sum + v.inventoryQuantity, 0);
}
function compareProducts(a: Product, b: Product, columnId: string): number {
switch (columnId as SortColumn) {
case "title":
return a.title.localeCompare(b.title);
case "status":
return a.status.localeCompare(b.status);
case "inventory":
return totalInventory(a) - totalInventory(b);
case "price":
return priceRange(a).min.amount - priceRange(b).min.amount;
default:
return 0;
}
}
/** The catalogue table: search, status filter, sortable columns, no selection —
* bulk product actions aren't in scope here, unlike the orders page. */
export default function ProductsPage({
products,
productHref,
onAddProduct,
nav = DEFAULT_DASHBOARD_NAV,
brand,
activeHref = "/dashboard/products",
actions,
locale = "en-US",
labels,
}: {
products: Product[];
productHref?: (product: Product) => string;
onAddProduct?: () => void;
nav?: DashboardNavItem[];
brand?: DashboardBrand;
activeHref?: string;
actions?: ReactNode;
locale?: string;
labels?: DashboardLabelsOverrides;
}) {
const copy = resolveLabels(labels);
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<"all" | ProductStatus>("all");
const [sort, setSort] = useState<DataTableSort>({ columnId: "title", direction: "asc" });
const filtered = useMemo(() => {
const query = search.trim().toLowerCase();
const rows = products.filter((product) => {
const matchesQuery =
query.length === 0 ||
product.title.toLowerCase().includes(query) ||
product.vendor.toLowerCase().includes(query) ||
product.variants.some((v) => v.sku.toLowerCase().includes(query));
const matchesStatus = statusFilter === "all" || product.status === statusFilter;
return matchesQuery && matchesStatus;
});
const sorted = [...rows].sort((a, b) => compareProducts(a, b, sort.columnId));
return sort.direction === "asc" ? sorted : sorted.reverse();
}, [products, search, statusFilter, sort]);
function handleSortChange(columnId: string) {
setSort((prev) =>
prev.columnId === columnId ? { columnId, direction: prev.direction === "asc" ? "desc" : "asc" } : { columnId, direction: "asc" },
);
}
const columns: DataTableColumn<Product>[] = [
{
id: "title",
header: copy.products.columns.product,
sortable: true,
cell: (product) => {
const image = product.images[0];
const content = (
<div className="flex items-center gap-3">
<div className="relative size-10 shrink-0 overflow-hidden rounded-md bg-muted ring-1 ring-foreground/10">
{image ? <Image src={image.url} alt={image.alt} fill sizes="40px" className="object-cover" /> : null}
</div>
<div className="min-w-0">
<p className="truncate text-sm font-medium">{product.title}</p>
<p className="truncate text-xs text-muted-foreground">
{product.vendor} · {copy.products.variantsCount(product.variants.length)}
</p>
</div>
</div>
);
return productHref ? (
<Link href={productHref(product)} className="hover:opacity-80">
{content}
</Link>
) : (
content
);
},
},
{
id: "status",
header: copy.products.columns.status,
sortable: true,
cell: (product) => <ProductStatusBadge status={product.status} label={copy.statuses.product[product.status]} />,
},
{
id: "inventory",
header: copy.products.columns.inventory,
sortable: true,
align: "right",
className: "tnum",
cell: (product) => copy.products.inventoryUnits(totalInventory(product)),
},
{
id: "price",
header: copy.products.columns.price,
sortable: true,
align: "right",
className: "tnum",
cell: (product) => {
const { min, max } = priceRange(product);
return min.amount === max.amount
? formatMoney(min, locale)
: `${formatMoney(min, locale)} – ${formatMoney(max, locale)}`;
},
},
];
return (
<DashboardShell brand={brand} nav={nav} activeHref={activeHref} actions={actions}>
<PageHeader
title={copy.products.title}
description={copy.products.description}
actions={
<Button size="sm" onClick={onAddProduct}>
<Plus />
{copy.products.addProduct}
</Button>
}
/>
<div className="space-y-4 p-4 md:p-6">
<FilterBar
searchValue={search}
onSearchChange={setSearch}
searchPlaceholder={copy.common.search}
selects={[
{
id: "status",
label: copy.products.filters.status,
value: statusFilter,
onChange: (v) => setStatusFilter(v as "all" | ProductStatus),
options: [
{ value: "all", label: copy.common.allStatuses },
...(Object.keys(copy.statuses.product) as ProductStatus[]).map((status) => ({
value: status,
label: copy.statuses.product[status],
})),
],
},
]}
/>
<div className="rounded-lg border">
<DataTable
columns={columns}
rows={filtered}
rowKey={(product) => product.id}
sort={sort}
onSortChange={handleSortChange}
emptyState={
<EmptyState
title={copy.emptyStates.productsTitle}
description={copy.emptyStates.productsDescription}
className="m-4 border-none"
/>
}
/>
</div>
</div>
</DashboardShell>
);
}
Docs
Price is shown as a range rather than a number: once a product has options there is no single "the" price, and picking one variant's to display is a lie that only shows up in a customer complaint.
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