feat: add medusa-react (#913)
This commit is contained in:
98
packages/medusa-react/src/contexts/cart.tsx
Normal file
98
packages/medusa-react/src/contexts/cart.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import React, { useState } from "react"
|
||||
import {
|
||||
useAddShippingMethodToCart,
|
||||
useCompleteCart,
|
||||
useCreateCart,
|
||||
useSetPaymentSession,
|
||||
useUpdateCart,
|
||||
useCreatePaymentSession,
|
||||
} from "../hooks/carts"
|
||||
import { Cart } from "../types"
|
||||
|
||||
interface CartState {
|
||||
cart?: Cart
|
||||
}
|
||||
|
||||
interface CartContext extends CartState {
|
||||
setCart: (cart: Cart) => void
|
||||
pay: ReturnType<typeof useSetPaymentSession>
|
||||
createCart: ReturnType<typeof useCreateCart>
|
||||
startCheckout: ReturnType<typeof useCreatePaymentSession>
|
||||
completeCheckout: ReturnType<typeof useCompleteCart>
|
||||
updateCart: ReturnType<typeof useUpdateCart>
|
||||
addShippingMethod: ReturnType<typeof useAddShippingMethodToCart>
|
||||
totalItems: number
|
||||
}
|
||||
|
||||
const CartContext = React.createContext<CartContext | null>(null)
|
||||
|
||||
export const useCart = () => {
|
||||
const context = React.useContext(CartContext)
|
||||
if (!context) {
|
||||
throw new Error("useCart must be used within a CartProvider")
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
interface CartProps {
|
||||
children: React.ReactNode
|
||||
initialState?: Cart
|
||||
}
|
||||
|
||||
const defaultInitialState = {
|
||||
id: "",
|
||||
items: [] as any,
|
||||
} as Cart
|
||||
|
||||
export const CartProvider = ({
|
||||
children,
|
||||
initialState = defaultInitialState,
|
||||
}: CartProps) => {
|
||||
const [cart, setCart] = useState<Cart>(initialState)
|
||||
|
||||
const createCart = useCreateCart({
|
||||
onSuccess: ({ cart }) => setCart(cart),
|
||||
})
|
||||
|
||||
const updateCart = useUpdateCart(cart?.id, {
|
||||
onSuccess: ({ cart }) => setCart(cart),
|
||||
})
|
||||
|
||||
const addShippingMethod = useAddShippingMethodToCart(cart?.id, {
|
||||
onSuccess: ({ cart }) => setCart(cart),
|
||||
})
|
||||
|
||||
const startCheckout = useCreatePaymentSession(cart?.id, {
|
||||
onSuccess: ({ cart }) => setCart(cart),
|
||||
})
|
||||
|
||||
const pay = useSetPaymentSession(cart?.id, {
|
||||
onSuccess: ({ cart }) => {
|
||||
setCart(cart)
|
||||
},
|
||||
})
|
||||
|
||||
const completeCheckout = useCompleteCart(cart?.id)
|
||||
|
||||
const totalItems = cart?.items
|
||||
.map((i) => i.quantity)
|
||||
.reduce((acc, curr) => acc + curr, 0)
|
||||
|
||||
return (
|
||||
<CartContext.Provider
|
||||
value={{
|
||||
cart,
|
||||
setCart,
|
||||
createCart,
|
||||
pay,
|
||||
startCheckout,
|
||||
completeCheckout,
|
||||
updateCart,
|
||||
addShippingMethod,
|
||||
totalItems: totalItems || 0,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</CartContext.Provider>
|
||||
)
|
||||
}
|
||||
3
packages/medusa-react/src/contexts/index.ts
Normal file
3
packages/medusa-react/src/contexts/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./medusa"
|
||||
export * from "./session-cart"
|
||||
export * from "./cart"
|
||||
38
packages/medusa-react/src/contexts/medusa.tsx
Normal file
38
packages/medusa-react/src/contexts/medusa.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React from "react"
|
||||
import { QueryClientProvider, QueryClientProviderProps } from "react-query"
|
||||
import Medusa from "@medusajs/medusa-js"
|
||||
|
||||
interface MedusaContextState {
|
||||
client: Medusa
|
||||
}
|
||||
|
||||
const MedusaContext = React.createContext<MedusaContextState | null>(null)
|
||||
|
||||
export const useMedusa = () => {
|
||||
const context = React.useContext(MedusaContext)
|
||||
if (!context) {
|
||||
throw new Error("useMedusa must be used within a MedusaProvider")
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
interface MedusaProviderProps {
|
||||
baseUrl: string
|
||||
queryClientProviderProps: QueryClientProviderProps
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export const MedusaProvider = ({
|
||||
queryClientProviderProps,
|
||||
baseUrl,
|
||||
children,
|
||||
}: MedusaProviderProps) => {
|
||||
const medusaClient = new Medusa({ baseUrl, maxRetries: 0 })
|
||||
return (
|
||||
<QueryClientProvider {...queryClientProviderProps}>
|
||||
<MedusaContext.Provider value={{ client: medusaClient }}>
|
||||
{children}
|
||||
</MedusaContext.Provider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
289
packages/medusa-react/src/contexts/session-cart.tsx
Normal file
289
packages/medusa-react/src/contexts/session-cart.tsx
Normal file
@@ -0,0 +1,289 @@
|
||||
import React, { useContext, useEffect } from "react"
|
||||
import { useLocalStorage } from "../hooks/utils"
|
||||
import { RegionInfo, ProductVariant } from "../types"
|
||||
import { getVariantPrice } from "../utils"
|
||||
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<Item>) => 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<SessionCartContextState | null>(
|
||||
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<Item> }
|
||||
}
|
||||
| { 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<Item>) => {
|
||||
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 (
|
||||
<SessionCartContext.Provider
|
||||
value={{
|
||||
...state,
|
||||
setRegion,
|
||||
addItem,
|
||||
updateItem,
|
||||
updateItemQuantity,
|
||||
incrementItemQuantity,
|
||||
decrementItemQuantity,
|
||||
removeItem,
|
||||
getItem,
|
||||
setItems,
|
||||
clearItems,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</SessionCartContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useSessionCart = () => {
|
||||
const context = useContext(SessionCartContext)
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useSessionCart should be used as a child of SessionCartProvider"
|
||||
)
|
||||
}
|
||||
return context
|
||||
}
|
||||
Reference in New Issue
Block a user