Module kit / Commerce and payments
Storefront preview
commerce-payments/ui/storefront-previewlive
| Kind | frontend |
|---|---|
| Surface | SPA |
| Maturity | prod |
| Reusable | extract |
| Design system | alchemy-ui |
| Stack | react-19, react-router-7, tailwind-3.4, alchemy-ui |
| Depends on | _shared/design-system-and-tokens, _shared/react-app-skeleton, _shared/workspace-scoping |
| Renders | commerce-payments/product-catalog-crud, commerce-payments/checkout-and-order-creation, commerce-payments/payrexx-order-settlement, commerce-payments/order-fulfillment-and-delivery, commerce-payments/workshop-seat-capacity |
| Entry | alchemy_labs/frontend/src/pages/commerce/Storefront.tsx |
| Reference implementation |
|
| Measured on | alchemy_labs main 62ba4fc41009b454e1c033e5036c7e8577db2061 2026-09-12 |
| Version | 1 |
Purpose
The merchant's own authenticated preview of their public store: opening /store/storefront shows the exact product grid an anonymous visitor would see (every product with status === 'published', each card linking to its own /buy/:productId checkout) plus a one-click "copy the store link" action, so a merchant can check what they are about to share before they share it. The invariant that makes it trustworthy: the page's own filter, products.filter(p => p.status === 'published'), is the same predicate the backend enforces on the read a card's link actually resolves to (GET /commerce/public/product/{id} returns only Product.is_active.is_(True) rows, commerce_router.py line 868): a card shown here is a card a real visitor can open, and a card hidden here is a card that visitor's link would 404 on.
It makes visible one sibling backend recipe directly: product-catalog-crud, whose GET /api/products is this page's only read. The other four backend recipes in this domain (checkout-and-order-creation, payrexx-order-settlement, order-fulfillment-and-delivery, workshop-seat-capacity) are not called by this component at all; this preview is the merchant-facing anchor a visitor leaves through, via a card's /buy/:id link, to reach the pipeline those four recipes cover. That pipeline itself renders on BuyPage.tsx, out of scope for this recipe.
When to use
Trigger phrases: "the storefront preview", "what does my store look like", "the store link", "copy my store link", "which products are live", "my public store page". Surface: SPA, a private Labs page behind PrivateRoute, reached only at /store/storefront ("Storefront" under the "Store" domain, App.tsx line 973, live for every signed-in tenant with the store surface).
Not this recipe: the page a visitor actually lands on when they follow a card (/buy/:productId, BuyPage.tsx), a separate, unauthenticated, single-product page this recipe does not rebuild; there is no live public store-listing route in this app at all: BuyPage.tsx is reached only per product, never through a /store/<slug> style public index, so "the public store" a visitor experiences is entirely this recipe's grid of individual /buy/:id links, never a page of its own. Also not this recipe: /billing/checkout (Checkout.tsx), the Labs platform's own Stripe SaaS pricing-tier picker, a different product, a different payment provider, and a different domain entirely from a merchant's Payrexx-backed commerce store. And not the four backend recipes a visitor's checkout actually exercises once they leave this page (backend_modules above), none of which this component calls.
Stack assumptions
S1 tokens and S2 skeleton assumed; pinned per the source at 62ba4fc: react 19, react-router-dom 7, tailwindcss 3.4. Component library: @prima-modules/alchemy-ui, of which the page uses exactly one atom, Button (components/alchemy-ui re-export); everything else on the page, the header, the chrome mock, the hero, the product tiles, is raw Tailwind against this repo's token classes (purple, ink, ink-60, paper, line, shadow-card, shadow-lift, the same @prima-modules/tokens preset billing-credits/ui/subscription-manager's gradients draw from), not alchemy-ui molecules; there is no Card, no Heading, no Text on this page, unlike every other frontend recipe in this kit measured so far. Data-fetch style: plain fetch through commerceService.ts's getAuthHeaders() (Bearer token from authSession.ts, S2, plus X-Workspace-ID read from localStorage when set), not the api axios singleton billing-credits/ui/subscription-manager uses; this page's provider (commerce/store.tsx) is its own fetch layer. Copy language: German, de-CH, every string on the page including the two hardcoded ones (Gotchas).
Data model
Client state (Storefront.tsx line 9 to 19), all local to the component: copied: boolean (initially false, flips true on the copy click and resets after 1.6s via setTimeout). url, a const, not state: the literal string 'atelier.alchemy.store', identical for every tenant (Gotchas). Everything else the page renders comes from useStore() (commerce/store.tsx), a context the route re-provides fresh on every mount of CommerceStoreProvider (App.tsx line 976): products: Product[], fetched by GET /api/products and mapped through productFromApi (adapter.ts line 184 to 227). The component destructures only products, never the context's own loading or error booleans (store.tsx lines 47 to 48), which is why this page has no loading spinner and no error card of its own (Application logic, Gotchas).
The demo's props (demo.tsx): { viewer, workspace, products: StoreProduct[] }, StoreProduct trimmed to the six fields the component reads (id, name, description, glyph, accent, status, billing) off the real Product type (types.ts lines 88 to 105); BillingConfig likewise trimmed to the fields billingSummary reads. copied is not a prop: it is the one piece of genuinely local UI state, seeded to false inside the demo's own useState, the same convention billing-credits/ui/subscription-manager's demo uses for its voucher input.
Browser storage: nothing of its own; relies on S2's accessToken and on currentWorkspaceId / workspaceId / X-Workspace-ID, whichever localStorage key commerceService.ts's getWorkspaceId() finds first (lines 9 to 15), attached as the X-Workspace-ID header the one read below actually consumes (Security and tenancy).
API surface
One read this page's own data depends on, plus two the provider it mounts inside issues unconditionally and this page never touches:
| Call | Route | Auth / tenancy | Parsed | Rendered as |
|---|---|---|---|---|
listProducts() (commerceService.ts 276 to 282) | GET /api/products | VerifiedUser + WorkspaceID (commerce_router.py 150 to 153) | the array, through productFromApi | live (the published subset) as the product grid; the draft rest, filtered out, renders nothing |
listOrders() (510 to 517), listSubscriptions() (540 to 545) | GET /api/orders, GET /api/commerce/subscriptions | same deps | into orders / subscriptions on the shared context | nothing on this page; CommerceStoreProvider.refresh() (store.tsx 76 to 92) fires all three with Promise.all on every mount regardless of which commerce page it wraps, so opening /store/storefront costs two reads this component never reads (Gotchas) |
No write. No error path of its own: a thrown listProducts() (a 401, a 500) is caught by store.tsx's refresh() and turned into the context's error string (line 91), which this page never reads, so a failed load and an empty store are visually identical here: an empty grid, nothing else (Application logic).
Application logic
Storefrontmounts insideCommerceStoreProvider, which the route wraps fresh (App.tsx974 to 979, not the outerApp-level provider some pages share), so the provider'srefresh()(store.tsx76) fires on every visit to this exact route, issuing all three reads beforeproductsis populated.useStore()destructuresproductsonly;live = products.filter(p => p.status === 'published')(line 11) runs on every render, no memoization, cheap at the product counts this app handles.urlis the fixed literal'atelier.alchemy.store', computed once per render, not derived fromworkspace, from an EmDash push target, or from any per-tenant field (Gotchas).copy()(lines 15 to 19): writeshttps://${url}to the clipboard if the API is available (navigator.clipboard?.writeText, swallowing a rejection with an empty catch), flipscopiedto true, and a baresetTimeoutflips it back after 1600ms with no cleanup on unmount (Gotchas).- The chrome mock and hero are static markup: the address bar shows the same hardcoded
url, the mark and "Atelier Zürich" title are literal JSX, not read fromworkspace.nameor any brand field (Gotchas): a merchant named "Studio Rendez-Vous" sees "Atelier Zürich" in their own preview. - The grid maps
live; each card is a plain<Link>to/buy/${p.id}, renderingp.glyph,p.name,p.description(clamped to two lines byline-clamp-2),billingSummary(p.billing)and a static "Ansehen →" affordance. No pagination, no sort order beyond whateverlist_productsreturns (created_at desc,commerce_router.py159), no per-card availability check (workshop seat counts,workshop-seat-capacity, are not queried here). - When
liveis empty (no published products, or the read has not resolved yet), the.mapproduces nothing: the chrome mock, the hero and the header all still render, only the grid area is blank, with no message telling the merchant why.
Invariants
- What is shown here is exactly what a visitor could open:
live's predicate,status === 'published', is the client-side mirror of the server's ownis_active.is_(True)filter on the public read (commerce_router.py868); the only way this preview and the real store could diverge is a change to one filter without the other, not anything this component itself gets to decide. - No mutation without an endpoint: this page performs zero writes; its only actions are a clipboard write and client-side navigation via
<Link>. - A loading store and an empty store are the same screen: the component reads no
loadingflag, so there is exactly one "nothing here yet" rendering, not two (Data model, Gotchas). - The copy-link target is a constant, not a lookup:
urlnever readsworkspace, so this invariant is really an anti-invariant worth stating plainly: the button always copies the same string regardless of which tenant is signed in (Gotchas).
Frontend
The component, rebuilt from the demo in the kit with the design system the recipe names, one frame per state. What is in the frames is what the recipe describes and what Primacode builds from; it is not a picture of the running application.
default server rendered, hydrated in view empty server rendered, hydrated in view commerce-payments/ui/storefront-preview/demo.tsx
/**
* Live demo for commerce-payments/ui/storefront-preview.
*
* Labs' Storefront.tsx (branch main, 62ba4fc, 92 lines) rebuilt as a
* presentational component. The real page reads `products` off
* `useStore()` (`commerce/store.tsx`, a context that fetches
* `GET /api/products` plus orders and subscriptions on every mount of
* `CommerceStoreProvider`, the last two unused by this page); this rebuild
* takes the same `products` array as a prop instead, so the demo mounts no
* provider and reaches no network, the convention every demo in this kit
* follows (AUTHORING.md "The exports").
*
* What is kept byte-faithful: the JSX tree (`PageHeader`, the copy-link
* button and its 1.6s "✓ Kopiert" flip, the browser-chrome mock, the store
* hero mark and tagline, the product grid and its card markup down to the
* Tailwind classes), the `live = products.filter(p => p.status ===
* 'published')` predicate (the same predicate the backend enforces on the
* public read a card's link resolves to, `commerce_router.py` line 868,
* Application logic), the `billingSummary` price line ported verbatim from
* `lib/commerceFormat.ts` lines 3 to 49 (`chf`, `perInterval`,
* `billingSummary`, pure, no `Intl` surprises since `Intl.NumberFormat`
* behaves the same on the server and in the browser, unlike the locale-
* dependent `toLocaleDateString` calls other recipes in this kit have to
* replace), and the absence of any loading or error branch: the source
* component destructures only `products` from `useStore()`, never `loading`
* or `error`, so a first fetch and a genuinely empty store render
* identically (an empty grid, no message); that is a real gap in the app,
* reproduced here rather than fixed, which is why this recipe ships two
* states, not three (Frontend, Gotchas).
*
* What necessarily differs, and why:
* - `Navigation` (a thin delegator to `AppShell`, `components/Navigation.tsx`
* line 23) is the shell rebuild every other recipe in this kit already
* ships, `ui-design-system/ui/appshell-navigation/demo.tsx`, imported
* relatively and mounted with `activePath="/store/storefront"` so the
* "Store" rail entry (`nav.tsx` line 111 to 125 in this repo, the same
* list the real app's `nav.tsx` line 162 to 174 mirrors) is the one lit.
* - The source hardcodes one literal, `const url = 'atelier.alchemy.store'`
* (line 13), for every tenant regardless of their actual store domain,
* a real gap, not a per-tenant value (Gotchas). This demo's sample data
* swaps it for a non-resolving placeholder domain
* (`atelier-nordwind.example`, RFC 2606) rather than reproducing a string
* that reads as a live host, per this kit's house rule against domains in
* sample data; the hardcoding itself is still named in Gotchas.
* - `navigator.clipboard?.writeText(...)` runs only inside the click
* handler, never at module scope, so it is kept as-is: harmless in a
* server render (never called) and functional once the frame hydrates.
* - The `grain` class on the product-tile background (line 70) is kept
* verbatim for fidelity even though it resolves to nothing: it is not
* defined in the source app's own CSS (`grep -n '\.grain' frontend/src/
* index.css` at 62ba4fc is empty) and is not one of this kit's token or
* alchemy-ui classes either, so it has no visible effect in the app or
* here (Gotchas, the same class of dead-class trap
* `billing-credits/ui/subscription-manager`'s Gotchas names for `dark:`).
*/
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import { defineStates } from '@prima-modules/demo';
import { Button } from '@prima-modules/alchemy-ui';
import { Shell, type ShellUser, type ShellWorkspace } from '../../../ui-design-system/ui/appshell-navigation/demo';
import { DOMAINS } from '../../../ui-design-system/ui/appshell-navigation/nav';
/* ---- props --------------------------------------------------------------- */
/** `Interval` and `BillingModel`, `types.ts` lines 2 to 3. */
type Interval = 'week' | 'month' | 'year';
type BillingModel = 'one_time' | 'subscription' | 'intro_then_sub';
/**
* `BillingConfig`, `types.ts` lines 18 to 32, trimmed to the six fields
* `billingSummary` actually reads (`commerceFormat.ts` lines 44 to 49); the
* source's `oneTimePrice`/`subPrice`/`subInterval`/`introPrice`/`thenPrice`/
* `thenInterval` also carry `convertAfterValue`, `convertAfterUnit` and
* `sendReminders`, unread by this page and left out here.
*/
export interface BillingConfig {
model: BillingModel;
oneTimePrice: number;
subPrice: number;
subInterval: Interval;
introPrice: number;
thenPrice: number;
thenInterval: Interval;
}
/**
* `Product`, `types.ts` lines 88 to 105, trimmed to the fields Storefront.tsx
* reads (`id`, `name`, `description`, `glyph`, `accent`, `status`, `billing`
* via `billingSummary`); `contentHtml`, `images`, `emdash`, `kind` and `tags`
* exist on the real type and are not read by this page.
*/
export interface StoreProduct {
id: string;
name: string;
description: string;
/** an emoji used as the tile's visual, `adapter.ts` line 203 */
glyph: string;
/** hex, the tile's gradient wash, `adapter.ts` line 204 */
accent: string;
status: 'published' | 'draft';
billing: BillingConfig;
}
export interface Props {
/** The signed-in merchant, for the shell's account row. */
viewer: ShellUser;
workspace: ShellWorkspace;
/** Every product in the workspace, published and draft alike, exactly as
* `useStore()` returns them; the component's own filter decides what a
* visitor would see (Application logic). */
products: StoreProduct[];
}
/* ---- helpers, ported verbatim (lib/commerceFormat.ts lines 3 to 49) ------ */
const chf = (n: number) =>
new Intl.NumberFormat('de-CH', { style: 'currency', currency: 'CHF', maximumFractionDigits: 0 }).format(n);
const perInterval: Record<Interval, string> = { week: '/wk', month: '/mo', year: '/yr' };
const billingSummary = (b: BillingConfig): string => {
if (b.model === 'one_time') return `${chf(b.oneTimePrice)} once`;
if (b.model === 'subscription') return `${chf(b.subPrice)}${perInterval[b.subInterval]}`;
const intro = b.introPrice === 0 ? 'Free' : `${chf(b.introPrice)}`;
return `${intro}, then ${chf(b.thenPrice)}${perInterval[b.thenInterval]}`;
};
/* ---- sample data ----------------------------------------------------------- */
const viewer: ShellUser = { name: 'Elena Roth', email: 'elena@example.com', isAdmin: false };
const workspace: ShellWorkspace = { id: 'ws_1', name: 'Atelier Nordwind', slug: 'atelier-nordwind' };
const publishedProducts: StoreProduct[] = [
{
id: 'prod_linen_apron',
name: 'Leinen-Schürze, handgenäht',
description: 'Schwere belgische Leinwand, verstellbarer Nackenriemen, ein Fach vorne.',
glyph: '🧵',
accent: '#BA573D',
status: 'published',
billing: { model: 'one_time', oneTimePrice: 68, subPrice: 0, subInterval: 'month', introPrice: 0, thenPrice: 0, thenInterval: 'month' },
},
{
id: 'prod_dye_workshop',
name: 'Workshop: Naturfarben binden',
description: 'Ein Nachmittag Indigo, Krapp und Walnussschale, alle Materialien inklusive.',
glyph: '🎨',
accent: '#6844C4',
status: 'published',
billing: { model: 'one_time', oneTimePrice: 120, subPrice: 0, subInterval: 'month', introPrice: 0, thenPrice: 0, thenInterval: 'month' },
},
{
id: 'prod_paper_guide',
name: 'Digitaler Guide: Papier schöpfen',
description: 'PDF plus Videoreihe, monatlich um neue Techniken ergänzt.',
glyph: '📄',
accent: '#2F7A5B',
status: 'published',
billing: { model: 'intro_then_sub', oneTimePrice: 0, subPrice: 0, subInterval: 'month', introPrice: 0, thenPrice: 12, thenInterval: 'month' },
},
{
id: 'prod_membership',
name: 'Atelier-Mitgliedschaft',
description: 'Freier Zugang zu allen offenen Werkstattabenden, monatlich kündbar.',
glyph: '🗝️',
accent: '#3C6E9B',
status: 'published',
billing: { model: 'subscription', oneTimePrice: 0, subPrice: 45, subInterval: 'month', introPrice: 0, thenPrice: 0, thenInterval: 'month' },
},
];
/** Not yet released: filtered out of `live` in every state, present in `products` in both. */
const draftProduct: StoreProduct = {
id: 'prod_autumn_collection',
name: 'Herbstkollektion 2026',
description: 'In Vorbereitung, noch nicht sichtbar für Besucher.',
glyph: '🍂',
accent: '#8C6A3F',
status: 'draft',
billing: { model: 'one_time', oneTimePrice: 0, subPrice: 0, subInterval: 'month', introPrice: 0, thenPrice: 0, thenInterval: 'month' },
};
export const states = defineStates<Props>({
'default': {
label: 'Four published products across every billing model, one draft not shown',
props: { viewer, workspace, products: [...publishedProducts, draftProduct] },
height: 1100,
viewport: 1440,
},
'empty': {
label: 'Nothing published yet: the grid renders with zero cards, no dedicated empty-state copy (a real gap, not fixed here)',
props: { viewer, workspace, products: [draftProduct] },
height: 640,
viewport: 1440,
},
});
/* ---- the page (Storefront.tsx lines 9 to 92) ------------------------------ */
function StorefrontPage({ products }: Pick<Props, 'products'>) {
const live = products.filter((p) => p.status === 'published');
const [copied, setCopied] = useState(false);
const url = 'atelier-nordwind.example';
const copy = () => {
navigator.clipboard?.writeText(`https://${url}`).catch(() => {});
setCopied(true);
setTimeout(() => setCopied(false), 1600);
};
return (
<div>
{/* PageHeader, commerce/ui.tsx lines 77 to 102 */}
<div className="flex flex-wrap items-end justify-between gap-4 mb-7">
<div>
<div className="text-[12px] font-semibold uppercase tracking-[0.14em] text-purple mb-1.5">Commerce</div>
<h1 className="text-[28px] leading-none font-extrabold tracking-[-0.02em] text-ink">Storefront</h1>
<p className="text-ink-60 mt-2 text-[15px] max-w-2xl">
So sieht dein öffentlicher Store aus, samt Link zum Teilen. Jedes Produkt öffnet seine eigene Kaufseite.
</p>
</div>
<Button variant="ghost" size="sm" onClick={copy}>
{copied ? '✓ Kopiert' : 'Teilen-Link kopieren'}
</Button>
</div>
{/* browser chrome mock */}
<div className="rounded-2xl border border-line bg-white shadow-card overflow-hidden">
<div className="flex items-center gap-2 px-4 py-3 border-b border-line bg-paper">
<div className="flex gap-1.5">
<span className="h-3 w-3 rounded-full bg-[#e4dcf3]" />
<span className="h-3 w-3 rounded-full bg-[#e4dcf3]" />
<span className="h-3 w-3 rounded-full bg-[#e4dcf3]" />
</div>
<div className="flex-1 mx-2">
<div className="bg-white border border-line rounded-lg px-3 py-1.5 text-[12.5px] text-ink-60 text-center">
🔒 {url}
</div>
</div>
</div>
{/* store hero */}
<div className="px-8 py-10 text-center bg-gradient-to-b from-purple-50/60 to-transparent">
<div className="h-14 w-14 mx-auto rounded-2xl bg-purple text-white grid place-items-center shadow-lift mb-4">
<svg width="26" height="26" viewBox="0 0 32 32" fill="none">
<path d="M9 21 L16 9 L23 21 Z" stroke="white" strokeWidth="2.4" strokeLinejoin="round" />
</svg>
</div>
<h2 className="text-[26px] font-extrabold tracking-[-0.02em] text-ink">Atelier Zürich</h2>
<p className="text-ink-60 mt-1">Objekte, Workshops und Guides aus dem Studio.</p>
</div>
{/* product grid */}
<div className="px-6 sm:px-8 pb-10 grid sm:grid-cols-2 lg:grid-cols-3 gap-5">
{live.map((p) => (
<Link
key={p.id}
to={`/buy/${p.id}`}
className="rounded-xl border border-line bg-white overflow-hidden hover:shadow-lift hover:-translate-y-0.5 transition-all group"
>
<div
className="h-32 grid place-items-center relative overflow-hidden grain"
style={{ background: `linear-gradient(150deg, ${p.accent}26, ${p.accent}0d)` }}
>
<span className="text-5xl">{p.glyph}</span>
</div>
<div className="p-4">
<div className="font-semibold text-ink group-hover:text-purple transition-colors leading-snug">
{p.name}
</div>
<div className="text-[13px] text-ink-60 mt-1 line-clamp-2">{p.description}</div>
<div className="flex items-center justify-between mt-3">
<span className="text-[14px] font-bold tnum text-ink">{billingSummary(p.billing)}</span>
<span className="text-[13px] font-semibold text-purple">Ansehen →</span>
</div>
</div>
</Link>
))}
</div>
</div>
</div>
);
}
export default function Demo({ viewer: v, workspace: ws, products }: Props) {
return (
<Shell domains={DOMAINS} user={v} workspaces={[ws]} currentWorkspaceId={ws.id} activePath="/store/storefront" hasTour={false}>
<StorefrontPage products={products} />
</Shell>
);
}
Component tree, from the recipe
<Route /store/storefront> PrivateRoute > CommerceStoreProvider (children)
Navigation = AppShell
Storefront
PageHeader eyebrow "Commerce", title "Storefront", sub, actions: Button ghost (copy-link, "✓ Kopiert" for 1.6s)
browser-chrome mock rounded-2xl card
chrome bar 3 dots + url pill "🔒 {hardcoded url}"
store hero mark svg + "Atelier Zürich" + tagline (both hardcoded, Gotchas)
product grid live = products.filter(status === 'published'), .map:
Link /buy/:id > tile (grain bg wash in p.accent) + glyph, name, description, billingSummary(p.billing), "Ansehen ->"
[live.length === 0] nothing renders here; no dedicated empty-state node (Application logic, Gotchas) The rendering is the live rebuild storefront-preview/demo.tsx, built from @prima-modules/alchemy-ui and @prima-modules/tokens and rendered by the docs host as one frame per state. The page's one input is products; the tree is the frontmatter's. The console is rendered inside the shell rebuild (ui-design-system/ui/appshell-navigation/demo.tsx, imported relatively the way billing-credits/ui/subscription-manager's demo imports it) with a member viewer, not an admin, on /store/storefront, so "Store" is the lit rail entry.
States (rendering.states, in order): default (four published products spanning all three billing models (one-time, subscription, intro-then-subscription) plus one draft product present in products but absent from the grid, proving the filter); empty (only the same draft product, so live is empty and the grid area renders no cards, no message, directly under the still-rendered chrome mock and hero: this is the real component's actual "nothing to show" rendering, not an invented empty-state design, Application logic). A third loading state is deliberately not given its own frame: the source has no visually distinct loading rendering to show (Data model), so a loading state here would either duplicate empty pixel-for-pixel or invent a spinner the app does not have; that is a real gap in the source, noted here and in Gotchas rather than papered over with a state the component cannot actually produce.
Tokens and classes as the source uses them: border-line, bg-paper, text-ink / text-ink-60, bg-purple / text-purple, shadow-card, shadow-lift, all from the @prima-modules/tokens preset, not alchemy-ui's own Tailwind layer (Stack assumptions); the single alchemy-ui atom is Button variant="ghost" size="sm". The product-tile background carries a grain class with no definition anywhere in this repo's CSS or in the app's own (Gotchas), kept for fidelity, visually inert either place.
Responsive: the grid is grid sm:grid-cols-2 lg:grid-cols-3, one column below sm, two at sm, three at lg; the header's actions wrap under the title on a narrow viewport (flex-wrap). Empty state: empty above, no dedicated empty-state component is used (commerce/ui.tsx's own EmptyState export exists in this app and is not reached for from this page, a real, measured choice, not an oversight this recipe can correct). Error state: none exists on this page (Data model); a failed GET /api/products looks identical to empty. Loading state: not given, for the reason above.
What the demo deliberately does not render: CommerceStoreProvider itself (the demo takes products as a prop, Data model); the real clipboard permission prompt some browsers show on first use (the click handler is real and functional once the frame hydrates, only untested here); the workspace-provider chain (AuthContext, WorkspaceContext) the shell rebuild already stands in for, the same convention every sibling demo in this kit follows.
Security & tenancy
What the browser may hold: S2's tokens and, when set, a workspace id string in localStorage, never a Payrexx credential, never a product's raw price beyond what GET /api/products already returns to its owner. Origin and CSRF: plain fetch with a Bearer header, not a cookie, the same as S2's default; X-Workspace-ID is attached client-side from localStorage and is advisory only, since WorkspaceID (api/dependencies.py) derives the authoritative workspace server-side from the verified session, not from a header a client could forge to read another workspace's catalog. Surface gating: PrivateRoute checks hasSurface for the build's own surface (store under the Labs surface set) before this component ever mounts (PrivateRoute.tsx); the backend refuses the same request at the router mount point independently (surface_gate.py), so the frontend check is UX only, not the boundary, the same split PrivateRoute.tsx's own comment documents.
Tenancy here is per-workspace, unlike billing-credits/ui/subscription-manager's per-user page: GET /api/products takes both VerifiedUser and WorkspaceID (commerce_router.py 150 to 153) and filters Product.workspace_id == workspace_id server-side (line 156), so switching workspaces in the shell and returning to this route does change what renders here, correctly. Hidden by CSS versus enforced: nothing; every published product a merchant's workspace owns is shown to that merchant regardless of role, and only the server enforces which workspace's rows come back. Attacker-controlled input the page sends: none; this route sends no path or query parameters of its own beyond the standard headers. What the page renders from the server unescaped: nothing; name, description and glyph are React children, escaped like any other string, and no dangerouslySetInnerHTML appears on this page (unlike the product editor, which renders contentHtml, out of scope here).
Tests
- Colocated with the demo:
storefront-preview/demo.test.tsx(vitest, jsdom,@testing-library/react),afterEach(cleanup)per this kit'stest-layout:defaultrenders exactly four cards and never the draft product's name;emptyrenders zero cards and still renders the chrome mock's url pill and the hero title; clicking the copy button flips the label to "✓ Kopiert" and, after the timer, back; no state's markup contains an access-token shape. - The kit's render smoke:
node tooling/verify/render-smoke.mjs commerce-payments/ui/storefront-previewrenders both states withrenderToStringandfetchstubbed (Acceptance checks). - Labs at 62ba4fc has no unit test of
Storefront.tsx(grep -rl Storefront frontend/src --include='*.test.*'is empty) and no Playwright spec for/store/storefront. Backend coverage ofGET /api/productsbelongs toproduct-catalog-crud's own suite, not to this page. - What a new app adds: a jsdom test that a
listProducts()rejection leaves the grid empty with no visible error, to pin down the gap this recipe's Gotchas documents today so a future fix has something to break; a Playwright spec that seeds one published and one draft product and asserts the draft's name never appears in/store/storefront's DOM.
Deploy & ops
Build target: the Labs Vite bundle (S2), a lazy chunk (App.tsx line 162, lazy(() => import('./pages/commerce/Storefront'))). Env baked at build: VITE_API_URL, S2's own; no other PUBLIC_*/VITE_* value is read on this page. CSP and CORS: nothing of its own; GET /api/products answers on the API origin under the SPA's existing allowance, gated by the store surface as above. Deploy path: S13's frontend rsync with the bundle; rollback is the bundle's. Nothing to migrate here directly; the schema GET /api/products reads belongs to product-catalog-crud. Argus: a synthetic workspace session calling GET /api/products returning 200 with an array is this page's liveness; the page has no health signal of its own.
Gotchas
- 2026-09-12 (measured at 62ba4fc):
const url = 'atelier.alchemy.store'(line 13) is a fixed literal, identical for every tenant regardless of their actual store domain, EmDash push target, or workspace slug. The copy-link button always copies the same string. A rebuild reads the real domain offworkspaceor the product's ownemdash.url(types.tsline 85) instead. - 2026-09-12 (measured at 62ba4fc): the store hero is likewise static: "Atelier Zürich" (line 57) and the tagline "Objekte, Workshops und Guides aus dem Studio." (line 58) are literal JSX, not read from
workspace.nameor any brand field. Every tenant's preview shows the same studio name. - 2026-09-12 (measured at 62ba4fc): the product-tile background carries a
grainclass (line 70) that resolves to nothing:grep -n '\.grain' frontend/src/index.cssat this commit is empty, and no Tailwind plugin infrontend/tailwind.config.jsdefines it either. The comment onindex.cssline 390 to 393 describes "the faint Swiss paper grain" as a shell-level effect (.az-shell's background gradients), a different, unrelated rule; the product tile's owngrainclass has never been wired to anything. Visually inert in the app today. - 2026-09-12 (measured at 62ba4fc):
CommerceStoreProvider.refresh()(store.tsx76 to 92) fireslistProducts(),listOrders()andlistSubscriptions()together withPromise.allon every mount; because the route wraps a fresh provider per commerce page (App.tsx974 to 979 et al.) rather than sharing one at the app root, visiting/store/storefrontcosts two reads (orders, subscriptions) this component never uses. Cheap at current data volumes, a real amount of unnecessary network on a merchant with a long order history. - 2026-09-12 (measured at 62ba4fc): the component reads no
loadingand noerrorfromuseStore()(lines 9 to 10 only destructureproducts), so a first fetch and a genuinely empty, all-draft store render identically: the chrome mock and hero paint immediately, the grid area is blank, with nothing telling the merchant which case they are looking at. - 2026-09-12 (measured at 62ba4fc):
setTimeout(() => setCopied(false), 1600)(line 18) has no cleanup; a merchant who clicks copy and navigates away within 1.6s leaves a pending timer that callssetStateon an unmounted component. Harmless in practice (React discards it), the same class of un-cleaned timer asbilling-credits/ui/subscription-manager's post-vouchersetTimeout. - 2026-09-12 (measured at 62ba4fc):
commerce/ui.tsxexports anEmptyStatecomponent (used elsewhere in the commerce pages, e.g.Products.tsx) that this page never imports; the empty grid documented above is not a deliberate design decision reachable in the code, it is simply the absence of one.
Reference implementation
Branch: alchemy_labs main at 62ba4fc41009b454e1c033e5036c7e8577db2061 (2026-09-12). Paths relative to the repo root:
frontend/src/pages/commerce/Storefront.tsx(92 lines): imports (1 to 7), the component,liveandcopied(9 to 19), the header (21 to 33), the chrome mock and hero (35 to 59), the product grid (61 to 87).frontend/src/components/Navigation.tsx: the delegator toAppShell.frontend/src/components/nav.tsx: thestoredomain, its five items including "Storefront" (162 to 174).frontend/src/components/commerce/store.tsx:useStore()(184),StoreProviderandrefresh()(76 to 92).frontend/src/components/commerce/ui.tsx:PageHeader(77 to 102), the unusedEmptyState(104 onward).frontend/src/components/commerce/adapter.ts:productFromApi(184 to 227), thestatus/glyph/accentmapping (203, 204, 225).frontend/src/lib/commerceFormat.ts:chf,perInterval,billingSummary(3 to 49).frontend/src/services/commerceService.ts:listProducts(276 to 282),listOrders(510 to 517),listSubscriptions(540 to 545),getAuthHeadersandgetWorkspaceId(9 to 26).frontend/src/pages/commerce/BuyPage.tsx: the page a card's link resolves to, not rebuilt by this recipe.frontend/src/App.tsx: the lazy import (162), the route (973 to 981).backend/api/routers/commerce_router.py:list_products(149 to 163),public_get_product(853 to 873, theis_activefilter this recipe's Purpose cites at line 868).- The demo:
modules/commerce-payments/ui/storefront-preview/demo.tsx; the shell it renders in:modules/ui-design-system/ui/appshell-navigation/demo.tsxandnav.tsx.
Deviations of the reference from this recipe, all dated 2026-09-12: the hardcoded url and hero copy; the dead grain class; the unread loading/error context fields; the two unused parallel reads on every mount; the uncleaned setTimeout.
Acceptance checks
From alchemy_labs/frontend at 62ba4fc with the harness up (tooling/verify/labs-local.sh up, an owner session with at least one published and one draft product seeded), or from a new app built to this recipe:
grep -n "status === 'published'" src/pages/commerce/Storefront.tsxprints one line (11).grep -n "atelier.alchemy.store" src/pages/commerce/Storefront.tsxprints one line (13), confirming the hardcoded domain named in Gotchas is still present (or absent, once fixed).- Browser walk-through, signed in as the owner of a workspace with two published and one draft product: (a) open
/store/storefront, observe the shell paint, then the chrome mock and hero, then exactly two cards in the grid, never the draft's name; (b) click "Teilen-Link kopieren", observe the label flip to "✓ Kopiert" for about 1.6 seconds and the clipboard holdhttps://atelier.alchemy.store; (c) click a card, observe navigation to/buy/<that product's id>; (d) with the workspace's products all set to draft, reload, observe the grid area render blank with the chrome mock and hero still present and no message. - Demo gate:
node tooling/verify/render-smoke.mjs commerce-payments/ui/storefront-previewrenders both states withrenderToString,fetchstubbed, 0 failures. - Verifier:
node tooling/verify/compare.mjs commerce-payments/ui/storefront-preview --state default --docs http://127.0.0.1:4321 --app http://127.0.0.1:3020 --session-file tooling/verify/.labs-local/session.json(an owner session with the four sample products' real-world equivalents seeded) writes a report withpage_errors: [];mismatch_ratiois read for the header and chrome-mock structure only, since the app's actual product names, prices and count are the seeded account's and the demo's are fictional sample data.
Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14. The recipe file is the source; this page is a rendering of it.