Skip to contentVibraUI

Price and stock

The price and the stock edited in place and saved through the server, a refusal marked on the field it is about; reads productRecord(id).

Preview

Install

npx shadcn@latest add @vibra/widget-ecommerce-product-product-edit

Needs the @vibra registry in your components.json — set it up once.

Source

app/ecommerce/products/[id]/components/product-edit.tsx
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import { formatCurrency, formatNumber } from "@/lib/format"
import { AsyncButton } from "@/components/ui/async-button"
import { Callout } from "@/components/ui/callout"
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Widget } from "@/components/ui/widget"

import { updateProduct } from "../actions"
import { useProduct } from "./product-context"

type Refusal = { message: string; field?: string }

/**
 * The price and the stock, edited in place. The numbers go to the server as
 * typed — the action, not the browser, decides what a valid price is — and
 * whatever it answers is printed here: a refusal in an alert above the button
 * with the field it was about marked invalid, a success in the status line
 * under it, and the figures at the top of the page take the saved values.
 */
export function ProductEdit({ id, name }: { id: string; name: string }) {
  const { figures, setFigures } = useProduct()
  const [price, setPrice] = React.useState(String(figures.priceCents / 100))
  const [stock, setStock] = React.useState(String(figures.stock))
  const [refusal, setRefusal] = React.useState<Refusal | null>(null)
  const [notice, setNotice] = React.useState("")

  async function save() {
    setRefusal(null)
    setNotice("")
    const result = await updateProduct(id, {
      // An empty box is not zero: it reaches the server as NaN and is refused.
      priceCents: price.trim() === "" ? Number.NaN : Math.round(Number(price) * 100),
      stock: stock.trim() === "" ? Number.NaN : Number(stock),
    })
    if (!result.ok) return setRefusal({ message: result.error.message, field: result.error.field })
    setFigures({ priceCents: result.data.priceCents, stock: result.data.stock })
    setPrice(String(result.data.priceCents / 100))
    setStock(String(result.data.stock))
    setNotice(
      `Saved. ${result.data.name} sells at ${formatCurrency(result.data.priceCents / 100)} with ${formatNumber(result.data.stock)} in stock.`
    )
  }

  const priceInvalid = refusal?.field === "priceCents"
  const stockInvalid = refusal?.field === "stock"
  // A field the refusal was about is described by it as well as by its hint,
  // so tabbing back to it says why it is invalid, not only that it is.
  const refusalId = `${id}-refusal`
  const describedBy = (field: string, invalid: boolean) => (invalid ? `${id}-${field}-hint ${refusalId}` : `${id}-${field}-hint`)

  return (
    <Widget data-widget="widget-ecommerce-product-product-edit" title="Price and stock" description={`What ${name} sells for, and what is on the shelf`} className="h-full">
      <form
        className="flex flex-col gap-4"
        onSubmit={(event) => {
          event.preventDefault()
        }}
        noValidate
      >
        <Field data-invalid={priceInvalid || undefined}>
          <FieldLabel htmlFor={`${id}-price`}>Price (USD)</FieldLabel>
          <Input
            id={`${id}-price`}
            name="price"
            type="number"
            inputMode="decimal"
            step="0.01"
            value={price}
            onChange={(event) => setPrice(event.target.value)}
            aria-invalid={priceInvalid || undefined}
            aria-describedby={describedBy("price", priceInvalid)}
            className="tabular-nums"
          />
          <FieldDescription id={`${id}-price-hint`}>The list price, before any discount at checkout.</FieldDescription>
        </Field>

        <Field data-invalid={stockInvalid || undefined}>
          <FieldLabel htmlFor={`${id}-stock`}>Units in stock</FieldLabel>
          <Input
            id={`${id}-stock`}
            name="stock"
            type="number"
            inputMode="numeric"
            step="1"
            value={stock}
            onChange={(event) => setStock(event.target.value)}
            aria-invalid={stockInvalid || undefined}
            aria-describedby={describedBy("stock", stockInvalid)}
            className="tabular-nums"
          />
          <FieldDescription id={`${id}-stock-hint`}>A count after a stocktake replaces the one on record.</FieldDescription>
        </Field>

        {refusal ? (
          <Callout id={refusalId} variant="danger" role="alert" title="Not saved">
            {refusal.message}
          </Callout>
        ) : null}

        <AsyncButton type="submit" onClick={save} className="self-start">
          Save changes
        </AsyncButton>

        <p
          data-slot="product-edit-status"
          role="status"
          aria-live="polite"
          className={cn("text-sm text-muted-foreground", !notice && "sr-only")}
        >
          {notice}
        </p>
      </form>
    </Widget>
  )
}
app/ecommerce/products/[id]/actions.ts
"use server"

import { mockAuthAdapter } from "@/lib/auth-adapter"
import { db, fields, type Result } from "@/lib/sample-data"

/**
 * What one product's page changes. Both return `Result`, so the edit form
 * prints the reason a save was refused beside the fields it was about.
 */

export async function signOut(): Promise<Result<{ signedOut: true }>> {
  await mockAuthAdapter.signOut()
  return { ok: true, data: { signedOut: true } }
}

export type ProductChange = { priceCents: number; stock: number }

/** The ceilings on what a form may write: $10,000,000 a unit, and a million units on hand. */
const MAX_PRICE_CENTS = 1_000_000_000
const MAX_STOCK = 1_000_000

/**
 * Writes a new list price and stock count. The form's numbers are checked
 * here, not trusted: a price is a whole number of cents at or above zero and
 * stock a whole number of units at or above zero, and anything else is
 * refused with the field it was about, so the form can mark it. The name in
 * the answer is the store's own, read off the row this action looked up.
 */
export async function updateProduct(
  id: string,
  change: ProductChange
): Promise<Result<{ id: string; name: string; priceCents: number; stock: number }>> {
  const product = await db.products.get(id)
  if (!product) {
    return { ok: false, error: { code: "not_found", message: `No product with id "${id}".` } }
  }

  const { priceCents, stock } = fields(change)
  if (typeof priceCents !== "number" || !Number.isFinite(priceCents)) {
    return { ok: false, error: { code: "invalid_price", message: "Enter a price.", field: "priceCents" } }
  }
  if (priceCents < 0) {
    return { ok: false, error: { code: "negative_price", message: "A price cannot be negative.", field: "priceCents" } }
  }
  if (priceCents > MAX_PRICE_CENTS) {
    return { ok: false, error: { code: "invalid_price", message: "A price runs to $10,000,000 at most.", field: "priceCents" } }
  }
  if (typeof stock !== "number" || !Number.isFinite(stock)) {
    return { ok: false, error: { code: "invalid_stock", message: "Enter a stock count.", field: "stock" } }
  }
  if (stock < 0) {
    return { ok: false, error: { code: "negative_stock", message: "Stock cannot be negative.", field: "stock" } }
  }
  if (!Number.isInteger(stock)) {
    return { ok: false, error: { code: "partial_stock", message: "Stock is counted in whole units.", field: "stock" } }
  }
  if (stock > MAX_STOCK) {
    return { ok: false, error: { code: "invalid_stock", message: "Stock runs to 1,000,000 units at most.", field: "stock" } }
  }

  const updated = await db.products.update(id, { priceCents: Math.round(priceCents), stock })
  if (!updated.ok) return updated
  return {
    ok: true,
    data: { id, name: updated.data.name, priceCents: updated.data.priceCents, stock: updated.data.stock },
  }
}
app/ecommerce/products/[id]/components/product-context.tsx
"use client"

import * as React from "react"

/** The two numbers the edit form can change, held once for every card that prints them. */
export type ProductFigures = { priceCents: number; stock: number }

type ProductContextValue = {
  figures: ProductFigures
  setFigures: (next: ProductFigures) => void
}

const ProductContext = React.createContext<ProductContextValue | null>(null)

/**
 * Holds the price and the stock the server last confirmed. The page renders
 * them from the row it read; a save replaces them with what the action wrote
 * back, so the figures at the top and the form at the bottom never disagree.
 */
export function ProductProvider({ initial, children }: { initial: ProductFigures; children: React.ReactNode }) {
  const [figures, setFigures] = React.useState(initial)
  const value = React.useMemo(() => ({ figures, setFigures }), [figures])
  return <ProductContext.Provider value={value}>{children}</ProductContext.Provider>
}

export function useProduct(): ProductContextValue {
  const context = React.useContext(ProductContext)
  if (!context) throw new Error("useProduct must be used inside <ProductProvider>.")
  return context
}

Its page

On its page the card sits among the rest of the dashboard and shares its range and its data with them.

From the Product record page