mirror of
https://github.com/vercel/commerce.git
synced 2025-03-28 00:05:53 +00:00
Optimistic cart (#1364)
This commit is contained in:
parent
d7a4f3dc46
commit
0ebf071826
@ -4,6 +4,7 @@ import { TAGS } from 'lib/constants';
|
|||||||
import { addToCart, createCart, getCart, removeFromCart, updateCart } from 'lib/shopify';
|
import { addToCart, createCart, getCart, removeFromCart, updateCart } from 'lib/shopify';
|
||||||
import { revalidateTag } from 'next/cache';
|
import { revalidateTag } from 'next/cache';
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
export async function addItem(prevState: any, selectedVariantId: string | undefined) {
|
export async function addItem(prevState: any, selectedVariantId: string | undefined) {
|
||||||
let cartId = cookies().get('cartId')?.value;
|
let cartId = cookies().get('cartId')?.value;
|
||||||
@ -81,3 +82,8 @@ export async function updateItemQuantity(
|
|||||||
return 'Error updating item quantity';
|
return 'Error updating item quantity';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function redirectToCheckout(formData: FormData) {
|
||||||
|
const url = formData.get('url') as string;
|
||||||
|
redirect(url);
|
||||||
|
}
|
||||||
|
@ -3,32 +3,22 @@
|
|||||||
import { MinusIcon, PlusIcon } from '@heroicons/react/24/outline';
|
import { MinusIcon, PlusIcon } from '@heroicons/react/24/outline';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { updateItemQuantity } from 'components/cart/actions';
|
import { updateItemQuantity } from 'components/cart/actions';
|
||||||
import LoadingDots from 'components/loading-dots';
|
|
||||||
import type { CartItem } from 'lib/shopify/types';
|
import type { CartItem } from 'lib/shopify/types';
|
||||||
import { useFormState, useFormStatus } from 'react-dom';
|
import { useFormState } from 'react-dom';
|
||||||
|
|
||||||
function SubmitButton({ type }: { type: 'plus' | 'minus' }) {
|
function SubmitButton({ type }: { type: 'plus' | 'minus' }) {
|
||||||
const { pending } = useFormStatus();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
onClick={(e: React.FormEvent<HTMLButtonElement>) => {
|
|
||||||
if (pending) e.preventDefault();
|
|
||||||
}}
|
|
||||||
aria-label={type === 'plus' ? 'Increase item quantity' : 'Reduce item quantity'}
|
aria-label={type === 'plus' ? 'Increase item quantity' : 'Reduce item quantity'}
|
||||||
aria-disabled={pending}
|
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'ease flex h-full min-w-[36px] max-w-[36px] flex-none items-center justify-center rounded-full px-2 transition-all duration-200 hover:border-neutral-800 hover:opacity-80',
|
'ease flex h-full min-w-[36px] max-w-[36px] flex-none items-center justify-center rounded-full px-2 transition-all duration-200 hover:border-neutral-800 hover:opacity-80',
|
||||||
{
|
{
|
||||||
'cursor-not-allowed': pending,
|
|
||||||
'ml-auto': type === 'minus'
|
'ml-auto': type === 'minus'
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{pending ? (
|
{type === 'plus' ? (
|
||||||
<LoadingDots className="bg-black dark:bg-white" />
|
|
||||||
) : type === 'plus' ? (
|
|
||||||
<PlusIcon className="h-4 w-4 dark:text-neutral-500" />
|
<PlusIcon className="h-4 w-4 dark:text-neutral-500" />
|
||||||
) : (
|
) : (
|
||||||
<MinusIcon className="h-4 w-4 dark:text-neutral-500" />
|
<MinusIcon className="h-4 w-4 dark:text-neutral-500" />
|
||||||
@ -37,7 +27,15 @@ function SubmitButton({ type }: { type: 'plus' | 'minus' }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EditItemQuantityButton({ item, type }: { item: CartItem; type: 'plus' | 'minus' }) {
|
export function EditItemQuantityButton({
|
||||||
|
item,
|
||||||
|
type,
|
||||||
|
optimisticUpdate
|
||||||
|
}: {
|
||||||
|
item: CartItem;
|
||||||
|
type: 'plus' | 'minus';
|
||||||
|
optimisticUpdate: any;
|
||||||
|
}) {
|
||||||
const [message, formAction] = useFormState(updateItemQuantity, null);
|
const [message, formAction] = useFormState(updateItemQuantity, null);
|
||||||
const payload = {
|
const payload = {
|
||||||
lineId: item.id,
|
lineId: item.id,
|
||||||
@ -47,7 +45,12 @@ export function EditItemQuantityButton({ item, type }: { item: CartItem; type: '
|
|||||||
const actionWithVariant = formAction.bind(null, payload);
|
const actionWithVariant = formAction.bind(null, payload);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form action={actionWithVariant}>
|
<form
|
||||||
|
action={async () => {
|
||||||
|
optimisticUpdate({ itemId: payload.lineId, newQuantity: payload.quantity });
|
||||||
|
await actionWithVariant();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<SubmitButton type={type} />
|
<SubmitButton type={type} />
|
||||||
<p aria-live="polite" className="sr-only" role="status">
|
<p aria-live="polite" className="sr-only" role="status">
|
||||||
{message}
|
{message}
|
||||||
|
@ -2,13 +2,16 @@
|
|||||||
|
|
||||||
import { Dialog, Transition } from '@headlessui/react';
|
import { Dialog, Transition } from '@headlessui/react';
|
||||||
import { ShoppingCartIcon } from '@heroicons/react/24/outline';
|
import { ShoppingCartIcon } from '@heroicons/react/24/outline';
|
||||||
|
import LoadingDots from 'components/loading-dots';
|
||||||
import Price from 'components/price';
|
import Price from 'components/price';
|
||||||
import { DEFAULT_OPTION } from 'lib/constants';
|
import { DEFAULT_OPTION } from 'lib/constants';
|
||||||
import type { Cart } from 'lib/shopify/types';
|
import type { Cart, CartItem } from 'lib/shopify/types';
|
||||||
import { createUrl } from 'lib/utils';
|
import { createUrl } from 'lib/utils';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Fragment, useEffect, useRef, useState } from 'react';
|
import { Fragment, useEffect, useOptimistic, useRef, useState } from 'react';
|
||||||
|
import { useFormStatus } from 'react-dom';
|
||||||
|
import { redirectToCheckout } from './actions';
|
||||||
import CloseCart from './close-cart';
|
import CloseCart from './close-cart';
|
||||||
import { DeleteItemButton } from './delete-item-button';
|
import { DeleteItemButton } from './delete-item-button';
|
||||||
import { EditItemQuantityButton } from './edit-item-quantity-button';
|
import { EditItemQuantityButton } from './edit-item-quantity-button';
|
||||||
@ -18,8 +21,58 @@ type MerchandiseSearchParams = {
|
|||||||
[key: string]: string;
|
[key: string]: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function CartModal({ cart }: { cart: Cart | undefined }) {
|
type NewState = {
|
||||||
|
itemId: string;
|
||||||
|
newQuantity: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function reducer(state: Cart | undefined, newState: NewState) {
|
||||||
|
if (!state) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedLines = state.lines.map((item: CartItem) => {
|
||||||
|
if (item.id === newState.itemId) {
|
||||||
|
const singleItemAmount = Number(item.cost.totalAmount.amount) / item.quantity;
|
||||||
|
const newTotalAmount = Number(item.cost.totalAmount.amount) + singleItemAmount;
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
quantity: newState.newQuantity,
|
||||||
|
cost: {
|
||||||
|
...item.cost,
|
||||||
|
totalAmount: {
|
||||||
|
...item.cost.totalAmount,
|
||||||
|
amount: newTotalAmount.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
|
||||||
|
const newTotalQuantity = updatedLines.reduce((sum, item) => sum + item.quantity, 0);
|
||||||
|
const newTotalAmount = updatedLines.reduce(
|
||||||
|
(sum, item) => sum + Number(item.cost.totalAmount.amount),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
lines: updatedLines,
|
||||||
|
totalQuantity: newTotalQuantity,
|
||||||
|
cost: {
|
||||||
|
...state.cost,
|
||||||
|
totalAmount: {
|
||||||
|
...state.cost.totalAmount,
|
||||||
|
amount: newTotalAmount.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CartModal({ cart: initialCart }: { cart: Cart | undefined }) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [cart, updateCartItem] = useOptimistic(initialCart, reducer);
|
||||||
const quantityRef = useRef(cart?.totalQuantity);
|
const quantityRef = useRef(cart?.totalQuantity);
|
||||||
const openCart = () => setIsOpen(true);
|
const openCart = () => setIsOpen(true);
|
||||||
const closeCart = () => setIsOpen(false);
|
const closeCart = () => setIsOpen(false);
|
||||||
@ -67,7 +120,6 @@ export default function CartModal({ cart }: { cart: Cart | undefined }) {
|
|||||||
<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-[390px] dark:border-neutral-700 dark:bg-black/80 dark:text-white">
|
<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-[390px] dark:border-neutral-700 dark:bg-black/80 dark:text-white">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-lg font-semibold">My Cart</p>
|
<p className="text-lg font-semibold">My Cart</p>
|
||||||
|
|
||||||
<button aria-label="Close cart" onClick={closeCart}>
|
<button aria-label="Close cart" onClick={closeCart}>
|
||||||
<CloseCart />
|
<CloseCart />
|
||||||
</button>
|
</button>
|
||||||
@ -140,11 +192,19 @@ export default function CartModal({ cart }: { cart: Cart | undefined }) {
|
|||||||
currencyCode={item.cost.totalAmount.currencyCode}
|
currencyCode={item.cost.totalAmount.currencyCode}
|
||||||
/>
|
/>
|
||||||
<div className="ml-auto flex h-9 flex-row items-center rounded-full border border-neutral-200 dark:border-neutral-700">
|
<div className="ml-auto flex h-9 flex-row items-center rounded-full border border-neutral-200 dark:border-neutral-700">
|
||||||
<EditItemQuantityButton item={item} type="minus" />
|
<EditItemQuantityButton
|
||||||
|
item={item}
|
||||||
|
type="minus"
|
||||||
|
optimisticUpdate={updateCartItem}
|
||||||
|
/>
|
||||||
<p className="w-6 text-center">
|
<p className="w-6 text-center">
|
||||||
<span className="w-full text-sm">{item.quantity}</span>
|
<span className="w-full text-sm">{item.quantity}</span>
|
||||||
</p>
|
</p>
|
||||||
<EditItemQuantityButton item={item} type="plus" />
|
<EditItemQuantityButton
|
||||||
|
item={item}
|
||||||
|
type="plus"
|
||||||
|
optimisticUpdate={updateCartItem}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -174,12 +234,9 @@ export default function CartModal({ cart }: { cart: Cart | undefined }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a
|
<form action={redirectToCheckout}>
|
||||||
href={cart.checkoutUrl}
|
<CheckoutButton cart={cart} />
|
||||||
className="block w-full rounded-full bg-blue-600 p-3 text-center text-sm font-medium text-white opacity-90 hover:opacity-100"
|
</form>
|
||||||
>
|
|
||||||
Proceed to Checkout
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Dialog.Panel>
|
</Dialog.Panel>
|
||||||
@ -189,3 +246,20 @@ export default function CartModal({ cart }: { cart: Cart | undefined }) {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CheckoutButton({ cart }: { cart: Cart }) {
|
||||||
|
const { pending } = useFormStatus();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<input type="hidden" name="url" value={cart.checkoutUrl} />
|
||||||
|
<button
|
||||||
|
className="block w-full rounded-full bg-blue-600 p-3 text-center text-sm font-medium text-white opacity-90 hover:opacity-100"
|
||||||
|
type="submit"
|
||||||
|
disabled={pending}
|
||||||
|
>
|
||||||
|
{pending ? <LoadingDots className="bg-white" /> : 'Proceed to Checkout'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user