diff --git a/.changeset/twenty-flies-exist.md b/.changeset/twenty-flies-exist.md new file mode 100644 index 0000000000..21f120c77a --- /dev/null +++ b/.changeset/twenty-flies-exist.md @@ -0,0 +1,6 @@ +--- +'demo-store': patch +'@shopify/hydrogen': patch +--- + +Add a `` component to make building product forms easier. Also added `getFirstAvailableVariant` and `getSelectedProductOptions` helper functions. See the [proposal](https://gist.github.com/blittle/d9205d4ac72528005dc6f3104c328ecd) for examples. diff --git a/examples/express/app/root.tsx b/examples/express/app/root.tsx index adb8d606d3..fc607d39ed 100644 --- a/examples/express/app/root.tsx +++ b/examples/express/app/root.tsx @@ -7,7 +7,7 @@ import { ScrollRestoration, useLoaderData, } from '@remix-run/react'; -import {Cart, Shop} from '@shopify/hydrogen-react/storefront-api-types'; +import type {Cart, Shop} from '@shopify/hydrogen/storefront-api-types'; import {Layout} from '~/components/Layout'; import styles from './styles/app.css'; import favicon from '../public/favicon.svg'; diff --git a/package-lock.json b/package-lock.json index a616b26bd0..f320244242 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22610,21 +22610,6 @@ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz", "integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==" }, - "node_modules/modern-node-polyfills/node_modules/rollup": { - "version": "2.79.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.1.tgz", - "integrity": "sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw==", - "peer": true, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=10.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, "node_modules/moment": { "version": "2.29.4", "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", @@ -54083,15 +54068,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz", "integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==" - }, - "rollup": { - "version": "2.79.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.1.tgz", - "integrity": "sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw==", - "peer": true, - "requires": { - "fsevents": "~2.3.2" - } } } }, diff --git a/packages/hydrogen/docs/generated/generated_docs_data.json b/packages/hydrogen/docs/generated/generated_docs_data.json index 11e7fd3b55..d495acdadc 100644 --- a/packages/hydrogen/docs/generated/generated_docs_data.json +++ b/packages/hydrogen/docs/generated/generated_docs_data.json @@ -8535,6 +8535,265 @@ } ] }, + { + "name": "VariantSelector", + "category": "components", + "isVisualComponent": true, + "related": [ + { + "name": "getSelectedProductOptions", + "type": "utilities", + "url": "/docs/api/hydrogen/2023-04/utilities/getselectedproductoptions" + }, + { + "name": "getFirstAvailableVariant", + "type": "utilities", + "url": "/docs/api/hydrogen/2023-04/utilities/getfirstavailablevariant" + } + ], + "description": "> Caution:\n> This component is in an unstable pre-release state and may have breaking changes in a future release.\n\nThe `VariantSelector` component helps you build a form for selecting available variants of a product. It is important for variant selection state to be maintained in the URL, so that the user can navigate to a product and return back to the same variant selection. It is also important that the variant selection state is shareable via URL. The `VariantSelector` component provides a render prop that renders for each product option.", + "type": "component", + "defaultExample": { + "description": "I am the default example", + "codeblock": { + "tabs": [ + { + "title": "JavaScript", + "code": "import {VariantSelector__unstable as VariantSelector} from '@shopify/hydrogen';\nimport {Link} from '@remix-run/react';\n\nconst ProductForm = ({product}) => {\n return (\n \n {({option}) => (\n <>\n
{option.name}
\n
\n {option.values.map(({value, isAvailable, path, isActive}) => (\n \n {value}\n \n ))}\n
\n \n )}\n
\n );\n};\n", + "language": "jsx" + }, + { + "title": "TypeScript", + "code": "import {VariantSelector__unstable as VariantSelector} from '@shopify/hydrogen';\nimport type {Product} from '@shopify/hydrogen/storefront-api-types';\nimport {Link} from '@remix-run/react';\n\nconst ProductForm = ({product}: {product: Product}) => {\n return (\n \n {({option}) => (\n <>\n
{option.name}
\n
\n {option.values.map(({value, isAvailable, path, isActive}) => (\n \n {value}\n \n ))}\n
\n \n )}\n
\n );\n};\n", + "language": "tsx" + } + ], + "title": "Example code" + } + }, + "definitions": [ + { + "title": "Props", + "description": "", + "type": "VariantSelectorProps", + "typeDefinitions": { + "VariantSelectorProps": { + "filePath": "/product/VariantSelector.ts", + "syntaxKind": "TypeAliasDeclaration", + "name": "VariantSelectorProps", + "value": "{\n /** Product options from the [Storefront API](/docs/api/storefront/2023-04/objects/ProductOption). Make sure both `name` and `values` are apart of your query. */\n options: Array> | undefined;\n /** Product variants from the [Storefront API](/docs/api/storefront/2023-04/objects/ProductVariant). You only need to pass this prop if you want to show product availability. If a product option combination is not found within `variants`, it is assumed to be available. Make sure to include `availableForSale` and `selectedOptions.name` and `selectedOptions.value`. */\n variants?:\n | PartialDeep\n | Array>;\n /** Provide a default variant when no options are selected. You can use the utility `getFirstAvailableVariant` to get a default variant. */\n defaultVariant?: PartialDeep;\n children: ({option}: {option: AvailableOption}) => ReactNode;\n}", + "description": "", + "members": [ + { + "filePath": "/product/VariantSelector.ts", + "syntaxKind": "PropertySignature", + "name": "options", + "value": "PartialObjectDeep[]", + "description": "Product options from the [Storefront API](/docs/api/storefront/2023-04/objects/ProductOption). Make sure both `name` and `values` are apart of your query." + }, + { + "filePath": "/product/VariantSelector.ts", + "syntaxKind": "PropertySignature", + "name": "variants", + "value": "PartialObjectDeep | PartialObjectDeep[]", + "description": "Product variants from the [Storefront API](/docs/api/storefront/2023-04/objects/ProductVariant). You only need to pass this prop if you want to show product availability. If a product option combination is not found within `variants`, it is assumed to be available. Make sure to include `availableForSale` and `selectedOptions.name` and `selectedOptions.value`.", + "isOptional": true + }, + { + "filePath": "/product/VariantSelector.ts", + "syntaxKind": "PropertySignature", + "name": "defaultVariant", + "value": "PartialDeep", + "description": "Provide a default variant when no options are selected. You can use the utility `getFirstAvailableVariant` to get a default variant.", + "isOptional": true + }, + { + "filePath": "/product/VariantSelector.ts", + "syntaxKind": "PropertySignature", + "name": "children", + "value": "({ option }: { option: AvailableOption; }) => ReactNode", + "description": "" + } + ] + }, + "AvailableOption": { + "filePath": "/product/VariantSelector.ts", + "syntaxKind": "TypeAliasDeclaration", + "name": "AvailableOption", + "value": "{\n name: string;\n value?: string;\n values: Array;\n}", + "description": "", + "members": [ + { + "filePath": "/product/VariantSelector.ts", + "syntaxKind": "PropertySignature", + "name": "name", + "value": "string", + "description": "" + }, + { + "filePath": "/product/VariantSelector.ts", + "syntaxKind": "PropertySignature", + "name": "value", + "value": "string", + "description": "", + "isOptional": true + }, + { + "filePath": "/product/VariantSelector.ts", + "syntaxKind": "PropertySignature", + "name": "values", + "value": "Array", + "description": "" + } + ] + }, + "Value": { + "filePath": "/product/VariantSelector.ts", + "syntaxKind": "TypeAliasDeclaration", + "name": "Value", + "value": "{\n value: string;\n isAvailable: boolean;\n path: string;\n isActive: boolean;\n}", + "description": "", + "members": [ + { + "filePath": "/product/VariantSelector.ts", + "syntaxKind": "PropertySignature", + "name": "value", + "value": "string", + "description": "" + }, + { + "filePath": "/product/VariantSelector.ts", + "syntaxKind": "PropertySignature", + "name": "isAvailable", + "value": "boolean", + "description": "" + }, + { + "filePath": "/product/VariantSelector.ts", + "syntaxKind": "PropertySignature", + "name": "path", + "value": "string", + "description": "" + }, + { + "filePath": "/product/VariantSelector.ts", + "syntaxKind": "PropertySignature", + "name": "isActive", + "value": "boolean", + "description": "" + } + ] + } + } + } + ] + }, + { + "name": "getFirstAvailableVariant", + "category": "utilities", + "isVisualComponent": false, + "related": [ + { + "name": "VariantSelector", + "type": "components", + "url": "/docs/api/hydrogen/2023-04/components/variantselector" + }, + { + "name": "getSelectedProductOptions", + "type": "utilities", + "url": "/docs/api/hydrogen/2023-04/utilities/getselectedproductoptions" + } + ], + "description": "> Caution:\n> This utility is in an unstable pre-release state and may have breaking changes in a future release.\n\nThe `getFirstAvailableVariant` returns the first variant that is available for purchase.", + "type": "component", + "defaultExample": { + "description": "I am the default example", + "codeblock": { + "tabs": [ + { + "title": "JavaScript", + "code": "import {getFirstAvailableVariant__unstable as getFirstAvailableVariant} from '@shopify/hydrogen';\nimport {json} from '@shopify/remix-oxygen';\n\nexport async function loader({params, context}) {\n const {product} = await context.storefront.query(PRODUCT_QUERY, {\n variables: {\n handle: params.productHandle,\n },\n });\n\n const firstAvailableVariant = getFirstAvailableVariant(product.variants);\n\n return json({product, firstAvailableVariant});\n}\n\nconst PRODUCT_QUERY = `#graphql\n query Product($handle: String!) {\n product(handle: $handle) {\n title\n description\n options {\n name\n values \n }\n variants(first: 250) {\n nodes {\n ...ProductVariantFragment\n }\n }\n }\n }\n`;\n", + "language": "jsx" + }, + { + "title": "TypeScript", + "code": "import {getFirstAvailableVariant__unstable as getFirstAvailableVariant} from '@shopify/hydrogen';\nimport {json, type LoaderArgs} from '@shopify/remix-oxygen';\n\nexport async function loader({params, context}: LoaderArgs) {\n const {product} = await context.storefront.query(PRODUCT_QUERY, {\n variables: {\n handle: params.productHandle,\n },\n });\n\n const firstAvailableVariant = getFirstAvailableVariant(product.variants);\n\n return json({product, firstAvailableVariant});\n}\n\nconst PRODUCT_QUERY = `#graphql\n query Product($handle: String!) {\n product(handle: $handle) {\n title\n description\n options {\n name\n values \n }\n variants(first: 250) {\n nodes {\n ...ProductVariantFragment\n }\n }\n }\n }\n`;\n", + "language": "tsx" + } + ], + "title": "Example code" + } + }, + "definitions": [ + { + "title": "Props", + "description": "", + "type": "GetFirstAvailableVariant", + "typeDefinitions": { + "GetFirstAvailableVariant": { + "filePath": "/product/VariantSelector.ts", + "syntaxKind": "TypeAliasDeclaration", + "name": "GetFirstAvailableVariant", + "value": "(\n variants:\n | PartialDeep\n | Array>,\n) => PartialDeep | undefined", + "description": "" + } + } + } + ] + }, + { + "name": "getSelectedProductOptions", + "category": "utilities", + "isVisualComponent": false, + "related": [ + { + "name": "VariantSelector", + "type": "components", + "url": "/docs/api/hydrogen/2023-04/components/variantselector" + }, + { + "name": "getFirstAvailableVariant", + "type": "utilities", + "url": "/docs/api/hydrogen/2023-04/utilities/getfirstavailablevariant" + } + ], + "description": "> Caution:\n> This utility is in an unstable pre-release state and may have breaking changes in a future release.\n\nThe `getSelectedProductOptions` returns the selected options from the Request search parameters. The selected options can then be easily passed to your GraphQL query with [`variantBySelectedOptions`](https://shopify.dev/docs/api/storefront/2023-04/objects/product#field-product-variantbyselectedoptions).", + "type": "component", + "defaultExample": { + "description": "I am the default example", + "codeblock": { + "tabs": [ + { + "title": "JavaScript", + "code": "import {getSelectedProductOptions__unstable as getSelectedProductOptions} from '@shopify/hydrogen';\nimport {json} from '@shopify/remix-oxygen';\n\nexport async function loader({request, params, context}) {\n const selectedOptions = getSelectedProductOptions(request);\n\n const {product} = await context.storefront.query(PRODUCT_QUERY, {\n variables: {\n handle: params.productHandle,\n selectedOptions,\n },\n });\n\n return json({product});\n}\n\nconst PRODUCT_QUERY = `#graphql\n query Product($handle: String!, $selectedOptions: [SelectedOptionInput!]!) {\n product(handle: $handle) {\n title\n description\n options {\n name\n values \n }\n selectedVariant: variantBySelectedOptions(selectedOptions: $selectedOptions) {\n ...ProductVariantFragment\n }\n }\n }\n`;\n", + "language": "jsx" + }, + { + "title": "TypeScript", + "code": "import {getSelectedProductOptions__unstable as getSelectedProductOptions} from '@shopify/hydrogen';\nimport {json, type LoaderArgs} from '@shopify/remix-oxygen';\n\nexport async function loader({request, params, context}: LoaderArgs) {\n const selectedOptions = getSelectedProductOptions(request);\n\n const {product} = await context.storefront.query(PRODUCT_QUERY, {\n variables: {\n handle: params.productHandle,\n selectedOptions,\n },\n });\n\n return json({product});\n}\n\nconst PRODUCT_QUERY = `#graphql\n query Product($handle: String!, $selectedOptions: [SelectedOptionInput!]!) {\n product(handle: $handle) {\n title\n description\n options {\n name\n values \n }\n selectedVariant: variantBySelectedOptions(selectedOptions: $selectedOptions) {\n ...ProductVariantFragment\n }\n }\n }\n`;\n", + "language": "tsx" + } + ], + "title": "Example code" + } + }, + "definitions": [ + { + "title": "Props", + "description": "", + "type": "GetSelectedProductOptions", + "typeDefinitions": { + "GetSelectedProductOptions": { + "filePath": "/product/VariantSelector.ts", + "syntaxKind": "TypeAliasDeclaration", + "name": "GetSelectedProductOptions", + "value": "(request: Request) => SelectedOptionInput[]", + "description": "" + } + } + } + ] + }, { "name": "graphiqlLoader", "category": "utilities", diff --git a/packages/hydrogen/src/index.ts b/packages/hydrogen/src/index.ts index c93a1c2c9b..5d06a8781d 100644 --- a/packages/hydrogen/src/index.ts +++ b/packages/hydrogen/src/index.ts @@ -46,6 +46,12 @@ export type { CartQueryReturn, } from './cart/queries/cart-types'; +export { + VariantSelector as VariantSelector__unstable, + getSelectedProductOptions as getSelectedProductOptions__unstable, + getFirstAvailableVariant as getFirstAvailableVariant__unstable, +} from './product/VariantSelector'; + export { AnalyticsEventName, AnalyticsPageType, diff --git a/packages/hydrogen/src/product/VariantSelector.doc.ts b/packages/hydrogen/src/product/VariantSelector.doc.ts new file mode 100644 index 0000000000..c96e265119 --- /dev/null +++ b/packages/hydrogen/src/product/VariantSelector.doc.ts @@ -0,0 +1,51 @@ +import {ReferenceEntityTemplateSchema} from '@shopify/generate-docs'; + +const data: ReferenceEntityTemplateSchema = { + name: 'VariantSelector', + category: 'components', + isVisualComponent: true, + related: [ + { + name: 'getSelectedProductOptions', + type: 'utilities', + url: '/docs/api/hydrogen/2023-04/utilities/getselectedproductoptions', + }, + { + name: 'getFirstAvailableVariant', + type: 'utilities', + url: '/docs/api/hydrogen/2023-04/utilities/getfirstavailablevariant', + }, + ], + description: `> Caution: +> This component is in an unstable pre-release state and may have breaking changes in a future release. + +The \`VariantSelector\` component helps you build a form for selecting available variants of a product. It is important for variant selection state to be maintained in the URL, so that the user can navigate to a product and return back to the same variant selection. It is also important that the variant selection state is shareable via URL. The \`VariantSelector\` component provides a render prop that renders for each product option.`, + type: 'component', + defaultExample: { + description: 'I am the default example', + codeblock: { + tabs: [ + { + title: 'JavaScript', + code: './VariantSelector.example.jsx', + language: 'jsx', + }, + { + title: 'TypeScript', + code: './VariantSelector.example.tsx', + language: 'tsx', + }, + ], + title: 'Example code', + }, + }, + definitions: [ + { + title: 'Props', + type: 'VariantSelectorProps', + description: '', + }, + ], +}; + +export default data; diff --git a/packages/hydrogen/src/product/VariantSelector.example.jsx b/packages/hydrogen/src/product/VariantSelector.example.jsx new file mode 100644 index 0000000000..5c5340d996 --- /dev/null +++ b/packages/hydrogen/src/product/VariantSelector.example.jsx @@ -0,0 +1,27 @@ +import {VariantSelector__unstable as VariantSelector} from '@shopify/hydrogen'; +import {Link} from '@remix-run/react'; + +const ProductForm = ({product}) => { + return ( + + {({option}) => ( + <> +
{option.name}
+
+ {option.values.map(({value, isAvailable, path, isActive}) => ( + + {value} + + ))} +
+ + )} +
+ ); +}; diff --git a/packages/hydrogen/src/product/VariantSelector.example.tsx b/packages/hydrogen/src/product/VariantSelector.example.tsx new file mode 100644 index 0000000000..8931de0782 --- /dev/null +++ b/packages/hydrogen/src/product/VariantSelector.example.tsx @@ -0,0 +1,28 @@ +import {VariantSelector__unstable as VariantSelector} from '@shopify/hydrogen'; +import type {Product} from '@shopify/hydrogen/storefront-api-types'; +import {Link} from '@remix-run/react'; + +const ProductForm = ({product}: {product: Product}) => { + return ( + + {({option}) => ( + <> +
{option.name}
+
+ {option.values.map(({value, isAvailable, path, isActive}) => ( + + {value} + + ))} +
+ + )} +
+ ); +}; diff --git a/packages/hydrogen/src/product/VariantSelector.js b/packages/hydrogen/src/product/VariantSelector.js new file mode 100644 index 0000000000..009ae8053d --- /dev/null +++ b/packages/hydrogen/src/product/VariantSelector.js @@ -0,0 +1,109 @@ +import {useLocation} from '@remix-run/react'; +import {flattenConnection} from '@shopify/hydrogen-react'; +import {useMemo, createElement, Fragment} from 'react'; +export function VariantSelector({ + options = [], + variants: _variants = [], + children, + defaultVariant, +}) { + const variants = + _variants instanceof Array ? _variants : flattenConnection(_variants); + const {pathname, search} = useLocation(); + const {searchParams, path} = useMemo(() => { + const isLocalePathname = /\/[a-zA-Z]{2}-[a-zA-Z]{2}\//g.test(pathname); + const path = isLocalePathname + ? `/${pathname.split('/').slice(2).join('/')}` + : pathname; + const searchParams = new URLSearchParams(search); + return { + searchParams: searchParams, + path, + }; + }, [pathname, search]); + // If an option only has one value, it doesn't need a UI to select it + // But instead it always needs to be added to the product options so + // the SFAPI properly finds the variant + const optionsWithOnlyOneValue = options.filter( + (option) => option?.values?.length === 1, + ); + return createElement( + Fragment, + null, + ...useMemo(() => { + return ( + options + // Only show options with more than one value + .filter((option) => option?.values?.length > 1) + .map((option) => { + let activeValue; + let availableValues = []; + for (let value of option.values) { + // The clone the search params for each value, so we can calculate + // a new URL for each option value pair + const clonedSearchParams = new URLSearchParams(searchParams); + clonedSearchParams.set(option.name, value); + // Because we hide options with only one value, they aren't selectable, + // but they still need to get into the URL + optionsWithOnlyOneValue.forEach((option) => { + clonedSearchParams.set(option.name, option.values[0]); + }); + // Find a variant that matches all selected options. + const variant = variants.find((variant) => + variant?.selectedOptions?.every( + (selectedOption) => + clonedSearchParams.get(selectedOption?.name) === + selectedOption?.value, + ), + ); + const currentParam = searchParams.get(option.name); + const calculatedActiveValue = currentParam + ? // If a URL parameter exists for the current option, check if it equals the current value + currentParam === value + : defaultVariant + ? // Else check if the default variant has the current option value + defaultVariant.selectedOptions?.some( + (selectedOption) => + selectedOption?.name === option.name && + selectedOption?.value === value, + ) + : false; + if (calculatedActiveValue) { + // Save out the current value if it's active. This should only ever happen once. + // Should we throw if it happens a second time? + activeValue = value; + } + availableValues.push({ + value: value, + isAvailable: variant ? variant.availableForSale : true, + path: path + '?' + clonedSearchParams.toString(), + isActive: Boolean(calculatedActiveValue), + }); + } + return children({ + option: { + name: option.name, + value: activeValue, + values: availableValues, + }, + }); + }) + ); + }, [options, variants, children]), + ); +} +export const getSelectedProductOptions = (request) => { + if (!(request instanceof Request)) + throw new TypeError(`Expected a Request instance, got ${typeof request}`); + const searchParams = new URL(request.url).searchParams; + const selectedOptions = []; + searchParams.forEach((value, name) => { + selectedOptions.push({name, value}); + }); + return selectedOptions; +}; +export function getFirstAvailableVariant(variants = []) { + return ( + variants instanceof Array ? variants : flattenConnection(variants) + ).find((variant) => variant?.availableForSale); +} diff --git a/packages/hydrogen/src/product/VariantSelector.test.ts b/packages/hydrogen/src/product/VariantSelector.test.ts new file mode 100644 index 0000000000..b27ef9ae2e --- /dev/null +++ b/packages/hydrogen/src/product/VariantSelector.test.ts @@ -0,0 +1,378 @@ +import { + VariantSelector, + getFirstAvailableVariant, + getSelectedProductOptions, +} from './VariantSelector'; +import {createElement} from 'react'; +import {cleanup, render} from '@testing-library/react'; +import {describe, it, expect, afterEach, vi, afterAll} from 'vitest'; +import {type LinkProps, useLocation} from '@remix-run/react'; + +vi.mock('@remix-run/react', () => ({ + useNavigation: vi.fn(() => ({ + state: 'idle', + })), + useLocation: vi.fn(() => fillLocation()), + Link: vi.fn(({to, state, preventScrollReset}: LinkProps) => + createElement('a', { + href: to, + state: JSON.stringify(state), + 'data-preventscrollreset': preventScrollReset, + }), + ), +})); + +function fillLocation(partial: Partial = {}) { + return { + key: '', + pathname: '/', + search: '', + hash: '', + state: null, + ...partial, + }; +} + +describe('getFirstAvailableVariant', () => { + it('returns the first available variant', () => { + expect( + getFirstAvailableVariant([ + { + availableForSale: false, + selectedOptions: [{name: 'Color', value: 'Red'}], + }, + { + availableForSale: true, + selectedOptions: [{name: 'Color', value: 'Blue'}], + }, + ]), + ).toEqual({ + availableForSale: true, + selectedOptions: [{name: 'Color', value: 'Blue'}], + }); + }); + + it('returns the first available variant from a connection', () => { + expect( + getFirstAvailableVariant({ + nodes: [ + { + availableForSale: false, + selectedOptions: [{name: 'Color', value: 'Red'}], + }, + { + availableForSale: true, + selectedOptions: [{name: 'Color', value: 'Blue'}], + }, + ], + }), + ).toEqual({ + availableForSale: true, + selectedOptions: [{name: 'Color', value: 'Blue'}], + }); + }); +}); + +describe('getSelectedProductOptions', () => { + it('returns the selected options', () => { + const req = new Request('https://localhost:8080/?Color=Red&Size=S'); + expect(getSelectedProductOptions(req)).toEqual([ + {name: 'Color', value: 'Red'}, + {name: 'Size', value: 'S'}, + ]); + }); +}); + +describe('', () => { + afterEach(() => { + cleanup(); + }); + + afterAll(() => { + vi.resetAllMocks(); + }); + + it('passes value and path for each variant permutation', () => { + const {asFragment} = render( + createElement(VariantSelector, { + options: [ + {name: 'Color', values: ['Red', 'Blue']}, + {name: 'Size', values: ['S', 'M']}, + ], + children: ({option}) => + createElement( + 'div', + null, + option.values.map(({value, path}) => + createElement('a', {key: option.name + value, href: path}, value), + ), + ), + }), + ); + + expect(asFragment()).toMatchInlineSnapshot(` + + + + + `); + }); + + it('automatically appends options with only one value to the URL', () => { + const {asFragment} = render( + createElement(VariantSelector, { + options: [ + {name: 'Color', values: ['Red']}, + {name: 'Size', values: ['S', 'M']}, + ], + children: ({option}) => + createElement( + 'div', + null, + option.values.map(({value, path}) => + createElement('a', {key: option.name + value, href: path}, value), + ), + ), + }), + ); + + expect(asFragment()).toMatchInlineSnapshot(` + + + + `); + }); + + it('shows whether or not an option is active', () => { + vi.mocked(useLocation).mockReturnValueOnce( + fillLocation({search: '?Size=M'}), + ); + + const {asFragment} = render( + createElement(VariantSelector, { + options: [ + {name: 'Color', values: ['Red']}, + {name: 'Size', values: ['S', 'M']}, + ], + children: ({option}) => + createElement( + 'div', + null, + option.values.map(({value, path, isActive}) => + createElement( + 'a', + { + key: option.name + value, + href: path, + className: isActive ? 'active' : undefined, + }, + value, + ), + ), + ), + }), + ); + + expect(asFragment()).toMatchInlineSnapshot(` + + + + `); + }); + + it('all options default to available', () => { + const {asFragment} = render( + createElement(VariantSelector, { + options: [{name: 'Size', values: ['S', 'M']}], + children: ({option}) => + createElement( + 'div', + null, + option.values.map(({value, path, isAvailable}) => + createElement( + 'a', + { + key: option.name + value, + href: path, + className: isAvailable ? 'available' : 'unavailable', + }, + value, + ), + ), + ), + }), + ); + + expect(asFragment()).toMatchInlineSnapshot(` + + + + `); + }); + + it('shows products as unavailable', () => { + const {asFragment} = render( + createElement(VariantSelector, { + options: [{name: 'Size', values: ['S', 'M']}], + variants: [ + { + availableForSale: true, + selectedOptions: [{name: 'Size', value: 'S'}], + }, + { + availableForSale: false, + selectedOptions: [{name: 'Size', value: 'M'}], + }, + ], + children: ({option}) => + createElement( + 'div', + null, + option.values.map(({value, path, isAvailable}) => + createElement( + 'a', + { + key: option.name + value, + href: path, + className: isAvailable ? 'available' : 'unavailable', + }, + value, + ), + ), + ), + }), + ); + + expect(asFragment()).toMatchInlineSnapshot(` + + + + `); + }); + + it('takes a connection as variants', () => { + const {asFragment} = render( + createElement(VariantSelector, { + options: [{name: 'Size', values: ['S', 'M']}], + variants: { + nodes: [ + { + availableForSale: true, + selectedOptions: [{name: 'Size', value: 'S'}], + }, + { + availableForSale: false, + selectedOptions: [{name: 'Size', value: 'M'}], + }, + ], + }, + children: ({option}) => + createElement( + 'div', + null, + option.values.map(({value, path, isAvailable}) => + createElement( + 'a', + { + key: option.name + value, + href: path, + className: isAvailable ? 'available' : 'unavailable', + }, + value, + ), + ), + ), + }), + ); + + expect(asFragment()).toMatchInlineSnapshot(` + + + + `); + }); +}); diff --git a/packages/hydrogen/src/product/VariantSelector.ts b/packages/hydrogen/src/product/VariantSelector.ts new file mode 100644 index 0000000000..6cfc7e8ac8 --- /dev/null +++ b/packages/hydrogen/src/product/VariantSelector.ts @@ -0,0 +1,175 @@ +import {useLocation} from '@remix-run/react'; +import {flattenConnection} from '@shopify/hydrogen-react'; +import type { + ProductOption, + ProductVariant, + ProductVariantConnection, + SelectedOptionInput, +} from '@shopify/hydrogen-react/storefront-api-types'; +import {ReactNode, useMemo, createElement, Fragment} from 'react'; +import type {PartialDeep} from 'type-fest'; + +type AvailableOption = { + name: string; + value?: string; + values: Array; +}; + +type Value = { + value: string; + isAvailable: boolean; + path: string; + isActive: boolean; +}; + +type VariantSelectorProps = { + /** Product options from the [Storefront API](/docs/api/storefront/2023-04/objects/ProductOption). Make sure both `name` and `values` are apart of your query. */ + options: Array> | undefined; + /** Product variants from the [Storefront API](/docs/api/storefront/2023-04/objects/ProductVariant). You only need to pass this prop if you want to show product availability. If a product option combination is not found within `variants`, it is assumed to be available. Make sure to include `availableForSale` and `selectedOptions.name` and `selectedOptions.value`. */ + variants?: + | PartialDeep + | Array>; + /** Provide a default variant when no options are selected. You can use the utility `getFirstAvailableVariant` to get a default variant. */ + defaultVariant?: PartialDeep; + children: ({option}: {option: AvailableOption}) => ReactNode; +}; + +export function VariantSelector({ + options = [], + variants: _variants = [], + children, + defaultVariant, +}: VariantSelectorProps) { + const variants = + _variants instanceof Array ? _variants : flattenConnection(_variants); + const {pathname, search} = useLocation(); + + const {searchParams, path} = useMemo(() => { + const isLocalePathname = /\/[a-zA-Z]{2}-[a-zA-Z]{2}\//g.test(pathname); + const path = isLocalePathname + ? `/${pathname.split('/').slice(2).join('/')}` + : pathname; + + const searchParams = new URLSearchParams(search); + + return { + searchParams: searchParams, + path, + }; + }, [pathname, search]); + + // If an option only has one value, it doesn't need a UI to select it + // But instead it always needs to be added to the product options so + // the SFAPI properly finds the variant + const optionsWithOnlyOneValue = options.filter( + (option) => option?.values?.length === 1, + ); + + return createElement( + Fragment, + null, + ...useMemo(() => { + return ( + options + // Only show options with more than one value + .filter((option) => option?.values?.length! > 1) + .map((option) => { + let activeValue; + let availableValues: Value[] = []; + + for (let value of option.values!) { + // The clone the search params for each value, so we can calculate + // a new URL for each option value pair + const clonedSearchParams = new URLSearchParams(searchParams); + clonedSearchParams.set(option.name!, value!); + + // Because we hide options with only one value, they aren't selectable, + // but they still need to get into the URL + optionsWithOnlyOneValue.forEach((option) => { + clonedSearchParams.set(option.name!, option.values![0]!); + }); + + // Find a variant that matches all selected options. + const variant = variants.find((variant) => + variant?.selectedOptions?.every( + (selectedOption) => + clonedSearchParams.get(selectedOption?.name!) === + selectedOption?.value, + ), + ); + + const currentParam = searchParams.get(option.name!); + + const calculatedActiveValue = currentParam + ? // If a URL parameter exists for the current option, check if it equals the current value + currentParam === value! + : defaultVariant + ? // Else check if the default variant has the current option value + defaultVariant.selectedOptions?.some( + (selectedOption) => + selectedOption?.name === option.name && + selectedOption?.value === value, + ) + : false; + + if (calculatedActiveValue) { + // Save out the current value if it's active. This should only ever happen once. + // Should we throw if it happens a second time? + activeValue = value; + } + + availableValues.push({ + value: value!, + isAvailable: variant ? variant.availableForSale! : true, + path: path + '?' + clonedSearchParams.toString(), + isActive: Boolean(calculatedActiveValue), + }); + } + + return children({ + option: { + name: option.name!, + value: activeValue, + values: availableValues, + }, + }); + }) + ); + }, [options, variants, children]), + ); +} + +type GetSelectedProductOptions = (request: Request) => SelectedOptionInput[]; + +export const getSelectedProductOptions: GetSelectedProductOptions = ( + request, +) => { + if (!(request instanceof Request)) + throw new TypeError(`Expected a Request instance, got ${typeof request}`); + + const searchParams = new URL(request.url).searchParams; + + const selectedOptions: SelectedOptionInput[] = []; + + searchParams.forEach((value, name) => { + selectedOptions.push({name, value}); + }); + + return selectedOptions; +}; + +type GetFirstAvailableVariant = ( + variants: + | PartialDeep + | Array>, +) => PartialDeep | undefined; + +export const getFirstAvailableVariant: GetFirstAvailableVariant = ( + variants: + | PartialDeep + | Array> = [], +): PartialDeep | undefined => { + return ( + variants instanceof Array ? variants : flattenConnection(variants) + ).find((variant) => variant?.availableForSale); +}; diff --git a/packages/hydrogen/src/product/getFirstAvailableVariant.doc.ts b/packages/hydrogen/src/product/getFirstAvailableVariant.doc.ts new file mode 100644 index 0000000000..0be0cd8cac --- /dev/null +++ b/packages/hydrogen/src/product/getFirstAvailableVariant.doc.ts @@ -0,0 +1,51 @@ +import {ReferenceEntityTemplateSchema} from '@shopify/generate-docs'; + +const data: ReferenceEntityTemplateSchema = { + name: 'getFirstAvailableVariant', + category: 'utilities', + isVisualComponent: false, + related: [ + { + name: 'VariantSelector', + type: 'components', + url: '/docs/api/hydrogen/2023-04/components/variantselector', + }, + { + name: 'getSelectedProductOptions', + type: 'utilities', + url: '/docs/api/hydrogen/2023-04/utilities/getselectedproductoptions', + }, + ], + description: `> Caution: +> This utility is in an unstable pre-release state and may have breaking changes in a future release. + +The \`getFirstAvailableVariant\` returns the first variant that is available for purchase.`, + type: 'component', + defaultExample: { + description: 'I am the default example', + codeblock: { + tabs: [ + { + title: 'JavaScript', + code: './getFirstAvailableVariant.example.jsx', + language: 'jsx', + }, + { + title: 'TypeScript', + code: './getFirstAvailableVariant.example.tsx', + language: 'tsx', + }, + ], + title: 'Example code', + }, + }, + definitions: [ + { + title: 'Props', + type: 'GetFirstAvailableVariant', + description: '', + }, + ], +}; + +export default data; diff --git a/packages/hydrogen/src/product/getFirstAvailableVariant.example.jsx b/packages/hydrogen/src/product/getFirstAvailableVariant.example.jsx new file mode 100644 index 0000000000..c7341d9467 --- /dev/null +++ b/packages/hydrogen/src/product/getFirstAvailableVariant.example.jsx @@ -0,0 +1,32 @@ +import {getFirstAvailableVariant__unstable as getFirstAvailableVariant} from '@shopify/hydrogen'; +import {json} from '@shopify/remix-oxygen'; + +export async function loader({params, context}) { + const {product} = await context.storefront.query(PRODUCT_QUERY, { + variables: { + handle: params.productHandle, + }, + }); + + const firstAvailableVariant = getFirstAvailableVariant(product.variants); + + return json({product, firstAvailableVariant}); +} + +const PRODUCT_QUERY = `#graphql + query Product($handle: String!) { + product(handle: $handle) { + title + description + options { + name + values + } + variants(first: 250) { + nodes { + ...ProductVariantFragment + } + } + } + } +`; diff --git a/packages/hydrogen/src/product/getFirstAvailableVariant.example.tsx b/packages/hydrogen/src/product/getFirstAvailableVariant.example.tsx new file mode 100644 index 0000000000..bc9fbab9b2 --- /dev/null +++ b/packages/hydrogen/src/product/getFirstAvailableVariant.example.tsx @@ -0,0 +1,32 @@ +import {getFirstAvailableVariant__unstable as getFirstAvailableVariant} from '@shopify/hydrogen'; +import {json, type LoaderArgs} from '@shopify/remix-oxygen'; + +export async function loader({params, context}: LoaderArgs) { + const {product} = await context.storefront.query(PRODUCT_QUERY, { + variables: { + handle: params.productHandle, + }, + }); + + const firstAvailableVariant = getFirstAvailableVariant(product.variants); + + return json({product, firstAvailableVariant}); +} + +const PRODUCT_QUERY = `#graphql + query Product($handle: String!) { + product(handle: $handle) { + title + description + options { + name + values + } + variants(first: 250) { + nodes { + ...ProductVariantFragment + } + } + } + } +`; diff --git a/packages/hydrogen/src/product/getSelectedProductOptions.doc.ts b/packages/hydrogen/src/product/getSelectedProductOptions.doc.ts new file mode 100644 index 0000000000..8b2aabbf05 --- /dev/null +++ b/packages/hydrogen/src/product/getSelectedProductOptions.doc.ts @@ -0,0 +1,51 @@ +import {ReferenceEntityTemplateSchema} from '@shopify/generate-docs'; + +const data: ReferenceEntityTemplateSchema = { + name: 'getSelectedProductOptions', + category: 'utilities', + isVisualComponent: false, + related: [ + { + name: 'VariantSelector', + type: 'components', + url: '/docs/api/hydrogen/2023-04/components/variantselector', + }, + { + name: 'getFirstAvailableVariant', + type: 'utilities', + url: '/docs/api/hydrogen/2023-04/utilities/getfirstavailablevariant', + }, + ], + description: `> Caution: +> This utility is in an unstable pre-release state and may have breaking changes in a future release. + +The \`getSelectedProductOptions\` returns the selected options from the Request search parameters. The selected options can then be easily passed to your GraphQL query with [\`variantBySelectedOptions\`](https://shopify.dev/docs/api/storefront/2023-04/objects/product#field-product-variantbyselectedoptions).`, + type: 'component', + defaultExample: { + description: 'I am the default example', + codeblock: { + tabs: [ + { + title: 'JavaScript', + code: './getSelectedProductOptions.example.jsx', + language: 'jsx', + }, + { + title: 'TypeScript', + code: './getSelectedProductOptions.example.tsx', + language: 'tsx', + }, + ], + title: 'Example code', + }, + }, + definitions: [ + { + title: 'Props', + type: 'GetSelectedProductOptions', + description: '', + }, + ], +}; + +export default data; diff --git a/packages/hydrogen/src/product/getSelectedProductOptions.example.jsx b/packages/hydrogen/src/product/getSelectedProductOptions.example.jsx new file mode 100644 index 0000000000..4eeb07fbbb --- /dev/null +++ b/packages/hydrogen/src/product/getSelectedProductOptions.example.jsx @@ -0,0 +1,31 @@ +import {getSelectedProductOptions__unstable as getSelectedProductOptions} from '@shopify/hydrogen'; +import {json} from '@shopify/remix-oxygen'; + +export async function loader({request, params, context}) { + const selectedOptions = getSelectedProductOptions(request); + + const {product} = await context.storefront.query(PRODUCT_QUERY, { + variables: { + handle: params.productHandle, + selectedOptions, + }, + }); + + return json({product}); +} + +const PRODUCT_QUERY = `#graphql + query Product($handle: String!, $selectedOptions: [SelectedOptionInput!]!) { + product(handle: $handle) { + title + description + options { + name + values + } + selectedVariant: variantBySelectedOptions(selectedOptions: $selectedOptions) { + ...ProductVariantFragment + } + } + } +`; diff --git a/packages/hydrogen/src/product/getSelectedProductOptions.example.tsx b/packages/hydrogen/src/product/getSelectedProductOptions.example.tsx new file mode 100644 index 0000000000..85e4dd639b --- /dev/null +++ b/packages/hydrogen/src/product/getSelectedProductOptions.example.tsx @@ -0,0 +1,31 @@ +import {getSelectedProductOptions__unstable as getSelectedProductOptions} from '@shopify/hydrogen'; +import {json, type LoaderArgs} from '@shopify/remix-oxygen'; + +export async function loader({request, params, context}: LoaderArgs) { + const selectedOptions = getSelectedProductOptions(request); + + const {product} = await context.storefront.query(PRODUCT_QUERY, { + variables: { + handle: params.productHandle, + selectedOptions, + }, + }); + + return json({product}); +} + +const PRODUCT_QUERY = `#graphql + query Product($handle: String!, $selectedOptions: [SelectedOptionInput!]!) { + product(handle: $handle) { + title + description + options { + name + values + } + selectedVariant: variantBySelectedOptions(selectedOptions: $selectedOptions) { + ...ProductVariantFragment + } + } + } +`; diff --git a/templates/demo-store/app/routes/($locale).products.$productHandle.tsx b/templates/demo-store/app/routes/($locale).products.$productHandle.tsx index ddcac06a3e..feb35cc030 100644 --- a/templates/demo-store/app/routes/($locale).products.$productHandle.tsx +++ b/templates/demo-store/app/routes/($locale).products.$productHandle.tsx @@ -1,23 +1,20 @@ -import {type ReactNode, useRef, Suspense, useMemo} from 'react'; +import {useRef, Suspense} from 'react'; import {Disclosure, Listbox} from '@headlessui/react'; import {defer, type LoaderArgs} from '@shopify/remix-oxygen'; -import { - useLoaderData, - Await, - useSearchParams, - useLocation, - useNavigation, -} from '@remix-run/react'; +import {useLoaderData, Await} from '@remix-run/react'; import type {ShopifyAnalyticsProduct} from '@shopify/hydrogen'; -import {AnalyticsPageType, Money, ShopPayButton} from '@shopify/hydrogen'; +import { + AnalyticsPageType, + Money, + ShopPayButton, + VariantSelector__unstable as VariantSelector, + getSelectedProductOptions__unstable as getSelectedProductOptions, + getFirstAvailableVariant__unstable as getFirstAvailableVariant, +} from '@shopify/hydrogen'; import invariant from 'tiny-invariant'; import clsx from 'clsx'; -import type { - SelectedOptionInput, - Product as ProductType, - ProductConnection, -} from '@shopify/hydrogen/storefront-api-types'; +import type {ProductVariantFragmentFragment} from 'storefrontapi.generated'; import { Heading, IconCaret, @@ -34,9 +31,9 @@ import { } from '~/components'; import {getExcerpt} from '~/lib/utils'; import {seoPayload} from '~/lib/seo.server'; -import {MEDIA_FRAGMENT, PRODUCT_CARD_FRAGMENT} from '~/data/fragments'; import type {Storefront} from '~/lib/type'; import {routeHeaders} from '~/data/cache'; +import {MEDIA_FRAGMENT, PRODUCT_CARD_FRAGMENT} from '~/data/fragments'; export const headers = routeHeaders; @@ -44,12 +41,7 @@ export async function loader({params, request, context}: LoaderArgs) { const {productHandle} = params; invariant(productHandle, 'Missing productHandle param, check route filename'); - const searchParams = new URL(request.url).searchParams; - - const selectedOptions: SelectedOptionInput[] = []; - searchParams.forEach((value, name) => { - selectedOptions.push({name, value}); - }); + const selectedOptions = getSelectedProductOptions(request); const {shop, product} = await context.storefront.query(PRODUCT_QUERY, { variables: { @@ -60,6 +52,19 @@ export async function loader({params, request, context}: LoaderArgs) { }, }); + // In order to show which variants are available in the UI, we need to query + // all of them. But there might be a *lot*, so instead separate the variants + // into it's own separate query that is deferred. So there's a brief moment + // where variant options might show as available when they're not, but after + // this deffered query resolves, the UI will update. + const variants = context.storefront.query(VARIANTS_QUERY, { + variables: { + handle: productHandle, + country: context.storefront.i18n.country, + language: context.storefront.i18n.language, + }, + }); + if (!product?.id) { throw new Response('product', {status: 404}); } @@ -84,6 +89,7 @@ export async function loader({params, request, context}: LoaderArgs) { }); return defer({ + variants, product, shop, storeDomain: shop.primaryDomain.url, @@ -99,7 +105,7 @@ export async function loader({params, request, context}: LoaderArgs) { } export default function Product() { - const {product, shop, recommended} = useLoaderData(); + const {product, shop, recommended, variants} = useLoaderData(); const {media, title, vendor, descriptionHtml} = product; const {shippingPolicy, refundPolicy} = shop; @@ -121,7 +127,18 @@ export default function Product() { {vendor} )} - + }> + + {(resp) => ( + + )} + +
{descriptionHtml && ( (); + const firstVariant = + getFirstAvailableVariant(product.variants) ?? product.variants.nodes[0]; - const [currentSearchParams] = useSearchParams(); - const {location} = useNavigation(); - - /** - * We update `searchParams` with in-flight request data from `location` (if available) - * to create an optimistic UI, e.g. check the product option before the - * request has completed. - */ - const searchParams = useMemo(() => { - return location - ? new URLSearchParams(location.search) - : currentSearchParams; - }, [currentSearchParams, location]); - - const firstVariant = product.variants.nodes[0]; - - /** - * We're making an explicit choice here to display the product options - * UI with a default variant, rather than wait for the user to select - * options first. Developers are welcome to opt-out of this behavior. - * By default, the first variant's options are used. - */ - const searchParamsWithDefaults = useMemo(() => { - const clonedParams = new URLSearchParams(searchParams); - - for (const {name, value} of firstVariant.selectedOptions) { - if (!searchParams.has(name)) { - clonedParams.set(name, value); - } - } - - return clonedParams; - }, [searchParams, firstVariant.selectedOptions]); + const closeRef = useRef(null); /** * Likewise, we're defaulting to the first variant for purposes @@ -220,10 +211,103 @@ export function ProductForm() { return (
- + variants={variants} + defaultVariant={firstVariant} + > + {({option}) => { + return ( +
+ + {option.name} + +
+ {option.values.length > 7 ? ( +
+ + {({open}) => ( + <> + + {option.value} + + + + {option.values + .filter((value) => value.isAvailable) + .map(({value, path, isActive}) => ( + + {({active}) => ( + { + if (!closeRef?.current) return; + closeRef.current.click(); + }} + > + {value} + {isActive && ( + + + + )} + + )} + + ))} + + + )} + +
+ ) : ( + option.values.map( + ({value, isAvailable, isActive, path}) => ( + + {value} + + ), + ) + )} +
+
+ ); + }} + {selectedVariant && (
{isOutOfStock ? ( @@ -234,7 +318,7 @@ export function ProductForm() { ; - searchParamsWithDefaults: URLSearchParams; -}) { - const closeRef = useRef(null); - return ( - <> - {options - .filter((option) => option.values.length > 1) - .map((option) => ( -
- - {option.name} - -
- {/** - * First, we render a bunch of elements for each option value. - * When the user clicks one of these buttons, it will hit the loader - * to get the new data. - * - * If there are more than 7 values, we render a dropdown. - * Otherwise, we just render plain links. - */} - {option.values.length > 7 ? ( -
- - {({open}) => ( - <> - - - {searchParamsWithDefaults.get(option.name)} - - - - - {option.values.map((value) => ( - - {({active}) => ( - { - if (!closeRef?.current) return; - closeRef.current.click(); - }} - > - {value} - {searchParamsWithDefaults.get(option.name) === - value && ( - - - - )} - - )} - - ))} - - - )} - -
- ) : ( - <> - {option.values.map((value) => { - const checked = - searchParamsWithDefaults.get(option.name) === value; - const id = `option-${option.name}-${value}`; - - return ( - - - - ); - })} - - )} -
-
- ))} - - ); -} - -function ProductOptionLink({ - optionName, - optionValue, - searchParams, - children, - ...props -}: { - optionName: string; - optionValue: string; - searchParams: URLSearchParams; - children?: ReactNode; - [key: string]: any; -}) { - const {pathname} = useLocation(); - const isLocalePathname = /\/[a-zA-Z]{2}-[a-zA-Z]{2}\//g.test(pathname); - // fixes internalized pathname - const path = isLocalePathname - ? `/${pathname.split('/').slice(2).join('/')}` - : pathname; - - const clonedSearchParams = new URLSearchParams(searchParams); - clonedSearchParams.set(optionName, optionValue); - - return ( - - {children ?? optionValue} - - ); -} - function ProductDetail({ title, content, @@ -575,6 +504,23 @@ const PRODUCT_QUERY = `#graphql ${PRODUCT_VARIANT_FRAGMENT} ` as const; +const VARIANTS_QUERY = `#graphql + query variants( + $country: CountryCode + $language: LanguageCode + $handle: String! + ) @inContext(country: $country, language: $language) { + product(handle: $handle) { + variants(first: 250) { + nodes { + ...ProductVariantFragment + } + } + } + } + ${PRODUCT_VARIANT_FRAGMENT} +` as const; + const RECOMMENDED_PRODUCTS_QUERY = `#graphql query productRecommendations( $productId: ID! diff --git a/templates/demo-store/storefrontapi.generated.d.ts b/templates/demo-store/storefrontapi.generated.d.ts index 0437d7ccbf..945398d5dc 100644 --- a/templates/demo-store/storefrontapi.generated.d.ts +++ b/templates/demo-store/storefrontapi.generated.d.ts @@ -1443,6 +1443,43 @@ export type ProductQuery = { }; }; +export type VariantsQueryVariables = StorefrontAPI.Exact<{ + country?: StorefrontAPI.InputMaybe; + language?: StorefrontAPI.InputMaybe; + handle: StorefrontAPI.Scalars['String']; +}>; + +export type VariantsQuery = { + product?: StorefrontAPI.Maybe<{ + variants: { + nodes: Array< + Pick< + StorefrontAPI.ProductVariant, + 'id' | 'availableForSale' | 'sku' | 'title' + > & { + selectedOptions: Array< + Pick + >; + image?: StorefrontAPI.Maybe< + Pick< + StorefrontAPI.Image, + 'id' | 'url' | 'altText' | 'width' | 'height' + > + >; + price: Pick; + compareAtPrice?: StorefrontAPI.Maybe< + Pick + >; + unitPrice?: StorefrontAPI.Maybe< + Pick + >; + product: Pick; + } + >; + }; + }>; +}; + export type ProductRecommendationsQueryVariables = StorefrontAPI.Exact<{ productId: StorefrontAPI.Scalars['ID']; count?: StorefrontAPI.InputMaybe;