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
"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>
)
}Install
$
npx shadcn@latest add @vibra/widget-ecommerce-product-product-editNeeds the @vibra registry in your components.json — set it up once.
Source
"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>
)
}"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 },
}
}"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