From e6437ae3774fb2e4bacadeef4bba9502c712e36f Mon Sep 17 00:00:00 2001 From: Scott Bolinger Date: Mon, 28 Sep 2026 13:31:45 -0700 Subject: [PATCH 1/5] fix: isolate storefront styles and validate purchase prices --- .changeset/steady-storefront-surfaces.md | 5 + packages/commerce-storefront/README.md | 4 +- .../commerce-storefront/src/artifacts.test.ts | 24 +++- packages/commerce-storefront/src/cart.tsx | 17 ++- .../src/catalog-model.test.ts | 19 ++++ .../commerce-storefront/src/catalog-model.ts | 14 +++ packages/commerce-storefront/src/catalog.tsx | 6 +- .../src/product-details.tsx | 5 +- .../src/storefront.test.tsx | 105 +++++++++++++++++- packages/commerce-storefront/src/styles.css | 11 +- 10 files changed, 197 insertions(+), 13 deletions(-) create mode 100644 .changeset/steady-storefront-surfaces.md diff --git a/.changeset/steady-storefront-surfaces.md b/.changeset/steady-storefront-surfaces.md new file mode 100644 index 00000000..21d59906 --- /dev/null +++ b/.changeset/steady-storefront-surfaces.md @@ -0,0 +1,5 @@ +--- +'@godaddy/gd-commerce-storefront': patch +--- + +Isolate storefront text and surface colors from host styles, support paired dark theme colors, and disable purchases when a selected SKU has no valid price in the store currency. diff --git a/packages/commerce-storefront/README.md b/packages/commerce-storefront/README.md index ab2dad6f..a29fc747 100644 --- a/packages/commerce-storefront/README.md +++ b/packages/commerce-storefront/README.md @@ -82,7 +82,9 @@ A connection failure leaves the surrounding application and its state mounted. C ``` -The stylesheet includes all required utilities and scopes them to the package's surfaces. The build removes CSS layer wrappers in their declared order, so the exported CSS can pass through a host Tailwind v3 PostCSS pipeline without `@tailwind` directives. Import it directly; consumers do not need to copy or rewrite the CSS. It does not add a global reset or require dependency scanning by a host Tailwind build. The `theme` prop reaches the drawer even though it is portalled into `document.body`. Keep text, controls and focus indicators accessible when changing colors. Utility class names and internal markup are not a customization API. +The stylesheet includes all required utilities and scopes them to the package's surfaces. The build removes CSS layer wrappers in their declared order, so the exported CSS can pass through a host Tailwind v3 PostCSS pipeline without `@tailwind` directives. Import it directly; consumers do not need to copy or rewrite the CSS. It does not add a global reset or require dependency scanning by a host Tailwind build. The `theme` prop reaches the drawer even though it is portalled into `document.body`. Surfaces pair `--commerce-surface` with `--commerce-text`; muted text and borders adapt to that pair. Descendants inherit the surface text and body font instead of global heading styles. Keep text, controls and focus indicators accessible when changing colors. Utility class names and internal markup are not a customization API. + +`AddToCartButton` requires a SKU price in the configured currency. Catalog cards pass `priceAvailable` from the group's price range because the catalog list omits nested SKU prices. Missing prices disable purchase; a selected variant never falls back to another variant's price. The first release uses English UI text and `en-US` currency formatting. Catalog title and description are configurable. Full localization and arbitrary component slots are outside this initial API. diff --git a/packages/commerce-storefront/src/artifacts.test.ts b/packages/commerce-storefront/src/artifacts.test.ts index a712925b..73d6cf71 100644 --- a/packages/commerce-storefront/src/artifacts.test.ts +++ b/packages/commerce-storefront/src/artifacts.test.ts @@ -28,7 +28,7 @@ it('ships CSS that scopes every document selector to a commerce surface', async it('ships a client package with framework peers external and no server dependencies', async () => { const js = await readFile(new URL('../dist/index.js', import.meta.url), 'utf8'); - expect(js).not.toMatch(/node:(?:fs|crypto)|GODADDY_OAUTH_CLIENT_SECRET|@godaddy\/commerce-server/); + expect(js).not.toMatch(/node:(?:fs|crypto)|GODADDY_OAUTH_CLIENT_SECRET|@godaddy\/gd-commerce-server/); expect(js).toContain('react/jsx-runtime'); expect(js).toContain('from "react"'); expect(js).toContain('from "@tanstack/react-query"'); @@ -38,6 +38,28 @@ it('ships a client package with framework peers external and no server dependenc expect(pkg.exports['./styles.css']).toBe('./dist/styles.css'); }); +it('pairs surface colors and resets host descendant typography before applying utilities', async () => { + const css = postcss.parse(await readFile(new URL('../dist/styles.css', import.meta.url), 'utf8')); + const declarations: Record = {}; + css.walkRules('.commerce-storefront', (rule) => { + rule.walkDecls((declaration) => { + declarations[declaration.prop] = declaration.value; + }); + }); + expect(declarations.color).toBe('var(--commerce-text, #171717)'); + expect(declarations['background-color']).toBe('var(--commerce-surface, #fff)'); + const resets: Record = {}; + css.walkRules('.commerce-storefront :where(*)', (rule) => { + rule.walkDecls((declaration) => { + resets[declaration.prop] = declaration.value; + }); + }); + expect(resets).toMatchObject({ color: 'inherit', 'font-family': 'inherit' }); + expect(css.toString().indexOf('.commerce-storefront :where(*)')).toBeLessThan( + css.toString().indexOf('.commerce-storefront .text-commerce-on-accent'), + ); +}); + // Vite processes dependency CSS separately through the host's PostCSS plugins. // No @tailwind directives or scanned package classes should be needed in the host. it('passes shipped CSS through Tailwind v3 without losing package styles', async () => { diff --git a/packages/commerce-storefront/src/cart.tsx b/packages/commerce-storefront/src/cart.tsx index 3b6fa554..db251605 100644 --- a/packages/commerce-storefront/src/cart.tsx +++ b/packages/commerce-storefront/src/cart.tsx @@ -4,7 +4,7 @@ import { Link } from 'react-router'; import { money } from './api'; import { type CartSummaryTotals, getCartSummaryTotals } from './cart-model'; import type { SKU } from './catalog-model'; -import { getAvailableInventoryQuantity } from './catalog-model'; +import { getAvailableInventoryQuantity, getSkuPrice } from './catalog-model'; import { useCommerce } from './commerce-provider'; import { StorefrontSurface } from './storefront-surface'; @@ -36,16 +36,21 @@ export function AddToCartButton({ sku, name, quantity = 1, + priceAvailable, }: { sku: SKU; name: string; quantity?: number; + /** Catalog cards have group pricing instead of nested SKU prices. */ + priceAvailable?: boolean; }): ReactElement { - const { addItem, pending, hydrating, error, open, connection } = useCommerce(); + const { addItem, pending, hydrating, error, open, connection, config } = useCommerce(); const [adding, setAdding] = useState(false); const available: number | null = getAvailableInventoryQuantity(sku); + const priced: boolean = priceAvailable ?? getSkuPrice(sku, config.currencyCode) !== null; const disabled: boolean = connection !== 'ready' || + !priced || !sku.id || adding || hydrating || @@ -72,7 +77,13 @@ export function AddToCartButton({ aria-busy={adding} onClick={() => void handleAdd()} > - {available === 0 ? 'Out of stock' : adding ? 'Adding…' : 'Add to cart'} + {!priced + ? 'Price unavailable' + : available === 0 + ? 'Out of stock' + : adding + ? 'Adding…' + : 'Add to cart'} {error && !open && (

diff --git a/packages/commerce-storefront/src/catalog-model.test.ts b/packages/commerce-storefront/src/catalog-model.test.ts index 9857c808..d0145203 100644 --- a/packages/commerce-storefront/src/catalog-model.test.ts +++ b/packages/commerce-storefront/src/catalog-model.test.ts @@ -3,6 +3,7 @@ import { getAvailableInventoryQuantity, getLabeledSkuOptions, getSingleMatchedSku, + getSkuPrice, type SKU, type SKUGroup, } from './catalog-model'; @@ -12,6 +13,24 @@ const group = (skus: SKU[], extra = {}): SKUGroup => ({ skus: { edges: skus.map((node) => ({ node })), totalCount: skus.length, ...extra }, }); describe('safe SKU selection', () => { + it.each([null, undefined, -1, Number.NaN, Number.POSITIVE_INFINITY])( + 'rejects unusable SKU price amounts: %s', + (value: number | null | undefined): void => { + expect( + getSkuPrice( + { prices: { edges: [null, { node: null }, { node: { value: { value, currencyCode: 'USD' } } }] } }, + 'USD', + ), + ).toBeNull(); + }, + ); + it('preserves the matched currency price and its compare-at value', (): void => { + const price = { + value: { value: 1000, currencyCode: 'CAD' }, + compareAtValue: { value: 1500, currencyCode: 'CAD' }, + }; + expect(getSkuPrice({ prices: { edges: [{ node: price }] } }, 'CAD')).toBe(price); + }); it('only permits direct purchase for exactly one complete SKU result', () => { expect(getSingleMatchedSku(group([sku('one')]))?.id).toBe('one'); expect(getSingleMatchedSku(group([sku('one')], { totalCount: 2 }))).toBeNull(); diff --git a/packages/commerce-storefront/src/catalog-model.ts b/packages/commerce-storefront/src/catalog-model.ts index 2696feb1..f3024ea4 100644 --- a/packages/commerce-storefront/src/catalog-model.ts +++ b/packages/commerce-storefront/src/catalog-model.ts @@ -94,6 +94,20 @@ export interface SKU { attributeValues?: Connection | null; } +export function getSkuPrice(sku: SKU, currencyCode: string): SKUPrice | null { + return ( + sku.prices?.edges?.find((edge) => { + const money = edge?.node?.value; + return ( + money?.currencyCode === currencyCode && + typeof money.value === 'number' && + Number.isFinite(money.value) && + money.value >= 0 + ); + })?.node ?? null + ); +} + export interface StringInFilter { in: string[]; } diff --git a/packages/commerce-storefront/src/catalog.tsx b/packages/commerce-storefront/src/catalog.tsx index d72b1dbb..14d6497e 100644 --- a/packages/commerce-storefront/src/catalog.tsx +++ b/packages/commerce-storefront/src/catalog.tsx @@ -88,7 +88,11 @@ function ProductCardContent({ product }: { product: SKUGroup }): ReactElement {

{!hasVariants && selectedSku ? ( - + = 0} + /> ) : ( {hasVariants ? 'Choose options' : 'View product'} diff --git a/packages/commerce-storefront/src/product-details.tsx b/packages/commerce-storefront/src/product-details.tsx index da0dd92f..3e51388f 100644 --- a/packages/commerce-storefront/src/product-details.tsx +++ b/packages/commerce-storefront/src/product-details.tsx @@ -10,6 +10,7 @@ import { getLabeledSkuOptions, getProductAttributes, getSingleMatchedSku, + getSkuPrice, type SKU, type SkuGroupResult, } from './catalog-model'; @@ -80,7 +81,7 @@ function ProductDetailsContent({ productId }: { productId: string }): ReactEleme ? (explicitSku ?? getSingleMatchedSku(attributes.length ? matched.data?.skuGroup : group)) : null; const skuId: string | null = selectedSku?.id ?? null; - const skuPrice = selectedSku?.prices?.edges?.find((edge) => edge?.node?.value)?.node; + const skuPrice = selectedSku ? getSkuPrice(selectedSku, config.currencyCode) : null; const selectedImages: string[] = getImageUrls(selectedSku); const images: string[] = [...new Set(selectedImages.length ? selectedImages : getImageUrls(group))]; const available: number | null = getAvailableInventoryQuantity(selectedSku); @@ -156,7 +157,7 @@ function ProductDetailsContent({ productId }: { productId: string }): ReactEleme > {skuPrice?.value?.value != null ? money(skuPrice.value.value, skuPrice.value.currencyCode ?? config.currencyCode) - : group.priceRange?.min != null + : !selectedSku && group.priceRange?.min != null ? `From ${money(group.priceRange.min, config.currencyCode)}` : 'Price unavailable'} {skuPrice?.compareAtValue?.value != null && diff --git a/packages/commerce-storefront/src/storefront.test.tsx b/packages/commerce-storefront/src/storefront.test.tsx index 2f247842..d7a330b8 100644 --- a/packages/commerce-storefront/src/storefront.test.tsx +++ b/packages/commerce-storefront/src/storefront.test.tsx @@ -7,8 +7,8 @@ import { describe, expect, it, vi } from 'vitest'; import { ApiError, CartIdStorage, money, request } from './api'; import { AddToCartButton, CartButton } from './cart'; import { addToCart, type CartOrder } from './cart-model'; -import { Catalog } from './catalog'; -import type { SKUGroup } from './catalog-model'; +import { Catalog, ProductCard } from './catalog'; +import type { SKU, SKUGroup } from './catalog-model'; import { type CommerceContextValue, useCommerce } from './commerce-provider'; import { CommerceStorefront } from './commerce-storefront'; import { ProductDetails } from './product-details'; @@ -141,7 +141,13 @@ describe('shared cart', () => { mockApi((path) => (path.endsWith('/config') ? response(configuration) : response({ cart: cart() }))); const view = mount(
- + , ); @@ -359,6 +365,99 @@ const group: SKUGroup = { skus: { totalCount: 2, edges: [] }, }; describe('catalog and product selection', () => { + it.each([ + undefined, + null, + { edges: [] }, + { edges: [{ node: { value: { value: 1200, currencyCode: 'CAD' } } }] }, + ])('blocks unpriced SKUs without creating a cart: %j', async (prices: SKU['prices']): Promise => { + const api = mockApi(() => response(configuration)); + const view = mount(); + await connected(view); + const button = screen.getByRole('button', { name: 'Price unavailable' }); + expect(button).toBeDisabled(); + await userEvent.click(button); + expect(api.mock.calls.some((call) => call[1]?.method === 'POST')).toBe(false); + }); + + it.each([null, 0, 1200])( + 'uses group pricing for catalog quick-add: %s', + async (min: number | null): Promise => { + const api = mockApi((path) => + path.endsWith('/config') ? response(configuration) : response({ cart: cart() }), + ); + const view = mount( + , + ); + await connected(view); + const button = screen.getByRole('button', { name: min === null ? 'Price unavailable' : 'Add to cart' }); + if (min === null) expect(button).toBeDisabled(); + else expect(button).toBeEnabled(); + await userEvent.click(button); + expect(api.mock.calls.some((call) => call[1]?.method === 'POST')).toBe(min !== null); + }, + ); + + it('does not present another variant price as the selected SKU price', async (): Promise => { + mockApi((path) => + path.endsWith('/config') + ? response(configuration) + : response({ + skuGroup: { + id: 'product', + label: 'Product', + priceRange: { min: 1200 }, + skus: { totalCount: 1, edges: [{ node: { id: 'sku-1', prices: { edges: [] } } }] }, + }, + }), + ); + const view = mount( + + } /> + , + '/products/product', + ); + await connected(view); + expect(await screen.findByTestId('product-price')).toHaveTextContent('Price unavailable'); + expect(screen.getByRole('button', { name: 'Price unavailable' })).toBeDisabled(); + }); + + it('selects the price in the store currency, including a zero price', async (): Promise => { + const prices: SKU['prices'] = { + edges: [ + { node: { value: { value: 1200, currencyCode: 'CAD' } } }, + { node: { value: { value: 0, currencyCode: 'USD' } } }, + ], + }; + mockApi((path) => + path.endsWith('/config') + ? response(configuration) + : response({ + skuGroup: { + id: 'product', + label: 'Product', + skus: { totalCount: 1, edges: [{ node: { id: 'sku-1', prices } }] }, + }, + }), + ); + const view = mount( + + } /> + , + '/products/product', + ); + await connected(view); + expect(await screen.findByTestId('product-price')).toHaveTextContent('$0.00'); + expect(screen.getByRole('button', { name: 'Add to cart' })).toBeEnabled(); + }); + it('purchases a one-SKU product without requiring variant configuration', async () => { const simpleProduct: SKUGroup = { id: 'mug', diff --git a/packages/commerce-storefront/src/styles.css b/packages/commerce-storefront/src/styles.css index b5202ce5..12fb5ef2 100644 --- a/packages/commerce-storefront/src/styles.css +++ b/packages/commerce-storefront/src/styles.css @@ -9,11 +9,18 @@ --color-commerce-on-accent: var(--commerce-on-accent, #fff); --color-white: var(--commerce-surface, #fff); --color-neutral-900: var(--commerce-text, #171717); + --color-neutral-100: color-mix(in srgb, var(--commerce-text, #171717) 5%, var(--commerce-surface, #fff)); + --color-neutral-200: color-mix(in srgb, var(--commerce-text, #171717) 15%, var(--commerce-surface, #fff)); + --color-neutral-300: color-mix(in srgb, var(--commerce-text, #171717) 25%, var(--commerce-surface, #fff)); + --color-neutral-400: color-mix(in srgb, var(--commerce-text, #171717) 55%, var(--commerce-surface, #fff)); + --color-neutral-600: color-mix(in srgb, var(--commerce-text, #171717) 75%, var(--commerce-surface, #fff)); + --color-neutral-700: color-mix(in srgb, var(--commerce-text, #171717) 85%, var(--commerce-surface, #fff)); --radius-lg: var(--commerce-radius, 0.5rem); } @layer base { - .commerce-storefront { color: var(--commerce-text, #171717); font-family: inherit; line-height: 1.5; } + .commerce-storefront { color: var(--commerce-text, #171717); background-color: var(--commerce-surface, #fff); font-family: inherit; line-height: 1.5; } + .commerce-storefront :where(*) { color: inherit; font-family: inherit; } .commerce-storefront *, .commerce-storefront *::before, .commerce-storefront *::after { box-sizing: border-box; border-width: 0; border-style: solid; } @@ -24,6 +31,6 @@ .commerce-storefront :where(a) { color: inherit; text-decoration: inherit; } .commerce-storefront :where(img) { display: block; max-width: 100%; } .commerce-storefront :where(ul) { list-style: none; } - .commerce-inline { display: inline-block; } + .commerce-inline { display: inline-block; background-color: transparent; } .commerce-sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border-width: 0; } } From a11d4a98b8063bf088f4e5ecb9d5bc24fcf2174f Mon Sep 17 00:00:00 2001 From: Scott Bolinger Date: Mon, 28 Sep 2026 14:11:05 -0700 Subject: [PATCH 2/5] fix: remove storefront purchase price checks --- .changeset/steady-storefront-surfaces.md | 2 +- packages/commerce-storefront/README.md | 2 - packages/commerce-storefront/src/cart.tsx | 17 +-- .../src/catalog-model.test.ts | 19 ---- .../commerce-storefront/src/catalog-model.ts | 14 --- packages/commerce-storefront/src/catalog.tsx | 6 +- .../src/product-details.tsx | 5 +- .../src/storefront.test.tsx | 105 +----------------- 8 files changed, 10 insertions(+), 160 deletions(-) diff --git a/.changeset/steady-storefront-surfaces.md b/.changeset/steady-storefront-surfaces.md index 21d59906..2c76be3b 100644 --- a/.changeset/steady-storefront-surfaces.md +++ b/.changeset/steady-storefront-surfaces.md @@ -2,4 +2,4 @@ '@godaddy/gd-commerce-storefront': patch --- -Isolate storefront text and surface colors from host styles, support paired dark theme colors, and disable purchases when a selected SKU has no valid price in the store currency. +Isolate storefront text and surface colors from host styles and support paired dark theme colors. diff --git a/packages/commerce-storefront/README.md b/packages/commerce-storefront/README.md index a29fc747..912aa544 100644 --- a/packages/commerce-storefront/README.md +++ b/packages/commerce-storefront/README.md @@ -84,8 +84,6 @@ A connection failure leaves the surrounding application and its state mounted. C The stylesheet includes all required utilities and scopes them to the package's surfaces. The build removes CSS layer wrappers in their declared order, so the exported CSS can pass through a host Tailwind v3 PostCSS pipeline without `@tailwind` directives. Import it directly; consumers do not need to copy or rewrite the CSS. It does not add a global reset or require dependency scanning by a host Tailwind build. The `theme` prop reaches the drawer even though it is portalled into `document.body`. Surfaces pair `--commerce-surface` with `--commerce-text`; muted text and borders adapt to that pair. Descendants inherit the surface text and body font instead of global heading styles. Keep text, controls and focus indicators accessible when changing colors. Utility class names and internal markup are not a customization API. -`AddToCartButton` requires a SKU price in the configured currency. Catalog cards pass `priceAvailable` from the group's price range because the catalog list omits nested SKU prices. Missing prices disable purchase; a selected variant never falls back to another variant's price. - The first release uses English UI text and `en-US` currency formatting. Catalog title and description are configurable. Full localization and arbitrary component slots are outside this initial API. ## Compatibility diff --git a/packages/commerce-storefront/src/cart.tsx b/packages/commerce-storefront/src/cart.tsx index db251605..3b6fa554 100644 --- a/packages/commerce-storefront/src/cart.tsx +++ b/packages/commerce-storefront/src/cart.tsx @@ -4,7 +4,7 @@ import { Link } from 'react-router'; import { money } from './api'; import { type CartSummaryTotals, getCartSummaryTotals } from './cart-model'; import type { SKU } from './catalog-model'; -import { getAvailableInventoryQuantity, getSkuPrice } from './catalog-model'; +import { getAvailableInventoryQuantity } from './catalog-model'; import { useCommerce } from './commerce-provider'; import { StorefrontSurface } from './storefront-surface'; @@ -36,21 +36,16 @@ export function AddToCartButton({ sku, name, quantity = 1, - priceAvailable, }: { sku: SKU; name: string; quantity?: number; - /** Catalog cards have group pricing instead of nested SKU prices. */ - priceAvailable?: boolean; }): ReactElement { - const { addItem, pending, hydrating, error, open, connection, config } = useCommerce(); + const { addItem, pending, hydrating, error, open, connection } = useCommerce(); const [adding, setAdding] = useState(false); const available: number | null = getAvailableInventoryQuantity(sku); - const priced: boolean = priceAvailable ?? getSkuPrice(sku, config.currencyCode) !== null; const disabled: boolean = connection !== 'ready' || - !priced || !sku.id || adding || hydrating || @@ -77,13 +72,7 @@ export function AddToCartButton({ aria-busy={adding} onClick={() => void handleAdd()} > - {!priced - ? 'Price unavailable' - : available === 0 - ? 'Out of stock' - : adding - ? 'Adding…' - : 'Add to cart'} + {available === 0 ? 'Out of stock' : adding ? 'Adding…' : 'Add to cart'} {error && !open && (

diff --git a/packages/commerce-storefront/src/catalog-model.test.ts b/packages/commerce-storefront/src/catalog-model.test.ts index d0145203..9857c808 100644 --- a/packages/commerce-storefront/src/catalog-model.test.ts +++ b/packages/commerce-storefront/src/catalog-model.test.ts @@ -3,7 +3,6 @@ import { getAvailableInventoryQuantity, getLabeledSkuOptions, getSingleMatchedSku, - getSkuPrice, type SKU, type SKUGroup, } from './catalog-model'; @@ -13,24 +12,6 @@ const group = (skus: SKU[], extra = {}): SKUGroup => ({ skus: { edges: skus.map((node) => ({ node })), totalCount: skus.length, ...extra }, }); describe('safe SKU selection', () => { - it.each([null, undefined, -1, Number.NaN, Number.POSITIVE_INFINITY])( - 'rejects unusable SKU price amounts: %s', - (value: number | null | undefined): void => { - expect( - getSkuPrice( - { prices: { edges: [null, { node: null }, { node: { value: { value, currencyCode: 'USD' } } }] } }, - 'USD', - ), - ).toBeNull(); - }, - ); - it('preserves the matched currency price and its compare-at value', (): void => { - const price = { - value: { value: 1000, currencyCode: 'CAD' }, - compareAtValue: { value: 1500, currencyCode: 'CAD' }, - }; - expect(getSkuPrice({ prices: { edges: [{ node: price }] } }, 'CAD')).toBe(price); - }); it('only permits direct purchase for exactly one complete SKU result', () => { expect(getSingleMatchedSku(group([sku('one')]))?.id).toBe('one'); expect(getSingleMatchedSku(group([sku('one')], { totalCount: 2 }))).toBeNull(); diff --git a/packages/commerce-storefront/src/catalog-model.ts b/packages/commerce-storefront/src/catalog-model.ts index f3024ea4..2696feb1 100644 --- a/packages/commerce-storefront/src/catalog-model.ts +++ b/packages/commerce-storefront/src/catalog-model.ts @@ -94,20 +94,6 @@ export interface SKU { attributeValues?: Connection | null; } -export function getSkuPrice(sku: SKU, currencyCode: string): SKUPrice | null { - return ( - sku.prices?.edges?.find((edge) => { - const money = edge?.node?.value; - return ( - money?.currencyCode === currencyCode && - typeof money.value === 'number' && - Number.isFinite(money.value) && - money.value >= 0 - ); - })?.node ?? null - ); -} - export interface StringInFilter { in: string[]; } diff --git a/packages/commerce-storefront/src/catalog.tsx b/packages/commerce-storefront/src/catalog.tsx index 14d6497e..d72b1dbb 100644 --- a/packages/commerce-storefront/src/catalog.tsx +++ b/packages/commerce-storefront/src/catalog.tsx @@ -88,11 +88,7 @@ function ProductCardContent({ product }: { product: SKUGroup }): ReactElement {

{!hasVariants && selectedSku ? ( - = 0} - /> + ) : ( {hasVariants ? 'Choose options' : 'View product'} diff --git a/packages/commerce-storefront/src/product-details.tsx b/packages/commerce-storefront/src/product-details.tsx index 3e51388f..da0dd92f 100644 --- a/packages/commerce-storefront/src/product-details.tsx +++ b/packages/commerce-storefront/src/product-details.tsx @@ -10,7 +10,6 @@ import { getLabeledSkuOptions, getProductAttributes, getSingleMatchedSku, - getSkuPrice, type SKU, type SkuGroupResult, } from './catalog-model'; @@ -81,7 +80,7 @@ function ProductDetailsContent({ productId }: { productId: string }): ReactEleme ? (explicitSku ?? getSingleMatchedSku(attributes.length ? matched.data?.skuGroup : group)) : null; const skuId: string | null = selectedSku?.id ?? null; - const skuPrice = selectedSku ? getSkuPrice(selectedSku, config.currencyCode) : null; + const skuPrice = selectedSku?.prices?.edges?.find((edge) => edge?.node?.value)?.node; const selectedImages: string[] = getImageUrls(selectedSku); const images: string[] = [...new Set(selectedImages.length ? selectedImages : getImageUrls(group))]; const available: number | null = getAvailableInventoryQuantity(selectedSku); @@ -157,7 +156,7 @@ function ProductDetailsContent({ productId }: { productId: string }): ReactEleme > {skuPrice?.value?.value != null ? money(skuPrice.value.value, skuPrice.value.currencyCode ?? config.currencyCode) - : !selectedSku && group.priceRange?.min != null + : group.priceRange?.min != null ? `From ${money(group.priceRange.min, config.currencyCode)}` : 'Price unavailable'} {skuPrice?.compareAtValue?.value != null && diff --git a/packages/commerce-storefront/src/storefront.test.tsx b/packages/commerce-storefront/src/storefront.test.tsx index d7a330b8..2f247842 100644 --- a/packages/commerce-storefront/src/storefront.test.tsx +++ b/packages/commerce-storefront/src/storefront.test.tsx @@ -7,8 +7,8 @@ import { describe, expect, it, vi } from 'vitest'; import { ApiError, CartIdStorage, money, request } from './api'; import { AddToCartButton, CartButton } from './cart'; import { addToCart, type CartOrder } from './cart-model'; -import { Catalog, ProductCard } from './catalog'; -import type { SKU, SKUGroup } from './catalog-model'; +import { Catalog } from './catalog'; +import type { SKUGroup } from './catalog-model'; import { type CommerceContextValue, useCommerce } from './commerce-provider'; import { CommerceStorefront } from './commerce-storefront'; import { ProductDetails } from './product-details'; @@ -141,13 +141,7 @@ describe('shared cart', () => { mockApi((path) => (path.endsWith('/config') ? response(configuration) : response({ cart: cart() }))); const view = mount(
- + , ); @@ -365,99 +359,6 @@ const group: SKUGroup = { skus: { totalCount: 2, edges: [] }, }; describe('catalog and product selection', () => { - it.each([ - undefined, - null, - { edges: [] }, - { edges: [{ node: { value: { value: 1200, currencyCode: 'CAD' } } }] }, - ])('blocks unpriced SKUs without creating a cart: %j', async (prices: SKU['prices']): Promise => { - const api = mockApi(() => response(configuration)); - const view = mount(); - await connected(view); - const button = screen.getByRole('button', { name: 'Price unavailable' }); - expect(button).toBeDisabled(); - await userEvent.click(button); - expect(api.mock.calls.some((call) => call[1]?.method === 'POST')).toBe(false); - }); - - it.each([null, 0, 1200])( - 'uses group pricing for catalog quick-add: %s', - async (min: number | null): Promise => { - const api = mockApi((path) => - path.endsWith('/config') ? response(configuration) : response({ cart: cart() }), - ); - const view = mount( - , - ); - await connected(view); - const button = screen.getByRole('button', { name: min === null ? 'Price unavailable' : 'Add to cart' }); - if (min === null) expect(button).toBeDisabled(); - else expect(button).toBeEnabled(); - await userEvent.click(button); - expect(api.mock.calls.some((call) => call[1]?.method === 'POST')).toBe(min !== null); - }, - ); - - it('does not present another variant price as the selected SKU price', async (): Promise => { - mockApi((path) => - path.endsWith('/config') - ? response(configuration) - : response({ - skuGroup: { - id: 'product', - label: 'Product', - priceRange: { min: 1200 }, - skus: { totalCount: 1, edges: [{ node: { id: 'sku-1', prices: { edges: [] } } }] }, - }, - }), - ); - const view = mount( - - } /> - , - '/products/product', - ); - await connected(view); - expect(await screen.findByTestId('product-price')).toHaveTextContent('Price unavailable'); - expect(screen.getByRole('button', { name: 'Price unavailable' })).toBeDisabled(); - }); - - it('selects the price in the store currency, including a zero price', async (): Promise => { - const prices: SKU['prices'] = { - edges: [ - { node: { value: { value: 1200, currencyCode: 'CAD' } } }, - { node: { value: { value: 0, currencyCode: 'USD' } } }, - ], - }; - mockApi((path) => - path.endsWith('/config') - ? response(configuration) - : response({ - skuGroup: { - id: 'product', - label: 'Product', - skus: { totalCount: 1, edges: [{ node: { id: 'sku-1', prices } }] }, - }, - }), - ); - const view = mount( - - } /> - , - '/products/product', - ); - await connected(view); - expect(await screen.findByTestId('product-price')).toHaveTextContent('$0.00'); - expect(screen.getByRole('button', { name: 'Add to cart' })).toBeEnabled(); - }); - it('purchases a one-SKU product without requiring variant configuration', async () => { const simpleProduct: SKUGroup = { id: 'mug', From 65a2925126a609b58ef13f877c76bb263da27b44 Mon Sep 17 00:00:00 2001 From: Scott Bolinger Date: Wed, 30 Sep 2026 08:50:42 -0700 Subject: [PATCH 3/5] fix: remove unsupported product status field --- .changeset/bright-variant-details.md | 5 ++ .../src/configuration-integration.test.ts | 88 +++++++++++++++++++ packages/commerce-server/src/router.test.ts | 3 +- .../server/api/commerce/products/[id]/GET.ts | 1 - 4 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 .changeset/bright-variant-details.md diff --git a/.changeset/bright-variant-details.md b/.changeset/bright-variant-details.md new file mode 100644 index 00000000..2de20178 --- /dev/null +++ b/.changeset/bright-variant-details.md @@ -0,0 +1,5 @@ +--- +'@godaddy/gd-commerce-server': patch +--- + +Remove the unsupported SKUGroup status selection from product details requests so variant products load through the Commerce API. diff --git a/packages/commerce-server/src/configuration-integration.test.ts b/packages/commerce-server/src/configuration-integration.test.ts index 14d2d2d5..af7d7b0e 100644 --- a/packages/commerce-server/src/configuration-integration.test.ts +++ b/packages/commerce-server/src/configuration-integration.test.ts @@ -9,6 +9,94 @@ afterEach((): void => { vi.unstubAllGlobals(); }); +it('serves variant product details without querying SKUGroup.status', async (): Promise => { + const upstream = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit): Promise => { + const { query, variables } = JSON.parse(String(init?.body)) as { + query: string; + variables: { id: string; attributeValues: string[] }; + }; + if (/^\s+status\s*$/m.test(query)) { + return Response.json({ errors: [{ message: 'Cannot query field "status" on type "SKUGroup".' }] }); + } + expect(variables.id).toBe('shirt'); + const skus = [ + { id: 'shirt-red', label: 'Red shirt' }, + { id: 'shirt-blue', label: 'Blue shirt' }, + ]; + return Response.json({ + data: { + skuGroup: { + id: 'shirt', + label: 'Shirt', + attributes: { + edges: [ + { + node: { + name: 'color', + label: 'Color', + values: { + edges: [ + { node: { name: 'red', label: 'Red' } }, + { node: { name: 'blue', label: 'Blue' } }, + ], + }, + }, + }, + ], + }, + skus: { + edges: skus + .filter( + (sku) => + variables.attributeValues.length === 0 || + variables.attributeValues.includes(sku.id.slice(6)), + ) + .map((node) => ({ node })), + }, + }, + }, + }); + }); + vi.stubGlobal('fetch', upstream); + const app = express(); + app.use( + '/api/commerce', + createCommerceRouter({ + configuration: createRuntimeCommerceConfiguration({ + environment: { + GODADDY_OAUTH_CLIENT_ID: 'client-1', + GODADDY_OAUTH_CLIENT_SECRET: 'secret-1', + GODADDY_STORE_ID: 'store-1', + GODADDY_CHANNEL_ID: 'channel-1', + GODADDY_CURRENCY_CODE: 'USD', + }, + }), + }), + ); + const server = app.listen(0, '127.0.0.1'); + await once(server, 'listening'); + try { + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Expected a listening TCP server'); + const url = `http://127.0.0.1:${address.port}/api/commerce/products/shirt`; + const product = await clientFetch(url); + expect(product.status).toBe(200); + expect( + (await product.json()).skuGroup.skus.edges.map(({ node }: { node: { id: string } }) => node.id), + ).toEqual(['shirt-red', 'shirt-blue']); + const selected = await clientFetch(`${url}?attributeValues=blue`); + expect(selected.status).toBe(200); + expect((await selected.json()).skuGroup.skus.edges).toEqual([ + { node: { id: 'shirt-blue', label: 'Blue shirt' } }, + ]); + expect(upstream).toHaveBeenCalledTimes(2); + } finally { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + } +}); + it.each([undefined, 'https://api.example.com', 'https://api.example.com:8443'])( 'uses host configuration throughout the router with API override %s', async (apiBaseUrl): Promise => { diff --git a/packages/commerce-server/src/router.test.ts b/packages/commerce-server/src/router.test.ts index c02f4bab..d93d1171 100644 --- a/packages/commerce-server/src/router.test.ts +++ b/packages/commerce-server/src/router.test.ts @@ -105,7 +105,8 @@ 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).toMatch(/skuGroup\(id: \$id\) \{\s+id\s+name/); + expect(query).not.toMatch(/^\s+status\s*$/m); expect(query).toContain('attributes(first: 50, orderBy: { position: ASC })'); expect(query).toContain('values(first: 50, orderBy: { position: ASC })'); expect(query).toContain('status: { eq: "ACTIVE" }'); 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 4b925ba3..5e96325a 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,7 +29,6 @@ const skuGroupQuery = ` query SkuGroup($id: String!, $first: Int, $attributeValues: [String!] = []) { skuGroup(id: $id) { id - status name label description From 7732af8104f5b09bb5fbc7a43bbefe187af2ba01 Mon Sep 17 00:00:00 2001 From: Scott Bolinger Date: Thu, 1 Oct 2026 13:59:35 -0700 Subject: [PATCH 4/5] fix: align product details with active catalog filter --- .changeset/bright-variant-details.md | 3 +- .../src/configuration-integration.test.ts | 13 ++++++-- .../src/lib/commerce/catalog-subgraph.ts | 1 - packages/commerce-server/src/router.test.ts | 17 +++++++++-- .../server/api/commerce/products/[id]/GET.ts | 18 +++++++++-- .../commerce-storefront/docs/server-api.md | 2 ++ .../commerce-storefront/src/catalog-model.ts | 1 - .../src/product-details.tsx | 30 +++++++------------ .../src/storefront.test.tsx | 18 ++--------- 9 files changed, 57 insertions(+), 46 deletions(-) diff --git a/.changeset/bright-variant-details.md b/.changeset/bright-variant-details.md index 2de20178..773b91b0 100644 --- a/.changeset/bright-variant-details.md +++ b/.changeset/bright-variant-details.md @@ -1,5 +1,6 @@ --- '@godaddy/gd-commerce-server': patch +'@godaddy/gd-commerce-storefront': patch --- -Remove the unsupported SKUGroup status selection from product details requests so variant products load through the Commerce API. +Remove the unsupported SKUGroup status selection from product details requests so variant products load through the Commerce API. Return 404 for products excluded by the catalog's ACTIVE filter, and remove the unreachable client status branch. diff --git a/packages/commerce-server/src/configuration-integration.test.ts b/packages/commerce-server/src/configuration-integration.test.ts index af7d7b0e..c6a0a421 100644 --- a/packages/commerce-server/src/configuration-integration.test.ts +++ b/packages/commerce-server/src/configuration-integration.test.ts @@ -18,15 +18,19 @@ it('serves variant product details without querying SKUGroup.status', async (): if (/^\s+status\s*$/m.test(query)) { return Response.json({ errors: [{ message: 'Cannot query field "status" on type "SKUGroup".' }] }); } - expect(variables.id).toBe('shirt'); + expect(query).toContain( + 'activeSkuGroups: skuGroups(id: { in: [$id] }, status: { eq: "ACTIVE" }, first: 1)', + ); + expect(['shirt', 'archived-shirt']).toContain(variables.id); const skus = [ { id: 'shirt-red', label: 'Red shirt' }, { id: 'shirt-blue', label: 'Blue shirt' }, ]; return Response.json({ data: { + activeSkuGroups: { edges: variables.id === 'shirt' ? [{ node: { id: 'shirt' } }] : [] }, skuGroup: { - id: 'shirt', + id: variables.id, label: 'Shirt', attributes: { edges: [ @@ -89,7 +93,10 @@ it('serves variant product details without querying SKUGroup.status', async (): expect((await selected.json()).skuGroup.skus.edges).toEqual([ { node: { id: 'shirt-blue', label: 'Blue shirt' } }, ]); - expect(upstream).toHaveBeenCalledTimes(2); + const archived = await clientFetch(`${url.replace('/shirt', '/archived-shirt')}?attributeValues=blue`); + expect(archived.status).toBe(404); + expect(await archived.json()).toEqual({ error: 'Product not found' }); + expect(upstream).toHaveBeenCalledTimes(3); } finally { await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())), diff --git a/packages/commerce-server/src/lib/commerce/catalog-subgraph.ts b/packages/commerce-server/src/lib/commerce/catalog-subgraph.ts index 93b03259..adeeb135 100644 --- a/packages/commerce-server/src/lib/commerce/catalog-subgraph.ts +++ b/packages/commerce-server/src/lib/commerce/catalog-subgraph.ts @@ -79,7 +79,6 @@ 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/router.test.ts b/packages/commerce-server/src/router.test.ts index d93d1171..bcd07e95 100644 --- a/packages/commerce-server/src/router.test.ts +++ b/packages/commerce-server/src/router.test.ts @@ -90,7 +90,10 @@ describe('Commerce scoped routes', () => { it('includes selected SKU data and preserves attribute-value name filters', async (): Promise => { const res: ReturnType = response(); - vi.mocked(gqlRequest).mockResolvedValueOnce({ skuGroup: { id: 'product' } }); + vi.mocked(gqlRequest).mockResolvedValueOnce({ + activeSkuGroups: { edges: [{ node: { id: 'product' } }] }, + skuGroup: { id: 'product' }, + }); await readProduct( { params: { id: 'product' }, @@ -105,6 +108,9 @@ describe('Commerce scoped routes', () => { expect(query).toContain('prices(first: 10)'); expect(query).toContain('inventoryCounts'); expect(query).toContain('pageInfo { hasNextPage }'); + expect(query).toContain( + 'activeSkuGroups: skuGroups(id: { in: [$id] }, status: { eq: "ACTIVE" }, first: 1)', + ); expect(query).toMatch(/skuGroup\(id: \$id\) \{\s+id\s+name/); expect(query).not.toMatch(/^\s+status\s*$/m); expect(query).toContain('attributes(first: 50, orderBy: { position: ASC })'); @@ -212,7 +218,12 @@ describe('Commerce scoped routes', () => { async (handler): Promise => { for (const scope of [undefined, getCommerceCartScope(binding)]) { const res = response(); - vi.mocked(gqlRequest).mockResolvedValueOnce({}); + const product = { skuGroup: { id: 'product-1' } }; + vi.mocked(gqlRequest).mockResolvedValueOnce( + handler === readProduct + ? { ...product, activeSkuGroups: { edges: [{ node: { id: 'product-1' } }] } } + : {}, + ); await handler( { headers: { 'x-commerce-scope': scope }, @@ -221,7 +232,7 @@ describe('Commerce scoped routes', () => { } as unknown as Request, res as unknown as Response, ); - expect(res.json).toHaveBeenCalledWith({}); + expect(res.json).toHaveBeenCalledWith(handler === readProduct ? product : {}); expect(res.status).not.toHaveBeenCalled(); } expect(gqlRequest).toHaveBeenCalledTimes(2); 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 5e96325a..3b9f8ce4 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 @@ -2,6 +2,7 @@ * GET /api/commerce/products/:id * * Proxy for the catalog `skuGroup` query — single-product (PDP) detail. + * Only ACTIVE groups appear on a product page, matching the catalog list. * Re-call this route after the user picks attribute values to narrow the * SKU set (pass `?attributeValues=red&attributeValues=large`). Use the * `getSingleMatchedSku` helper returns the selected SKU, including price, @@ -12,7 +13,7 @@ * (from getProductAttributes), never its `id`. * skuGroupFirst - max SKUs to return before any attribute is picked (default 50) * - * Response: { skuGroup: SKUGroup | null } + * Response: { skuGroup: SKUGroup } for an active product, otherwise 404. */ import type { Request, Response } from 'express'; import { validateCommerceCartScope } from '@/lib/commerce/cart-scope'; @@ -20,13 +21,19 @@ import { buildSkuGroupVariables, catalogStorefrontEndpoint, type SkuGroupResult, + type SkuGroupsResult, type SkuGroupVariables, } from '@/lib/commerce/catalog-subgraph'; import { type CommerceConfig, readCommerceConfigForResponse } from '@/lib/commerce/config'; import { gqlRequest, storefrontHeaders } from '@/lib/commerce/gql'; +type ProductDetailsResult = SkuGroupResult & { activeSkuGroups?: SkuGroupsResult['skuGroups'] }; + const skuGroupQuery = ` query SkuGroup($id: String!, $first: Int, $attributeValues: [String!] = []) { + activeSkuGroups: skuGroups(id: { in: [$id] }, status: { eq: "ACTIVE" }, first: 1) { + edges { node { id } } + } skuGroup(id: $id) { id name @@ -136,14 +143,19 @@ export default async function handler(req: Request, res: Response): Promise({ + const data = await gqlRequest({ endpoint: catalogStorefrontEndpoint({ storeId, apiBaseUrl }), query: skuGroupQuery, variables, headers: storefrontHeaders({ storeId, clientId }), }); - res.json(data); + if (!data.skuGroup || !data.activeSkuGroups?.edges?.some((edge) => edge?.node?.id === productId)) { + res.status(404).json({ error: 'Product not found' }); + return; + } + + res.json({ skuGroup: data.skuGroup }); } catch (error) { res.status(500).json({ error: 'Failed to load product', diff --git a/packages/commerce-storefront/docs/server-api.md b/packages/commerce-storefront/docs/server-api.md index 0ea2ac48..9c07cb96 100644 --- a/packages/commerce-storefront/docs/server-api.md +++ b/packages/commerce-storefront/docs/server-api.md @@ -31,6 +31,8 @@ Server implementations can use GoDaddy Commerce APIs or their existing integrati `/checkout` is required only when checkout is enabled. `/discounts` is needed if the host uses `applyDiscount`. The standard catalog/detail/cart flow uses the other routes. All product/cart IDs in request paths are encoded. +The companion `@godaddy/gd-commerce-server` returns 404 from product details when the group is missing or excluded by the catalog's ACTIVE filter, including direct links to inactive products. + ## Configuration `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. diff --git a/packages/commerce-storefront/src/catalog-model.ts b/packages/commerce-storefront/src/catalog-model.ts index 2696feb1..45b4fc39 100644 --- a/packages/commerce-storefront/src/catalog-model.ts +++ b/packages/commerce-storefront/src/catalog-model.ts @@ -54,7 +54,6 @@ 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-storefront/src/product-details.tsx b/packages/commerce-storefront/src/product-details.tsx index da0dd92f..1692cd47 100644 --- a/packages/commerce-storefront/src/product-details.tsx +++ b/packages/commerce-storefront/src/product-details.tsx @@ -46,7 +46,6 @@ function ProductDetailsContent({ productId }: { productId: string }): ReactEleme }), }); const group = product.data?.skuGroup; - const productUnavailable: boolean = group?.status != null && group.status !== 'ACTIVE'; const attributes = getProductAttributes(group); const skuOptions: SKU[] = getLabeledSkuOptions(group); const explicitSku: SKU | undefined = skuOptions.find((sku: SKU): boolean => sku.id === params.get('sku')); @@ -273,26 +272,19 @@ function ProductDetailsContent({ productId }: { productId: string }): ReactEleme onChange={(event) => setQuantity(event.target.valueAsNumber)} />
- {productUnavailable ? ( -

This product is no longer available.

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

This product is no longer available.

- ) : ( - - ))} + {!selectedSku && ( + + )}
diff --git a/packages/commerce-storefront/src/storefront.test.tsx b/packages/commerce-storefront/src/storefront.test.tsx index 2f247842..fd43cfb1 100644 --- a/packages/commerce-storefront/src/storefront.test.tsx +++ b/packages/commerce-storefront/src/storefront.test.tsx @@ -403,20 +403,9 @@ describe('catalog and product selection', () => { }); }); - 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' } }], - }, - }; + it('shows an unavailable message for a product excluded by the server', async () => { mockApi((path) => - path.endsWith('/config') ? response(configuration) : response({ skuGroup: inactiveProduct }), + path.endsWith('/config') ? response(configuration) : response({ error: 'Product not found' }, 404), ); const view = mount( @@ -425,8 +414,7 @@ describe('catalog and product selection', () => { '/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(await screen.findByRole('heading', { name: 'Product unavailable' })).toBeVisible(); expect(screen.queryByRole('button', { name: 'Add to cart' })).not.toBeInTheDocument(); }); From 786d7441f7a34cf5cc405f3595804f299611edf5 Mon Sep 17 00:00:00 2001 From: Scott Bolinger Date: Thu, 1 Oct 2026 14:10:12 -0700 Subject: [PATCH 5/5] fix: show not-found page for missing products --- .changeset/bright-variant-details.md | 2 +- packages/commerce-storefront/src/product-details.tsx | 8 +++++--- packages/commerce-storefront/src/storefront.test.tsx | 6 ++++-- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/.changeset/bright-variant-details.md b/.changeset/bright-variant-details.md index 773b91b0..db02b197 100644 --- a/.changeset/bright-variant-details.md +++ b/.changeset/bright-variant-details.md @@ -3,4 +3,4 @@ '@godaddy/gd-commerce-storefront': patch --- -Remove the unsupported SKUGroup status selection from product details requests so variant products load through the Commerce API. Return 404 for products excluded by the catalog's ACTIVE filter, and remove the unreachable client status branch. +Remove the unsupported SKUGroup status selection from product details requests so variant products load through the Commerce API. Return 404 for products excluded by the catalog's ACTIVE filter, and show the storefront's not-found page for those responses. diff --git a/packages/commerce-storefront/src/product-details.tsx b/packages/commerce-storefront/src/product-details.tsx index 1692cd47..bd52809f 100644 --- a/packages/commerce-storefront/src/product-details.tsx +++ b/packages/commerce-storefront/src/product-details.tsx @@ -1,7 +1,7 @@ import { useQuery } from '@tanstack/react-query'; import { type ReactElement, useId, useState } from 'react'; import { Link, useParams, useSearchParams } from 'react-router'; -import { message, money, request } from './api'; +import { ApiError, message, money, request } from './api'; import { AddToCartButton, buttonClass, inputClass } from './cart'; import { ProductImage } from './catalog'; import { @@ -45,6 +45,8 @@ function ProductDetailsContent({ productId }: { productId: string }): ReactEleme headers: { 'X-Commerce-Scope': config.cartScope }, }), }); + const productNotFound: boolean = + product.isError && product.error instanceof ApiError && product.error.status === 404; const group = product.data?.skuGroup; const attributes = getProductAttributes(group); const skuOptions: SKU[] = getLabeledSkuOptions(group); @@ -93,7 +95,7 @@ function ProductDetailsContent({ productId }: { productId: string }): ReactEleme setImageIndex(0); }; if (product.isPending) return

Loading product…

; - if (product.isError) + if (product.isError && !productNotFound) return (

Product unavailable

@@ -103,7 +105,7 @@ function ProductDetailsContent({ productId }: { productId: string }): ReactEleme
); - if (!group) + if (productNotFound || !group) return (

Product not found

diff --git a/packages/commerce-storefront/src/storefront.test.tsx b/packages/commerce-storefront/src/storefront.test.tsx index fd43cfb1..ea3bc707 100644 --- a/packages/commerce-storefront/src/storefront.test.tsx +++ b/packages/commerce-storefront/src/storefront.test.tsx @@ -403,7 +403,7 @@ describe('catalog and product selection', () => { }); }); - it('shows an unavailable message for a product excluded by the server', async () => { + it('shows the not-found page without a retry for a product excluded by the server', async () => { mockApi((path) => path.endsWith('/config') ? response(configuration) : response({ error: 'Product not found' }, 404), ); @@ -414,7 +414,9 @@ describe('catalog and product selection', () => { '/products/retired-mug', ); await connected(view); - expect(await screen.findByRole('heading', { name: 'Product unavailable' })).toBeVisible(); + expect(await screen.findByRole('heading', { name: 'Product not found' })).toBeVisible(); + expect(screen.getByRole('link', { name: 'Back to shop' })).toHaveAttribute('href', '/shop'); + expect(screen.queryByRole('button', { name: 'Retry product' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Add to cart' })).not.toBeInTheDocument(); });