Command Palette

Search for a command to run...

Integrate ApiShip Fulfillment to Storefront

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.

Environment Variables

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:

1. Provider-specific Data Support

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.

Open and add the following:

Directory structure in the Medusa Storefront after updating the file for cart

export async function setShippingMethod({
cartId,
shippingMethodId,
data,
}: {
cartId: string
shippingMethodId: string
data?: 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.

2. Calculating Shipping Costs and Requesting Pickup Points

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:

Directory structure in the Medusa Storefront after updating the file for fulfillment

// ... other helpers
export 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.

3. Require Recipient Phone Number

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:

Directory structure in the Medusa Storefront after updating the file for shipping address component

<Input
label="Phone"
name="shipping_address.phone"
autoComplete="tel"
value={formData["shipping_address.phone"]}
onChange={handleChange}
required // Required for ApiShip
data-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.

4. Pickup Point Selection Wrapper

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:

Directory structure in the Medusa Storefront after creating the file for apiship wrapper component

"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"
import
ApishipMap,
{ ApishipPoint }
from "./apiship-map"
type ApishipTariffForPoint = {
key: string
providerKey: string
tariffProviderId?: string
tariffId?: number
tariffName?: string
deliveryCost?: number
deliveryCostOriginal?: number
daysMin?: number
daysMax?: number
calendarDaysMin?: number
calendarDaysMax?: number
workDaysMin?: number
workDaysMax?: number
}
type ApishipCalculation = {
deliveryToPoint?: Array<{
providerKey: string
tariffs?: Array<{
tariffProviderId?: string
tariffId?: number
tariffName?: string
deliveryCost?: number
deliveryCostOriginal?: number
daysMin?: number
daysMax?: number
calendarDaysMin?: number
calendarDaysMax?: number
workDaysMin?: number
workDaysMax?: number
pointIds?: number[]
}>
}>
}
type Chosen = {
pointId: string
description?: string
worktime?: Record<string, string>
photos?: string[]
pointLabel: string
tariffKey: string
tariffLabel: string
priceLabel: string
daysLabel: string
apiship: {
pointId: string
pointProviderKey?: string
tariffKey: string
tariffId?: number
tariffProviderId?: string
tariffProviderKey?: string
deliveryCost?: number
daysMin?: number
daysMax?: 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 const
const 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 ?? 0
const max = t.daysMax ?? 0
if (!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 = false
const isCancelled = () => cancelled
fn(isCancelled).catch((e) => console.error(e))
return () => {
cancelled = true
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps)
}
type PanelProps = {
enabled: boolean
cart: HttpTypes.StoreCart
shippingOptionId: string | null
onReadyChange?: (ready: boolean) => void
onPriceUpdate?: (shippingOptionId: string, amount: number) => void
onError?: (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) return
setIsLoadingPoints(true)
try {
const calculation = (await retrieveCalculation(cart.id, shippingOptionId)) as ApishipCalculation
if (isCancelled()) return
const 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()) return
setPoints(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) return
await 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 null
return (
<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>
<ApishipMap
points={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.deliveryCost
const 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>
<Button
size="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.

5. Pickup Point Map Component

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:

Directory structure in the Medusa Storefront after creating the file for ApiShip map component

"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: string
providerKey?: string
availableOperation?: number
name?: string
description?: string
worktime?: Record<string, string>
photos?: string[]
lat: number
lng: number
code?: string
postIndex?: string
region?: string
city?: string
address?: string
phone?: string
}
export type ApishipTariffForPoint = {
key: string
providerKey: string
tariffProviderId?: string
tariffId?: number
tariffName?: string
deliveryCost?: number
deliveryCostOriginal?: number
daysMin?: number
daysMax?: number
calendarDaysMin?: number
calendarDaysMax?: number
workDaysMin?: number
workDaysMax?: number
}
type MapProps = {
points: ApishipPoint[]
tariffsByPointId: Record<string, ApishipTariffForPoint[]>
isLoading?: boolean
currencyCode: string
selectedPointId: string | null
selectedTariffKey: string | null
isPanelOpen: boolean
onClosePanel: () => void
onSelectPoint: (id: string) => void
onSelectTariff: (tariffKey: string) => void
onChoose: (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 = true
script.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 ?? 0
const max = t.daysMax ?? 0
if (!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 null
return activeTariffs.find((t) => t.key === selectedTariffKey) ?? null
}, [activeTariffs, selectedTariffKey])
const clearMarkers = useCallback(() => {
const map = mapRef.current
if (!map) return
for (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 = null
initPromiseRef.current = null
}, [clearMarkers])
useEffect(() => {
if (!containerRef.current || mapRef.current) return
let cancelled = false
initPromiseRef.current = (async () => {
const apikey = process.env.NEXT_PUBLIC_YANDEX_MAPS_API_KEY
if (!apikey) throw new Error("NEXT_PUBLIC_YANDEX_MAPS_API_KEY is not set")
await ensureYmaps3Loaded({ apikey, lang: "ru_RU" })
if (cancelled) return
const ymaps3 = window.ymaps3
if (!ymaps3) return
await ymaps3.ready
if (cancelled) return
ymaps3.import.registerCdn("https://cdn.jsdelivr.net/npm/{package}", "@yandex/ymaps3-default-ui-theme@0.0")
const { YMap, YMapDefaultSchemeLayer, YMapDefaultFeaturesLayer } = ymaps3
const 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 = true
destroyMap()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [destroyMap])
useEffect(() => {
; (async () => {
if (!initPromiseRef.current) return
await initPromiseRef.current
try {
mapRef.current?.setLocation?.({ center, zoom: 10 })
} catch { }
})()
}, [center])
useEffect(() => {
let cancelled = false
; (async () => {
if (!initPromiseRef.current) return
await initPromiseRef.current
if (cancelled) return
const map = mapRef.current
const ymaps3 = window.ymaps3
if (!map || !ymaps3) return
const { YMapMarker } = ymaps3
clearMarkers()
for (const p of points) {
const el = document.createElement("div")
const SIZE = 14
el.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 === selectedPointId
el.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 | null
if (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 === 0
return (
<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">
<button
type="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 === selectedTariffKey
const cost = typeof t.deliveryCostOriginal === "number" ? t.deliveryCostOriginal : t.deliveryCost
const price = typeof cost === "number" ? money(cost, currencyCode) : "—"
return (
<button
key={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">
<Button
size="small"
onClick={() => {
if (!activePoint || !selectedTariff) return
onChoose({ 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.

6. Shipping Step Completion

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:

Directory structure in the Medusa Storefront after updating the file for payment component

// ... other imports
import { 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 completed
const [apishipReady, setApishipReady] = useState(true)
// Helper to detect ApiShip pickup delivery option
const isApishipToPoint = (option?: HttpTypes.StoreCartShippingOption | null) =>
option?.price_type === "calculated" &&
option?.data?.deliveryType === 2
// Currently selected shipping option
const activeShippingOption = useMemo(() => {
return _shippingMethods?.find(
(option) => option.id === shippingMethodId
) ?? null
}, [_shippingMethods, shippingMethodId])
// ApiShip pickup is active only for specific options
const isApishipActive = useMemo(() => {
return isOpen &&
!!shippingMethodId &&
isApishipToPoint(activeShippingOption)
}, [isOpen, shippingMethodId, activeShippingOption])
const handleSetShippingMethod = async (
id: string,
variant: "shipping" | "pickup"
) => {
// ... set shipping method logic
if (variant === "pickup") {
setShowPickupOptions(PICKUP_OPTION_ON)
setApishipReady(true) // reset pickup selection state
} else {
setShowPickupOptions(PICKUP_OPTION_OFF)
}
}
return (
<>
{/* ... shipping methods UI */}
<ApishipWrapper
enabled={isApishipActive}
cart={cart}
shippingOptionId={shippingMethodId}
onReadyChange={setApishipReady}
onPriceUpdate={(id, amount) => {
setCalculatedPricesMap((prev) => ({
...prev,
[id]: amount,
}))
}}
onError={(msg) => setError(msg)}
/>
<Button
size="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.

Example

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-plugins
cd medusa-plugins
git diff @gorgo/medusa-fulfillment-apiship@0.1.0...main -- examples/fulfillment-apiship/medusa-storefront