HextorUI
block

Dashboard — Product editor

Title, description, media, pricing, inventory, variants and organisation, with a status control.

npx shadcn@latest add @hextor/store-dash-product-edit

Source

"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