snow-uiProhome

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.

#ItemComponent and its dataSuggested route
M1home-pageHomePage (home: the queue, today's numbers, 30 days of sales, top products, recent orders; setup for a new store)/commerce
M2orders-pageOrdersPage (initial, query)/commerce/orders?view=&q=&payment=&fulfilment=&channel=&days=&sort=&page=&size=
M3order-detail-pageOrderDetailPage (order, customerOrders)/commerce/orders/[number]
—packing-slips-pagePackingSlipsPage (orders)/commerce/orders/packing-slips?ids=
M4products-pageProductsPage (initial, query, layout, collections, vendors)/commerce/products?q=&status=&collection=&stock=&vendor=&layout=grid
M5product-editor-pageProductEditorPage (product or null for a new one, collections, vendors)/commerce/products/[id] (new)
M6customers-pageCustomersPage (initial, query, countries, selected)/commerce/customers?view=&q=&country=&customer=
M7returns-pageReturnsPage (returns, selectedId, tab)/commerce/returns/[[...id]]?status=
—commerce-templateCommerceTemplateProvider, CommerceShell, useCommerceTemplate, useCommerceState, useRevalidate, commerceRoutes, the status chips, commerceErrorMessagethe commerce routes' layout

Blocks (registry:block)

New with this template:

ItemWhat it is
bulk-actions-barThe count of selected items (announced politely), the actions and “Clear selection” in a floating bar that sticks to the bottom of the view; Escape clears; it never takes the focus.
ranked-listTop N with values in text and decorative bars.

Shared with the CRM template (one implementation each; the CRM came first, these pages use the same blocks):

ItemHow Commerce uses it
kpi-ribbonHome's “Today so far” (vertical) and a customer's numbers: signed changes coloured by goodDirection (a supplement to the sign and the arrow), sparklines.
chart-cardHome's sales chart: a render function gets “Show as a table”, the title's ID and the loading state; height="lg" sizes the plot (give the chart height="100%"); empty replaces it before the first sale.
task-listHome's work queue: variant="queue", one link per kind of work with its count and age in the link's name; “All caught up” when empty.
activity-timelineThe order's timeline: items with their day heading, <time>, and a one-tab composer (no tab list) for comments with a note, ⌘Enter / Ctrl+Enter; { error } from onSubmit keeps the text with your server's words, the new comment takes the focus.
stage-pathThe order's fulfilment, read-only (aria-current="step", each state in words; a cancelled order's first step is lost).
record-layoutThe order and the product editor: header, main and a labelled aside (complementary); the aside under main below xl, tabs below md (CSS: no shift on hydration, both parts stay mounted).
split-viewReturns: scroll="page" (the list stays in view under the top bar), SplitViewList / SplitViewRow, a back link below lg; ↑ / ↓, Home and End move between rows.

Extended (additively; existing callers render as before):

  • data-table: selectable with selected / onSelectedChange (or defaultSelected): 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 (the bulk-actions-bar after the table); views (saved views as tabs with counts). selectRange and updateSelection are exported for your own lists.
  • record-properties: a property's edit (text, textarea, number, date, select, tags) with onSave edits it in place (the CRM's former inline-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 } from onSave (or a rejection) keeps the field open with the message.
  • filter-bar: an end slot (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

Terminal
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-commerce

npm 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)

tsx
// 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>
  )
}
tsx
// 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>
  )
}
tsx
// 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”.

MethodUsed byResult
getHome(), getCounts()M1, the sidebarCommerceHome; { unfulfilled, returns, lowStock }
listOrders(query)M2{ orders, total, counts } (the views' counts)
fulfilOrders(target), archiveOrders(target), addOrderTags(target, tags), exportOrders(target)M2 bulk actions{ ids } of the orders changed (archive's Undo uses them); a CSV Blob. target is { ids } or { query } (“Select all”).
unarchiveOrders(ids), getOrders(ids)M2, packing slips
getOrder(number), fulfilOrder(id, input), checkTracking(carrier, tracking)M3the changed Order; { valid }
refundOrder(id, input)M3the Order; amount-exceeds (on the amount field)
cancelOrder(id, reason), updateOrder(id, { tags, note }), addOrderComment(id, text), sendPaymentLink(id)M3
listProducts(query), updateProducts(ids, edit), deleteProduct(id)M4{ products, total }; { updated }
checkHandle(handle, productId), uploadProductMedia(file), saveProduct(id, draft)M5{ available, usedBy }; the image's URL; the saved Product, handle-taken (on the handle)
listCustomers(query), getCustomer(id), updateCustomer(id, patch), exportCustomers(query)M6{ customers, total, counts }; CustomerDetail
listReturns(), approveReturn(id, decision), rejectReturn(id, reason), reopenReturn(id)M7the changed ReturnRequest (reopenReturn is the toast's Undo)

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,50 in de) and refuses more decimals than the currency has; formatMoneyInput writes 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):

InputResult
A tracking number that doesn't fit the carrier (UPS 1Z + 16, USPS 20–22 digits, DHL 10 digits)fails the check
Refund reason declinethe provider declines the refund
A refund above what was paidamount-exceeds
A handle another product uses (field-cap)handle-taken, “Already used by Field cap”
Product title Reserveda field error on the title
failOn: ['listOrders', …] (?fail= on the site)those methods fail like a dropped connection

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 h1 per 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 AlertDialog that 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 → success or 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.