diff --git a/.changeset/fresh-catalog-products.md b/.changeset/fresh-catalog-products.md new file mode 100644 index 00000000..a32ad085 --- /dev/null +++ b/.changeset/fresh-catalog-products.md @@ -0,0 +1,6 @@ +--- +'@godaddy/gd-commerce-server': patch +'@godaddy/gd-commerce-storefront': patch +--- + +Load active catalog products and SKUs in merchant-defined variant order, while keeping inactive product details visible without purchase controls. diff --git a/.changeset/quiet-tax-totals.md b/.changeset/quiet-tax-totals.md new file mode 100644 index 00000000..59be9817 --- /dev/null +++ b/.changeset/quiet-tax-totals.md @@ -0,0 +1,6 @@ +--- +'@godaddy/gd-commerce-storefront': patch +'@godaddy/gd-commerce-server': patch +--- + +Show the draft-order subtotal and explain that shipping, taxes, and discounts are calculated at checkout. diff --git a/AGENTS.md b/AGENTS.md index 5dd80d7b..16708443 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -86,7 +86,7 @@ biome-config-godaddy (packages/biome-config-godaddy) - typecheck: tsc --noEmit - test: vitest run -@godaddy/commerce-server (packages/commerce-server) +@godaddy/gd-commerce-server (packages/commerce-server) - build: tsdown - typecheck: tsc --noEmit - lint: biome check src @@ -137,9 +137,9 @@ Packages (top-level purpose) - React component library for checkout flows; integrates with commerce APIs - Uses tsdown for TS build and Tailwind CLI v4 for CSS build; Vitest for tests; Vite preview - Depends on @godaddy/localizations -- @godaddy/commerce-storefront +- @godaddy/gd-commerce-storefront - React catalog, product details, and cart components using a same-origin Commerce API -- @godaddy/commerce-server +- @godaddy/gd-commerce-server - Express 5 routers for the storefront API, hosted checkout, and order lookup - Uses tsdown, Vitest, and Biome; usable independently of the storefront package - @godaddy/localizations @@ -288,7 +288,7 @@ D. @godaddy/react - Uses path alias "@/*" for src - If adding components, follow existing patterns in src/components/checkout/** and src/components/ui/** -E. @godaddy/commerce-storefront +E. @godaddy/gd-commerce-storefront - Opinionated React storefront; fixed same-origin `/api/commerce` server contract documented in packages/commerce-storefront/docs/server-api.md. - Peers: React/React DOM 18 or 19, React Router 7 or 8.3+, TanStack Query 5. Host owns router/query providers; CommerceStorefront owns the cart provider and drawer. - Build: tsdown plus local Tailwind CLI, CSS scoping, and layer removal in declared order; styles exported as ./styles.css. Artifact tests process the output through Tailwind v3 to verify host compatibility. No host Tailwind setup or global reset. @@ -296,7 +296,7 @@ E. @godaddy/commerce-storefront - Example: examples/commerce-storefront, port 5184, development-only in-memory server; production build needs real API routes. - Keep credentials, merchant provisioning and platform configuration out of this client package. No dependency on the separate commerce web-component runtime. -F. @godaddy/commerce-server +F. @godaddy/gd-commerce-server - Mount `createCommerceRouter()` at `/api/commerce` after `express.json()`. `createCommerceCatalogRouter()` installs config/catalog/cart routes; `createGoDaddyPaymentsRouter()` installs checkout/order-status routes. - Hosts can supply `CommerceConfiguration`. The default runtime reader uses server environment variables for credentials, store/channel IDs, currency, and checkout flags; it does not read files. The API defaults to `https://api.godaddy.com`; an explicit server-controlled `apiBaseUrl` option supports alternate origins without embedding environment-specific hosts. Keep this package server-only. - Hosts own deployment-specific configuration loading, provisioning readiness, retries, and optional `sourceApp`/`owner` attribution. Shipping options use the hosted checkout API shape; omit them to use the store configuration. @@ -305,7 +305,7 @@ F. @godaddy/commerce-server - Source alias `@/*` maps to `src/*` in TypeScript and Vitest. tsdown resolves it when bundling JavaScript and declarations; consumers need no alias configuration. When changing module resolution, verify a packed consumer outside the workspace. - Validate supplied `X-Commerce-Scope` headers before catalog/cart/checkout calls. It guards against stale bindings and is not authorization. Hosts own authentication and authorization. - Cart mutations return a refreshed cart. Keep URL cart/item IDs authoritative, allowlist PATCH fields, and only clear carts for explicit missing/expired-order errors; upstream authentication or transport failures must preserve saved carts. -- Commands: `pnpm --filter @godaddy/commerce-server build`, `typecheck`, `lint`, and `test`. See packages/commerce-server/README.md and packages/commerce-storefront/docs/server-api.md for integration details. +- Commands: `pnpm --filter @godaddy/gd-commerce-server build`, `typecheck`, `lint`, and `test`. See packages/commerce-server/README.md and packages/commerce-storefront/docs/server-api.md for integration details. G. @godaddy/localizations - Purpose: Localization bundles for checkout UI diff --git a/README.md b/README.md index 62848256..51341742 100644 --- a/README.md +++ b/README.md @@ -52,10 +52,10 @@ This monorepo contains the following packages: | [`biome-config-godaddy`](/packages/biome-config-godaddy) | Fast Rust-based alternative to ESLint and Prettier using Biome | [![npm](https://img.shields.io/npm/v/biome-config-godaddy.svg)](https://www.npmjs.com/package/biome-config-godaddy) | | [`@godaddy/app-connect`](/packages/app-connect) | Platform integration tools for GoDaddy apps | [![npm](https://img.shields.io/npm/v/@godaddy/app-connect.svg)](https://www.npmjs.com/package/@godaddy/app-connect) | | [`@godaddy/react`](/packages/react) | React components and commerce API integration | [![npm](https://img.shields.io/npm/v/@godaddy/react.svg)](https://www.npmjs.com/package/@godaddy/react) | -| [@godaddy/commerce-storefront](packages/commerce-storefront) | Opinionated React catalog, product, and cart templates; [consumer example](examples/commerce-storefront) | Unreleased | -| [@godaddy/commerce-server](packages/commerce-server) | Express APIs for Commerce catalog, carts, hosted checkout, and order lookup | Unreleased | +| [@godaddy/gd-commerce-storefront](packages/commerce-storefront) | Opinionated React catalog, product, and cart templates; [consumer example](examples/commerce-storefront) | Unreleased | +| [@godaddy/gd-commerce-server](packages/commerce-server) | Express APIs for Commerce catalog, carts, hosted checkout, and order lookup | Unreleased | -`@godaddy/commerce-server` implements the same-origin API used by `@godaddy/commerce-storefront`. The packages can also be used independently with a custom client or server; see the [server integration guide](packages/commerce-server/README.md) and [storefront API contract](packages/commerce-storefront/docs/server-api.md). +`@godaddy/gd-commerce-server` implements the same-origin API used by `@godaddy/gd-commerce-storefront`. The packages can also be used independently with a custom client or server; see the [server integration guide](packages/commerce-server/README.md) and [storefront API contract](packages/commerce-storefront/docs/server-api.md). ## Why GoDaddy JavaScript? diff --git a/examples/commerce-storefront/README.md b/examples/commerce-storefront/README.md index 4a7563b9..7e44dd0c 100644 --- a/examples/commerce-storefront/README.md +++ b/examples/commerce-storefront/README.md @@ -1,12 +1,12 @@ # Independent commerce storefront example -This React Router application consumes the compiled `@godaddy/commerce-storefront` package and its shipped CSS. It runs as a standalone application without Tailwind configuration. +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. Run from the repository root with Node 24: ```sh pnpm install -pnpm --filter @godaddy/commerce-storefront build +pnpm --filter @godaddy/gd-commerce-storefront build pnpm --filter commerce-storefront-example dev ``` diff --git a/examples/commerce-storefront/main.tsx b/examples/commerce-storefront/main.tsx index a5b491e2..1cafa741 100644 --- a/examples/commerce-storefront/main.tsx +++ b/examples/commerce-storefront/main.tsx @@ -1,8 +1,8 @@ import { createRoot } from 'react-dom/client'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { BrowserRouter, Link, Navigate, Route, Routes } from 'react-router'; -import { Catalog, CartButton, CommerceStorefront, ProductDetails } from '@godaddy/commerce-storefront'; -import '@godaddy/commerce-storefront/styles.css'; +import { Catalog, CartButton, CommerceStorefront, ProductDetails } from '@godaddy/gd-commerce-storefront'; +import '@godaddy/gd-commerce-storefront/styles.css'; import './styles.css'; const client = new QueryClient(); diff --git a/examples/commerce-storefront/package.json b/examples/commerce-storefront/package.json index d101012d..961a9498 100644 --- a/examples/commerce-storefront/package.json +++ b/examples/commerce-storefront/package.json @@ -8,7 +8,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@godaddy/commerce-storefront": "workspace:*", + "@godaddy/gd-commerce-storefront": "workspace:*", "@tanstack/react-query": "^5.66.0", "react": "^19", "react-dom": "^19", diff --git a/examples/commerce-storefront/vite.config.ts b/examples/commerce-storefront/vite.config.ts index 885be445..153796d3 100644 --- a/examples/commerce-storefront/vite.config.ts +++ b/examples/commerce-storefront/vite.config.ts @@ -1,6 +1,6 @@ import { randomUUID } from 'node:crypto'; import { defineConfig, type Plugin } from 'vite'; -import type { CartOrder, SKU, SKUGroup } from '@godaddy/commerce-storefront'; +import type { CartOrder, SKU, SKUGroup } from '@godaddy/gd-commerce-storefront'; const money = (value: number) => ({ value, currencyCode: 'USD' }); const sku = (id: string, price: number, quantity = 20): SKU => ({ diff --git a/packages/commerce-server/CHANGELOG.md b/packages/commerce-server/CHANGELOG.md index 0fcdfc62..4f4521aa 100644 --- a/packages/commerce-server/CHANGELOG.md +++ b/packages/commerce-server/CHANGELOG.md @@ -1,4 +1,4 @@ -# @godaddy/commerce-server +# @godaddy/gd-commerce-server ## 0.1.0 diff --git a/packages/commerce-server/README.md b/packages/commerce-server/README.md index c1741082..eb867c1f 100644 --- a/packages/commerce-server/README.md +++ b/packages/commerce-server/README.md @@ -1,10 +1,10 @@ -# @godaddy/commerce-server +# @godaddy/gd-commerce-server An opinionated Express router for GoDaddy Commerce catalog, cart, and hosted checkout APIs. ```ts import express from 'express'; -import { createCommerceRouter, createRuntimeCommerceConfiguration } from '@godaddy/commerce-server'; +import { createCommerceRouter, createRuntimeCommerceConfiguration } from '@godaddy/gd-commerce-server'; const app = express(); app.use(express.json()); @@ -22,7 +22,9 @@ The default configuration reads these **server-only environment variables** on e - `GODADDY_OAUTH_CLIENT_ID` and `GODADDY_OAUTH_CLIENT_SECRET` - `GODADDY_STORE_ID` and `GODADDY_CHANNEL_ID` - `GODADDY_CURRENCY_CODE` -- Optional `GODADDY_CHECKOUT_CONFIGURATION`: JSON with boolean `enablePromotionCodes`, `enableTaxCollection`, and `enableShipping` fields. All three default to false when this variable is absent. Optional `shipping` accepts the checkout API's `originAddress` or `fulfillmentLocationId`; omit it to use store configuration. +- Optional `GODADDY_CHECKOUT_CONFIGURATION`: JSON with boolean `enablePromotionCodes`, `enableTaxCollection`, and `enableShipping` fields. All three default to false when this variable is absent or malformed so configuration discovery cannot prevent checkout creation. Optional `shipping` accepts the checkout API's `originAddress` or `fulfillmentLocationId`; omit it to use store configuration. + +See [Checkout configuration](docs/checkout-configuration.md) for the temporary build-time synchronization flow and its eventual-consistency limitation. The API origin defaults to `https://api.godaddy.com`. The package does not load files, provision merchants, or assign application attribution. Hosts own these concerns and any readiness checks or retries before invoking Commerce. diff --git a/packages/commerce-server/docs/checkout-configuration.md b/packages/commerce-server/docs/checkout-configuration.md new file mode 100644 index 00000000..8e09e268 --- /dev/null +++ b/packages/commerce-server/docs/checkout-configuration.md @@ -0,0 +1,62 @@ +# Checkout configuration + +## Goal + +Storefronts should respond to a merchant enabling or disabling shipping, taxes, or discounts without requiring the builder agent to edit application code. This document records the current design, its temporary limitation, and the intended long-term solution. + +## Decisions + +- The cart remains a draft order. We will not create a checkout session when the cart is created or maintain a lazy session while the shopper edits it. +- The cart does not calculate or estimate shipping, taxes, or discounts. It displays **Subtotal** and one message: “Shipping, taxes, and discounts are calculated at checkout.” The message has the stable `commerce-cart-checkout-adjustments-note` class so an application can hide it easily. +- A checkout session is created only when the shopper selects **Checkout**. The Checkout API and the enabled commerce providers perform the actual calculations. +- Checkout capability flags are server-only. They must not be exposed by `/api/commerce/config` or supplied by browser code. +- We will defer live capability discovery in this library because the Checkout API is expected to discover enabled providers through App Registry. Until that work ships, configuration can be refreshed during an initial build or a later rebuild/redeploy. + +This keeps the cart simple, avoids a second source of pricing logic, and lets shipping rates, tax rules, and discounts remain authoritative in their respective services. + +## Source of truth + +App Registry is the source of truth for whether the store has a provider enabled for each capability: + +| Capability | App Registry action | +| --- | --- | +| Shipping | `commerce.shipping-rates.calculate` | +| Taxes | `commerce.taxes.calculate` | +| Discounts | `commerce.price-adjustment.apply` | + +App Registry indicates that a capable GPA is enabled; the shipping, tax, and discount services still own their settings, rules, and calculations. The general Settings API is not a consolidated source for these three enablement states. + +Commerce Admin already exposes this lookup to builders through the `commerce_checkout_configuration_get` MCP tool. Given a `storeId` and `channelId`, it validates their binding, queries enabled App Registry actions, and returns checkout flags such as `enableShipping`, `enableTaxCollection`, and `enablePromotionCodes`, plus shipping-origin readiness. + +## Temporary build-time flow + +Until Checkout API performs App Registry discovery itself: + +1. During every initial build and rebuild/redeploy, the builder calls `commerce_checkout_configuration_get`. +2. The deployment writes the three returned feature flags to the server-only `GODADDY_CHECKOUT_CONFIGURATION` value; it does not copy the full MCP response or generate conditional application code. +3. When Checkout is selected, `commerce-server` reads that value and explicitly configures the new checkout session. +4. The enabled providers calculate live rates and adjustments during checkout. + +For example: + +```json +{ + "enablePromotionCodes": true, + "enableTaxCollection": true, + "enableShipping": true +} +``` + +Restoring the server reader alone does not discover merchant settings: the builder/deployment integration must populate this value. Shipping-origin address details do not need to be copied into application code; hosted checkout can use store configuration. + +The accepted stopgap is eventually consistent: a provider enabled or disabled after deployment is reflected on the next rebuild, not immediately. This is acceptable only while the Checkout API change is pending. + +If the MCP lookup fails, the deployment does not receive the expected flags, or the server value is malformed, checkout falls back to all optional capabilities disabled. In particular, it sends `enableShipping: false` rather than preventing checkout creation. Builders should surface the lookup problem during the build, but it must not make the deployed checkout unusable. + +## Important compatibility detail + +The current checkout request builder applies explicit defaults of `false` for shipping, shipping-address collection, and tax collection. Removing `GODADDY_CHECKOUT_CONFIGURATION` without changing that behavior disables those features and is a regression. Also, omitting fields is not a complete substitute: Checkout API currently defaults shipping to enabled, but does not similarly enable address collection, taxes, or promotion codes. + +## Long-term flow + +Checkout API should query App Registry when a session is created and automatically enable shipping, taxes, and discounts for the store’s active GPAs. Once that is available and verified, this repository can remove the static checkout configuration and its build-time synchronization. The cart and draft-order lifecycle do not need to change. diff --git a/packages/commerce-server/package.json b/packages/commerce-server/package.json index c14b2bf5..531d67f5 100644 --- a/packages/commerce-server/package.json +++ b/packages/commerce-server/package.json @@ -1,5 +1,5 @@ { - "name": "@godaddy/commerce-server", + "name": "@godaddy/gd-commerce-server", "version": "0.1.0", "description": "Opinionated Express server integration for GoDaddy Commerce", "type": "module", diff --git a/packages/commerce-server/src/config.test.ts b/packages/commerce-server/src/config.test.ts index 17b5db12..a2c6a920 100644 --- a/packages/commerce-server/src/config.test.ts +++ b/packages/commerce-server/src/config.test.ts @@ -97,14 +97,20 @@ describe('Commerce runtime configuration', () => { }); }); - it.each(['{bad json', 'null', '{"enableShipping":true}'])( - 'rejects malformed checkout configuration: %s', - (raw): void => { - expect(() => - createRuntimeCommerceConfiguration({ - environment: { ...environment(), GODADDY_CHECKOUT_CONFIGURATION: raw }, - }).readCheckout(), - ).toThrow('Commerce config: GODADDY_CHECKOUT_CONFIGURATION'); - }, - ); + it.each([ + '{bad json', + 'null', + '{"enableShipping":true}', + '{"enablePromotionCodes":false,"enableTaxCollection":false,"enableShipping":true,"shipping":{"originAddressConfigured":true}}', + ])('defaults malformed checkout configuration to disabled capabilities: %s', (raw): void => { + expect( + createRuntimeCommerceConfiguration({ + environment: { ...environment(), GODADDY_CHECKOUT_CONFIGURATION: raw }, + }).readCheckout(), + ).toEqual({ + enablePromotionCodes: false, + enableTaxCollection: false, + enableShipping: false, + }); + }); }); diff --git a/packages/commerce-server/src/configuration-integration.test.ts b/packages/commerce-server/src/configuration-integration.test.ts index ff9ec77e..14d2d2d5 100644 --- a/packages/commerce-server/src/configuration-integration.test.ts +++ b/packages/commerce-server/src/configuration-integration.test.ts @@ -91,7 +91,10 @@ it.each([undefined, 'https://api.example.com', 'https://api.example.com:8443'])( const base = `http://127.0.0.1:${address.port}/api/commerce`; const configResponse = await clientFetch(`${base}/config`); const publicConfig = await configResponse.json(); - expect(publicConfig).toEqual({ cartScope: expect.any(String), currencyCode: 'USD' }); + expect(publicConfig).toEqual({ + cartScope: expect.any(String), + currencyCode: 'USD', + }); const headers = { 'Content-Type': 'application/json', 'X-Commerce-Scope': publicConfig.cartScope }; expect((await clientFetch(`${base}/products`, { headers })).status).toBe(200); expect( diff --git a/packages/commerce-server/src/create-checkout-session.test.ts b/packages/commerce-server/src/create-checkout-session.test.ts index 82cb754a..1e615dd7 100644 --- a/packages/commerce-server/src/create-checkout-session.test.ts +++ b/packages/commerce-server/src/create-checkout-session.test.ts @@ -101,6 +101,16 @@ describe('createCheckoutSession', () => { }, ); + it('disables optional checkout capabilities when configuration falls back to defaults', async (): Promise => { + await createCheckoutSession(cart, configuration); + expect(mockGqlRequest.mock.calls[0]?.[0].variables.input).toMatchObject({ + enablePromotionCodes: false, + enableTaxCollection: false, + enableShipping: false, + enableShippingAddressCollection: false, + }); + }); + it.each(flows)('uses only host-owned attribution for %s checkout', async (_name, params): Promise => { config = { ...config, sourceApp: 'merchant-site', owner: 'merchant-orders' }; mockGqlRequest.mockResolvedValue(response({ sourceApp: 'merchant-site' })); @@ -143,13 +153,6 @@ describe('createCheckoutSession', () => { }, ); - it.each(flows)('rejects missing payment methods for %s checkout', async (_name, params): Promise => { - mockGqlRequest.mockResolvedValue(response({ paymentMethods: null })); - await expect(createCheckoutSession(params, configuration)).rejects.toThrow( - 'Checkout session did not configure payment methods.', - ); - }); - it.each([flows[1], flows[2]])( 'uses the store shipping configuration for %s checkout', async (_name, params): Promise => { @@ -234,6 +237,13 @@ describe('createCheckoutSession', () => { ); }); + it.each(flows)('rejects missing payment methods for %s checkout', async (_name, params): Promise => { + mockGqlRequest.mockResolvedValue(response({ paymentMethods: null })); + await expect(createCheckoutSession(params, configuration)).rejects.toThrow( + 'Checkout session did not configure payment methods.', + ); + }); + it('uses the configured currency for non-catalog pricing', async (): Promise => { config.currencyCode = 'GBP'; await createCheckoutSession( diff --git a/packages/commerce-server/src/lib/commerce/cart-scope.ts b/packages/commerce-server/src/lib/commerce/cart-scope.ts index 8854944a..46c5382a 100644 --- a/packages/commerce-server/src/lib/commerce/cart-scope.ts +++ b/packages/commerce-server/src/lib/commerce/cart-scope.ts @@ -3,12 +3,12 @@ import { createHash } from 'node:crypto'; import type { Request, Response } from 'express'; import type { CommerceConfig } from './config'; -type CartBinding = Pick; +type CartBinding = Pick; /** Public cache/storage scope, not an authorization credential. */ export function getCommerceCartScope(config: CartBinding): string { return createHash('sha256') - .update(JSON.stringify([config.apiBaseUrl, config.storeId, config.channelId])) + .update(JSON.stringify([config.apiBaseUrl, config.storeId, config.channelId, config.currencyCode])) .digest('hex') .slice(0, 32); } diff --git a/packages/commerce-server/src/lib/commerce/catalog-subgraph.ts b/packages/commerce-server/src/lib/commerce/catalog-subgraph.ts index adeeb135..93b03259 100644 --- a/packages/commerce-server/src/lib/commerce/catalog-subgraph.ts +++ b/packages/commerce-server/src/lib/commerce/catalog-subgraph.ts @@ -79,6 +79,7 @@ export type SKUGroupSKU = SKU; export interface SKUGroup { id?: string | null; + status?: string | null; name?: string | null; label?: string | null; description?: string | null; diff --git a/packages/commerce-server/src/lib/commerce/checkout-config.ts b/packages/commerce-server/src/lib/commerce/checkout-config.ts index 7cae4047..6568a42d 100644 --- a/packages/commerce-server/src/lib/commerce/checkout-config.ts +++ b/packages/commerce-server/src/lib/commerce/checkout-config.ts @@ -23,28 +23,34 @@ export function parseCommerceCheckoutConfiguration(raw: string | undefined): Com let value: unknown; try { value = JSON.parse(raw); - } catch (error) { - throw new Error('Commerce config: GODADDY_CHECKOUT_CONFIGURATION must be valid JSON.', { - cause: error, - }); + } catch { + return DEFAULT_CHECKOUT_CONFIGURATION; } if (value === null || typeof value !== 'object' || Array.isArray(value)) { - throw new Error('Commerce config: GODADDY_CHECKOUT_CONFIGURATION must be a JSON object.'); + return DEFAULT_CHECKOUT_CONFIGURATION; } const candidate = value as Record; for (const key of ['enablePromotionCodes', 'enableTaxCollection', 'enableShipping'] as const) { if (typeof candidate[key] !== 'boolean') { - throw new Error(`Commerce config: GODADDY_CHECKOUT_CONFIGURATION.${key} must be boolean.`); + return DEFAULT_CHECKOUT_CONFIGURATION; } } - const shipping = candidate.shipping; - if ( - shipping !== undefined && - (shipping === null || typeof shipping !== 'object' || Array.isArray(shipping)) - ) { - throw new Error('Commerce config: GODADDY_CHECKOUT_CONFIGURATION.shipping must be an object.'); + const shipping = candidate.shipping as Record | undefined; + if (shipping !== undefined) { + if (shipping === null || typeof shipping !== 'object' || Array.isArray(shipping)) { + return DEFAULT_CHECKOUT_CONFIGURATION; + } + const hasOriginAddress = + shipping.originAddress !== null && + typeof shipping.originAddress === 'object' && + !Array.isArray(shipping.originAddress); + const hasFulfillmentLocationId = + typeof shipping.fulfillmentLocationId === 'string' && shipping.fulfillmentLocationId.trim() !== ''; + if (hasOriginAddress === hasFulfillmentLocationId) { + return DEFAULT_CHECKOUT_CONFIGURATION; + } } return { diff --git a/packages/commerce-server/src/router.test.ts b/packages/commerce-server/src/router.test.ts index 3bb8bd54..c02f4bab 100644 --- a/packages/commerce-server/src/router.test.ts +++ b/packages/commerce-server/src/router.test.ts @@ -3,6 +3,7 @@ import type { Request, Response } from 'express'; import express from 'express'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { getCommerceCartScope } from './lib/commerce/cart-scope'; +import type { CommerceConfiguration } from './lib/commerce/config'; import { createCheckoutSession } from './lib/commerce/create-checkout-session'; import { GraphQLErrorWithCodes, gqlRequest } from './lib/commerce/gql'; import { getCartOrderQuery } from './lib/commerce/order-subgraph'; @@ -36,7 +37,7 @@ const binding = { clientId: 'client-1', clientSecret: 'server-only-secret', }; -const configuration = { +const configuration: CommerceConfiguration = { read: (): typeof binding => binding, readCheckout: (): { enablePromotionCodes: false; enableTaxCollection: false; enableShipping: false } => ({ enablePromotionCodes: false, @@ -65,6 +66,10 @@ describe('Commerce scoped routes', () => { expect(getCartOrderQuery).not.toMatch(/\bstatuses\s*\{/); }); + it('uses currency as part of the persisted cart binding', (): void => { + expect(getCommerceCartScope(binding)).not.toBe(getCommerceCartScope({ ...binding, currencyCode: 'GBP' })); + }); + it.each([readCart, addItem, updateItem, deleteItem, applyDiscount, readProduct, readSku])( 'rejects array route IDs before an upstream request', async (handler): Promise => { @@ -100,9 +105,23 @@ describe('Commerce scoped routes', () => { expect(query).toContain('prices(first: 10)'); expect(query).toContain('inventoryCounts'); expect(query).toContain('pageInfo { hasNextPage }'); + expect(query).toMatch(/skuGroup\(id: \$id\) \{\s+id\s+status/); + expect(query).toContain('attributes(first: 50, orderBy: { position: ASC })'); + expect(query).toContain('values(first: 50, orderBy: { position: ASC })'); + expect(query).toContain('status: { eq: "ACTIVE" }'); expect(res.json).toHaveBeenCalledWith({ skuGroup: { id: 'product' } }); }); + it('loads only active catalog products and active card SKUs', async (): Promise => { + const res: ReturnType = response(); + vi.mocked(gqlRequest).mockResolvedValueOnce({ skuGroups: { edges: [] } }); + await readProducts({ query: {} } as unknown as Request, res as unknown as Response); + const query: string = vi.mocked(gqlRequest).mock.calls[0]?.[0].query ?? ''; + expect(query).toContain('status: { eq: "ACTIVE" }'); + expect(query).toContain('skus(first: 2, status: { eq: "ACTIVE" })'); + expect(query).toContain('priceRange(status: { eq: "ACTIVE" })'); + }); + it.each([readProducts, readProduct, readSku])( 'keeps catalog queries within the upstream depth limit of 10', async (handler: typeof readProducts): Promise => { diff --git a/packages/commerce-server/src/server/api/commerce/config/GET.ts b/packages/commerce-server/src/server/api/commerce/config/GET.ts index 7deb364a..3169634f 100644 --- a/packages/commerce-server/src/server/api/commerce/config/GET.ts +++ b/packages/commerce-server/src/server/api/commerce/config/GET.ts @@ -1,17 +1,21 @@ /** * GET /api/commerce/config - * Public { cartScope, currencyCode } for the shared CommerceProvider. + * Public binding configuration for the shared CommerceProvider. * Store/channel IDs and credentials stay server-side. Do not cache across bindings. */ import type { Request, Response } from 'express'; import { getCommerceCartScope } from '@/lib/commerce/cart-scope'; -import { type CommerceConfig, readCommerceConfigForResponse } from '@/lib/commerce/config'; +import { type CommerceConfig, commerceConfigurationForResponse } from '@/lib/commerce/config'; export default async function handler(_req: Request, res: Response): Promise { res.setHeader('Cache-Control', 'no-store'); try { - const config: CommerceConfig = readCommerceConfigForResponse(res); - res.json({ cartScope: getCommerceCartScope(config), currencyCode: config.currencyCode }); + const configuration = commerceConfigurationForResponse(res); + const config: CommerceConfig = configuration.read(); + res.json({ + cartScope: getCommerceCartScope(config), + currencyCode: config.currencyCode, + }); } catch (cause: unknown) { res.status(503).json({ error: 'Commerce configuration is unavailable. Complete the store connection before continuing.', diff --git a/packages/commerce-server/src/server/api/commerce/products/GET.ts b/packages/commerce-server/src/server/api/commerce/products/GET.ts index aea3f87d..e2ccfb7b 100644 --- a/packages/commerce-server/src/server/api/commerce/products/GET.ts +++ b/packages/commerce-server/src/server/api/commerce/products/GET.ts @@ -32,7 +32,14 @@ import { gqlRequest, storefrontHeaders } from '@/lib/commerce/gql'; // Nested SKU price money fields would exceed the catalog API's depth limit of 10. const skuGroupsQuery = ` query SkuGroups($first: Int, $after: String, $id: SKUGroupIdsFilter, $listId: ListIdFilter, $label: LabelFilter) { - skuGroups(first: $first, after: $after, id: $id, listId: $listId, label: $label) { + skuGroups( + first: $first + after: $after + id: $id + listId: $listId + label: $label + status: { eq: "ACTIVE" } + ) { edges { cursor node { @@ -42,11 +49,11 @@ const skuGroupsQuery = ` description htmlDescription type - priceRange { + priceRange(status: { eq: "ACTIVE" }) { min max } - compareAtPriceRange { + compareAtPriceRange(status: { eq: "ACTIVE" }) { min max } @@ -58,7 +65,7 @@ const skuGroupsQuery = ` } } } - attributes { + attributes(first: 50, orderBy: { position: ASC }) { edges { node { id @@ -66,7 +73,7 @@ const skuGroupsQuery = ` label description htmlDescription - values(first: 50) { + values(first: 50, orderBy: { position: ASC }) { edges { node { id @@ -78,7 +85,7 @@ const skuGroupsQuery = ` } } } - skus(first: 2) { + skus(first: 2, status: { eq: "ACTIVE" }) { pageInfo { hasNextPage } totalCount edges { diff --git a/packages/commerce-server/src/server/api/commerce/products/[id]/GET.ts b/packages/commerce-server/src/server/api/commerce/products/[id]/GET.ts index 1fb13b99..4b925ba3 100644 --- a/packages/commerce-server/src/server/api/commerce/products/[id]/GET.ts +++ b/packages/commerce-server/src/server/api/commerce/products/[id]/GET.ts @@ -29,16 +29,17 @@ const skuGroupQuery = ` query SkuGroup($id: String!, $first: Int, $attributeValues: [String!] = []) { skuGroup(id: $id) { id + status name label description htmlDescription type - priceRange { + priceRange(status: { eq: "ACTIVE" }) { min max } - compareAtPriceRange { + compareAtPriceRange(status: { eq: "ACTIVE" }) { min max } @@ -50,7 +51,7 @@ const skuGroupQuery = ` } } } - attributes { + attributes(first: 50, orderBy: { position: ASC }) { edges { node { id @@ -58,7 +59,7 @@ const skuGroupQuery = ` label description htmlDescription - values(first: 50) { + values(first: 50, orderBy: { position: ASC }) { edges { node { id @@ -70,7 +71,7 @@ const skuGroupQuery = ` } } } - skus(attributeValues: { has: $attributeValues }, first: $first) { + skus(attributeValues: { has: $attributeValues }, first: $first, status: { eq: "ACTIVE" }) { pageInfo { hasNextPage } totalCount edges { diff --git a/packages/commerce-storefront/CHANGELOG.md b/packages/commerce-storefront/CHANGELOG.md index 347392a0..e2eeb90d 100644 --- a/packages/commerce-storefront/CHANGELOG.md +++ b/packages/commerce-storefront/CHANGELOG.md @@ -1,4 +1,4 @@ -# @godaddy/commerce-storefront +# @godaddy/gd-commerce-storefront ## 0.1.0 diff --git a/packages/commerce-storefront/README.md b/packages/commerce-storefront/README.md index 1156dfe9..ab2dad6f 100644 --- a/packages/commerce-storefront/README.md +++ b/packages/commerce-storefront/README.md @@ -1,6 +1,6 @@ # Commerce storefront -`@godaddy/commerce-storefront` provides complete React storefront templates: a catalog, product details with variant selection, a shared cart, and a cart drawer. Applications import compiled components instead of copying and maintaining their implementation. +`@godaddy/gd-commerce-storefront` provides complete React storefront templates: a catalog, product details with variant selection, a shared cart, and a cart drawer. Applications import compiled components instead of copying and maintaining their implementation. This is an opinionated package for React applications that use React Router 7 or 8.3+, TanStack Query 5, and the documented same-origin Commerce API. It works with the host application’s router and query provider and does not require Tailwind configuration. @@ -9,7 +9,7 @@ This is an opinionated package for React applications that use React Router 7 or This package is not published yet. Use the local workspace example while reviewing this branch. After the first release, install it with: ```sh -pnpm add @godaddy/commerce-storefront @tanstack/react-query react react-dom react-router +pnpm add @godaddy/gd-commerce-storefront @tanstack/react-query react react-dom react-router ``` Import the stylesheet once. Mount `CommerceStorefront` once inside your application's existing router and query provider. It owns the commerce state and renders one cart drawer. Keep your header and page routes inside it so cart buttons share that state. @@ -21,8 +21,8 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { BrowserRouter, Route, Routes } from 'react-router'; import { Catalog, CartButton, CommerceStorefront, ProductDetails, -} from '@godaddy/commerce-storefront'; -import '@godaddy/commerce-storefront/styles.css'; +} from '@godaddy/gd-commerce-storefront'; +import '@godaddy/gd-commerce-storefront/styles.css'; const client = new QueryClient(); @@ -45,7 +45,7 @@ export function App() { } ``` -Use the providers your application already has; do not create another router or query client for this package. Your server must implement the [server API contract](docs/server-api.md) before these components can load products. Express hosts can mount `@godaddy/commerce-server`; custom servers can implement the contract directly. The host owns store credentials, merchant provisioning, payment readiness, and runtime configuration. +Use the providers your application already has; do not create another router or query client for this package. Your server must implement the [server API contract](docs/server-api.md) before these components can load products. Express hosts can mount `@godaddy/gd-commerce-server`; custom servers can implement the contract directly. The host owns store credentials, merchant provisioning, payment readiness, and runtime configuration. ## Configuration @@ -58,7 +58,7 @@ Use the providers your application already has; do not create another router or Provide root-relative paths without a trailing slash. Enable `checkoutSuccessPath` only after your server supports checkout and validates merchant readiness. Mount a corresponding return page. A redirect back from checkout is **not proof of payment**; that page must obtain authoritative payment status from your server. This package does not provide a payment receipt page or merchant onboarding. -`GET /api/commerce/config` supplies the currency and opaque cart scope. The scope must change when the store/channel binding changes. Applications do not pass store IDs or credentials into the browser package. One storefront binding is supported per page and query client. +`GET /api/commerce/config` supplies the currency and opaque cart scope. The scope must change when the store, channel, or currency binding changes. The draft-order cart displays its subtotal and explains that shipping, taxes, and discounts are calculated at checkout. The explanation has the stable `commerce-cart-checkout-adjustments-note` class so applications can hide it when needed. The cart does not create a checkout session or calculate adjustments. Applications do not pass store IDs or credentials into the browser package. One storefront binding is supported per page and query client. A connection failure leaves the surrounding application and its state mounted. Catalog and product surfaces show the connection error and retry action. Custom integrations can render `CommerceStatus` or inspect `useCommerce().connection`. @@ -129,10 +129,10 @@ From the repository root, with Node 24: ```sh pnpm install -pnpm --filter @godaddy/commerce-storefront build -pnpm --filter @godaddy/commerce-storefront typecheck -pnpm --filter @godaddy/commerce-storefront lint -pnpm --filter @godaddy/commerce-storefront test +pnpm --filter @godaddy/gd-commerce-storefront build +pnpm --filter @godaddy/gd-commerce-storefront typecheck +pnpm --filter @godaddy/gd-commerce-storefront lint +pnpm --filter @godaddy/gd-commerce-storefront test pnpm --filter commerce-storefront-example build pnpm --filter commerce-storefront-example dev ``` diff --git a/packages/commerce-storefront/docs/server-api.md b/packages/commerce-storefront/docs/server-api.md index 9b73a489..0ea2ac48 100644 --- a/packages/commerce-storefront/docs/server-api.md +++ b/packages/commerce-storefront/docs/server-api.md @@ -1,6 +1,6 @@ # Server API contract -The browser calls JSON endpoints under `/api/commerce` on its own origin. The consuming application provides these routes. This browser package contains no server credentials, platform configuration reader, merchant provisioning, or GraphQL transport. Express applications can use the companion `@godaddy/commerce-server` package; other hosts can implement this contract directly. +The browser calls JSON endpoints under `/api/commerce` on its own origin. The consuming application provides these routes. This browser package contains no server credentials, platform configuration reader, merchant provisioning, or GraphQL transport. Express applications can use the companion `@godaddy/gd-commerce-server` package; other hosts can implement this contract directly. Server implementations can use GoDaddy Commerce APIs or their existing integration, but must return these shapes and enforce the same purchase rules. Returning a similar-looking GraphQL mutation result is insufficient: cart mutations return a complete refreshed cart. @@ -11,13 +11,13 @@ Server implementations can use GoDaddy Commerce APIs or their existing integrati - Read requests use `cache: no-store`, cancellation, and a 15-second timeout. The initial cart creation/add request is also a write and is never retried automatically. - Return a non-2xx response with `{ "error": "A useful customer-facing message" }` for failure. HTML error pages are also handled as failures. - Only cart reads with HTTP 404 or 410 clear an expired saved cart. Network errors, 401/403/409/5xx, and other failures preserve the ID and block writes until hydration succeeds. -- Resolve prices, taxes, discounts and availability on the server. Browser SKU names and quantities are input, not pricing authority. Protect mutations against CSRF as appropriate for the host application's authentication. +- Resolve prices and availability on the server. The cart does not calculate shipping, taxes, or discounts; it explains that they are calculated at checkout. Browser SKU names and quantities are input, not pricing authority. Protect mutations against CSRF as appropriate for the host application's authentication. ## Endpoints | Method and path | Input | Successful JSON response | | --- | --- | --- | -| `GET /config` | None | `{ cartScope: string, currencyCode: string }` | +| `GET /config` | None | `{ cartScope, currencyCode }` | | `GET /products?first=6&after=` | Optional opaque cursor | `{ skuGroups: Connection }` | | `GET /products/:id` | URI-encoded product ID | `{ skuGroup: SKUGroup \| null }` | | `GET /products/:id?attributeValues=blue&attributeValues=large` | Repeated selected attribute **names**, not IDs | `{ skuGroup: SKUGroup \| null }` with matching SKU connection | @@ -33,7 +33,7 @@ Server implementations can use GoDaddy Commerce APIs or their existing integrati ## Configuration -`cartScope` is a nonempty opaque identifier for the effective store/channel binding. It is not a secret. Rotate it when the binding changes so a saved cart cannot cross stores. `currencyCode` is a three-letter uppercase ISO 4217 code, for example `USD`. Money integers use that currency's smallest unit: USD 1234 is $12.34; JPY 1234 is ¥1,234. +`cartScope` is a nonempty opaque identifier for the effective store/channel/currency binding. It is not a secret. Rotate it when that binding changes so a saved cart cannot cross stores or currencies. `currencyCode` is a three-letter uppercase ISO 4217 code, for example `USD`. Money integers use that currency's smallest unit: USD 1234 is $12.34; JPY 1234 is ¥1,234. The cart shows the draft-order subtotal and the message “Shipping, taxes, and discounts are calculated at checkout.” The message has the stable `commerce-cart-checkout-adjustments-note` class so a host can hide it without changing the component. The browser rechecks configuration on window focus when stale. Return current server configuration rather than a browser-selected store. Persisted IDs use the package-specific key documented in the README; migration from another application's storage keys belongs to that application's integration. @@ -96,6 +96,6 @@ Optional totals include `shippingTotal`, `taxTotal`, and `discountTotal`. Option The client refreshes the cart before checkout and sends its ID, an absolute catalog `returnUrl`, and an absolute `successUrl` with `orderId` appended. The server must verify the cart's binding, contents, merchant readiness, and allowed return origins/paths before creating a session. Return an HTTPS checkout URL. The client rejects missing or non-HTTPS URLs and performs a browser navigation to the returned URL. -When using `@godaddy/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. +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. diff --git a/packages/commerce-storefront/package.json b/packages/commerce-storefront/package.json index 0879e566..10fc8171 100644 --- a/packages/commerce-storefront/package.json +++ b/packages/commerce-storefront/package.json @@ -1,5 +1,5 @@ { - "name": "@godaddy/commerce-storefront", + "name": "@godaddy/gd-commerce-storefront", "version": "0.1.0", "description": "Opinionated React storefront templates for GoDaddy Commerce", "type": "module", diff --git a/packages/commerce-storefront/src/artifacts.test.ts b/packages/commerce-storefront/src/artifacts.test.ts index 59a5296b..a712925b 100644 --- a/packages/commerce-storefront/src/artifacts.test.ts +++ b/packages/commerce-storefront/src/artifacts.test.ts @@ -45,7 +45,7 @@ it('passes shipped CSS through Tailwind v3 without losing package styles', async const result = await postcss([tailwindV3({ content: [{ raw: '
Host application
' }] })]).process( css, { - from: 'node_modules/@godaddy/commerce-storefront/dist/styles.css', + from: 'node_modules/@godaddy/gd-commerce-storefront/dist/styles.css', }, ); expect(result.css).not.toMatch(/@(?:layer|tailwind|apply|theme|source)\b/); diff --git a/packages/commerce-storefront/src/cart.tsx b/packages/commerce-storefront/src/cart.tsx index 592557de..3b6fa554 100644 --- a/packages/commerce-storefront/src/cart.tsx +++ b/packages/commerce-storefront/src/cart.tsx @@ -263,31 +263,16 @@ export function CartDrawer(): ReactElement { {items.length > 0 && (
- {( - [ - ['Subtotal', cart?.totals?.subTotal, summary.subtotal], - ['Shipping', cart?.totals?.shippingTotal, summary.shipping], - ['Tax', cart?.totals?.taxTotal, summary.taxes], - ] as const - ).map( - ([label, amount, value]) => - typeof amount?.value === 'number' && ( -
-
{label}
-
{money(value, amount.currencyCode ?? currency)}
-
- ), + {typeof cart?.totals?.subTotal?.value === 'number' && ( +
+
Subtotal
+
{money(summary.subtotal, cart.totals.subTotal.currencyCode ?? currency)}
+
)} -
-
Total
-
- {typeof cart?.totals?.total?.value === 'number' - ? money(summary.total, currency) - : 'Unavailable'} -
-
-

Shipping and taxes may change at checkout.

+

+ Shipping, taxes, and discounts are calculated at checkout. +

{config.checkoutSuccessPath ? (
- + {productUnavailable ? ( +

This product is no longer available.

+ ) : ( + + )} )} - {!selectedSku && ( - - )} + {!selectedSku && + (productUnavailable ? ( +

This product is no longer available.

+ ) : ( + + ))} diff --git a/packages/commerce-storefront/src/storefront.test.tsx b/packages/commerce-storefront/src/storefront.test.tsx index b14210e0..2f247842 100644 --- a/packages/commerce-storefront/src/storefront.test.tsx +++ b/packages/commerce-storefront/src/storefront.test.tsx @@ -13,7 +13,10 @@ import { type CommerceContextValue, useCommerce } from './commerce-provider'; import { CommerceStorefront } from './commerce-storefront'; import { ProductDetails } from './product-details'; -const configuration = { cartScope: 'store-one', currencyCode: 'USD' }; +const configuration = { + cartScope: 'store-one', + currencyCode: 'USD', +}; const storageKey = 'godaddy:commerce-storefront:cart:store-one'; const response = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status }); const cart = (id = 'cart-1', quantity = 1): CartOrder => ({ @@ -108,6 +111,31 @@ describe('connection boundary', () => { }); describe('shared cart', () => { + it('shows the subtotal and a hideable checkout adjustments note', async () => { + localStorage.setItem(storageKey, 'cart-1'); + const draftCart: CartOrder = { + ...cart(), + totals: { + subTotal: { value: 1200, currencyCode: 'USD' }, + discountTotal: { value: 0, currencyCode: 'USD' }, + shippingTotal: { value: 0, currencyCode: 'USD' }, + taxTotal: { value: 100, currencyCode: 'USD' }, + total: { value: 1300, currencyCode: 'USD' }, + }, + }; + mockApi((path) => (path.endsWith('/config') ? response(configuration) : response({ cart: draftCart }))); + const view = mount(); + await connected(view); + act(() => view.context().setOpen(true)); + expect(screen.getByText('Subtotal')).toBeVisible(); + expect(screen.getByText('$12.00')).toBeVisible(); + expect(screen.getByText('Shipping, taxes, and discounts are calculated at checkout.')).toHaveClass( + 'commerce-cart-checkout-adjustments-note', + ); + expect(screen.queryByText('Total')).not.toBeInTheDocument(); + expect(screen.queryByText('$1.00')).not.toBeInTheDocument(); + }); + it('returns keyboard focus to the add trigger and never submits a host form', async () => { const submit = vi.fn((event) => event.preventDefault()); mockApi((path) => (path.endsWith('/config') ? response(configuration) : response({ cart: cart() }))); @@ -331,6 +359,77 @@ const group: SKUGroup = { skus: { totalCount: 2, edges: [] }, }; describe('catalog and product selection', () => { + it('purchases a one-SKU product without requiring variant configuration', async () => { + const simpleProduct: SKUGroup = { + id: 'mug', + label: 'Studio mug', + description: 'A ceramic mug.', + priceRange: { min: 2400, max: 2400 }, + attributes: { edges: [], totalCount: 0 }, + skus: { + totalCount: 1, + pageInfo: { hasNextPage: false }, + edges: [ + { + node: { + id: 'mug-sku', + prices: { edges: [{ node: { value: { value: 2400, currencyCode: 'USD' } } }] }, + inventoryCounts: { edges: [{ node: { type: 'AVAILABLE', quantity: 8 } }] }, + }, + }, + ], + }, + }; + const api = mockApi((path, init) => { + if (path.endsWith('/config')) return response(configuration); + if (init?.method === 'POST') return response({ cart: cart() }, 201); + return response({ skuGroup: simpleProduct }); + }); + const view = mount( + + } /> + , + '/products/mug', + ); + await connected(view); + expect(await screen.findByTestId('product-price')).toHaveTextContent('$24.00'); + const add = screen.getByRole('button', { name: 'Add to cart' }); + expect(add).toBeEnabled(); + await userEvent.click(add); + await waitFor(() => expect(api.mock.calls.some((call) => call[1]?.method === 'POST')).toBe(true)); + const request = api.mock.calls.find((call) => call[1]?.method === 'POST'); + expect(JSON.parse(String(request?.[1]?.body))).toEqual({ + lineItems: [{ skuId: 'mug-sku', name: 'Studio mug', quantity: 1 }], + }); + }); + + it('shows an inactive product without offering an Add to Cart button', async () => { + const inactiveProduct: SKUGroup = { + id: 'retired-mug', + status: 'INACTIVE', + label: 'Retired mug', + attributes: { edges: [], totalCount: 0 }, + skus: { + totalCount: 1, + pageInfo: { hasNextPage: false }, + edges: [{ node: { id: 'retired-mug-sku' } }], + }, + }; + mockApi((path) => + path.endsWith('/config') ? response(configuration) : response({ skuGroup: inactiveProduct }), + ); + const view = mount( + + } /> + , + '/products/retired-mug', + ); + await connected(view); + expect(await screen.findByRole('heading', { name: 'Retired mug' })).toBeVisible(); + expect(screen.getByText('This product is no longer available.')).toBeVisible(); + expect(screen.queryByRole('button', { name: 'Add to cart' })).not.toBeInTheDocument(); + }); + it('waits for verified attribute names and blocks sold-out variants', async () => { const api = mockApi((path) => { if (path.endsWith('/config')) return response(configuration); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5eb9cf7e..396ae314 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,7 +53,7 @@ importers: examples/commerce-storefront: dependencies: - '@godaddy/commerce-storefront': + '@godaddy/gd-commerce-storefront': specifier: workspace:* version: link:../../packages/commerce-storefront '@tanstack/react-query':