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-discountsSource
"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