Command Palette

Search for a command to run...

Integrate ApiShip to Storefront

To integrate the ApiShip plugin 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 plugin when creating an order in 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:

.env
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

src/modules/checkout/components/shipping-address/index.tsx
1<Input
2 label="Phone"
3 name="shipping_address.phone"
4 autoComplete="tel"
5 value={formData["shipping_address.phone"]}
6 onChange={handleChange}
7 required // Required for ApiShip
8 data-testid="shipping-phone-input"
9/>

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

src/lib/data/cart.ts
1import type { ApishipHttpTypes } from "@gorgo/medusa-fulfillment-apiship/types"
2
3export async function retrieveCart(cartId?: string, fields?: string) {
4 // ...
5 // add +shipping_methods.data parameter
6 fields ??= "*items, *region, *items.product, *items.variant, *items.thumbnail, *items.metadata, +items.total, *promotions, +shipping_methods.name, +shipping_methods.data"
7 // ...
8}
9
10export async function setShippingMethod({
11 // ...
12 data,
13}: {
14 // ...
15 data?: Record<string, unknown>
16}) {
17 // ...
18 return sdk.store.cart
19 .addShippingMethod(
20 cartId,
21 {
22 // ...
23 data // ApiShip selection data
24 },
25 // ...
26 )
27 // ...
28}

Also add the following function:

src/lib/data/cart.ts
1export async function removeShippingMethodFromCart(shippingMethodId: string) {
2 const headers = {
3 ...(await getAuthHeaders()),
4 }
5
6 const next = {
7 ...(await getCacheOptions("fulfillment")),
8 }
9
10 return sdk.client
11 .fetch<ApishipHttpTypes.DeleteResponse<"shipping_method">>(
12 `/store/shipping-methods/${shippingMethodId}`,
13 {
14 method: "DELETE",
15 headers,
16 next,
17 }
18 )
19 .then(async () => {
20 const cartCacheTag = await getCacheTag("carts")
21 revalidateTag(cartCacheTag)
22 })
23 .catch(() => null)
24}

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

src/lib/data/fulfillment.ts
1import type { ApishipHttpTypes } from "@gorgo/medusa-fulfillment-apiship/types"
2
3type StorefrontApishipPoint = Omit<
4 ApishipHttpTypes.StoreApishipPoint,
5 "id" | "lat" | "lng" | "worktime"
6> & {
7 id: string
8 lat: number
9 lng: number
10 worktime?: Record<string, string>
11}
12
13type StorefrontApishipPointListResponse = {
14 points: StorefrontApishipPoint[]
15}
16
17type StorefrontApishipCalculation = {
18 deliveryToDoor?: Array<{
19 providerKey: string
20 tariffs?: Array<ApishipHttpTypes.StoreApishipDoorTariff>
21 }>
22 deliveryToPoint?: Array<{
23 providerKey: string
24 tariffs?: Array<ApishipHttpTypes.StoreApishipPointTariff>
25 }>
26}
27
28// ... other helpers
29
30export const retrieveCalculation = async (
31 cartId: string,
32 shippingOptionId: string
33): Promise<StorefrontApishipCalculation | null> => {
34 const headers = {
35 ...(await getAuthHeaders()),
36
37 }
38
39 const next = {
40 ...(await getCacheOptions("fulfillment")),
41 }
42
43 const body = { cart_id: cartId }
44
45 return sdk.client
46 .fetch<ApishipHttpTypes.StoreApishipCalculationResponse>(
47 `/store/apiship/${shippingOptionId}/calculate`,
48 {
49 method: "POST",
50 headers,
51 body,
52 next,
53 }
54 )
55 .then(({ calculation }) => ({
56 deliveryToDoor: (calculation.deliveryToDoor ?? []).flatMap((group) => {
57 if (!group.providerKey) {
58 return []
59 }
60
61 return [
62 {
63 providerKey: group.providerKey,
64 tariffs: group.tariffs,
65 },
66 ]
67 }),
68 deliveryToPoint: (calculation.deliveryToPoint ?? []).flatMap((group) => {
69 if (!group.providerKey) {
70 return []
71 }
72
73 return [
74 {
75 providerKey: group.providerKey,
76 tariffs: group.tariffs,
77 },
78 ]
79 }),
80 }))
81 .catch((e) => {
82 return null
83 })
84}
85
86export const getPointAddresses = async (
87 cartId: string,
88 shippingOptionId: string,
89 pointIds: Array<number>
90): Promise<StorefrontApishipPointListResponse | null> => {
91 const headers = {
92 ...(await getAuthHeaders()),
93 }
94
95 const next = {
96 ...(await getCacheOptions("fulfillment")),
97 }
98
99 if (!pointIds.length) {
100 return {
101 points: [],
102 }
103 }
104
105 const filter = `id=[${pointIds.join(",")}]`
106 const fields = [
107 "id",
108 "description",
109 "providerKey",
110 "name",
111 "address",
112 "photos",
113 "worktime",
114 "timetable",
115 "lat",
116 "lng",
117 ].join(",")
118 const key = `apiship:points:${cartId}:${shippingOptionId}`
119
120 return sdk.client
121 .fetch<ApishipHttpTypes.StoreApishipPointListResponse>(
122 `/store/apiship/points`,
123 {
124 method: "GET",
125 headers,
126 query: {
127 key,
128 filter,
129 fields,
130 limit: 0,
131 },
132 next,
133 }
134 )
135 .then(({ points }) => ({
136 points: (points ?? []).flatMap((point) => {
137 if (
138 point.id === undefined ||
139 point.id === null ||
140 point.lat === undefined ||
141 point.lat === null ||
142 point.lng === undefined ||
143 point.lng === null
144 ) {
145 return []
146 }
147
148 return [
149 {
150 ...point,
151 id: String(point.id),
152 lat: point.lat,
153 lng: point.lng,
154 worktime: point.worktime as Record<string, string> | undefined,
155 },
156 ]
157 }),
158 }))
159 .catch((e) => {
160 console.error("getPointsAddresses error", e)
161 return null
162 })
163}
164
165export const retrieveProviders = async (): Promise<ApishipHttpTypes.StoreApishipProviderListResponse | null> => {
166 const headers = {
167 ...(await getAuthHeaders()),
168 }
169
170 const next = {
171 ...(await getCacheOptions("fulfillment")),
172 }
173
174 return sdk.client
175 .fetch<ApishipHttpTypes.StoreApishipProviderListResponse>(
176 `/store/apiship/providers`,
177 {
178 method: "GET",
179 headers,
180 next,
181 }
182 )
183 .catch((e) => {
184 console.error("retrieveProviders error", e)
185 return null
186 })
187}

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

src/modules/checkout/components/shipping/apiship/types/index.ts
1import type { ApishipHttpTypes } from "@gorgo/medusa-fulfillment-apiship/types"
2
3export type ApishipCalculation = {
4 deliveryToDoor?: Array<{
5 providerKey: string
6 tariffs?: Array<ApishipHttpTypes.StoreApishipDoorTariff>
7 }>
8 deliveryToPoint?: Array<{
9 providerKey: string
10 tariffs?: Array<ApishipHttpTypes.StoreApishipPointTariff>
11 }>
12}
13
14export type ApishipTariff = {
15 key: string
16 providerKey: string
17} & (
18 ApishipHttpTypes.StoreApishipDoorTariff |
19 ApishipHttpTypes.StoreApishipPointTariff
20)
21
22export type ApishipPoint = Omit<
23 ApishipHttpTypes.StoreApishipPoint,
24 "id" | "lat" | "lng" | "worktime"
25> & {
26 id: string
27 lat: number
28 lng: number
29 worktime?: Record<string, string>
30}
31
32export type Chosen = {
33 deliveryType: number
34 tariff: ApishipTariff
35 point?: ApishipPoint
36}

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

src/modules/checkout/components/shipping/apiship/utils/index.ts
1import {
2 ApishipCalculation,
3 ApishipTariff,
4} from "../types"
5import { useEffect, useRef } from "react"
6
7export function days(tariff: ApishipTariff) {
8 const min = tariff.daysMin ?? 0
9 const max = tariff.daysMax ?? 0
10 if (!min && !max) return null
11 if (min === max) return min === 1 ? `${min} day` : `${min} days`
12 return `${min}${max} days`
13}
14
15export function useLatestRef<T>(value: T) {
16 const ref = useRef(value)
17 useEffect(() => {
18 ref.current = value
19 }, [value])
20 return ref
21}
22
23export function buildTariffKey (
24 providerKey: string,
25 tariff: Omit<ApishipTariff, "key" | "providerKey">,
26 idx: number
27) {
28 return `${providerKey}:${tariff.tariffId ?? tariff.tariffProviderId ?? tariff.tariffName ?? idx}`
29}
30
31export function useLockBodyScroll(locked: boolean) {
32 useEffect(() => {
33 if (!locked) return
34
35 const body = document.body
36 const html = document.documentElement
37
38 const prevBodyOverflow = body.style.overflow
39 const prevBodyPaddingRight = body.style.paddingRight
40 const prevHtmlOverflow = html.style.overflow
41
42 const scrollbarWidth = window.innerWidth - html.clientWidth
43 if (scrollbarWidth > 0) {
44 body.style.paddingRight = `${scrollbarWidth}px`
45 }
46
47 body.style.overflow = "hidden"
48 html.style.overflow = "hidden"
49
50 return () => {
51 body.style.overflow = prevBodyOverflow
52 body.style.paddingRight = prevBodyPaddingRight
53 html.style.overflow = prevHtmlOverflow
54 }
55 }, [locked])
56}
57
58export function useAsyncEffect(fn: (isCancelled: () => boolean) => Promise<void>, deps: any[]) {
59 useEffect(() => {
60 let cancelled = false
61 const isCancelled = () => cancelled
62 fn(isCancelled).catch((e) => console.error(e))
63 return () => { cancelled = true }
64 // eslint-disable-next-line react-hooks/exhaustive-deps
65 }, deps)
66}
67
68export function buildTariffsByPointId(calculation?: ApishipCalculation | null) {
69 const map: Record<string, ApishipTariff[]> = {}
70 calculation?.deliveryToPoint?.forEach(({ providerKey, tariffs }) => {
71 tariffs?.forEach((tariff) => {
72 for (const pointId of tariff.pointIds ?? []) {
73 const key = `${providerKey}:${tariff.tariffProviderId ?? ""}:${tariff.tariffId ?? ""}`
74 const entry: ApishipTariff = { key, providerKey, ...tariff }
75 const arr = (map[String(pointId)] ??= [])
76 if (!arr.some((t) => t.key === key)) arr.push(entry)
77 }
78 })
79 })
80 return map
81}
82
83export function extractPointIds(map: Record<string, ApishipTariff[]>) {
84 return Object.keys(map).map(Number).filter(Number.isFinite)
85}

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

src/modules/checkout/components/shipping/apiship/apiship-map.tsx
1"use client"
2
3import { useCallback, useEffect, useMemo, useRef } from "react"
4import { Button, Heading, Text, clx, IconButton } from "@medusajs/ui"
5import { Loader, XMark } from "@medusajs/icons"
6import { Radio, RadioGroup } from "@headlessui/react"
7import MedusaRadio from "@modules/common/components/radio"
8import {
9 ApishipPoint,
10 ApishipTariff
11} from "./types"
12import {
13 days,
14 useLatestRef
15} from "./utils"
16
17const DEFAULT_CENTER: [number, number] = [37.618423, 55.751244]
18
19type ApishipMapProps = {
20 points: ApishipPoint[]
21 tariffsByPointId: Record<string, ApishipTariff[]>
22 isLoading?: boolean
23 selectedPointId: string | null
24 selectedTariffKey: string | null
25 isPanelOpen: boolean
26 onClosePanel: () => void
27 onSelectPoint: (id: string) => void
28 onSelectTariff: (tariffKey: string) => void
29 onChoose: (payload: { point: ApishipPoint; tariff: ApishipTariff }) => void
30 chosen?: { pointId?: string; tariffKey?: string } | null
31 providersMap: Record<string, string>
32}
33
34const WEEK_DAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] as const
35
36const Schedule = ({ worktime }: { worktime: Record<string, string> }) => {
37 if (!worktime || Object.keys(worktime).length === 0) return null
38 return (
39 <div className="flex flex-col gap-[1px]">
40 {Object.keys(worktime).map((day) => {
41 const label = WEEK_DAYS[Number(day) - 1]
42 const time = worktime[day]
43 return (
44 <div className="flex flex-row justify-between" key={day}>
45 <Text className="txt-medium text-ui-fg-subtle">
46 {label}
47 </Text>
48 <Text className="text-ui-fg-muted">
49 {time}
50 </Text>
51 </div>
52 )
53 })}
54 </div>
55 )
56}
57
58declare global {
59 interface Window {
60 ymaps3?: any
61 __ymaps3_loading_promise__?: Promise<void>
62 }
63}
64
65function ensureYmaps3Loaded(params: { apikey: string; lang?: string }): Promise<void> {
66 if (typeof window === "undefined") return Promise.resolve()
67 if (window.__ymaps3_loading_promise__) return window.__ymaps3_loading_promise__
68
69 window.__ymaps3_loading_promise__ = new Promise<void>((resolve, reject) => {
70 const existing = document.querySelector<HTMLScriptElement>('script[src^="https://api-maps.yandex.ru/v3/"]')
71 if (existing) return resolve()
72
73 const script = document.createElement("script")
74 script.src = `https://api-maps.yandex.ru/v3/?apikey=${encodeURIComponent(params.apikey)}&lang=${encodeURIComponent(
75 params.lang ?? "ru_RU"
76 )}`
77 script.async = true
78 script.onload = () => resolve()
79 script.onerror = () => reject(new Error(`Failed to load Yandex Maps JS API v3 script: ${script.src}`))
80 document.head.appendChild(script)
81 })
82
83 return window.__ymaps3_loading_promise__
84}
85
86export const ApishipMap: React.FC<ApishipMapProps> = ({
87 points,
88 tariffsByPointId,
89 isLoading,
90 selectedPointId,
91 selectedTariffKey,
92 isPanelOpen,
93 onClosePanel,
94 onSelectPoint,
95 onSelectTariff,
96 onChoose,
97 chosen,
98 providersMap
99}) => {
100 const containerRef = useRef<HTMLDivElement | null>(null)
101 const mapRef = useRef<any>(null)
102 const markersRef = useRef<Map<string, { marker: any; el: HTMLDivElement }>>(new Map())
103 const initPromiseRef = useRef<Promise<void> | null>(null)
104
105 const isSameAsChosen =
106 !!chosen?.pointId &&
107 !!chosen?.tariffKey &&
108 chosen.pointId === selectedPointId &&
109 chosen.tariffKey === selectedTariffKey
110
111 const onSelectPointRef = useLatestRef(onSelectPoint)
112
113 const center = useMemo<[number, number]>(() => {
114 const p = points?.[0]
115 return p ? [p.lng, p.lat] : DEFAULT_CENTER
116 }, [points])
117
118 const activePoint = useMemo(() => {
119 return selectedPointId ? points.find((p) => p.id === selectedPointId) ?? null : null
120 }, [points, selectedPointId])
121
122 const activeTariffs = useMemo(() => {
123 if (!selectedPointId) return []
124 return tariffsByPointId[selectedPointId] ?? []
125 }, [tariffsByPointId, selectedPointId])
126
127 const selectedTariff = useMemo(() => {
128 if (!selectedTariffKey) return null
129 return activeTariffs.find((t) => t.key === selectedTariffKey) ?? null
130 }, [activeTariffs, selectedTariffKey])
131
132 const clearMarkers = useCallback(() => {
133 const map = mapRef.current
134 if (!map) return
135 for (const { marker } of markersRef.current.values()) {
136 try {
137 map.removeChild(marker)
138 } catch { }
139 }
140 markersRef.current.clear()
141 }, [])
142
143 const destroyMap = useCallback(() => {
144 clearMarkers()
145 try {
146 mapRef.current?.destroy?.()
147 } catch { }
148 mapRef.current = null
149 initPromiseRef.current = null
150 }, [clearMarkers])
151
152 const applySelectionStyles = useCallback(() => {
153 for (const [id, { el }] of markersRef.current.entries()) {
154 const sel = id === selectedPointId
155
156 el.style.background = sel ? "#3b82f6" : "white"
157 el.style.border = sel ? "2px solid #1d4ed8" : "2px solid rgba(0,0,0,0.25)"
158
159 const dot = el.firstElementChild as HTMLElement | null
160 if (dot) {
161 dot.style.border = sel
162 ? "2px solid rgba(255,255,255,0.95)"
163 : "2px solid rgba(0,0,0,0.25)"
164 }
165 }
166 }, [selectedPointId])
167
168 useEffect(() => {
169 if (!containerRef.current || mapRef.current) return
170 let cancelled = false
171
172 initPromiseRef.current = (async () => {
173 const apikey = process.env.NEXT_PUBLIC_YANDEX_MAPS_API_KEY
174 if (!apikey) throw new Error("NEXT_PUBLIC_YANDEX_MAPS_API_KEY is not set")
175
176 await ensureYmaps3Loaded({ apikey, lang: "ru_RU" })
177 if (cancelled) return
178
179 const ymaps3 = window.ymaps3
180 if (!ymaps3) return
181 await ymaps3.ready
182 if (cancelled) return
183
184 ymaps3.import.registerCdn("https://cdn.jsdelivr.net/npm/{package}", "@yandex/ymaps3-default-ui-theme@0.0")
185
186 const { YMap, YMapDefaultSchemeLayer, YMapDefaultFeaturesLayer } = ymaps3
187
188 const map = new YMap(containerRef.current!, { location: { center, zoom: 10 } })
189 map.addChild(new YMapDefaultSchemeLayer({}))
190 map.addChild(new YMapDefaultFeaturesLayer({ zIndex: 1800 }))
191
192 mapRef.current = map
193 })().catch((e) => console.error("Yandex map init failed", e))
194
195 return () => {
196 cancelled = true
197 destroyMap()
198 }
199 // eslint-disable-next-line react-hooks/exhaustive-deps
200 }, [destroyMap])
201
202 useEffect(() => {
203 (async () => {
204 if (!initPromiseRef.current) return
205 await initPromiseRef.current
206 try {
207 mapRef.current?.setLocation?.({ center, zoom: 10 })
208 } catch { }
209 })()
210 }, [center])
211
212 useEffect(() => {
213 let cancelled = false;
214 (async () => {
215 if (!initPromiseRef.current) return
216 await initPromiseRef.current
217 if (cancelled) return
218
219 const map = mapRef.current
220 const ymaps3 = window.ymaps3
221 if (!map || !ymaps3) return
222
223 const { YMapMarker } = ymaps3
224
225 clearMarkers()
226
227 for (const p of points) {
228 const el = document.createElement("div")
229
230 const SIZE = 18
231
232 el.style.width = `${SIZE}px`
233 el.style.height = `${SIZE}px`
234
235 el.style.background = "white"
236 el.style.border = "2px solid rgba(0,0,0,0.25)"
237 el.style.borderRadius = "50% 50% 50% 0"
238 el.style.transform = "rotate(-45deg)"
239 el.style.boxShadow = "0 2px 2px rgba(0,0,0,0.18)"
240 el.style.cursor = "pointer"
241 el.style.position = "relative"
242 el.style.transformOrigin = "50% 50%"
243
244 const dot = document.createElement("div")
245 dot.style.width = "8px"
246 dot.style.height = "8px"
247 dot.style.background = "white"
248 dot.style.border = "2px solid rgba(0,0,0,0.25)"
249 dot.style.borderRadius = "9999px"
250 dot.style.position = "absolute"
251 dot.style.left = "50%"
252 dot.style.top = "50%"
253 dot.style.transform = "translate(-50%, -50%) rotate(45deg)"
254 dot.style.boxSizing = "border-box"
255
256 el.appendChild(dot)
257
258 el.title = p.name ?? p.address ?? `Point ${p.id}`
259
260 el.addEventListener("click", (e) => {
261 e.preventDefault()
262 e.stopPropagation()
263 onSelectPointRef.current(p.id)
264 })
265
266 const marker = new YMapMarker({ coordinates: [p.lng, p.lat] }, el)
267 map.addChild(marker)
268 markersRef.current.set(p.id, { marker, el })
269 }
270 applySelectionStyles()
271 })()
272
273 return () => {
274 cancelled = true
275 }
276 }, [points, clearMarkers, onSelectPointRef])
277
278 useEffect(() => {
279 applySelectionStyles()
280 }, [applySelectionStyles])
281
282 const showNoPoints = !isLoading && points.length === 0
283
284 return (
285 <div className="relative w-full h-full">
286 <div ref={containerRef} className="w-full h-full" />
287 {isLoading && (
288 <div className="absolute inset-0 bg-white/80 flex items-center justify-center">
289 <Loader />
290 </div>
291 )}
292 {showNoPoints && (
293 <div className="absolute inset-0 bg-white/80 flex items-center justify-center">
294 <Text className="text-ui-fg-muted">No pickup points found for this shipping method.</Text>
295 </div>
296 )}
297 {!isLoading && activePoint && isPanelOpen && !showNoPoints && (
298 <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">
299 <div className="flex flex-row justify-between p-[35px] pb-0 items-center">
300 <Heading
301 level="h2"
302 className="flex flex-row text-3xl-regular gap-x-2 items-baseline"
303 >
304 {`${providersMap?.[activePoint.providerKey ?? ""] ?? ""} pickup point`.trimStart()}
305 </Heading>
306 <IconButton
307 aria-label="Close"
308 onClick={(e) => {
309 e.preventDefault()
310 e.stopPropagation()
311 onClosePanel()
312 }}
313 className="shadow-none"
314 >
315 <XMark />
316 </IconButton>
317 </div>
318
319 <div className="flex-1 min-h-0 overflow-y-auto p-[35px] pt-[18px] flex flex-col gap-[18px]">
320 <div className="flex flex-col">
321 <Text className="font-medium txt-medium text-ui-fg-base">
322 {activePoint.name}
323 </Text>
324 <Text className="text-ui-fg-muted txt-medium">
325 {activePoint.address}
326 </Text>
327 </div>
328
329 <div className="flex flex-col gap-[10px]">
330 <Text className="font-medium txt-medium text-ui-fg-base">Tariffs</Text>
331
332 <RadioGroup className="flex flex-col gap-[10px]">
333 {activeTariffs.map((t) => {
334 const active = t.key === selectedTariffKey
335 const cost =
336 typeof t.deliveryCostOriginal === "number"
337 ? t.deliveryCostOriginal
338 : t.deliveryCost
339
340 return (
341 <Radio
342 key={t.tariffId}
343 value={t.tariffId}
344 data-testid="delivery-option-radio"
345 onClick={() => {
346 onSelectTariff(t.key)
347 }}
348 className={clx(
349 "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",
350 { "border-ui-border-interactive": active }
351 )}
352 >
353 <div className="flex gap-2 w-full">
354 <MedusaRadio checked={active} />
355 <div className="flex flex-row w-full items-center justify-between">
356 <div className="flex flex-col">
357 <span className="txt-compact-small-plus">
358 {t.tariffName}
359 </span>
360 <span className="txt-small text-ui-fg-subtle">
361 Delivery time: {days(t)}
362 </span>
363 </div>
364 <span className="txt-small-plus text-ui-fg-subtle">
365 {`RUB ${cost}`}
366 </span>
367 </div>
368 </div>
369 </Radio>
370 )
371 })}
372 </RadioGroup>
373 </div>
374 <Button
375 size="large"
376 onClick={() => {
377 if (!activePoint || !selectedTariff) return
378 onChoose({ point: activePoint, tariff: selectedTariff })
379 }}
380 disabled={!selectedTariff || isSameAsChosen}
381 className="w-full mb-[16px] !overflow-visible"
382 >
383 Choose
384 </Button>
385 {activePoint.worktime && (
386 <div className="flex flex-col">
387 <Text className="font-medium txt-medium text-ui-fg-base">
388 Schedule
389 </Text>
390 <Schedule worktime={activePoint.worktime} />
391 </div>
392 )}
393
394 {!!activePoint.photos?.length && (
395 <div className="flex flex-col gap-[10px]">
396 <Text className="font-medium txt-medium text-ui-fg-base">Photos</Text>
397 <div className="flex flex-row gap-[10px] overflow-x-auto">
398 {activePoint.photos.map((src, index) => (
399 // eslint-disable-next-line @next/next/no-img-element
400 <img
401 key={index}
402 src={src}
403 alt={`Photo ${index + 1} of pickup point`}
404 className="w-auto h-[120px] rounded-md border object-cover"
405 />
406 ))}
407 </div>
408 </div>
409 )}
410
411 {activePoint.description && (
412 <div className="flex flex-col">
413 <Text className="font-medium txt-medium text-ui-fg-base">
414 Description
415 </Text>
416 <Text className="text-ui-fg-muted txt-medium">
417 {activePoint.description}
418 </Text>
419 </div>
420 )}
421 </div>
422 </div>
423 )}
424 </div>
425 )
426}

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

src/modules/checkout/components/shipping/apiship/apiship-chosen.tsx
1"use client"
2
3import { Heading, Text } from "@medusajs/ui"
4import { Chosen } from "./types"
5import { days } from "./utils"
6
7type ApishipChosenProps = {
8 chosen: Chosen
9 onRemove: () => void
10 onEdit: () => void
11}
12
13export const ApishipChosen: React.FC<ApishipChosenProps> = ({
14 chosen,
15 onRemove,
16 onEdit
17}) => {
18 const cost =
19 typeof chosen.tariff.deliveryCostOriginal === "number"
20 ? chosen.tariff.deliveryCostOriginal
21 : chosen.tariff.deliveryCost
22
23 const isToPoint = chosen.deliveryType === 2
24
25 return (
26 <div className="flex flex-col gap-4 mt-[32px]">
27 <div className="flex flex-row justify-between">
28 <Heading level="h2" className="txt-xlarge">
29 {isToPoint ? "To the Pickup Point" : "By Courier"}
30 </Heading>
31 <div className="flex flex-row gap-[16px]">
32 <Text>
33 <button
34 onClick={(e) => {
35 e.preventDefault()
36 e.stopPropagation()
37 onRemove()
38 }}
39 className="text-ui-fg-interactive hover:text-ui-fg-interactive-hover"
40 >
41 Remove
42 </button>
43 </Text>
44 <Text>
45 <button
46 onClick={(e) => {
47 e.preventDefault()
48 e.stopPropagation()
49 onEdit()
50 }}
51 className="text-ui-fg-interactive hover:text-ui-fg-interactive-hover"
52 >
53 Edit
54 </button>
55 </Text>
56 </div>
57 </div>
58
59 {isToPoint ? (
60 <div className="flex flex-col gap-4">
61 <div className="flex flex-col gap-4 w-[60%]">
62 <div className="flex flex-col">
63 <Text>{chosen.point?.name}</Text>
64 {chosen.point?.address && (
65 <Text className="text-ui-fg-muted">{chosen.point.address}</Text>
66 )}
67 {chosen.point?.timetable && (
68 <Text className="text-ui-fg-muted">{chosen.point.timetable}</Text>
69 )}
70 </div>
71 {chosen.point?.description && (
72 <Text className="text-ui-fg-muted leading-none">{chosen.point.description}</Text>
73 )}
74 </div>
75 <Text>
76 {chosen.tariff.tariffName} · {`RUB ${cost ?? "—"}`}{days(chosen.tariff) ? ` · ${days(chosen.tariff)}` : ""}
77 </Text>
78 </div>
79 ) : (
80 <Text>
81 {
82 [chosen?.tariff?.tariffName,
83 typeof cost === "number" ? `RUB ${cost}` : "RUB —",
84 days?.(chosen?.tariff) || null,
85 ].filter(Boolean).join(" · ")
86 }
87 </Text>
88 )}
89 </div>
90 )
91}

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

src/modules/checkout/components/shipping/apiship/apiship-pickup-point-modal.tsx
1"use client"
2
3import { useCallback, useEffect, useRef, useState } from "react"
4import { IconButton } from "@medusajs/ui"
5import { XMark } from "@medusajs/icons"
6import { HttpTypes } from "@medusajs/types"
7import { setShippingMethod } from "@lib/data/cart"
8import {
9 calculatePriceForShippingOption,
10 retrieveCalculation,
11 getPointAddresses,
12} from "@lib/data/fulfillment"
13import { ApishipMap } from "./apiship-map"
14import {
15 ApishipPoint,
16 ApishipTariff,
17 Chosen
18} from "./types"
19import {
20 buildTariffsByPointId,
21 extractPointIds,
22 useAsyncEffect,
23 useLatestRef,
24 useLockBodyScroll,
25} from "./utils"
26
27type ApishipPickupPointModalProps = {
28 open: boolean
29 onClose: (cancel?: boolean) => void
30 cart: HttpTypes.StoreCart
31 shippingOptionId: string | null
32 initialChosen?: Chosen | null
33 onPriceUpdate?: (shippingOptionId: string, amount: number) => void
34 onError?: (message: string) => void
35 onChosenChange: (chosen: Chosen | null) => void
36 providersMap: Record<string, string>
37}
38
39export const ApishipPickupPointModal: React.FC<ApishipPickupPointModalProps> = ({
40 open,
41 onClose,
42 cart,
43 shippingOptionId,
44 initialChosen,
45 onPriceUpdate,
46 onError,
47 onChosenChange,
48 providersMap
49}) => {
50 const onErrorRef = useLatestRef(onError)
51 const onPriceRef = useLatestRef(onPriceUpdate)
52
53 const [isLoadingPoints, setIsLoadingPoints] = useState(false)
54 const [points, setPoints] = useState<ApishipPoint[]>([])
55 const [tariffsByPointId, setTariffsByPointId] = useState<Record<string, ApishipTariff[]>>({})
56
57 const [selectedPointId, setSelectedPointId] = useState<string | null>(null)
58 const [selectedTariffKey, setSelectedTariffKey] = useState<string | null>(null)
59 const [isPanelOpen, setIsPanelOpen] = useState(false)
60
61 useLockBodyScroll(open)
62
63 useEffect(() => {
64 if (!open) return
65
66 if (initialChosen?.deliveryType === 2) {
67 setSelectedPointId(initialChosen.point?.id ?? null)
68 setSelectedTariffKey(initialChosen.tariff?.key ?? null)
69 setIsPanelOpen(true)
70 return
71 }
72
73 setSelectedPointId(null)
74 setSelectedTariffKey(null)
75 setIsPanelOpen(false)
76 }, [open, initialChosen, shippingOptionId])
77
78 useEffect(() => {
79 setSelectedPointId(null)
80 setSelectedTariffKey(null)
81 setIsPanelOpen(false)
82 }, [shippingOptionId])
83
84 useAsyncEffect(async (isCancelled) => {
85 if (!open || !shippingOptionId) return
86
87 setIsLoadingPoints(true)
88 try {
89 const calculation = await retrieveCalculation(cart.id, shippingOptionId)
90 if (isCancelled()) return
91 const tariffsMap = buildTariffsByPointId(calculation)
92 setTariffsByPointId(tariffsMap)
93
94 const pointIds = extractPointIds(tariffsMap)
95 if (!pointIds.length) {
96 setPoints([])
97 return
98 }
99
100 const pointAddresses = await getPointAddresses(
101 cart.id,
102 shippingOptionId,
103 pointIds
104 )
105 if (isCancelled()) return
106
107 setPoints(pointAddresses?.points ?? [])
108 } catch (e: any) {
109 console.error(e)
110 onErrorRef.current?.(e?.message ?? "Failed to load pickup points")
111 setPoints([])
112 setTariffsByPointId({})
113 } finally {
114 if (!isCancelled()) setIsLoadingPoints(false)
115 }
116 }, [open, shippingOptionId, cart.id])
117
118 const persistChosen = useCallback(async (next: Chosen) => {
119 if (!shippingOptionId) return
120
121 await setShippingMethod({
122 cartId: cart.id,
123 shippingMethodId: shippingOptionId,
124 data: { apishipData: next },
125 })
126
127 const calculation = await calculatePriceForShippingOption(shippingOptionId, cart.id)
128 if (calculation?.id && typeof calculation.amount === "number") {
129 onPriceRef.current?.(calculation.id, calculation.amount)
130 }
131 }, [cart.id, shippingOptionId, onPriceRef])
132
133 if (!open) return null
134
135 return (
136 <div className="fixed inset-0 z-50">
137 <div
138 className="absolute inset-0 bg-black/50"
139 />
140 <div
141 className="absolute inset-0 flex items-center justify-center p-4"
142 onClick={(e) => {
143 if (e.target === e.currentTarget) onClose(initialChosen ? false : true)
144 }}
145 >
146 <div className="relative">
147 <IconButton
148 aria-label="Close"
149 onClick={() => onClose(initialChosen ? false : true)}
150 className="absolute right-2 top-2 z-[60] shadow-none"
151 >
152 <XMark />
153 </IconButton>
154 <div
155 className="
156 relative
157 h-[820px] w-[1350px]
158 max-w-[calc(100vw-32px)] max-h-[calc(100vh-32px)]
159 overflow-hidden rounded-rounded border bg-white
160 "
161 >
162 <ApishipMap
163 points={points}
164 tariffsByPointId={tariffsByPointId}
165 isLoading={isLoadingPoints}
166 selectedPointId={selectedPointId}
167 selectedTariffKey={selectedTariffKey}
168 isPanelOpen={isPanelOpen}
169 onClosePanel={() => {
170 setIsPanelOpen(false)
171 setSelectedPointId(null)
172 }}
173 onSelectPoint={(pid) => {
174 setSelectedPointId(pid)
175 setSelectedTariffKey(null)
176 setIsPanelOpen(true)
177 }}
178 onSelectTariff={(key) => setSelectedTariffKey(key)}
179 chosen={
180 initialChosen?.deliveryType === 2
181 ? { pointId: initialChosen.point?.id, tariffKey: initialChosen.tariff?.key }
182 : null
183 }
184 onChoose={async ({ point, tariff }) => {
185 const cost =
186 typeof tariff.deliveryCostOriginal === "number"
187 ? tariff.deliveryCostOriginal
188 : tariff.deliveryCost
189
190 const chosen: Chosen = {
191 deliveryType: 2,
192 tariff,
193 point
194 }
195 try {
196 await persistChosen(chosen)
197 onChosenChange(chosen)
198 onClose()
199 } catch (e: any) {
200 onErrorRef.current?.(e?.message ?? "Failed to save the selected tariff")
201 }
202 }}
203 providersMap={providersMap}
204 />
205 </div>
206 </div>
207 </div>
208 </div>
209 )
210}

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

src/modules/checkout/components/shipping/apiship/apiship-courier-modal.tsx
1"use client"
2
3import { useCallback, useEffect, useMemo, useState } from "react"
4import { Button, clx, Heading, IconButton, Text } from "@medusajs/ui"
5import MedusaRadio from "@modules/common/components/radio"
6import { Loader, XMark } from "@medusajs/icons"
7import { HttpTypes } from "@medusajs/types"
8import { Radio, RadioGroup } from "@headlessui/react"
9import { setShippingMethod } from "@lib/data/cart"
10import { calculatePriceForShippingOption, retrieveCalculation } from "@lib/data/fulfillment"
11import {
12 ApishipCalculation,
13 ApishipTariff,
14 Chosen
15} from "./types"
16import {
17 buildTariffKey,
18 days,
19 useLatestRef,
20 useLockBodyScroll
21} from "./utils"
22
23type ApishipCourierModalProps = {
24 open: boolean
25 onClose: (cancel?: boolean) => void
26 cart: HttpTypes.StoreCart
27 shippingOptionId: string | null
28 initialChosen?: Chosen | null
29 onPriceUpdate?: (shippingOptionId: string, amount: number) => void
30 onError?: (message: string) => void
31 onChosenChange: (chosen: Chosen | null) => void
32 providersMap: Record<string, string>
33}
34
35export const ApishipCourierModal: React.FC<ApishipCourierModalProps> = ({
36 open,
37 onClose,
38 cart,
39 shippingOptionId,
40 initialChosen,
41 onPriceUpdate,
42 onError,
43 onChosenChange,
44 providersMap,
45}) => {
46 const onErrorRef = useLatestRef(onError)
47 const onPriceRef = useLatestRef(onPriceUpdate)
48
49 const [isLoading, setIsLoading] = useState(false)
50 const [isLoadingCalc, setIsLoadingCalc] = useState(false)
51
52 const [calculation, setCalculation] = useState<ApishipCalculation | null>(null)
53 const [selectedTariffKey, setSelectedTariffKey] = useState<string | null>(null)
54
55 useLockBodyScroll(open)
56
57 useEffect(() => {
58 if (!open) return
59
60 if (initialChosen) {
61 setSelectedTariffKey(initialChosen.tariff?.key ?? null)
62 return
63 }
64
65 setSelectedTariffKey(null)
66 }, [open, initialChosen, shippingOptionId])
67
68 useEffect(() => {
69 setSelectedTariffKey(null)
70 }, [shippingOptionId])
71
72 function useAsyncEffect(fn: (isCancelled: () => boolean) => Promise<void>, deps: any[]) {
73 useEffect(() => {
74 let cancelled = false
75 const isCancelled = () => cancelled
76 fn(isCancelled).catch((e) => console.error(e))
77 return () => { cancelled = true }
78 // eslint-disable-next-line react-hooks/exhaustive-deps
79 }, deps)
80 }
81
82 useAsyncEffect(async (isCancelled) => {
83 if (!open || !shippingOptionId) return
84
85 setIsLoadingCalc(true)
86 setCalculation(null)
87
88 try {
89 const calculation = await retrieveCalculation(cart.id, shippingOptionId)
90 if (isCancelled()) return
91 setCalculation(calculation)
92 } catch (e: any) {
93 console.error(e)
94 onErrorRef.current?.(e?.message ?? "Failed to load calculation")
95 setCalculation(null)
96 } finally {
97 if (!isCancelled()) setIsLoadingCalc(false)
98 }
99 }, [open, shippingOptionId, cart.id])
100
101 const doorGroups = useMemo(() => {
102 return calculation?.deliveryToDoor ?? []
103 }, [calculation])
104
105 const tariffsFlat = useMemo(() => {
106 return doorGroups.flatMap((g) =>
107 (g.tariffs ?? []).map((t, idx) => {
108 const key = buildTariffKey(g.providerKey, t, idx)
109 const full: ApishipTariff = {
110 ...t,
111 key,
112 providerKey: g.providerKey,
113 }
114 return full
115 })
116 )
117 }, [doorGroups])
118
119 const selectedTariff = useMemo(() => {
120 if (!selectedTariffKey) return null
121 return tariffsFlat.find((t) => t.key === selectedTariffKey) ?? null
122 }, [tariffsFlat, selectedTariffKey])
123
124 const persistChosen = useCallback(async () => {
125 if (!shippingOptionId || !selectedTariff) return
126
127 setIsLoading(true)
128 try {
129 const next: Chosen = {
130 deliveryType: 1,
131 tariff: selectedTariff
132 }
133
134 await setShippingMethod({
135 cartId: cart.id,
136 shippingMethodId: shippingOptionId,
137 data: { apishipData: next },
138 })
139
140 const calc = await calculatePriceForShippingOption(shippingOptionId, cart.id)
141 if (calc?.id && typeof calc.amount === "number") {
142 onPriceRef.current?.(calc.id, calc.amount)
143 }
144
145 onChosenChange(next)
146 onClose()
147 } catch (e: any) {
148 onErrorRef.current?.(e?.message ?? "Failed to save courier tariff")
149 } finally {
150 setIsLoading(false)
151 }
152 }, [cart.id, shippingOptionId, selectedTariff, onPriceRef, onChosenChange, onClose, onErrorRef])
153
154 if (!open) return null
155
156 return (
157 <div className="fixed inset-0 z-50">
158 <div
159 className="absolute inset-0 bg-black/50"
160 />
161 <div
162 className="absolute inset-0 flex items-center justify-center p-4"
163 onClick={(e) => {
164 if (e.target === e.currentTarget) onClose(initialChosen ? false : true)
165 }}
166 >
167 <div className="relative w-[470px] max-w-[calc(100vw-32px)]">
168 <div
169 className="
170 h-[820px] w-[470px]
171 max-w-[calc(100vw-32px)] max-h-[calc(100vh-32px)]
172 overflow-hidden rounded-rounded border bg-white
173 flex flex-col
174 "
175 >
176 <div className=" flex flex-row justify-between items-center p-[35px] pb-0">
177 <Heading
178 level="h2"
179 className="flex flex-row text-3xl-regular gap-x-2 items-baseline"
180 >
181 By courier
182 </Heading>
183 <IconButton
184 aria-label="Close"
185 onClick={() => onClose(initialChosen ? false : true)}
186 className="shadow-none"
187 >
188 <XMark />
189 </IconButton>
190 </div>
191 <div className="flex-1 min-h-0 overflow-y-auto px-[35px] pt-[18px]">
192 {isLoadingCalc ? (
193 <div className="h-full w-full flex items-center justify-center">
194 <Loader />
195 </div>
196 ) : doorGroups.length === 0 ? (
197 <Text className="text-ui-fg-muted">No courier tariffs available.</Text>
198 ) : (
199 <div className="flex flex-col gap-[15px] pb-[18px]">
200 {doorGroups
201 .filter((g) => (g.tariffs?.length ?? 0) > 0)
202 .map((g) => (
203 <div key={g.providerKey} className="flex flex-col gap-[10px]">
204 <Text className="font-medium txt-medium text-ui-fg-base">
205 {providersMap[g.providerKey] ?? g.providerKey}
206 </Text>
207 <RadioGroup
208 value={selectedTariffKey}
209 onChange={(v) => setSelectedTariffKey(String(v))}
210 className="flex flex-col gap-[10px]"
211 >
212 {(g.tariffs ?? []).map((t, idx) => {
213 const k = buildTariffKey(g.providerKey, t, idx)
214 const checked = k === selectedTariffKey
215
216 const cost =
217 typeof t.deliveryCostOriginal === "number"
218 ? t.deliveryCostOriginal
219 : t.deliveryCost
220
221 return (
222 <Radio
223 key={k}
224 value={k}
225 className={clx(
226 "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",
227 { "border-ui-border-interactive": checked }
228 )}
229 >
230 <div className="flex gap-2 w-full">
231 <MedusaRadio checked={checked} />
232 <div className="flex flex-row w-full items-center justify-between">
233 <div className="flex flex-col">
234 <span className="txt-compact-small-plus">
235 {t.tariffName}
236 </span>
237 <span className="txt-small text-ui-fg-subtle">
238 Delivery time: {days(t as ApishipTariff)}
239 </span>
240 </div>
241 <span className="txt-small-plus text-ui-fg-subtle">
242 {typeof cost === "number" ? `RUB ${cost}` : "—"}
243 </span>
244 </div>
245 </div>
246 </Radio>
247 )
248 })}
249 </RadioGroup>
250 </div>
251 ))}
252 </div>
253 )}
254 </div>
255 <div className="p-[35px] pt-[18px] bg-white">
256 <Button
257 size="large"
258 onClick={persistChosen}
259 isLoading={isLoading}
260 disabled={!selectedTariffKey || isLoadingCalc || selectedTariff?.key === initialChosen?.tariff.key}
261 className="w-full"
262 >
263 Choose
264 </Button>
265 </div>
266 </div>
267 </div>
268 </div>
269 </div>
270 )
271}

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

src/modules/checkout/components/shipping/apiship/index.ts
1export * from "./apiship-courier-modal"
2export * from "./apiship-chosen"
3export * from "./apiship-map"
4export * from "./apiship-pickup-point-modal"
5export * from "./types"
6export * 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

src/modules/checkout/components/shipping/index.tsx
1// ... other imports
2import { setShippingMethod, removeShippingMethodFromCart } from "@lib/data/cart"
3import { calculatePriceForShippingOption, retrieveProviders } from "@lib/data/fulfillment"
4import { useEffect, useMemo, useState } from "react"
5import {
6 ApishipPickupPointModal,
7 ApishipCourierModal,
8 ApishipChosen,
9 days
10} from "./apiship"
11
12// ...
13
14const Shipping: React.FC<ShippingProps> = ({
15 cart,
16 availableShippingMethods,
17}) => {
18
19 // ... other states
20
21 // add the following states
22 const [providersMap, setProvidersMap] = useState<Record<string, string>>({})
23 const [apishipChosen, setApishipChosen] = useState<any | null>(null)
24 const [apishipPickupPointModalOpen, setApishipPickupPointModalOpen] = useState(false)
25 const [apishipCourierModalOpen, setApishipCourierModalOpen] = useState(false)
26
27 // ...
28
29 // add the following
30 const isApishipCalculated = (option?: HttpTypes.StoreCartShippingOption | null) =>
31 option?.price_type === "calculated" && option?.provider_id === "apiship_apiship"
32
33 const isApishipToDoor = (option?: HttpTypes.StoreCartShippingOption | null) =>
34 isApishipCalculated(option) && option?.data?.deliveryType === 1
35
36 const isApishipToPoint = (option?: HttpTypes.StoreCartShippingOption | null) =>
37 isApishipCalculated(option) && option?.data?.deliveryType === 2
38
39 const activeShippingOption = useMemo(() => {
40 return _shippingMethods?.find((option) => option.id === shippingMethodId) ?? null
41 }, [_shippingMethods, shippingMethodId])
42
43 const apishipMode = useMemo<"point" | "door" | null>(() => {
44 if (!isOpen || !shippingMethodId) return null
45 if (isApishipToPoint(activeShippingOption)) return "point"
46 if (isApishipToDoor(activeShippingOption)) return "door"
47 return null
48 }, [isOpen, shippingMethodId, activeShippingOption])
49
50 useEffect(() => {
51 setApishipChosen(cart.shipping_methods?.at(-1)?.data?.apishipData ?? null)
52 }, [cart.shipping_methods])
53
54 useEffect(() => {
55 if (!isOpen) return
56
57 if (!apishipMode) {
58 setApishipPickupPointModalOpen(false)
59 setApishipCourierModalOpen(false)
60 setApishipChosen(null)
61 return
62 }
63 const chosenMode =
64 apishipChosen?.deliveryType === 2 ? "point"
65 : apishipChosen?.deliveryType === 1 ? "door"
66 : null
67
68 const hasValidChosen = !!apishipChosen && chosenMode === apishipMode
69 if (hasValidChosen) {
70 setApishipPickupPointModalOpen(false)
71 setApishipCourierModalOpen(false)
72 return
73 }
74 if (apishipMode === "point") {
75 setApishipPickupPointModalOpen(true)
76 setApishipCourierModalOpen(false)
77 } else {
78 setApishipCourierModalOpen(true)
79 setApishipPickupPointModalOpen(false)
80 }
81 }, [isOpen, apishipMode, apishipChosen])
82
83 useEffect(() => {
84 let cancelled = false
85
86 ; (async () => {
87 const response = await retrieveProviders()
88 const providers = response?.providers
89 if (cancelled) return
90
91 const map: Record<string, string> = {}
92 for (const provider of providers ?? []) map[provider.key] = provider.name
93 setProvidersMap(map)
94 })()
95
96 return () => { cancelled = true }
97 }, [])
98
99 useEffect(() => {
100 if (!isOpen) return
101 if (!apishipChosen) return
102
103 if (!apishipMode) {
104 setApishipChosen(null)
105 return
106 }
107
108 const chosenMode = apishipChosen.deliveryType === 2 ? "point" : "door"
109 if (chosenMode !== apishipMode) {
110 setApishipChosen(null)
111 }
112 }, [isOpen, apishipMode, apishipChosen])
113
114 // ... other effects
115
116 return (
117 <div className="bg-white">
118 {/* ... */}
119 {isOpen ? (
120 <>
121 <div className="grid">
122 {/* ... */}
123 <div data-testid="delivery-options-container">
124 <div className="pb-8 md:pt-0 pt-2">
125 {/* ... */}
126 <RadioGroup
127 value={shippingMethodId}
128 onChange={(v) => {
129 if (v) {
130 return handleSetShippingMethod(v, "shipping")
131 }
132 }}
133 >
134 {_shippingMethods?.map((option) => {
135 const isDisabled =
136 option.price_type === "calculated" &&
137 !isLoadingPrices &&
138 typeof calculatedPricesMap[option.id] !== "number"
139
140 return (
141 <Radio
142 key={option.id}
143 value={option.id}
144 data-testid="delivery-option-radio"
145 disabled={isDisabled}
146 className={clx(
147 "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",
148 {
149 "border-ui-border-interactive":
150 option.id === shippingMethodId,
151 "hover:shadow-brders-none cursor-not-allowed":
152 isDisabled,
153 }
154 )}
155 >
156 {/* ... */}
157 {/* ApiShip prices are shown as "from X" because final amount depends on chosen tariff so change the following span */}
158 <span className="justify-self-end text-ui-fg-base">
159 {option.price_type === "flat" ? (
160 convertToLocale({
161 amount: option.amount!,
162 currency_code: cart?.currency_code,
163 })
164 ) : calculatedPricesMap[option.id] ? (
165 option.provider_id === "apiship_apiship" ? (
166 "from " + convertToLocale({
167 amount: calculatedPricesMap[option.id],
168 currency_code: cart?.currency_code,
169 })
170 ) :
171 convertToLocale({
172 amount: calculatedPricesMap[option.id],
173 currency_code: cart?.currency_code,
174 })
175 ) : isLoadingPrices ? (
176 <Loader />
177 ) : (
178 "-"
179 )}
180 </span>
181 </Radio>
182 )
183 })}
184 </RadioGroup>
185 {/* add the following apiship components */}
186 <ApishipPickupPointModal
187 open={apishipPickupPointModalOpen}
188 onClose={async (cancel?: boolean) => {
189 setApishipPickupPointModalOpen(false)
190 if (cancel) {
191 await removeShippingMethodFromCart(cart.shipping_methods?.[0]?.id!)
192 setShippingMethodId(null)
193 }
194 }}
195 cart={cart}
196 shippingOptionId={shippingMethodId}
197 initialChosen={apishipChosen?.deliveryType === 2 ? apishipChosen : null}
198 onPriceUpdate={(id, amount) => {
199 setCalculatedPricesMap((prev) => ({ ...prev, [id]: amount }))
200 }}
201 onError={(msg) => setError(msg)}
202 onChosenChange={(chosen) => setApishipChosen(chosen)}
203 providersMap={providersMap}
204 />
205 <ApishipCourierModal
206 open={apishipCourierModalOpen}
207 onClose={async (cancel?: boolean) => {
208 setApishipCourierModalOpen(false)
209 if (cancel) {
210 await removeShippingMethodFromCart(cart.shipping_methods?.[0]?.id!)
211 setShippingMethodId(null)
212 }
213 }}
214 cart={cart}
215 shippingOptionId={shippingMethodId}
216 initialChosen={apishipChosen?.deliveryType === 1 ? apishipChosen : null}
217 onPriceUpdate={(id, amount) => {
218 setCalculatedPricesMap((prev) => ({ ...prev, [id]: amount }))
219 }}
220 onError={(msg) => setError(msg)}
221 onChosenChange={(chosen) => setApishipChosen(chosen)}
222 providersMap={providersMap}
223 />
224 {apishipChosen && (
225 <ApishipChosen
226 chosen={apishipChosen}
227 onRemove={async () => {
228 await removeShippingMethodFromCart(cart.shipping_methods?.[0]?.id!)
229 setApishipChosen(null)
230 setShippingMethodId(null)
231 }}
232 onEdit={() => {
233 console.log(cart)
234 if (apishipMode === "point") setApishipPickupPointModalOpen(true)
235 if (apishipMode === "door") setApishipCourierModalOpen(true)
236 }}
237 />
238 )}
239 </div>
240 </div>
241 </div>
242
243 {/* ... */}
244
245 <div>
246 {/* ... */}
247 <Button
248 // ... other properties
249 // change the condition for ‘disabled’
250 disabled={
251 !cart.shipping_methods?.[0] ||
252 shippingMethodId === null ||
253 (
254 (_shippingMethods?.find((o) => o.id === shippingMethodId)?.provider_id === "apiship_apiship")
255 && !apishipChosen
256 )
257 }
258 >
259 Continue to payment
260 </Button>
261 </div>
262 </>
263 ) : (
264 <div>
265 <div className="text-small-regular">
266 {cart && (cart.shipping_methods?.length ?? 0) > 0 && (
267 {/* change the following div */}
268 <div className="flex flex-col">
269 <Text className="txt-medium-plus text-ui-fg-base mb-1">
270 Method
271 </Text>
272 <div className="flex flex-col">
273 <Text className="txt-medium text-ui-fg-subtle">
274 {cart.shipping_methods!.at(-1)!.name}
275 </Text>
276 {(apishipChosen && apishipChosen.point?.address) && (
277 <Text className="txt-medium text-ui-fg-subtle">
278 {apishipChosen.point.address}
279 </Text>
280 )}
281 {apishipChosen && (
282 <Text className="txt-medium text-ui-fg-subtle">
283 {
284 [apishipChosen?.tariff?.tariffName,
285 typeof (typeof apishipChosen.tariff.deliveryCostOriginal === "number"
286 ? apishipChosen.tariff.deliveryCostOriginal
287 : apishipChosen.tariff.deliveryCost) === "number"
288 ? `RUB ${(typeof apishipChosen.tariff.deliveryCostOriginal === "number"
289 ? apishipChosen.tariff.deliveryCostOriginal
290 : apishipChosen.tariff.deliveryCost)}` : "RUB —",
291 days?.(apishipChosen?.tariff) || null,
292 ].filter(Boolean).join(" · ")
293 }
294 </Text>
295 )}
296 </div>
297 </div>
298 )}
299 </div>
300 </div>
301 )}
302 <Divider className="mt-8" />
303 </div>
304 )
305}
306
307export 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:

Terminal
1git clone https://github.com/gorgojs/medusa-plugins
2cd medusa-plugins
3git diff @gorgo/medusa-fulfillment-apiship@0.0.1...main -- examples/fulfillment-apiship/medusa-storefront
Edited May 29, 2026·Edit this page