Optimistic cart (#1364)
This commit is contained in:
		| @@ -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; | ||||
| @@ -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); | ||||
| } | ||||
|   | ||||
| @@ -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" /> | ||||
| @@ -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, | ||||
| @@ -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} | ||||
|   | ||||
| @@ -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'; | ||||
| @@ -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); | ||||
| @@ -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> | ||||
| @@ -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> | ||||
| @@ -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> | ||||
| @@ -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> | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user