block
Dashboard — Discount editor
Code, kind, value, minimum, usage limit and window, with a live preview against a sample cart.
npx shadcn@latest add @hextor/store-dash-discount-editSource
"use client";
import { useState } from "react";
import type { ReactNode } from "react";
import { Minus, Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
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 { TotalsSummary } from "@/registry/hextor/components/store/dashboard/totals-summary";
import { DiscountStatusBadge } from "@/registry/hextor/components/store/dashboard/discount-status-badge";
import { defaultLabels as dashboardLabels } from "@/registry/hextor/components/store/dashboard/labels";
import {
resolveDiscountsLabels,
type DiscountsLabelsOverrides,
} from "@/registry/hextor/components/store/dashboard/discounts-labels";
import { cn } from "@/registry/hextor/lib/utils";
import {
cartTotals,
formatMoney,
money,
subtotal as computeSubtotal,
type CurrencyCode,
type Discount,
type DiscountKind,
type Money,
} from "@/registry/hextor/lib/store";
/**
* A minor-unit divisor for editing money as a plain decimal, re-derived here
* rather than imported: `store.ts` keeps its currency → minor-units table
* private (only `formatMoney` needs it), and `product-edit-view.tsx` — the
* one other place that needs this exact fact — makes the same call for the
* same reason. Two four-line copies beat a shared export with one caller
* on each side of a boundary this task doesn't cross.
*/
function minorUnitsDivisor(currency: CurrencyCode): number {
return currency === "JPY" ? 1 : 100;
}
function amountToInputValue(amount: number, currency: CurrencyCode): string {
return (amount / minorUnitsDivisor(currency)).toString();
}
function inputValueToAmount(value: string, currency: CurrencyCode): number {
const parsed = Number.parseFloat(value);
return Math.round((Number.isFinite(parsed) ? parsed : 0) * minorUnitsDivisor(currency));
}
const KIND_OPTIONS: DiscountKind[] = ["percentage", "fixed_amount", "free_shipping"];
/** A sensible starting value whenever the merchant switches `kind` — a raw
* percentage number left over from "20" reads as "2000%" the moment the
* same field starts meaning minor units instead. */
function defaultValueForKind(kind: DiscountKind): number {
switch (kind) {
case "percentage":
return 10;
case "fixed_amount":
return 10000;
case "free_shipping":
return 0;
}
}
export interface SampleCartLine {
id: string;
title: string;
unitPrice: Money;
quantity: number;
}
const DEFAULT_SAMPLE_CART: SampleCartLine[] = [
{ id: "sample_1", title: "Sample item A", unitPrice: money(59000), quantity: 2 },
{ id: "sample_2", title: "Sample item B", unitPrice: money(129000), quantity: 1 },
];
/**
* The discount form: code, type, value, the two eligibility conditions
* (minimum subtotal, usage limit), the active window, and a toggle — next
* to a live preview that runs the *actual* draft through `cartTotals` /
* `discountIneligibility` against an editable sample cart. That reuse is
* the point: a merchant sees the same math a customer's cart would produce,
* not a description of it, so a 20%-off-with-a-฿1,000-minimum code proves
* out (or doesn't) before it is ever saved.
*/
export default function DiscountEditPage({
discount,
now,
sampleCart = DEFAULT_SAMPLE_CART,
currency = "THB",
backHref = "/dashboard/discounts",
onSave,
onCancel,
nav = DEFAULT_DASHBOARD_NAV,
brand,
activeHref = "/dashboard/discounts",
actions,
locale = "en-US",
labels,
}: {
discount: Discount;
now: Date;
/** The illustrative cart the preview runs the draft against — editable quantities, fixed lines. */
sampleCart?: SampleCartLine[];
currency?: CurrencyCode;
backHref?: string;
onSave?: (discount: Discount) => void;
onCancel?: () => void;
nav?: DashboardNavItem[];
brand?: DashboardBrand;
activeHref?: string;
actions?: ReactNode;
locale?: string;
labels?: DiscountsLabelsOverrides;
}) {
const copy = resolveDiscountsLabels(labels);
const [draft, setDraft] = useState<Discount>(discount);
const [quantities, setQuantities] = useState<Record<string, number>>(() =>
Object.fromEntries(sampleCart.map((line) => [line.id, line.quantity])),
);
// Cheap but honest at this scale, same call as `product-edit-view.tsx`.
const isDirty = JSON.stringify(draft) !== JSON.stringify(discount);
function updateDraft(patch: Partial<Discount>) {
setDraft((prev) => ({ ...prev, ...patch }));
}
function setQuantity(id: string, quantity: number) {
setQuantities((prev) => ({ ...prev, [id]: Math.max(0, quantity) }));
}
function handleSave() {
onSave?.(draft);
}
function handleCancel() {
setDraft(discount);
setQuantities(Object.fromEntries(sampleCart.map((line) => [line.id, line.quantity])));
onCancel?.();
}
const previewLines = sampleCart.map((line) => ({
unitPrice: line.unitPrice,
quantity: quantities[line.id] ?? line.quantity,
}));
const previewSubtotal = computeSubtotal(previewLines, currency);
const previewTotals = cartTotals(previewLines, currency, { discount: draft, shipping: money(0, currency), now });
return (
<DashboardShell brand={brand} nav={nav} activeHref={activeHref} actions={actions}>
<PageHeader
title={discount.code || "New discount"}
breadcrumb={[{ label: copy.edit.back, href: backHref }, { label: discount.code || "New discount" }]}
actions={
<span className={cn("text-xs font-medium", isDirty ? "text-brand" : "text-muted-foreground")}>
{isDirty ? dashboardLabels.common.unsavedChanges : dashboardLabels.common.allChangesSaved}
</span>
}
/>
<div className="grid grid-cols-1 gap-4 p-4 md:p-6 lg:grid-cols-3">
<div className="space-y-4 lg:col-span-2">
<Card>
<CardHeader>
<CardTitle>{copy.edit.sections.details}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="discount-code">{copy.edit.fields.code}</Label>
<Input
id="discount-code"
value={draft.code}
onChange={(e) => updateDraft({ code: e.target.value.toUpperCase() })}
className="uppercase"
/>
<p className="text-xs text-muted-foreground">{copy.edit.fields.codeHint}</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="discount-kind">{copy.edit.fields.kind}</Label>
<Select
value={draft.kind}
onValueChange={(value) => {
const kind = value as DiscountKind;
updateDraft({ kind, value: defaultValueForKind(kind) });
}}
>
<SelectTrigger id="discount-kind" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{KIND_OPTIONS.map((kind) => (
<SelectItem key={kind} value={kind}>
{copy.kinds[kind]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{draft.kind !== "free_shipping" ? (
<div className="space-y-1.5">
<Label htmlFor="discount-value">{copy.edit.fields.value}</Label>
<div className="flex items-center gap-2">
<Input
id="discount-value"
inputMode="decimal"
value={
draft.kind === "percentage"
? draft.value.toString()
: amountToInputValue(draft.value, currency)
}
onChange={(e) => {
if (draft.kind === "percentage") {
const pct = Number.parseFloat(e.target.value);
updateDraft({ value: Number.isFinite(pct) ? Math.min(100, Math.max(0, pct)) : 0 });
} else {
updateDraft({ value: inputValueToAmount(e.target.value, currency) });
}
}}
/>
{draft.kind === "percentage" ? (
<span className="text-sm text-muted-foreground">{copy.edit.fields.valueSuffixPercent}</span>
) : null}
</div>
</div>
) : null}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{copy.edit.sections.conditions}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="discount-minimum">{copy.edit.fields.minimumSubtotal}</Label>
<Input
id="discount-minimum"
inputMode="decimal"
value={draft.minimumSubtotal ? amountToInputValue(draft.minimumSubtotal.amount, currency) : ""}
onChange={(e) => {
const raw = e.target.value;
updateDraft({
minimumSubtotal: raw ? money(inputValueToAmount(raw, currency), currency) : undefined,
});
}}
/>
<p className="text-xs text-muted-foreground">{copy.edit.fields.minimumSubtotalHint}</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="discount-usage-limit">{copy.edit.fields.usageLimit}</Label>
<Input
id="discount-usage-limit"
type="number"
inputMode="numeric"
min={0}
value={draft.usageLimit ?? ""}
onChange={(e) => {
const raw = e.target.value;
updateDraft({ usageLimit: raw ? Math.max(0, Number.parseInt(raw, 10) || 0) : undefined });
}}
/>
<p className="text-xs text-muted-foreground">{copy.edit.fields.usageLimitHint}</p>
</div>
</div>
<div className="flex items-center gap-2">
<Switch
id="discount-active"
checked={draft.active}
onCheckedChange={(checked) => updateDraft({ active: checked })}
/>
<div>
<Label htmlFor="discount-active" className="font-normal">
{copy.edit.fields.active}
</Label>
<p className="text-xs text-muted-foreground">{copy.edit.fields.activeHint}</p>
</div>
</div>
</CardContent>
</Card>
</div>
<div className="space-y-4 lg:sticky lg:top-16 lg:self-start">
<Card>
<CardHeader>
<CardTitle>{copy.edit.sections.window}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="discount-starts">{copy.edit.fields.startsAt}</Label>
<Input
id="discount-starts"
type="date"
value={draft.startsAt.slice(0, 10)}
onChange={(e) => updateDraft({ startsAt: new Date(e.target.value).toISOString() })}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="discount-ends">{copy.edit.fields.endsAt}</Label>
<Input
id="discount-ends"
type="date"
value={draft.endsAt ? draft.endsAt.slice(0, 10) : ""}
onChange={(e) => {
const raw = e.target.value;
updateDraft({ endsAt: raw ? new Date(raw).toISOString() : undefined });
}}
/>
<p className="text-xs text-muted-foreground">{copy.edit.fields.endsAtHint}</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{copy.edit.sections.preview}</CardTitle>
<CardDescription>{copy.edit.preview.description}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2.5">
<p className="text-xs font-medium text-muted-foreground">{copy.edit.preview.sampleCartTitle}</p>
{sampleCart.map((line) => {
const quantity = quantities[line.id] ?? line.quantity;
return (
<div key={line.id} className="flex items-center justify-between gap-2 text-sm">
<div className="min-w-0 flex-1">
<p className="truncate">{line.title}</p>
<p className="tnum text-xs text-muted-foreground">{formatMoney(line.unitPrice, locale)}</p>
</div>
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
aria-label={`Decrease ${copy.edit.preview.quantityLabel}`}
onClick={() => setQuantity(line.id, quantity - 1)}
className="flex size-6 items-center justify-center rounded-md border text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-brand-ring"
>
<Minus className="size-3" aria-hidden />
</button>
<span className="tnum w-5 text-center text-sm">{quantity}</span>
<button
type="button"
aria-label={`Increase ${copy.edit.preview.quantityLabel}`}
onClick={() => setQuantity(line.id, quantity + 1)}
className="flex size-6 items-center justify-center rounded-md border text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-brand-ring"
>
<Plus className="size-3" aria-hidden />
</button>
</div>
</div>
);
})}
</div>
<div className="flex items-center justify-between border-t pt-3 text-sm">
<span className="text-muted-foreground">{copy.edit.preview.subtotalLabel}</span>
<span className="tnum font-medium">{formatMoney(previewSubtotal, locale)}</span>
</div>
<DiscountStatusBadge discount={draft} subtotal={previewSubtotal} now={now} locale={locale} />
<TotalsSummary totals={previewTotals} locale={locale} className="border-t pt-3" />
</CardContent>
</Card>
</div>
</div>
{isDirty ? (
<div className="sticky bottom-0 z-20 flex items-center justify-between gap-3 border-t bg-background/95 px-4 py-3 backdrop-blur supports-backdrop-filter:bg-background/80 md:px-6">
<p className="text-sm text-muted-foreground">{dashboardLabels.common.unsavedChanges}</p>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={handleCancel}>
{copy.edit.cancel}
</Button>
<Button size="sm" onClick={handleSave} className="bg-brand text-brand-foreground hover:bg-brand/90">
{copy.edit.save}
</Button>
</div>
</div>
) : null}
</DashboardShell>
);
}
Docs
The preview exists because a discount that behaves differently from what was intended is otherwise discovered by customers. Set 20% off with a minimum and you see the resulting cart before saving.
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