Command Palette

Search for a command to run...

Integrate ApiShip 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.

For the storefront on Next.js, you need to make the following changes:

Step 1: Set Environment Variables

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

Step 2: 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.

Step 3: Handle Cart Data

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.

Open and add the following:

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

export async function retrieveCart(cartId?: string, fields?: string) {
// ...
// add +shipping_methods.data parameter
fields ??= "*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<Record<string, unknown>>(
`/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:

  • The storefront can attach ApiShip-specific selection data when applying a shipping method.
  • It can read the saved data back from the cart when the page is reloaded.
  • It can fully clear the selection by removing the shipping method from the cart when the customer decides to reset their choice.

Step 4: Fetch ApiShip Data

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:

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
})
}
export const retrieveProviders = async () => {
const headers = {
...(await getAuthHeaders()),
}
const next = {
...(await getCacheOptions("fulfillment")),
}
return sdk.client
.fetch<{
providers: Array<{
key: string
name: string
description: string | null
}>
}>(`/store/apiship/providers`, {
method: "POST",
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.

Step 5: Define Types

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:

Directory structure in the Medusa Storefront after creating the file for ApiShip types

export type ApishipCalculation = {
deliveryToDoor?: Array<{
providerKey: string
tariffs?: Array<
Omit<ApishipTariff, "key" | "providerKey">
>
}>
deliveryToPoint?: Array<{
providerKey: string
tariffs?: Array<
Omit<ApishipTariff, "key" | "providerKey"> & { pointIds?: number[] }
>
}>
}
export type ApishipTariff = {
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
}
export type ApishipPoint = {
id: string
providerKey?: string
availableOperation?: number
name?: string
description?: string
worktime?: Record<string, string>
photos?: string[]
timetable?: string
lat: number
lng: number
code?: string
postIndex?: string
region?: string
city?: string
address?: string
phone?: string
}
export type Chosen = {
deliveryType: number
tariff: ApishipTariff
point?: 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.

Step 6: Create Utilities

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:

Directory structure in the Medusa Storefront after creating the file for ApiShip utilities

import {
ApishipCalculation,
ApishipTariff,
} from "../types"
import { useEffect, useRef } from "react"
export function days(tariff: ApishipTariff) {
const min = tariff.daysMin ?? 0
const max = tariff.daysMax ?? 0
if (!min && !max) return null
if (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) return
const body = document.body
const html = document.documentElement
const prevBodyOverflow = body.style.overflow
const prevBodyPaddingRight = body.style.paddingRight
const prevHtmlOverflow = html.style.overflow
const scrollbarWidth = window.innerWidth - html.clientWidth
if (scrollbarWidth > 0) {
body.style.paddingRight = `${scrollbarWidth}px`
}
body.style.overflow = "hidden"
html.style.overflow = "hidden"
return () => {
body.style.overflow = prevBodyOverflow
body.style.paddingRight = prevBodyPaddingRight
html.style.overflow = prevHtmlOverflow
}
}, [locked])
}
export 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)
}
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.

Step 7: Create Pickup Point Map

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.

Open and add the following:

Directory structure in the Medusa Storefront after creating the file for pickup point map

"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?: boolean
selectedPointId: string | null
selectedTariffKey: string | null
isPanelOpen: boolean
onClosePanel: () => void
onSelectPoint: (id: string) => void
onSelectTariff: (tariffKey: string) => void
onChoose: (payload: { point: ApishipPoint; tariff: ApishipTariff }) => void
chosen?: { pointId?: string; tariffKey?: string } | null
providersMap: Record<string, string>
}
const WEEK_DAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] as const
const Schedule = ({ worktime }: { worktime: Record<string, string> }) => {
if (!worktime || Object.keys(worktime).length === 0) return null
return (
<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 = 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__
}
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 === selectedTariffKey
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])
const applySelectionStyles = useCallback(() => {
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.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) 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 = 18
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 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 === 0
return (
<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">
<Heading
level="h2"
className="flex flex-row text-3xl-regular gap-x-2 items-baseline"
>
{`${providersMap?.[activePoint.providerKey ?? ""] ?? ""} pickup point`.trimStart()}
</Heading>
<IconButton
aria-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 === selectedTariffKey
const cost =
typeof t.deliveryCostOriginal === "number"
? t.deliveryCostOriginal
: t.deliveryCost
return (
<Radio
key={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>
<Button
size="large"
onClick={() => {
if (!activePoint || !selectedTariff) return
onChoose({ 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
<img
key={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.

Step 8: Create ApiShip Delivery Summary

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.

Open and add the following:

Directory structure in the Medusa Storefront after creating the file for ApiShip chosen

"use client"
import { Heading, Text } from "@medusajs/ui"
import { Chosen } from "./types"
import { days } from "./utils"
type ApishipChosenProps = {
chosen: Chosen
onRemove: () => void
onEdit: () => void
}
export const ApishipChosen: React.FC<ApishipChosenProps> = ({
chosen,
onRemove,
onEdit
}) => {
const cost =
typeof chosen.tariff.deliveryCostOriginal === "number"
? chosen.tariff.deliveryCostOriginal
: chosen.tariff.deliveryCost
const isToPoint = chosen.deliveryType === 2
return (
<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>
<button
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onRemove()
}}
className="text-ui-fg-interactive hover:text-ui-fg-interactive-hover"
>
Remove
</button>
</Text>
<Text>
<button
onClick={(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.

Step 9: Create Pickup Point Modal

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.

Open and add the following:

Directory structure in the Medusa Storefront after creating the file for ApiShip pickup point modal window

"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 {
ApishipCalculation,
ApishipPoint,
ApishipTariff,
Chosen
} from "./types"
import {
buildTariffsByPointId,
extractPointIds,
useAsyncEffect,
useLatestRef,
useLockBodyScroll,
} from "./utils"
type ApishipPickupPointModalProps = {
open: boolean
onClose: (cancel?: boolean) => void
cart: HttpTypes.StoreCart
shippingOptionId: string | null
initialChosen?: Chosen | null
onPriceUpdate?: (shippingOptionId: string, amount: number) => void
onError?: (message: string) => void
onChosenChange: (chosen: Chosen | null) => void
providersMap: 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) return
if (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) 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(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) return
await 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 null
return (
<div className="fixed inset-0 z-50">
<div
className="absolute inset-0 bg-black/50"
/>
<div
className="absolute inset-0 flex items-center justify-center p-4"
onClick={(e) => {
if (e.target === e.currentTarget) onClose(initialChosen ? false : true)
}}
>
<div className="relative">
<IconButton
aria-label="Close"
onClick={() => onClose(initialChosen ? false : true)}
className="absolute right-2 top-2 z-[60] shadow-none"
>
<XMark />
</IconButton>
<div
className="
relative
h-[820px] w-[1350px]
max-w-[calc(100vw-32px)] max-h-[calc(100vh-32px)]
overflow-hidden rounded-rounded border bg-white
"
>
<ApishipMap
points={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.deliveryCost
const 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.

Step 10: Create Courier Tariff Selection Modal

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.

Open and add the following:

Directory structure in the Medusa Storefront after creating the file for ApiShip courier modal window

"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: boolean
onClose: (cancel?: boolean) => void
cart: HttpTypes.StoreCart
shippingOptionId: string | null
initialChosen?: Chosen | null
onPriceUpdate?: (shippingOptionId: string, amount: number) => void
onError?: (message: string) => void
onChosenChange: (chosen: Chosen | null) => void
providersMap: 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) return
if (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 = false
const isCancelled = () => cancelled
fn(isCancelled).catch((e) => console.error(e))
return () => { cancelled = true }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps)
}
useAsyncEffect(async (isCancelled) => {
if (!open || !shippingOptionId) return
setIsLoadingCalc(true)
setCalculation(null)
try {
const calculation = (await retrieveCalculation(cart.id, shippingOptionId)) as ApishipCalculation
if (isCancelled()) return
setCalculation(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 null
return tariffsFlat.find((t) => t.key === selectedTariffKey) ?? null
}, [tariffsFlat, selectedTariffKey])
const persistChosen = useCallback(async () => {
if (!shippingOptionId || !selectedTariff) return
setIsLoading(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 null
return (
<div className="fixed inset-0 z-50">
<div
className="absolute inset-0 bg-black/50"
/>
<div
className="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)]">
<div
className="
h-[820px] w-[470px]
max-w-[calc(100vw-32px)] max-h-[calc(100vh-32px)]
overflow-hidden rounded-rounded border bg-white
flex flex-col
"
>
<div className=" flex flex-row justify-between items-center p-[35px] pb-0">
<Heading
level="h2"
className="flex flex-row text-3xl-regular gap-x-2 items-baseline"
>
By courier
</Heading>
<IconButton
aria-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>
<RadioGroup
value={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 === selectedTariffKey
const cost =
typeof t.deliveryCostOriginal === "number"
? t.deliveryCostOriginal
: t.deliveryCost
return (
<Radio
key={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">
<Button
size="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.

Step 11: Create Barrel File

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.

Open and add the following:

Directory structure in the Medusa Storefront after creating the file for single entry point for the integration

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.

Step 12: Integrate Delivery Step

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.

Open and add the following:

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

// ... other imports
import { 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 states
const [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 following
const 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 === 1
const isApishipToPoint = (option?: HttpTypes.StoreCartShippingOption | null) =>
isApishipCalculated(option) && option?.data?.deliveryType === 2
const activeShippingOption = useMemo(() => {
return _shippingMethods?.find((option) => option.id === shippingMethodId) ?? null
}, [_shippingMethods, shippingMethodId])
const apishipMode = useMemo<"point" | "door" | null>(() => {
if (!isOpen || !shippingMethodId) return null
if (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) return
if (!apishipMode) {
setApishipPickupPointModalOpen(false)
setApishipCourierModalOpen(false)
setApishipChosen(null)
return
}
const chosenMode =
apishipChosen?.deliveryType === 2 ? "point"
: apishipChosen?.deliveryType === 1 ? "door"
: null
const hasValidChosen = !!apishipChosen && chosenMode === apishipMode
if (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?.providers
if (cancelled) return
const map: Record<string, string> = {}
for (const provider of providers ?? []) map[provider.key] = provider.name
setProvidersMap(map)
})()
return () => { cancelled = true }
}, [])
useEffect(() => {
if (!isOpen) return
if (!apishipChosen) return
if (!apishipMode) {
setApishipChosen(null)
return
}
const chosenMode = apishipChosen.deliveryType === 2 ? "point" : "door"
if (chosenMode !== apishipMode) {
setApishipChosen(null)
}
}, [isOpen, apishipMode, apishipChosen])
// ... other effects
return (
<div className="bg-white">
{/* ... */}
{isOpen ? (
<>
<div className="grid">
{/* ... */}
<div data-testid="delivery-options-container">
<div className="pb-8 md:pt-0 pt-2">
{/* ... */}
<RadioGroup
value={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 (
<Radio
key={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 */}
<ApishipPickupPointModal
open={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}
/>
<ApishipCourierModal
open={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 && (
<ApishipChosen
chosen={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:

  • The customer can’t continue to payment for an ApiShip shipping option until a valid ApiShip selection has been saved in the cart.
  • The ApiShip calculated prices are displayed as to reflect that the final amount depends on the selected tariff.

Full Storefront Integration Code 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, 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.0.1...main -- examples/fulfillment-apiship/medusa-storefront