Skip to content

Commit 0ebf071

Browse files
authored
Optimistic cart (vercel#1364)
1 parent d7a4f3d commit 0ebf071

File tree

3 files changed

+109
-26
lines changed

3 files changed

+109
-26
lines changed

components/cart/actions.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { TAGS } from 'lib/constants';
44
import { addToCart, createCart, getCart, removeFromCart, updateCart } from 'lib/shopify';
55
import { revalidateTag } from 'next/cache';
66
import { cookies } from 'next/headers';
7+
import { redirect } from 'next/navigation';
78

89
export async function addItem(prevState: any, selectedVariantId: string | undefined) {
910
let cartId = cookies().get('cartId')?.value;
@@ -81,3 +82,8 @@ export async function updateItemQuantity(
8182
return 'Error updating item quantity';
8283
}
8384
}
85+
86+
export async function redirectToCheckout(formData: FormData) {
87+
const url = formData.get('url') as string;
88+
redirect(url);
89+
}

components/cart/edit-item-quantity-button.tsx

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,32 +3,22 @@
33
import { MinusIcon, PlusIcon } from '@heroicons/react/24/outline';
44
import clsx from 'clsx';
55
import { updateItemQuantity } from 'components/cart/actions';
6-
import LoadingDots from 'components/loading-dots';
76
import type { CartItem } from 'lib/shopify/types';
8-
import { useFormState, useFormStatus } from 'react-dom';
7+
import { useFormState } from 'react-dom';
98

109
function SubmitButton({ type }: { type: 'plus' | 'minus' }) {
11-
const { pending } = useFormStatus();
12-
1310
return (
1411
<button
1512
type="submit"
16-
onClick={(e: React.FormEvent<HTMLButtonElement>) => {
17-
if (pending) e.preventDefault();
18-
}}
1913
aria-label={type === 'plus' ? 'Increase item quantity' : 'Reduce item quantity'}
20-
aria-disabled={pending}
2114
className={clsx(
2215
'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',
2316
{
24-
'cursor-not-allowed': pending,
2517
'ml-auto': type === 'minus'
2618
}
2719
)}
2820
>
29-
{pending ? (
30-
<LoadingDots className="bg-black dark:bg-white" />
31-
) : type === 'plus' ? (
21+
{type === 'plus' ? (
3222
<PlusIcon className="h-4 w-4 dark:text-neutral-500" />
3323
) : (
3424
<MinusIcon className="h-4 w-4 dark:text-neutral-500" />
@@ -37,7 +27,15 @@ function SubmitButton({ type }: { type: 'plus' | 'minus' }) {
3727
);
3828
}
3929

40-
export function EditItemQuantityButton({ item, type }: { item: CartItem; type: 'plus' | 'minus' }) {
30+
export function EditItemQuantityButton({
31+
item,
32+
type,
33+
optimisticUpdate
34+
}: {
35+
item: CartItem;
36+
type: 'plus' | 'minus';
37+
optimisticUpdate: any;
38+
}) {
4139
const [message, formAction] = useFormState(updateItemQuantity, null);
4240
const payload = {
4341
lineId: item.id,
@@ -47,7 +45,12 @@ export function EditItemQuantityButton({ item, type }: { item: CartItem; type: '
4745
const actionWithVariant = formAction.bind(null, payload);
4846

4947
return (
50-
<form action={actionWithVariant}>
48+
<form
49+
action={async () => {
50+
optimisticUpdate({ itemId: payload.lineId, newQuantity: payload.quantity });
51+
await actionWithVariant();
52+
}}
53+
>
5154
<SubmitButton type={type} />
5255
<p aria-live="polite" className="sr-only" role="status">
5356
{message}

components/cart/modal.tsx

Lines changed: 86 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,16 @@
22

33
import { Dialog, Transition } from '@headlessui/react';
44
import { ShoppingCartIcon } from '@heroicons/react/24/outline';
5+
import LoadingDots from 'components/loading-dots';
56
import Price from 'components/price';
67
import { DEFAULT_OPTION } from 'lib/constants';
7-
import type { Cart } from 'lib/shopify/types';
8+
import type { Cart, CartItem } from 'lib/shopify/types';
89
import { createUrl } from 'lib/utils';
910
import Image from 'next/image';
1011
import Link from 'next/link';
11-
import { Fragment, useEffect, useRef, useState } from 'react';
12+
import { Fragment, useEffect, useOptimistic, useRef, useState } from 'react';
13+
import { useFormStatus } from 'react-dom';
14+
import { redirectToCheckout } from './actions';
1215
import CloseCart from './close-cart';
1316
import { DeleteItemButton } from './delete-item-button';
1417
import { EditItemQuantityButton } from './edit-item-quantity-button';
@@ -18,8 +21,58 @@ type MerchandiseSearchParams = {
1821
[key: string]: string;
1922
};
2023

21-
export default function CartModal({ cart }: { cart: Cart | undefined }) {
24+
type NewState = {
25+
itemId: string;
26+
newQuantity: number;
27+
};
28+
29+
function reducer(state: Cart | undefined, newState: NewState) {
30+
if (!state) {
31+
return state;
32+
}
33+
34+
const updatedLines = state.lines.map((item: CartItem) => {
35+
if (item.id === newState.itemId) {
36+
const singleItemAmount = Number(item.cost.totalAmount.amount) / item.quantity;
37+
const newTotalAmount = Number(item.cost.totalAmount.amount) + singleItemAmount;
38+
return {
39+
...item,
40+
quantity: newState.newQuantity,
41+
cost: {
42+
...item.cost,
43+
totalAmount: {
44+
...item.cost.totalAmount,
45+
amount: newTotalAmount.toString()
46+
}
47+
}
48+
};
49+
}
50+
return item;
51+
});
52+
53+
const newTotalQuantity = updatedLines.reduce((sum, item) => sum + item.quantity, 0);
54+
const newTotalAmount = updatedLines.reduce(
55+
(sum, item) => sum + Number(item.cost.totalAmount.amount),
56+
0
57+
);
58+
59+
return {
60+
...state,
61+
lines: updatedLines,
62+
totalQuantity: newTotalQuantity,
63+
cost: {
64+
...state.cost,
65+
totalAmount: {
66+
...state.cost.totalAmount,
67+
amount: newTotalAmount.toString()
68+
}
69+
}
70+
};
71+
}
72+
73+
export default function CartModal({ cart: initialCart }: { cart: Cart | undefined }) {
2274
const [isOpen, setIsOpen] = useState(false);
75+
const [cart, updateCartItem] = useOptimistic(initialCart, reducer);
2376
const quantityRef = useRef(cart?.totalQuantity);
2477
const openCart = () => setIsOpen(true);
2578
const closeCart = () => setIsOpen(false);
@@ -67,7 +120,6 @@ export default function CartModal({ cart }: { cart: Cart | undefined }) {
67120
<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">
68121
<div className="flex items-center justify-between">
69122
<p className="text-lg font-semibold">My Cart</p>
70-
71123
<button aria-label="Close cart" onClick={closeCart}>
72124
<CloseCart />
73125
</button>
@@ -140,11 +192,19 @@ export default function CartModal({ cart }: { cart: Cart | undefined }) {
140192
currencyCode={item.cost.totalAmount.currencyCode}
141193
/>
142194
<div className="ml-auto flex h-9 flex-row items-center rounded-full border border-neutral-200 dark:border-neutral-700">
143-
<EditItemQuantityButton item={item} type="minus" />
195+
<EditItemQuantityButton
196+
item={item}
197+
type="minus"
198+
optimisticUpdate={updateCartItem}
199+
/>
144200
<p className="w-6 text-center">
145201
<span className="w-full text-sm">{item.quantity}</span>
146202
</p>
147-
<EditItemQuantityButton item={item} type="plus" />
203+
<EditItemQuantityButton
204+
item={item}
205+
type="plus"
206+
optimisticUpdate={updateCartItem}
207+
/>
148208
</div>
149209
</div>
150210
</div>
@@ -174,12 +234,9 @@ export default function CartModal({ cart }: { cart: Cart | undefined }) {
174234
/>
175235
</div>
176236
</div>
177-
<a
178-
href={cart.checkoutUrl}
179-
className="block w-full rounded-full bg-blue-600 p-3 text-center text-sm font-medium text-white opacity-90 hover:opacity-100"
180-
>
181-
Proceed to Checkout
182-
</a>
237+
<form action={redirectToCheckout}>
238+
<CheckoutButton cart={cart} />
239+
</form>
183240
</div>
184241
)}
185242
</Dialog.Panel>
@@ -189,3 +246,20 @@ export default function CartModal({ cart }: { cart: Cart | undefined }) {
189246
</>
190247
);
191248
}
249+
250+
function CheckoutButton({ cart }: { cart: Cart }) {
251+
const { pending } = useFormStatus();
252+
253+
return (
254+
<>
255+
<input type="hidden" name="url" value={cart.checkoutUrl} />
256+
<button
257+
className="block w-full rounded-full bg-blue-600 p-3 text-center text-sm font-medium text-white opacity-90 hover:opacity-100"
258+
type="submit"
259+
disabled={pending}
260+
>
261+
{pending ? <LoadingDots className="bg-white" /> : 'Proceed to Checkout'}
262+
</button>
263+
</>
264+
);
265+
}

0 commit comments

Comments
 (0)