HextorUI
block

Dashboard — Products

Catalogue table with inventory, status and price range, plus search and status filters.

npx shadcn@latest add @hextor/store-dash-products

Source

"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