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, type TableDensity } from "@/registry/hextor/components/store/dashboard/data-table";
import {
  ColumnVisibilityMenu,
  DataGridCard,
  DensityToggle,
  SavedViews,
  type SavedView,
} from "@/registry/hextor/components/store/dashboard/data-grid";
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";
type CustomerView = "all" | "subscribed" | "not_subscribed";

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;
  }
}

function matchesView(customer: Customer, view: CustomerView): boolean {
  switch (view) {
    case "all":
      return true;
    case "subscribed":
      return customer.acceptsMarketing;
    case "not_subscribed":
      return !customer.acceptsMarketing;
  }
}

const REQUIRED_COLUMN_IDS = ["name", "spent"];

/** The customer list: saved views by marketing consent, search by
 *  name/email, sortable columns, column visibility and density. */
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 [view, setView] = useState<CustomerView>("all");
  const [sort, setSort] = useState<DataTableSort>({ columnId: "spent", direction: "desc" });
  const [density, setDensity] = useState<TableDensity>("comfortable");
  const [hiddenColumnIds, setHiddenColumnIds] = useState<Set<string>>(new Set());

  const preViewFiltered = useMemo(() => {
    const query = search.trim().toLowerCase();
    return customers.filter(
      (customer) =>
        query.length === 0 ||
        customer.name.toLowerCase().includes(query) ||
        customer.email.toLowerCase().includes(query),
    );
  }, [customers, search]);

  const views: SavedView[] = (["all", "subscribed", "not_subscribed"] as CustomerView[]).map((id) => ({
    id,
    label:
      id === "all"
        ? copy.common.allViews
        : id === "subscribed"
          ? copy.customers.marketing.subscribed
          : copy.customers.marketing.notSubscribed,
    count: preViewFiltered.filter((c) => matchesView(c, id)).length,
  }));

  const filtered = useMemo(() => {
    const rows = preViewFiltered.filter((c) => matchesView(c, view));
    const sorted = [...rows].sort((a, b) => compareCustomers(a, b, sort.columnId));
    return sort.direction === "asc" ? sorted : sorted.reverse();
  }, [preViewFiltered, view, sort]);

  function handleSortChange(columnId: string) {
    setSort((prev) =>
      prev.columnId === columnId ? { columnId, direction: prev.direction === "asc" ? "desc" : "asc" } : { columnId, direction: "asc" },
    );
  }

  const allColumns: 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="text-brand 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>
      ),
    },
  ];

  const columns = allColumns.filter((col) => !hiddenColumnIds.has(col.id));

  function toggleColumn(id: string, visible: boolean) {
    setHiddenColumnIds((prev) => {
      const next = new Set(prev);
      if (visible) next.delete(id);
      else next.add(id);
      return next;
    });
  }

  return (
    <DashboardShell brand={brand} nav={nav} activeHref={activeHref} actions={actions}>
      <PageHeader title={copy.customers.title} description={copy.customers.description} />

      <div className="p-4 md:p-6">
        <DataGridCard
          views={<SavedViews views={views} value={view} onChange={(id) => setView(id as CustomerView)} />}
          toolbar={
            <FilterBar
              searchValue={search}
              onSearchChange={setSearch}
              searchPlaceholder={copy.common.search}
              actions={
                <>
                  <ColumnVisibilityMenu
                    columns={allColumns}
                    hiddenIds={hiddenColumnIds}
                    onVisibilityChange={toggleColumn}
                    requiredIds={REQUIRED_COLUMN_IDS}
                    label={copy.common.columns}
                  />
                  <DensityToggle
                    value={density}
                    onChange={setDensity}
                    labels={{ comfortable: copy.common.densityComfortable, compact: copy.common.densityCompact }}
                  />
                </>
              }
            />
          }
          footer={<span className="tnum">{copy.common.rowsShown(filtered.length, customers.length)}</span>}
        >
          <DataTable
            columns={columns}
            rows={filtered}
            rowKey={(customer) => customer.id}
            sort={sort}
            onSortChange={handleSortChange}
            density={density}
            stickyHeader
            emptyState={
              <EmptyState
                title={copy.emptyStates.customersTitle}
                description={copy.emptyStates.customersDescription}
                className="border-none"
              />
            }
          />
        </DataGridCard>
      </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