diff --git a/components/cart/add-to-cart.tsx b/components/cart/add-to-cart.tsx index b0cd68908..4f7cb31b3 100644 --- a/components/cart/add-to-cart.tsx +++ b/components/cart/add-to-cart.tsx @@ -3,8 +3,8 @@ import { PlusIcon } from '@heroicons/react/24/outline'; import clsx from 'clsx'; import { addItem } from 'components/cart/actions'; +import { useProductOptions } from 'components/product/product-context'; import { Product, ProductVariant } from 'lib/shopify/types'; -import { useSearchParams } from 'next/navigation'; import { useFormState } from 'react-dom'; import { useCart } from './cart-context'; @@ -60,14 +60,15 @@ function SubmitButton({ export function AddToCart({ product }: { product: Product }) { const { variants, availableForSale } = product; const { addCartItem } = useCart(); + const { options: selectedOptions } = useProductOptions(); const [message, formAction] = useFormState(addItem, null); - const searchParams = useSearchParams(); - const defaultVariantId = variants.length === 1 ? variants[0]?.id : undefined; + const variant = variants.find((variant: ProductVariant) => variant.selectedOptions.every( - (option) => option.value === searchParams.get(option.name.toLowerCase()) + (option) => option.value === selectedOptions[option.name.toLowerCase()] ) ); + const defaultVariantId = variants.length === 1 ? variants[0]?.id : undefined; const selectedVariantId = variant?.id || defaultVariantId; const actionWithVariant = formAction.bind(null, selectedVariantId); const finalVariant = variants.find((variant) => variant.id === selectedVariantId)!; diff --git a/components/product/product-context.tsx b/components/product/product-context.tsx new file mode 100644 index 000000000..6809822af --- /dev/null +++ b/components/product/product-context.tsx @@ -0,0 +1,73 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import React, { createContext, useContext, useMemo, useOptimistic } from 'react'; + +type ProductOptionsState = { + [key: string]: string; +}; + +type ProductOptionsAction = { type: 'UPDATE_OPTION'; payload: { name: string; value: string } }; + +type ProductOptionsContextType = { + options: ProductOptionsState; + updateOption: (name: string, value: string) => void; +}; + +const ProductOptionsContext = createContext(undefined); + +function productOptionsReducer( + state: ProductOptionsState, + action: ProductOptionsAction +): ProductOptionsState { + switch (action.type) { + case 'UPDATE_OPTION': { + return { + ...state, + [action.payload.name]: action.payload.value + }; + } + default: + return state; + } +} + +export function ProductOptionsProvider({ children }: { children: React.ReactNode }) { + const router = useRouter(); + const searchParams = useSearchParams(); + + const getInitialOptions = () => { + const params: ProductOptionsState = {}; + for (const [key, value] of searchParams.entries()) { + params[key] = value; + } + return params; + }; + + const [options, updateOptions] = useOptimistic(getInitialOptions(), productOptionsReducer); + + const updateOption = (name: string, value: string) => { + updateOptions({ type: 'UPDATE_OPTION', payload: { name, value } }); + const newParams = new URLSearchParams(window.location.search); + newParams.set(name, value); + router.push(`?${newParams.toString()}`, { scroll: false }); + }; + + const value = useMemo( + () => ({ + options, + updateOption + }), + [options] + ); + + return {children}; +} + +export function useProductOptions() { + const context = useContext(ProductOptionsContext); + if (context === undefined) { + throw new Error('useProductOptions must be used within a ProductOptionsProvider'); + } + return context; +} diff --git a/components/product/product-description.tsx b/components/product/product-description.tsx index 5cf6cdf54..b2e3dbfea 100644 --- a/components/product/product-description.tsx +++ b/components/product/product-description.tsx @@ -1,5 +1,6 @@ import { AddToCart } from 'components/cart/add-to-cart'; import Price from 'components/price'; +import { ProductOptionsProvider } from 'components/product/product-context'; import Prose from 'components/prose'; import { Product } from 'lib/shopify/types'; import { Suspense } from 'react'; @@ -7,30 +8,26 @@ import { VariantSelector } from './variant-selector'; export function ProductDescription({ product }: { product: Product }) { return ( - <> -
-

{product.title}

-
- + + +
+

{product.title}

+
+ +
-
- - - - {product.descriptionHtml ? ( - - ) : null} - - + {product.descriptionHtml ? ( + + ) : null} - - + + ); } diff --git a/components/product/variant-selector.tsx b/components/product/variant-selector.tsx index 9d47eb5c8..a36ca7cba 100644 --- a/components/product/variant-selector.tsx +++ b/components/product/variant-selector.tsx @@ -1,14 +1,13 @@ 'use client'; import clsx from 'clsx'; +import { useProductOptions } from 'components/product/product-context'; import { ProductOption, ProductVariant } from 'lib/shopify/types'; -import { createUrl } from 'lib/utils'; -import { usePathname, useRouter, useSearchParams } from 'next/navigation'; type Combination = { id: string; availableForSale: boolean; - [key: string]: string | boolean; // ie. { color: 'Red', size: 'Large', ... } + [key: string]: string | boolean; }; export function VariantSelector({ @@ -18,9 +17,7 @@ export function VariantSelector({ options: ProductOption[]; variants: ProductVariant[]; }) { - const router = useRouter(); - const pathname = usePathname(); - const searchParams = useSearchParams(); + const { options: selectedOptions, updateOption } = useProductOptions(); const hasNoOptionsOrJustOneOption = !options.length || (options.length === 1 && options[0]?.values.length === 1); @@ -31,7 +28,6 @@ export function VariantSelector({ const combinations: Combination[] = variants.map((variant) => ({ id: variant.id, availableForSale: variant.availableForSale, - // Adds key / value pairs for each variant (ie. "color": "Black" and "size": 'M"). ...variant.selectedOptions.reduce( (accumulator, option) => ({ ...accumulator, [option.name.toLowerCase()]: option.value }), {} @@ -45,24 +41,11 @@ export function VariantSelector({ {option.values.map((value) => { const optionNameLowerCase = option.name.toLowerCase(); - // Base option params on current params so we can preserve any other param state in the url. - const optionSearchParams = new URLSearchParams(searchParams.toString()); + // Base option params on current selectedOptions so we can preserve any other param state. + const optionParams = { ...selectedOptions, [optionNameLowerCase]: value }; - // Update the option params using the current option to reflect how the url *would* change, - // if the option was clicked. - optionSearchParams.set(optionNameLowerCase, value); - const optionUrl = createUrl(pathname, optionSearchParams); - - // In order to determine if an option is available for sale, we need to: - // - // 1. Filter out all other param state - // 2. Filter out invalid options - // 3. Check if the option combination is available for sale - // - // This is the "magic" that will cross check possible variant combinations and preemptively - // disable combinations that are not available. For example, if the color gray is only available in size medium, - // then all other sizes should be disabled. - const filtered = Array.from(optionSearchParams.entries()).filter(([key, value]) => + // Filter out invalid options and check if the option combination is available for sale. + const filtered = Object.entries(optionParams).filter(([key, value]) => options.find( (option) => option.name.toLowerCase() === key && option.values.includes(value) ) @@ -73,23 +56,21 @@ export function VariantSelector({ ) ); - // The option is active if it's in the url params. - const isActive = searchParams.get(optionNameLowerCase) === value; + // The option is active if it's in the selected options. + const isActive = selectedOptions[optionNameLowerCase] === value; return (