Search for a command to run...
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:
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:
.envNEXT_PUBLIC_YANDEX_MAPS_API_KEY=supersecret
ApiShip requires the recipient’s phone number for shipping calculations and for creating an order in the ApiShip. For this reason, the storefront must enforce a phone number in the shipping address form.
Open and mark the phone input as required:
src/modules/checkout/components/shipping-address/index.tsx1<Input2 label="Phone"3 name="shipping_address.phone"4 autoComplete="tel"5 value={formData["shipping_address.phone"]}6 onChange={handleChange}7 required // Required for ApiShip8 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.
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.
src/lib/data/cart.ts1import type { ApishipHttpTypes } from "@gorgo/medusa-fulfillment-apiship/types"23export async function retrieveCart(cartId?: string, fields?: string) {4 // ...5 // add +shipping_methods.data parameter6 fields ??= "*items, *region, *items.product, *items.variant, *items.thumbnail, *items.metadata, +items.total, *promotions, +shipping_methods.name, +shipping_methods.data"7 // ...8}910export async function setShippingMethod({11 // ...12 data,13}: {14 // ...15 data?: Record<string, unknown>16}) {17 // ...18 return sdk.store.cart19 .addShippingMethod(20 cartId,21 {22 // ...23 data // ApiShip selection data24 },25 // ...26 )27 // ...28}
Also add the following function:
src/lib/data/cart.ts1export async function removeShippingMethodFromCart(shippingMethodId: string) {2 const headers = {3 ...(await getAuthHeaders()),4 }56 const next = {7 ...(await getCacheOptions("fulfillment")),8 }910 return sdk.client11 .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:
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:
src/lib/data/fulfillment.ts1import type { ApishipHttpTypes } from "@gorgo/medusa-fulfillment-apiship/types"23type StorefrontApishipPoint = Omit<4 ApishipHttpTypes.StoreApishipPoint,5 "id" | "lat" | "lng" | "worktime"6> & {7 id: string8 lat: number9 lng: number10 worktime?: Record<string, string>11}1213type StorefrontApishipPointListResponse = {14 points: StorefrontApishipPoint[]15}1617type StorefrontApishipCalculation = {18 deliveryToDoor?: Array<{19 providerKey: string20 tariffs?: Array<ApishipHttpTypes.StoreApishipDoorTariff>21 }>22 deliveryToPoint?: Array<{23 providerKey: string24 tariffs?: Array<ApishipHttpTypes.StoreApishipPointTariff>25 }>26}2728// ... other helpers2930export const retrieveCalculation = async (31 cartId: string,32 shippingOptionId: string33): Promise<StorefrontApishipCalculation | null> => {34 const headers = {35 ...(await getAuthHeaders()),3637 }3839 const next = {40 ...(await getCacheOptions("fulfillment")),41 }4243 const body = { cart_id: cartId }4445 return sdk.client46 .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 }6061 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 }7273 return [74 {75 providerKey: group.providerKey,76 tariffs: group.tariffs,77 },78 ]79 }),80 }))81 .catch((e) => {82 return null83 })84}8586export const getPointAddresses = async (87 cartId: string,88 shippingOptionId: string,89 pointIds: Array<number>90): Promise<StorefrontApishipPointListResponse | null> => {91 const headers = {92 ...(await getAuthHeaders()),93 }9495 const next = {96 ...(await getCacheOptions("fulfillment")),97 }9899 if (!pointIds.length) {100 return {101 points: [],102 }103 }104105 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}`119120 return sdk.client121 .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 === null144 ) {145 return []146 }147148 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 null162 })163}164165export const retrieveProviders = async (): Promise<ApishipHttpTypes.StoreApishipProviderListResponse | null> => {166 const headers = {167 ...(await getAuthHeaders()),168 }169170 const next = {171 ...(await getCacheOptions("fulfillment")),172 }173174 return sdk.client175 .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 null186 })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.
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:
src/modules/checkout/components/shipping/apiship/types/index.ts1import type { ApishipHttpTypes } from "@gorgo/medusa-fulfillment-apiship/types"23export type ApishipCalculation = {4 deliveryToDoor?: Array<{5 providerKey: string6 tariffs?: Array<ApishipHttpTypes.StoreApishipDoorTariff>7 }>8 deliveryToPoint?: Array<{9 providerKey: string10 tariffs?: Array<ApishipHttpTypes.StoreApishipPointTariff>11 }>12}1314export type ApishipTariff = {15 key: string16 providerKey: string17} & (18 ApishipHttpTypes.StoreApishipDoorTariff |19 ApishipHttpTypes.StoreApishipPointTariff20)2122export type ApishipPoint = Omit<23 ApishipHttpTypes.StoreApishipPoint,24 "id" | "lat" | "lng" | "worktime"25> & {26 id: string27 lat: number28 lng: number29 worktime?: Record<string, string>30}3132export type Chosen = {33 deliveryType: number34 tariff: ApishipTariff35 point?: ApishipPoint36}
represents the calculated delivery options returned by the store API, separated into and groups by provider. is the normalized payload that the storefront saves in the cart once the user picks a tariff and a pickup point.
The storefront integration uses a set of shared utilities to keep the UI consistent and to avoid duplicating logic across the components. These helpers handle delivery time formatting, stable callback references for async effects, deterministic tariff keys for selection, scroll locking for modal windows, and mapping pickup point tariffs into a structure that is easy to render.
Open and add the following helpers:
src/modules/checkout/components/shipping/apiship/utils/index.ts1import {2 ApishipCalculation,3 ApishipTariff,4} from "../types"5import { useEffect, useRef } from "react"67export function days(tariff: ApishipTariff) {8 const min = tariff.daysMin ?? 09 const max = tariff.daysMax ?? 010 if (!min && !max) return null11 if (min === max) return min === 1 ? `${min} day` : `${min} days`12 return `${min}–${max} days`13}1415export function useLatestRef<T>(value: T) {16 const ref = useRef(value)17 useEffect(() => {18 ref.current = value19 }, [value])20 return ref21}2223export function buildTariffKey (24 providerKey: string,25 tariff: Omit<ApishipTariff, "key" | "providerKey">,26 idx: number27) {28 return `${providerKey}:${tariff.tariffId ?? tariff.tariffProviderId ?? tariff.tariffName ?? idx}`29}3031export function useLockBodyScroll(locked: boolean) {32 useEffect(() => {33 if (!locked) return3435 const body = document.body36 const html = document.documentElement3738 const prevBodyOverflow = body.style.overflow39 const prevBodyPaddingRight = body.style.paddingRight40 const prevHtmlOverflow = html.style.overflow4142 const scrollbarWidth = window.innerWidth - html.clientWidth43 if (scrollbarWidth > 0) {44 body.style.paddingRight = `${scrollbarWidth}px`45 }4647 body.style.overflow = "hidden"48 html.style.overflow = "hidden"4950 return () => {51 body.style.overflow = prevBodyOverflow52 body.style.paddingRight = prevBodyPaddingRight53 html.style.overflow = prevHtmlOverflow54 }55 }, [locked])56}5758export function useAsyncEffect(fn: (isCancelled: () => boolean) => Promise<void>, deps: any[]) {59 useEffect(() => {60 let cancelled = false61 const isCancelled = () => cancelled62 fn(isCancelled).catch((e) => console.error(e))63 return () => { cancelled = true }64 // eslint-disable-next-line react-hooks/exhaustive-deps65 }, deps)66}6768export 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 map81}8283export 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.
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.
src/modules/checkout/components/shipping/apiship/apiship-map.tsx1"use client"23import { 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 ApishipTariff11} from "./types"12import {13 days,14 useLatestRef15} from "./utils"1617const DEFAULT_CENTER: [number, number] = [37.618423, 55.751244]1819type ApishipMapProps = {20 points: ApishipPoint[]21 tariffsByPointId: Record<string, ApishipTariff[]>22 isLoading?: boolean23 selectedPointId: string | null24 selectedTariffKey: string | null25 isPanelOpen: boolean26 onClosePanel: () => void27 onSelectPoint: (id: string) => void28 onSelectTariff: (tariffKey: string) => void29 onChoose: (payload: { point: ApishipPoint; tariff: ApishipTariff }) => void30 chosen?: { pointId?: string; tariffKey?: string } | null31 providersMap: Record<string, string>32}3334const WEEK_DAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] as const3536const Schedule = ({ worktime }: { worktime: Record<string, string> }) => {37 if (!worktime || Object.keys(worktime).length === 0) return null38 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}5758declare global {59 interface Window {60 ymaps3?: any61 __ymaps3_loading_promise__?: Promise<void>62 }63}6465function 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__6869 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()7273 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 = true78 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 })8283 return window.__ymaps3_loading_promise__84}8586export 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 providersMap99}) => {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)104105 const isSameAsChosen =106 !!chosen?.pointId &&107 !!chosen?.tariffKey &&108 chosen.pointId === selectedPointId &&109 chosen.tariffKey === selectedTariffKey110111 const onSelectPointRef = useLatestRef(onSelectPoint)112113 const center = useMemo<[number, number]>(() => {114 const p = points?.[0]115 return p ? [p.lng, p.lat] : DEFAULT_CENTER116 }, [points])117118 const activePoint = useMemo(() => {119 return selectedPointId ? points.find((p) => p.id === selectedPointId) ?? null : null120 }, [points, selectedPointId])121122 const activeTariffs = useMemo(() => {123 if (!selectedPointId) return []124 return tariffsByPointId[selectedPointId] ?? []125 }, [tariffsByPointId, selectedPointId])126127 const selectedTariff = useMemo(() => {128 if (!selectedTariffKey) return null129 return activeTariffs.find((t) => t.key === selectedTariffKey) ?? null130 }, [activeTariffs, selectedTariffKey])131132 const clearMarkers = useCallback(() => {133 const map = mapRef.current134 if (!map) return135 for (const { marker } of markersRef.current.values()) {136 try {137 map.removeChild(marker)138 } catch { }139 }140 markersRef.current.clear()141 }, [])142143 const destroyMap = useCallback(() => {144 clearMarkers()145 try {146 mapRef.current?.destroy?.()147 } catch { }148 mapRef.current = null149 initPromiseRef.current = null150 }, [clearMarkers])151152 const applySelectionStyles = useCallback(() => {153 for (const [id, { el }] of markersRef.current.entries()) {154 const sel = id === selectedPointId155156 el.style.background = sel ? "#3b82f6" : "white"157 el.style.border = sel ? "2px solid #1d4ed8" : "2px solid rgba(0,0,0,0.25)"158159 const dot = el.firstElementChild as HTMLElement | null160 if (dot) {161 dot.style.border = sel162 ? "2px solid rgba(255,255,255,0.95)"163 : "2px solid rgba(0,0,0,0.25)"164 }165 }166 }, [selectedPointId])167168 useEffect(() => {169 if (!containerRef.current || mapRef.current) return170 let cancelled = false171172 initPromiseRef.current = (async () => {173 const apikey = process.env.NEXT_PUBLIC_YANDEX_MAPS_API_KEY174 if (!apikey) throw new Error("NEXT_PUBLIC_YANDEX_MAPS_API_KEY is not set")175176 await ensureYmaps3Loaded({ apikey, lang: "ru_RU" })177 if (cancelled) return178179 const ymaps3 = window.ymaps3180 if (!ymaps3) return181 await ymaps3.ready182 if (cancelled) return183184 ymaps3.import.registerCdn("https://cdn.jsdelivr.net/npm/{package}", "@yandex/ymaps3-default-ui-theme@0.0")185186 const { YMap, YMapDefaultSchemeLayer, YMapDefaultFeaturesLayer } = ymaps3187188 const map = new YMap(containerRef.current!, { location: { center, zoom: 10 } })189 map.addChild(new YMapDefaultSchemeLayer({}))190 map.addChild(new YMapDefaultFeaturesLayer({ zIndex: 1800 }))191192 mapRef.current = map193 })().catch((e) => console.error("Yandex map init failed", e))194195 return () => {196 cancelled = true197 destroyMap()198 }199 // eslint-disable-next-line react-hooks/exhaustive-deps200 }, [destroyMap])201202 useEffect(() => {203 (async () => {204 if (!initPromiseRef.current) return205 await initPromiseRef.current206 try {207 mapRef.current?.setLocation?.({ center, zoom: 10 })208 } catch { }209 })()210 }, [center])211212 useEffect(() => {213 let cancelled = false;214 (async () => {215 if (!initPromiseRef.current) return216 await initPromiseRef.current217 if (cancelled) return218219 const map = mapRef.current220 const ymaps3 = window.ymaps3221 if (!map || !ymaps3) return222223 const { YMapMarker } = ymaps3224225 clearMarkers()226227 for (const p of points) {228 const el = document.createElement("div")229230 const SIZE = 18231232 el.style.width = `${SIZE}px`233 el.style.height = `${SIZE}px`234235 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%"243244 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"255256 el.appendChild(dot)257258 el.title = p.name ?? p.address ?? `Point ${p.id}`259260 el.addEventListener("click", (e) => {261 e.preventDefault()262 e.stopPropagation()263 onSelectPointRef.current(p.id)264 })265266 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 })()272273 return () => {274 cancelled = true275 }276 }, [points, clearMarkers, onSelectPointRef])277278 useEffect(() => {279 applySelectionStyles()280 }, [applySelectionStyles])281282 const showNoPoints = !isLoading && points.length === 0283284 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 <Heading301 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 <IconButton307 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>318319 <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>328329 <div className="flex flex-col gap-[10px]">330 <Text className="font-medium txt-medium text-ui-fg-base">Tariffs</Text>331332 <RadioGroup className="flex flex-col gap-[10px]">333 {activeTariffs.map((t) => {334 const active = t.key === selectedTariffKey335 const cost =336 typeof t.deliveryCostOriginal === "number"337 ? t.deliveryCostOriginal338 : t.deliveryCost339340 return (341 <Radio342 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 <Button375 size="large"376 onClick={() => {377 if (!activePoint || !selectedTariff) return378 onChoose({ point: activePoint, tariff: selectedTariff })379 }}380 disabled={!selectedTariff || isSameAsChosen}381 className="w-full mb-[16px] !overflow-visible"382 >383 Choose384 </Button>385 {activePoint.worktime && (386 <div className="flex flex-col">387 <Text className="font-medium txt-medium text-ui-fg-base">388 Schedule389 </Text>390 <Schedule worktime={activePoint.worktime} />391 </div>392 )}393394 {!!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-element400 <img401 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 )}410411 {activePoint.description && (412 <div className="flex flex-col">413 <Text className="font-medium txt-medium text-ui-fg-base">414 Description415 </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.
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.
src/modules/checkout/components/shipping/apiship/apiship-chosen.tsx1"use client"23import { Heading, Text } from "@medusajs/ui"4import { Chosen } from "./types"5import { days } from "./utils"67type ApishipChosenProps = {8 chosen: Chosen9 onRemove: () => void10 onEdit: () => void11}1213export const ApishipChosen: React.FC<ApishipChosenProps> = ({14 chosen,15 onRemove,16 onEdit17}) => {18 const cost =19 typeof chosen.tariff.deliveryCostOriginal === "number"20 ? chosen.tariff.deliveryCostOriginal21 : chosen.tariff.deliveryCost2223 const isToPoint = chosen.deliveryType === 22425 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 <button34 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 Remove42 </button>43 </Text>44 <Text>45 <button46 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 Edit54 </button>55 </Text>56 </div>57 </div>5859 {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.
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.
src/modules/checkout/components/shipping/apiship/apiship-pickup-point-modal.tsx1"use client"23import { 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 Chosen18} from "./types"19import {20 buildTariffsByPointId,21 extractPointIds,22 useAsyncEffect,23 useLatestRef,24 useLockBodyScroll,25} from "./utils"2627type ApishipPickupPointModalProps = {28 open: boolean29 onClose: (cancel?: boolean) => void30 cart: HttpTypes.StoreCart31 shippingOptionId: string | null32 initialChosen?: Chosen | null33 onPriceUpdate?: (shippingOptionId: string, amount: number) => void34 onError?: (message: string) => void35 onChosenChange: (chosen: Chosen | null) => void36 providersMap: Record<string, string>37}3839export const ApishipPickupPointModal: React.FC<ApishipPickupPointModalProps> = ({40 open,41 onClose,42 cart,43 shippingOptionId,44 initialChosen,45 onPriceUpdate,46 onError,47 onChosenChange,48 providersMap49}) => {50 const onErrorRef = useLatestRef(onError)51 const onPriceRef = useLatestRef(onPriceUpdate)5253 const [isLoadingPoints, setIsLoadingPoints] = useState(false)54 const [points, setPoints] = useState<ApishipPoint[]>([])55 const [tariffsByPointId, setTariffsByPointId] = useState<Record<string, ApishipTariff[]>>({})5657 const [selectedPointId, setSelectedPointId] = useState<string | null>(null)58 const [selectedTariffKey, setSelectedTariffKey] = useState<string | null>(null)59 const [isPanelOpen, setIsPanelOpen] = useState(false)6061 useLockBodyScroll(open)6263 useEffect(() => {64 if (!open) return6566 if (initialChosen?.deliveryType === 2) {67 setSelectedPointId(initialChosen.point?.id ?? null)68 setSelectedTariffKey(initialChosen.tariff?.key ?? null)69 setIsPanelOpen(true)70 return71 }7273 setSelectedPointId(null)74 setSelectedTariffKey(null)75 setIsPanelOpen(false)76 }, [open, initialChosen, shippingOptionId])7778 useEffect(() => {79 setSelectedPointId(null)80 setSelectedTariffKey(null)81 setIsPanelOpen(false)82 }, [shippingOptionId])8384 useAsyncEffect(async (isCancelled) => {85 if (!open || !shippingOptionId) return8687 setIsLoadingPoints(true)88 try {89 const calculation = await retrieveCalculation(cart.id, shippingOptionId)90 if (isCancelled()) return91 const tariffsMap = buildTariffsByPointId(calculation)92 setTariffsByPointId(tariffsMap)9394 const pointIds = extractPointIds(tariffsMap)95 if (!pointIds.length) {96 setPoints([])97 return98 }99100 const pointAddresses = await getPointAddresses(101 cart.id,102 shippingOptionId,103 pointIds104 )105 if (isCancelled()) return106107 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])117118 const persistChosen = useCallback(async (next: Chosen) => {119 if (!shippingOptionId) return120121 await setShippingMethod({122 cartId: cart.id,123 shippingMethodId: shippingOptionId,124 data: { apishipData: next },125 })126127 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])132133 if (!open) return null134135 return (136 <div className="fixed inset-0 z-50">137 <div138 className="absolute inset-0 bg-black/50"139 />140 <div141 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 <IconButton148 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 <div155 className="156 relative157 h-[820px] w-[1350px]158 max-w-[calc(100vw-32px)] max-h-[calc(100vh-32px)]159 overflow-hidden rounded-rounded border bg-white160 "161 >162 <ApishipMap163 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 === 2181 ? { pointId: initialChosen.point?.id, tariffKey: initialChosen.tariff?.key }182 : null183 }184 onChoose={async ({ point, tariff }) => {185 const cost =186 typeof tariff.deliveryCostOriginal === "number"187 ? tariff.deliveryCostOriginal188 : tariff.deliveryCost189190 const chosen: Chosen = {191 deliveryType: 2,192 tariff,193 point194 }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.
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.
src/modules/checkout/components/shipping/apiship/apiship-courier-modal.tsx1"use client"23import { 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 Chosen15} from "./types"16import {17 buildTariffKey,18 days,19 useLatestRef,20 useLockBodyScroll21} from "./utils"2223type ApishipCourierModalProps = {24 open: boolean25 onClose: (cancel?: boolean) => void26 cart: HttpTypes.StoreCart27 shippingOptionId: string | null28 initialChosen?: Chosen | null29 onPriceUpdate?: (shippingOptionId: string, amount: number) => void30 onError?: (message: string) => void31 onChosenChange: (chosen: Chosen | null) => void32 providersMap: Record<string, string>33}3435export 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)4849 const [isLoading, setIsLoading] = useState(false)50 const [isLoadingCalc, setIsLoadingCalc] = useState(false)5152 const [calculation, setCalculation] = useState<ApishipCalculation | null>(null)53 const [selectedTariffKey, setSelectedTariffKey] = useState<string | null>(null)5455 useLockBodyScroll(open)5657 useEffect(() => {58 if (!open) return5960 if (initialChosen) {61 setSelectedTariffKey(initialChosen.tariff?.key ?? null)62 return63 }6465 setSelectedTariffKey(null)66 }, [open, initialChosen, shippingOptionId])6768 useEffect(() => {69 setSelectedTariffKey(null)70 }, [shippingOptionId])7172 function useAsyncEffect(fn: (isCancelled: () => boolean) => Promise<void>, deps: any[]) {73 useEffect(() => {74 let cancelled = false75 const isCancelled = () => cancelled76 fn(isCancelled).catch((e) => console.error(e))77 return () => { cancelled = true }78 // eslint-disable-next-line react-hooks/exhaustive-deps79 }, deps)80 }8182 useAsyncEffect(async (isCancelled) => {83 if (!open || !shippingOptionId) return8485 setIsLoadingCalc(true)86 setCalculation(null)8788 try {89 const calculation = await retrieveCalculation(cart.id, shippingOptionId)90 if (isCancelled()) return91 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])100101 const doorGroups = useMemo(() => {102 return calculation?.deliveryToDoor ?? []103 }, [calculation])104105 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 full115 })116 )117 }, [doorGroups])118119 const selectedTariff = useMemo(() => {120 if (!selectedTariffKey) return null121 return tariffsFlat.find((t) => t.key === selectedTariffKey) ?? null122 }, [tariffsFlat, selectedTariffKey])123124 const persistChosen = useCallback(async () => {125 if (!shippingOptionId || !selectedTariff) return126127 setIsLoading(true)128 try {129 const next: Chosen = {130 deliveryType: 1,131 tariff: selectedTariff132 }133134 await setShippingMethod({135 cartId: cart.id,136 shippingMethodId: shippingOptionId,137 data: { apishipData: next },138 })139140 const calc = await calculatePriceForShippingOption(shippingOptionId, cart.id)141 if (calc?.id && typeof calc.amount === "number") {142 onPriceRef.current?.(calc.id, calc.amount)143 }144145 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])153154 if (!open) return null155156 return (157 <div className="fixed inset-0 z-50">158 <div159 className="absolute inset-0 bg-black/50"160 />161 <div162 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 <div169 className="170 h-[820px] w-[470px]171 max-w-[calc(100vw-32px)] max-h-[calc(100vh-32px)]172 overflow-hidden rounded-rounded border bg-white173 flex flex-col174 "175 >176 <div className=" flex flex-row justify-between items-center p-[35px] pb-0">177 <Heading178 level="h2"179 className="flex flex-row text-3xl-regular gap-x-2 items-baseline"180 >181 By courier182 </Heading>183 <IconButton184 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 {doorGroups201 .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 <RadioGroup208 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 === selectedTariffKey215216 const cost =217 typeof t.deliveryCostOriginal === "number"218 ? t.deliveryCostOriginal219 : t.deliveryCost220221 return (222 <Radio223 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 <Button257 size="large"258 onClick={persistChosen}259 isLoading={isLoading}260 disabled={!selectedTariffKey || isLoadingCalc || selectedTariff?.key === initialChosen?.tariff.key}261 className="w-full"262 >263 Choose264 </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.
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.
src/modules/checkout/components/shipping/apiship/index.ts1export * 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.
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.
src/modules/checkout/components/shipping/index.tsx1// ... other imports2import { 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 days10} from "./apiship"1112// ...1314const Shipping: React.FC<ShippingProps> = ({15 cart,16 availableShippingMethods,17}) => {1819 // ... other states2021 // add the following states22 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)2627 // ...2829 // add the following30 const isApishipCalculated = (option?: HttpTypes.StoreCartShippingOption | null) =>31 option?.price_type === "calculated" && option?.provider_id === "apiship_apiship"3233 const isApishipToDoor = (option?: HttpTypes.StoreCartShippingOption | null) =>34 isApishipCalculated(option) && option?.data?.deliveryType === 13536 const isApishipToPoint = (option?: HttpTypes.StoreCartShippingOption | null) =>37 isApishipCalculated(option) && option?.data?.deliveryType === 23839 const activeShippingOption = useMemo(() => {40 return _shippingMethods?.find((option) => option.id === shippingMethodId) ?? null41 }, [_shippingMethods, shippingMethodId])4243 const apishipMode = useMemo<"point" | "door" | null>(() => {44 if (!isOpen || !shippingMethodId) return null45 if (isApishipToPoint(activeShippingOption)) return "point"46 if (isApishipToDoor(activeShippingOption)) return "door"47 return null48 }, [isOpen, shippingMethodId, activeShippingOption])4950 useEffect(() => {51 setApishipChosen(cart.shipping_methods?.at(-1)?.data?.apishipData ?? null)52 }, [cart.shipping_methods])5354 useEffect(() => {55 if (!isOpen) return5657 if (!apishipMode) {58 setApishipPickupPointModalOpen(false)59 setApishipCourierModalOpen(false)60 setApishipChosen(null)61 return62 }63 const chosenMode =64 apishipChosen?.deliveryType === 2 ? "point"65 : apishipChosen?.deliveryType === 1 ? "door"66 : null6768 const hasValidChosen = !!apishipChosen && chosenMode === apishipMode69 if (hasValidChosen) {70 setApishipPickupPointModalOpen(false)71 setApishipCourierModalOpen(false)72 return73 }74 if (apishipMode === "point") {75 setApishipPickupPointModalOpen(true)76 setApishipCourierModalOpen(false)77 } else {78 setApishipCourierModalOpen(true)79 setApishipPickupPointModalOpen(false)80 }81 }, [isOpen, apishipMode, apishipChosen])8283 useEffect(() => {84 let cancelled = false8586 ; (async () => {87 const response = await retrieveProviders()88 const providers = response?.providers89 if (cancelled) return9091 const map: Record<string, string> = {}92 for (const provider of providers ?? []) map[provider.key] = provider.name93 setProvidersMap(map)94 })()9596 return () => { cancelled = true }97 }, [])9899 useEffect(() => {100 if (!isOpen) return101 if (!apishipChosen) return102103 if (!apishipMode) {104 setApishipChosen(null)105 return106 }107108 const chosenMode = apishipChosen.deliveryType === 2 ? "point" : "door"109 if (chosenMode !== apishipMode) {110 setApishipChosen(null)111 }112 }, [isOpen, apishipMode, apishipChosen])113114 // ... other effects115116 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 <RadioGroup127 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"139140 return (141 <Radio142 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 <ApishipPickupPointModal187 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 <ApishipCourierModal206 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 <ApishipChosen226 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>242243 {/* ... */}244245 <div>246 {/* ... */}247 <Button248 // ... other properties249 // 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 && !apishipChosen256 )257 }258 >259 Continue to payment260 </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 Method271 </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.deliveryCostOriginal287 : apishipChosen.tariff.deliveryCost) === "number"288 ? `RUB ${(typeof apishipChosen.tariff.deliveryCostOriginal === "number"289 ? apishipChosen.tariff.deliveryCostOriginal290 : 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}306307export default Shipping
This change adds two dedicated modal windows for ApiShip (pickup point selection and courier tariff selection) and a small summary block that shows the currently saved choice with and actions. It also ensures:
You can refer to the modifications made in the Medusa Next.js Starter Template, which are located in the directory.
The complete integration diff can be viewed in the comparison page, and explore the differences under the directory. Or run diff in the terminal:
Terminal1git clone https://github.com/gorgojs/medusa-plugins2cd medusa-plugins3git diff @gorgo/medusa-fulfillment-apiship@0.0.1...main -- examples/fulfillment-apiship/medusa-storefront