block
Dashboard — Product editor
Title, description, media, pricing, inventory, variants and organisation, with a status control.
npx shadcn@latest add @hextor/store-dash-product-editSource
"use client";
import { useState } from "react";
import type { ReactNode } from "react";
import Image from "next/image";
import { ImagePlus } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } 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 { Textarea } from "@/components/ui/textarea";
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 { resolveLabels, type DashboardLabelsOverrides } from "@/registry/hextor/components/store/dashboard/labels";
import type { Collection, Product, ProductStatus, ProductVariant } from "@/registry/hextor/lib/store";
/**
* Minor-unit divisor for editing a price as a plain decimal input.
* `store.ts` keeps its currency → minor-units table private (only
* `formatMoney` needs it); this form needs the same fact at one bit of
* granularity — "does this currency have decimals at all" — so it is
* re-derived here rather than exported for a single caller.
*/
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));
}
const DEFAULT_STATUS_OPTIONS: ProductStatus[] = ["active", "draft", "archived"];
/**
* The product form: title/description, media, pricing, inventory, variants
* and organisation, editing a local draft of `Product` and handing the
* result to `onSave` on submit. Pricing and inventory show a single set of
* fields when the product has exactly one variant and no options (the
* common "simple product" case); a product with real variants edits price
* and stock per row in the Variants section instead, since there is no
* single "the" price once options exist.
*/
export default function ProductEditPage({
product,
collections,
statusOptions = DEFAULT_STATUS_OPTIONS,
backHref = "/dashboard/products",
onSave,
onCancel,
nav = DEFAULT_DASHBOARD_NAV,
brand,
activeHref = "/dashboard/products",
actions,
labels,
}: {
product: Product;
/** Resolves `collectionIds` to titles for display; omit to show raw ids. */
collections?: Collection[];
statusOptions?: ProductStatus[];
backHref?: string;
onSave?: (product: Product) => void;
onCancel?: () => void;
nav?: DashboardNavItem[];
brand?: DashboardBrand;
activeHref?: string;
actions?: ReactNode;
labels?: DashboardLabelsOverrides;
}) {
const copy = resolveLabels(labels);
const [draft, setDraft] = useState<Product>(product);
const [tagsInput, setTagsInput] = useState(product.tags.join(", "));
const isSimpleProduct = draft.variants.length === 1 && draft.options.length === 0;
const primaryVariant = draft.variants[0];
function updateVariant(id: string, patch: Partial<ProductVariant>) {
setDraft((prev) => ({
...prev,
variants: prev.variants.map((v) => (v.id === id ? { ...v, ...patch } : v)),
}));
}
function handleSave() {
onSave?.({
...draft,
tags: tagsInput
.split(",")
.map((t) => t.trim())
.filter(Boolean),
});
}
function handleCancel() {
setDraft(product);
setTagsInput(product.tags.join(", "));
onCancel?.();
}
return (
<DashboardShell brand={brand} nav={nav} activeHref={activeHref} actions={actions}>
<PageHeader
title={product.title}
breadcrumb={[{ label: copy.productEdit.back, href: backHref }, { label: product.title }]}
actions={
<>
<Button variant="outline" size="sm" onClick={handleCancel}>
{copy.productEdit.cancel}
</Button>
<Button size="sm" onClick={handleSave}>
{copy.productEdit.save}
</Button>
</>
}
/>
<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.productEdit.sections.title}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="product-title">{copy.productEdit.fields.title}</Label>
<Input
id="product-title"
value={draft.title}
onChange={(e) => setDraft((prev) => ({ ...prev, title: e.target.value }))}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="product-description">{copy.productEdit.fields.description}</Label>
<Textarea
id="product-description"
rows={5}
value={draft.description}
onChange={(e) => setDraft((prev) => ({ ...prev, description: e.target.value }))}
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{copy.productEdit.sections.media}</CardTitle>
</CardHeader>
<CardContent>
{draft.images.length === 0 ? (
<p className="mb-3 text-sm text-muted-foreground">{copy.productEdit.media.empty}</p>
) : null}
<div className="grid grid-cols-3 gap-2 sm:grid-cols-4">
{draft.images.map((image, i) => (
<div
key={`${image.url}-${i}`}
className="relative aspect-square overflow-hidden rounded-lg bg-muted ring-1 ring-foreground/10"
>
<Image src={image.url} alt={image.alt} fill sizes="120px" className="object-cover" />
</div>
))}
<button
type="button"
className="flex aspect-square flex-col items-center justify-center gap-1 rounded-lg border border-dashed text-xs text-muted-foreground transition-colors hover:border-foreground/30 hover:text-foreground"
>
<ImagePlus className="size-5" aria-hidden />
{copy.productEdit.media.addMedia}
</button>
</div>
</CardContent>
</Card>
{isSimpleProduct && primaryVariant ? (
<>
<Card>
<CardHeader>
<CardTitle>{copy.productEdit.sections.pricing}</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="product-price">{copy.productEdit.fields.price}</Label>
<Input
id="product-price"
inputMode="decimal"
value={amountToInputValue(primaryVariant.price.amount, primaryVariant.price.currency)}
onChange={(e) =>
updateVariant(primaryVariant.id, {
price: {
amount: inputValueToAmount(e.target.value, primaryVariant.price.currency),
currency: primaryVariant.price.currency,
},
})
}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="product-compare-price">{copy.productEdit.fields.compareAtPrice}</Label>
<Input
id="product-compare-price"
inputMode="decimal"
value={
primaryVariant.compareAtPrice
? amountToInputValue(primaryVariant.compareAtPrice.amount, primaryVariant.price.currency)
: ""
}
onChange={(e) => {
const raw = e.target.value;
updateVariant(primaryVariant.id, {
compareAtPrice: raw
? { amount: inputValueToAmount(raw, primaryVariant.price.currency), currency: primaryVariant.price.currency }
: undefined,
});
}}
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{copy.productEdit.sections.inventory}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="max-w-40 space-y-1.5">
<Label htmlFor="product-quantity">{copy.productEdit.fields.quantity}</Label>
<Input
id="product-quantity"
type="number"
inputMode="numeric"
value={primaryVariant.inventoryQuantity}
onChange={(e) =>
updateVariant(primaryVariant.id, { inventoryQuantity: Number.parseInt(e.target.value, 10) || 0 })
}
/>
</div>
<div className="flex items-center gap-2">
<Switch
id="product-continue-selling"
checked={primaryVariant.continueSellingWhenOutOfStock ?? false}
onCheckedChange={(checked) =>
updateVariant(primaryVariant.id, { continueSellingWhenOutOfStock: checked })
}
/>
<Label htmlFor="product-continue-selling" className="font-normal">
{copy.productEdit.fields.continueSelling}
</Label>
</div>
</CardContent>
</Card>
</>
) : (
<Card>
<CardHeader>
<CardTitle>{copy.productEdit.sections.pricing}</CardTitle>
<CardDescription>{copy.productEdit.pricedPerVariant}</CardDescription>
</CardHeader>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>{copy.productEdit.sections.variants}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{draft.variants.map((variant) => (
<div key={variant.id} className="grid grid-cols-2 gap-3 rounded-lg border p-3 sm:grid-cols-4">
<div className="col-span-2 flex items-center sm:col-span-1">
<p className="text-sm font-medium">
{variant.selectedOptions.map((o) => o.value).join(" / ") || variant.title}
</p>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">{copy.productEdit.fields.sku}</Label>
<Input
value={variant.sku}
onChange={(e) => updateVariant(variant.id, { sku: e.target.value })}
className="h-8"
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">{copy.productEdit.fields.price}</Label>
<Input
inputMode="decimal"
value={amountToInputValue(variant.price.amount, variant.price.currency)}
onChange={(e) =>
updateVariant(variant.id, {
price: { amount: inputValueToAmount(e.target.value, variant.price.currency), currency: variant.price.currency },
})
}
className="h-8"
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">{copy.productEdit.fields.quantity}</Label>
<Input
type="number"
inputMode="numeric"
value={variant.inventoryQuantity}
onChange={(e) => updateVariant(variant.id, { inventoryQuantity: Number.parseInt(e.target.value, 10) || 0 })}
className="h-8"
/>
</div>
</div>
))}
</CardContent>
</Card>
</div>
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle>{copy.productEdit.sections.organization}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="product-status">{copy.productEdit.fields.status}</Label>
<Select
value={draft.status}
onValueChange={(value) => setDraft((prev) => ({ ...prev, status: value as ProductStatus }))}
>
<SelectTrigger id="product-status" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{statusOptions.map((status) => (
<SelectItem key={status} value={status}>
{copy.statuses.product[status]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="product-vendor">{copy.productEdit.fields.vendor}</Label>
<Input
id="product-vendor"
value={draft.vendor}
onChange={(e) => setDraft((prev) => ({ ...prev, vendor: e.target.value }))}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="product-tags">{copy.productEdit.fields.tags}</Label>
<Input id="product-tags" value={tagsInput} onChange={(e) => setTagsInput(e.target.value)} />
</div>
{draft.collectionIds.length > 0 ? (
<div className="space-y-1.5">
<Label>{copy.productEdit.fields.collections}</Label>
<div className="flex flex-wrap gap-1.5">
{draft.collectionIds.map((id) => {
const collection = collections?.find((c) => c.id === id);
return (
<Badge key={id} variant="outline">
{collection?.title ?? id}
</Badge>
);
})}
</div>
</div>
) : null}
</CardContent>
</Card>
</div>
</div>
</DashboardShell>
);
}
Docs
onSave hands you the whole edited product rather than a diff — a template cannot know your mutation shape, and a partial patch it guessed at would be worse than none.
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