block
Dashboard — Settings
Store details, currency, checkout, shipping zones, payment methods, notifications and staff.
npx shadcn@latest add @hextor/store-dash-settingsSource
"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