From e10f9e7cf7d68d7b3b8fe22f32d9e16b5a550585 Mon Sep 17 00:00:00 2001 From: Scott Bolinger Date: Wed, 7 Oct 2026 13:42:34 -0700 Subject: [PATCH 1/4] Add sample storefront components for reusable templates --- .changeset/template-catalog-samples.md | 6 + examples/commerce-storefront/README.md | 35 ++- examples/commerce-storefront/main.tsx | 25 +- examples/commerce-storefront/package.json | 5 +- examples/commerce-storefront/tsconfig.json | 5 +- examples/commerce-storefront/vite.config.ts | 34 ++- packages/commerce-server/README.md | 20 ++ packages/commerce-server/src/index.ts | 1 + .../src/lib/commerce/config.ts | 16 +- .../lib/commerce/create-checkout-session.ts | 7 +- .../src/lib/commerce/get-order-status.ts | 7 +- packages/commerce-server/src/router.test.ts | 82 ++++++ .../src/server/api/commerce/checkout/POST.ts | 7 +- .../src/server/api/commerce/config/GET.ts | 7 + packages/commerce-storefront/README.md | 43 ++++ .../commerce-storefront/docs/server-api.md | 32 +++ packages/commerce-storefront/src/cart.tsx | 92 +++++-- packages/commerce-storefront/src/catalog.tsx | 60 ++++- .../src/commerce-provider.tsx | 49 +++- .../commerce-storefront/src/sample-cart.tsx | 97 +++++++ .../src/storefront-surface.tsx | 4 +- .../src/storefront.test.tsx | 242 +++++++++++++++++- pnpm-lock.yaml | 9 + 23 files changed, 826 insertions(+), 59 deletions(-) create mode 100644 .changeset/template-catalog-samples.md create mode 100644 packages/commerce-storefront/src/sample-cart.tsx diff --git a/.changeset/template-catalog-samples.md b/.changeset/template-catalog-samples.md new file mode 100644 index 00000000..ba2c746f --- /dev/null +++ b/.changeset/template-catalog-samples.md @@ -0,0 +1,6 @@ +--- +"@godaddy/gd-commerce-storefront": minor +"@godaddy/gd-commerce-server": minor +--- + +Support reusable storefront templates with host-authorized sample products and a local sample cart. Catalog sample products automatically yield to a connected store without source changes; unknown state, empty live catalogs, and failures never activate samples. Add optional authoritative binding state to the server config contract and reject live operations while nonready. Preserve legacy successful config responses and live cart semantics. diff --git a/examples/commerce-storefront/README.md b/examples/commerce-storefront/README.md index 7e44dd0c..9ab2dea4 100644 --- a/examples/commerce-storefront/README.md +++ b/examples/commerce-storefront/README.md @@ -1,19 +1,38 @@ -# Independent commerce storefront example +# Local storefront template -This React Router application consumes the compiled `@godaddy/gd-commerce-storefront` package and its shipped CSS. It runs as a standalone application without Tailwind configuration. +A standalone development fixture for preparing a reusable template without creating a store +or supplying Commerce credentials. It consumes the built workspace packages; it is not an AAB +template artifact or evidence of real merchant activation. -Run from the repository root with Node 24: +From the repository root (Node 24 and pnpm 10.14.0): ```sh pnpm install +pnpm --filter @godaddy/gd-commerce-server build pnpm --filter @godaddy/gd-commerce-storefront build pnpm --filter commerce-storefront-example dev ``` -Open . The Vite development server serves a demonstration catalog and in-memory cart at `/api/commerce`. Try adding the mug, changing quantities, removing items, and selecting the blue or sold-out clay tote. Reloading the browser restores a saved cart while the demo server remains running. Restarting the server expires it. +Open http://127.0.0.1:5184. Both Home and Shop use `Catalog sampleProducts`, one provider, +and the shared header/cart drawer. Add Product 1, navigate between pages, change quantity, +and remove it. Reload clears the sample cart. No checkout exists. -This mock is for local UI demonstration only. It creates no Commerce orders or payments and is not a production server implementation. Checkout is intentionally disabled. The production build verifies bundling; it does not include the mock API, so a deployed build needs an implementation of the [server contract](../../packages/commerce-storefront/docs/server-api.md). +The development controls alter the local server's authoritative state: -```sh -pnpm --filter commerce-storefront-example build -``` +- **Unbound:** real server package config/router, package-owned samples and local cart. +- **Connecting:** samples disappear and actions suspend on the next automatic config read. +- **Configuration error:** the real config handler returns 503; samples do not return. +- **Live empty catalog (simulated):** ready config and an empty response from the existing + development catalog fixture. No fallback to samples. +- **Existing live demo (simulated):** retains the example's pre-existing mug/tote and in-memory + cart API, including variants and unavailable inventory. No upstream requests or payments. + +Unbound/connecting configurations refresh automatically every five seconds while visible. +Use **Refresh configuration** to exercise ready-state failures or recovery immediately. No +component prop changes or page reloads are needed. Sample items never enter the live demo cart. +Development state controls only exist in Vite dev; a production build requires real host API +routes and authoritative binding projection. Never copy the development API or its fixed +configuration into a customer template. The sample catalog code and data live inside the package. + +This fixture checks component behavior and the server state boundary. AAB import/projection +and a real store's catalog/cart/checkout still require their own integration verification. diff --git a/examples/commerce-storefront/main.tsx b/examples/commerce-storefront/main.tsx index 1cafa741..b654cd92 100644 --- a/examples/commerce-storefront/main.tsx +++ b/examples/commerce-storefront/main.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { createRoot } from 'react-dom/client'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { BrowserRouter, Link, Navigate, Route, Routes } from 'react-router'; @@ -6,16 +7,34 @@ import '@godaddy/gd-commerce-storefront/styles.css'; import './styles.css'; const client = new QueryClient(); +function DevelopmentControls() { + const [status, setStatus] = useState('Unbound template'); + return
+ Local template test controls + {' '} + +

{status}. Unbound/connecting pages refresh automatically every 5 seconds.

+
; +} createRoot(document.getElementById('root')!).render( Skip to products -
Field & Form
+
Template example
-

Demo catalog and in-memory cart. No orders or payments are created.

+

Local template demonstration. Example products and prices. No orders or payments are created.

+ {import.meta.env.DEV && } - } /> +

Your template storefront

The same catalog and cart activate when a store connects.

} /> + } /> } /> } />
diff --git a/examples/commerce-storefront/package.json b/examples/commerce-storefront/package.json index 961a9498..c10cda5d 100644 --- a/examples/commerce-storefront/package.json +++ b/examples/commerce-storefront/package.json @@ -19,6 +19,9 @@ "@types/react": "^19.0.8", "@types/react-dom": "^19.0.3", "typescript": "~5.7.3", - "vite": "^6.4.1" + "vite": "^6.4.1", + "@godaddy/gd-commerce-server": "workspace:*", + "express": "^5.1.0", + "@types/express": "^5.0.3" } } diff --git a/examples/commerce-storefront/tsconfig.json b/examples/commerce-storefront/tsconfig.json index 68481738..a94fafc5 100644 --- a/examples/commerce-storefront/tsconfig.json +++ b/examples/commerce-storefront/tsconfig.json @@ -11,7 +11,10 @@ "strict": true, "skipLibCheck": true, "noEmit": true, - "esModuleInterop": true + "esModuleInterop": true, + "types": [ + "vite/client" + ] }, "include": [ "*.ts", diff --git a/examples/commerce-storefront/vite.config.ts b/examples/commerce-storefront/vite.config.ts index 153796d3..cbec97a8 100644 --- a/examples/commerce-storefront/vite.config.ts +++ b/examples/commerce-storefront/vite.config.ts @@ -1,3 +1,5 @@ +import express from 'express'; +import { createCommerceCatalogRouter, type CommerceConfiguration } from '@godaddy/gd-commerce-server'; import { randomUUID } from 'node:crypto'; import { defineConfig, type Plugin } from 'vite'; import type { CartOrder, SKU, SKUGroup } from '@godaddy/gd-commerce-storefront'; @@ -18,20 +20,46 @@ const products: SKUGroup[] = [ // Demonstration only. This is neither a production server nor a Commerce API emulator. function demoApi(): Plugin { const carts = new Map(); + let state: 'unbound' | 'connecting' | 'error' | 'live' | 'live-empty' = 'unbound'; + const configuration: CommerceConfiguration = { + readBindingState: () => { + if (state === 'error') throw new Error('Development host state unavailable.'); + return state === 'live' || state === 'live-empty' ? 'ready' : state; + }, + // Development-only values. Live demo catalog/cart routes below never contact this origin. + read: () => ({ clientId: 'local-example', clientSecret: 'not-a-secret', storeId: 'local-example', channelId: 'local-example', currencyCode: 'USD', apiBaseUrl: 'https://example.invalid' }), + readCheckout: () => ({ enablePromotionCodes: false, enableTaxCollection: false, enableShipping: false }), + }; + const router = createCommerceCatalogRouter(configuration); return { name: 'storefront-demo-api', configureServer(server) { + const app = express(); + app.post('/__demo/commerce-state', express.json(), (req, res) => { + const next = req.body?.state; + if (!['unbound', 'connecting', 'error', 'live', 'live-empty'].includes(next)) { + res.status(400).json({ error: 'Unknown demo state.' }); + return; + } + state = next; + carts.clear(); + res.json({ state }); + }); + app.use('/api/commerce', (req, res, next) => { + if (req.path === '/config' || (state !== 'live' && state !== 'live-empty')) router(req, res, next); + else next(); + }); + server.middlewares.use(app); server.middlewares.use('/api/commerce', async (req, res, next) => { try { const url = new URL(req.url ?? '/', 'http://localhost'); const send = (value: unknown, status = 200) => { res.statusCode = status; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(value)); }; - if (url.pathname === '/config') return send({ cartScope: 'demo-v1', currencyCode: 'USD' }); - if (url.pathname === '/products') return send({ skuGroups: { edges: products.map(node => ({ node })), pageInfo: { hasNextPage: false } } }); + if (url.pathname === '/products') return send({ skuGroups: { edges: state === 'live-empty' ? [] : products.map(node => ({ node })), pageInfo: { hasNextPage: false } } }); if (url.pathname.startsWith('/products/')) { const product = products.find(item => item.id === url.pathname.split('/')[2]); const color = url.searchParams.get('attributeValues'); return send({ skuGroup: product && color ? { ...product, skus: { edges: color === 'blue' ? [{ node: blue }] : color === 'clay' ? [{ node: clay }] : [], totalCount: 1 } } : product ?? null }); } if (!url.pathname.startsWith('/cart')) return next(); - if (req.headers['x-commerce-scope'] !== 'demo-v1') return send({ error: 'Store changed. Reload the page.' }, 409); + if (!req.headers['x-commerce-scope']) return send({ error: 'Store changed. Reload the page.' }, 409); let body = ''; for await (const chunk of req) body += chunk; const input = body ? JSON.parse(body) : {}; diff --git a/packages/commerce-server/README.md b/packages/commerce-server/README.md index eb867c1f..f5529273 100644 --- a/packages/commerce-server/README.md +++ b/packages/commerce-server/README.md @@ -57,3 +57,23 @@ Configure `checkoutReturnUrls` on the router using trusted deployment settings. Without this policy, HTTP checkout returns 503 before creating a session. Invalid request destinations return 400. This applies to both router presets that expose checkout. Trusted in-process callers of `createCheckoutSession()` own their return URLs and must construct or validate them server-side. A return from hosted checkout is not proof of payment. The order-status route uses the authorized Orders REST API, which supports completed orders, and returns its payment status (for example `PAID` or `PENDING`; `unknown` if absent). The server OAuth client must be granted `commerce.order:read`. The helper verifies the returned order ID, store, and channel and returns a limited summary without customer contact data. Hosts must authenticate callers and authorize access to each requested order before exposing this route. + +### Host-authorized template samples + +Hosts that support reusable store templates can implement the optional synchronous +`CommerceConfiguration.readBindingState()` method. Its return type is exported as +`CommerceBindingState` (`unbound | connecting | ready`), with `undefined` preserving legacy +behavior. Unknown or failed host reads must throw. The default environment configuration does +not infer binding absence from missing credentials. + +`GET /config` returns `{ state: 'unbound' }` or `{ state: 'connecting' }` without reading store +credentials. `ready` includes the usual opaque `cartScope` and `currencyCode` after `read()` +succeeds. Absent state support retains the existing successful response shape. All responses +are uncached; malformed state or unavailable configuration returns 503. Data/checkout route +reads and in-process checkout/order helpers reject nonready state before upstream calls. + +The host must project authoritative lifecycle state into each destination runtime, including +connecting before setup and ready after successful durable binding. Lost configuration is an +error; never use it as evidence of unbound. After a successful binding, only verified intentional +unbinding may restore unbound. The client package owns generic sample products and a local +cart; this server never creates sample Commerce records or simulates checkout. diff --git a/packages/commerce-server/src/index.ts b/packages/commerce-server/src/index.ts index fd69b46b..8e170a83 100644 --- a/packages/commerce-server/src/index.ts +++ b/packages/commerce-server/src/index.ts @@ -5,6 +5,7 @@ export { } from './lib/commerce/checkout-config'; export type { CheckoutReturnUrls } from './lib/commerce/checkout-return-urls'; export { + type CommerceBindingState, type CommerceConfig, type CommerceConfiguration, createRuntimeCommerceConfiguration, diff --git a/packages/commerce-server/src/lib/commerce/config.ts b/packages/commerce-server/src/lib/commerce/config.ts index 19381526..ae24339d 100644 --- a/packages/commerce-server/src/lib/commerce/config.ts +++ b/packages/commerce-server/src/lib/commerce/config.ts @@ -22,7 +22,11 @@ export interface CommerceConfig { owner?: string; } +export type CommerceBindingState = 'unbound' | 'connecting' | 'ready'; + export interface CommerceConfiguration { + /** Authoritative host state. Missing support preserves legacy live/error behavior. */ + readBindingState?(): CommerceBindingState | undefined; read(): CommerceConfig; readCheckout(): CommerceCheckoutConfiguration; } @@ -105,5 +109,15 @@ export function commerceConfigurationForResponse(res: Response): CommerceConfigu } export function readCommerceConfigForResponse(res: Response): CommerceConfig { - return commerceConfigurationForResponse(res).read(); + const configuration = commerceConfigurationForResponse(res); + assertCommerceBindingReady(configuration); + return configuration.read(); +} + +/** Nonready and invalid host states must never use leftover credentials. */ +export function assertCommerceBindingReady(configuration: CommerceConfiguration): void { + const state = configuration.readBindingState?.(); + if (state !== undefined && state !== 'ready') { + throw new Error('Commerce binding is not ready. Complete the store connection before continuing.'); + } } diff --git a/packages/commerce-server/src/lib/commerce/create-checkout-session.ts b/packages/commerce-server/src/lib/commerce/create-checkout-session.ts index a57e8bbc..3a08574b 100644 --- a/packages/commerce-server/src/lib/commerce/create-checkout-session.ts +++ b/packages/commerce-server/src/lib/commerce/create-checkout-session.ts @@ -25,7 +25,11 @@ import { DEFAULT_CHECKOUT_PAYMENT_METHODS, getOAuthAccessToken, } from './checkout-subgraph'; -import { type CommerceConfiguration, createRuntimeCommerceConfiguration } from './config'; +import { + assertCommerceBindingReady, + type CommerceConfiguration, + createRuntimeCommerceConfiguration, +} from './config'; import { gqlRequest } from './gql'; export interface CreateCheckoutSessionParams { @@ -175,6 +179,7 @@ export async function createCheckoutSession( throw new Error('createCheckoutSession: exactly one of draftOrderId, skuId, or lineItemData is required'); } + assertCommerceBindingReady(configuration); const { storeId, channelId, diff --git a/packages/commerce-server/src/lib/commerce/get-order-status.ts b/packages/commerce-server/src/lib/commerce/get-order-status.ts index a01c66a3..9eb296a5 100644 --- a/packages/commerce-server/src/lib/commerce/get-order-status.ts +++ b/packages/commerce-server/src/lib/commerce/get-order-status.ts @@ -3,7 +3,11 @@ * Unlike the storefront cart API, this endpoint includes completed orders. */ import { authorizationHeaders, getOAuthAccessToken } from './checkout-subgraph'; -import { type CommerceConfiguration, createRuntimeCommerceConfiguration } from './config'; +import { + assertCommerceBindingReady, + type CommerceConfiguration, + createRuntimeCommerceConfiguration, +} from './config'; import type { Money } from './gql'; export interface CommerceOrderStatus { @@ -43,6 +47,7 @@ export async function getOrderStatus( throw new Error('getOrderStatus: a valid orderId is required'); } + assertCommerceBindingReady(configuration); const { storeId, channelId, clientId, clientSecret, apiBaseUrl, currencyCode } = configuration.read(); const token = await getOAuthAccessToken({ clientId, diff --git a/packages/commerce-server/src/router.test.ts b/packages/commerce-server/src/router.test.ts index bcd07e95..033dedb8 100644 --- a/packages/commerce-server/src/router.test.ts +++ b/packages/commerce-server/src/router.test.ts @@ -380,3 +380,85 @@ describe('Commerce router mounting', (): void => { } }); }); + +describe('authoritative template binding state', () => { + it.each(['unbound', 'connecting'] as const)( + 'reports %s without credentials or upstream calls', + async (state) => { + const res = response(); + const read = vi.fn(() => { + throw new Error('No credentials'); + }); + res.locals.commerceConfiguration = { ...configuration, read, readBindingState: () => state }; + await configHandler({} as Request, res as unknown as Response); + expect(res.json).toHaveBeenCalledWith({ state }); + expect(res.setHeader).toHaveBeenCalledWith('Cache-Control', 'no-store'); + expect(read).not.toHaveBeenCalled(); + }, + ); + + it('retains legacy config and validates an explicitly ready binding', async () => { + for (const state of [undefined, 'ready'] as const) { + const res = response(); + res.locals.commerceConfiguration = { ...configuration, readBindingState: () => state }; + await configHandler({} as Request, res as unknown as Response); + expect(res.json).toHaveBeenCalledWith({ + ...(state ? { state } : {}), + cartScope: getCommerceCartScope(binding), + currencyCode: 'USD', + }); + } + const res = response(); + res.locals.commerceConfiguration = { + ...configuration, + readBindingState: () => 'ready', + read: () => { + throw new Error('Lost configuration'); + }, + }; + await configHandler({} as Request, res as unknown as Response); + expect(res.status).toHaveBeenCalledWith(503); + }); + + it.each(['invalid', 'throws'])('rejects %s host state rather than granting samples', async (state) => { + const res = response(); + res.locals.commerceConfiguration = { + ...configuration, + readBindingState: () => { + if (state === 'throws') throw new Error('Host read failed'); + return state as 'ready'; + }, + }; + await configHandler({} as Request, res as unknown as Response); + expect(res.status).toHaveBeenCalledWith(503); + }); + + it.each([ + createCart, + readCart, + addItem, + updateItem, + deleteItem, + applyDiscount, + checkout, + readProducts, + readProduct, + readSku, + ])('blocks nonready handlers even with leftover credentials', async (handler) => { + vi.clearAllMocks(); + const res = response(); + res.locals.commerceConfiguration = { ...configuration, readBindingState: () => 'connecting' }; + await handler( + { + headers: {}, + query: {}, + params: { id: 'cart-1', itemId: 'item-1' }, + body: { skuId: 'sku-1', name: 'Product', quantity: 1 }, + } as unknown as Request, + res as unknown as Response, + ); + expect(res.status).toHaveBeenCalled(); + expect(gqlRequest).not.toHaveBeenCalled(); + expect(createCheckoutSession).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/commerce-server/src/server/api/commerce/checkout/POST.ts b/packages/commerce-server/src/server/api/commerce/checkout/POST.ts index 42dd56c0..db8405a7 100644 --- a/packages/commerce-server/src/server/api/commerce/checkout/POST.ts +++ b/packages/commerce-server/src/server/api/commerce/checkout/POST.ts @@ -39,7 +39,11 @@ import type { Request, Response } from 'express'; import { validateCommerceCartScope } from '@/lib/commerce/cart-scope'; import type { CheckoutReturnUrlValidator } from '@/lib/commerce/checkout-return-urls'; -import { type CommerceConfig, commerceConfigurationForResponse } from '@/lib/commerce/config'; +import { + assertCommerceBindingReady, + type CommerceConfig, + commerceConfigurationForResponse, +} from '@/lib/commerce/config'; import { type CreateCheckoutSessionParams, @@ -76,6 +80,7 @@ export default async function handler(req: Request, res: Response): Promise +
+ + +``` + +`Catalog.sampleProducts` defaults to `false`. It permits six internal examples (Product 1–6, +$10–$60 USD) **only** when `/api/commerce/config` reports authoritative `state: 'unbound'`. +The prop may remain after activation. Connecting a store changes the server state; the +components automatically switch to real products without an agent rewriting the component +tree or changing props. Real empty catalogs and errors never fall back to samples. + +The sample cart supports adding, changing quantities (1–999), removing items, and an example +subtotal. It uses the existing drawer and theme, survives navigation under the shared provider, +and resets on reload, unmount, or loss of verified unbound state. No live cart storage, +product/cart/checkout requests, or upstream Commerce calls are used. Configuration reads +remain necessary. Sample entries have no SKU or draft-order shape and are never transferred +to a real cart. Checkout is unavailable, including direct `useCommerce().checkout()` calls. + +`useCommerce().connection` now includes `unbound` and `connecting` in addition to `loading`, +`ready`, and `error`. Only `ready` represents usable live Commerce. The public `cart` and live +mutation methods retain their live-only semantics; sample cart data is internal. A catalog +without `sampleProducts`, and direct product-detail visits, display the unconnected state. +Sample grids and drawers expose `data-commerce-source="sample"` for readiness checks. + +The provider reads configuration on mount/focus and every five seconds while visible and +unbound/connecting. The first failed refresh suspends samples and clears the sample cart; +recovery can resume an empty demo. Polling stops after ready; focus and explicit retry remain +available. Hosts must publish uncached authoritative state and must never infer unbound from +missing credentials, outages, or failed reads. A connected site may return to unbound only +through a verified intentional unbinding operation. This is a host lifecycle responsibility. + +Install a compatible server package and host state adapter before using sample templates. +Old successful config responses remain supported; old or unknown hosts never implicitly +permit samples. Template archives carry UI and dependencies, not author credentials, binding +state, saved carts, or checkout settings. See [server contract](docs/server-api.md) and the +[local template example](../../examples/commerce-storefront/README.md). diff --git a/packages/commerce-storefront/docs/server-api.md b/packages/commerce-storefront/docs/server-api.md index 9c07cb96..746b2e50 100644 --- a/packages/commerce-storefront/docs/server-api.md +++ b/packages/commerce-storefront/docs/server-api.md @@ -101,3 +101,35 @@ The client refreshes the cart before checkout and sends its ID, an absolute cata When using `@godaddy/gd-commerce-server`, configure the router's `checkoutReturnUrls.returnUrls` with the absolute catalog return URL and `checkoutReturnUrls.successUrls` with the absolute success-page URL. The package matches these exact destinations and permits an additional `orderId` parameter on success URLs. Missing policy disables HTTP checkout. Public HTTP requests accept only cart or SKU checkout; non-catalog amounts belong in a trusted server handler. Do not treat `checkoutSuccessPath`, a client-provided URL, the redirect itself, or a draft-order response as proof that payment succeeded. Verify payment using the checkout/payment service or trusted webhook state. Expire or reject paid/closed drafts on subsequent cart reads so returning customers cannot reuse a completed cart. + +## Authoritative template state + +New hosts may return one of these uncached config responses: + +```json +{ "state": "unbound" } +{ "state": "connecting" } +{ "state": "ready", "cartScope": "opaque-binding-scope", "currencyCode": "USD" } +``` + +Only an explicit `unbound` response permits `Catalog sampleProducts`. `connecting` suspends +all catalog/cart actions. Loading, malformed state, HTTP errors, and missing routes do not +permit samples. A successful legacy `{ cartScope, currencyCode }` response still means ready. + +`@godaddy/gd-commerce-server` accepts optional synchronous +`CommerceConfiguration.readBindingState(): 'unbound' | 'connecting' | 'ready' | undefined`. +Return `undefined` to retain legacy config reading. Return `unbound` only after an authoritative +host check proves absence of a binding; throw on uncertainty/read failure. Nonready config +responses require no credentials. `ready` still requires `read()` to succeed. Data/checkout +handlers reject nonready state even if old credentials remain. Never ship fixed `unbound` +state in a copied template or derive it from absent environment variables. + +Hosts own lifecycle projection: publish connecting before setup, ready only after durable +binding and runtime projection succeed, and keep failures nonready. After connection, a +missing configuration must never restore samples. Only verified intentional unbinding can +authorize unbound again. Configuration changes must be visible to subsequent requests. + +The storefront polls every five seconds while visible and unbound/connecting and refreshes +on mount/focus. Failed refreshes immediately revoke sample eligibility (without automatic +request retries). Aborted configuration queries cannot overwrite newer responses. +Release server and client support before adopting the template prop; no new endpoint is required. diff --git a/packages/commerce-storefront/src/cart.tsx b/packages/commerce-storefront/src/cart.tsx index 3b6fa554..adcc2e85 100644 --- a/packages/commerce-storefront/src/cart.tsx +++ b/packages/commerce-storefront/src/cart.tsx @@ -6,6 +6,7 @@ import { type CartSummaryTotals, getCartSummaryTotals } from './cart-model'; import type { SKU } from './catalog-model'; import { getAvailableInventoryQuantity } from './catalog-model'; import { useCommerce } from './commerce-provider'; +import { useSampleCart } from './sample-cart'; import { StorefrontSurface } from './storefront-surface'; export const buttonClass: string = @@ -15,13 +16,17 @@ export const inputClass: string = export function CartButton(): ReactElement { const { cart, setOpen, open, connection } = useCommerce(); - const count: number = cart?.lineItems?.reduce((total, item) => total + (item.quantity ?? 0), 0) ?? 0; + const sample = useSampleCart(); + const count: number = + connection === 'unbound' + ? sample.items.reduce((total, item) => total + item.quantity, 0) + : (cart?.lineItems?.reduce((total, item) => total + (item.quantity ?? 0), 0) ?? 0); return ( + + + + + ))} + + + ); +} diff --git a/packages/commerce-storefront/src/commerce-provider.tsx b/packages/commerce-storefront/src/commerce-provider.tsx index cf2a7d7c..bf7a461d 100644 --- a/packages/commerce-storefront/src/commerce-provider.tsx +++ b/packages/commerce-storefront/src/commerce-provider.tsx @@ -12,12 +12,13 @@ import { } from 'react'; import { ApiError, CartIdStorage, checkedFetch, message, request, type StorefrontConfig } from './api'; import { type AddToCartItemInput, addToCart, type CartOrder } from './cart-model'; +import { SampleCartProvider } from './sample-cart'; interface CartResponse { cart: CartOrder | null; } export interface CommerceContextValue { - connection: 'loading' | 'ready' | 'error'; + connection: 'loading' | 'unbound' | 'connecting' | 'ready' | 'error'; connectionError: string | null; retryConnection: () => void; theme: StorefrontTheme; @@ -68,12 +69,20 @@ export interface CommerceProviderProps { checkoutSuccessPath?: string; } -async function loadConfig( - signal: AbortSignal, -): Promise> { - const config = await request>('/config', { signal }); +type PublicConfiguration = + | { state: 'unbound' | 'connecting' } + | ({ state: 'ready' } & Pick); + +async function loadConfig(signal: AbortSignal): Promise { + const config = await request>('/config', { signal }); + if (config?.state === 'unbound' || config?.state === 'connecting') { + if ('cartScope' in config || 'currencyCode' in config) + throw new Error('The store returned invalid configuration.'); + return { state: config.state }; + } if ( !config || + (config.state !== undefined && config.state !== 'ready') || typeof config.cartScope !== 'string' || !config.cartScope.trim() || typeof config.currencyCode !== 'string' || @@ -81,7 +90,7 @@ async function loadConfig( ) { throw new Error('The store returned invalid configuration.'); } - return config; + return { state: 'ready', cartScope: config.cartScope, currencyCode: config.currencyCode }; } export function CommerceProvider({ @@ -96,19 +105,24 @@ export function CommerceProvider({ queryKey: ['commerce', 'configuration'], queryFn: ({ signal }) => loadConfig(signal), staleTime: 30_000, - refetchOnWindowFocus: true, + refetchOnMount: 'always', + refetchOnWindowFocus: 'always', + // Errors after an unbound response also need recovery; stale data never grants eligibility. + refetchInterval: (query) => + query.state.data?.state === 'unbound' || query.state.data?.state === 'connecting' ? 5_000 : false, + refetchIntervalInBackground: false, }); return ( { void config.refetch(); @@ -138,7 +152,7 @@ function BoundCommerceProvider({ const storageKey: string = `godaddy:commerce-storefront:cart:${config.cartScope}`; const cartIdStorage = useMemo(() => new CartIdStorage(storageKey), [storageKey]); const ready = connection === 'ready'; - const session = useMemo(() => ({ cartIdStorage, ready }), [cartIdStorage, ready]); + const session = useMemo(() => ({ cartIdStorage, connection }), [cartIdStorage, connection]); const currentSession = useRef(session); currentSession.current = session; const readyRef = useRef(ready); @@ -256,6 +270,7 @@ function BoundCommerceProvider({ setOpen(false); setError(null); setStorageWarning(null); + setAnnouncement(''); setPending(false); setHydrating(ready); if (ready) void refresh(); @@ -272,7 +287,7 @@ function BoundCommerceProvider({ window.removeEventListener('storage', onStorage); window.removeEventListener('focus', onFocus); }; - }, [cartIdStorage, ready]); + }, [cartIdStorage, connection]); function addItem(item: AddToCartItemInput): Promise { rememberOpener(); @@ -406,7 +421,17 @@ function BoundCommerceProvider({ checkout, }} > - {children} + { + rememberOpener(); + setAnnouncement(`${name} added to your sample cart.`); + setOpen(true); + }} + > + {children} + {announcement} diff --git a/packages/commerce-storefront/src/sample-cart.tsx b/packages/commerce-storefront/src/sample-cart.tsx new file mode 100644 index 00000000..8e907f9f --- /dev/null +++ b/packages/commerce-storefront/src/sample-cart.tsx @@ -0,0 +1,97 @@ +import { + createContext, + type ReactElement, + type ReactNode, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; + +// Deliberately distinct from SKU groups, SKUs and draft orders. Never sent to Commerce. +export const sampleProducts = Array.from({ length: 6 }, (_, index) => ({ + key: `sample-${index + 1}`, + name: `Product ${index + 1}`, + price: (index + 1) * 1_000, +})); + +interface SampleCartValue { + items: { key: string; name: string; price: number; quantity: number }[]; + add: (key: string) => boolean; + changeQuantity: (key: string, quantity: number) => Promise; + remove: (key: string) => Promise; +} + +const SampleCartContext = createContext(null); + +export function useSampleCart(): SampleCartValue { + const value = useContext(SampleCartContext); + if (!value) throw new Error('Mount CommerceProvider around sample products.'); + return value; +} + +export function SampleCartProvider({ + enabled, + onAdd, + announce, + children, +}: { + enabled: boolean; + onAdd: (name: string) => void; + announce: (message: string) => void; + children: ReactNode; +}): ReactElement { + const session = useMemo(() => ({ enabled }), [enabled]); + const active = useRef(session); + active.current = session; + const [state, setState] = useState({ session, quantities: {} as Record }); + useEffect(() => { + active.current = session; + setState({ session, quantities: {} }); + return () => { + active.current = null; + }; + }, [session]); + const quantities = state.session === session && enabled ? state.quantities : {}; + function update(key: string, quantity?: number): boolean { + if (!enabled || active.current !== session || !sampleProducts.some((product) => product.key === key)) + return false; + if (quantity !== undefined && (!Number.isSafeInteger(quantity) || quantity < 0 || quantity > 999)) + return false; + setState((previous) => { + const current = previous.session === session ? previous.quantities : {}; + const next = quantity ?? (current[key] ?? 0) + 1; + if (next > 999) return previous; + return { session, quantities: { ...current, [key]: next } }; + }); + return true; + } + return ( + + quantities[product.key] ? [{ ...product, quantity: quantities[product.key] }] : [], + ), + add: (key) => { + const product = sampleProducts.find((product) => product.key === key); + if (!product || !update(key)) return false; + onAdd(product.name); + return true; + }, + changeQuantity: async (key, quantity) => { + const updated = quantity >= 1 && update(key, quantity); + if (updated) announce('Sample cart quantity updated.'); + return updated; + }, + remove: async (key) => { + const updated = update(key, 0); + if (updated) announce('Sample item removed from cart.'); + return updated; + }, + }} + > + {children} + + ); +} diff --git a/packages/commerce-storefront/src/storefront-surface.tsx b/packages/commerce-storefront/src/storefront-surface.tsx index 7d30ed2c..17ea027b 100644 --- a/packages/commerce-storefront/src/storefront-surface.tsx +++ b/packages/commerce-storefront/src/storefront-surface.tsx @@ -21,7 +21,9 @@ export function CommerceStatus(): ReactElement | null { if (connection === 'ready') return null; return ( - {connection === 'loading' ? ( + {connection === 'unbound' ? ( +

Connect a store to show products.

+ ) : connection === 'loading' || connection === 'connecting' ? (

Connecting to the store…

) : (
diff --git a/packages/commerce-storefront/src/storefront.test.tsx b/packages/commerce-storefront/src/storefront.test.tsx index ea3bc707..bffd0ed7 100644 --- a/packages/commerce-storefront/src/storefront.test.tsx +++ b/packages/commerce-storefront/src/storefront.test.tsx @@ -2,7 +2,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { type ReactNode, useState } from 'react'; -import { MemoryRouter, Route, Routes, useLocation } from 'react-router'; +import { Link, MemoryRouter, Route, Routes, useLocation } from 'react-router'; import { describe, expect, it, vi } from 'vitest'; import { ApiError, CartIdStorage, money, request } from './api'; import { AddToCartButton, CartButton } from './cart'; @@ -40,7 +40,7 @@ function mockApi(handler: (path: string, init?: RequestInit) => Response | Promi return fn; } function mount(children?: ReactNode, path = '/shop', checkoutSuccessPath?: string) { - const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); + const client = new QueryClient({ defaultOptions: { queries: { gcTime: 0 } } }); let context: CommerceContextValue; function Observe() { context = useCommerce(); @@ -257,7 +257,11 @@ describe('shared cart', () => { await Promise.resolve(); }); act(() => { - view.client.setQueryData(['commerce', 'configuration'], { ...configuration, cartScope: 'store-two' }); + view.client.setQueryData(['commerce', 'configuration'], { + state: 'ready', + ...configuration, + cartScope: 'store-two', + }); }); await connected(view); await act(async () => { @@ -293,7 +297,7 @@ describe('shared cart', () => { }); await waitFor(() => expect(view.context().connection).toBe('error')); act(() => { - view.client.setQueryData(['commerce', 'configuration'], configuration); + view.client.setQueryData(['commerce', 'configuration'], { state: 'ready', ...configuration }); }); await connected(view); await act(async () => { @@ -530,3 +534,233 @@ describe('storage and transport', () => { expect(money(1234, 'KWD')).toContain('1.234'); }); }); + +describe('template samples', () => { + it('shares six internal samples and a local cart without live requests or storage', async () => { + const api = mockApi(() => response({ state: 'unbound' })); + const read = vi.spyOn(Storage.prototype, 'getItem'); + const write = vi.spyOn(Storage.prototype, 'setItem'); + const remove = vi.spyOn(Storage.prototype, 'removeItem'); + const view = mount( + <> + + + + , + '/shop', + '/thanks', + ); + await screen.findAllByRole('button', { name: 'Add Product 1 to sample cart' }); + expect(screen.getAllByRole('button', { name: /to sample cart/ })).toHaveLength(12); + expect(view.context().connection).toBe('unbound'); + expect(view.context().cart).toBeNull(); + const buttons = screen.getAllByRole('button', { name: 'Add Product 1 to sample cart' }); + await userEvent.click(buttons[0]); + expect(screen.getByRole('dialog')).toHaveAttribute('data-commerce-source', 'sample'); + await userEvent.keyboard('{Escape}'); + expect(buttons[0]).toHaveFocus(); + await userEvent.click(buttons[1]); + const drawer = screen.getByRole('dialog'); + expect(within(drawer).getAllByTestId('cart-item')).toHaveLength(1); + expect(within(drawer).getAllByText('$20.00')).toHaveLength(2); + await userEvent.click(within(drawer).getByRole('button', { name: 'Increase quantity of Product 1' })); + expect(within(drawer).getAllByText('$30.00')).toHaveLength(2); + await userEvent.click(within(drawer).getByRole('button', { name: 'Decrease quantity of Product 1' })); + expect(within(drawer).getAllByText('$20.00')).toHaveLength(2); + expect(within(drawer).queryByRole('button', { name: /Proceed to Checkout/ })).not.toBeInTheDocument(); + expect(await view.context().checkout()).toBe(false); + expect(await view.context().addItem(item)).toBe(false); + expect(await view.context().changeQuantity('sample-1', 2)).toBe(false); + expect(await view.context().applyDiscount('TEST')).toBe(false); + await userEvent.click(within(drawer).getByRole('button', { name: 'Remove Product 1' })); + expect(within(drawer).getByText('Your cart is empty')).toBeVisible(); + expect(api.mock.calls.every(([path]) => String(path).endsWith('/config'))).toBe(true); + expect(read).not.toHaveBeenCalled(); + expect(write).not.toHaveBeenCalled(); + expect(remove).not.toHaveBeenCalled(); + }); + + it.each([ + { state: 'connecting' }, + { state: 'unknown' }, + {}, + { state: 'ready' }, + { state: 'unbound', ...configuration }, + ])('does not infer samples from %j', async (config) => { + const api = mockApi(() => response(config)); + const view = mount( + <> + + + , + ); + await waitFor(() => expect(view.context().connection).not.toBe('loading')); + expect(screen.queryByText('Product 1')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Cart/ })).toBeDisabled(); + expect(api).toHaveBeenCalledTimes(1); + }); + + it('requires catalog opt-in and keeps unbound product details inert', async () => { + const api = mockApi(() => response({ state: 'unbound' })); + mount( + <> + + + } /> + + , + '/products/sample-1', + ); + expect(await screen.findAllByText('Connect a store to show products.')).toHaveLength(2); + expect(screen.queryByText('Product 1')).not.toBeInTheDocument(); + expect(api).toHaveBeenCalledTimes(1); + }); + + it('automatically activates an empty live catalog without prop edits and discards sample items', async () => { + let config: unknown = { state: 'unbound' }; + const api = mockApi((path) => + path.endsWith('/config') ? response(config) : response({ skuGroups: { edges: [] } }), + ); + const view = mount( + <> + + + , + ); + await userEvent.click(await screen.findByRole('button', { name: 'Add Product 1 to sample cart' })); + config = { state: 'ready', ...configuration }; + await act(async () => { + await view.client.refetchQueries({ queryKey: ['commerce', 'configuration'] }); + }); + expect(await screen.findByText('No products available.')).toBeVisible(); + expect(document.querySelector('[data-commerce-source="sample"]')).toBeNull(); + expect(screen.getByRole('button', { name: 'Cart 0' })).toBeEnabled(); + expect(view.context().cart).toBeNull(); + expect(api.mock.calls.some(([path]) => String(path).includes('/cart'))).toBe(false); + expect(api.mock.calls.some(([, options]) => options?.body?.toString().includes('sample-'))).toBe(false); + }); + + it('suspends samples on the first failed config refresh and recovers with an empty sample cart', async () => { + const api = mockApi(() => response({ state: 'unbound' })); + const view = mount( + <> + + + , + ); + await userEvent.click(await screen.findByRole('button', { name: 'Add Product 1 to sample cart' })); + api.mockResolvedValue(response({ error: 'Host state unavailable' }, 503)); + const before = api.mock.calls.length; + await act(async () => { + await view.client.refetchQueries({ queryKey: ['commerce', 'configuration'] }); + }); + expect(await screen.findByText('Host state unavailable')).toBeVisible(); + expect(view.context().connection).toBe('error'); + expect(api.mock.calls.length).toBe(before + 1); + expect(document.querySelector('[data-commerce-source="sample"]')).toBeNull(); + api.mockResolvedValue(response({ state: 'unbound' })); + await userEvent.click(screen.getByRole('button', { name: 'Retry connection' })); + await waitFor(() => expect(screen.getByRole('button', { name: 'Cart 0' })).toBeEnabled()); + }); + + it('polls for binding changes without a page reload', async () => { + vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }); + try { + let config: unknown = { state: 'unbound' }; + const api = mockApi((path) => + path.endsWith('/config') ? response(config) : response({ skuGroups: { edges: [] } }), + ); + const view = mount(); + await screen.findByRole('button', { name: 'Add Product 1 to sample cart' }); + config = { state: 'connecting' }; + await act(async () => { + await vi.advanceTimersByTimeAsync(5_000); + }); + await waitFor(() => expect(view.context().connection).toBe('connecting')); + expect(screen.queryByText('Product 1')).toBeNull(); + config = { state: 'ready', ...configuration }; + await act(async () => { + await vi.advanceTimersByTimeAsync(5_000); + }); + await screen.findByText('No products available.'); + const count = api.mock.calls.length; + await act(async () => { + await vi.advanceTimersByTimeAsync(15_000); + }); + expect(api).toHaveBeenCalledTimes(count); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe('template lifetime boundaries', () => { + it('preserves samples across routed pages but resets when the storefront is remounted', async () => { + mockApi(() => response({ state: 'unbound' })); + const shell = ( + <> + + About + Shop + + } /> + About page

} /> +
+ + ); + const view = mount(shell); + await userEvent.click(await screen.findByRole('button', { name: 'Add Product 1 to sample cart' })); + await userEvent.keyboard('{Escape}'); + await userEvent.click(screen.getByRole('link', { name: 'About' })); + expect(screen.getByText('About page')).toBeVisible(); + expect(screen.getByRole('button', { name: 'Cart 1' })).toBeEnabled(); + await userEvent.click(screen.getByRole('link', { name: 'Shop' })); + expect(screen.getByRole('button', { name: 'Cart 1' })).toBeEnabled(); + view.unmount(); + mount(shell); + await waitFor(() => expect(screen.getByRole('button', { name: 'Cart 0' })).toBeEnabled()); + }); + + it('never restores samples for a failed live catalog or lost connected configuration', async () => { + let config: Response = response({ state: 'ready', ...configuration }); + mockApi((path) => + path.endsWith('/config') ? config.clone() : response({ error: 'Catalog unavailable' }, 503), + ); + const view = mount(); + expect(await screen.findByText('Catalog unavailable')).toBeVisible(); + expect(document.querySelector('[data-commerce-source="sample"]')).toBeNull(); + config = response({ error: 'Configuration lost' }, 503); + await act(async () => { + await view.client.refetchQueries({ queryKey: ['commerce', 'configuration'] }); + }); + expect(await screen.findByText('Configuration lost')).toBeVisible(); + expect(document.querySelector('[data-commerce-source="sample"]')).toBeNull(); + }); + + it('ignores an older unbound config response after a newer ready response', async () => { + const old = deferred(); + let reads = 0; + mockApi((path) => { + if (!path.endsWith('/config')) return response({ skuGroups: { edges: [] } }); + reads++; + if (reads === 1) return response({ state: 'unbound' }); + if (reads === 2) return old.promise; + return response({ state: 'ready', ...configuration }); + }); + const view = mount(); + await screen.findByText('Product 1'); + act(() => { + void view.client.refetchQueries({ queryKey: ['commerce', 'configuration'] }); + }); + await waitFor(() => expect(reads).toBe(2)); + await act(async () => { + await view.client.refetchQueries({ queryKey: ['commerce', 'configuration'] }); + }); + expect(await screen.findByText('No products available.')).toBeVisible(); + await act(async () => { + old.resolve(response({ state: 'unbound' })); + }); + expect(view.context().connection).toBe('ready'); + expect(document.querySelector('[data-commerce-source="sample"]')).toBeNull(); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 396ae314..46f3814a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -69,6 +69,12 @@ importers: specifier: ^7.0.0 version: 7.18.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0) devDependencies: + '@godaddy/gd-commerce-server': + specifier: workspace:* + version: link:../../packages/commerce-server + '@types/express': + specifier: ^5.0.3 + version: 5.0.3 '@types/node': specifier: ^22.13.1 version: 22.18.12 @@ -78,6 +84,9 @@ importers: '@types/react-dom': specifier: ^19.0.3 version: 19.2.2(@types/react@19.2.2) + express: + specifier: ^5.1.0 + version: 5.2.1 typescript: specifier: ~5.7.3 version: 5.7.3 From 788e39a8b96c93ca43ad528a260bd5759c9d482e Mon Sep 17 00:00:00 2001 From: Scott Bolinger Date: Wed, 7 Oct 2026 15:30:39 -0700 Subject: [PATCH 2/4] Simplify sample cart copy and restore the storefront example Shorten the sample cart title and checkout note. Return the example app to its existing live demo and remove its link from the package README. Co-Authored-By: Claude Opus 5.5 --- examples/commerce-storefront/README.md | 35 +++++---------------- examples/commerce-storefront/main.tsx | 25 ++------------- examples/commerce-storefront/package.json | 5 +-- examples/commerce-storefront/tsconfig.json | 5 +-- examples/commerce-storefront/vite.config.ts | 34 ++------------------ packages/commerce-storefront/README.md | 3 +- packages/commerce-storefront/src/cart.tsx | 6 ++-- pnpm-lock.yaml | 9 ------ 8 files changed, 21 insertions(+), 101 deletions(-) diff --git a/examples/commerce-storefront/README.md b/examples/commerce-storefront/README.md index 9ab2dea4..7e44dd0c 100644 --- a/examples/commerce-storefront/README.md +++ b/examples/commerce-storefront/README.md @@ -1,38 +1,19 @@ -# Local storefront template +# Independent commerce storefront example -A standalone development fixture for preparing a reusable template without creating a store -or supplying Commerce credentials. It consumes the built workspace packages; it is not an AAB -template artifact or evidence of real merchant activation. +This React Router application consumes the compiled `@godaddy/gd-commerce-storefront` package and its shipped CSS. It runs as a standalone application without Tailwind configuration. -From the repository root (Node 24 and pnpm 10.14.0): +Run from the repository root with Node 24: ```sh pnpm install -pnpm --filter @godaddy/gd-commerce-server build pnpm --filter @godaddy/gd-commerce-storefront build pnpm --filter commerce-storefront-example dev ``` -Open http://127.0.0.1:5184. Both Home and Shop use `Catalog sampleProducts`, one provider, -and the shared header/cart drawer. Add Product 1, navigate between pages, change quantity, -and remove it. Reload clears the sample cart. No checkout exists. +Open . The Vite development server serves a demonstration catalog and in-memory cart at `/api/commerce`. Try adding the mug, changing quantities, removing items, and selecting the blue or sold-out clay tote. Reloading the browser restores a saved cart while the demo server remains running. Restarting the server expires it. -The development controls alter the local server's authoritative state: +This mock is for local UI demonstration only. It creates no Commerce orders or payments and is not a production server implementation. Checkout is intentionally disabled. The production build verifies bundling; it does not include the mock API, so a deployed build needs an implementation of the [server contract](../../packages/commerce-storefront/docs/server-api.md). -- **Unbound:** real server package config/router, package-owned samples and local cart. -- **Connecting:** samples disappear and actions suspend on the next automatic config read. -- **Configuration error:** the real config handler returns 503; samples do not return. -- **Live empty catalog (simulated):** ready config and an empty response from the existing - development catalog fixture. No fallback to samples. -- **Existing live demo (simulated):** retains the example's pre-existing mug/tote and in-memory - cart API, including variants and unavailable inventory. No upstream requests or payments. - -Unbound/connecting configurations refresh automatically every five seconds while visible. -Use **Refresh configuration** to exercise ready-state failures or recovery immediately. No -component prop changes or page reloads are needed. Sample items never enter the live demo cart. -Development state controls only exist in Vite dev; a production build requires real host API -routes and authoritative binding projection. Never copy the development API or its fixed -configuration into a customer template. The sample catalog code and data live inside the package. - -This fixture checks component behavior and the server state boundary. AAB import/projection -and a real store's catalog/cart/checkout still require their own integration verification. +```sh +pnpm --filter commerce-storefront-example build +``` diff --git a/examples/commerce-storefront/main.tsx b/examples/commerce-storefront/main.tsx index b654cd92..1cafa741 100644 --- a/examples/commerce-storefront/main.tsx +++ b/examples/commerce-storefront/main.tsx @@ -1,4 +1,3 @@ -import { useState } from 'react'; import { createRoot } from 'react-dom/client'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { BrowserRouter, Link, Navigate, Route, Routes } from 'react-router'; @@ -7,34 +6,16 @@ import '@godaddy/gd-commerce-storefront/styles.css'; import './styles.css'; const client = new QueryClient(); -function DevelopmentControls() { - const [status, setStatus] = useState('Unbound template'); - return
- Local template test controls - {' '} - -

{status}. Unbound/connecting pages refresh automatically every 5 seconds.

-
; -} createRoot(document.getElementById('root')!).render( Skip to products -
Template example
+
Field & Form
-

Local template demonstration. Example products and prices. No orders or payments are created.

- {import.meta.env.DEV && } +

Demo catalog and in-memory cart. No orders or payments are created.

-

Your template storefront

The same catalog and cart activate when a store connects.

} /> - } /> + } /> } /> } />
diff --git a/examples/commerce-storefront/package.json b/examples/commerce-storefront/package.json index c10cda5d..961a9498 100644 --- a/examples/commerce-storefront/package.json +++ b/examples/commerce-storefront/package.json @@ -19,9 +19,6 @@ "@types/react": "^19.0.8", "@types/react-dom": "^19.0.3", "typescript": "~5.7.3", - "vite": "^6.4.1", - "@godaddy/gd-commerce-server": "workspace:*", - "express": "^5.1.0", - "@types/express": "^5.0.3" + "vite": "^6.4.1" } } diff --git a/examples/commerce-storefront/tsconfig.json b/examples/commerce-storefront/tsconfig.json index a94fafc5..68481738 100644 --- a/examples/commerce-storefront/tsconfig.json +++ b/examples/commerce-storefront/tsconfig.json @@ -11,10 +11,7 @@ "strict": true, "skipLibCheck": true, "noEmit": true, - "esModuleInterop": true, - "types": [ - "vite/client" - ] + "esModuleInterop": true }, "include": [ "*.ts", diff --git a/examples/commerce-storefront/vite.config.ts b/examples/commerce-storefront/vite.config.ts index cbec97a8..153796d3 100644 --- a/examples/commerce-storefront/vite.config.ts +++ b/examples/commerce-storefront/vite.config.ts @@ -1,5 +1,3 @@ -import express from 'express'; -import { createCommerceCatalogRouter, type CommerceConfiguration } from '@godaddy/gd-commerce-server'; import { randomUUID } from 'node:crypto'; import { defineConfig, type Plugin } from 'vite'; import type { CartOrder, SKU, SKUGroup } from '@godaddy/gd-commerce-storefront'; @@ -20,46 +18,20 @@ const products: SKUGroup[] = [ // Demonstration only. This is neither a production server nor a Commerce API emulator. function demoApi(): Plugin { const carts = new Map(); - let state: 'unbound' | 'connecting' | 'error' | 'live' | 'live-empty' = 'unbound'; - const configuration: CommerceConfiguration = { - readBindingState: () => { - if (state === 'error') throw new Error('Development host state unavailable.'); - return state === 'live' || state === 'live-empty' ? 'ready' : state; - }, - // Development-only values. Live demo catalog/cart routes below never contact this origin. - read: () => ({ clientId: 'local-example', clientSecret: 'not-a-secret', storeId: 'local-example', channelId: 'local-example', currencyCode: 'USD', apiBaseUrl: 'https://example.invalid' }), - readCheckout: () => ({ enablePromotionCodes: false, enableTaxCollection: false, enableShipping: false }), - }; - const router = createCommerceCatalogRouter(configuration); return { name: 'storefront-demo-api', configureServer(server) { - const app = express(); - app.post('/__demo/commerce-state', express.json(), (req, res) => { - const next = req.body?.state; - if (!['unbound', 'connecting', 'error', 'live', 'live-empty'].includes(next)) { - res.status(400).json({ error: 'Unknown demo state.' }); - return; - } - state = next; - carts.clear(); - res.json({ state }); - }); - app.use('/api/commerce', (req, res, next) => { - if (req.path === '/config' || (state !== 'live' && state !== 'live-empty')) router(req, res, next); - else next(); - }); - server.middlewares.use(app); server.middlewares.use('/api/commerce', async (req, res, next) => { try { const url = new URL(req.url ?? '/', 'http://localhost'); const send = (value: unknown, status = 200) => { res.statusCode = status; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(value)); }; - if (url.pathname === '/products') return send({ skuGroups: { edges: state === 'live-empty' ? [] : products.map(node => ({ node })), pageInfo: { hasNextPage: false } } }); + if (url.pathname === '/config') return send({ cartScope: 'demo-v1', currencyCode: 'USD' }); + if (url.pathname === '/products') return send({ skuGroups: { edges: products.map(node => ({ node })), pageInfo: { hasNextPage: false } } }); if (url.pathname.startsWith('/products/')) { const product = products.find(item => item.id === url.pathname.split('/')[2]); const color = url.searchParams.get('attributeValues'); return send({ skuGroup: product && color ? { ...product, skus: { edges: color === 'blue' ? [{ node: blue }] : color === 'clay' ? [{ node: clay }] : [], totalCount: 1 } } : product ?? null }); } if (!url.pathname.startsWith('/cart')) return next(); - if (!req.headers['x-commerce-scope']) return send({ error: 'Store changed. Reload the page.' }, 409); + if (req.headers['x-commerce-scope'] !== 'demo-v1') return send({ error: 'Store changed. Reload the page.' }, 409); let body = ''; for await (const chunk of req) body += chunk; const input = body ? JSON.parse(body) : {}; diff --git a/packages/commerce-storefront/README.md b/packages/commerce-storefront/README.md index cc7535b7..356f538d 100644 --- a/packages/commerce-storefront/README.md +++ b/packages/commerce-storefront/README.md @@ -185,5 +185,4 @@ through a verified intentional unbinding operation. This is a host lifecycle res Install a compatible server package and host state adapter before using sample templates. Old successful config responses remain supported; old or unknown hosts never implicitly permit samples. Template archives carry UI and dependencies, not author credentials, binding -state, saved carts, or checkout settings. See [server contract](docs/server-api.md) and the -[local template example](../../examples/commerce-storefront/README.md). +state, saved carts, or checkout settings. See the [server contract](docs/server-api.md). diff --git a/packages/commerce-storefront/src/cart.tsx b/packages/commerce-storefront/src/cart.tsx index adcc2e85..871ce681 100644 --- a/packages/commerce-storefront/src/cart.tsx +++ b/packages/commerce-storefront/src/cart.tsx @@ -162,7 +162,7 @@ export function CartDrawer(): ReactElement {
- {isSample ? 'Sample cart — checkout unavailable' : 'Your cart'} + {isSample ? 'Sample cart' : 'Your cart'} {isSample @@ -322,7 +322,9 @@ export function CartDrawer(): ReactElement {

)} {isSample ? ( -

Example total in USD. Checkout unavailable.

+

+ Example total in USD. Checkout is not set up yet. +

) : config.checkoutSuccessPath ? (