import React, { useContext, useEffect } from "react" import { useLocalStorage } from "../hooks/utils" import { RegionInfo, ProductVariant } from "../types" import { getVariantPrice } from "../helpers" import { isArray, isEmpty, isObject } from "lodash" interface Item { variant: ProductVariant quantity: number readonly total?: number } export interface SessionCartState { region: RegionInfo items: Item[] totalItems: number total: number } interface SessionCartContextState extends SessionCartState { setRegion: (region: RegionInfo) => void addItem: (item: Item) => void removeItem: (id: string) => void updateItem: (id: string, item: Partial) => void setItems: (items: Item[]) => void updateItemQuantity: (id: string, quantity: number) => void incrementItemQuantity: (id: string) => void decrementItemQuantity: (id: string) => void getItem: (id: string) => Item | undefined clearItems: () => void } const SessionCartContext = React.createContext( null ) enum ACTION_TYPES { INIT, ADD_ITEM, SET_ITEMS, REMOVE_ITEM, UPDATE_ITEM, CLEAR_ITEMS, SET_REGION, } type Action = | { type: ACTION_TYPES.SET_REGION; payload: RegionInfo } | { type: ACTION_TYPES.INIT; payload: object } | { type: ACTION_TYPES.ADD_ITEM; payload: Item } | { type: ACTION_TYPES.UPDATE_ITEM payload: { id: string; item: Partial } } | { type: ACTION_TYPES.REMOVE_ITEM; payload: { id: string } } | { type: ACTION_TYPES.SET_ITEMS; payload: Item[] } | { type: ACTION_TYPES.CLEAR_ITEMS } const reducer = (state: SessionCartState, action: Action) => { switch (action.type) { case ACTION_TYPES.INIT: { return state } case ACTION_TYPES.SET_REGION: { return generateCartState( { ...state, region: action.payload, }, state.items ) } case ACTION_TYPES.ADD_ITEM: { const duplicateVariantIndex = state.items.findIndex( item => item.variant.id === action.payload?.variant?.id ) if (duplicateVariantIndex !== -1) { state.items.splice(duplicateVariantIndex, 1) } const items = [...state.items, action.payload] return generateCartState(state, items) } case ACTION_TYPES.UPDATE_ITEM: { const items = state.items.map(item => item.variant.id === action.payload.id ? { ...item, ...action.payload.item } : item ) return generateCartState(state, items) } case ACTION_TYPES.REMOVE_ITEM: { const items = state.items.filter( item => item.variant.id !== action.payload.id ) return generateCartState(state, items) } case ACTION_TYPES.SET_ITEMS: { return generateCartState(state, action.payload) } case ACTION_TYPES.CLEAR_ITEMS: { return { ...state, items: [], total: 0, totalItems: 0, } } default: return state } } export const generateCartState = (state: SessionCartState, items: Item[]) => { const newItems = generateItems(state.region, items) return { ...state, items: newItems, totalItems: items.reduce((sum, item) => sum + item.quantity, 0), total: calculateSessionCartTotal(newItems), } } const generateItems = (region: RegionInfo, items: Item[]) => { return items.map(item => ({ ...item, total: getVariantPrice(item.variant, region), })) } const calculateSessionCartTotal = (items: Item[]) => { return items.reduce( (total, item) => total + item.quantity * (item.total || 0), 0 ) } interface SessionCartProviderProps { children: React.ReactNode initialState?: SessionCartState } const defaultInitialState: SessionCartState = { region: {} as RegionInfo, items: [], total: 0, totalItems: 0, } export const SessionCartProvider = ({ initialState = defaultInitialState, children, }: SessionCartProviderProps) => { const [saved, save] = useLocalStorage( "medusa-session-cart", JSON.stringify(initialState) ) const [state, dispatch] = React.useReducer(reducer, JSON.parse(saved)) useEffect(() => { save(JSON.stringify(state)) }, [state, save]) const setRegion = (region: RegionInfo) => { if (!isObject(region) || isEmpty(region)) { throw new Error("region must be a non-empty object") } dispatch({ type: ACTION_TYPES.SET_REGION, payload: region }) } const getItem = (id: string) => { return state.items.find(item => item.variant.id === id) } const setItems = (items: Item[]) => { if (!isArray(items)) { throw new Error("items must be an array of items") } dispatch({ type: ACTION_TYPES.SET_ITEMS, payload: items }) } const addItem = (item: Item) => { if (!isObject(item) || isEmpty(item)) { throw new Error("item must be a non-empty object") } dispatch({ type: ACTION_TYPES.ADD_ITEM, payload: item }) } const updateItem = (id: string, item: Partial) => { dispatch({ type: ACTION_TYPES.UPDATE_ITEM, payload: { id, item } }) } const updateItemQuantity = (id: string, quantity: number) => { const item = getItem(id) if (!item) return quantity = quantity <= 0 ? 1 : quantity dispatch({ type: ACTION_TYPES.UPDATE_ITEM, payload: { id, item: { ...item, quantity: Math.min(item.variant.inventory_quantity, quantity), }, }, }) } const incrementItemQuantity = (id: string) => { const item = getItem(id) if (!item) return dispatch({ type: ACTION_TYPES.UPDATE_ITEM, payload: { id, item: { ...item, quantity: Math.min( item.variant.inventory_quantity, item.quantity + 1 ), }, }, }) } const decrementItemQuantity = (id: string) => { const item = getItem(id) if (!item) return dispatch({ type: ACTION_TYPES.UPDATE_ITEM, payload: { id, item: { ...item, quantity: Math.max(0, item.quantity - 1) }, }, }) } const removeItem = (id: string) => { dispatch({ type: ACTION_TYPES.REMOVE_ITEM, payload: { id }, }) } const clearItems = () => { dispatch({ type: ACTION_TYPES.CLEAR_ITEMS, }) } return ( {children} ) } export const useSessionCart = () => { const context = useContext(SessionCartContext) if (!context) { throw new Error( "useSessionCart should be used as a child of SessionCartProvider" ) } return context }