Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/bright-variant-details.md
Original file line number Diff line number Diff line change
@@ -0,0 +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. Return 404 for products excluded by the catalog's ACTIVE filter, and show the storefront's not-found page for those responses.
5 changes: 5 additions & 0 deletions .changeset/steady-storefront-surfaces.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@godaddy/gd-commerce-storefront': patch
---

Isolate storefront text and surface colors from host styles and support paired dark theme colors.
95 changes: 95 additions & 0 deletions packages/commerce-server/src/configuration-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,101 @@ afterEach((): void => {
vi.unstubAllGlobals();
});

it('serves variant product details without querying SKUGroup.status', async (): Promise<void> => {
const upstream = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
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(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: variables.id,
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' } },
]);
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<void>((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<void> => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
20 changes: 16 additions & 4 deletions packages/commerce-server/src/router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,10 @@ describe('Commerce scoped routes', () => {

it('includes selected SKU data and preserves attribute-value name filters', async (): Promise<void> => {
const res: ReturnType<typeof response> = 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' },
Expand All @@ -105,7 +108,11 @@ 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(
'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 })');
expect(query).toContain('values(first: 50, orderBy: { position: ASC })');
expect(query).toContain('status: { eq: "ACTIVE" }');
Expand Down Expand Up @@ -211,7 +218,12 @@ describe('Commerce scoped routes', () => {
async (handler): Promise<void> => {
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 },
Expand All @@ -220,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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -12,24 +13,29 @@
* (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';
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
status
name
label
description
Expand Down Expand Up @@ -137,14 +143,19 @@ export default async function handler(req: Request, res: Response): Promise<void
skuGroupFirst: asNumber(req.query.skuGroupFirst),
});

const data = await gqlRequest<SkuGroupResult, SkuGroupVariables>({
const data = await gqlRequest<ProductDetailsResult, SkuGroupVariables>({
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',
Expand Down
2 changes: 1 addition & 1 deletion packages/commerce-storefront/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ A connection failure leaves the surrounding application and its state mounted. C
</CommerceStorefront>
```

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.

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.

Expand Down
2 changes: 2 additions & 0 deletions packages/commerce-storefront/docs/server-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
24 changes: 23 additions & 1 deletion packages/commerce-storefront/src/artifacts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"');
Expand All @@ -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<string, string> = {};
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<string, string> = {};
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 () => {
Expand Down
1 change: 0 additions & 1 deletion packages/commerce-storefront/src/catalog-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
38 changes: 16 additions & 22 deletions packages/commerce-storefront/src/product-details.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -45,8 +45,9 @@ 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 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'));
Expand Down Expand Up @@ -94,7 +95,7 @@ function ProductDetailsContent({ productId }: { productId: string }): ReactEleme
setImageIndex(0);
};
if (product.isPending) return <p role='status'>Loading product…</p>;
if (product.isError)
if (product.isError && !productNotFound)
return (
<div role='alert'>
<h1 className='text-2xl font-semibold'>Product unavailable</h1>
Expand All @@ -104,7 +105,7 @@ function ProductDetailsContent({ productId }: { productId: string }): ReactEleme
</button>
</div>
);
if (!group)
if (productNotFound || !group)
return (
<div>
<h1 className='text-2xl font-semibold'>Product not found</h1>
Expand Down Expand Up @@ -273,26 +274,19 @@ function ProductDetailsContent({ productId }: { productId: string }): ReactEleme
onChange={(event) => setQuantity(event.target.valueAsNumber)}
/>
</div>
{productUnavailable ? (
<p className='text-sm'>This product is no longer available.</p>
) : (
<AddToCartButton sku={selectedSku} name={name} quantity={quantity} />
)}
<AddToCartButton sku={selectedSku} name={name} quantity={quantity} />
</>
)}
{!selectedSku &&
(productUnavailable ? (
<p className='text-sm'>This product is no longer available.</p>
) : (
<button
type='button'
className={`${buttonClass} w-full`}
disabled
aria-describedby={!complete ? `${fieldId}-variant-guidance` : undefined}
>
Add to cart
</button>
))}
{!selectedSku && (
<button
type='button'
className={`${buttonClass} w-full`}
disabled
aria-describedby={!complete ? `${fieldId}-variant-guidance` : undefined}
>
Add to cart
</button>
)}
</div>
</div>
</div>
Expand Down
Loading
Loading