Search for a command to run...
To integrate the ApiShip fulfillment provider into a Next.js storefront, you need to extend the checkout shipping step to support pickup-point delivery.
ApiShip shipping differs from regular shipping methods in that it requires additional data to be collected during checkout, such as the selected pickup point and delivery tariff. This data must be loaded dynamically from the backend, presented to the customer in a convenient form, and then persisted in the cart so it can be used later by the fulfillment provider when creating an order in the ApiShip.
The pickup point selector uses Yandex Maps JS API v3. To enable the map in the storefront, you must provide a public API key through environment variables.
Add the following to your storefront’s file:
For the storefront on Next.js, you need to make the following changes:
To support ApiShip delivery, the storefront needs a way to attach provider-specific data to a shipping method. This data is used to store delivery-related information that cannot be derived from the shipping option alone, such as the selected pickup point and tariff.
export async function setShippingMethod({cartId,shippingMethodId,data,}: {cartId: stringshippingMethodId: stringdata?: Record<string, any>}) {const headers = {...(await getAuthHeaders()),}return sdk.store.cart.addShippingMethod(cartId,{option_id: shippingMethodId,data // ApiShip selection data},{},headers).then(async () => {const cartCacheTag = await getCacheTag("carts")revalidateTag(cartCacheTag)}).catch(medusaError)}
By extending the helper to accept an optional field, the storefront can include ApiShip-specific information when applying a shipping method to the cart.
This data is later read by the ApiShip fulfillment provider during order creation, allowing it to correctly create an order in the ApiShip using the pickup point and tariff selected during checkout.
To render pickup delivery options in the shipping step, the storefront needs access to ApiShip calculation data and pickup points details. This information is not static and must be requested dynamically based on the current cart and selected shipping option.
Open and add the following helpers:
// ... other helpersexport const retrieveCalculation = async (cartId: string,shippingOptionId: string) => {const headers = {...(await getAuthHeaders()),}const next = {...(await getCacheOptions("fulfillment")),}const body = { cart_id: cartId }return sdk.client.fetch<Record<string, unknown>>(`/store/apiship/${shippingOptionId}/calculate`,{method: "POST",headers,body,next,}).then(({ data }) => data).catch((e) => {return null})}export const getPointAddresses = async (cartId: string,shippingOptionId: string,pointIds: Array<number>) => {const headers = {...(await getAuthHeaders()),}const next = {...(await getCacheOptions("fulfillment")),}const body = {cartId,shippingOptionId,pointIds}return sdk.client.fetch<{points: any[]meta: any}>(`/store/apiship/points`, {method: "POST",headers,body,next,}).catch((e) => {console.error("getPointsAddresses error", e)return null})}
The request returns available delivery tariffs together with the pickup point identifiers they apply to. The request resolves those identifiers into full pickup point data, such as addresses and coordinates, which can then be displayed in the checkout interface.
ApiShip requires the recipient’s phone number for shipping calculations and for creating an order in the ApiShip. For this reason, the storefront must enforce a phone number in the shipping address form.
Open and mark the phone input as required:
<Inputlabel="Phone"name="shipping_address.phone"autoComplete="tel"value={formData["shipping_address.phone"]}onChange={handleChange}required // Required for ApiShipdata-testid="shipping-phone-input"/>
By marking the phone input as , the checkout always collects the recipient phone number early in the flow, ensuring the ApiShip shipping calculation and shipment creation requests have the necessary data available.
To integrate ApiShip delivery into the shipping step, the storefront needs a component that loads pickup points and tariffs for the selected shipping option, tracks the current selection, and persists the final choice into the cart.
Create the file with the following content:
"use client"import { useCallback, useEffect, useRef, useState } from "react"import { Button, Text } from "@medusajs/ui"import { HttpTypes } from "@medusajs/types"import { setShippingMethod } from "@lib/data/cart"import {calculatePriceForShippingOption,retrieveCalculation,getPointAddresses,} from "@lib/data/fulfillment"importApishipMap,{ ApishipPoint }from "./apiship-map"type ApishipTariffForPoint = {key: stringproviderKey: stringtariffProviderId?: stringtariffId?: numbertariffName?: stringdeliveryCost?: numberdeliveryCostOriginal?: numberdaysMin?: numberdaysMax?: numbercalendarDaysMin?: numbercalendarDaysMax?: numberworkDaysMin?: numberworkDaysMax?: number}type ApishipCalculation = {deliveryToPoint?: Array<{providerKey: stringtariffs?: Array<{tariffProviderId?: stringtariffId?: numbertariffName?: stringdeliveryCost?: numberdeliveryCostOriginal?: numberdaysMin?: numberdaysMax?: numbercalendarDaysMin?: numbercalendarDaysMax?: numberworkDaysMin?: numberworkDaysMax?: numberpointIds?: number[]}>}>}type Chosen = {pointId: stringdescription?: stringworktime?: Record<string, string>photos?: string[]pointLabel: stringtariffKey: stringtariffLabel: stringpriceLabel: stringdaysLabel: stringapiship: {pointId: stringpointProviderKey?: stringtariffKey: stringtariffId?: numbertariffProviderId?: stringtariffProviderKey?: stringdeliveryCost?: numberdaysMin?: numberdaysMax?: number}}function buildTariffsByPointId(calculation?: ApishipCalculation | null) {const map: Record<string, ApishipTariffForPoint[]> = {}calculation?.deliveryToPoint?.forEach(({ providerKey, tariffs }) => {tariffs?.forEach((tariff) => {for (const pointId of tariff.pointIds ?? []) {const key = `${providerKey}:${tariff.tariffProviderId ?? ""}:${tariff.tariffId ?? ""}`const entry: ApishipTariffForPoint = {key,providerKey,tariffProviderId: tariff.tariffProviderId,tariffId: tariff.tariffId,tariffName: tariff.tariffName,deliveryCost: tariff.deliveryCost,deliveryCostOriginal: tariff.deliveryCostOriginal,daysMin: tariff.daysMin,daysMax: tariff.daysMax,calendarDaysMin: tariff.calendarDaysMin,calendarDaysMax: tariff.calendarDaysMax,workDaysMin: tariff.workDaysMin,workDaysMax: tariff.workDaysMax,}const arr = (map[String(pointId)] ??= [])if (!arr.some((tariff) => tariff.key === key)) arr.push(entry)}})})return map}const WEEK_DAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] as constconst Schedule = ({worktime}: {worktime: Record<string, string>}) => {if (Object.keys(worktime).length > 0) {return (<div className="py-3"><Text className="text-ui-fg-muted mt-1">Schedule:</Text>{Object.keys(worktime).map((day) => {const label = WEEK_DAYS[Number(day) - 1]const time = worktime[day]return (<Text className="text-ui-fg-muted mt-1" key={day}>{label}: {time}</Text>)})}</div>)} else {return null}}function extractPointIds(map: Record<string, ApishipTariffForPoint[]>) {return Object.keys(map).map(Number).filter(Number.isFinite)}function money(amount: number, currencyCode: string) {try {return new Intl.NumberFormat("ru-RU", {style: "currency",currency: currencyCode.toUpperCase(),maximumFractionDigits: 2,}).format(amount)} catch {return `${amount} ${currencyCode.toUpperCase()}`}}function days(t: ApishipTariffForPoint) {const min = t.daysMin ?? 0const max = t.daysMax ?? 0if (!min && !max) return "—"if (min === max) return min === 1 ? `${min} day` : `${min} days`return `${min}–${max} days`}function useLatestRef<T>(value: T) {const ref = useRef(value)useEffect(() => {ref.current = value}, [value])return ref}function useAsyncEffect(fn: (isCancelled: () => boolean) => Promise<void>, deps: any[]) {useEffect(() => {let cancelled = falseconst isCancelled = () => cancelledfn(isCancelled).catch((e) => console.error(e))return () => {cancelled = true}// eslint-disable-next-line react-hooks/exhaustive-deps}, deps)}type PanelProps = {enabled: booleancart: HttpTypes.StoreCartshippingOptionId: string | nullonReadyChange?: (ready: boolean) => voidonPriceUpdate?: (shippingOptionId: string, amount: number) => voidonError?: (message: string) => void}const ApishipWrapper = ({enabled,cart,shippingOptionId,onReadyChange,onPriceUpdate,onError,}: PanelProps) => {const onErrorRef = useLatestRef(onError)const onReadyRef = useLatestRef(onReadyChange)const onPriceRef = useLatestRef(onPriceUpdate)const [isLoadingPoints, setIsLoadingPoints] = useState(false)const [points, setPoints] = useState<ApishipPoint[]>([])const [tariffsByPointId, setTariffsByPointId] = useState<Record<string, ApishipTariffForPoint[]>>({})const [selectedPointId, setSelectedPointId] = useState<string | null>(null)const [selectedTariffKey, setSelectedTariffKey] = useState<string | null>(null)const [chosen, setChosen] = useState<Chosen | null>(null)const [isPanelOpen, setIsPanelOpen] = useState(false)useEffect(() => {if (!enabled) {setIsLoadingPoints(false)setPoints([])setTariffsByPointId({})setSelectedPointId(null)setSelectedTariffKey(null)setChosen(null)setIsPanelOpen(false)onReadyRef.current?.(true)} else {onReadyRef.current?.(false)}}, [enabled, onReadyRef])useEffect(() => {onReadyRef.current?.(!enabled || !!chosen)}, [enabled, chosen, onReadyRef])useAsyncEffect(async (isCancelled) => {if (!enabled || !shippingOptionId) returnsetIsLoadingPoints(true)try {const calculation = (await retrieveCalculation(cart.id, shippingOptionId)) as ApishipCalculationif (isCancelled()) returnconst tariffsMap = buildTariffsByPointId(calculation)setTariffsByPointId(tariffsMap)const pointIds = extractPointIds(tariffsMap)if (!pointIds.length) {setPoints([])return}const pointAddresses = (await getPointAddresses(cart.id, shippingOptionId, pointIds)) as {points: ApishipPoint[]}if (isCancelled()) returnsetPoints(pointAddresses?.points ?? [])} catch (e: any) {console.error("ApishipToPointPanel load failed", e)onErrorRef.current?.(e?.message ?? "Failed to load pickup points")setPoints([])setTariffsByPointId({})} finally {if (!isCancelled()) setIsLoadingPoints(false)}},[enabled, shippingOptionId, cart.id])const persistChosen = useCallback(async (next: Chosen) => {if (!shippingOptionId) returnawait setShippingMethod({cartId: cart.id,shippingMethodId: shippingOptionId,data: { apiship: next.apiship },})const calculation = await calculatePriceForShippingOption(shippingOptionId, cart.id)if (calculation?.id && typeof calculation.amount === "number") {onPriceRef.current?.(calculation.id, calculation.amount)}},[cart.id, shippingOptionId, onPriceRef])if (!enabled) return nullreturn (<div className="mt-4 rounded-rounded border bg-ui-bg-base p-4"><span className="font-medium txt-medium text-ui-fg-base block">Pickup Point</span><span className="mb-3 text-ui-fg-muted txt-medium block">Select a pickup point and tariff</span><ApishipMappoints={points}tariffsByPointId={tariffsByPointId}isLoading={isLoadingPoints}currencyCode={cart.currency_code}selectedPointId={selectedPointId}selectedTariffKey={selectedTariffKey}isPanelOpen={isPanelOpen}onClosePanel={() => setIsPanelOpen(false)}onSelectPoint={(pid) => {setSelectedPointId(pid)setSelectedTariffKey(null)setIsPanelOpen(true)}}onSelectTariff={(key) => {setSelectedTariffKey(key)}}onChoose={async ({ point, tariff }) => {const cost =typeof tariff.deliveryCostOriginal === "number" ? tariff.deliveryCostOriginal : tariff.deliveryCostconst next: Chosen = {pointId: point.id,description: point.description,worktime: point.worktime,photos: point.photos,pointLabel: point.name || point.address || `Point #${point.id}`,tariffKey: tariff.key,tariffLabel: `${tariff.tariffName || "Tariff"} (${tariff.providerKey})`,priceLabel: typeof cost === "number" ? money(cost, cart.currency_code) : "—",daysLabel: days(tariff),apiship: {pointId: point.id,pointProviderKey: point.providerKey,tariffKey: tariff.key,tariffId: tariff.tariffId,tariffProviderId: tariff.tariffProviderId,tariffProviderKey: tariff.providerKey,deliveryCost: cost,daysMin: tariff.daysMin,daysMax: tariff.daysMax,},}setChosen(next)try {await persistChosen(next)} catch (e: any) {onErrorRef.current?.(e?.message ?? "Failed to save the selected tariff")}}}/>{chosen && (<div className="mt-4 rounded-rounded border p-4"><Text className="txt-medium-plus">Pickup Point</Text><Text className="text-ui-fg-muted mt-1">{chosen.pointLabel}</Text><Text className="text-ui-fg-muted mt-1">{chosen.description}</Text><Schedule worktime={chosen.worktime!} /><Text className="text-ui-fg-muted mt-1 pb-3">{chosen.tariffLabel} · {chosen.priceLabel} · {chosen.daysLabel}</Text><Buttonsize="small"onClick={(e) => {e.preventDefault()e.stopPropagation()setSelectedPointId(null)setSelectedTariffKey(null)setChosen(null)setIsPanelOpen(false)}}>Remove</Button></div>)}</div>)}export default ApishipWrapper
The component retrieves the ApiShip calculation, builds a tariff list grouped by pickup point, and then resolves pickup point IDs into full point objects. When a point and tariff are selected, the wrapper saves the selection into the cart by calling with necessary data, so the ApiShip fulfillment provider can later create the fulfillment using the stored metadata.
To select a pickup point in the shipping step, the storefront needs a UI component that visualizes available points and allows choosing a tariff for a specific point. This is implemented as a client-side component based on Yandex Maps JS API v3 and Medusa UI primitives.
Create the file with the following content:
"use client"import { useCallback, useEffect, useMemo, useRef } from "react"import { Button, Text, clx } from "@medusajs/ui"const DEFAULT_CENTER: [number, number] = [37.618423, 55.751244]export type ApishipPoint = {id: stringproviderKey?: stringavailableOperation?: numbername?: stringdescription?: stringworktime?: Record<string, string>photos?: string[]lat: numberlng: numbercode?: stringpostIndex?: stringregion?: stringcity?: stringaddress?: stringphone?: string}export type ApishipTariffForPoint = {key: stringproviderKey: stringtariffProviderId?: stringtariffId?: numbertariffName?: stringdeliveryCost?: numberdeliveryCostOriginal?: numberdaysMin?: numberdaysMax?: numbercalendarDaysMin?: numbercalendarDaysMax?: numberworkDaysMin?: numberworkDaysMax?: number}type MapProps = {points: ApishipPoint[]tariffsByPointId: Record<string, ApishipTariffForPoint[]>isLoading?: booleancurrencyCode: stringselectedPointId: string | nullselectedTariffKey: string | nullisPanelOpen: booleanonClosePanel: () => voidonSelectPoint: (id: string) => voidonSelectTariff: (tariffKey: string) => voidonChoose: (payload: { point: ApishipPoint; tariff: ApishipTariffForPoint }) => void}export function useLatestRef<T>(value: T) {const ref = useRef(value)useEffect(() => {ref.current = value}, [value])return ref}declare global {interface Window {ymaps3?: any__ymaps3_loading_promise__?: Promise<void>}}function ensureYmaps3Loaded(params: { apikey: string; lang?: string }): Promise<void> {if (typeof window === "undefined") return Promise.resolve()if (window.__ymaps3_loading_promise__) return window.__ymaps3_loading_promise__window.__ymaps3_loading_promise__ = new Promise<void>((resolve, reject) => {const existing = document.querySelector<HTMLScriptElement>('script[src^="https://api-maps.yandex.ru/v3/"]')if (existing) return resolve()const script = document.createElement("script")script.src = `https://api-maps.yandex.ru/v3/?apikey=${encodeURIComponent(params.apikey)}&lang=${encodeURIComponent(params.lang ?? "ru_RU")}`script.async = truescript.onload = () => resolve()script.onerror = () => reject(new Error(`Failed to load Yandex Maps JS API v3 script: ${script.src}`))document.head.appendChild(script)})return window.__ymaps3_loading_promise__}function money(amount: number, currencyCode: string) {try {return new Intl.NumberFormat("ru-RU", {style: "currency",currency: currencyCode.toUpperCase(),maximumFractionDigits: 2,}).format(amount)} catch {return `${amount} ${currencyCode.toUpperCase()}`}}function days(t: ApishipTariffForPoint) {const min = t.daysMin ?? 0const max = t.daysMax ?? 0if (!min && !max) return "—"if (min === max) return min === 1 ? `${min} day` : `${min} days`return `${min}–${max} days`}const ApishipMap = ({points,tariffsByPointId,isLoading,currencyCode,selectedPointId,selectedTariffKey,isPanelOpen,onClosePanel,onSelectPoint,onSelectTariff,onChoose,}: MapProps) => {const containerRef = useRef<HTMLDivElement | null>(null)const mapRef = useRef<any>(null)const markersRef = useRef<Map<string, { marker: any; el: HTMLDivElement }>>(new Map())const initPromiseRef = useRef<Promise<void> | null>(null)const onSelectPointRef = useLatestRef(onSelectPoint)const center = useMemo<[number, number]>(() => {const p = points?.[0]return p ? [p.lng, p.lat] : DEFAULT_CENTER}, [points])const activePoint = useMemo(() => {return selectedPointId ? points.find((p) => p.id === selectedPointId) ?? null : null}, [points, selectedPointId])const activeTariffs = useMemo(() => {if (!selectedPointId) return []return tariffsByPointId[selectedPointId] ?? []}, [tariffsByPointId, selectedPointId])const selectedTariff = useMemo(() => {if (!selectedTariffKey) return nullreturn activeTariffs.find((t) => t.key === selectedTariffKey) ?? null}, [activeTariffs, selectedTariffKey])const clearMarkers = useCallback(() => {const map = mapRef.currentif (!map) returnfor (const { marker } of markersRef.current.values()) {try {map.removeChild(marker)} catch { }}markersRef.current.clear()}, [])const destroyMap = useCallback(() => {clearMarkers()try {mapRef.current?.destroy?.()} catch { }mapRef.current = nullinitPromiseRef.current = null}, [clearMarkers])useEffect(() => {if (!containerRef.current || mapRef.current) returnlet cancelled = falseinitPromiseRef.current = (async () => {const apikey = process.env.NEXT_PUBLIC_YANDEX_MAPS_API_KEYif (!apikey) throw new Error("NEXT_PUBLIC_YANDEX_MAPS_API_KEY is not set")await ensureYmaps3Loaded({ apikey, lang: "ru_RU" })if (cancelled) returnconst ymaps3 = window.ymaps3if (!ymaps3) returnawait ymaps3.readyif (cancelled) returnymaps3.import.registerCdn("https://cdn.jsdelivr.net/npm/{package}", "@yandex/ymaps3-default-ui-theme@0.0")const { YMap, YMapDefaultSchemeLayer, YMapDefaultFeaturesLayer } = ymaps3const map = new YMap(containerRef.current!, { location: { center, zoom: 10 } })map.addChild(new YMapDefaultSchemeLayer({}))map.addChild(new YMapDefaultFeaturesLayer({ zIndex: 1800 }))mapRef.current = map})().catch((e) => console.error("Yandex map init failed", e))return () => {cancelled = truedestroyMap()}// eslint-disable-next-line react-hooks/exhaustive-deps}, [destroyMap])useEffect(() => {; (async () => {if (!initPromiseRef.current) returnawait initPromiseRef.currenttry {mapRef.current?.setLocation?.({ center, zoom: 10 })} catch { }})()}, [center])useEffect(() => {let cancelled = false; (async () => {if (!initPromiseRef.current) returnawait initPromiseRef.currentif (cancelled) returnconst map = mapRef.currentconst ymaps3 = window.ymaps3if (!map || !ymaps3) returnconst { YMapMarker } = ymaps3clearMarkers()for (const p of points) {const el = document.createElement("div")const SIZE = 14el.style.width = `${SIZE}px`el.style.height = `${SIZE}px`el.style.background = "white"el.style.border = "2px solid rgba(0,0,0,0.25)"el.style.borderRadius = "50% 50% 50% 0"el.style.transform = "rotate(-45deg)"el.style.boxShadow = "0 2px 10px rgba(0,0,0,0.18)"el.style.cursor = "pointer"el.style.position = "relative"el.style.transformOrigin = "50% 50%"const dot = document.createElement("div")dot.style.width = "6px"dot.style.height = "6px"dot.style.background = "white"dot.style.border = "2px solid rgba(0,0,0,0.25)"dot.style.borderRadius = "9999px"dot.style.position = "absolute"dot.style.left = "50%"dot.style.top = "50%"dot.style.transform = "translate(-50%, -50%) rotate(45deg)"dot.style.boxSizing = "border-box"el.appendChild(dot)el.title = p.name ?? p.address ?? `Point ${p.id}`el.addEventListener("click", (e) => {e.preventDefault()e.stopPropagation()onSelectPointRef.current(p.id)})const marker = new YMapMarker({ coordinates: [p.lng, p.lat] }, el)map.addChild(marker)markersRef.current.set(p.id, { marker, el })}})()return () => {cancelled = true}}, [points, clearMarkers, onSelectPointRef])useEffect(() => {for (const [id, { el }] of markersRef.current.entries()) {const sel = id === selectedPointIdel.style.background = sel ? "#3b82f6" : "white"el.style.border = sel ? "2px solid #1d4ed8" : "2px solid rgba(0,0,0,0.25)"const dot = el.firstElementChild as HTMLElement | nullif (dot) {dot.style.background = sel ? "white" : "white"dot.style.border = sel ? "2px solid rgba(255,255,255,0.95)" : "2px solid rgba(0,0,0,0.25)"}}}, [selectedPointId])const showNoPoints = !isLoading && points.length === 0return (<div className="w-full"><div className="relative rounded-rounded overflow-hidden border"><div ref={containerRef} style={{ width: "100%", height: 520 }} />{isLoading && (<div className="absolute inset-0 bg-white/80 flex items-center justify-center"><Text className="text-ui-fg-muted">Loading pickup points…</Text></div>)}{showNoPoints && (<div className="absolute inset-0 bg-white/80 flex items-center justify-center"><Text className="text-ui-fg-muted">No pickup points found for this shipping method.</Text></div>)}{activePoint && isPanelOpen && !showNoPoints && (<div className="absolute left-3 top-3 right-3 md:right-auto md:w-[460px] rounded-rounded border bg-white/95 p-4 shadow"><buttontype="button"aria-label="Close"onClick={(e) => {e.preventDefault()e.stopPropagation()onClosePanel()}}className="absolute right-2 top-2 inline-flex h-8 w-8 items-center justify-center rounded-full border bg-white/70 text-ui-fg-subtle hover:bg-white">✕</button><Text className="txt-medium-plus">{activePoint.name || `Point #${activePoint.id}`}</Text>{activePoint.address && <Text className="text-ui-fg-muted mt-1">{activePoint.address}</Text>}<div className="mt-3"><Text className="txt-medium-plus">Tariffs</Text>{activeTariffs.length ? (<div className="mt-2 flex flex-col gap-2 max-h-[220px] overflow-auto p-1">{activeTariffs.map((t) => {const active = t.key === selectedTariffKeyconst cost = typeof t.deliveryCostOriginal === "number" ? t.deliveryCostOriginal : t.deliveryCostconst price = typeof cost === "number" ? money(cost, currencyCode) : "—"return (<buttonkey={t.key}type="button"onClick={() => onSelectTariff(t.key)}className={clx("box-border text-left rounded-rounded border px-3 py-2 hover:shadow-borders-interactive-with-active",{ "border-ui-border-interactive bg-ui-bg-subtle": active })}><div className="flex items-center justify-between gap-3"><Text className="txt-small-plus">{t.tariffName || "Tariff"} <span className="text-ui-fg-muted">({t.providerKey})</span></Text><Text className="txt-small-plus">{price}</Text></div><Text className="text-ui-fg-muted mt-1 txt-small">Delivery time: {days(t)}</Text></button>)})}</div>) : (<Text className="text-ui-fg-muted mt-2">There are no tariffs for this point.</Text>)}</div><div className="mt-3 flex items-center gap-2"><Buttonsize="small"onClick={() => {if (!activePoint || !selectedTariff) returnonChoose({ point: activePoint, tariff: selectedTariff })}}disabled={!selectedTariff}>Choose</Button>{!selectedTariff && (<Text className="text-ui-fg-muted txt-small">Select a tariff to confirm your choice.</Text>)}</div></div>)}</div>{!activePoint && !showNoPoints && !isLoading && (<Text className="text-ui-fg-muted mt-2">Click on a point on the map to select a pickup point.</Text>)}</div>)}export default ApishipMap
The component renders pickup points as map markers and opens a details panel for the selected point. The panel lists available tariffs for that point, including price and delivery time, and confirms the selection through the callback, passing the chosen point and tariff back to the checkout flow.
To enable ApiShip delivery in checkout, the shipping step must detect when an ApiShip pickup shipping option is selected, render the pickup selection UI, and control the step completion state based on whether the selection is saved to the cart.
Open and add the following content:
// ... other importsimport { useEffect, useMemo, useState } from "react"import ApishipWrapper from "./apiship-wrapper"const Shipping: React.FC<ShippingProps> = ({cart,availableShippingMethods,}) => {// ... other checkout state// Controls whether ApiShip pickup selection is completedconst [apishipReady, setApishipReady] = useState(true)// Helper to detect ApiShip pickup delivery optionconst isApishipToPoint = (option?: HttpTypes.StoreCartShippingOption | null) =>option?.price_type === "calculated" &&option?.data?.deliveryType === 2// Currently selected shipping optionconst activeShippingOption = useMemo(() => {return _shippingMethods?.find((option) => option.id === shippingMethodId) ?? null}, [_shippingMethods, shippingMethodId])// ApiShip pickup is active only for specific optionsconst isApishipActive = useMemo(() => {return isOpen &&!!shippingMethodId &&isApishipToPoint(activeShippingOption)}, [isOpen, shippingMethodId, activeShippingOption])const handleSetShippingMethod = async (id: string,variant: "shipping" | "pickup") => {// ... set shipping method logicif (variant === "pickup") {setShowPickupOptions(PICKUP_OPTION_ON)setApishipReady(true) // reset pickup selection state} else {setShowPickupOptions(PICKUP_OPTION_OFF)}}return (<>{/* ... shipping methods UI */}<ApishipWrapperenabled={isApishipActive}cart={cart}shippingOptionId={shippingMethodId}onReadyChange={setApishipReady}onPriceUpdate={(id, amount) => {setCalculatedPricesMap((prev) => ({...prev,[id]: amount,}))}}onError={(msg) => setError(msg)}/><Buttonsize="large"className="mt"onClick={handleSubmit}isLoading={isLoading}disabled={!cart.shipping_methods?.[0] ||(isApishipActive && !apishipReady) // block until pickup selected}data-testid="submit-delivery-option-button">Continue to payment</Button>{isApishipActive && !apishipReady && (<Text className="text-ui-fg-muted mt-2">To continue, select a pickup point and click <b>Choose</b> on the desired tariff.</Text>)}</>)}
This integration adds an state that is updated by through . When an ApiShip shipping option is active and the selection is not yet completed, the shipping step disables the button. This ensures the cart always contains the required ApiShip shipping data before moving to the payment step.
You can refer to the modifications made in the Medusa Next.js Starter Template, which are located in the directory.
The complete integration diff can be viewed in the comparison page, open the tab, and explore the differences under the directory. Or run diff in the terminal:
git clone https://github.com/gorgojs/medusa-pluginscd medusa-pluginsgit diff @gorgo/medusa-fulfillment-apiship@0.1.0...main -- examples/fulfillment-apiship/medusa-storefront