diff --git a/packages/hydrogen-react/.eslintrc.cjs b/packages/hydrogen-react/.eslintrc.cjs index a40726e04..a3a13696a 100644 --- a/packages/hydrogen-react/.eslintrc.cjs +++ b/packages/hydrogen-react/.eslintrc.cjs @@ -12,6 +12,7 @@ module.exports = { plugins: ['eslint-plugin-tsdoc'], ignorePatterns: [ '**/storefront-api-types.d.ts', + '**/customer-account-api-types.d.ts', '**/codegen.ts', '**/dist/**', '**/coverage/**', diff --git a/packages/hydrogen-react/src/getProductOptions.test.ts b/packages/hydrogen-react/src/getProductOptions.test.ts index 53fc13348..33968a97f 100644 --- a/packages/hydrogen-react/src/getProductOptions.test.ts +++ b/packages/hydrogen-react/src/getProductOptions.test.ts @@ -279,6 +279,7 @@ describe('getAdjacentAndFirstAvailableVariants', () => { describe('checkProductParam', () => { beforeEach(() => { + // eslint-disable-next-line @typescript-eslint/no-empty-function vi.spyOn(console, 'error').mockImplementation(() => {}); }); diff --git a/packages/hydrogen-react/src/getProductOptions.ts b/packages/hydrogen-react/src/getProductOptions.ts index bf5044449..316f253ea 100644 --- a/packages/hydrogen-react/src/getProductOptions.ts +++ b/packages/hydrogen-react/src/getProductOptions.ts @@ -1,17 +1,11 @@ -import {validate} from 'graphql'; -import { - decodeEncodedVariant, - isOptionValueCombinationInEncodedVariant, -} from './optionValueDecoder'; +import {isOptionValueCombinationInEncodedVariant} from './optionValueDecoder.js'; import type { - Maybe, Product, ProductOption, ProductOptionValue, ProductVariant, SelectedOption, } from './storefront-api-types'; -import {PartialDeep} from 'type-fest'; export type RecursivePartial = { [P in keyof T]?: RecursivePartial; @@ -32,72 +26,70 @@ type MappedProductOptionValue = ProductOptionValue & ProductOptionValueState; * Creates a mapping of product options to their index for matching encoded values * For example, a product option of * [ - * { + * \{ * name: 'Color', - * optionValues: [{name: 'Red'}, {name: 'Blue'}] - * }, - * { + * optionValues: [\{name: 'Red'\}, \{name: 'Blue'\}] + * \}, + * \{ * name: 'Size', - * optionValues: [{name: 'Small'}, {name: 'Medium'}, {name: 'Large'}] - * } + * optionValues: [\{name: 'Small'\}, \{name: 'Medium'\}, \{name: 'Large'\}] + * \} * ] * Would return * [ - * {Red: 0, Blue: 1}, - * {Small: 0, Medium: 1, Large: 2} + * \{Red: 0, Blue: 1\}, + * \{Small: 0, Medium: 1, Large: 2\} * ] - * @param options - * @returns */ function mapProductOptions(options: ProductOption[]): ProductOptionsMapping[] { - return options.map((option) => { + return options.map((option: ProductOption) => { return Object.assign( {}, - ...option?.optionValues.map((value, index) => { - return {[value.name]: index}; - }), - ); + ...(option?.optionValues + ? option.optionValues.map((value, index) => { + return {[value.name]: index}; + }) + : []), + ) as ProductOptionsMapping; }); } /** - * Converts the product option into an Object for building query params + * Converts the product option into an Object\ for building query params * For example, a selected product option of * [ - * { + * \{ * name: 'Color', * value: 'Red', - * }, - * { + * \}, + * \{ * name: 'Size', * value: 'Medium', - * } + * \} * ] * Would return - * { + * \{ * Color: 'Red', * Size: 'Medium', - * } + * \} */ -function mapSelectedProductOptionToObject( +export function mapSelectedProductOptionToObject( options: Pick[], -) { +): Record { return Object.assign( {}, ...options.map((key) => { return {[key.name]: key.value}; }), - ); + ) as Record; } /** - * - * @param options Returns selected options as a JSON string - * @returns + * Returns the JSON stringify result of mapSelectedProductOptionToObject */ function mapSelectedProductOptionToObjectAsString( options: Pick[], -) { +): string { return JSON.stringify(mapSelectedProductOptionToObject(options)); } @@ -105,23 +97,23 @@ function mapSelectedProductOptionToObjectAsString( * Encode the selected product option as a key for mapping to the encoded variants * For example, a selected product option of * [ - * { + * \{ * name: 'Color', * value: 'Red', - * }, - * { + * \}, + * \{ * name: 'Size', * value: 'Medium', - * } + * \} * ] * Would return * [0,1] * * Also works with the result of mapSelectedProductOption. For example: - * { + * \{ * Color: 'Red', * Size: 'Medium', - * } + * \} * Would return * [0,1] * @@ -134,7 +126,7 @@ function encodeSelectedProductOptionAsKey( | Pick[] | Record, productOptionMappings: ProductOptionsMapping[], -) { +): string { if (Array.isArray(selectedOption)) { return JSON.stringify( selectedOption.map((key, index) => { @@ -154,34 +146,31 @@ function encodeSelectedProductOptionAsKey( * Takes an array of product variants and maps them to an object with the encoded selected option values as the key. * For example, a product variant of * [ - * { + * \{ * id: 1, * selectedOptions: [ - * {name: 'Color', value: 'Red'}, - * {name: 'Size', value: 'Small'}, + * \{name: 'Color', value: 'Red'\}, + * \{name: 'Size', value: 'Small'\}, * ], - * }, - * { + * \}, + * \{ * id: 2, * selectedOptions: [ - * {name: 'Color', value: 'Red'}, - * {name: 'Size', value: 'Medium'}, + * \{name: 'Color', value: 'Red'\}, + * \{name: 'Size', value: 'Medium'\}, * ], - * } + * \} * ] * Would return - * { - * '[0,0]': {id: 1, selectedOptions: [{name: 'Color', value: 'Red'}, {name: 'Size', value: 'Small'}]}, - * '[0,1]': {id: 2, selectedOptions: [{name: 'Color', value: 'Red'}, {name: 'Size', value: 'Medium'}]}, - * } - * @param variants - * @param productOptionMappings - * @returns + * \{ + * '[0,0]': \{id: 1, selectedOptions: [\{name: 'Color', value: 'Red'\}, \{name: 'Size', value: 'Small'\}]\}, + * '[0,1]': \{id: 2, selectedOptions: [\{name: 'Color', value: 'Red'\}, \{name: 'Size', value: 'Medium'\}]\}, + * \} */ function mapVariants( variants: ProductVariant[], productOptionMappings: ProductOptionsMapping[], -) { +): Record { return Object.assign( {}, ...variants.map((variant) => { @@ -191,7 +180,7 @@ function mapVariants( ); return {[variantKey]: variant}; }), - ); + ) as Record; } export type MappedProductOptions = Omit & { @@ -210,7 +199,7 @@ const PRODUCT_INPUTS_EXTRA = [ 'encodedVariantAvailability', ]; -function logError(key: string) { +function logError(key: string): boolean { console.error( `[h2:error:getProductOptions] product.${key} is missing. Make sure you query for this field from the Storefront API.`, ); @@ -314,12 +303,10 @@ function checkProductVariantParam( /** * Finds all the variants provided by adjacentVariants, options.optionValues.firstAvailableVariant, * and selectedOrFirstAvailableVariant and return them in a single array - * @param product - * @returns */ export function getAdjacentAndFirstAvailableVariants( product: RecursivePartial, -) { +): ProductVariant[] { // Checks for valid product input const checkedProduct = checkProductParam(product); @@ -355,6 +342,10 @@ export function getAdjacentAndFirstAvailableVariants( return Object.values(availableVariants); } +/** + * Returns a product options array with its relevant information + * about the variant + */ export function getProductOptions( product: RecursivePartial, ): MappedProductOptions[] { @@ -401,7 +392,10 @@ export function getProductOptions( ); // Top-down option check for existence and availability - const topDownKey = JSON.parse(targetKey).slice(0, optionIndex + 1); + const topDownKey = (JSON.parse(targetKey) as number[]).slice( + 0, + optionIndex + 1, + ); const exists = isOptionValueCombinationInEncodedVariant( topDownKey, encodedVariantExistence || '', @@ -436,40 +430,5 @@ export function getProductOptions( }; }); - // Workaround for bug in encodedVariantAvailability - // Remove once https://github.com/Shopify/combined-listings-app/issues/2377 is resolved - const optionsAvailable: boolean[] = []; - productOptions.reverse().map((option, optionIndex) => { - let optionAvailable = false; - if (optionIndex === 0) { - // Make sure leaf option values always reflect the variant's availableForSale attribute - option.optionValues.map((value, valueIndex) => { - if (!value.isDifferentProduct) { - const available = value.variant.availableForSale; - productOptions[optionIndex].optionValues[valueIndex].available = - available; - if (available) optionAvailable = true; - } - }); - } else { - // If not the last option, for selected optionValue, check for previous option for availability - const selectedValue = option.optionValues.filter( - (value) => value.selected, - ); - const previousOptionAvailable = optionsAvailable[optionIndex - 1]; - if ( - selectedValue && - selectedValue.length === 1 && - !selectedValue[0].isDifferentProduct - ) { - selectedValue[0].available = previousOptionAvailable; - if (previousOptionAvailable) optionAvailable = true; - } - } - - // Keep track of availability of the current option - optionsAvailable.push(optionAvailable); - }); - - return productOptions.reverse(); + return productOptions; } diff --git a/packages/hydrogen-react/src/index.ts b/packages/hydrogen-react/src/index.ts index fba18eaa3..4901d4248 100644 --- a/packages/hydrogen-react/src/index.ts +++ b/packages/hydrogen-react/src/index.ts @@ -49,6 +49,7 @@ export { getAdjacentAndFirstAvailableVariants, getProductOptions, type MappedProductOptions, + mapSelectedProductOptionToObject, } from './getProductOptions.js'; export {Image, IMAGE_FRAGMENT} from './Image.js'; export {useLoadScript} from './load-script.js'; diff --git a/packages/hydrogen/src/index.ts b/packages/hydrogen/src/index.ts index cf9cd2434..30cbd430b 100644 --- a/packages/hydrogen/src/index.ts +++ b/packages/hydrogen/src/index.ts @@ -144,6 +144,7 @@ export { decodeEncodedVariant, getProductOptions, getAdjacentAndFirstAvailableVariants, + mapSelectedProductOptionToObject, } from '@shopify/hydrogen-react'; export {RichText} from './RichText'; diff --git a/packages/hydrogen/src/product/useOptimisticVariant.ts b/packages/hydrogen/src/product/useOptimisticVariant.ts index 48e578f63..b7c88dc85 100644 --- a/packages/hydrogen/src/product/useOptimisticVariant.ts +++ b/packages/hydrogen/src/product/useOptimisticVariant.ts @@ -30,36 +30,31 @@ export function useOptimisticVariant< const navigation = useNavigation(); const [resolvedVariants, setResolvedVariants] = useState< Array> - >( - variants instanceof Array - ? variants - : (variants as PartialDeep).product?.variants?.nodes || - [], - ); + >([]); - // useEffect(() => { - // Promise.resolve(variants) - // .then((productWithVariants) => { - // if (productWithVariants) { - // setResolvedVariants( - // productWithVariants instanceof Array - // ? productWithVariants - // : (productWithVariants as PartialDeep).product - // ?.variants?.nodes || [], - // ); - // } - // }) - // .catch((error) => { - // reportError( - // new Error( - // '[h2:error:useOptimisticVariant] An error occurred while resolving the variants for the optimistic product hook.', - // { - // cause: error, - // }, - // ), - // ); - // }); - // }, [variants]); + useEffect(() => { + Promise.resolve(variants) + .then((productWithVariants) => { + if (productWithVariants) { + setResolvedVariants( + productWithVariants instanceof Array + ? productWithVariants + : (productWithVariants as PartialDeep).product + ?.variants?.nodes || [], + ); + } + }) + .catch((error) => { + reportError( + new Error( + '[h2:error:useOptimisticVariant] An error occurred while resolving the variants for the optimistic product hook.', + { + cause: error, + }, + ), + ); + }); + }, [JSON.stringify(variants)]); if (navigation.state === 'loading') { const queryParams = new URLSearchParams(navigation.location.search); @@ -84,8 +79,6 @@ export function useOptimisticVariant< }); }); - console.log({matchingVariant, queryParams: queryParams.toString()}); - if (matchingVariant) { return { ...matchingVariant, diff --git a/templates/skeleton/app/components/ProductFormV2.tsx b/templates/skeleton/app/components/ProductFormV2.tsx index e46114fbb..53217715f 100644 --- a/templates/skeleton/app/components/ProductFormV2.tsx +++ b/templates/skeleton/app/components/ProductFormV2.tsx @@ -1,11 +1,22 @@ import {Link, useNavigate} from '@remix-run/react'; -import {MappedProductOptions} from "@shopify/hydrogen"; -import {Maybe, ProductOptionValueSwatch} from '@shopify/hydrogen/storefront-api-types'; +import {type MappedProductOptions} from '@shopify/hydrogen'; +import type { + Maybe, + ProductOptionValueSwatch, +} from '@shopify/hydrogen/storefront-api-types'; +import {AddToCartButton} from './AddToCartButton'; +import {useAside} from './Aside'; +import type {ProductFragment} from 'storefrontapi.generated'; -export function ProductFormV2({productOptions}: { - productOptions: MappedProductOptions[] +export function ProductFormV2({ + productOptions, + selectedVariant, +}: { + productOptions: MappedProductOptions[]; + selectedVariant: ProductFragment['selectedOrFirstAvailableVariant']; }) { const navigate = useNavigate(); + const {open} = useAside(); return ( <> {productOptions.map((option) => ( @@ -19,6 +30,7 @@ export function ProductFormV2({productOptions}: { variantUriQuery, selected, available, + exists, isDifferentProduct, swatch, } = value; @@ -33,7 +45,9 @@ export function ProductFormV2({productOptions}: { replace to={`/products/${handle}?${variantUriQuery}`} style={{ - border: selected ? '1px solid black' : '1px solid transparent', + border: selected + ? '1px solid black' + : '1px solid transparent', opacity: available ? 1 : 0.3, }} > @@ -44,12 +58,17 @@ export function ProductFormV2({productOptions}: { return (