HextorUI
block

Dashboard — Discounts

Codes with usage against limit, the active window, and the reason a code is not applying.

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

Source

"use client";

import { useMemo, useState } from "react";
import type { ReactNode } from "react";
import Link from "next/link";
import { Plus } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
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 { DiscountStatusBadge } from "@/registry/hextor/components/store/dashboard/discount-status-badge";
import { EmptyState } from "@/registry/hextor/components/store/dashboard/empty-state";
import { defaultLabels as dashboardLabels } from "@/registry/hextor/components/store/dashboard/labels";
import {
  resolveDiscountsLabels,
  type DiscountsLabelsOverrides,
} from "@/registry/hextor/components/store/dashboard/discounts-labels";
import { discountIneligibility, formatMoney, money, type Discount, type DiscountKind } from "@/registry/hextor/lib/store";

type SortColumn = "code" | "kind" | "value" | "usage" | "window";

/**
 * The cart-independent reading of a discount's state — see
 * `discount-status-badge.tsx`'s file header. Evaluated against the
 * discount's own minimum (or zero) so a row never reads "ineligible" just
 * because no particular cart is being checked against it.
 */
function timeAndUsageReason(discount: Discount, now: Date) {
  return discountIneligibility(discount, discount.minimumSubtotal ?? money(0), now);
}

type DiscountView = "all" | "active" | "scheduled" | "inactive";

function matchesView(discount: Discount, view: DiscountView, now: Date): boolean {
  const reason = timeAndUsageReason(discount, now);
  switch (view) {
    case "all":
      return true;
    case "active":
      return reason === null;
    case "scheduled":
      return reason === "not_started";
    case "inactive":
      return reason === "expired" || reason === "usage_limit_reached" || reason === "inactive";
  }
}

function compareDiscounts(a: Discount, b: Discount, columnId: string): number {
  switch (columnId as SortColumn) {
    case "code":
      return a.code.localeCompare(b.code);
    case "kind":
      return a.kind.localeCompare(b.kind);
    case "value":
      return a.value - b.value;
    case "usage":
      return a.usageCount - b.usageCount;
    case "window":
      return a.startsAt.localeCompare(b.startsAt);
    default:
      return 0;
  }
}

const REQUIRED_COLUMN_IDS = ["code", "status"];
const KIND_OPTIONS: DiscountKind[] = ["percentage", "fixed_amount", "free_shipping"];

/**
 * The discount list: saved views by cart-independent state, a type filter,
 * search, sortable columns, column visibility and density — the same
 * mechanics as `orders-view.tsx`, applied to `Discount` instead of `Order`.
 * `now` is a prop rather than `new Date()` in render, because the whole
 * "status" column is a function of the clock — see `discountIneligibility`
 * in store.ts.
 */
export default function DiscountsPage({
  discounts,
  now,
  discountHref,
  onAddDiscount,
  nav = DEFAULT_DASHBOARD_NAV,
  brand,
  activeHref = "/dashboard/discounts",
  actions,
  locale = "en-US",
  labels,
}: {
  discounts: Discount[];
  now: Date;
  discountHref?: (discount: Discount) => string;
  onAddDiscount?: () => void;
  nav?: DashboardNavItem[];
  brand?: DashboardBrand;
  activeHref?: string;
  actions?: ReactNode;
  locale?: string;
  labels?: DiscountsLabelsOverrides;
}) {
  const copy = resolveDiscountsLabels(labels);
  const [search, setSearch] = useState("");
  const [kindFilter, setKindFilter] = useState<"all" | DiscountKind>("all");
  const [view, setView] = useState<DiscountView>("all");
  const [sort, setSort] = useState<DataTableSort>({ columnId: "window", 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 discounts.filter((discount) => {
      const matchesQuery = query.length === 0 || discount.code.toLowerCase().includes(query);
      const matchesKind = kindFilter === "all" || discount.kind === kindFilter;
      return matchesQuery && matchesKind;
    });
  }, [discounts, search, kindFilter]);

  const views: SavedView[] = (["all", "active", "scheduled", "inactive"] as DiscountView[]).map((id) => ({
    id,
    label: id === "all" ? dashboardLabels.common.allViews : copy.views[id],
    count: preViewFiltered.filter((d) => matchesView(d, id, now)).length,
  }));

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

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

  function describeValue(discount: Discount): string {
    switch (discount.kind) {
      case "percentage":
        return copy.value.percentage(discount.value);
      case "fixed_amount":
        return copy.value.fixedAmount(formatMoney(money(discount.value), locale));
      case "free_shipping":
        return copy.value.freeShipping;
    }
  }

  const allColumns: DataTableColumn<Discount>[] = [
    {
      id: "code",
      header: copy.columns.code,
      sortable: true,
      cell: (discount) =>
        discountHref ? (
          <Link href={discountHref(discount)} className="font-medium text-brand hover:underline">
            {discount.code}
          </Link>
        ) : (
          <span className="font-medium">{discount.code}</span>
        ),
    },
    {
      id: "kind",
      header: copy.columns.type,
      sortable: true,
      cell: (discount) => <Badge variant="outline">{copy.kinds[discount.kind]}</Badge>,
    },
    {
      id: "value",
      header: copy.columns.value,
      sortable: true,
      cell: (discount) => <span className="tnum">{describeValue(discount)}</span>,
    },
    {
      id: "usage",
      header: copy.columns.usage,
      sortable: true,
      cell: (discount) =>
        discount.usageLimit === undefined ? (
          <span className="tnum text-muted-foreground">
            {copy.usage.unlimited} · {discount.usageCount.toLocaleString(locale)}
          </span>
        ) : (
          <div className="flex w-32 flex-col gap-1">
            <span className="tnum text-xs text-muted-foreground">
              {copy.usage.ofLimit(discount.usageCount, discount.usageLimit)}
            </span>
            <Progress value={Math.min(100, (discount.usageCount / discount.usageLimit) * 100)} className="h-1.5" />
          </div>
        ),
    },
    {
      id: "window",
      header: copy.columns.window,
      sortable: true,
      className: "tnum text-muted-foreground",
      cell: (discount) =>
        discount.endsAt
          ? copy.window.ranged(
              new Date(discount.startsAt).toLocaleDateString(locale),
              new Date(discount.endsAt).toLocaleDateString(locale),
            )
          : copy.window.ongoing(new Date(discount.startsAt).toLocaleDateString(locale)),
    },
    {
      id: "status",
      header: copy.columns.status,
      cell: (discount) => <DiscountStatusBadge discount={discount} now={now} locale={locale} />,
    },
  ];

  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.title}
        description={copy.description}
        actions={
          <Button size="sm" onClick={onAddDiscount} className="bg-brand text-brand-foreground hover:bg-brand/90">
            <Plus />
            {copy.addDiscount}
          </Button>
        }
      />

      <div className="p-4 md:p-6">
        <DataGridCard
          views={<SavedViews views={views} value={view} onChange={(id) => setView(id as DiscountView)} />}
          toolbar={
            <FilterBar
              searchValue={search}
              onSearchChange={setSearch}
              searchPlaceholder={dashboardLabels.common.search}
              selects={[
                {
                  id: "kind",
                  label: copy.filters.kind,
                  value: kindFilter,
                  onChange: (v) => setKindFilter(v as "all" | DiscountKind),
                  options: [
                    { value: "all", label: copy.filters.allTypes },
                    ...KIND_OPTIONS.map((kind) => ({ value: kind, label: copy.kinds[kind] })),
                  ],
                },
              ]}
              actions={
                <>
                  <ColumnVisibilityMenu
                    columns={allColumns}
                    hiddenIds={hiddenColumnIds}
                    onVisibilityChange={toggleColumn}
                    requiredIds={REQUIRED_COLUMN_IDS}
                    label={dashboardLabels.common.columns}
                  />
                  <DensityToggle
                    value={density}
                    onChange={setDensity}
                    labels={{ comfortable: dashboardLabels.common.densityComfortable, compact: dashboardLabels.common.densityCompact }}
                  />
                </>
              }
            />
          }
          footer={<span className="tnum">{dashboardLabels.common.rowsShown(filtered.length, discounts.length)}</span>}
        >
          <DataTable
            columns={columns}
            rows={filtered}
            rowKey={(discount) => discount.id}
            sort={sort}
            onSortChange={handleSortChange}
            density={density}
            stickyHeader
            emptyState={<EmptyState title={copy.empty.title} description={copy.empty.description} className="border-none" />}
          />
        </DataGridCard>
      </div>
    </DashboardShell>
  );
}

Docs

State is computed, not stored. @hextor/store-model's discountIneligibility returns WHY a code does not apply — not started, expired, usage limit reached, below minimum — and the row shows that reason rather than a bare inactive chip. The point of that function is that why beats a boolean.

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