'use client'; import clsx from 'clsx'; import { Money, ProductOption, ProductVariant } from 'lib/shopify/types'; import { createUrl } from 'lib/utils'; import Link from 'next/link'; import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { useEffect } from 'react'; type ParamsMap = { [key: string]: string; // ie. { color: 'Red', size: 'Large', ... } }; type OptimizedVariant = { id: string; availableForSale: boolean; params: URLSearchParams; price: Money; [key: string]: string | boolean | URLSearchParams | Money; // ie. { color: 'Red', size: 'Large', ... } }; export function VariantSelector({ options, variants, setSelectedVariant, }: { options: ProductOption[]; variants: ProductVariant[]; setSelectedVariant: (value: OptimizedVariant | undefined) => void, }) { const pathname = usePathname(); const currentParams = useSearchParams(); const router = useRouter(); const hasNoOptionsOrJustOneOption = !options.length || (options.length === 1 && options[0]?.values.length === 1); // Discard any unexpected options or values from url and create params map. const paramsMap: ParamsMap = Object.fromEntries( Array.from(currentParams.entries()).filter(([key, value]) => options.find((option) => option.name.toLowerCase() === key && option.values.includes(value)) ) ); // Optimize variants for easier lookups. const optimizedVariants: OptimizedVariant[] = variants.map((variant) => { const optimized: OptimizedVariant = { id: variant.id, availableForSale: variant.availableForSale, params: new URLSearchParams(), price: variant.price, }; variant.selectedOptions.forEach((selectedOption) => { const name = selectedOption.name.toLowerCase(); const value = selectedOption.value; optimized[name] = value; optimized.params.set(name, value); }); return optimized; }); // Find the first variant that is: // // 1. Available for sale // 2. Matches all options specified in the url (note that this // could be a partial match if some options are missing from the url). // // If no match (full or partial) is found, use the first variant that is // available for sale. const selectedVariant: OptimizedVariant | undefined = optimizedVariants.find( (variant) => variant.availableForSale && Object.entries(paramsMap).every(([key, value]) => variant[key] === value) ) || optimizedVariants.find((variant) => variant.availableForSale); const selectedVariantParams = new URLSearchParams(selectedVariant?.params); const currentUrl = createUrl(pathname, currentParams); const selectedVariantUrl = createUrl(pathname, selectedVariantParams); useEffect(() => { setSelectedVariant(selectedVariant); }, [selectedVariantUrl]); if (hasNoOptionsOrJustOneOption) { return null; } if (currentUrl !== selectedVariantUrl) { router.replace(selectedVariantUrl); } return options.map((option) => ( <dl className="mb-8" key={option.id}> <dt className="mb-4 text-sm uppercase tracking-wide">{option.name}</dt> <dd className="flex flex-wrap gap-3"> {option.values.map((value) => { // Base option params on selected variant params. const optionParams = new URLSearchParams(selectedVariantParams); // Update the params using the current option to reflect how the url would change. optionParams.set(option.name.toLowerCase(), value); const optionUrl = createUrl(pathname, optionParams); // The option is active if it in the url params. const isActive = selectedVariantParams.get(option.name.toLowerCase()) === value; // The option is available for sale if it fully matches the variant in the option's url params. // It's super important to note that this is the options params, *not* the selected variant's params. // This is the "magic" that will cross check possible future variant combinations and preemptively // disable combinations that are not possible. const isAvailableForSale = optimizedVariants.find((a) => Array.from(optionParams.entries()).every(([key, value]) => a[key] === value) )?.availableForSale; const DynamicTag = isAvailableForSale ? Link : 'p'; const dynamicProps = { ...(isAvailableForSale && { scroll: false }) }; return ( <DynamicTag key={value} aria-disabled={!isAvailableForSale} href={optionUrl} title={`${option.name} ${value}${!isAvailableForSale ? ' (Out of Stock)' : ''}`} className={clsx( 'flex min-w-[48px] items-center justify-center rounded-full border bg-neutral-100 px-2 py-1 text-sm dark:border-neutral-800 dark:bg-neutral-900', { 'cursor-default ring-2 ring-gray-600': isActive, 'ring-1 ring-transparent transition duration-300 ease-in-out hover:scale-110 hover:ring-gray-600 ': !isActive && isAvailableForSale, 'relative z-10 cursor-not-allowed overflow-hidden bg-neutral-100 text-neutral-500 ring-1 ring-neutral-300 before:absolute before:inset-x-0 before:-z-10 before:h-px before:-rotate-45 before:bg-neutral-300 before:transition-transform dark:bg-neutral-900 dark:text-neutral-400 dark:ring-neutral-700 before:dark:bg-neutral-700': !isAvailableForSale } )} {...dynamicProps} > {value} </DynamicTag> ); })} </dd> </dl> )); }