mirror of
https://github.com/vercel/commerce.git
synced 2025-05-18 15:36:58 +00:00
feat: implement variant selector panel
Signed-off-by: Chloe <pinkcloudvnn@gmail.com>
This commit is contained in:
parent
d5cdb55845
commit
2ad07c3682
@ -4,7 +4,7 @@ import { ShoppingCartIcon } from '@heroicons/react/24/outline';
|
|||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { addItem } from 'components/cart/actions';
|
import { addItem } from 'components/cart/actions';
|
||||||
import LoadingDots from 'components/loading-dots';
|
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 { ProductVariant } from 'lib/shopify/types';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { useFormState, useFormStatus } from 'react-dom';
|
import { useFormState, useFormStatus } from 'react-dom';
|
||||||
@ -69,18 +69,20 @@ export function AddToCart({
|
|||||||
}) {
|
}) {
|
||||||
const [message, formAction] = useFormState(addItem, null);
|
const [message, formAction] = useFormState(addItem, null);
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const defaultVariantId = variants.length === 1 ? variants[0]?.id : undefined;
|
|
||||||
const variant = variants.find((variant: ProductVariant) =>
|
const variant = variants.find((variant: ProductVariant) =>
|
||||||
variant.selectedOptions.every(
|
variant.selectedOptions.every(
|
||||||
(option) => option.value === searchParams.get(option.name.toLowerCase())
|
(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 missingCoreVariantId = variant?.coreVariantId && !searchParams.has(CORE_VARIANT_ID_KEY);
|
||||||
|
|
||||||
const coreVariantId = searchParams.get(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);
|
const actionWithVariant = formAction.bind(null, selectedVariantIds);
|
||||||
|
|
||||||
|
@ -2,15 +2,14 @@
|
|||||||
|
|
||||||
import Price from 'components/price';
|
import Price from 'components/price';
|
||||||
import { CORE_VARIANT_ID_KEY, CORE_WAIVER } from 'lib/constants';
|
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 { cn, createUrl } from 'lib/utils';
|
||||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
type CoreChargeProps = {
|
type CoreChargeProps = {
|
||||||
variants: ProductVariant[];
|
variants: ProductVariant[];
|
||||||
defaultPrice: Money;
|
|
||||||
};
|
};
|
||||||
const CoreCharge = ({ variants, defaultPrice }: CoreChargeProps) => {
|
const CoreCharge = ({ variants }: CoreChargeProps) => {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
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') => {
|
const handleSelectCoreChargeOption = (coreVariantId: string) => {
|
||||||
if (action === 'add' && variant?.coreVariantId) {
|
optionSearchParams.set(CORE_VARIANT_ID_KEY, coreVariantId);
|
||||||
optionSearchParams.set(CORE_VARIANT_ID_KEY, variant.coreVariantId);
|
|
||||||
} else if (action === 'remove') {
|
|
||||||
optionSearchParams.set(CORE_VARIANT_ID_KEY, CORE_WAIVER);
|
|
||||||
}
|
|
||||||
|
|
||||||
const newUrl = createUrl(pathname, optionSearchParams);
|
const newUrl = createUrl(pathname, optionSearchParams);
|
||||||
router.replace(newUrl, { scroll: false });
|
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
|
const coreChargeOptions = [
|
||||||
// or if users have selected the core waiver but the selected variant does not have a waiver available
|
waiverAvailable && {
|
||||||
// we remove the core charge from the url
|
label: 'Core Waiver',
|
||||||
if (
|
value: CORE_WAIVER,
|
||||||
variant?.coreVariantId &&
|
price: { amount: 0, currencyCode: variant?.price.currencyCode }
|
||||||
optionSearchParams.has(CORE_VARIANT_ID_KEY) &&
|
},
|
||||||
(coreVariantIdSearchParam !== CORE_WAIVER || !variant.waiverAvailable) &&
|
coreVariantId &&
|
||||||
coreVariantIdSearchParam !== variant.coreVariantId
|
coreCharge && {
|
||||||
) {
|
label: 'Core Charge',
|
||||||
optionSearchParams.delete(CORE_VARIANT_ID_KEY);
|
value: coreVariantId,
|
||||||
const newUrl = createUrl(pathname, optionSearchParams);
|
price: coreCharge
|
||||||
router.replace(newUrl, { scroll: false });
|
}
|
||||||
}
|
].filter(Boolean) as CoreChargeOption[];
|
||||||
|
|
||||||
const selectedPayCoreCharge = coreVariantIdSearchParam === variant?.coreVariantId;
|
if (!optionSearchParams.has(CORE_VARIANT_ID_KEY) && coreChargeOptions.length > 0) {
|
||||||
const selectedCoreWaiver = coreVariantIdSearchParam === CORE_WAIVER;
|
handleSelectCoreChargeOption((coreChargeOptions[0] as CoreChargeOption).value);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col text-xs lg:text-sm">
|
<div className="flex flex-col text-xs lg:text-sm">
|
||||||
@ -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.
|
recycling. When you return the old part, you'll receive a refund of the core charge.
|
||||||
</p>
|
</p>
|
||||||
<ul className="flex min-h-16 flex-row space-x-4 pt-2">
|
<ul className="flex min-h-16 flex-row space-x-4 pt-2">
|
||||||
{waiverAvailable ? (
|
{coreChargeOptions.map((option) => (
|
||||||
<li className="flex w-32">
|
<li className="flex w-32" key={option.value}>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleSelectCoreChargeOption('remove')}
|
onClick={() => handleSelectCoreChargeOption(option.value)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex w-full flex-col flex-wrap items-center justify-center space-y-2 rounded-md border p-2 text-center text-xs font-medium',
|
'flex w-full flex-col flex-wrap items-center justify-center space-y-2 rounded-md border p-2 text-center text-xs font-medium',
|
||||||
{
|
{
|
||||||
'ring-2 ring-secondary': selectedCoreWaiver
|
'ring-2 ring-secondary': coreVariantIdSearchParam === option.value
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span>Core Waiver</span>
|
<span>{option.label}</span>
|
||||||
<Price amount="0" currencyCode={defaultPrice.currencyCode} />
|
<Price {...option.price} />
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
) : null}
|
))}
|
||||||
{coreCharge && variant?.coreVariantId ? (
|
|
||||||
<li className="flex w-32">
|
|
||||||
<button
|
|
||||||
onClick={() => handleSelectCoreChargeOption('add')}
|
|
||||||
className={cn(
|
|
||||||
'flex w-full flex-col flex-wrap items-center justify-center space-y-2 rounded-md border p-2 text-center text-xs font-medium',
|
|
||||||
{
|
|
||||||
'ring-2 ring-secondary': selectedPayCoreCharge
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span>Core Charge</span>
|
|
||||||
<Price amount={coreCharge.amount} currencyCode={coreCharge.currencyCode} />
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
) : null}
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -19,7 +19,11 @@ export function ProductDescription({ product }: { product: Product }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<VariantSelector options={product.options} variants={product.variants} />
|
<VariantSelector
|
||||||
|
options={product.options}
|
||||||
|
variants={product.variants}
|
||||||
|
minPrice={product.priceRange.minVariantPrice}
|
||||||
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
{product.descriptionHtml ? (
|
{product.descriptionHtml ? (
|
||||||
@ -30,11 +34,11 @@ export function ProductDescription({ product }: { product: Product }) {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="mb-4 border-t pb-4 pt-6 dark:border-neutral-700">
|
<div className="mb-4 border-t pb-4 pt-6 dark:border-neutral-700">
|
||||||
<CoreCharge variants={product.variants} defaultPrice={product.priceRange.minVariantPrice} />
|
<CoreCharge variants={product.variants} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-4 border-t py-6 dark:border-neutral-700">
|
<div className="mb-4 border-t py-6 dark:border-neutral-700">
|
||||||
<Warranty productType={product.productType} />
|
<Warranty />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
|
@ -1,9 +1,14 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { Dialog, Transition } from '@headlessui/react';
|
||||||
|
import { XMarkIcon } from '@heroicons/react/24/outline';
|
||||||
import clsx from 'clsx';
|
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 { createUrl } from 'lib/utils';
|
||||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import { Fragment, MouseEvent, useEffect, useState } from 'react';
|
||||||
|
|
||||||
type Combination = {
|
type Combination = {
|
||||||
id: string;
|
id: string;
|
||||||
@ -13,20 +18,17 @@ type Combination = {
|
|||||||
|
|
||||||
export function VariantSelector({
|
export function VariantSelector({
|
||||||
options,
|
options,
|
||||||
variants
|
variants,
|
||||||
|
minPrice
|
||||||
}: {
|
}: {
|
||||||
options: ProductOption[];
|
options: ProductOption[];
|
||||||
variants: ProductVariant[];
|
variants: ProductVariant[];
|
||||||
|
minPrice: Money;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const hasNoOptionsOrJustOneOption =
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
!options.length || (options.length === 1 && options[0]?.values.length === 1);
|
|
||||||
|
|
||||||
if (hasNoOptionsOrJustOneOption) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const combinations: Combination[] = variants.map((variant) => ({
|
const combinations: Combination[] = variants.map((variant) => ({
|
||||||
id: variant.id,
|
id: variant.id,
|
||||||
@ -38,70 +40,231 @@ export function VariantSelector({
|
|||||||
)
|
)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return options.map((option) => (
|
const variantsById: Record<string, ProductVariant> = variants.reduce((acc, variant) => {
|
||||||
<dl className="mb-6" key={option.id}>
|
return { ...acc, [variant.id]: variant };
|
||||||
<dt className="mb-4 text-sm font-medium tracking-wide">{option.name}</dt>
|
}, {});
|
||||||
<dd className="flex flex-wrap gap-3">
|
|
||||||
{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.
|
// If a variant is not selected, we want to select the first available for sale variant as default
|
||||||
const optionSearchParams = new URLSearchParams(searchParams.toString());
|
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 (!hasSelectedVariant) {
|
||||||
// if the option was clicked.
|
const defaultVariant = variants.find((variant) => variant.availableForSale);
|
||||||
optionSearchParams.set(optionNameLowerCase, value);
|
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:
|
if (hasNoOptionsOrJustOneOption) {
|
||||||
//
|
return null;
|
||||||
// 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
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// The option is active if it's in the url params.
|
const openModal = () => setIsOpen(true);
|
||||||
const isActive = searchParams.get(optionNameLowerCase) === value;
|
const closeModal = () => setIsOpen(false);
|
||||||
|
|
||||||
return (
|
const handleCoreChargeClick = (
|
||||||
<button
|
event: MouseEvent<HTMLButtonElement, globalThis.MouseEvent>,
|
||||||
key={value}
|
coreVariantId: string | null
|
||||||
aria-disabled={!isAvailableForSale}
|
) => {
|
||||||
disabled={!isAvailableForSale}
|
event.stopPropagation();
|
||||||
onClick={() => {
|
if (!coreVariantId) return;
|
||||||
router.replace(optionUrl, { scroll: false });
|
const optionSearchParams = new URLSearchParams(searchParams.toString());
|
||||||
}}
|
optionSearchParams.set(CORE_VARIANT_ID_KEY, coreVariantId);
|
||||||
title={`${option.name} ${value}${!isAvailableForSale ? ' (Out of Stock)' : ''}`}
|
const newUrl = createUrl(pathname, optionSearchParams);
|
||||||
className={clsx(
|
router.replace(newUrl, { scroll: false });
|
||||||
'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-secondary': isActive,
|
return (
|
||||||
'ring-1 ring-transparent transition duration-300 ease-in-out hover:scale-110 hover:ring-secondary ':
|
<div className="mb-6 flex flex-row gap-1 text-sm font-medium">
|
||||||
!isActive && isAvailableForSale,
|
More Remanufactured and Used Options{' '}
|
||||||
'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':
|
<button
|
||||||
!isAvailableForSale
|
className="flex flex-row gap-0.5 font-normal text-blue-800 hover:underline"
|
||||||
}
|
aria-label="Open variants selector"
|
||||||
)}
|
onClick={openModal}
|
||||||
>
|
>
|
||||||
{value}
|
from
|
||||||
</button>
|
<Price amount={minPrice.amount} currencyCode={minPrice.currencyCode} />
|
||||||
);
|
</button>
|
||||||
})}
|
<Transition show={isOpen} as={Fragment}>
|
||||||
</dd>
|
<Dialog onClose={closeModal} className="relative z-50">
|
||||||
</dl>
|
<Transition.Child
|
||||||
));
|
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="fixed inset-0 bg-black/30" aria-hidden="true" />
|
||||||
|
</Transition.Child>
|
||||||
|
<Transition.Child
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<Dialog.Panel className="fixed bottom-0 right-0 top-0 flex h-full w-full flex-col border-l border-neutral-200 bg-white/80 p-6 text-black 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 ">
|
||||||
|
<div>
|
||||||
|
{options.map((option) => {
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
key={option.id}
|
||||||
|
className="flex-grow flex-col space-y-4 overflow-auto px-1 py-4"
|
||||||
|
>
|
||||||
|
{option.values.map((value) => {
|
||||||
|
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 })}
|
||||||
|
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">
|
||||||
|
{option.name}:
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-600">{value}</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) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
disabled={!isActive}
|
||||||
|
className={clsx(
|
||||||
|
'flex flex-row items-center gap-2 rounded-full border border-neutral-300 px-3 py-1 text-xs',
|
||||||
|
{
|
||||||
|
'bg-gray-200':
|
||||||
|
isActive &&
|
||||||
|
searchParams.get(CORE_VARIANT_ID_KEY) === option.value,
|
||||||
|
'bg-transparent':
|
||||||
|
searchParams.get(CORE_VARIANT_ID_KEY) !== option.value,
|
||||||
|
'cursor-not-allowed opacity-50 hover:bg-transparent':
|
||||||
|
!isActive,
|
||||||
|
'hover:bg-gray-200': isActive
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
onClick={(e) => handleCoreChargeClick(e, option.value)}
|
||||||
|
>
|
||||||
|
<span>{option.label}</span>
|
||||||
|
<Price {...option.price} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog.Panel>
|
||||||
|
</Transition.Child>
|
||||||
|
</Dialog>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
@ -2,16 +2,12 @@ import { ShieldCheckIcon } from '@heroicons/react/24/outline';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import WarrantySelector from './warranty-selector';
|
import WarrantySelector from './warranty-selector';
|
||||||
|
|
||||||
type WarrantyProps = {
|
const Warranty = () => {
|
||||||
productType: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const Warranty = ({ productType }: WarrantyProps) => {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col text-xs lg:text-sm">
|
<div className="flex flex-col text-xs lg:text-sm">
|
||||||
<div className="mb-3 flex flex-row items-center space-x-2 text-base font-medium">
|
<div className="mb-3 flex flex-row items-center space-x-2 text-base font-medium">
|
||||||
<ShieldCheckIcon className="h-7 w-7" />
|
<ShieldCheckIcon className="h-7 w-7" />
|
||||||
<span> Protect your {productType ?? 'product'}</span>
|
<span> Protect your product</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-1 flex flex-row items-center space-x-1 divide-x divide-gray-400 leading-none lg:space-x-3">
|
<div className="mb-1 flex flex-row items-center space-x-1 divide-x divide-gray-400 leading-none lg:space-x-3">
|
||||||
<span>Extended Warranty</span>
|
<span>Extended Warranty</span>
|
||||||
|
@ -73,9 +73,6 @@ const productFragment = /* GraphQL */ `
|
|||||||
}
|
}
|
||||||
tags
|
tags
|
||||||
updatedAt
|
updatedAt
|
||||||
productType: metafield(namespace: "custom", key: "product_type") {
|
|
||||||
value
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
${imageFragment}
|
${imageFragment}
|
||||||
${seoFragment}
|
${seoFragment}
|
||||||
|
@ -194,12 +194,11 @@ const reshapeProduct = (product: ShopifyProduct, filterHiddenProducts: boolean =
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { images, variants, productType, ...rest } = product;
|
const { images, variants, ...rest } = product;
|
||||||
return {
|
return {
|
||||||
...rest,
|
...rest,
|
||||||
images: reshapeImages(images, product.title),
|
images: reshapeImages(images, product.title),
|
||||||
variants: reshapeVariants(removeEdgesAndNodes(variants)),
|
variants: reshapeVariants(removeEdgesAndNodes(variants))
|
||||||
productType: productType?.value ?? null
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -62,10 +62,9 @@ export type Page = {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Product = Omit<ShopifyProduct, 'variants' | 'images' | 'productType'> & {
|
export type Product = Omit<ShopifyProduct, 'variants' | 'images'> & {
|
||||||
variants: ProductVariant[];
|
variants: ProductVariant[];
|
||||||
images: Image[];
|
images: Image[];
|
||||||
productType: string | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ProductOption = {
|
export type ProductOption = {
|
||||||
@ -148,9 +147,6 @@ export type ShopifyProduct = {
|
|||||||
handle: string;
|
handle: string;
|
||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
productType: {
|
|
||||||
value: string;
|
|
||||||
} | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ShopifyCartOperation = {
|
export type ShopifyCartOperation = {
|
||||||
@ -288,3 +284,9 @@ export type ShopifyProductsOperation = {
|
|||||||
sortKey?: string;
|
sortKey?: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CoreChargeOption = {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
price: Money;
|
||||||
|
};
|
||||||
|
Loading…
x
Reference in New Issue
Block a user