commerce/components/cart/cart-context.tsx
2025-03-06 11:10:28 -06:00

234 lines
5.5 KiB
TypeScript

"use client";
import { Cart, CartItem, Product, ProductVariant } from "lib/sfcc/types";
import React, {
createContext,
use,
useContext,
useMemo,
useOptimistic,
} from "react";
type UpdateType = "plus" | "minus" | "delete";
type CartAction =
| {
type: "UPDATE_ITEM";
payload: { merchandiseId: string; updateType: UpdateType };
}
| {
type: "ADD_ITEM";
payload: { variant: ProductVariant; product: Product };
};
type CartContextType = {
cartPromise: Promise<Cart | undefined>;
};
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);
return {
id: existingItem?.id,
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 createEmptyCart(): Cart {
return {
id: undefined,
checkoutUrl: "",
totalQuantity: 0,
lines: [],
cost: {
subtotalAmount: { amount: "0", currencyCode: "USD" },
totalAmount: { amount: "0", currencyCode: "USD" },
totalTaxAmount: { amount: "0", currencyCode: "USD" },
},
};
}
function cartReducer(state: Cart | undefined, action: CartAction): Cart {
const currentCart = state || createEmptyCart();
switch (action.type) {
case "UPDATE_ITEM": {
const { merchandiseId, updateType } = action.payload;
const updatedLines = currentCart.lines
.map((item) =>
item.merchandise.id === merchandiseId
? updateCartItem(item, updateType)
: item
)
.filter(Boolean) as CartItem[];
if (updatedLines.length === 0) {
return {
...currentCart,
lines: [],
totalQuantity: 0,
cost: {
...currentCart.cost,
totalAmount: { ...currentCart.cost.totalAmount, amount: "0" },
},
};
}
return {
...currentCart,
...updateCartTotals(updatedLines),
lines: updatedLines,
};
}
case "ADD_ITEM": {
const { variant, product } = action.payload;
const existingItem = currentCart.lines.find(
(item) => item.merchandise.id === variant.id
);
const updatedItem = createOrUpdateCartItem(
existingItem,
variant,
product
);
const updatedLines = existingItem
? currentCart.lines.map((item) =>
item.merchandise.id === variant.id ? updatedItem : item
)
: [...currentCart.lines, updatedItem];
return {
...currentCart,
...updateCartTotals(updatedLines),
lines: updatedLines,
};
}
default:
return currentCart;
}
}
export function CartProvider({
children,
cartPromise,
}: {
children: React.ReactNode;
cartPromise: Promise<Cart | undefined>;
}) {
return (
<CartContext.Provider value={{ cartPromise }}>
{children}
</CartContext.Provider>
);
}
export function useCart() {
const context = useContext(CartContext);
if (context === undefined) {
throw new Error("useCart must be used within a CartProvider");
}
const initialCart = use(context.cartPromise);
const [optimisticCart, updateOptimisticCart] = useOptimistic(
initialCart,
cartReducer
);
const updateCartItem = (merchandiseId: string, updateType: UpdateType) => {
updateOptimisticCart({
type: "UPDATE_ITEM",
payload: { merchandiseId, updateType },
});
};
const addCartItem = (variant: ProductVariant, product: Product) => {
updateOptimisticCart({ type: "ADD_ITEM", payload: { variant, product } });
};
return useMemo(
() => ({
cart: optimisticCart,
updateCartItem,
addCartItem,
}),
[optimisticCart]
);
}