Skip to content

Commit

Permalink
Optimistic cart (vercel#1364)
Browse files Browse the repository at this point in the history
  • Loading branch information
leerob authored Jul 25, 2024
1 parent d7a4f3d commit 0ebf071
Show file tree
Hide file tree
Showing 3 changed files with 109 additions and 26 deletions.
6 changes: 6 additions & 0 deletions components/cart/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { TAGS } from 'lib/constants';
import { addToCart, createCart, getCart, removeFromCart, updateCart } from 'lib/shopify';
import { revalidateTag } from 'next/cache';
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';

export async function addItem(prevState: any, selectedVariantId: string | undefined) {
let cartId = cookies().get('cartId')?.value;
Expand Down Expand Up @@ -81,3 +82,8 @@ export async function updateItemQuantity(
return 'Error updating item quantity';
}
}

export async function redirectToCheckout(formData: FormData) {
const url = formData.get('url') as string;
redirect(url);
}
31 changes: 17 additions & 14 deletions components/cart/edit-item-quantity-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,32 +3,22 @@
import { MinusIcon, PlusIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
import { updateItemQuantity } from 'components/cart/actions';
import LoadingDots from 'components/loading-dots';
import type { CartItem } from 'lib/shopify/types';
import { useFormState, useFormStatus } from 'react-dom';
import { useFormState } from 'react-dom';

function SubmitButton({ type }: { type: 'plus' | 'minus' }) {
const { pending } = useFormStatus();

return (
<button
type="submit"
onClick={(e: React.FormEvent<HTMLButtonElement>) => {
if (pending) e.preventDefault();
}}
aria-label={type === 'plus' ? 'Increase item quantity' : 'Reduce item quantity'}
aria-disabled={pending}
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',
{
'cursor-not-allowed': pending,
'ml-auto': type === 'minus'
}
)}
>
{pending ? (
<LoadingDots className="bg-black dark:bg-white" />
) : type === 'plus' ? (
{type === 'plus' ? (
<PlusIcon className="h-4 w-4 dark:text-neutral-500" />
) : (
<MinusIcon className="h-4 w-4 dark:text-neutral-500" />
Expand All @@ -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 payload = {
lineId: item.id,
Expand All @@ -47,7 +45,12 @@ export function EditItemQuantityButton({ item, type }: { item: CartItem; type: '
const actionWithVariant = formAction.bind(null, payload);

return (
<form action={actionWithVariant}>
<form
action={async () => {
optimisticUpdate({ itemId: payload.lineId, newQuantity: payload.quantity });
await actionWithVariant();
}}
>
<SubmitButton type={type} />
<p aria-live="polite" className="sr-only" role="status">
{message}
Expand Down
98 changes: 86 additions & 12 deletions components/cart/modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@

import { Dialog, Transition } from '@headlessui/react';
import { ShoppingCartIcon } from '@heroicons/react/24/outline';
import LoadingDots from 'components/loading-dots';
import Price from 'components/price';
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 Image from 'next/image';
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 { DeleteItemButton } from './delete-item-button';
import { EditItemQuantityButton } from './edit-item-quantity-button';
Expand All @@ -18,8 +21,58 @@ type MerchandiseSearchParams = {
[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 [cart, updateCartItem] = useOptimistic(initialCart, reducer);
const quantityRef = useRef(cart?.totalQuantity);
const openCart = () => setIsOpen(true);
const closeCart = () => setIsOpen(false);
Expand Down Expand Up @@ -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">
<div className="flex items-center justify-between">
<p className="text-lg font-semibold">My Cart</p>

<button aria-label="Close cart" onClick={closeCart}>
<CloseCart />
</button>
Expand Down Expand Up @@ -140,11 +192,19 @@ export default function CartModal({ cart }: { cart: Cart | undefined }) {
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">
<EditItemQuantityButton item={item} type="minus" />
<EditItemQuantityButton
item={item}
type="minus"
optimisticUpdate={updateCartItem}
/>
<p className="w-6 text-center">
<span className="w-full text-sm">{item.quantity}</span>
</p>
<EditItemQuantityButton item={item} type="plus" />
<EditItemQuantityButton
item={item}
type="plus"
optimisticUpdate={updateCartItem}
/>
</div>
</div>
</div>
Expand Down Expand Up @@ -174,12 +234,9 @@ export default function CartModal({ cart }: { cart: Cart | undefined }) {
/>
</div>
</div>
<a
href={cart.checkoutUrl}
className="block w-full rounded-full bg-blue-600 p-3 text-center text-sm font-medium text-white opacity-90 hover:opacity-100"
>
Proceed to Checkout
</a>
<form action={redirectToCheckout}>
<CheckoutButton cart={cart} />
</form>
</div>
)}
</Dialog.Panel>
Expand All @@ -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>
</>
);
}

0 comments on commit 0ebf071

Please sign in to comment.