This commit is contained in:
Lee Robinson 2024-07-27 20:29:08 -05:00
parent dd7449f975
commit d7caedd0a3
12 changed files with 234 additions and 142 deletions

View File

@ -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 (
<html lang="en" className={GeistSans.variable}>
<body className="bg-neutral-50 text-black selection:bg-teal-300 dark:bg-neutral-900 dark:text-white dark:selection:bg-pink-500 dark:selection:text-white">
<Navbar />
<main>{children}</main>
<CartProvider cartPromise={cart}>
<Navbar />
<main>{children}</main>
</CartProvider>
</body>
</html>
);

View File

@ -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);
}

View File

@ -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 (
<button
onClick={(e: React.FormEvent<HTMLButtonElement>) => {
if (pending) e.preventDefault();
}}
aria-label="Add to cart"
aria-disabled={pending}
className={clsx(buttonClasses, {
'hover:opacity-90': true,
[disabledClasses]: pending
'hover:opacity-90': true
})}
>
<div className="absolute left-0 ml-4">
{pending ? <LoadingDots className="mb-3 bg-white" /> : <PlusIcon className="h-5" />}
<PlusIcon className="h-5" />
</div>
Add To Cart
</button>
);
}
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 (
<form action={actionWithVariant}>
<form
action={async () => {
addCartItem(finalVariant, product);
await actionWithVariant();
}}
>
<SubmitButton availableForSale={availableForSale} selectedVariantId={selectedVariantId} />
<p aria-live="polite" className="sr-only" role="status">
{message}

View File

@ -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<CartContextType | undefined>(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<Cart, 'totalQuantity' | 'cost'> {
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<Cart | undefined>;
}) {
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 <CartContext.Provider value={value}>{children}</CartContext.Provider>;
}
export function useCart() {
const context = useContext(CartContext);
if (context === undefined) {
throw new Error('useCart must be used within a CartProvider');
}
return context;
}

View File

@ -19,7 +19,7 @@ export function DeleteItemButton({
return (
<form
action={async () => {
optimisticUpdate({ itemId, newQuantity: 0, type: 'minus' });
optimisticUpdate(itemId, 'delete');
await actionWithVariant();
}}
>

View File

@ -47,7 +47,7 @@ export function EditItemQuantityButton({
return (
<form
action={async () => {
optimisticUpdate({ itemId: payload.lineId, newQuantity: payload.quantity, type });
optimisticUpdate(payload.lineId, type);
await actionWithVariant();
}}
>

View File

@ -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 <CartModal cart={cart} />;
}

View File

@ -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
</div>
</div>
<form action={redirectToCheckout}>
<CheckoutButton cart={cart} />
<CheckoutButton />
</form>
</div>
)}
@ -272,19 +194,16 @@ export default function CartModal({ cart: initialCart }: { cart: Cart | undefine
);
}
function CheckoutButton({ cart }: { cart: Cart }) {
function CheckoutButton() {
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>
</>
<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>
);
}

View File

@ -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() {
</Suspense>
</div>
<div className="flex justify-end md:w-1/3">
<Suspense fallback={<OpenCart />}>
<Cart />
</Suspense>
<CartModal />
</div>
</div>
</nav>

View File

@ -29,7 +29,7 @@ export function ProductDescription({ product }: { product: Product }) {
) : null}
<Suspense fallback={null}>
<AddToCart variants={product.variants} availableForSale={product.availableForSale} />
<AddToCart product={product} />
</Suspense>
</>
);

View File

@ -254,7 +254,11 @@ export async function updateCart(
return reshapeCart(res.body.data.cartLinesUpdate.cart);
}
export async function getCart(cartId: string): Promise<Cart | undefined> {
export async function getCart(cartId: string | undefined): Promise<Cart | undefined> {
if (!cartId) {
return undefined;
}
const res = await shopifyFetch<ShopifyCartOperation>({
query: getCartQuery,
variables: { cartId },

View File

@ -12,6 +12,13 @@ export type Cart = Omit<ShopifyCart, 'lines'> & {
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;
};
};