HextaUI

Product Editor

The add and edit product page for a store admin. Handles unsaved changes, SKU errors from the server and someone else editing at once.

Open in new tab

Product Editor is the page a merchant uses to add or change a product: title and description, media, pricing with live profit and margin, stock per location, shipping, variants, a search result preview, and a rail for status, sales channels, type, vendor, collections and tags. It renders the page frame with breadcrumb, status badge, Preview and a menu for Duplicate, Archive and Delete, and leaves the sidebar to you.

Every edit goes into one draft. The save bar slides up when the draft differs from the saved product and goes away again if you undo your edits by hand, because the check is a deep compare. ⌘S saves, Discard asks first, and Preview or the breadcrumb nudge the bar instead of leaving. A second try within a few seconds asks whether to leave without saving. onSave can return field errors, which are shown on their fields while the first one scrolls into view and gets focus, or a conflict, which turns the bar into “Leo Park changed this product 2 min ago” with Review changes and Overwrite.

MediaUploader takes drops, pastes and the file picker. The first image is the large featured tile. You can drag tiles and the rest move out of the way as you go, or press Space, the arrow keys and Space again. Each upload shows its own progress. A failed one stays in place with Retry, unsupported types and files over the size limit are named in a message, and removed images come back with Undo.

VariantMatrix edits up to three options as value chips that you can reorder, rename and remove, and keeps one row for every combination. Rows are grouped by an option with a summary like “$48–$96 · 12 in stock”. You can set price or stock for a whole group or a selection, delete a combination and restore it with the same SKU, filter rows, and work through hundreds of variants because collapsed groups don’t render and long groups load 25 rows at a time.

  1. Obtenha o HextaUI Pro

    Um único pagamento libera todos os blocos, incluindo os futuros. Ver preços.

  2. Adicione o registro Pro ao components.json

    components.json
    {
      "registries": {
        "@hextaui-pro": {
          "url": "https://hextaui.com/r/pro/{name}.json",
          "headers": {
            "Authorization": "Bearer ${HEXTAUI_PRO_TOKEN}"
          }
        }
      }
    }
  3. Adicione seu token

    Crie um token na sua página de conta e coloque-o em .env.local como HEXTAUI_PRO_TOKEN.

  4. Adicione o bloco

    pnpm dlx shadcn@latest add @hextaui-pro/product-editor

Save with Shopify’s productSet

Map the draft to a ProductSetInput and send it in one mutation. userErrors come back as field errors, so a taken SKU lands on its variant row, and a 409 from your route becomes the concurrent edit state.

"use client"

import { ProductEditor, type ProductEditorProps } from "@/components/blocks/product-editor/product-editor"
import type { ProductDraft } from "@/components/blocks/product-editor/product-draft"

const productSet = `
  mutation ProductSet($input: ProductSetInput!, $identifier: ProductSetIdentifiers) {
    productSet(input: $input, identifier: $identifier, synchronous: true) {
      product { id updatedAt }
      userErrors { field message code }
    }
  }
`

type UserError = { field: string[] | null; message: string; code: string | null }

function toProductSetInput(draft: ProductDraft) {
  return {
    title: draft.title,
    descriptionHtml: draft.description,
    handle: draft.handle,
    status: draft.status.toUpperCase(),
    productType: draft.productType,
    vendor: draft.vendor,
    tags: draft.tags,
    seo: { title: draft.seoTitle, description: draft.seoDescription },
    productOptions: draft.options.map((option, position) => ({
      name: option.name,
      position: position + 1,
      values: option.values.map((name) => ({ name })),
    })),
    variants: draft.variants.map((variant) => ({
      id: variant.id.startsWith("gid://") ? variant.id : undefined,
      sku: variant.sku,
      price: variant.price.amount,
      compareAtPrice: variant.compareAtPrice?.amount ?? null,
      optionValues: draft.options.map((option) => ({
        optionName: option.name,
        name: variant.selectedOptions[option.name],
      })),
      inventoryQuantities: Object.entries(variant.quantities).map(([locationId, quantity]) => ({
        locationId,
        name: "available",
        quantity,
      })),
    })),
  }
}

function fieldKey(draft: ProductDraft, error: UserError) {
  const [, , index, field] = error.field ?? []
  const variant = draft.variants[Number(index)]
  if (error.field?.[1] === "variants" && variant) return `variant:${variant.id}:${field === "price" ? "price" : "sku"}`
  return error.field?.[1] ?? "title"
}

const onSave: ProductEditorProps["onSave"] = async (draft, { overwrite }) => {
  const response = await fetch("/api/shopify/admin", {
    method: "POST",
    headers: { "Content-Type": "application/json", "If-Match": overwrite ? "*" : draft.id },
    body: JSON.stringify({ query: productSet, variables: { input: toProductSetInput(draft), identifier: { id: draft.id } } }),
  })
  if (response.status === 409) {
    const latest = (await response.json()) as { author: string; updatedAt: string; product: ProductDraft }
    return { conflict: { author: latest.author, at: latest.updatedAt, value: latest.product } }
  }
  if (!response.ok) throw new Error("Shopify didn’t answer. Try again.")
  const { data } = (await response.json()) as { data: { productSet: { userErrors: UserError[] } } }
  const errors = data.productSet.userErrors
  if (errors.length > 0) {
    return { errors: Object.fromEntries(errors.map((error) => [fieldKey(draft, error), error.message])) }
  }
}

export function ShopifyProductPage({ product }: { product: ProductDraft }) {
  return (
    <ProductEditor
      product={product}
      onSave={onSave}
      locations={[{ id: "gid://shopify/Location/1", name: "Brooklyn studio" }]}
      channels={[{ id: "online-store", name: "Online Store" }]}
      store={{ name: "Halden", domain: "halden.co" }}
    />
  )
}

Save through the Medusa admin API

Update the product, then batch the variants. Throw with the API’s message and the save bar shows it with Try again. onNavigate hands guarded links to the Next.js router.

"use client"

import { useRouter } from "next/navigation"

import { ProductEditor } from "@/components/blocks/product-editor/product-editor"
import type { ProductDraft } from "@/components/blocks/product-editor/product-draft"

async function medusa(path: string, body: unknown) {
  const response = await fetch(`${process.env.NEXT_PUBLIC_MEDUSA_URL}/admin${path}`, {
    method: "POST",
    credentials: "include",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
  })
  const json = await response.json()
  if (!response.ok) throw new Error(json.message ?? "Medusa couldn’t save the product.")
  return json
}

export function MedusaProductPage({ product }: { product: ProductDraft }) {
  const router = useRouter()

  return (
    <ProductEditor
      product={product}
      locations={[{ id: "sloc_01", name: "Brooklyn studio" }]}
      channels={[{ id: "sc_01", name: "Webshop" }]}
      store={{ name: "Halden", domain: "halden.co" }}
      onNavigate={(href) => router.push(href)}
      onSave={async (draft) => {
        await medusa(`/products/${draft.id}`, {
          title: draft.title,
          description: draft.description,
          handle: draft.handle,
          status: draft.status === "active" ? "published" : draft.status === "draft" ? "draft" : "rejected",
          tags: draft.tags.map((value) => ({ value })),
          options: draft.options.map((option) => ({ title: option.name, values: option.values })),
        })
        await medusa(`/products/${draft.id}/variants/batch`, {
          update: draft.variants.map((variant) => ({
            id: variant.id,
            sku: variant.sku,
            options: variant.selectedOptions,
            prices: [{ currency_code: variant.price.currencyCode.toLowerCase(), amount: Number(variant.price.amount) }],
          })),
        })
      }}
    />
  )
}

Upload media to S3 with presigned URLs

Ask your server for a presigned PUT URL, send the file with XMLHttpRequest so progress reaches each tile, and return the public URL. Aborting the signal cancels the request when a tile is removed.

"use client"

import { MediaUploader, type MediaUploadContext } from "@/components/blocks/product-editor/media-uploader"
import type { MediaItem } from "@/components/blocks/product-editor/product-draft"

function put(url: string, file: File, { onProgress, signal }: MediaUploadContext) {
  return new Promise<void>((resolve, reject) => {
    const request = new XMLHttpRequest()
    request.open("PUT", url)
    request.setRequestHeader("Content-Type", file.type)
    request.upload.onprogress = (event) => event.lengthComputable && onProgress(event.loaded / event.total)
    request.onload = () => (request.status < 300 ? resolve() : reject(new Error(`Upload failed with ${request.status}.`)))
    request.onerror = () => reject(new Error("The connection dropped during the upload."))
    signal.addEventListener("abort", () => request.abort())
    request.send(file)
  })
}

async function uploadToS3(file: File, context: MediaUploadContext) {
  const response = await fetch("/api/uploads", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ name: file.name, type: file.type, size: file.size }),
    signal: context.signal,
  })
  if (!response.ok) throw new Error("Couldn’t start the upload.")
  const { uploadUrl, publicUrl, key } = (await response.json()) as {
    uploadUrl: string
    publicUrl: string
    key: string
  }
  await put(uploadUrl, file, context)
  return { id: key, src: publicUrl }
}

export function ProductMedia({
  value,
  onValueChange,
}: {
  value: MediaItem[]
  onValueChange: (value: MediaItem[]) => void
}) {
  return (
    <MediaUploader
      value={value}
      onValueChange={onValueChange}
      onUpload={uploadToS3}
      maxSize={20 * 1024 * 1024}
    />
  )
}

Anatomia

As partes que você compõe, de fora para dentro.

ParteDescrição
ProductEditorThe page: header, main column of cards, right rail, save bar and dialogs.
DraftSaveBarThe floating bar for unsaved changes, save progress, errors and conflicts.
useDraftSaved value, draft, deep dirty check, validation, save, discard and conflict state.
MediaUploaderDrop zone and sortable image grid with uploads and alt text.
VariantMatrixOptions editor and the grouped variant table.
SeoPreviewA search result snippet with title, description and URL fields.
TagsInputFree-form tags as removable chips.
product-draft helpersProductDraft types, combination helpers, validateProduct, getProfit and describeProductChanges.

ProductEditor

Also takes div props.

PropTipoPadrão
productThe saved product. A new value replaces the draft when there are no unsaved changes.
ProductDraft–
onSaveSave the draft. Return errors keyed by field, such as sku or variant:ID:sku, a conflict, or the saved value. Throw to show the message in the save bar.
(draft, { overwrite, redirectFrom }) => void | { errors?, conflict?, value? } | Promise<…>–
conflictSomeone else’s newer version, for example from a live subscription. Without changes, the review dialog works them out from value.
{ author; at; value?; changes? } | null–
onUploadUpload one image. Without it, images stay as local previews.
(file, { onProgress, signal }) => Promise<{ src; id? }>–
locationsStock locations for quantities.
{ id; name; description? }[]–
channelsSales channels shown as checkboxes.
{ id; name; description? }[]–
collectionsCollections to choose from.
string[]–
storeUsed in the search preview and URL field.
{ name; domain }–
backHrefThe breadcrumb link.
string"/products"
onNavigateCalled for the breadcrumb once it’s safe to leave.
(href: string) => void–
onPreview, onDuplicate, onArchive, onDeleteHeader actions. Preview and Duplicate are guarded by unsaved changes. Without onArchive, Archive sets the status in the draft.
() => void | Promise<void>–
onDirtyChangeCalled when the draft becomes dirty or clean, for router blockers.
(dirty: boolean) => void–
onConflictResolveCalled when the merchant keeps their colleague’s version or overwrites it.
(resolution: "theirs" | "overwrite") => void–
localeLocale for money, numbers and relative times.
Intl.LocalesArgument"en-US"
saveBarLabelsWords in the save bar.
Partial<SaveBarLabels>–

MediaUploader

Also takes div props.

PropTipoPadrão
valueImages in order. The first is featured.
MediaItem[]–
onValueChangeCalled after a reorder, upload, alt text change or removal.
(value: MediaItem[]) => void–
onUploadUpload a file and report progress from 0 to 1. Throw to fail the tile with your message.
(file, { onProgress, signal }) => Promise<{ src; id?; width?; height? }>–
onUploadingChangeHow many uploads are running.
(count: number) => void–
acceptAccepted MIME types.
string[]JPG, PNG, WebP, GIF, AVIF
maxSizeMaior arquivo em bytes.
number20 MB
maxFilesMost images in total.
number250
labelsWords for tiles, errors and announcements.
Partial<MediaUploaderLabels>–
PropTipoPadrão
valueThe draft, or any object with these fields.
{ options; variants; price; compareAtPrice; sku; barcode; quantities }–
onValueChangeCalled with the whole updated value.
(value) => void–
locationsLocations for the stock column.
InventoryLocation[]–
mediaImages for variant thumbnails.
MediaItem[]–
trackQuantityShows the stock column and summaries.
booleantrue
errorsErrors keyed variant:ID:price or variant:ID:sku. Groups holding them open.
Record<string, string>–
pageSizeRows shown per group before Show more.
number25
PropTipoPadrão
valueThe saved value.
Value–
onSaveSave and return errors, a conflict or the saved value.
(value, { overwrite }) => DraftSaveResult–
validateErrors found before saving.
(value) => Record<string, string>–
normalizeClean the draft before it is validated and saved.
(value) => Value–
conflictAn outside conflict to show.
DraftConflict | null–
TeclaAção
MetaSSaves the product when there are unsaved changes. Ctrl+S on Windows and Linux.
SpaceOn an image or option value, picks it up or drops it.
ArrowLeftArrowRightArrowUpArrowDownMoves a picked-up image or value one place.
HomeEndMoves a picked-up image or value to the start or end.
EscapeCancels a move and puts the item back.
EnterOn an option value, renames it. In the add value field, adds the value and its variants.
BackspaceOn an option value or tag, removes it. In an empty add field, moves to the last chip.
MetaBIn the description, wraps the selection in bold. Meta+I does italic.
  • The save bar is a region named Unsaved changes. A polite live region says its state once, focus is never moved when it appears, and save failures are sent to an alert region.
  • Images and option values are sortable buttons with instructions linked through aria-describedby. Pick up, every move, drop and cancel are announced, such as “Image “Ash vase” moved to position 2 of 7.”
  • Server errors set aria-invalid on the field, show the message next to it and move focus to the first one. Upload problems and rejected files are announced and named in words, never only by color.
  • The variant table uses table, row and cell roles with labelled checkboxes and inputs, such as “Price, Sand / Small”. Group toggles set aria-expanded, and motion falls back to fades or nothing under reduced motion.

Construído com

Os componentes gratuitos do HextaUI de que Product Editor é feito. Cada um é instalado separadamente.

Código

13 arquivos, adicionados a components/blocks/product-editor.