/ecommerce/productsProduct catalogue
The catalogue as a table: search by name or SKU, facets on category and status, stock drawn against each reorder point, units sold in the last thirty days, a row menu that archives, and a bulk bar for many rows at once.
Every row is a db.products record, read on the server and handed to one client island; sixty rows are one page of JSON, so search, facets and paging run in the browser. Units sold are counted off db.orders lines by the dashboard's rule for a sale (vocabulary-ecommerce's salesBetween: paid and fulfilled orders only), over the thirty days before REFERENCE_DATE and the thirty before that for the pill, because the catalogue row records no sales of its own. Every stock bar is drawn on one scale, the deepest shelf in the catalogue, with a tick where the product's reorder point falls on it, and turns the warning tone at or below that point; since that share of the deepest shelf means nothing read aloud, the bar announces the count itself. The table opens on the active catalogue with the status facet set; archiving goes through a server action returning Result, the row takes its new status from the answer and leaves the filtered list, a success is said in an always-mounted status line and a refusal — an already archived product, a row gone from the store — in a danger callout above the rows. The four figures are counted in the same island from the rows the table holds, so an archive moves the active count, the stock value and the low-stock count in the render that moves its row; units sold is history and stays. The bulk bar archives or exports the selected rows; a row the server gives no answer for stays selected, is named in the callout and can be pressed again. Products carry no photography: each row opens on its ProductArt drawing. On a phone the category, price, sales, supplier and status columns step aside for the product, its stock and its menu, and an archived product says so under its name. Composes AppShell, PageHeader, StatCardGroup, StatCard, DataTable, DataTableColumnHeader, DataTableRowActions, ProductArt, Progress, StatusBadge, CurrencyCell, NumberCell, SelectionActionBar, Button and Callout.
Preview
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { PageHeader } from "@/components/ui/page-header"
import { signOut } from "./actions"
import { ProductsTable } from "./components/products-table"
import {
currentUser,
lastUpdated,
productRows,
SALES_WINDOW_DAYS,
shellNotifications,
unitsSoldBefore,
} from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/ecommerce/nav"
/**
* The catalogue. A server component: it reads every product and what each
* sold through `db`, and hands the rows to the one island that counts them,
* filters them, selects them and archives them.
*/
export default async function ProductsPage() {
return (
<AppShell
nav={NAV}
activeHref={ROUTES.products}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
titleId="ecommerce-products-title"
title="Products"
description="What is for sale, what it costs, and what is left on the shelf."
meta={lastUpdated()}
/>
<ProductsTable
titleId="ecommerce-products-title"
rows={productRows()}
asOf={REFERENCE_DATE}
soldBefore={unitsSoldBefore()}
windowDays={SALES_WINDOW_DAYS}
/>
</AppShell>
)
}Install
npx shadcn@latest add @vibra/ecommerce-productsNeeds the @vibra registry in your components.json — set it up once.
Source
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { PageHeader } from "@/components/ui/page-header"
import { signOut } from "./actions"
import { ProductsTable } from "./components/products-table"
import {
currentUser,
lastUpdated,
productRows,
SALES_WINDOW_DAYS,
shellNotifications,
unitsSoldBefore,
} from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/ecommerce/nav"
/**
* The catalogue. A server component: it reads every product and what each
* sold through `db`, and hands the rows to the one island that counts them,
* filters them, selects them and archives them.
*/
export default async function ProductsPage() {
return (
<AppShell
nav={NAV}
activeHref={ROUTES.products}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
titleId="ecommerce-products-title"
title="Products"
description="What is for sale, what it costs, and what is left on the shelf."
meta={lastUpdated()}
/>
<ProductsTable
titleId="ecommerce-products-title"
rows={productRows()}
asOf={REFERENCE_DATE}
soldBefore={unitsSoldBefore()}
windowDays={SALES_WINDOW_DAYS}
/>
</AppShell>
)
}/**
* What the catalogue page reads. Every row is a `db.products` record; what a
* product sold is counted off the lines of `db.orders`, because the catalogue
* row records no sales of its own, by the dashboard's own rule for a sale
* (`salesBetween`): paid and fulfilled orders, so a cancelled cart and a
* refund are not sales. "Now" is `REFERENCE_DATE`; nothing here reads a clock.
*/
import { salesBetween } from "@/lib/dashboards/ecommerce/vocabulary"
import { formatDate, getInitials } from "@/lib/format"
import { db, REFERENCE_DATE, type Member, type Product } from "@/lib/sample-data"
const DAY_MS = 86_400_000
/** The window "units sold" is counted over, and compared against the one before it. */
export const SALES_WINDOW_DAYS = 30
/** One row of the catalogue table: the product, and what it sold in the window. */
export type ProductRow = {
id: string
sku: string
name: string
category: string
/** List price in whole dollars. */
price: number
stock: number
reorderPoint: number
supplier: string
status: Product["status"]
/** Units sold in the last `SALES_WINDOW_DAYS` days. */
sold: number
}
/** Units sold per product in the window ending `offset` windows before "now". */
function unitsSold(offset: number): Map<string, number> {
const end = REFERENCE_DATE.getTime() - offset * SALES_WINDOW_DAYS * DAY_MS
const sales = salesBetween(db.orders.all(), end - SALES_WINDOW_DAYS * DAY_MS, end)
return new Map([...sales].map(([id, sale]) => [id, sale.units]))
}
const total = (units: Map<string, number>) => [...units.values()].reduce((sum, value) => sum + value, 0)
/** Every product in the catalogue, archived ones included — the table's status facet hides them. */
export function productRows(): ProductRow[] {
const sold = unitsSold(0)
return db.products
.all()
.map((product) => ({
id: product.id,
sku: product.sku,
name: product.name,
category: product.category,
price: product.priceCents / 100,
stock: product.stock,
reorderPoint: product.reorderPoint,
supplier: product.supplier,
status: product.status,
sold: sold.get(product.id) ?? 0,
}))
.sort((a, b) => a.name.localeCompare(b.name))
}
/**
* Units sold in the thirty days before the current window, for the pill on
* the units-sold figure. Every other figure over the table is counted from
* the rows themselves, in the island that holds them.
*/
export function unitsSoldBefore(): number {
return total(unitsSold(1))
}
/** The freshness line under the title, measured against REFERENCE_DATE. */
export function lastUpdated(): string {
return `Synced ${formatDate(REFERENCE_DATE, "medium", { timeZone: "UTC" })}`
}
function ownerRow(): Member {
return db.members.all().find((member) => member.role === "owner") ?? db.members.all()[0]
}
/** The person looking at the page: whoever owns this workspace. */
export function currentUser() {
const owner = ownerRow()
return { name: owner.name, email: owner.email, initials: getInitials(owner.name), avatarUrl: owner.avatarUrl }
}
/** The bell's contents: the newest notifications, unread first in the panel. */
export function shellNotifications() {
return db.notifications
.all()
.sort((a, b) => b.at.getTime() - a.at.getTime())
.slice(0, 6)
.map(({ id, title, description, at, read, href }) => ({ id, title, description, at, read, href }))
}"use server"
import { mockAuthAdapter } from "@/lib/auth-adapter"
import { db, type Result } from "@/lib/sample-data"
/**
* What the catalogue changes. Both return `Result`, so the table prints the
* reason an archive was refused beside the rows it was asked about.
*/
export async function signOut(): Promise<Result<{ signedOut: true }>> {
await mockAuthAdapter.signOut()
return { ok: true, data: { signedOut: true } }
}
/**
* Takes a product off sale. The name in every answer is the store's own, read
* off the row this action just looked up — never a name the table sent — and
* a product that is already archived is refused rather than written twice.
* Its stock and its order history stay as they are: archiving stops selling
* the thing, it does not make the shelf or the past disappear.
*/
export async function archiveProduct(id: string): Promise<Result<{ id: string; name: string }>> {
const product = await db.products.get(id)
if (!product) {
return { ok: false, error: { code: "not_found", message: `No product with id "${id}".` } }
}
if (product.status === "archived") {
return { ok: false, error: { code: "already_archived", message: `${product.name} is already archived.` } }
}
const updated = await db.products.update(id, { status: "archived" })
if (!updated.ok) return updated
return { ok: true, data: { id, name: updated.data.name } }
}"use client"
import Link from "next/link"
import { ArchiveIcon, ArrowUpRightIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
PRODUCT_STATUS_LABELS,
PRODUCT_STATUS_MAP,
productHref,
} from "@/lib/dashboards/ecommerce/vocabulary"
import { formatNumber } from "@/lib/format"
import {
DataTableColumnHeader,
DataTableRowActions,
selectionColumn,
type DataTableColumnDef,
} from "@/components/ui/data-table"
import { ProductArt } from "@/components/ui/product-art"
import { Progress } from "@/components/ui/progress"
import { StatusBadge } from "@/components/ui/status-badge"
import { CurrencyCell, NumberCell } from "@/components/ui/table-cells"
import { type ProductRow } from "../data"
export type ProductColumnHandlers = {
onOpen: (row: ProductRow) => void
onArchive: (row: ProductRow) => void
/** The deepest shelf in the catalogue: every stock bar is drawn on this one scale. */
ceiling: number
}
/** A count as a share of the catalogue's deepest shelf, 0–100. */
const share = (count: number, ceiling: number) => Math.min(100, Math.round((count / Math.max(1, ceiling)) * 100))
/**
* The columns step in as the table's own width allows — a container query on
* the frame, not the viewport, because at 768 the sidebar leaves the table
* less room than a 640 phone gets. Under 36rem: the product, its stock and
* its menu. From 36rem the price joins; from 42rem the status; from 56rem
* the category and the sales; from 64rem the supplier.
*/
const FROM = {
price: "hidden @xl/products:table-cell",
status: "hidden @2xl/products:table-cell",
wide: "hidden @4xl/products:table-cell",
widest: "hidden @5xl/products:table-cell",
} as const
/**
* The catalogue's columns. The product column searches on the name and the
* SKU together, so one box finds either, and opens on the product's drawing.
* Stock is a bar on the catalogue's own scale with a tick at the reorder
* point, and turns the warning tone at or below it. Archive is offered on
* every row — the server owns which products it applies to, and it says so
* out loud when the answer is no, which is more use than a greyed-out item.
* In a narrow frame the other columns step aside (see FROM), and an
* archived product says so under its name while the status column is away.
*/
export function productColumns({ onOpen, onArchive, ceiling }: ProductColumnHandlers): DataTableColumnDef<ProductRow>[] {
return [
selectionColumn<ProductRow>({ rowLabel: (row) => row.name }),
{
id: "product",
accessorFn: (row) => `${row.name} ${row.sku}`,
header: ({ column }) => <DataTableColumnHeader column={column} title="Product" />,
cell: ({ row }) => (
<div className="flex min-w-0 items-center gap-2.5">
<ProductArt
name={row.original.name}
category={row.original.category}
className="size-8 shrink-0 rounded-md @xl/products:size-9"
/>
{/* In a narrow frame the name truncates, so stock and the menu stay in view. */}
<div className="flex min-w-0 max-w-[7.5rem] flex-col @md/products:max-w-[12rem] @xl/products:max-w-none">
<Link
href={productHref(row.original.id)}
className="truncate rounded-sm font-medium underline-offset-4 focus-ring hover:underline"
>
{row.original.name}
</Link>
<span className="font-mono text-xs text-muted-foreground">{row.original.sku}</span>
{row.original.status === "archived" ? (
<span className="text-xs text-muted-foreground @2xl/products:hidden">{PRODUCT_STATUS_LABELS.archived}</span>
) : null}
</div>
</div>
),
meta: { label: "Product" },
},
{
accessorKey: "category",
header: ({ column }) => <DataTableColumnHeader column={column} title="Category" />,
cell: ({ row }) => <span className="text-muted-foreground">{row.original.category}</span>,
meta: { label: "Category", className: FROM.wide },
},
{
accessorKey: "price",
header: ({ column }) => <DataTableColumnHeader column={column} title="Price" />,
cell: ({ row }) => <CurrencyCell value={row.original.price} />,
meta: { align: "right", label: "Price", className: FROM.price },
},
{
accessorKey: "stock",
header: ({ column }) => <DataTableColumnHeader column={column} title="Stock" />,
cell: ({ row }) => {
const { name, stock, reorderPoint } = row.original
const low = stock <= reorderPoint
return (
<div className="flex w-14 flex-col gap-1 @xl/products:w-36">
<div className="flex items-baseline justify-between gap-2 text-xs">
<span className={cn("font-medium tabular-nums", low && "text-warning")}>{stock}</span>
{/* A narrow frame keeps the count and the tick; the words come back with the room. */}
<span className="hidden text-muted-foreground tabular-nums @xl/products:inline">reorder at {reorderPoint}</span>
</div>
{/* Every bar shares one scale — the deepest shelf in the catalogue —
so two rows compare at a glance, and the tick is where this
product's reorder point falls on that scale. */}
<div className="relative">
{/* The bar's share of the deepest shelf means nothing read
aloud, so what it announces is the count itself. */}
<Progress
value={share(stock, ceiling)}
aria-label={`${name} stock, reorder at ${reorderPoint}`}
getAriaValueText={() => `${formatNumber(stock)} in stock`}
className={cn(low && "[&_[data-slot=progress-indicator]]:bg-warning")}
/>
<span
data-slot="reorder-tick"
aria-hidden="true"
className="absolute top-1/2 h-2.5 w-px -translate-y-1/2 bg-foreground"
style={{ left: `${share(reorderPoint, ceiling)}%` }}
/>
</div>
</div>
)
},
meta: { label: "Stock" },
},
{
accessorKey: "sold",
header: ({ column }) => <DataTableColumnHeader column={column} title="Sold, 30d" />,
cell: ({ row }) => <NumberCell value={row.original.sold} />,
meta: { align: "right", label: "Sold, 30 days", className: FROM.wide },
},
{
accessorKey: "supplier",
header: ({ column }) => <DataTableColumnHeader column={column} title="Supplier" />,
cell: ({ row }) => <span className="whitespace-nowrap text-muted-foreground">{row.original.supplier}</span>,
meta: { label: "Supplier", className: FROM.widest },
},
{
accessorKey: "status",
header: ({ column }) => <DataTableColumnHeader column={column} title="Status" />,
cell: ({ row }) => (
<StatusBadge
status={row.original.status}
map={PRODUCT_STATUS_MAP}
label={PRODUCT_STATUS_LABELS[row.original.status]}
/>
),
meta: { label: "Status", className: FROM.status },
},
{
id: "actions",
size: 44,
enableSorting: false,
enableHiding: false,
cell: ({ row }) => (
<DataTableRowActions
label={`Actions for ${row.original.name}`}
actions={[
{ label: "Open product", icon: <ArrowUpRightIcon />, onSelect: () => onOpen(row.original) },
{
label: "Archive product",
icon: <ArchiveIcon />,
destructive: true,
separatorBefore: true,
onSelect: () => onArchive(row.original),
},
]}
/>
),
},
]
}"use client"
import { formatCurrency, formatNumber } from "@/lib/format"
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"
import { type ProductRow } from "../data"
export type ProductsStatsProps = {
rows: ProductRow[]
/** Units sold in the thirty days before the current window, for the pill. */
soldBefore: number
windowDays: number
}
/**
* The four figures over the catalogue, counted from the rows the table holds
* — so archiving a product takes it out of the active count and the stock
* value in the render that moves its row. Units sold is history: archiving
* leaves it alone. It is the one figure with an earlier window, so the one
* with a pill, and none when that window sold nothing. The low-stock tile
* tints its frame while anything needs reordering; the number stays in ink.
*/
export function ProductsStats({ rows, soldBefore, windowDays }: ProductsStatsProps) {
const live = rows.filter((row) => row.status === "active")
const low = live.filter((row) => row.stock <= row.reorderPoint)
const empty = low.filter((row) => row.stock === 0)
// In cents, so a total of prices never rounds a half-cent the other way.
const value = live.reduce((sum, row) => sum + Math.round(row.price * 100) * row.stock, 0) / 100
const sold = rows.reduce((sum, row) => sum + row.sold, 0)
return (
<StatCardGroup data-widget="widget-ecommerce-products-products-stats" columns={4}>
<StatCard
label="Active products"
value={formatNumber(live.length)}
description={`${rows.length - live.length} archived`}
/>
<StatCard
label="Units sold"
value={formatNumber(sold)}
delta={soldBefore === 0 ? (sold === 0 ? 0 : undefined) : sold / soldBefore - 1}
description={`last ${windowDays} days, vs the ${windowDays} before`}
/>
<StatCard
label="Stock value"
value={formatCurrency(value, "USD", { maximumFractionDigits: 0 })}
description="active stock at list price"
/>
<StatCard
label="At or below reorder point"
value={formatNumber(low.length)}
description={empty.length === 0 ? "none out of stock" : `${empty.length} out of stock`}
tone={low.length > 0 ? "warning" : "neutral"}
/>
</StatCardGroup>
)
}"use client"
import * as React from "react"
import { useRouter } from "next/navigation"
import type { ColumnFiltersState, RowSelectionState } from "@tanstack/react-table"
import { ArchiveIcon, DownloadIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { PRODUCT_STATUS_LABELS, productHref } from "@/lib/dashboards/ecommerce/vocabulary"
import { downloadText, exportFilename, rowsToCsv } from "@/lib/export"
import { SelectionActionBar } from "@/components/ui/action-bar"
import { Button } from "@/components/ui/button"
import { Callout } from "@/components/ui/callout"
import { DataTable, type DataTableFacet } from "@/components/ui/data-table"
import { archiveProduct } from "../actions"
import { type ProductRow } from "../data"
import { productColumns } from "./product-columns"
import { ProductsStats } from "./products-stats"
/** The catalogue's departments, in the order the facet lists them. */
const CATEGORIES = ["Electronics", "Office", "Furniture", "Apparel", "Home", "Outdoor"]
const FACETS: DataTableFacet<ProductRow>[] = [
{
columnId: "category",
title: "Category",
options: CATEGORIES.map((category) => ({ label: category, value: category })),
},
{
columnId: "status",
title: "Status",
options: Object.entries(PRODUCT_STATUS_LABELS).map(([value, label]) => ({ label, value })),
},
]
/** The catalogue opens on what is for sale; the status facet brings the archive back. */
const OPENING_FILTERS: ColumnFiltersState = [{ id: "status", value: ["active"] }]
const CSV_COLUMNS = [
{ key: "sku", label: "SKU" },
{ key: "name", label: "Product" },
{ key: "category", label: "Category" },
{ key: "price", label: "Price (USD)" },
{ key: "stock", label: "Stock" },
{ key: "reorderPoint", label: "Reorder point" },
{ key: "sold", label: "Sold, 30 days" },
{ key: "supplier", label: "Supplier" },
{ key: "status", label: "Status" },
]
const plural = (count: number) => `${count} product${count === 1 ? "" : "s"}`
/** "A", "A and B", "A, B and C". */
const listed = (names: string[]) =>
names.length < 2 ? names.join("") : `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`
/** What to say when the server gave no answer at all — not a refusal, so the press can be tried again. */
const noAnswer = (rows: ProductRow[]) => `No answer from the server for ${listed(rows.map((row) => row.name))}. Try again.`
/**
* The catalogue's figures and its table, held in one island so an archive
* moves both. Filtering, search and paging happen in the browser —
* sixty rows are one page of JSON — and every archive goes through the server
* and is read back from its answer: an archived row takes its new status from
* the `Result`, so the status facet moves it out of the list. A success is
* said in the status line, a refusal in an alert above the rows; the two never
* share a slot.
*/
export type ProductsTableProps = {
/** The page's h1 id, which names the table. */
titleId: string
rows: ProductRow[]
/** The day an export is named for — "now", so the file and its figures agree. */
asOf: Date
/** Units sold in the thirty days before the current window, for the figures' pill. */
soldBefore: number
windowDays: number
}
export function ProductsTable({ titleId, rows: initial, asOf, soldBefore, windowDays }: ProductsTableProps) {
const router = useRouter()
const [rows, setRows] = React.useState(initial)
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(OPENING_FILTERS)
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({})
const [notice, setNotice] = React.useState("")
const [refusal, setRefusal] = React.useState<{ title: string; message: string } | null>(null)
const [busy, setBusy] = React.useState(false)
/**
* Archives each row in turn, and reports what the server said about all of
* them: the ones it archived, its refusals, and the rows it gave no answer
* for at all — a call that threw, which is no refusal and may be retried.
*/
const archive = React.useCallback(async (targets: ProductRow[]) => {
setNotice("")
setRefusal(null)
const archived: string[] = []
const refused: string[] = []
const unanswered: ProductRow[] = []
for (const target of targets) {
try {
const result = await archiveProduct(target.id)
if (result.ok) archived.push(result.data.id)
else refused.push(result.error.message)
} catch {
unanswered.push(target)
}
}
if (archived.length > 0) {
setRows((current) =>
current.map((row) => (archived.includes(row.id) ? { ...row, status: "archived" } : row))
)
}
return { archived, refused, unanswered }
}, [])
const archiveOne = React.useCallback(
async (row: ProductRow) => {
const { archived, refused, unanswered } = await archive([row])
if (unanswered.length > 0) return setRefusal({ title: "Not archived", message: noAnswer(unanswered) })
if (refused.length > 0) return setRefusal({ title: "Not archived", message: refused[0] })
if (archived.length > 0) setNotice(`Archived ${row.name}. It is off sale; its orders stay on record.`)
},
[archive]
)
async function archiveSelected() {
setBusy(true)
try {
const { archived, refused, unanswered } = await archive(rows.filter((row) => rowSelection[row.id]))
// A row the server never answered for stays picked, so the same press tries it again.
setRowSelection(Object.fromEntries(unanswered.map((row) => [row.id, true])))
if (archived.length > 0) setNotice(`Archived ${plural(archived.length)}.`)
const problems = [...refused, ...(unanswered.length > 0 ? [noAnswer(unanswered)] : [])]
if (problems.length > 0) setRefusal({ title: "Not archived", message: problems.join(" ") })
} finally {
setBusy(false)
}
}
function exportSelected() {
const picked = rows.filter((row) => rowSelection[row.id])
const written = downloadText(exportFilename("products", "csv", asOf), rowsToCsv(picked, CSV_COLUMNS))
setRefusal(written ? null : { title: "Not exported", message: "This browser cannot save the file." })
if (written) setNotice(`Exported ${plural(picked.length)} as CSV.`)
}
const ceiling = React.useMemo(() => Math.max(1, ...initial.map((row) => row.stock)), [initial])
const columns = React.useMemo(
() =>
productColumns({
onOpen: (row) => router.push(productHref(row.id)),
onArchive: (row) => void archiveOne(row),
ceiling,
}),
[router, archiveOne, ceiling]
)
const selected = Object.keys(rowSelection).length
return (
<div className="flex flex-col gap-[var(--density-gap,1rem)]">
<ProductsStats rows={rows} soldBefore={soldBefore} windowDays={windowDays} />
{/* The container the columns measure themselves against: see product-columns. */}
<div className="@container/products flex flex-col gap-3">
{refusal ? (
<Callout variant="danger" role="alert" title={refusal.title}>
{refusal.message}
</Callout>
) : null}
{/* Always mounted, so the first announcement is heard; collapsed to the
reader's ear alone while there is nothing to say. */}
<p
data-slot="products-status"
role="status"
aria-live="polite"
className={cn("text-sm text-muted-foreground", !notice && "sr-only")}
>
{notice}
</p>
<DataTable
aria-labelledby={titleId}
columns={columns}
data={rows}
facets={FACETS}
searchKey="product"
searchPlaceholder="Search name or SKU"
getRowId={(row) => row.id}
state={{ columnFilters, rowSelection }}
onColumnFiltersChange={setColumnFilters}
onRowSelectionChange={setRowSelection}
emptyMessage="No products match these filters."
pageSizeOptions={[10, 25, 50]}
/>
</div>
<SelectionActionBar count={selected} onClear={() => setRowSelection({})} itemLabel="product">
<Button type="button" variant="outline" size="sm" onClick={exportSelected}>
<DownloadIcon data-icon="inline-start" aria-hidden="true" />
Export
</Button>
<Button type="button" variant="destructive" size="sm" disabled={busy} onClick={() => void archiveSelected()}>
<ArchiveIcon data-icon="inline-start" aria-hidden="true" />
Archive
</Button>
</SelectionActionBar>
</div>
)
}