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
5 changes: 5 additions & 0 deletions .changeset/hide-unoffered-attribute-values.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@godaddy/gd-commerce-storefront": minor
---

The product page leaves out attribute values that no active SKU has. Before, a product with five sizes but SKUs in only two showed all five, and choosing one of the other three showed "This combination is unavailable. Choose different options." A link with such a value, such as `?option.size=s`, now counts as that option not chosen. This needs every SKU with its attribute values, which `@godaddy/gd-commerce-server` 0.4.0 and later return for up to 50 SKUs. For a product with more SKUs, or with an older server, every value is still shown. An attribute that no SKU has a value for keeps all its values.
2 changes: 1 addition & 1 deletion packages/commerce-storefront/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ Add your own elements between parts, reorder them, or leave out display parts. `
| `ProductDetailsAvailability` | Stock for the verified SKU; nothing before verification |
| `ProductDetailsAddToCart` | Adds the verified SKU and quantity, labeled with the total, such as "Add to cart · $64.00". Until every option is chosen it names the next one, such as "Select size", and pressing it shows an error on each missing option and focuses the first. While a selection is checked or unavailable, it is disabled and its accessible description gives the reason |

Values whose SKUs are all sold out, given the shopper's other selections, are crossed out and announced as "sold out", but stay selectable, so the shopper can change options in any order. This needs every SKU with its attribute values in the product response, which `@godaddy/gd-commerce-server` returns for up to 50 SKUs; for a product with more, no value is marked, and the add button still says "Out of stock" once a sold-out SKU is chosen.
Values that no active SKU has are left out, so every value shown can be bought, and a link that names one is treated as if that option is not chosen. An attribute that no SKU has a value for keeps all its values. Values whose SKUs are all sold out, given the shopper's other selections, are crossed out and announced as "sold out", but stay selectable, so the shopper can change options in any order. Both need every SKU with its attribute values in the product response, which `@godaddy/gd-commerce-server` returns for up to 50 SKUs; for a product with more, every value is shown, none is marked, and the add button still says "Out of stock" once a sold-out SKU is chosen.

Every part is named `ProductDetails*` because it works only inside `ProductDetailsRoot`. `AddToCartButton` is the standalone button for a SKU you already have.

Expand Down
2 changes: 1 addition & 1 deletion packages/commerce-storefront/docs/server-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ Exported `SKUGroup`, `SKU`, `Connection`, `SkuGroupsResult` and `SkuGroupResult`

For options, return `attributes.edges[].node` with `name`, `label`, and `values.edges[].node` containing `name` and `label`. For example, the attribute `color` has values named `blue` and `clay`. The browser sends those value names as repeated `attributeValues` parameters. Return the group with a SKU connection filtered to that exact combination. Zero or multiple results cannot be purchased. Include accurate `totalCount` and `hasNextPage`; a truncated one-item result must never look like a complete match.

Optionally, give each SKU in the unfiltered response its `attributeValues.edges[].node.name`. With every SKU and its value names, the product page crosses out values whose SKUs are all sold out, given the shopper's other selections. Without them, or when `hasNextPage` is true, no value is marked sold out; the add button still reports a sold-out SKU once it is chosen.
Optionally, give each SKU in the unfiltered response its `attributeValues.edges[].node.name`. With every SKU and its value names, the product page leaves out values that no SKU has and crosses out values whose SKUs are all sold out, given the shopper's other selections. Without them, or when `hasNextPage` is true, every value is shown and none is marked sold out; the add button still reports a sold-out SKU once it is chosen.

Products without attribute definitions can offer explicit SKU selection only when all SKUs are returned with unique IDs and unique nonempty labels/names. Ambiguous or incomplete collections cannot be purchased. No inventory records means untracked inventory; records without an `AVAILABLE` quantity mean unavailable. Supply appropriate records for physical inventory.

Expand Down
52 changes: 51 additions & 1 deletion packages/commerce-storefront/src/default-design.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,21 @@ const shoe: SKUGroup = {
},
skus: { totalCount: 9, edges: [] },
};
// The scarf offers five sizes, but only XS and L have an active SKU.
const scarfSkus: SKU[] = [sku('scarf-xs', ['xs'], 3), sku('scarf-l', ['l'], 3)];
const scarf: SKUGroup = {
id: 'scarf',
label: 'Scarf',
priceRange: { min: 2500, max: 2500 },
attributes: {
edges: [{ node: { name: 'size', label: 'Size', values: values(['xs', 's', 'm', 'l', 'xl']) } }],
},
skus: {
totalCount: scarfSkus.length,
pageInfo: { hasNextPage: false },
edges: scarfSkus.map((node) => ({ node })),
},
};
const card = (id: string, label: string, extra: Partial<SKUGroup> = {}): SKUGroup => ({
id,
label,
Expand Down Expand Up @@ -101,7 +116,7 @@ function mockApi() {
});
}
const id = url.pathname.split('/').at(-1);
const group = [shirt, shoe].find((product) => product.id === id);
const group = [shirt, shoe, scarf].find((product) => product.id === id);
if (!group) return response({ error: 'Product not found' }, 404);
const selected = url.searchParams.getAll('attributeValues');
if (!selected.length) return response({ skuGroup: group });
Expand Down Expand Up @@ -174,6 +189,41 @@ describe('default product page', () => {
expect(await screen.findByRole('radio', { name: 'Green' })).toBeInTheDocument();
});

it('offers only the values some active SKU has, so every choice can be bought', async () => {
mockApi();
mount(<ProductDetails />, '/products/scarf');
expect(await screen.findByRole('radio', { name: 'Xs' })).toBeEnabled();
expect(screen.getAllByRole('radio').map((radio) => radio.getAttribute('value'))).toEqual(['xs', 'l']);
await userEvent.click(screen.getByRole('radio', { name: 'L' }));
expect(await screen.findByRole('button', { name: 'Add to cart, $25.00' })).toBeEnabled();
expect(
screen.queryByText('This combination is unavailable. Choose different options.'),
).not.toBeInTheDocument();
});

it('treats a shared link to a value with no active SKU as not yet chosen', async () => {
mockApi();
mount(<ProductDetails />, '/products/scarf?option.size=s');
expect(await screen.findByRole('button', { name: 'Select size' })).toBeInTheDocument();
expect(screen.getAllByRole('radio').every((radio) => !(radio as HTMLInputElement).checked)).toBe(true);
expect(
screen.queryByText('This combination is unavailable. Choose different options.'),
).not.toBeInTheDocument();
});

it('keeps every value when the SKU list is incomplete', async () => {
mockApi();
const truncated = { ...scarf, skus: { ...scarf.skus, pageInfo: { hasNextPage: true } } };
vi.mocked(fetch).mockImplementation(async (input) =>
String(input).endsWith('/config')
? response({ cartScope: 's', currencyCode: 'USD' })
: response({ skuGroup: truncated }),
);
mount(<ProductDetails />, '/products/scarf');
expect(await screen.findByRole('radio', { name: 'M' })).toBeInTheDocument();
expect(screen.getAllByRole('radio')).toHaveLength(5);
});

it('strikes through a starting compare-at price only when it belongs to the cheapest SKUs', async () => {
mockApi();
mount(<ProductDetails />, '/products/shirt');
Expand Down
62 changes: 47 additions & 15 deletions packages/commerce-storefront/src/product-details-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -247,16 +247,15 @@ function ProductDetailsLoader({
const productNotFound: boolean =
product.isError && product.error instanceof ApiError && product.error.status === 404;
const group = product.data?.skuGroup;
const attributes = getProductAttributes(group);
const attributes: StorefrontProductAttribute[] = getOfferedAttributes(group, getProductAttributes(group));
const skuOptions: SKU[] = getLabeledSkuOptions(group);
const explicitSku: SKU | undefined = skuOptions.find((sku: SKU): boolean => sku.id === params.get('sku'));
const selections: string[] = attributes.map((attribute) => params.get(`option.${attribute.name}`) ?? '');
const complete: boolean =
skuOptions.length > 0
? !!explicitSku
: attributes.every((attribute, index) =>
attribute.values.some((value) => value.name === selections[index]),
);
// A value the product does not offer, such as one from an old link, counts as not chosen.
const selections: string[] = attributes.map((attribute) => {
const value: string = params.get(`option.${attribute.name}`) ?? '';
return attribute.values.some((candidate) => candidate.name === value) ? value : '';
});
const complete: boolean = skuOptions.length > 0 ? !!explicitSku : selections.every(Boolean);
const selectionParams: URLSearchParams = new URLSearchParams();
selections.filter(Boolean).forEach((value) => {
selectionParams.append('attributeValues', value);
Expand Down Expand Up @@ -419,6 +418,45 @@ function focusFirstMissing(root: RefObject<HTMLElement | null>): void {
?.focus();
}

interface SkuAttributeValues {
skus: SKU[];
/** Each SKU's attribute value names, in `skus` order. */
values: (string[] | null)[];
/** Every active SKU is loaded with its attribute values. */
complete: boolean;
}

function getSkuAttributeValues(group: SKUGroup | null | undefined): SkuAttributeValues {
const connection = group?.skus;
const skus: SKU[] = connection?.edges?.flatMap((edge) => (edge?.node ? [edge.node] : [])) ?? [];
const values: (string[] | null)[] = skus.map(getSkuAttributeValueNames);
const complete: boolean =
!connection?.pageInfo?.hasNextPage &&
(connection?.totalCount ?? skus.length) === skus.length &&
values.every((names) => names !== null);
return { skus, values, complete };
}

/**
* The attributes with only the values some active SKU has, so a shopper is never offered a value that cannot be
* bought. Needs every SKU with its attribute values, so it keeps every value for a truncated list or an older
* server. An attribute that no SKU has a value for keeps every value, so its control still renders.
*/
function getOfferedAttributes(
group: SKUGroup | null | undefined,
attributes: StorefrontProductAttribute[],
): StorefrontProductAttribute[] {
const { values, complete } = getSkuAttributeValues(group);
if (!complete) return attributes;
const offered: Set<string> = new Set(values.flatMap((names) => names ?? []));
return attributes.map((attribute) => {
const kept: StorefrontProductAttribute['values'] = attribute.values.filter((value) =>
offered.has(value.name),
);
return kept.length ? { ...attribute, values: kept } : attribute;
});
}

/**
* For each attribute, the values whose every SKU, among those with the other selected values, has tracked stock
* used up. Needs every SKU with its attribute values, so it is empty for a truncated list or an older server.
Expand All @@ -428,13 +466,7 @@ function getSoldOutValues(
attributes: StorefrontProductAttribute[],
selections: string[],
): ReadonlySet<string>[] {
const connection = group?.skus;
const skus: SKU[] = connection?.edges?.flatMap((edge) => (edge?.node ? [edge.node] : [])) ?? [];
const values: (string[] | null)[] = skus.map(getSkuAttributeValueNames);
const complete: boolean =
!connection?.pageInfo?.hasNextPage &&
(connection?.totalCount ?? skus.length) === skus.length &&
values.every((names) => names !== null);
const { skus, values, complete } = getSkuAttributeValues(group);
return attributes.map((attribute, index) => {
const soldOut: Set<string> = new Set();
if (!complete) return soldOut;
Expand Down
Loading