Commerce admin
Ship today’s orders, handle returns, keep products and stock right, and know the best customers.
The operations lead of an online store and its support staff. Shell: grouped sidebar with work-queue counts. Open the live preview (on the mock backend). Set up snow-ui and the registries first (installation); the template guides cover other routers than Next.js.
What's included
Pages (registry:page)
Each page is a client component that you mount from a route of your app with the data it shows; it reads the adapter, the routes and the formatting from CommerceTemplateProvider.
Blocks (registry:block)
New with this template:
Shared with the CRM template (one implementation each; the CRM came first, these pages use the same blocks):
Extended (additively; existing callers render as before):
data-table:selectablewithselected/onSelectedChange(ordefaultSelected): a checkbox column, the page's checkbox (checked, indeterminate), Shift+click and Shift+Space ranges from the last row toggled,isRowSelectable;selectAll(“Select all 1,204” once the page is selected: the page then acts on its query);bulkActions(thebulk-actions-barafter the table);views(saved views as tabs with counts).selectRangeandupdateSelectionare exported for your own lists.record-properties: a property'sedit(text,textarea,number,date,select,tags) withonSaveedits it in place (the CRM's formerinline-properties): click the value or press Enter on it, Enter (⌘Enter in a text area) saves, a select saves on choice, Escape cancels, the focus returns to the value;{ error }fromonSave(or a rejection) keeps the field open with the message.filter-bar: anendslot (the list / grid toggle).
Also used: app-shell-sidebar, user-menu, command-menu, page-header, order-summary, empty-state, sticky-save-bar (Settings), and choice-cards, file-dropzone, onboarding-checklist, form-error-summary (Auth).
Shared items
commerce-types (CommerceAdapter, the data types, CommerceResult, settleCommerce, the queries and their URL params: orderQueryFromParams / orderQueryToParams and the product and customer ones), commerce-math (refunds, money typed in fields, the variants matrix — below), use-follow-search (the pages following the URL), format and density. The mock data is mock-commerce (createMockCommerceAdapter, MOCK_COMMERCE_RULES, COMMERCE_SCENARIOS, mockCommerceDataset(scenario), queryOrders, queryProducts, queryCustomers, customerDetail, commerceHome, commerceCounts). No page or block imports it: install mock-commerce only to click through the pages before your backend is wired (a registry test checks that no installable item reaches it, even indirectly).
The whole template: commerce
@snow-ui-pro/commerce is a registry:item without files of its own: its registryDependencies are every page and block above.
Install
npx shadcn@latest add @snow-ui-pro/commerce
# Optional: the mock backend, to click through the pages first.
npx shadcn@latest add @snow-ui-pro/mock-commercenpm dependencies: react-hook-form, @phosphor-icons/react and @holakirr/snow-ui-charts (with its stylesheet, from the free snow-ui-charts item).
Wire it up (Next.js App Router)
// app/commerce/commerce-provider.tsx
'use client'
import type { Route } from 'next'
import NextLink from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import type { ReactNode } from 'react'
import {
CommerceTemplateProvider,
commerceRoutes,
} from '@/components/snow-ui-pro/templates/commerce/CommerceTemplate'
import type { CommerceCounts } from '@/components/snow-ui-pro/lib/commerce-types'
import type { LinkComponent } from '@/components/snow-ui-pro/lib/navigation'
import { commerceAdapter } from '@/lib/commerce' // your CommerceAdapter
const Link: LinkComponent = ({ href, ...props }) => (
<NextLink href={href as Route} {...props} />
)
export function CommerceProvider({
now,
counts,
sidebarCollapsed,
children,
}: {
now: string
counts: CommerceCounts
sidebarCollapsed: boolean
children: ReactNode
}) {
const router = useRouter()
const search = useSearchParams()
return (
<CommerceTemplateProvider
adapter={commerceAdapter}
navigate={(href) => router.push(href as Route)}
// Filters and the customer quick view: a new URL without a server
// round trip, which Next.js syncs with useSearchParams.
replace={(href) => window.history.replaceState(null, '', href)}
push={(href) => window.history.pushState(null, '', href)}
// The pages follow it: Back and Forward restore a page as it first
// rendered, and its filters and quick view from here.
search={search.toString()}
routes={{ ...commerceRoutes('/commerce'), settings: '/settings' }}
linkAs={Link}
now={now}
counts={counts}
timeZone="Europe/Lisbon" // the store's
storeName="Acme Supply"
sidebarCollapsed={sidebarCollapsed}
>
{children}
</CommerceTemplateProvider>
)
}// app/commerce/layout.tsx — "now" and the queue counts from the server;
// the sidebar as the person left it (the library's cookie)
import { readSidebarState } from '@holakirr/snow-ui'
import { headers } from 'next/headers'
import { getCounts } from '@/lib/commerce'
import { CommerceProvider } from './commerce-provider'
export default async function Layout({ children }: { children: React.ReactNode }) {
const collapsed = readSidebarState((await headers()).get('cookie')) === false
return (
<CommerceProvider
now={new Date().toISOString()}
counts={await getCounts()}
sidebarCollapsed={collapsed}
>
{children}
</CommerceProvider>
)
}// app/commerce/orders/page.tsx — the query lives in the URL
import { OrdersPage } from '@/components/snow-ui-pro/templates/commerce/OrdersPage'
import {
orderQueryFromParams,
orderQueryToParams,
} from '@/components/snow-ui-pro/lib/commerce-types'
import { listOrders } from '@/lib/commerce'
type Props = { searchParams: Promise<Record<string, string | undefined>> }
export default async function Page({ searchParams }: Props) {
const query = orderQueryFromParams(await searchParams)
// A query that arrives by a link starts the page over; the page's own
// filter changes only replace the URL.
return (
<OrdersPage
key={String(orderQueryToParams(query))}
query={query}
initial={await listOrders(query)}
/>
)
}The other pages follow the same pattern; key OrderDetailPage and ProductEditorPage by their record's ID. The list pages follow the URL: after Back, Forward or a link, the filters, the returns tab and the customer quick view say what the URL says, and the rows reload. Give the provider your router's search: a router may restore a page the way it first rendered it (Next.js does on Back and Forward), and the URL then wins over the page's first props. Without search the pages follow the browser's Back and Forward (popstate) between their own URLs only. Pages keep what they loaded after mounting: after a change (markChanged()), the navigation's counts refresh and a page mounted later reloads its data once (useRevalidate).
The adapter
Resolve with { ok: false, error } for expected failures (field puts the message on a form field); reject only when the request failed — the pages show it as a network error with “Try again”.
Money
Amounts are integers in the currency's minor units, formatted with Intl and the currency (format.money). commerce-math has the arithmetic the pages use, unit-tested:
refundBreakdown(order, selection, includeShipping): the selected units at their prices, less their share of the order's discounts, plus their share of the tax (both proportional to value), plus the shipping — capped at what was paid less earlier refunds (refundableAmount). Refunding every unit and the shipping gives the order's total.parseMoney(text, currency, locale)reads typed amounts (24.50,1,024.50,24,50inde) and refuses more decimals than the currency has;formatMoneyInputwrites them back.
The refund dialog suggests the breakdown's amount until you type one; any amount up to the maximum can go (a partial refund), and nothing is sent before a confirmation that names the amount and the card.
Variants
syncVariants(options, variants, defaults) keeps the variants in step with the options (optionCombinations, the first option changing slowest): a variant whose values still exist keeps its ID, SKU, price and stock; a new option spreads the existing variants over its first value; new combinations copy the price of the variant they share the most values with and start with no stock; at most MAX_VARIANTS (100). optionErrors and duplicateSkus back the editor's validation.
The mock rules
createMockCommerceAdapter({ latencyMs, scenario, failOn }) answers after a fixed delay (0 in tests, 400 on the site):
Scenarios (?scenario= on the site): default (1,204 orders over 120 days, 48 products, 3,410 customers, 37 return requests), new-store (no data: the setup checklist and the first-use states), busy-day (60 orders this morning), failed-payment (the newest orders' payments failed). The clock is 16 June 2026, 09:16 in Lisbon (COMMERCE_MOCK_NOW).
Accessibility
- One
h1per page, which takes the focus after a client-side navigation; a skip link, the top bar (header), the sidebar (nav“Primary”) with the work-queue counts in the links' names (“Orders, 14 orders to fulfil”), the record aside (complementary“Order details”). - Selection: real checkboxes, the page's one indeterminate when some rows are selected; Shift+Space or Shift+click selects a range; the count is announced politely; the bar comes after the table in the tab order and never takes the focus; Escape in it clears the selection and returns the focus to the page's checkbox; after a bulk change the focus goes to the first row.
- Statuses are chips with text (a failed payment adds an icon); stock levels say “4 left” with a warning icon; colour is never the only cue.
- Destructive and irreversible actions are confirmed in an
AlertDialogthat names the object and the consequence: refunds (with the amount and the card), cancelling an order, deleting a product, rejecting a return. Archiving offers Undo instead. - Forms follow the Pro form rules: a changed field is checked when it loses focus; the tracking number and the handle show
progress→successor an error; on save the first invalid field takes the focus, or a summary of the errors when there are more than three; busy buttons keep their label and ignore a second press. - The product editor's save bar saves with ⌘S / Ctrl+S and asks before a link or a reload leaves unsaved changes.
- Every list ignores answers to older queries (a slow response never replaces a newer one).
- Checked in the site's end-to-end tests: every page at 320, 768 and 1280px in the light and dark themes with axe (WCAG 2.2 AA), no sideways scrolling, the scenario states, right to left, and each page's flows with the keyboard only — bulk actions, refunds, fulfilment, the product editor, the quick view, returns.
Not yet
- Draft orders, collections, segments and the inventory page are not part of this release: the sidebar's Inventory link is the low-stock filter of Products.
- Creating an order and importing products from a CSV are left out.
- The product grid has no selection: bulk edits are in the list.
- Media reordering is by the image's menu (Move earlier / later), not by dragging.