HextorUI
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-edit

Source

"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