HextorUI
block

Dashboard — Settings

Store details, currency, checkout, shipping zones, payment methods, notifications and staff.

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

Source

"use client";

import { useState } from "react";
import type { ReactNode } from "react";
import { Plus, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
  DEFAULT_DASHBOARD_NAV,
  DashboardShell,
  type DashboardBrand,
  type DashboardNavItem,
} from "@/registry/hextor/components/store/dashboard/dashboard-shell";
import { PageHeader } from "@/registry/hextor/components/store/dashboard/page-header";
import { SettingsSection, SettingsRow } from "@/registry/hextor/components/store/dashboard/settings-section";
import { SettingsConfirmDialog } from "@/registry/hextor/components/store/dashboard/settings-confirm-dialog";
import { resolveLabels, type SettingsLabelsOverrides } from "@/registry/hextor/components/store/dashboard/settings-labels";
import { VerdictCallout } from "@/registry/hextor/ui/verdict-badge";
import { cn } from "@/registry/hextor/lib/utils";
import { money, type Address, type CurrencyCode, type Money } from "@/registry/hextor/lib/store";

// ─────────────────────────── the settings shape ───────────────────────────
// No domain type in store.ts covers store configuration — orders, products
// and customers are the storefront's data, this is the merchant's. Defined
// here, next to the one view that edits it, the same way `orders-view.tsx`
// owns its local `OrderView` union rather than pushing a single-consumer
// type into the shared model.

export interface StoreDetailsSettings {
  name: string;
  supportEmail: string;
  supportPhone?: string;
  address: Address;
}

export interface CurrencyFormatSettings {
  currency: CurrencyCode;
  /** BCP-47 locale used for number/date formatting, e.g. "th-TH". */
  locale: string;
  weightUnit: "kg" | "lb";
}

export interface CheckoutSettings {
  guestCheckoutEnabled: boolean;
  requirePhone: boolean;
  /** Whether the marketing opt-in checkbox at checkout starts ticked. See the section below — this is deliberately not a bare boolean toggle in the UI. */
  marketingConsentDefault: boolean;
}

export interface ShippingRate {
  id: string;
  name: string;
  price: Money;
}

export interface ShippingZone {
  id: string;
  name: string;
  countries: string[];
  rates: ShippingRate[];
}

export type PaymentMethodId = "card" | "bank_transfer" | "cod" | "promptpay";

export interface PaymentMethodSetting {
  id: PaymentMethodId;
  enabled: boolean;
}

export interface NotificationSettings {
  orderConfirmationEmail: boolean;
  shippingUpdateEmail: boolean;
  lowStockAlertEmail: boolean;
  lowStockThreshold: number;
}

export type StaffRole = "owner" | "admin" | "staff";

export interface StaffMember {
  id: string;
  name: string;
  email: string;
  role: StaffRole;
}

export interface StoreSettings {
  details: StoreDetailsSettings;
  currency: CurrencyFormatSettings;
  checkout: CheckoutSettings;
  shippingZones: ShippingZone[];
  paymentMethods: PaymentMethodSetting[];
  notifications: NotificationSettings;
  staff: StaffMember[];
}

const DEFAULT_CURRENCY_OPTIONS: CurrencyCode[] = ["THB", "USD", "EUR", "GBP", "JPY", "SGD"];
const DEFAULT_PAYMENT_METHOD_OPTIONS: PaymentMethodId[] = ["card", "bank_transfer", "cod", "promptpay"];
const DEFAULT_STAFF_ROLE_OPTIONS: StaffRole[] = ["owner", "admin", "staff"];

/** Minor-unit divisor for editing a price as a plain decimal input — see `product-edit-view.tsx`, which re-derives the same single fact for the same reason: `store.ts` keeps its full currency table private. */
function minorUnitsDivisor(currency: string): number {
  return currency === "JPY" ? 1 : 100;
}
function amountToInputValue(amount: number, currency: string): string {
  return (amount / minorUnitsDivisor(currency)).toString();
}
function inputValueToAmount(value: string, currency: string): number {
  const parsed = Number.parseFloat(value);
  return Math.round((Number.isFinite(parsed) ? parsed : 0) * minorUnitsDivisor(currency));
}

function newId(prefix: string): string {
  return `${prefix}_${Math.random().toString(36).slice(2, 9)}`;
}

type ConfirmTarget = { kind: "zone"; id: string } | { kind: "staff"; id: string };

/**
 * The Settings page: seven sections, one draft, one sticky save bar that
 * only shows once the draft differs from `settings` — the same dirty-check
 * shape as `product-edit-view.tsx`. Deleting a shipping zone or removing a
 * staff member's access always asks first, naming exactly what will happen
 * (see `SettingsConfirmDialog`); confirming stages the removal into the
 * draft, so it still only takes effect when the page is saved like every
 * other change here.
 */
export default function SettingsView({
  settings,
  onSave,
  onCancel,
  currencyOptions = DEFAULT_CURRENCY_OPTIONS,
  paymentMethodOptions = DEFAULT_PAYMENT_METHOD_OPTIONS,
  staffRoleOptions = DEFAULT_STAFF_ROLE_OPTIONS,
  nav = DEFAULT_DASHBOARD_NAV,
  brand,
  activeHref = "/dashboard/settings",
  actions,
  labels,
}: {
  settings: StoreSettings;
  onSave?: (settings: StoreSettings) => void;
  onCancel?: () => void;
  currencyOptions?: CurrencyCode[];
  paymentMethodOptions?: PaymentMethodId[];
  staffRoleOptions?: StaffRole[];
  nav?: DashboardNavItem[];
  brand?: DashboardBrand;
  activeHref?: string;
  actions?: ReactNode;
  labels?: SettingsLabelsOverrides;
}) {
  const copy = resolveLabels(labels);
  const [draft, setDraft] = useState<StoreSettings>(settings);
  const [confirmTarget, setConfirmTarget] = useState<ConfirmTarget | null>(null);
  const [newStaffName, setNewStaffName] = useState("");
  const [newStaffEmail, setNewStaffEmail] = useState("");
  const [newStaffRole, setNewStaffRole] = useState<StaffRole>(staffRoleOptions[staffRoleOptions.length - 1] ?? "staff");

  const isDirty = JSON.stringify(draft) !== JSON.stringify(settings);

  function handleSave() {
    // Empty country tokens (a trailing "Thailand, ") are only cleaned up
    // here, not while typing — trimming on every keystroke is what makes a
    // comma-separated field fight the cursor mid-word.
    onSave?.({
      ...draft,
      shippingZones: draft.shippingZones.map((zone) => ({
        ...zone,
        countries: zone.countries.map((c) => c.trim()).filter(Boolean),
      })),
    });
  }

  function handleCancel() {
    setDraft(settings);
    onCancel?.();
  }

  function updateZone(zoneId: string, patch: Partial<ShippingZone>) {
    setDraft((prev) => ({
      ...prev,
      shippingZones: prev.shippingZones.map((z) => (z.id === zoneId ? { ...z, ...patch } : z)),
    }));
  }

  function updateRate(zoneId: string, rateId: string, patch: Partial<ShippingRate>) {
    setDraft((prev) => ({
      ...prev,
      shippingZones: prev.shippingZones.map((z) =>
        z.id === zoneId ? { ...z, rates: z.rates.map((r) => (r.id === rateId ? { ...r, ...patch } : r)) } : z,
      ),
    }));
  }

  function addZone() {
    const currency = draft.currency.currency;
    setDraft((prev) => ({
      ...prev,
      shippingZones: [
        ...prev.shippingZones,
        {
          id: newId("zone"),
          name: "",
          countries: [],
          rates: [{ id: newId("rate"), name: "Standard", price: money(0, currency) }],
        },
      ],
    }));
  }

  function addRate(zoneId: string) {
    const currency = draft.currency.currency;
    updateZone(zoneId, {
      rates: [
        ...(draft.shippingZones.find((z) => z.id === zoneId)?.rates ?? []),
        { id: newId("rate"), name: "", price: money(0, currency) },
      ],
    });
  }

  function removeRate(zoneId: string, rateId: string) {
    updateZone(zoneId, {
      rates: (draft.shippingZones.find((z) => z.id === zoneId)?.rates ?? []).filter((r) => r.id !== rateId),
    });
  }

  function removeZone(zoneId: string) {
    setDraft((prev) => ({ ...prev, shippingZones: prev.shippingZones.filter((z) => z.id !== zoneId) }));
  }

  function togglePaymentMethod(id: PaymentMethodId, enabled: boolean) {
    setDraft((prev) => ({
      ...prev,
      paymentMethods: prev.paymentMethods.map((m) => (m.id === id ? { ...m, enabled } : m)),
    }));
  }

  function removeStaff(staffId: string) {
    setDraft((prev) => ({ ...prev, staff: prev.staff.filter((s) => s.id !== staffId) }));
  }

  function addStaff() {
    if (!newStaffName.trim() || !newStaffEmail.trim()) return;
    setDraft((prev) => ({
      ...prev,
      staff: [...prev.staff, { id: newId("staff"), name: newStaffName.trim(), email: newStaffEmail.trim(), role: newStaffRole }],
    }));
    setNewStaffName("");
    setNewStaffEmail("");
  }

  const confirmZone = confirmTarget?.kind === "zone" ? draft.shippingZones.find((z) => z.id === confirmTarget.id) : undefined;
  const confirmStaff = confirmTarget?.kind === "staff" ? draft.staff.find((s) => s.id === confirmTarget.id) : undefined;

  return (
    <DashboardShell brand={brand} nav={nav} activeHref={activeHref} actions={actions}>
      <PageHeader
        title={copy.title}
        description={copy.description}
        actions={
          <span className={cn("text-xs font-medium", isDirty ? "text-brand" : "text-muted-foreground")}>
            {isDirty ? copy.common.unsavedChanges : copy.common.allChangesSaved}
          </span>
        }
      />

      <div className="mx-auto max-w-3xl space-y-4 p-4 md:p-6">
        {/* Store details */}
        <SettingsSection title={copy.storeDetails.sectionTitle} description={copy.storeDetails.sectionDescription}>
          <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
            <div className="space-y-1.5 sm:col-span-2">
              <Label htmlFor="settings-store-name">{copy.storeDetails.fields.name}</Label>
              <Input
                id="settings-store-name"
                value={draft.details.name}
                onChange={(e) => setDraft((p) => ({ ...p, details: { ...p.details, name: e.target.value } }))}
              />
            </div>
            <div className="space-y-1.5">
              <Label htmlFor="settings-support-email">{copy.storeDetails.fields.supportEmail}</Label>
              <Input
                id="settings-support-email"
                type="email"
                value={draft.details.supportEmail}
                onChange={(e) => setDraft((p) => ({ ...p, details: { ...p.details, supportEmail: e.target.value } }))}
              />
            </div>
            <div className="space-y-1.5">
              <Label htmlFor="settings-support-phone">{copy.storeDetails.fields.supportPhone}</Label>
              <Input
                id="settings-support-phone"
                value={draft.details.supportPhone ?? ""}
                onChange={(e) => setDraft((p) => ({ ...p, details: { ...p.details, supportPhone: e.target.value } }))}
              />
            </div>
            <div className="space-y-1.5 sm:col-span-2">
              <Label htmlFor="settings-address-line1">{copy.storeDetails.fields.addressLine1}</Label>
              <Input
                id="settings-address-line1"
                value={draft.details.address.line1}
                onChange={(e) =>
                  setDraft((p) => ({ ...p, details: { ...p.details, address: { ...p.details.address, line1: e.target.value } } }))
                }
              />
            </div>
            <div className="space-y-1.5 sm:col-span-2">
              <Label htmlFor="settings-address-line2">{copy.storeDetails.fields.addressLine2}</Label>
              <Input
                id="settings-address-line2"
                value={draft.details.address.line2 ?? ""}
                onChange={(e) =>
                  setDraft((p) => ({ ...p, details: { ...p.details, address: { ...p.details.address, line2: e.target.value } } }))
                }
              />
            </div>
            <div className="space-y-1.5">
              <Label htmlFor="settings-address-city">{copy.storeDetails.fields.city}</Label>
              <Input
                id="settings-address-city"
                value={draft.details.address.city}
                onChange={(e) =>
                  setDraft((p) => ({ ...p, details: { ...p.details, address: { ...p.details.address, city: e.target.value } } }))
                }
              />
            </div>
            <div className="space-y-1.5">
              <Label htmlFor="settings-address-province">{copy.storeDetails.fields.province}</Label>
              <Input
                id="settings-address-province"
                value={draft.details.address.province ?? ""}
                onChange={(e) =>
                  setDraft((p) => ({ ...p, details: { ...p.details, address: { ...p.details.address, province: e.target.value } } }))
                }
              />
            </div>
            <div className="space-y-1.5">
              <Label htmlFor="settings-address-postal">{copy.storeDetails.fields.postalCode}</Label>
              <Input
                id="settings-address-postal"
                value={draft.details.address.postalCode}
                onChange={(e) =>
                  setDraft((p) => ({ ...p, details: { ...p.details, address: { ...p.details.address, postalCode: e.target.value } } }))
                }
              />
            </div>
            <div className="space-y-1.5">
              <Label htmlFor="settings-address-country">{copy.storeDetails.fields.country}</Label>
              <Input
                id="settings-address-country"
                value={draft.details.address.country}
                onChange={(e) =>
                  setDraft((p) => ({ ...p, details: { ...p.details, address: { ...p.details.address, country: e.target.value } } }))
                }
              />
            </div>
            <div className="space-y-1.5">
              <Label htmlFor="settings-address-phone">{copy.storeDetails.fields.addressPhone}</Label>
              <Input
                id="settings-address-phone"
                value={draft.details.address.phone ?? ""}
                onChange={(e) =>
                  setDraft((p) => ({ ...p, details: { ...p.details, address: { ...p.details.address, phone: e.target.value } } }))
                }
              />
            </div>
          </div>
        </SettingsSection>

        {/* Currency and formatting */}
        <SettingsSection title={copy.currency.sectionTitle} description={copy.currency.sectionDescription}>
          <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
            <div className="space-y-1.5">
              <Label htmlFor="settings-currency">{copy.currency.fields.currency}</Label>
              <Select
                value={draft.currency.currency}
                onValueChange={(v) => {
                  if (typeof v === "string") setDraft((p) => ({ ...p, currency: { ...p.currency, currency: v as CurrencyCode } }));
                }}
              >
                <SelectTrigger id="settings-currency" className="w-full">
                  <SelectValue />
                </SelectTrigger>
                <SelectContent>
                  {currencyOptions.map((c) => (
                    <SelectItem key={c} value={c}>
                      {c}
                    </SelectItem>
                  ))}
                </SelectContent>
              </Select>
            </div>
            <div className="space-y-1.5">
              <Label htmlFor="settings-weight-unit">{copy.currency.fields.weightUnit}</Label>
              <Select
                value={draft.currency.weightUnit}
                onValueChange={(v) => {
                  if (typeof v === "string") setDraft((p) => ({ ...p, currency: { ...p.currency, weightUnit: v as "kg" | "lb" } }));
                }}
              >
                <SelectTrigger id="settings-weight-unit" className="w-full">
                  <SelectValue />
                </SelectTrigger>
                <SelectContent>
                  <SelectItem value="kg">{copy.currency.weightUnitOptions.kg}</SelectItem>
                  <SelectItem value="lb">{copy.currency.weightUnitOptions.lb}</SelectItem>
                </SelectContent>
              </Select>
            </div>
            <div className="space-y-1.5 sm:col-span-2">
              <Label htmlFor="settings-locale">{copy.currency.fields.locale}</Label>
              <Input
                id="settings-locale"
                value={draft.currency.locale}
                onChange={(e) => setDraft((p) => ({ ...p, currency: { ...p.currency, locale: e.target.value } }))}
              />
              <p className="text-xs text-muted-foreground">{copy.currency.fields.localeHint}</p>
            </div>
          </div>
        </SettingsSection>

        {/* Checkout */}
        <SettingsSection title={copy.checkout.sectionTitle} description={copy.checkout.sectionDescription}>
          <SettingsRow
            htmlFor="settings-guest-checkout"
            label={copy.checkout.guestCheckout.label}
            description={copy.checkout.guestCheckout.description}
          >
            <Switch
              id="settings-guest-checkout"
              checked={draft.checkout.guestCheckoutEnabled}
              onCheckedChange={(checked) => setDraft((p) => ({ ...p, checkout: { ...p.checkout, guestCheckoutEnabled: checked } }))}
            />
          </SettingsRow>
          <SettingsRow
            htmlFor="settings-require-phone"
            label={copy.checkout.requirePhone.label}
            description={copy.checkout.requirePhone.description}
          >
            <Switch
              id="settings-require-phone"
              checked={draft.checkout.requirePhone}
              onCheckedChange={(checked) => setDraft((p) => ({ ...p, checkout: { ...p.checkout, requirePhone: checked } }))}
            />
          </SettingsRow>

          {/* Marketing consent default: a deliberate choice, not a bare
              switch, because the two options don't have equal weight — one
              of them is how a store ends up mailing people who never
              agreed to it. */}
          <div className="space-y-2 border-t pt-4">
            <p className="text-sm font-medium">{copy.checkout.marketingConsent.label}</p>
            <p className="text-xs text-muted-foreground">{copy.checkout.marketingConsent.description}</p>
            <RadioGroup
              className="gap-3"
              value={draft.checkout.marketingConsentDefault ? "on" : "off"}
              onValueChange={(v) => {
                if (typeof v === "string") {
                  setDraft((p) => ({ ...p, checkout: { ...p.checkout, marketingConsentDefault: v === "on" } }));
                }
              }}
            >
              <label className="flex items-start gap-2.5 text-sm font-normal">
                <RadioGroupItem value="off" className="mt-0.5" />
                <span>
                  <span className="block font-medium text-foreground">{copy.checkout.marketingConsent.optionOff}</span>
                  <span className="block text-xs text-muted-foreground">{copy.checkout.marketingConsent.optionOffDescription}</span>
                </span>
              </label>
              <label className="flex items-start gap-2.5 text-sm font-normal">
                <RadioGroupItem value="on" className="mt-0.5" />
                <span>
                  <span className="block font-medium text-foreground">{copy.checkout.marketingConsent.optionOn}</span>
                  <span className="block text-xs text-muted-foreground">{copy.checkout.marketingConsent.optionOnDescription}</span>
                </span>
              </label>
            </RadioGroup>
            {draft.checkout.marketingConsentDefault ? (
              <VerdictCallout verdict="warn" title={copy.checkout.marketingConsent.warningTitle}>
                {copy.checkout.marketingConsent.warningBody}
              </VerdictCallout>
            ) : null}
          </div>
        </SettingsSection>

        {/* Shipping zones and rates */}
        <SettingsSection
          title={copy.shipping.sectionTitle}
          description={copy.shipping.sectionDescription}
          actions={
            <Button variant="outline" size="sm" onClick={addZone}>
              <Plus /> {copy.shipping.addZone}
            </Button>
          }
        >
          {draft.shippingZones.length === 0 ? (
            <p className="text-sm text-muted-foreground">{copy.shipping.emptyZones}</p>
          ) : (
            draft.shippingZones.map((zone) => (
              <div key={zone.id} className="space-y-3 rounded-lg border p-3">
                <div className="flex items-start gap-3">
                  <div className="grid flex-1 grid-cols-1 gap-3 sm:grid-cols-2">
                    <div className="space-y-1.5">
                      <Label className="text-xs text-muted-foreground">{copy.shipping.zoneName}</Label>
                      <Input value={zone.name} onChange={(e) => updateZone(zone.id, { name: e.target.value })} className="h-8" />
                    </div>
                    <div className="space-y-1.5">
                      <Label className="text-xs text-muted-foreground">{copy.shipping.countries}</Label>
                      <Input
                        value={zone.countries.join(", ")}
                        onChange={(e) => updateZone(zone.id, { countries: e.target.value.split(",").map((c) => c.trim()) })}
                        placeholder={copy.shipping.countriesHint}
                        className="h-8"
                      />
                    </div>
                  </div>
                  <Button
                    variant="ghost"
                    size="icon-sm"
                    className="mt-5 shrink-0 text-destructive hover:bg-destructive/10"
                    onClick={() => setConfirmTarget({ kind: "zone", id: zone.id })}
                    aria-label={copy.shipping.removeZone}
                  >
                    <Trash2 />
                  </Button>
                </div>

                <div className="space-y-2 border-t pt-3">
                  <p className="text-xs font-medium text-muted-foreground">{copy.shipping.rates}</p>
                  {zone.rates.map((rate) => (
                    <div key={rate.id} className="flex items-center gap-2">
                      <Input
                        value={rate.name}
                        onChange={(e) => updateRate(zone.id, rate.id, { name: e.target.value })}
                        placeholder={copy.shipping.rateName}
                        className="h-8 flex-1"
                      />
                      <Input
                        inputMode="decimal"
                        value={amountToInputValue(rate.price.amount, rate.price.currency)}
                        onChange={(e) =>
                          updateRate(zone.id, rate.id, {
                            price: { amount: inputValueToAmount(e.target.value, rate.price.currency), currency: rate.price.currency },
                          })
                        }
                        aria-label={copy.shipping.ratePrice}
                        className="h-8 w-28"
                      />
                      <Button
                        variant="ghost"
                        size="icon-sm"
                        onClick={() => removeRate(zone.id, rate.id)}
                        aria-label={copy.shipping.removeRate}
                      >
                        <Trash2 className="size-3.5" />
                      </Button>
                    </div>
                  ))}
                  <Button variant="outline" size="sm" onClick={() => addRate(zone.id)}>
                    <Plus /> {copy.shipping.addRate}
                  </Button>
                </div>
              </div>
            ))
          )}
        </SettingsSection>

        {/* Payment methods */}
        <SettingsSection title={copy.paymentMethods.sectionTitle} description={copy.paymentMethods.sectionDescription}>
          {paymentMethodOptions.map((id) => {
            const setting = draft.paymentMethods.find((m) => m.id === id);
            return (
              <SettingsRow key={id} htmlFor={`settings-payment-${id}`} label={copy.paymentMethods.methods[id]}>
                <Switch
                  id={`settings-payment-${id}`}
                  checked={setting?.enabled ?? false}
                  onCheckedChange={(checked) => togglePaymentMethod(id, checked)}
                />
              </SettingsRow>
            );
          })}
        </SettingsSection>

        {/* Notifications */}
        <SettingsSection title={copy.notifications.sectionTitle} description={copy.notifications.sectionDescription}>
          <SettingsRow
            htmlFor="settings-notify-confirmation"
            label={copy.notifications.orderConfirmationEmail.label}
            description={copy.notifications.orderConfirmationEmail.description}
          >
            <Switch
              id="settings-notify-confirmation"
              checked={draft.notifications.orderConfirmationEmail}
              onCheckedChange={(checked) =>
                setDraft((p) => ({ ...p, notifications: { ...p.notifications, orderConfirmationEmail: checked } }))
              }
            />
          </SettingsRow>
          <SettingsRow
            htmlFor="settings-notify-shipping"
            label={copy.notifications.shippingUpdateEmail.label}
            description={copy.notifications.shippingUpdateEmail.description}
          >
            <Switch
              id="settings-notify-shipping"
              checked={draft.notifications.shippingUpdateEmail}
              onCheckedChange={(checked) => setDraft((p) => ({ ...p, notifications: { ...p.notifications, shippingUpdateEmail: checked } }))}
            />
          </SettingsRow>
          <SettingsRow
            htmlFor="settings-notify-lowstock"
            label={copy.notifications.lowStockAlertEmail.label}
            description={copy.notifications.lowStockAlertEmail.description}
          >
            <Switch
              id="settings-notify-lowstock"
              checked={draft.notifications.lowStockAlertEmail}
              onCheckedChange={(checked) => setDraft((p) => ({ ...p, notifications: { ...p.notifications, lowStockAlertEmail: checked } }))}
            />
          </SettingsRow>
          {draft.notifications.lowStockAlertEmail ? (
            <div className="max-w-40 space-y-1.5">
              <Label htmlFor="settings-lowstock-threshold">{copy.notifications.lowStockThreshold}</Label>
              <Input
                id="settings-lowstock-threshold"
                type="number"
                inputMode="numeric"
                value={draft.notifications.lowStockThreshold}
                onChange={(e) =>
                  setDraft((p) => ({
                    ...p,
                    notifications: { ...p.notifications, lowStockThreshold: Number.parseInt(e.target.value, 10) || 0 },
                  }))
                }
              />
            </div>
          ) : null}
        </SettingsSection>

        {/* Staff and permissions */}
        <SettingsSection title={copy.staff.sectionTitle} description={copy.staff.sectionDescription}>
          {draft.staff.length === 0 ? (
            <p className="text-sm text-muted-foreground">{copy.staff.emptyStaff}</p>
          ) : (
            <div className="space-y-2">
              {draft.staff.map((member) => (
                <div key={member.id} className="flex flex-wrap items-center gap-3 rounded-lg border p-2.5">
                  <div className="min-w-0 flex-1">
                    <p className="truncate text-sm font-medium">{member.name}</p>
                    <p className="truncate text-xs text-muted-foreground">{member.email}</p>
                  </div>
                  <Select
                    value={member.role}
                    onValueChange={(v) => {
                      if (typeof v === "string") {
                        setDraft((p) => ({ ...p, staff: p.staff.map((s) => (s.id === member.id ? { ...s, role: v as StaffRole } : s)) }));
                      }
                    }}
                  >
                    <SelectTrigger size="sm" aria-label={copy.staff.columns.role}>
                      <SelectValue />
                    </SelectTrigger>
                    <SelectContent>
                      {staffRoleOptions.map((role) => (
                        <SelectItem key={role} value={role}>
                          {copy.staff.roles[role]}
                        </SelectItem>
                      ))}
                    </SelectContent>
                  </Select>
                  <Button
                    variant="ghost"
                    size="sm"
                    className="text-destructive hover:bg-destructive/10"
                    onClick={() => setConfirmTarget({ kind: "staff", id: member.id })}
                  >
                    {copy.staff.remove}
                  </Button>
                </div>
              ))}
            </div>
          )}

          <div className="grid grid-cols-1 gap-3 border-t pt-4 sm:grid-cols-[1fr_1fr_auto_auto]">
            <div className="space-y-1.5">
              <Label htmlFor="settings-new-staff-name" className="text-xs text-muted-foreground">
                {copy.staff.addStaff.nameLabel}
              </Label>
              <Input id="settings-new-staff-name" value={newStaffName} onChange={(e) => setNewStaffName(e.target.value)} className="h-8" />
            </div>
            <div className="space-y-1.5">
              <Label htmlFor="settings-new-staff-email" className="text-xs text-muted-foreground">
                {copy.staff.addStaff.emailLabel}
              </Label>
              <Input
                id="settings-new-staff-email"
                type="email"
                value={newStaffEmail}
                onChange={(e) => setNewStaffEmail(e.target.value)}
                className="h-8"
              />
            </div>
            <div className="space-y-1.5">
              <Label className="text-xs text-muted-foreground">{copy.staff.addStaff.roleLabel}</Label>
              <Select value={newStaffRole} onValueChange={(v) => typeof v === "string" && setNewStaffRole(v as StaffRole)}>
                <SelectTrigger size="sm" className="w-full">
                  <SelectValue />
                </SelectTrigger>
                <SelectContent>
                  {staffRoleOptions.map((role) => (
                    <SelectItem key={role} value={role}>
                      {copy.staff.roles[role]}
                    </SelectItem>
                  ))}
                </SelectContent>
              </Select>
            </div>
            <Button size="sm" variant="outline" className="self-end" onClick={addStaff}>
              <Plus /> {copy.staff.addStaff.submit}
            </Button>
          </div>
        </SettingsSection>
      </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">{copy.common.unsavedChanges}</p>
          <div className="flex items-center gap-2">
            <Button variant="outline" size="sm" onClick={handleCancel}>
              {copy.common.cancel}
            </Button>
            <Button size="sm" onClick={handleSave} className="bg-brand text-brand-foreground hover:bg-brand/90">
              {copy.common.save}
            </Button>
          </div>
        </div>
      ) : null}

      <SettingsConfirmDialog
        open={confirmTarget !== null}
        onOpenChange={(open) => {
          if (!open) setConfirmTarget(null);
        }}
        title={
          confirmZone
            ? copy.shipping.confirmRemoveZone.title(confirmZone.name || copy.shipping.zoneName)
            : confirmStaff
              ? copy.staff.confirmRemove.title(confirmStaff.name)
              : ""
        }
        description={
          confirmZone
            ? copy.shipping.confirmRemoveZone.description(confirmZone.name || copy.shipping.zoneName, confirmZone.rates.length)
            : confirmStaff
              ? copy.staff.confirmRemove.description(confirmStaff.name)
              : ""
        }
        confirmLabel={confirmZone ? copy.shipping.confirmRemoveZone.confirm : copy.staff.confirmRemove.confirm}
        cancelLabel={confirmZone ? copy.shipping.confirmRemoveZone.cancel : copy.staff.confirmRemove.cancel}
        onConfirm={() => {
          if (confirmTarget?.kind === "zone") removeZone(confirmTarget.id);
          if (confirmTarget?.kind === "staff") removeStaff(confirmTarget.id);
        }}
      />
    </DashboardShell>
  );
}

Docs

Marketing-consent default is a radio group with the consequence spelled out, not a bare switch. Pre-ticked consent is not consent, and a settings screen that makes opt-out the silent default is how a store ends up with a list it cannot lawfully mail.

Destructive changes name what will happen — which zone, which person — rather than asking a generic are-you-sure, and nothing is destroyed until Save, so the confirm and the save bar cannot disagree.

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