diff --git a/components/cart/add-to-cart.tsx b/components/cart/add-to-cart.tsx index b866b1714..9a0b92790 100644 --- a/components/cart/add-to-cart.tsx +++ b/components/cart/add-to-cart.tsx @@ -4,7 +4,7 @@ import { ShoppingCartIcon } from '@heroicons/react/24/outline'; import clsx from 'clsx'; import { addItem } from 'components/cart/actions'; import LoadingDots from 'components/loading-dots'; -import { CORE_VARIANT_ID_KEY } from 'lib/constants'; +import { CORE_VARIANT_ID_KEY, CORE_WAIVER } from 'lib/constants'; import { ProductVariant } from 'lib/shopify/types'; import { useSearchParams } from 'next/navigation'; import { useFormState, useFormStatus } from 'react-dom'; @@ -69,18 +69,20 @@ export function AddToCart({ }) { 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()) ) ); - const selectedVariantId = variant?.id || defaultVariantId; + const selectedVariantId = variant?.id; const missingCoreVariantId = variant?.coreVariantId && !searchParams.has(CORE_VARIANT_ID_KEY); const coreVariantId = searchParams.get(CORE_VARIANT_ID_KEY); - const selectedVariantIds = [coreVariantId, selectedVariantId].filter(Boolean) as string[]; + // remove special core-waiver value as it is not a valid variant + const selectedVariantIds = [coreVariantId, selectedVariantId] + .filter(Boolean) + .filter((value) => value !== CORE_WAIVER) as string[]; const actionWithVariant = formAction.bind(null, selectedVariantIds); diff --git a/components/product/core-charge.tsx b/components/product/core-charge.tsx index 74147ea0c..7b16f08cc 100644 --- a/components/product/core-charge.tsx +++ b/components/product/core-charge.tsx @@ -2,15 +2,14 @@ import Price from 'components/price'; import { CORE_VARIANT_ID_KEY, CORE_WAIVER } from 'lib/constants'; -import { Money, ProductVariant } from 'lib/shopify/types'; +import { CoreChargeOption, ProductVariant } from 'lib/shopify/types'; import { cn, createUrl } from 'lib/utils'; import { usePathname, useRouter, useSearchParams } from 'next/navigation'; type CoreChargeProps = { variants: ProductVariant[]; - defaultPrice: Money; }; -const CoreCharge = ({ variants, defaultPrice }: CoreChargeProps) => { +const CoreCharge = ({ variants }: CoreChargeProps) => { const searchParams = useSearchParams(); const pathname = usePathname(); const router = useRouter(); @@ -24,35 +23,32 @@ const CoreCharge = ({ variants, defaultPrice }: CoreChargeProps) => { ) ); - const { coreCharge, waiverAvailable } = variant ?? {}; + const { coreCharge, waiverAvailable, coreVariantId } = variant ?? {}; - const handleSelectCoreChargeOption = (action: 'add' | 'remove') => { - if (action === 'add' && variant?.coreVariantId) { - optionSearchParams.set(CORE_VARIANT_ID_KEY, variant.coreVariantId); - } else if (action === 'remove') { - optionSearchParams.set(CORE_VARIANT_ID_KEY, CORE_WAIVER); - } + const handleSelectCoreChargeOption = (coreVariantId: string) => { + optionSearchParams.set(CORE_VARIANT_ID_KEY, coreVariantId); const newUrl = createUrl(pathname, optionSearchParams); router.replace(newUrl, { scroll: false }); }; - // if the selected variant has changed, and the core change variant id is not the same as the selected variant id - // or if users have selected the core waiver but the selected variant does not have a waiver available - // we remove the core charge from the url - if ( - variant?.coreVariantId && - optionSearchParams.has(CORE_VARIANT_ID_KEY) && - (coreVariantIdSearchParam !== CORE_WAIVER || !variant.waiverAvailable) && - coreVariantIdSearchParam !== variant.coreVariantId - ) { - optionSearchParams.delete(CORE_VARIANT_ID_KEY); - const newUrl = createUrl(pathname, optionSearchParams); - router.replace(newUrl, { scroll: false }); - } + const coreChargeOptions = [ + waiverAvailable && { + label: 'Core Waiver', + value: CORE_WAIVER, + price: { amount: 0, currencyCode: variant?.price.currencyCode } + }, + coreVariantId && + coreCharge && { + label: 'Core Charge', + value: coreVariantId, + price: coreCharge + } + ].filter(Boolean) as CoreChargeOption[]; - const selectedPayCoreCharge = coreVariantIdSearchParam === variant?.coreVariantId; - const selectedCoreWaiver = coreVariantIdSearchParam === CORE_WAIVER; + if (!optionSearchParams.has(CORE_VARIANT_ID_KEY) && coreChargeOptions.length > 0) { + handleSelectCoreChargeOption((coreChargeOptions[0] as CoreChargeOption).value); + } return (
@@ -63,38 +59,22 @@ const CoreCharge = ({ variants, defaultPrice }: CoreChargeProps) => { recycling. When you return the old part, you'll receive a refund of the core charge.

); diff --git a/components/product/product-description.tsx b/components/product/product-description.tsx index a5318563b..d58bded3b 100644 --- a/components/product/product-description.tsx +++ b/components/product/product-description.tsx @@ -19,7 +19,11 @@ export function ProductDescription({ product }: { product: Product }) { /> - + {product.descriptionHtml ? ( @@ -30,11 +34,11 @@ export function ProductDescription({ product }: { product: Product }) { ) : null}
- +
- +
diff --git a/components/product/variant-selector.tsx b/components/product/variant-selector.tsx index c9c48bad6..a50d42d8b 100644 --- a/components/product/variant-selector.tsx +++ b/components/product/variant-selector.tsx @@ -1,9 +1,14 @@ 'use client'; +import { Dialog, Transition } from '@headlessui/react'; +import { XMarkIcon } from '@heroicons/react/24/outline'; import clsx from 'clsx'; -import { ProductOption, ProductVariant } from 'lib/shopify/types'; +import Price from 'components/price'; +import { CORE_VARIANT_ID_KEY, CORE_WAIVER } from 'lib/constants'; +import { CoreChargeOption, Money, ProductOption, ProductVariant } from 'lib/shopify/types'; import { createUrl } from 'lib/utils'; import { usePathname, useRouter, useSearchParams } from 'next/navigation'; +import { Fragment, MouseEvent, useEffect, useState } from 'react'; type Combination = { id: string; @@ -13,20 +18,17 @@ type Combination = { export function VariantSelector({ options, - variants + variants, + minPrice }: { options: ProductOption[]; variants: ProductVariant[]; + minPrice: Money; }) { const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); - const hasNoOptionsOrJustOneOption = - !options.length || (options.length === 1 && options[0]?.values.length === 1); - - if (hasNoOptionsOrJustOneOption) { - return null; - } + const [isOpen, setIsOpen] = useState(false); const combinations: Combination[] = variants.map((variant) => ({ id: variant.id, @@ -38,70 +40,231 @@ export function VariantSelector({ ) })); - return options.map((option) => ( -
-
{option.name}
-
- {option.values.map((value) => { - const optionNameLowerCase = option.name.toLowerCase(); + const variantsById: Record = variants.reduce((acc, variant) => { + return { ...acc, [variant.id]: variant }; + }, {}); - // Base option params on current params so we can preserve any other param state in the url. - const optionSearchParams = new URLSearchParams(searchParams.toString()); + // If a variant is not selected, we want to select the first available for sale variant as default + useEffect(() => { + const hasSelectedVariant = Array.from(searchParams.entries()).some(([key, value]) => { + return combinations.some((combination) => combination[key] === 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); + if (!hasSelectedVariant) { + const defaultVariant = variants.find((variant) => variant.availableForSale); + if (defaultVariant) { + const optionSearchParams = new URLSearchParams(searchParams.toString()); + defaultVariant.selectedOptions.forEach((option) => { + optionSearchParams.set(option.name.toLowerCase(), option.value); + }); + const defaultUrl = createUrl(pathname, optionSearchParams); + router.replace(defaultUrl, { scroll: false }); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); - const optionUrl = createUrl(pathname, optionSearchParams); + const hasNoOptionsOrJustOneOption = + !options.length || (options.length === 1 && options[0]?.values.length === 1); - // 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]) => - options.find( - (option) => option.name.toLowerCase() === key && option.values.includes(value) - ) - ); - const isAvailableForSale = combinations.find((combination) => - filtered.every( - ([key, value]) => combination[key] === value && combination.availableForSale - ) - ); + if (hasNoOptionsOrJustOneOption) { + return null; + } - // The option is active if it's in the url params. - const isActive = searchParams.get(optionNameLowerCase) === value; + const openModal = () => setIsOpen(true); + const closeModal = () => setIsOpen(false); - return ( - - ); - })} -
-
- )); + const handleCoreChargeClick = ( + event: MouseEvent, + coreVariantId: string | null + ) => { + event.stopPropagation(); + if (!coreVariantId) return; + const optionSearchParams = new URLSearchParams(searchParams.toString()); + optionSearchParams.set(CORE_VARIANT_ID_KEY, coreVariantId); + const newUrl = createUrl(pathname, optionSearchParams); + router.replace(newUrl, { scroll: false }); + }; + + return ( +
+ More Remanufactured and Used Options{' '} + + + + + + + + + +
+ ); } diff --git a/components/product/warranty.tsx b/components/product/warranty.tsx index 83b8f7ec8..514f432e5 100644 --- a/components/product/warranty.tsx +++ b/components/product/warranty.tsx @@ -2,16 +2,12 @@ import { ShieldCheckIcon } from '@heroicons/react/24/outline'; import Link from 'next/link'; import WarrantySelector from './warranty-selector'; -type WarrantyProps = { - productType: string | null; -}; - -const Warranty = ({ productType }: WarrantyProps) => { +const Warranty = () => { return (
- Protect your {productType ?? 'product'} + Protect your product
Extended Warranty diff --git a/lib/shopify/fragments/product.ts b/lib/shopify/fragments/product.ts index 4e8e7c3b8..1680db310 100644 --- a/lib/shopify/fragments/product.ts +++ b/lib/shopify/fragments/product.ts @@ -73,9 +73,6 @@ const productFragment = /* GraphQL */ ` } tags updatedAt - productType: metafield(namespace: "custom", key: "product_type") { - value - } } ${imageFragment} ${seoFragment} diff --git a/lib/shopify/index.ts b/lib/shopify/index.ts index 87dfd0d55..1f2fa79cf 100644 --- a/lib/shopify/index.ts +++ b/lib/shopify/index.ts @@ -194,12 +194,11 @@ const reshapeProduct = (product: ShopifyProduct, filterHiddenProducts: boolean = return undefined; } - const { images, variants, productType, ...rest } = product; + const { images, variants, ...rest } = product; return { ...rest, images: reshapeImages(images, product.title), - variants: reshapeVariants(removeEdgesAndNodes(variants)), - productType: productType?.value ?? null + variants: reshapeVariants(removeEdgesAndNodes(variants)) }; }; diff --git a/lib/shopify/types.ts b/lib/shopify/types.ts index 4fd5b993b..3d54660cd 100644 --- a/lib/shopify/types.ts +++ b/lib/shopify/types.ts @@ -62,10 +62,9 @@ export type Page = { updatedAt: string; }; -export type Product = Omit & { +export type Product = Omit & { variants: ProductVariant[]; images: Image[]; - productType: string | null; }; export type ProductOption = { @@ -148,9 +147,6 @@ export type ShopifyProduct = { handle: string; }[]; }; - productType: { - value: string; - } | null; }; export type ShopifyCartOperation = { @@ -288,3 +284,9 @@ export type ShopifyProductsOperation = { sortKey?: string; }; }; + +export type CoreChargeOption = { + label: string; + value: string; + price: Money; +};