From d7caedd0a31134040db2a00648420bf6b15e5432 Mon Sep 17 00:00:00 2001 From: Lee Robinson Date: Sat, 27 Jul 2024 20:29:08 -0500 Subject: [PATCH] WIP --- app/layout.tsx | 13 +- components/cart/actions.ts | 13 +- components/cart/add-to-cart.tsx | 34 ++-- components/cart/cart-context.tsx | 166 ++++++++++++++++++ components/cart/delete-item-button.tsx | 2 +- components/cart/edit-item-quantity-button.tsx | 2 +- components/cart/index.tsx | 14 -- components/cart/modal.tsx | 107 ++--------- components/layout/navbar/index.tsx | 8 +- components/product/product-description.tsx | 2 +- lib/shopify/index.ts | 6 +- lib/shopify/types.ts | 9 +- 12 files changed, 234 insertions(+), 142 deletions(-) create mode 100644 components/cart/cart-context.tsx delete mode 100644 components/cart/index.tsx diff --git a/app/layout.tsx b/app/layout.tsx index 1e17f31d3..f11923b52 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,6 +1,9 @@ +import { CartProvider } from 'components/cart/cart-context'; import Navbar from 'components/layout/navbar'; import { GeistSans } from 'geist/font/sans'; +import { getCart } from 'lib/shopify'; import { ensureStartsWith } from 'lib/utils'; +import { cookies } from 'next/headers'; import { ReactNode } from 'react'; import './globals.css'; @@ -32,11 +35,17 @@ export const metadata = { }; export default async function RootLayout({ children }: { children: ReactNode }) { + const cartId = cookies().get('cartId')?.value; + // Don't await the fetch, pass the Promise to the context provider + const cart = getCart(cartId); + return ( - -
{children}
+ + +
{children}
+
); diff --git a/components/cart/actions.ts b/components/cart/actions.ts index ea6d82dd7..e24b3b02c 100644 --- a/components/cart/actions.ts +++ b/components/cart/actions.ts @@ -79,11 +79,18 @@ export async function updateItemQuantity( ]); revalidateTag(TAGS.cart); } catch (e) { + console.log(e); return 'Error updating item quantity'; } } -export async function redirectToCheckout(formData: FormData) { - const url = formData.get('url') as string; - redirect(url); +export async function redirectToCheckout() { + let cartId = cookies().get('cartId')?.value; + let cart = await getCart(cartId); + + if (!cart) { + return; + } + + redirect(cart.checkoutUrl); } diff --git a/components/cart/add-to-cart.tsx b/components/cart/add-to-cart.tsx index 35329b03d..b0cd68908 100644 --- a/components/cart/add-to-cart.tsx +++ b/components/cart/add-to-cart.tsx @@ -3,10 +3,10 @@ import { PlusIcon } from '@heroicons/react/24/outline'; import clsx from 'clsx'; import { addItem } from 'components/cart/actions'; -import LoadingDots from 'components/loading-dots'; -import { ProductVariant } from 'lib/shopify/types'; +import { Product, ProductVariant } from 'lib/shopify/types'; import { useSearchParams } from 'next/navigation'; -import { useFormState, useFormStatus } from 'react-dom'; +import { useFormState } from 'react-dom'; +import { useCart } from './cart-context'; function SubmitButton({ availableForSale, @@ -15,7 +15,6 @@ function SubmitButton({ availableForSale: boolean; selectedVariantId: string | undefined; }) { - const { pending } = useFormStatus(); const buttonClasses = 'relative flex w-full items-center justify-center rounded-full bg-blue-600 p-4 tracking-wide text-white'; const disabledClasses = 'cursor-not-allowed opacity-60 hover:opacity-60'; @@ -45,31 +44,22 @@ function SubmitButton({ return ( ); } -export function AddToCart({ - variants, - availableForSale -}: { - variants: ProductVariant[]; - availableForSale: boolean; -}) { +export function AddToCart({ product }: { product: Product }) { + const { variants, availableForSale } = product; + const { addCartItem } = useCart(); const [message, formAction] = useFormState(addItem, null); const searchParams = useSearchParams(); const defaultVariantId = variants.length === 1 ? variants[0]?.id : undefined; @@ -80,9 +70,15 @@ export function AddToCart({ ); const selectedVariantId = variant?.id || defaultVariantId; const actionWithVariant = formAction.bind(null, selectedVariantId); + const finalVariant = variants.find((variant) => variant.id === selectedVariantId)!; return ( -
+ { + addCartItem(finalVariant, product); + await actionWithVariant(); + }} + >

{message} diff --git a/components/cart/cart-context.tsx b/components/cart/cart-context.tsx new file mode 100644 index 000000000..89293f3ba --- /dev/null +++ b/components/cart/cart-context.tsx @@ -0,0 +1,166 @@ +'use client'; + +import type { Cart, CartItem, Product, ProductVariant } from 'lib/shopify/types'; +import React, { createContext, use, useContext, useMemo, useOptimistic } from 'react'; + +type UpdateType = 'plus' | 'minus' | 'delete'; + +type CartAction = + | { type: 'UPDATE_ITEM'; payload: { itemId: string; updateType: UpdateType } } + | { type: 'ADD_ITEM'; payload: { variant: ProductVariant; product: Product } }; + +type CartContextType = { + cart: Cart | undefined; + updateCartItem: (itemId: string, updateType: UpdateType) => void; + addCartItem: (variant: ProductVariant, product: Product) => void; +}; + +const CartContext = createContext(undefined); + +function calculateItemCost(quantity: number, price: string): string { + return (Number(price) * quantity).toString(); +} + +function updateCartItem(item: CartItem, updateType: UpdateType): CartItem | null { + if (updateType === 'delete') return null; + + const newQuantity = updateType === 'plus' ? item.quantity + 1 : item.quantity - 1; + if (newQuantity === 0) return null; + + const singleItemAmount = Number(item.cost.totalAmount.amount) / item.quantity; + const newTotalAmount = calculateItemCost(newQuantity, singleItemAmount.toString()); + + return { + ...item, + quantity: newQuantity, + cost: { + ...item.cost, + totalAmount: { + ...item.cost.totalAmount, + amount: newTotalAmount + } + } + }; +} + +function createOrUpdateCartItem( + existingItem: CartItem | undefined, + variant: ProductVariant, + product: Product +): CartItem { + const quantity = existingItem ? existingItem.quantity + 1 : 1; + const totalAmount = calculateItemCost(quantity, variant.price.amount); + + console.log('quantity', quantity); + return { + id: existingItem?.id || `${variant.id}_${Date.now()}`, + quantity, + cost: { + totalAmount: { + amount: totalAmount, + currencyCode: variant.price.currencyCode + } + }, + merchandise: { + id: variant.id, + title: variant.title, + selectedOptions: variant.selectedOptions, + product: { + id: product.id, + handle: product.handle, + title: product.title, + featuredImage: product.featuredImage + } + } + }; +} + +function updateCartTotals(lines: CartItem[]): Pick { + const totalQuantity = lines.reduce((sum, item) => sum + item.quantity, 0); + const totalAmount = lines.reduce((sum, item) => sum + Number(item.cost.totalAmount.amount), 0); + const currencyCode = lines[0]?.cost.totalAmount.currencyCode ?? 'USD'; + + return { + totalQuantity, + cost: { + subtotalAmount: { amount: totalAmount.toString(), currencyCode }, + totalAmount: { amount: totalAmount.toString(), currencyCode }, + totalTaxAmount: { amount: '0', currencyCode } + } + }; +} + +function cartReducer(state: Cart | undefined, action: CartAction): Cart | undefined { + if (!state) return state; + + switch (action.type) { + case 'UPDATE_ITEM': { + const { itemId, updateType } = action.payload; + const updatedLines = state.lines + .map((item) => (item.id === itemId ? updateCartItem(item, updateType) : item)) + .filter(Boolean) as CartItem[]; + + if (updatedLines.length === 0) { + return { + ...state, + lines: [], + totalQuantity: 0, + cost: { ...state.cost, totalAmount: { ...state.cost.totalAmount, amount: '0' } } + }; + } + + return { ...state, ...updateCartTotals(updatedLines), lines: updatedLines }; + } + case 'ADD_ITEM': { + const { variant, product } = action.payload; + const existingItem = state.lines.find((item) => item.merchandise.id === variant.id); + const updatedItem = createOrUpdateCartItem(existingItem, variant, product); + + const updatedLines = existingItem + ? state.lines.map((item) => (item.merchandise.id === variant.id ? updatedItem : item)) + : [updatedItem, ...state.lines]; + + return { ...state, ...updateCartTotals(updatedLines), lines: updatedLines }; + } + default: + return state; + } +} + +export function CartProvider({ + children, + cartPromise +}: { + children: React.ReactNode; + cartPromise: Promise; +}) { + const initialCart = use(cartPromise); + const [optimisticCart, updateOptimisticCart] = useOptimistic(initialCart, cartReducer); + + const updateCartItem = (itemId: string, updateType: UpdateType) => { + updateOptimisticCart({ type: 'UPDATE_ITEM', payload: { itemId, updateType } }); + }; + + const addCartItem = (variant: ProductVariant, product: Product) => { + updateOptimisticCart({ type: 'ADD_ITEM', payload: { variant, product } }); + }; + + const value = useMemo( + () => ({ + cart: optimisticCart, + updateCartItem, + addCartItem + }), + [optimisticCart] + ); + + return {children}; +} + +export function useCart() { + const context = useContext(CartContext); + if (context === undefined) { + throw new Error('useCart must be used within a CartProvider'); + } + return context; +} diff --git a/components/cart/delete-item-button.tsx b/components/cart/delete-item-button.tsx index 5b436a93c..fc8d04844 100644 --- a/components/cart/delete-item-button.tsx +++ b/components/cart/delete-item-button.tsx @@ -19,7 +19,7 @@ export function DeleteItemButton({ return ( { - optimisticUpdate({ itemId, newQuantity: 0, type: 'minus' }); + optimisticUpdate(itemId, 'delete'); await actionWithVariant(); }} > diff --git a/components/cart/edit-item-quantity-button.tsx b/components/cart/edit-item-quantity-button.tsx index 7b0f90f45..c3a1af0ba 100644 --- a/components/cart/edit-item-quantity-button.tsx +++ b/components/cart/edit-item-quantity-button.tsx @@ -47,7 +47,7 @@ export function EditItemQuantityButton({ return ( { - optimisticUpdate({ itemId: payload.lineId, newQuantity: payload.quantity, type }); + optimisticUpdate(payload.lineId, type); await actionWithVariant(); }} > diff --git a/components/cart/index.tsx b/components/cart/index.tsx deleted file mode 100644 index 3e250ba93..000000000 --- a/components/cart/index.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { getCart } from 'lib/shopify'; -import { cookies } from 'next/headers'; -import CartModal from './modal'; - -export default async function Cart() { - const cartId = cookies().get('cartId')?.value; - let cart; - - if (cartId) { - cart = await getCart(cartId); - } - - return ; -} diff --git a/components/cart/modal.tsx b/components/cart/modal.tsx index 8b2b89d1b..69d47d95c 100644 --- a/components/cart/modal.tsx +++ b/components/cart/modal.tsx @@ -5,13 +5,13 @@ 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, CartItem } from 'lib/shopify/types'; import { createUrl } from 'lib/utils'; import Image from 'next/image'; import Link from 'next/link'; -import { Fragment, useEffect, useOptimistic, useRef, useState } from 'react'; +import { Fragment, useEffect, useRef, useState } from 'react'; import { useFormStatus } from 'react-dom'; import { redirectToCheckout } from './actions'; +import { useCart } from './cart-context'; import CloseCart from './close-cart'; import { DeleteItemButton } from './delete-item-button'; import { EditItemQuantityButton } from './edit-item-quantity-button'; @@ -21,96 +21,18 @@ type MerchandiseSearchParams = { [key: string]: string; }; -type NewState = { - itemId: string; - newQuantity: number; - type: 'plus' | 'minus'; -}; - -function reducer(state: Cart | undefined, newState: NewState) { - if (!state) { - return state; - } - - let updatedLines = state.lines - .map((item: CartItem) => { - if (item.id === newState.itemId) { - if (newState.type === 'minus' && newState.newQuantity === 0) { - // Remove the item if quantity becomes 0 - return null; - } - - const singleItemAmount = Number(item.cost.totalAmount.amount) / item.quantity; - const newTotalAmount = singleItemAmount * newState.newQuantity; - - return { - ...item, - quantity: newState.newQuantity, - cost: { - ...item.cost, - totalAmount: { - ...item.cost.totalAmount, - amount: newTotalAmount.toString() - } - } - }; - } - return item; - }) - .filter(Boolean) as CartItem[]; - - const newTotalQuantity = updatedLines.reduce((sum, item) => sum + item.quantity, 0); - const newTotalAmount = updatedLines.reduce( - (sum, item) => sum + Number(item.cost.totalAmount.amount), - 0 - ); - - // If there are no items left, return an empty cart - if (updatedLines.length === 0) { - return { - ...state, - lines: [], - totalQuantity: 0, - cost: { - ...state.cost, - totalAmount: { - ...state.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 }) { +export default function CartModal() { + const { cart, updateCartItem } = useCart(); const [isOpen, setIsOpen] = useState(false); - const [cart, updateCartItem] = useOptimistic(initialCart, reducer); const quantityRef = useRef(cart?.totalQuantity); const openCart = () => setIsOpen(true); const closeCart = () => setIsOpen(false); useEffect(() => { - // Open cart modal when quantity changes. if (cart?.totalQuantity !== quantityRef.current) { - // But only if it's not already open (quantity also changes when editing items in cart). if (!isOpen) { setIsOpen(true); } - - // Always update the quantity reference quantityRef.current = cart?.totalQuantity; } }, [isOpen, cart?.totalQuantity, quantityRef]); @@ -260,7 +182,7 @@ export default function CartModal({ cart: initialCart }: { cart: Cart | undefine - + )} @@ -272,19 +194,16 @@ export default function CartModal({ cart: initialCart }: { cart: Cart | undefine ); } -function CheckoutButton({ cart }: { cart: Cart }) { +function CheckoutButton() { const { pending } = useFormStatus(); return ( - <> - - - + ); } diff --git a/components/layout/navbar/index.tsx b/components/layout/navbar/index.tsx index 969c63205..c10ba8c43 100644 --- a/components/layout/navbar/index.tsx +++ b/components/layout/navbar/index.tsx @@ -1,5 +1,4 @@ -import Cart from 'components/cart'; -import OpenCart from 'components/cart/open-cart'; +import CartModal from 'components/cart/modal'; import LogoSquare from 'components/logo-square'; import { getMenu } from 'lib/shopify'; import { Menu } from 'lib/shopify/types'; @@ -7,6 +6,7 @@ import Link from 'next/link'; import { Suspense } from 'react'; import MobileMenu from './mobile-menu'; import Search, { SearchSkeleton } from './search'; + const { SITE_NAME } = process.env; export default async function Navbar() { @@ -53,9 +53,7 @@ export default async function Navbar() {

- }> - - +
diff --git a/components/product/product-description.tsx b/components/product/product-description.tsx index 10232ae3d..5cf6cdf54 100644 --- a/components/product/product-description.tsx +++ b/components/product/product-description.tsx @@ -29,7 +29,7 @@ export function ProductDescription({ product }: { product: Product }) { ) : null} - + ); diff --git a/lib/shopify/index.ts b/lib/shopify/index.ts index a34257bcf..d126099c2 100644 --- a/lib/shopify/index.ts +++ b/lib/shopify/index.ts @@ -254,7 +254,11 @@ export async function updateCart( return reshapeCart(res.body.data.cartLinesUpdate.cart); } -export async function getCart(cartId: string): Promise { +export async function getCart(cartId: string | undefined): Promise { + if (!cartId) { + return undefined; + } + const res = await shopifyFetch({ query: getCartQuery, variables: { cartId }, diff --git a/lib/shopify/types.ts b/lib/shopify/types.ts index 23dc02d46..789c6d53e 100644 --- a/lib/shopify/types.ts +++ b/lib/shopify/types.ts @@ -12,6 +12,13 @@ export type Cart = Omit & { lines: CartItem[]; }; +export type CartProduct = { + id: string; + handle: string; + title: string; + featuredImage: Image; +}; + export type CartItem = { id: string; quantity: number; @@ -25,7 +32,7 @@ export type CartItem = { name: string; value: string; }[]; - product: Product; + product: CartProduct; }; };