mirror of
https://github.com/vercel/commerce.git
synced 2025-05-12 12:47:50 +00:00
264 lines
12 KiB
TypeScript
264 lines
12 KiB
TypeScript
'use client';
|
|
|
|
import { Dialog, DialogPanel, Transition, TransitionChild } from '@headlessui/react';
|
|
import { XMarkIcon } from '@heroicons/react/24/outline';
|
|
import clsx from 'clsx';
|
|
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, useEffect, useState } from 'react';
|
|
|
|
type Combination = {
|
|
id: string;
|
|
availableForSale: boolean;
|
|
[key: string]: string | boolean; // ie. { color: 'Red', size: 'Large', ... }
|
|
};
|
|
|
|
export function VariantSelector({
|
|
options,
|
|
variants,
|
|
minPrice
|
|
}: {
|
|
options: ProductOption[];
|
|
variants: ProductVariant[];
|
|
minPrice: Money;
|
|
}) {
|
|
const router = useRouter();
|
|
const pathname = usePathname();
|
|
const searchParams = useSearchParams();
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
|
|
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 }),
|
|
{}
|
|
)
|
|
}));
|
|
|
|
const variantsById: Record<string, ProductVariant> = variants.reduce((acc, variant) => {
|
|
return { ...acc, [variant.id]: variant };
|
|
}, {});
|
|
|
|
// 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);
|
|
});
|
|
|
|
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 hasNoOptionsOrJustOneOption =
|
|
!options.length || (options.length === 1 && options[0]?.values.length === 1);
|
|
|
|
if (hasNoOptionsOrJustOneOption) {
|
|
return null;
|
|
}
|
|
|
|
const openModal = () => setIsOpen(true);
|
|
const closeModal = () => setIsOpen(false);
|
|
|
|
return (
|
|
<div className="mb-6 flex flex-row gap-1 rounded-md border p-2 text-sm font-medium">
|
|
See more Remanufactured and Used Options{' '}
|
|
<button
|
|
className="flex flex-row gap-0.5 font-normal text-blue-800 hover:underline"
|
|
aria-label="Open variants selector"
|
|
onClick={openModal}
|
|
>
|
|
from
|
|
<Price amount={minPrice.amount} currencyCode={minPrice.currencyCode} />
|
|
</button>
|
|
<Transition show={isOpen} as={Fragment}>
|
|
<Dialog onClose={closeModal} className="relative z-50">
|
|
<TransitionChild
|
|
as={Fragment}
|
|
enter="transition-all ease-in-out duration-300"
|
|
enterFrom="opacity-0 backdrop-blur-none"
|
|
enterTo="opacity-100 backdrop-blur-[.5px]"
|
|
leave="transition-all ease-in-out duration-200"
|
|
leaveFrom="opacity-100 backdrop-blur-[.5px]"
|
|
leaveTo="opacity-0 backdrop-blur-none"
|
|
>
|
|
<div className="bg-black/30 fixed inset-0" aria-hidden="true" />
|
|
</TransitionChild>
|
|
<TransitionChild
|
|
as={Fragment}
|
|
enter="transition-all ease-in-out duration-300"
|
|
enterFrom="translate-x-full"
|
|
enterTo="translate-x-0"
|
|
leave="transition-all ease-in-out duration-200"
|
|
leaveFrom="translate-x-0"
|
|
leaveTo="translate-x-full"
|
|
>
|
|
<DialogPanel className="text-black fixed bottom-0 right-0 top-0 flex h-full w-full flex-col border-l border-neutral-200 bg-white/80 p-6 backdrop-blur-xl md:w-[500px]">
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-lg font-semibold">Manufactured & Used Options</p>
|
|
|
|
<button aria-label="Close cart" onClick={closeModal} className="text-black">
|
|
<XMarkIcon className="h-6" />
|
|
</button>
|
|
</div>
|
|
<div className="mt-5 flex h-full flex-col justify-between overflow-hidden">
|
|
{options.map((option) => {
|
|
return (
|
|
<ul
|
|
key={option.id}
|
|
className="flex-grow flex-col space-y-4 overflow-auto px-2 py-4"
|
|
>
|
|
{option.values.map((value) => {
|
|
console.log('option', option);
|
|
console.log('variants', variants);
|
|
const optionNameLowerCase = option.name.toLowerCase();
|
|
const optionSearchParams = new URLSearchParams(searchParams.toString());
|
|
|
|
optionSearchParams.set(optionNameLowerCase, value);
|
|
|
|
// 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
|
|
)
|
|
);
|
|
|
|
const variant = isAvailableForSale
|
|
? variantsById[isAvailableForSale.id]
|
|
: undefined;
|
|
|
|
const coreChargeOptions = [
|
|
variant?.waiverAvailable && {
|
|
label: 'Core Waiver',
|
|
value: CORE_WAIVER,
|
|
price: { amount: 0, currencyCode: variant?.price.currencyCode }
|
|
},
|
|
variant?.coreVariantId &&
|
|
variant.coreCharge && {
|
|
label: 'Core Charge',
|
|
value: variant.coreVariantId,
|
|
price: variant.coreCharge
|
|
}
|
|
].filter(Boolean) as CoreChargeOption[];
|
|
|
|
// preset the first core charge option if not set
|
|
coreChargeOptions[0] &&
|
|
optionSearchParams.set(CORE_VARIANT_ID_KEY, coreChargeOptions[0].value);
|
|
|
|
const optionUrl = createUrl(pathname, optionSearchParams);
|
|
|
|
// The option is active if it's in the url params.
|
|
const isActive = searchParams.get(optionNameLowerCase) === value;
|
|
|
|
return (
|
|
<li
|
|
key={value}
|
|
className={clsx('flex w-full rounded border border-neutral-300', {
|
|
'cursor-default ring-2 ring-secondary': isActive,
|
|
'ring-2 ring-transparent hover:ring-secondary':
|
|
!isActive && isAvailableForSale,
|
|
'cursor-not-allowed opacity-60 ring-1 ring-neutral-300':
|
|
!isAvailableForSale
|
|
})}
|
|
>
|
|
<button
|
|
disabled={!isAvailableForSale}
|
|
aria-disabled={!isAvailableForSale}
|
|
onClick={() => {
|
|
router.replace(optionUrl, { scroll: false });
|
|
closeModal();
|
|
}}
|
|
className="flex w-full flex-col gap-2 px-4 py-3"
|
|
>
|
|
<div className="flex w-full flex-row items-center justify-between">
|
|
<div className="flex flex-col items-start gap-1">
|
|
{variant ? (
|
|
<Price
|
|
amount={variant.price.amount}
|
|
currencyCode={variant.price.currencyCode}
|
|
className="text-base font-semibold"
|
|
/>
|
|
) : null}
|
|
<div className="flex items-center gap-1">
|
|
<span className="text-xs font-medium text-gray-600">SKU:</span>
|
|
<span className="text-xs text-gray-600">
|
|
{variant?.sku || 'N/A'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
{!isAvailableForSale ? <span>Out of Stock</span> : null}
|
|
</div>
|
|
<div className="mt-1.5 flex flex-row flex-wrap items-center gap-3">
|
|
{coreChargeOptions.map((option) => (
|
|
<div
|
|
key={option.value}
|
|
className={
|
|
'flex flex-row items-center gap-2 rounded-full border border-neutral-300 bg-transparent px-3 py-1 text-xs'
|
|
}
|
|
>
|
|
<span>{option.label}</span>
|
|
<Price {...option.price} />
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="mt-2 flex w-full flex-col gap-1 border-t border-gray-300 pl-1 pt-2 text-xs tracking-normal">
|
|
<div className="flex flex-row items-center gap-2">
|
|
<span>Condition:</span>
|
|
<span>{variant?.condition || 'N/A'}</span>
|
|
</div>
|
|
<div className="flex flex-row items-center gap-2">
|
|
<span>Estimated Delivery:</span>
|
|
<span>{variant?.estimatedDelivery || 'N/A'}</span>
|
|
</div>
|
|
<div className="flex flex-row items-center gap-2">
|
|
<span>Mileage:</span>
|
|
<span>{variant?.mileage || 'N/A'}</span>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
);
|
|
})}
|
|
</div>
|
|
</DialogPanel>
|
|
</TransitionChild>
|
|
</Dialog>
|
|
</Transition>
|
|
</div>
|
|
);
|
|
}
|