HextorUI
block

Dashboard — Customers

Customer table with orders count, total spent and marketing consent.

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

Source

"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