All files / lib/cart cart-store.tsx

98.07% Statements 51/52
96.55% Branches 28/29
100% Functions 15/15
98.03% Lines 50/51

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 1883x                                           3x                                                   3x     17x       10x 1x     9x 9x   9x 1x     7x   7x 9x               5x       4x   4x 2x       7x   1x                       50x   50x 19x 8x     11x 11x                 50x 45x 15x     30x 30x           50x 11x     50x 1x     50x 4x     50x 1x     50x 1x     50x 1x     50x 2x     50x 50x                             50x       50x   50x       50x    
"use client";
 
import {
  createContext,
  useCallback,
  useContext,
  useEffect,
  useMemo,
  useReducer,
  type ReactNode,
} from "react";
 
import { getProductBySlug } from "@/data/products";
import {
  cartReducer,
  getCartItemCount,
  getCartSubtotal,
  initialCartState,
} from "@/lib/cart/cart-reducer";
import type { CartLineItem, CartState } from "@/lib/cart/cart-types";
import type { Product } from "@/types/product";
 
const storageKey = "headless-commerce-cart";
 
/**
 * Public cart context contract exposed through `useCart`.
 */
export interface CartContextValue extends CartState {
  readonly addItem: (product: Product, quantity?: number) => void;
  readonly clearCart: () => void;
  readonly closeCart: () => void;
  readonly decrementItem: (slug: string) => void;
  readonly incrementItem: (slug: string) => void;
  readonly itemCount: number;
  readonly openCart: () => void;
  readonly removeItem: (slug: string) => void;
  readonly subtotalCents: number;
}
 
/**
 * Cart provider options used by the application shell and component tests.
 */
export interface CartProviderProps {
  readonly children: ReactNode;
  readonly initialState?: CartState;
  readonly persist?: boolean;
}
 
const CartContext = createContext<CartContextValue | null>(null);
 
function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null;
}
 
function parseStoredItems(value: string | null): CartLineItem[] {
  if (!value) {
    return [];
  }
 
  try {
    const parsed: unknown = JSON.parse(value);
 
    if (!Array.isArray(parsed)) {
      return [];
    }
 
    const items: CartLineItem[] = [];
 
    for (const item of parsed) {
      if (
        !isRecord(item) ||
        !isRecord(item.product) ||
        typeof item.product.slug !== "string" ||
        typeof item.quantity !== "number" ||
        !Number.isSafeInteger(item.quantity) ||
        item.quantity <= 0
      ) {
        continue;
      }
 
      // Resolve persisted identifiers against the current catalog, never trust stored prices.
      const product = getProductBySlug(item.product.slug);
 
      if (product && !items.some((line) => line.product.slug === product.slug)) {
        items.push({ product, quantity: item.quantity });
      }
    }
 
    return items;
  } catch {
    return [];
  }
}
 
/**
 * Client-side cart provider with reducer state and localStorage persistence.
 */
export function CartProvider({
  children,
  initialState,
  persist = true,
}: CartProviderProps): ReactNode {
  const [state, dispatch] = useReducer(cartReducer, initialState ?? initialCartState);
 
  useEffect(() => {
    if (!persist || initialState) {
      return;
    }
 
    try {
      dispatch({
        items: parseStoredItems(window.localStorage.getItem(storageKey)),
        type: "hydrate",
      });
    } catch {
      // Storage can be unavailable; the cart remains usable in memory.
    }
  }, [initialState, persist]);
 
  useEffect(() => {
    if (!persist) {
      return;
    }
 
    try {
      window.localStorage.setItem(storageKey, JSON.stringify(state.items));
    } catch {
      // Quota or privacy restrictions must not interrupt shopping.
    }
  }, [persist, state.items]);
 
  const addItem = useCallback((product: Product, quantity?: number) => {
    dispatch({ product, quantity, type: "add" });
  }, []);
 
  const clearCart = useCallback(() => {
    dispatch({ type: "clear" });
  }, []);
 
  const closeCart = useCallback(() => {
    dispatch({ type: "close" });
  }, []);
 
  const decrementItem = useCallback((slug: string) => {
    dispatch({ slug, type: "decrement" });
  }, []);
 
  const incrementItem = useCallback((slug: string) => {
    dispatch({ slug, type: "increment" });
  }, []);
 
  const openCart = useCallback(() => {
    dispatch({ type: "open" });
  }, []);
 
  const removeItem = useCallback((slug: string) => {
    dispatch({ slug, type: "remove" });
  }, []);
 
  const value = useMemo<CartContextValue>(
    () => ({
      ...state,
      addItem,
      clearCart,
      closeCart,
      decrementItem,
      incrementItem,
      itemCount: getCartItemCount(state.items),
      openCart,
      removeItem,
      subtotalCents: getCartSubtotal(state.items),
    }),
    [addItem, clearCart, closeCart, decrementItem, incrementItem, openCart, removeItem, state],
  );
 
  return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
}
 
export function useCart(): CartContextValue {
  const context = useContext(CartContext);
 
  Iif (!context) {
    throw new Error("useCart must be used inside CartProvider.");
  }
 
  return context;
}