mirror of
https://github.com/vercel/commerce.git
synced 2025-06-07 08:46:58 +00:00
optimistic variants
This commit is contained in:
parent
a9e67c43bb
commit
0a3da9c037
@ -3,8 +3,8 @@
|
|||||||
import { PlusIcon } from '@heroicons/react/24/outline';
|
import { PlusIcon } 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 { useProductOptions } from 'components/product/product-context';
|
||||||
import { Product, ProductVariant } from 'lib/shopify/types';
|
import { Product, ProductVariant } from 'lib/shopify/types';
|
||||||
import { useSearchParams } from 'next/navigation';
|
|
||||||
import { useFormState } from 'react-dom';
|
import { useFormState } from 'react-dom';
|
||||||
import { useCart } from './cart-context';
|
import { useCart } from './cart-context';
|
||||||
|
|
||||||
@ -60,14 +60,15 @@ function SubmitButton({
|
|||||||
export function AddToCart({ product }: { product: Product }) {
|
export function AddToCart({ product }: { product: Product }) {
|
||||||
const { variants, availableForSale } = product;
|
const { variants, availableForSale } = product;
|
||||||
const { addCartItem } = useCart();
|
const { addCartItem } = useCart();
|
||||||
|
const { options: selectedOptions } = useProductOptions();
|
||||||
const [message, formAction] = useFormState(addItem, null);
|
const [message, formAction] = useFormState(addItem, null);
|
||||||
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 === selectedOptions[option.name.toLowerCase()]
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
const defaultVariantId = variants.length === 1 ? variants[0]?.id : undefined;
|
||||||
const selectedVariantId = variant?.id || defaultVariantId;
|
const selectedVariantId = variant?.id || defaultVariantId;
|
||||||
const actionWithVariant = formAction.bind(null, selectedVariantId);
|
const actionWithVariant = formAction.bind(null, selectedVariantId);
|
||||||
const finalVariant = variants.find((variant) => variant.id === selectedVariantId)!;
|
const finalVariant = variants.find((variant) => variant.id === selectedVariantId)!;
|
||||||
|
73
components/product/product-context.tsx
Normal file
73
components/product/product-context.tsx
Normal file
@ -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<ProductOptionsContextType | undefined>(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 <ProductOptionsContext.Provider value={value}>{children}</ProductOptionsContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useProductOptions() {
|
||||||
|
const context = useContext(ProductOptionsContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useProductOptions must be used within a ProductOptionsProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
@ -1,5 +1,6 @@
|
|||||||
import { AddToCart } from 'components/cart/add-to-cart';
|
import { AddToCart } from 'components/cart/add-to-cart';
|
||||||
import Price from 'components/price';
|
import Price from 'components/price';
|
||||||
|
import { ProductOptionsProvider } from 'components/product/product-context';
|
||||||
import Prose from 'components/prose';
|
import Prose from 'components/prose';
|
||||||
import { Product } from 'lib/shopify/types';
|
import { Product } from 'lib/shopify/types';
|
||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
@ -7,7 +8,8 @@ import { VariantSelector } from './variant-selector';
|
|||||||
|
|
||||||
export function ProductDescription({ product }: { product: Product }) {
|
export function ProductDescription({ product }: { product: Product }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<Suspense fallback={null}>
|
||||||
|
<ProductOptionsProvider>
|
||||||
<div className="mb-6 flex flex-col border-b pb-6 dark:border-neutral-700">
|
<div className="mb-6 flex flex-col border-b pb-6 dark:border-neutral-700">
|
||||||
<h1 className="mb-2 text-5xl font-medium">{product.title}</h1>
|
<h1 className="mb-2 text-5xl font-medium">{product.title}</h1>
|
||||||
<div className="mr-auto w-auto rounded-full bg-blue-600 p-2 text-sm text-white">
|
<div className="mr-auto w-auto rounded-full bg-blue-600 p-2 text-sm text-white">
|
||||||
@ -17,20 +19,15 @@ export function ProductDescription({ product }: { product: Product }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Suspense fallback={null}>
|
|
||||||
<VariantSelector options={product.options} variants={product.variants} />
|
<VariantSelector options={product.options} variants={product.variants} />
|
||||||
</Suspense>
|
|
||||||
|
|
||||||
{product.descriptionHtml ? (
|
{product.descriptionHtml ? (
|
||||||
<Prose
|
<Prose
|
||||||
className="mb-6 text-sm leading-tight dark:text-white/[60%]"
|
className="mb-6 text-sm leading-tight dark:text-white/[60%]"
|
||||||
html={product.descriptionHtml}
|
html={product.descriptionHtml}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<AddToCart product={product} />
|
<AddToCart product={product} />
|
||||||
|
</ProductOptionsProvider>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,14 +1,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
import { useProductOptions } from 'components/product/product-context';
|
||||||
import { ProductOption, ProductVariant } from 'lib/shopify/types';
|
import { ProductOption, ProductVariant } from 'lib/shopify/types';
|
||||||
import { createUrl } from 'lib/utils';
|
|
||||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
|
||||||
|
|
||||||
type Combination = {
|
type Combination = {
|
||||||
id: string;
|
id: string;
|
||||||
availableForSale: boolean;
|
availableForSale: boolean;
|
||||||
[key: string]: string | boolean; // ie. { color: 'Red', size: 'Large', ... }
|
[key: string]: string | boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function VariantSelector({
|
export function VariantSelector({
|
||||||
@ -18,9 +17,7 @@ export function VariantSelector({
|
|||||||
options: ProductOption[];
|
options: ProductOption[];
|
||||||
variants: ProductVariant[];
|
variants: ProductVariant[];
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const { options: selectedOptions, updateOption } = useProductOptions();
|
||||||
const pathname = usePathname();
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const hasNoOptionsOrJustOneOption =
|
const hasNoOptionsOrJustOneOption =
|
||||||
!options.length || (options.length === 1 && options[0]?.values.length === 1);
|
!options.length || (options.length === 1 && options[0]?.values.length === 1);
|
||||||
|
|
||||||
@ -31,7 +28,6 @@ export function VariantSelector({
|
|||||||
const combinations: Combination[] = variants.map((variant) => ({
|
const combinations: Combination[] = variants.map((variant) => ({
|
||||||
id: variant.id,
|
id: variant.id,
|
||||||
availableForSale: variant.availableForSale,
|
availableForSale: variant.availableForSale,
|
||||||
// Adds key / value pairs for each variant (ie. "color": "Black" and "size": 'M").
|
|
||||||
...variant.selectedOptions.reduce(
|
...variant.selectedOptions.reduce(
|
||||||
(accumulator, option) => ({ ...accumulator, [option.name.toLowerCase()]: option.value }),
|
(accumulator, option) => ({ ...accumulator, [option.name.toLowerCase()]: option.value }),
|
||||||
{}
|
{}
|
||||||
@ -45,24 +41,11 @@ export function VariantSelector({
|
|||||||
{option.values.map((value) => {
|
{option.values.map((value) => {
|
||||||
const optionNameLowerCase = option.name.toLowerCase();
|
const optionNameLowerCase = option.name.toLowerCase();
|
||||||
|
|
||||||
// Base option params on current params so we can preserve any other param state in the url.
|
// Base option params on current selectedOptions so we can preserve any other param state.
|
||||||
const optionSearchParams = new URLSearchParams(searchParams.toString());
|
const optionParams = { ...selectedOptions, [optionNameLowerCase]: value };
|
||||||
|
|
||||||
// Update the option params using the current option to reflect how the url *would* change,
|
// Filter out invalid options and check if the option combination is available for sale.
|
||||||
// if the option was clicked.
|
const filtered = Object.entries(optionParams).filter(([key, value]) =>
|
||||||
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]) =>
|
|
||||||
options.find(
|
options.find(
|
||||||
(option) => option.name.toLowerCase() === key && option.values.includes(value)
|
(option) => option.name.toLowerCase() === key && option.values.includes(value)
|
||||||
)
|
)
|
||||||
@ -73,17 +56,15 @@ export function VariantSelector({
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// The option is active if it's in the url params.
|
// The option is active if it's in the selected options.
|
||||||
const isActive = searchParams.get(optionNameLowerCase) === value;
|
const isActive = selectedOptions[optionNameLowerCase] === value;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={value}
|
key={value}
|
||||||
aria-disabled={!isAvailableForSale}
|
aria-disabled={!isAvailableForSale}
|
||||||
disabled={!isAvailableForSale}
|
disabled={!isAvailableForSale}
|
||||||
onClick={() => {
|
onClick={() => updateOption(optionNameLowerCase, value)}
|
||||||
router.replace(optionUrl, { scroll: false });
|
|
||||||
}}
|
|
||||||
title={`${option.name} ${value}${!isAvailableForSale ? ' (Out of Stock)' : ''}`}
|
title={`${option.name} ${value}${!isAvailableForSale ? ' (Out of Stock)' : ''}`}
|
||||||
className={clsx(
|
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',
|
'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',
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "es5",
|
"target": "es2015",
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"downlevelIteration": true,
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user