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.
For the storefront on Next.js, you need to make the following changes:
The pickup point selector uses Yandex Maps JavaScript 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:
NEXT_PUBLIC_YANDEX_MAPS_API_KEY=supersecret
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 into checkout, the storefront needs a way to save the customer’s delivery choice in the cart and retrieve it later. This makes the flow stable across refreshes and navigation. If a customer has already selected a tariff and a pickup point, the storefront can restore that selection instead of asking them to choose again. It also needs a proper removal action that clears the selection not only visually, but in the cart itself.
import type { ApishipHttpTypes } from "@gorgo/medusa-fulfillment-apiship/types"export async function retrieveCart(cartId?: string, fields?: string) {// ...// add +shipping_methods.data parameterfields ??= "*items, *region, *items.product, *items.variant, *items.thumbnail, *items.metadata, +items.total, *promotions, +shipping_methods.name, +shipping_methods.data"// ...}export async function setShippingMethod({// ...data,}: {// ...data?: Record<string, unknown>}) {// ...return sdk.store.cart.addShippingMethod(cartId,{// ...data // ApiShip selection data},// ...)// ...}
Also add the following function:
export async function removeShippingMethodFromCart(shippingMethodId: string) {const headers = {...(await getAuthHeaders()),}const next = {...(await getCacheOptions("fulfillment")),}return sdk.client.fetch<ApishipHttpTypes.DeleteResponse<"shipping_method">>(`/store/shipping-methods/${shippingMethodId}`,{method: "DELETE",headers,next,}).then(async () => {const cartCacheTag = await getCacheTag("carts")revalidateTag(cartCacheTag)}).catch(() => null)}
These changes enable three core behaviors:
To properly render ApiShip delivery options in the shipping step, the storefront needs access to dynamic calculation data, pickup point details, and the list of available providers. In addition to tariffs and pickup points, the storefront also retrieves provider metadata to display human-readable carrier names in the checkout interface instead of internal identifiers.
Open and add the following helpers:
import type { ApishipHttpTypes } from "@gorgo/medusa-fulfillment-apiship/types"type StorefrontApishipPoint = Omit<ApishipHttpTypes.StoreApishipPoint,"id" | "lat" | "lng" | "worktime"> & {id: stringlat: numberlng: numberworktime?: Record<string, string>}type StorefrontApishipPointListResponse = {points: StorefrontApishipPoint[]}type StorefrontApishipCalculation = {deliveryToDoor?: Array<{providerKey: stringtariffs?: Array<ApishipHttpTypes.StoreApishipDoorTariff>}>deliveryToPoint?: Array<{providerKey: stringtariffs?: Array<ApishipHttpTypes.StoreApishipPointTariff>}>}// ... other helpersexport const retrieveCalculation = async (cartId: string,shippingOptionId: string): Promise<StorefrontApishipCalculation | null> => {const headers = {...(await getAuthHeaders()),}const next = {...(await getCacheOptions("fulfillment")),}const body = { cart_id: cartId }return sdk.client.fetch<ApishipHttpTypes.StoreApishipCalculationResponse>(`/store/apiship/${shippingOptionId}/calculate`,{method: "POST",headers,body,next,}).then(({ calculation }) => ({deliveryToDoor: (calculation.deliveryToDoor ?? []).flatMap((group) => {if (!group.providerKey) {return []}return [{providerKey: group.providerKey,tariffs: group.tariffs,},]}),deliveryToPoint: (calculation.deliveryToPoint ?? []).flatMap((group) => {if (!group.providerKey) {return []}return [{providerKey: group.providerKey,tariffs: group.tariffs,},]}),})).catch((e) => {return null})}export const getPointAddresses = async (cartId: string,shippingOptionId: string,pointIds: Array<number>): Promise<StorefrontApishipPointListResponse | null> => {const headers = {...(await getAuthHeaders()),}const next = {...(await getCacheOptions("fulfillment")),}if (!pointIds.length) {return {points: [],}}const filter = `id=[${pointIds.join(",")}]`const fields = ["id","description","providerKey","name","address","photos","worktime","timetable","lat","lng",].join(",")const key = `apiship:points:${cartId}:${shippingOptionId}`return sdk.client.fetch<ApishipHttpTypes.StoreApishipPointListResponse>(`/store/apiship/points`,{method: "GET",headers,query: {key,filter,fields,limit: 0,},next,}).then(({ points }) => ({points: (points ?? []).flatMap((point) => {if (point.id === undefined ||point.id === null ||point.lat === undefined ||point.lat === null ||point.lng === undefined ||point.lng === null) {return []}return [{...point,id: String(point.id),lat: point.lat,lng: point.lng,worktime: point.worktime as Record<string, string> | undefined,},]}),})).catch((e) => {console.error("getPointsAddresses error", e)return null})}export const retrieveProviders = async (): Promise<ApishipHttpTypes.StoreApishipProviderListResponse | null> => {const headers = {...(await getAuthHeaders()),}const next = {...(await getCacheOptions("fulfillment")),}return sdk.client.fetch<ApishipHttpTypes.StoreApishipProviderListResponse>(`/store/apiship/providers`,{method: "GET",headers,next,}).catch((e) => {console.error("retrieveProviders 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.
In addition, the storefront fetches the list of ApiShip providers to display human-friendly carrier names in the UI.
The storefront integration relies on a small set of shared types that describe the data returned by the ApiShip calculation endpoint and the data persisted in the cart as the user’s delivery selection. These types are used across the map UI, courier and pickup points modal windows, and the chosen component to keep the flow consistent and type-safe.
Open and add the following types:
import type { ApishipHttpTypes } from "@gorgo/medusa-fulfillment-apiship/types"export type ApishipCalculation = {deliveryToDoor?: Array<{providerKey: stringtariffs?: Array<ApishipHttpTypes.StoreApishipDoorTariff>}>deliveryToPoint?: Array<{providerKey: stringtariffs?: Array<ApishipHttpTypes.StoreApishipPointTariff>}>}export type ApishipTariff = {key: stringproviderKey: string} & (ApishipHttpTypes.StoreApishipDoorTariff |ApishipHttpTypes.StoreApishipPointTariff)export type ApishipPoint = Omit<ApishipHttpTypes.StoreApishipPoint,"id" | "lat" | "lng" | "worktime"> & {id: stringlat: numberlng: numberworktime?: Record<string, string>}export type Chosen = {deliveryType: numbertariff: ApishipTariffpoint?: ApishipPoint}
represents the calculated delivery options returned by the store API, separated into and groups by provider. is the normalized payload that the storefront saves in the cart once the user picks a tariff and a pickup point.
The storefront integration uses a set of shared utilities to keep the UI consistent and to avoid duplicating logic across the components. These helpers handle delivery time formatting, stable callback references for async effects, deterministic tariff keys for selection, scroll locking for modal windows, and mapping pickup point tariffs into a structure that is easy to render.
Open and add the following helpers:
import {ApishipCalculation,ApishipTariff,} from "../types"import { useEffect, useRef } from "react"export function days(tariff: ApishipTariff) {const min = tariff.daysMin ?? 0const max = tariff.daysMax ?? 0if (!min && !max) return nullif (min === max) return min === 1 ? `${min} day` : `${min} days`return `${min}–${max} days`}export function useLatestRef<T>(value: T) {const ref = useRef(value)useEffect(() => {ref.current = value}, [value])return ref}export function buildTariffKey (providerKey: string,tariff: Omit<ApishipTariff, "key" | "providerKey">,idx: number) {return `${providerKey}:${tariff.tariffId ?? tariff.tariffProviderId ?? tariff.tariffName ?? idx}`}export function useLockBodyScroll(locked: boolean) {useEffect(() => {if (!locked) returnconst body = document.bodyconst html = document.documentElementconst prevBodyOverflow = body.style.overflowconst prevBodyPaddingRight = body.style.paddingRightconst prevHtmlOverflow = html.style.overflowconst scrollbarWidth = window.innerWidth - html.clientWidthif (scrollbarWidth > 0) {body.style.paddingRight = `${scrollbarWidth}px`}body.style.overflow = "hidden"html.style.overflow = "hidden"return () => {body.style.overflow = prevBodyOverflowbody.style.paddingRight = prevBodyPaddingRighthtml.style.overflow = prevHtmlOverflow}}, [locked])}export 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)}export function buildTariffsByPointId(calculation?: ApishipCalculation | null) {const map: Record<string, ApishipTariff[]> = {}calculation?.deliveryToPoint?.forEach(({ providerKey, tariffs }) => {tariffs?.forEach((tariff) => {for (const pointId of tariff.pointIds ?? []) {const key = `${providerKey}:${tariff.tariffProviderId ?? ""}:${tariff.tariffId ?? ""}`const entry: ApishipTariff = { key, providerKey, ...tariff }const arr = (map[String(pointId)] ??= [])if (!arr.some((t) => t.key === key)) arr.push(entry)}})})return map}export function extractPointIds(map: Record<string, ApishipTariff[]>) {return Object.keys(map).map(Number).filter(Number.isFinite)}
These utilities are referenced by the modal windows and the map for formatting delivery times, keeping selection keys stable between renders, safely running async effects without state updates after unmount, and correctly preparing the pickup point data model used to render tariffs per point.
This component is responsible for rendering the Yandex Map, placing pickup point markers on it, and showing a details panel with available tariffs when a point is selected.
"use client"import { useCallback, useEffect, useMemo, useRef } from "react"import { Button, Heading, Text, clx, IconButton } from "@medusajs/ui"import { Loader, XMark } from "@medusajs/icons"import { Radio, RadioGroup } from "@headlessui/react"import MedusaRadio from "@modules/common/components/radio"import {ApishipPoint,ApishipTariff} from "./types"import {days,useLatestRef} from "./utils"const DEFAULT_CENTER: [number, number] = [37.618423, 55.751244]type ApishipMapProps = {points: ApishipPoint[]tariffsByPointId: Record<string, ApishipTariff[]>isLoading?: booleanselectedPointId: string | nullselectedTariffKey: string | nullisPanelOpen: booleanonClosePanel: () => voidonSelectPoint: (id: string) => voidonSelectTariff: (tariffKey: string) => voidonChoose: (payload: { point: ApishipPoint; tariff: ApishipTariff }) => voidchosen?: { pointId?: string; tariffKey?: string } | nullprovidersMap: Record<string, string>}const WEEK_DAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] as constconst Schedule = ({ worktime }: { worktime: Record<string, string> }) => {if (!worktime || Object.keys(worktime).length === 0) return nullreturn (<div className="flex flex-col gap-[1px]">{Object.keys(worktime).map((day) => {const label = WEEK_DAYS[Number(day) - 1]const time = worktime[day]return (<div className="flex flex-row justify-between" key={day}><Text className="txt-medium text-ui-fg-subtle">{label}</Text><Text className="text-ui-fg-muted">{time}</Text></div>)})}</div>)}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__}export const ApishipMap: React.FC<ApishipMapProps> = ({points,tariffsByPointId,isLoading,selectedPointId,selectedTariffKey,isPanelOpen,onClosePanel,onSelectPoint,onSelectTariff,onChoose,chosen,providersMap}) => {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 isSameAsChosen =!!chosen?.pointId &&!!chosen?.tariffKey &&chosen.pointId === selectedPointId &&chosen.tariffKey === selectedTariffKeyconst 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])const applySelectionStyles = useCallback(() => {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.border = sel? "2px solid rgba(255,255,255,0.95)": "2px solid rgba(0,0,0,0.25)"}}}, [selectedPointId])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 = 18el.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 2px 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 = "8px"dot.style.height = "8px"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 })}applySelectionStyles()})()return () => {cancelled = true}}, [points, clearMarkers, onSelectPointRef])useEffect(() => {applySelectionStyles()}, [applySelectionStyles])const showNoPoints = !isLoading && points.length === 0return (<div className="relative w-full h-full"><div ref={containerRef} className="w-full h-full" />{isLoading && (<div className="absolute inset-0 bg-white/80 flex items-center justify-center"><Loader /></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>)}{!isLoading && activePoint && isPanelOpen && !showNoPoints && (<div className="absolute left-0 top-0 z-[70] w-full md:w-[470px] h-full border-r bg-white flex flex-col min-h-0"><div className="flex flex-row justify-between p-[35px] pb-0 items-center"><Headinglevel="h2"className="flex flex-row text-3xl-regular gap-x-2 items-baseline">{`${providersMap?.[activePoint.providerKey ?? ""] ?? ""} pickup point`.trimStart()}</Heading><IconButtonaria-label="Close"onClick={(e) => {e.preventDefault()e.stopPropagation()onClosePanel()}}className="shadow-none"><XMark /></IconButton></div><div className="flex-1 min-h-0 overflow-y-auto p-[35px] pt-[18px] flex flex-col gap-[18px]"><div className="flex flex-col"><Text className="font-medium txt-medium text-ui-fg-base">{activePoint.name}</Text><Text className="text-ui-fg-muted txt-medium">{activePoint.address}</Text></div><div className="flex flex-col gap-[10px]"><Text className="font-medium txt-medium text-ui-fg-base">Tariffs</Text><RadioGroup className="flex flex-col gap-[10px]">{activeTariffs.map((t) => {const active = t.key === selectedTariffKeyconst cost =typeof t.deliveryCostOriginal === "number"? t.deliveryCostOriginal: t.deliveryCostreturn (<Radiokey={t.tariffId}value={t.tariffId}data-testid="delivery-option-radio"onClick={() => {onSelectTariff(t.key)}}className={clx("flex items-center justify-between text-small-regular cursor-pointer py-2 border rounded-rounded pl-2 pr-3 hover:shadow-borders-interactive-with-active",{ "border-ui-border-interactive": active })}><div className="flex gap-2 w-full"><MedusaRadio checked={active} /><div className="flex flex-row w-full items-center justify-between"><div className="flex flex-col"><span className="txt-compact-small-plus">{t.tariffName}</span><span className="txt-small text-ui-fg-subtle">Delivery time: {days(t)}</span></div><span className="txt-small-plus text-ui-fg-subtle">{`RUB ${cost}`}</span></div></div></Radio>)})}</RadioGroup></div><Buttonsize="large"onClick={() => {if (!activePoint || !selectedTariff) returnonChoose({ point: activePoint, tariff: selectedTariff })}}disabled={!selectedTariff || isSameAsChosen}className="w-full mb-[16px] !overflow-visible">Choose</Button>{activePoint.worktime && (<div className="flex flex-col"><Text className="font-medium txt-medium text-ui-fg-base">Schedule</Text><Schedule worktime={activePoint.worktime} /></div>)}{!!activePoint.photos?.length && (<div className="flex flex-col gap-[10px]"><Text className="font-medium txt-medium text-ui-fg-base">Photos</Text><div className="flex flex-row gap-[10px] overflow-x-auto">{activePoint.photos.map((src, index) => (// eslint-disable-next-line @next/next/no-img-element<imgkey={index}src={src}alt={`Photo ${index + 1} of pickup point`}className="w-auto h-[120px] rounded-md border object-cover"/>))}</div></div>)}{activePoint.description && (<div className="flex flex-col"><Text className="font-medium txt-medium text-ui-fg-base">Description</Text><Text className="text-ui-fg-muted txt-medium">{activePoint.description}</Text></div>)}</div></div>)}</div>)}
This component initializes Yandex Maps once, re-centers the map when the pickup point list changes, and renders clickable markers for each available point. When the user selects a point, it shows a side panel where tariffs are displayed and can be chosen. The map UI also respects the previously saved selection. If the user opens the modal again, the component highlights the already chosen pickup point and tariff and prevents re-selecting the same option.
This component renders a compact summary of the shipping option the customer has already selected. It is shown after the user selects a pickup point or courier tariff, so the checkout can clearly display what is currently saved in the cart.
"use client"import { Heading, Text } from "@medusajs/ui"import { Chosen } from "./types"import { days } from "./utils"type ApishipChosenProps = {chosen: ChosenonRemove: () => voidonEdit: () => void}export const ApishipChosen: React.FC<ApishipChosenProps> = ({chosen,onRemove,onEdit}) => {const cost =typeof chosen.tariff.deliveryCostOriginal === "number"? chosen.tariff.deliveryCostOriginal: chosen.tariff.deliveryCostconst isToPoint = chosen.deliveryType === 2return (<div className="flex flex-col gap-4 mt-[32px]"><div className="flex flex-row justify-between"><Heading level="h2" className="txt-xlarge">{isToPoint ? "To the Pickup Point" : "By Courier"}</Heading><div className="flex flex-row gap-[16px]"><Text><buttononClick={(e) => {e.preventDefault()e.stopPropagation()onRemove()}}className="text-ui-fg-interactive hover:text-ui-fg-interactive-hover">Remove</button></Text><Text><buttononClick={(e) => {e.preventDefault()e.stopPropagation()onEdit()}}className="text-ui-fg-interactive hover:text-ui-fg-interactive-hover">Edit</button></Text></div></div>{isToPoint ? (<div className="flex flex-col gap-4"><div className="flex flex-col gap-4 w-[60%]"><div className="flex flex-col"><Text>{chosen.point?.name}</Text>{chosen.point?.address && (<Text className="text-ui-fg-muted">{chosen.point.address}</Text>)}{chosen.point?.timetable && (<Text className="text-ui-fg-muted">{chosen.point.timetable}</Text>)}</div>{chosen.point?.description && (<Text className="text-ui-fg-muted leading-none">{chosen.point.description}</Text>)}</div><Text>{chosen.tariff.tariffName} · {`RUB ${cost ?? "—"}`}{days(chosen.tariff) ? ` · ${days(chosen.tariff)}` : ""}</Text></div>) : (<Text>{[chosen?.tariff?.tariffName,typeof cost === "number" ? `RUB ${cost}` : "RUB —",days?.(chosen?.tariff) || null,].filter(Boolean).join(" · ")}</Text>)}</div>)}
With this in place, the storefront can show the previously saved selection directly in the delivery step, including pickup point and tariff details. It also allows the user to either remove it or reopen the modal window to change it.
This component implements a dedicated modal window that allows the customer to select an ApiShip pickup point and an associated tariff directly on the map. The modal is responsible for loading pickup points for the selected shipping option, restoring the previously saved selection (if any), and persisting the new selection back to the cart once the customer confirms it.
"use client"import { useCallback, useEffect, useRef, useState } from "react"import { IconButton } from "@medusajs/ui"import { XMark } from "@medusajs/icons"import { HttpTypes } from "@medusajs/types"import { setShippingMethod } from "@lib/data/cart"import {calculatePriceForShippingOption,retrieveCalculation,getPointAddresses,} from "@lib/data/fulfillment"import { ApishipMap } from "./apiship-map"import {ApishipPoint,ApishipTariff,Chosen} from "./types"import {buildTariffsByPointId,extractPointIds,useAsyncEffect,useLatestRef,useLockBodyScroll,} from "./utils"type ApishipPickupPointModalProps = {open: booleanonClose: (cancel?: boolean) => voidcart: HttpTypes.StoreCartshippingOptionId: string | nullinitialChosen?: Chosen | nullonPriceUpdate?: (shippingOptionId: string, amount: number) => voidonError?: (message: string) => voidonChosenChange: (chosen: Chosen | null) => voidprovidersMap: Record<string, string>}export const ApishipPickupPointModal: React.FC<ApishipPickupPointModalProps> = ({open,onClose,cart,shippingOptionId,initialChosen,onPriceUpdate,onError,onChosenChange,providersMap}) => {const onErrorRef = useLatestRef(onError)const onPriceRef = useLatestRef(onPriceUpdate)const [isLoadingPoints, setIsLoadingPoints] = useState(false)const [points, setPoints] = useState<ApishipPoint[]>([])const [tariffsByPointId, setTariffsByPointId] = useState<Record<string, ApishipTariff[]>>({})const [selectedPointId, setSelectedPointId] = useState<string | null>(null)const [selectedTariffKey, setSelectedTariffKey] = useState<string | null>(null)const [isPanelOpen, setIsPanelOpen] = useState(false)useLockBodyScroll(open)useEffect(() => {if (!open) returnif (initialChosen?.deliveryType === 2) {setSelectedPointId(initialChosen.point?.id ?? null)setSelectedTariffKey(initialChosen.tariff?.key ?? null)setIsPanelOpen(true)return}setSelectedPointId(null)setSelectedTariffKey(null)setIsPanelOpen(false)}, [open, initialChosen, shippingOptionId])useEffect(() => {setSelectedPointId(null)setSelectedTariffKey(null)setIsPanelOpen(false)}, [shippingOptionId])useAsyncEffect(async (isCancelled) => {if (!open || !shippingOptionId) returnsetIsLoadingPoints(true)try {const calculation = await retrieveCalculation(cart.id, shippingOptionId)if (isCancelled()) returnconst tariffsMap = buildTariffsByPointId(calculation)setTariffsByPointId(tariffsMap)const pointIds = extractPointIds(tariffsMap)if (!pointIds.length) {setPoints([])return}const pointAddresses = await getPointAddresses(cart.id,shippingOptionId,pointIds)if (isCancelled()) returnsetPoints(pointAddresses?.points ?? [])} catch (e: any) {console.error(e)onErrorRef.current?.(e?.message ?? "Failed to load pickup points")setPoints([])setTariffsByPointId({})} finally {if (!isCancelled()) setIsLoadingPoints(false)}}, [open, shippingOptionId, cart.id])const persistChosen = useCallback(async (next: Chosen) => {if (!shippingOptionId) returnawait setShippingMethod({cartId: cart.id,shippingMethodId: shippingOptionId,data: { apishipData: next },})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 (!open) return nullreturn (<div className="fixed inset-0 z-50"><divclassName="absolute inset-0 bg-black/50"/><divclassName="absolute inset-0 flex items-center justify-center p-4"onClick={(e) => {if (e.target === e.currentTarget) onClose(initialChosen ? false : true)}}><div className="relative"><IconButtonaria-label="Close"onClick={() => onClose(initialChosen ? false : true)}className="absolute right-2 top-2 z-[60] shadow-none"><XMark /></IconButton><divclassName="relativeh-[820px] w-[1350px]max-w-[calc(100vw-32px)] max-h-[calc(100vh-32px)]overflow-hidden rounded-rounded border bg-white"><ApishipMappoints={points}tariffsByPointId={tariffsByPointId}isLoading={isLoadingPoints}selectedPointId={selectedPointId}selectedTariffKey={selectedTariffKey}isPanelOpen={isPanelOpen}onClosePanel={() => {setIsPanelOpen(false)setSelectedPointId(null)}}onSelectPoint={(pid) => {setSelectedPointId(pid)setSelectedTariffKey(null)setIsPanelOpen(true)}}onSelectTariff={(key) => setSelectedTariffKey(key)}chosen={initialChosen?.deliveryType === 2? { pointId: initialChosen.point?.id, tariffKey: initialChosen.tariff?.key }: null}onChoose={async ({ point, tariff }) => {const cost =typeof tariff.deliveryCostOriginal === "number"? tariff.deliveryCostOriginal: tariff.deliveryCostconst chosen: Chosen = {deliveryType: 2,tariff,point}try {await persistChosen(chosen)onChosenChange(chosen)onClose()} catch (e: any) {onErrorRef.current?.(e?.message ?? "Failed to save the selected tariff")}}}providersMap={providersMap}/></div></div></div></div>)}
This modal window keeps the body scroll locked while it is open, fetches pickup points using the shipping calculation results, and passes the previously saved selection into the map so the UI can reflect what the customer already picked. When the user confirms a point and tariff, the selection is saved into the cart shipping method data and the updated price is recalculated.
This component implements a dedicated modal window for selecting courier delivery tariffs. It opens when the customer chooses courier delivery and allows them to select one of the available tariffs.
When the modal window is open, it restores the previously selected tariff (if one was already saved in the cart), so the user sees their earlier choice immediately. All available courier tariffs are displayed in a grouped layout, and the customer can select one using radio buttons.
"use client"import { useCallback, useEffect, useMemo, useState } from "react"import { Button, clx, Heading, IconButton, Text } from "@medusajs/ui"import MedusaRadio from "@modules/common/components/radio"import { Loader, XMark } from "@medusajs/icons"import { HttpTypes } from "@medusajs/types"import { Radio, RadioGroup } from "@headlessui/react"import { setShippingMethod } from "@lib/data/cart"import { calculatePriceForShippingOption, retrieveCalculation } from "@lib/data/fulfillment"import {ApishipCalculation,ApishipTariff,Chosen} from "./types"import {buildTariffKey,days,useLatestRef,useLockBodyScroll} from "./utils"type ApishipCourierModalProps = {open: booleanonClose: (cancel?: boolean) => voidcart: HttpTypes.StoreCartshippingOptionId: string | nullinitialChosen?: Chosen | nullonPriceUpdate?: (shippingOptionId: string, amount: number) => voidonError?: (message: string) => voidonChosenChange: (chosen: Chosen | null) => voidprovidersMap: Record<string, string>}export const ApishipCourierModal: React.FC<ApishipCourierModalProps> = ({open,onClose,cart,shippingOptionId,initialChosen,onPriceUpdate,onError,onChosenChange,providersMap,}) => {const onErrorRef = useLatestRef(onError)const onPriceRef = useLatestRef(onPriceUpdate)const [isLoading, setIsLoading] = useState(false)const [isLoadingCalc, setIsLoadingCalc] = useState(false)const [calculation, setCalculation] = useState<ApishipCalculation | null>(null)const [selectedTariffKey, setSelectedTariffKey] = useState<string | null>(null)useLockBodyScroll(open)useEffect(() => {if (!open) returnif (initialChosen) {setSelectedTariffKey(initialChosen.tariff?.key ?? null)return}setSelectedTariffKey(null)}, [open, initialChosen, shippingOptionId])useEffect(() => {setSelectedTariffKey(null)}, [shippingOptionId])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)}useAsyncEffect(async (isCancelled) => {if (!open || !shippingOptionId) returnsetIsLoadingCalc(true)setCalculation(null)try {const calculation = await retrieveCalculation(cart.id, shippingOptionId)if (isCancelled()) returnsetCalculation(calculation)} catch (e: any) {console.error(e)onErrorRef.current?.(e?.message ?? "Failed to load calculation")setCalculation(null)} finally {if (!isCancelled()) setIsLoadingCalc(false)}}, [open, shippingOptionId, cart.id])const doorGroups = useMemo(() => {return calculation?.deliveryToDoor ?? []}, [calculation])const tariffsFlat = useMemo(() => {return doorGroups.flatMap((g) =>(g.tariffs ?? []).map((t, idx) => {const key = buildTariffKey(g.providerKey, t, idx)const full: ApishipTariff = {...t,key,providerKey: g.providerKey,}return full}))}, [doorGroups])const selectedTariff = useMemo(() => {if (!selectedTariffKey) return nullreturn tariffsFlat.find((t) => t.key === selectedTariffKey) ?? null}, [tariffsFlat, selectedTariffKey])const persistChosen = useCallback(async () => {if (!shippingOptionId || !selectedTariff) returnsetIsLoading(true)try {const next: Chosen = {deliveryType: 1,tariff: selectedTariff}await setShippingMethod({cartId: cart.id,shippingMethodId: shippingOptionId,data: { apishipData: next },})const calc = await calculatePriceForShippingOption(shippingOptionId, cart.id)if (calc?.id && typeof calc.amount === "number") {onPriceRef.current?.(calc.id, calc.amount)}onChosenChange(next)onClose()} catch (e: any) {onErrorRef.current?.(e?.message ?? "Failed to save courier tariff")} finally {setIsLoading(false)}}, [cart.id, shippingOptionId, selectedTariff, onPriceRef, onChosenChange, onClose, onErrorRef])if (!open) return nullreturn (<div className="fixed inset-0 z-50"><divclassName="absolute inset-0 bg-black/50"/><divclassName="absolute inset-0 flex items-center justify-center p-4"onClick={(e) => {if (e.target === e.currentTarget) onClose(initialChosen ? false : true)}}><div className="relative w-[470px] max-w-[calc(100vw-32px)]"><divclassName="h-[820px] w-[470px]max-w-[calc(100vw-32px)] max-h-[calc(100vh-32px)]overflow-hidden rounded-rounded border bg-whiteflex flex-col"><div className=" flex flex-row justify-between items-center p-[35px] pb-0"><Headinglevel="h2"className="flex flex-row text-3xl-regular gap-x-2 items-baseline">By courier</Heading><IconButtonaria-label="Close"onClick={() => onClose(initialChosen ? false : true)}className="shadow-none"><XMark /></IconButton></div><div className="flex-1 min-h-0 overflow-y-auto px-[35px] pt-[18px]">{isLoadingCalc ? (<div className="h-full w-full flex items-center justify-center"><Loader /></div>) : doorGroups.length === 0 ? (<Text className="text-ui-fg-muted">No courier tariffs available.</Text>) : (<div className="flex flex-col gap-[15px] pb-[18px]">{doorGroups.filter((g) => (g.tariffs?.length ?? 0) > 0).map((g) => (<div key={g.providerKey} className="flex flex-col gap-[10px]"><Text className="font-medium txt-medium text-ui-fg-base">{providersMap[g.providerKey] ?? g.providerKey}</Text><RadioGroupvalue={selectedTariffKey}onChange={(v) => setSelectedTariffKey(String(v))}className="flex flex-col gap-[10px]">{(g.tariffs ?? []).map((t, idx) => {const k = buildTariffKey(g.providerKey, t, idx)const checked = k === selectedTariffKeyconst cost =typeof t.deliveryCostOriginal === "number"? t.deliveryCostOriginal: t.deliveryCostreturn (<Radiokey={k}value={k}className={clx("flex items-center justify-between text-small-regular cursor-pointer py-2 border rounded-rounded pl-2 pr-3 hover:shadow-borders-interactive-with-active",{ "border-ui-border-interactive": checked })}><div className="flex gap-2 w-full"><MedusaRadio checked={checked} /><div className="flex flex-row w-full items-center justify-between"><div className="flex flex-col"><span className="txt-compact-small-plus">{t.tariffName}</span><span className="txt-small text-ui-fg-subtle">Delivery time: {days(t as ApishipTariff)}</span></div><span className="txt-small-plus text-ui-fg-subtle">{typeof cost === "number" ? `RUB ${cost}` : "—"}</span></div></div></Radio>)})}</RadioGroup></div>))}</div>)}</div><div className="p-[35px] pt-[18px] bg-white"><Buttonsize="large"onClick={persistChosen}isLoading={isLoading}disabled={!selectedTariffKey || isLoadingCalc || selectedTariff?.key === initialChosen?.tariff.key}className="w-full">Choose</Button></div></div></div></div></div>)}
Once the customer confirms their choice by clicking , the selected tariff is saved to the cart, the shipping price is recalculated, and the modal closes.
All ApiShip storefront components and helpers are exported through a barrel file that acts as a single entry point for the integration. It re-exports the main UI building blocks, along with shared types and utility helpers, so other parts of the storefront can import everything from one place instead of referencing internal file paths.
export * from "./apiship-courier-modal"export * from "./apiship-chosen"export * from "./apiship-map"export * from "./apiship-pickup-point-modal"export * from "./types"export * from "./utils"
This keeps imports clean and consistent, and makes the integration easier to maintain or refactor later without changing import paths across the storefront.
To support ApiShip calculated delivery methods, the delivery step needs an extra UI flow where the customer chooses a specific tariff, and optionally a pickup point. This update adds ApiShip-specific state and modal windows, loads provider names for displaying, and saves the final selection in the cart so the checkout can proceed only after the customer has made a valid choice.
// ... other importsimport { setShippingMethod, removeShippingMethodFromCart } from "@lib/data/cart"import { calculatePriceForShippingOption, retrieveProviders } from "@lib/data/fulfillment"import { useEffect, useMemo, useState } from "react"import {ApishipPickupPointModal,ApishipCourierModal,ApishipChosen,days} from "./apiship"// ...const Shipping: React.FC<ShippingProps> = ({cart,availableShippingMethods,}) => {// ... other states// add the following statesconst [providersMap, setProvidersMap] = useState<Record<string, string>>({})const [apishipChosen, setApishipChosen] = useState<any | null>(null)const [apishipPickupPointModalOpen, setApishipPickupPointModalOpen] = useState(false)const [apishipCourierModalOpen, setApishipCourierModalOpen] = useState(false)// ...// add the followingconst isApishipCalculated = (option?: HttpTypes.StoreCartShippingOption | null) =>option?.price_type === "calculated" && option?.provider_id === "apiship_apiship"const isApishipToDoor = (option?: HttpTypes.StoreCartShippingOption | null) =>isApishipCalculated(option) && option?.data?.deliveryType === 1const isApishipToPoint = (option?: HttpTypes.StoreCartShippingOption | null) =>isApishipCalculated(option) && option?.data?.deliveryType === 2const activeShippingOption = useMemo(() => {return _shippingMethods?.find((option) => option.id === shippingMethodId) ?? null}, [_shippingMethods, shippingMethodId])const apishipMode = useMemo<"point" | "door" | null>(() => {if (!isOpen || !shippingMethodId) return nullif (isApishipToPoint(activeShippingOption)) return "point"if (isApishipToDoor(activeShippingOption)) return "door"return null}, [isOpen, shippingMethodId, activeShippingOption])useEffect(() => {setApishipChosen(cart.shipping_methods?.at(-1)?.data?.apishipData ?? null)}, [cart.shipping_methods])useEffect(() => {if (!isOpen) returnif (!apishipMode) {setApishipPickupPointModalOpen(false)setApishipCourierModalOpen(false)setApishipChosen(null)return}const chosenMode =apishipChosen?.deliveryType === 2 ? "point": apishipChosen?.deliveryType === 1 ? "door": nullconst hasValidChosen = !!apishipChosen && chosenMode === apishipModeif (hasValidChosen) {setApishipPickupPointModalOpen(false)setApishipCourierModalOpen(false)return}if (apishipMode === "point") {setApishipPickupPointModalOpen(true)setApishipCourierModalOpen(false)} else {setApishipCourierModalOpen(true)setApishipPickupPointModalOpen(false)}}, [isOpen, apishipMode, apishipChosen])useEffect(() => {let cancelled = false; (async () => {const response = await retrieveProviders()const providers = response?.providersif (cancelled) returnconst map: Record<string, string> = {}for (const provider of providers ?? []) map[provider.key] = provider.namesetProvidersMap(map)})()return () => { cancelled = true }}, [])useEffect(() => {if (!isOpen) returnif (!apishipChosen) returnif (!apishipMode) {setApishipChosen(null)return}const chosenMode = apishipChosen.deliveryType === 2 ? "point" : "door"if (chosenMode !== apishipMode) {setApishipChosen(null)}}, [isOpen, apishipMode, apishipChosen])// ... other effectsreturn (<div className="bg-white">{/* ... */}{isOpen ? (<><div className="grid">{/* ... */}<div data-testid="delivery-options-container"><div className="pb-8 md:pt-0 pt-2">{/* ... */}<RadioGroupvalue={shippingMethodId}onChange={(v) => {if (v) {return handleSetShippingMethod(v, "shipping")}}}>{_shippingMethods?.map((option) => {const isDisabled =option.price_type === "calculated" &&!isLoadingPrices &&typeof calculatedPricesMap[option.id] !== "number"return (<Radiokey={option.id}value={option.id}data-testid="delivery-option-radio"disabled={isDisabled}className={clx("flex items-center justify-between text-small-regular cursor-pointer py-4 border rounded-rounded px-8 mb-2 hover:shadow-borders-interactive-with-active",{"border-ui-border-interactive":option.id === shippingMethodId,"hover:shadow-brders-none cursor-not-allowed":isDisabled,})}>{/* ... */}{/* ApiShip prices are shown as "from X" because final amount depends on chosen tariff so change the following span */}<span className="justify-self-end text-ui-fg-base">{option.price_type === "flat" ? (convertToLocale({amount: option.amount!,currency_code: cart?.currency_code,})) : calculatedPricesMap[option.id] ? (option.provider_id === "apiship_apiship" ? ("from " + convertToLocale({amount: calculatedPricesMap[option.id],currency_code: cart?.currency_code,})) :convertToLocale({amount: calculatedPricesMap[option.id],currency_code: cart?.currency_code,})) : isLoadingPrices ? (<Loader />) : ("-")}</span></Radio>)})}</RadioGroup>{/* add the following apiship components */}<ApishipPickupPointModalopen={apishipPickupPointModalOpen}onClose={async (cancel?: boolean) => {setApishipPickupPointModalOpen(false)if (cancel) {await removeShippingMethodFromCart(cart.shipping_methods?.[0]?.id!)setShippingMethodId(null)}}}cart={cart}shippingOptionId={shippingMethodId}initialChosen={apishipChosen?.deliveryType === 2 ? apishipChosen : null}onPriceUpdate={(id, amount) => {setCalculatedPricesMap((prev) => ({ ...prev, [id]: amount }))}}onError={(msg) => setError(msg)}onChosenChange={(chosen) => setApishipChosen(chosen)}providersMap={providersMap}/><ApishipCourierModalopen={apishipCourierModalOpen}onClose={async (cancel?: boolean) => {setApishipCourierModalOpen(false)if (cancel) {await removeShippingMethodFromCart(cart.shipping_methods?.[0]?.id!)setShippingMethodId(null)}}}cart={cart}shippingOptionId={shippingMethodId}initialChosen={apishipChosen?.deliveryType === 1 ? apishipChosen : null}onPriceUpdate={(id, amount) => {setCalculatedPricesMap((prev) => ({ ...prev, [id]: amount }))}}onError={(msg) => setError(msg)}onChosenChange={(chosen) => setApishipChosen(chosen)}providersMap={providersMap}/>{apishipChosen && (<ApishipChosenchosen={apishipChosen}onRemove={async () => {await removeShippingMethodFromCart(cart.shipping_methods?.[0]?.id!)setApishipChosen(null)setShippingMethodId(null)}}onEdit={() => {console.log(cart)if (apishipMode === "point") setApishipPickupPointModalOpen(true)if (apishipMode === "door") setApishipCourierModalOpen(true)}}/>)}</div></div></div>{/* ... */}<div>{/* ... */}<Button// ... other properties// change the condition for ‘disabled’disabled={!cart.shipping_methods?.[0] ||shippingMethodId === null ||((_shippingMethods?.find((o) => o.id === shippingMethodId)?.provider_id === "apiship_apiship")&& !apishipChosen)}>Continue to payment</Button></div></>) : (<div><div className="text-small-regular">{cart && (cart.shipping_methods?.length ?? 0) > 0 && ({/* change the following div */}<div className="flex flex-col"><Text className="txt-medium-plus text-ui-fg-base mb-1">Method</Text><div className="flex flex-col"><Text className="txt-medium text-ui-fg-subtle">{cart.shipping_methods!.at(-1)!.name}</Text>{(apishipChosen && apishipChosen.point?.address) && (<Text className="txt-medium text-ui-fg-subtle">{apishipChosen.point.address}</Text>)}{apishipChosen && (<Text className="txt-medium text-ui-fg-subtle">{[apishipChosen?.tariff?.tariffName,typeof (typeof apishipChosen.tariff.deliveryCostOriginal === "number"? apishipChosen.tariff.deliveryCostOriginal: apishipChosen.tariff.deliveryCost) === "number"? `RUB ${(typeof apishipChosen.tariff.deliveryCostOriginal === "number"? apishipChosen.tariff.deliveryCostOriginal: apishipChosen.tariff.deliveryCost)}` : "RUB —",days?.(apishipChosen?.tariff) || null,].filter(Boolean).join(" · ")}</Text>)}</div></div>)}</div></div>)}<Divider className="mt-8" /></div>)}export default Shipping
This change adds two dedicated modal windows for ApiShip (pickup point selection and courier tariff selection) and a small summary block that shows the currently saved choice with and actions. It also ensures:
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, 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.0.1...main -- examples/fulfillment-apiship/medusa-storefront