Command Palette

Search for a command to run...

Интеграция Фулфилмент ApiShip с витриной магазина (Storefront)

Чтобы интегрировать провайдер фулфилмента ApiShip в магазин Next.js, необходимо расширить этап оформления заказа, чтобы обеспечить поддержку доставки в пункт выдачи закаков.

Доставка ApiShip отличается от обычных способов доставки тем, что требует сбора дополнительных данных во время оформления заказа, таких как выбранный пункт выдачи и тариф доставки. Эти данные должны динамически загружаться из бэкэнда, представляться клиенту в удобной форме, а затем сохраняться в корзине, чтобы их можно было использовать позже провайдером фулфилмента при создании заказа в ApiShip.

Переменные окружения

Выбора пункта выдачи заказов использует JS API Яндекс Карт v3. Чтобы включить карту в storefront, необходимо задать публичный ключ API через переменные окружения.

Добавьте следующее в файл вашего storefront:

Далее, в Storefront на Next.js необходимо внести следующие изменения:

1. Поддержка данных провайдера

Для поддержки доставки ApiShip в Storefront должна быть возможность прикреплять специфичные для провайдера данные к способу доставки. Эти данные используются для хранения информации связанной с доставкой, которую невозможно определить только на основе выбранного shipping option, например, выбранный пункт выдачи и тариф.

Откройте и добавьте следующий код:

Структура проекта Medusa Storefront после обновления файла корзины

export async function setShippingMethod({
cartId,
shippingMethodId,
data,
}: {
cartId: string
shippingMethodId: string
data?: Record<string, any>
}) {
const headers = {
...(await getAuthHeaders()),
}
return sdk.store.cart
.addShippingMethod(
cartId,
{
option_id: shippingMethodId,
data // Данные ApiShip
},
{},
headers
)
.then(async () => {
const cartCacheTag = await getCacheTag("carts")
revalidateTag(cartCacheTag)
})
.catch(medusaError)
}

Расширив хелпер , чтобы он принимал опциональное поле , storefront может передавать информацию, специфичную для ApiShip, при применении способа доставки к корзине.

Эти данные позже считываются провайдером фулфилмента ApiShip при создании заказа, что позволяет ему правильно создать заказ в ApiShip, используя пункт выдачи и тариф, выбранные при оформлении заказа.

2. Расчёт стоимости доставки и получение пунктов выдачи

Чтобы отобразить варианты доставки в пункт выдачи на этапе выбора доставки, storefront должен иметь доступ к данным расчетов ApiShip и информации о пунктах выдачи. Эти данные не являются статичными и должны запрашиваться динамически на основе текущей корзины и выбранного способа доставки.

Откройте и добавьте следующие хелперы:

Структура проекта Medusa Storefront после обновления файла для фулфилмента

// ... другие хелперы
export const retrieveCalculation = async (
cartId: string,
shippingOptionId: string
) => {
const headers = {
...(await getAuthHeaders()),
}
const next = {
...(await getCacheOptions("fulfillment")),
}
const body = { cart_id: cartId }
return sdk.client
.fetch<Record<string, unknown>>(
`/store/apiship/${shippingOptionId}/calculate`,
{
method: "POST",
headers,
body,
next,
}
)
.then(({ data }) => data)
.catch((e) => {
return null
})
}
export const getPointAddresses = async (
cartId: string,
shippingOptionId: string,
pointIds: Array<number>
) => {
const headers = {
...(await getAuthHeaders()),
}
const next = {
...(await getCacheOptions("fulfillment")),
}
const body = {
cartId,
shippingOptionId,
pointIds
}
return sdk.client
.fetch<{
points: any[]
meta: any
}>(`/store/apiship/points`, {
method: "POST",
headers,
body,
next,
})
.catch((e) => {
console.error("getPointsAddresses error", e)
return null
})
}

Запрос возвращает доступные тарифы доставки вместе с идентификаторами пунктов выдачи, к которым они относятся. Запрос преобразует эти идентификаторы в полные данные о пунктах выдачи, такие как адреса и координаты, которые затем могут отображаться в интерфейсе оформления заказа.

3. Сделать номер телефона получателя обязательным

ApiShip требует номер телефона получателя для расчета стоимости доставки и создания заказа в ApiShip. По этой причине storefront должен требовать указание номера телефона в форме адреса доставки.

Откройте и отметьте поле ввода номера телефона как обязательное:

Структура проекта Medusa Storefront после обновления файла для компонента адреса доставки

<Input
label="Phone"
name="shipping_address.phone"
autoComplete="tel"
value={formData["shipping_address.phone"]}
onChange={handleChange}
required // Обязательно для ApiShip
data-testid="shipping-phone-input"
/>

Отмечая поле ввода номера телефона как , процесс оформления заказа всегда получает номер телефона получателя на раннем этапе оформления заказа, что обеспечивает наличие необходимых данных для расчёта доставки и создания заказов в ApiShip.

4. Компонент выбора пункта выдачи

Для интеграции доставки ApiShip на этапе выбора доставки storefront требуется компонент, который загружает пункты выдачи и тарифы для выбранного способа доставки, отслеживает текущее состояние выбора и сохраняет итоговый выбор в корзину.

Создайте файл со следующим содержимым:

Структура проекта Medusa Storefront после создания файла для компонента выбора пункта выдачи

"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import { Button, Text } from "@medusajs/ui"
import { HttpTypes } from "@medusajs/types"
import { setShippingMethod } from "@lib/data/cart"
import {
calculatePriceForShippingOption,
retrieveCalculation,
getPointAddresses,
} from "@lib/data/fulfillment"
import
ApishipMap,
{ ApishipPoint }
from "./apiship-map"
type ApishipTariffForPoint = {
key: string
providerKey: string
tariffProviderId?: string
tariffId?: number
tariffName?: string
deliveryCost?: number
deliveryCostOriginal?: number
daysMin?: number
daysMax?: number
calendarDaysMin?: number
calendarDaysMax?: number
workDaysMin?: number
workDaysMax?: number
}
type ApishipCalculation = {
deliveryToPoint?: Array<{
providerKey: string
tariffs?: Array<{
tariffProviderId?: string
tariffId?: number
tariffName?: string
deliveryCost?: number
deliveryCostOriginal?: number
daysMin?: number
daysMax?: number
calendarDaysMin?: number
calendarDaysMax?: number
workDaysMin?: number
workDaysMax?: number
pointIds?: number[]
}>
}>
}
type Chosen = {
pointId: string
description?: string
worktime?: Record<string, string>
photos?: string[]
pointLabel: string
tariffKey: string
tariffLabel: string
priceLabel: string
daysLabel: string
apiship: {
pointId: string
pointProviderKey?: string
tariffKey: string
tariffId?: number
tariffProviderId?: string
tariffProviderKey?: string
deliveryCost?: number
daysMin?: number
daysMax?: number
}
}
function buildTariffsByPointId(calculation?: ApishipCalculation | null) {
const map: Record<string, ApishipTariffForPoint[]> = {}
calculation?.deliveryToPoint?.forEach(({ providerKey, tariffs }) => {
tariffs?.forEach((tariff) => {
for (const pointId of tariff.pointIds ?? []) {
const key = `${providerKey}:${tariff.tariffProviderId ?? ""}:${tariff.tariffId ?? ""}`
const entry: ApishipTariffForPoint = {
key,
providerKey,
tariffProviderId: tariff.tariffProviderId,
tariffId: tariff.tariffId,
tariffName: tariff.tariffName,
deliveryCost: tariff.deliveryCost,
deliveryCostOriginal: tariff.deliveryCostOriginal,
daysMin: tariff.daysMin,
daysMax: tariff.daysMax,
calendarDaysMin: tariff.calendarDaysMin,
calendarDaysMax: tariff.calendarDaysMax,
workDaysMin: tariff.workDaysMin,
workDaysMax: tariff.workDaysMax,
}
const arr = (map[String(pointId)] ??= [])
if (!arr.some((tariff) => tariff.key === key)) arr.push(entry)
}
})
})
return map
}
const WEEK_DAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] as const
const Schedule = ({
worktime
}: {
worktime: Record<string, string>
}) => {
if (Object.keys(worktime).length > 0) {
return (
<div className="py-3">
<Text className="text-ui-fg-muted mt-1">Schedule:</Text>
{Object.keys(worktime).map((day) => {
const label = WEEK_DAYS[Number(day) - 1]
const time = worktime[day]
return (
<Text className="text-ui-fg-muted mt-1" key={day}>
{label}: {time}
</Text>
)
})}
</div>
)
} else {
return null
}
}
function extractPointIds(map: Record<string, ApishipTariffForPoint[]>) {
return Object.keys(map).map(Number).filter(Number.isFinite)
}
function money(amount: number, currencyCode: string) {
try {
return new Intl.NumberFormat("ru-RU", {
style: "currency",
currency: currencyCode.toUpperCase(),
maximumFractionDigits: 2,
}).format(amount)
} catch {
return `${amount} ${currencyCode.toUpperCase()}`
}
}
function days(t: ApishipTariffForPoint) {
const min = t.daysMin ?? 0
const max = t.daysMax ?? 0
if (!min && !max) return "—"
if (min === max) return min === 1 ? `${min} day` : `${min} days`
return `${min}${max} days`
}
function useLatestRef<T>(value: T) {
const ref = useRef(value)
useEffect(() => {
ref.current = value
}, [value])
return ref
}
function useAsyncEffect(fn: (isCancelled: () => boolean) => Promise<void>, deps: any[]) {
useEffect(() => {
let cancelled = false
const isCancelled = () => cancelled
fn(isCancelled).catch((e) => console.error(e))
return () => {
cancelled = true
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps)
}
type PanelProps = {
enabled: boolean
cart: HttpTypes.StoreCart
shippingOptionId: string | null
onReadyChange?: (ready: boolean) => void
onPriceUpdate?: (shippingOptionId: string, amount: number) => void
onError?: (message: string) => void
}
const ApishipWrapper = ({
enabled,
cart,
shippingOptionId,
onReadyChange,
onPriceUpdate,
onError,
}: PanelProps) => {
const onErrorRef = useLatestRef(onError)
const onReadyRef = useLatestRef(onReadyChange)
const onPriceRef = useLatestRef(onPriceUpdate)
const [isLoadingPoints, setIsLoadingPoints] = useState(false)
const [points, setPoints] = useState<ApishipPoint[]>([])
const [tariffsByPointId, setTariffsByPointId] = useState<Record<string, ApishipTariffForPoint[]>>(
{}
)
const [selectedPointId, setSelectedPointId] = useState<string | null>(null)
const [selectedTariffKey, setSelectedTariffKey] = useState<string | null>(null)
const [chosen, setChosen] = useState<Chosen | null>(null)
const [isPanelOpen, setIsPanelOpen] = useState(false)
useEffect(() => {
if (!enabled) {
setIsLoadingPoints(false)
setPoints([])
setTariffsByPointId({})
setSelectedPointId(null)
setSelectedTariffKey(null)
setChosen(null)
setIsPanelOpen(false)
onReadyRef.current?.(true)
} else {
onReadyRef.current?.(false)
}
}, [enabled, onReadyRef])
useEffect(() => {
onReadyRef.current?.(!enabled || !!chosen)
}, [enabled, chosen, onReadyRef])
useAsyncEffect(
async (isCancelled) => {
if (!enabled || !shippingOptionId) return
setIsLoadingPoints(true)
try {
const calculation = (await retrieveCalculation(cart.id, shippingOptionId)) as ApishipCalculation
if (isCancelled()) return
const tariffsMap = buildTariffsByPointId(calculation)
setTariffsByPointId(tariffsMap)
const pointIds = extractPointIds(tariffsMap)
if (!pointIds.length) {
setPoints([])
return
}
const pointAddresses = (await getPointAddresses(cart.id, shippingOptionId, pointIds)) as {
points: ApishipPoint[]
}
if (isCancelled()) return
setPoints(pointAddresses?.points ?? [])
} catch (e: any) {
console.error("ApishipToPointPanel load failed", e)
onErrorRef.current?.(e?.message ?? "Failed to load pickup points")
setPoints([])
setTariffsByPointId({})
} finally {
if (!isCancelled()) setIsLoadingPoints(false)
}
},
[enabled, shippingOptionId, cart.id]
)
const persistChosen = useCallback(
async (next: Chosen) => {
if (!shippingOptionId) return
await setShippingMethod({
cartId: cart.id,
shippingMethodId: shippingOptionId,
data: { apiship: next.apiship },
})
const calculation = await calculatePriceForShippingOption(shippingOptionId, cart.id)
if (calculation?.id && typeof calculation.amount === "number") {
onPriceRef.current?.(calculation.id, calculation.amount)
}
},
[cart.id, shippingOptionId, onPriceRef]
)
if (!enabled) return null
return (
<div className="mt-4 rounded-rounded border bg-ui-bg-base p-4">
<span className="font-medium txt-medium text-ui-fg-base block">Pickup Point</span>
<span className="mb-3 text-ui-fg-muted txt-medium block">Select a pickup point and tariff</span>
<ApishipMap
points={points}
tariffsByPointId={tariffsByPointId}
isLoading={isLoadingPoints}
currencyCode={cart.currency_code}
selectedPointId={selectedPointId}
selectedTariffKey={selectedTariffKey}
isPanelOpen={isPanelOpen}
onClosePanel={() => setIsPanelOpen(false)}
onSelectPoint={(pid) => {
setSelectedPointId(pid)
setSelectedTariffKey(null)
setIsPanelOpen(true)
}}
onSelectTariff={(key) => {
setSelectedTariffKey(key)
}}
onChoose={async ({ point, tariff }) => {
const cost =
typeof tariff.deliveryCostOriginal === "number" ? tariff.deliveryCostOriginal : tariff.deliveryCost
const next: Chosen = {
pointId: point.id,
description: point.description,
worktime: point.worktime,
photos: point.photos,
pointLabel: point.name || point.address || `Point #${point.id}`,
tariffKey: tariff.key,
tariffLabel: `${tariff.tariffName || "Tariff"} (${tariff.providerKey})`,
priceLabel: typeof cost === "number" ? money(cost, cart.currency_code) : "—",
daysLabel: days(tariff),
apiship: {
pointId: point.id,
pointProviderKey: point.providerKey,
tariffKey: tariff.key,
tariffId: tariff.tariffId,
tariffProviderId: tariff.tariffProviderId,
tariffProviderKey: tariff.providerKey,
deliveryCost: cost,
daysMin: tariff.daysMin,
daysMax: tariff.daysMax,
},
}
setChosen(next)
try {
await persistChosen(next)
} catch (e: any) {
onErrorRef.current?.(e?.message ?? "Failed to save the selected tariff")
}
}}
/>
{chosen && (
<div className="mt-4 rounded-rounded border p-4">
<Text className="txt-medium-plus">Pickup Point</Text>
<Text className="text-ui-fg-muted mt-1">{chosen.pointLabel}</Text>
<Text className="text-ui-fg-muted mt-1">{chosen.description}</Text>
<Schedule worktime={chosen.worktime!} />
<Text className="text-ui-fg-muted mt-1 pb-3">
{chosen.tariffLabel} · {chosen.priceLabel} · {chosen.daysLabel}
</Text>
<Button
size="small"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
setSelectedPointId(null)
setSelectedTariffKey(null)
setChosen(null)
setIsPanelOpen(false)
}}
>
Remove
</Button>
</div>
)}
</div>
)
}
export default ApishipWrapper

Компонент получает данные расчёта ApiShip, формирует список тарифов, сгруппированных по пунктам выдачи, и затем преобразует идентификаторы пунктов выдачи в полноценные объекты пунктов. Когда пункт и тариф выбраны, компонент сохраняет выбор в корзине, вызывая с необходимыми данными, чтобы провайдер фулфилмента ApiShip мог позже создать заказ, используя сохранённые данные.

5. Компонент карты с пунктами выдачи

Для выбора пункта выдачи на этапе доставки storefront требуется UI-компонент, который визуализирует доступные пункты и позволяет выбрать тариф для конкретного пункта. Этот компонент реализован как клиентский компонент на основе JS API Яндекс Карт v3 и UI-компонентов Medusa.

Создайте файл со следующим содержимым:

Структура проекта Medusa Storefront после создания файла для компонента карты пунктов выдачи

"use client"
import { useCallback, useEffect, useMemo, useRef } from "react"
import { Button, Text, clx } from "@medusajs/ui"
const DEFAULT_CENTER: [number, number] = [37.618423, 55.751244]
export type ApishipPoint = {
id: string
providerKey?: string
availableOperation?: number
name?: string
description?: string
worktime?: Record<string, string>
photos?: string[]
lat: number
lng: number
code?: string
postIndex?: string
region?: string
city?: string
address?: string
phone?: string
}
export type ApishipTariffForPoint = {
key: string
providerKey: string
tariffProviderId?: string
tariffId?: number
tariffName?: string
deliveryCost?: number
deliveryCostOriginal?: number
daysMin?: number
daysMax?: number
calendarDaysMin?: number
calendarDaysMax?: number
workDaysMin?: number
workDaysMax?: number
}
type MapProps = {
points: ApishipPoint[]
tariffsByPointId: Record<string, ApishipTariffForPoint[]>
isLoading?: boolean
currencyCode: string
selectedPointId: string | null
selectedTariffKey: string | null
isPanelOpen: boolean
onClosePanel: () => void
onSelectPoint: (id: string) => void
onSelectTariff: (tariffKey: string) => void
onChoose: (payload: { point: ApishipPoint; tariff: ApishipTariffForPoint }) => void
}
export function useLatestRef<T>(value: T) {
const ref = useRef(value)
useEffect(() => {
ref.current = value
}, [value])
return ref
}
declare global {
interface Window {
ymaps3?: any
__ymaps3_loading_promise__?: Promise<void>
}
}
function ensureYmaps3Loaded(params: { apikey: string; lang?: string }): Promise<void> {
if (typeof window === "undefined") return Promise.resolve()
if (window.__ymaps3_loading_promise__) return window.__ymaps3_loading_promise__
window.__ymaps3_loading_promise__ = new Promise<void>((resolve, reject) => {
const existing = document.querySelector<HTMLScriptElement>('script[src^="https://api-maps.yandex.ru/v3/"]')
if (existing) return resolve()
const script = document.createElement("script")
script.src = `https://api-maps.yandex.ru/v3/?apikey=${encodeURIComponent(params.apikey)}&lang=${encodeURIComponent(
params.lang ?? "ru_RU"
)}`
script.async = true
script.onload = () => resolve()
script.onerror = () => reject(new Error(`Failed to load Yandex Maps JS API v3 script: ${script.src}`))
document.head.appendChild(script)
})
return window.__ymaps3_loading_promise__
}
function money(amount: number, currencyCode: string) {
try {
return new Intl.NumberFormat("ru-RU", {
style: "currency",
currency: currencyCode.toUpperCase(),
maximumFractionDigits: 2,
}).format(amount)
} catch {
return `${amount} ${currencyCode.toUpperCase()}`
}
}
function days(t: ApishipTariffForPoint) {
const min = t.daysMin ?? 0
const max = t.daysMax ?? 0
if (!min && !max) return "—"
if (min === max) return min === 1 ? `${min} day` : `${min} days`
return `${min}${max} days`
}
const ApishipMap = ({
points,
tariffsByPointId,
isLoading,
currencyCode,
selectedPointId,
selectedTariffKey,
isPanelOpen,
onClosePanel,
onSelectPoint,
onSelectTariff,
onChoose,
}: MapProps) => {
const containerRef = useRef<HTMLDivElement | null>(null)
const mapRef = useRef<any>(null)
const markersRef = useRef<Map<string, { marker: any; el: HTMLDivElement }>>(new Map())
const initPromiseRef = useRef<Promise<void> | null>(null)
const onSelectPointRef = useLatestRef(onSelectPoint)
const center = useMemo<[number, number]>(() => {
const p = points?.[0]
return p ? [p.lng, p.lat] : DEFAULT_CENTER
}, [points])
const activePoint = useMemo(() => {
return selectedPointId ? points.find((p) => p.id === selectedPointId) ?? null : null
}, [points, selectedPointId])
const activeTariffs = useMemo(() => {
if (!selectedPointId) return []
return tariffsByPointId[selectedPointId] ?? []
}, [tariffsByPointId, selectedPointId])
const selectedTariff = useMemo(() => {
if (!selectedTariffKey) return null
return activeTariffs.find((t) => t.key === selectedTariffKey) ?? null
}, [activeTariffs, selectedTariffKey])
const clearMarkers = useCallback(() => {
const map = mapRef.current
if (!map) return
for (const { marker } of markersRef.current.values()) {
try {
map.removeChild(marker)
} catch { }
}
markersRef.current.clear()
}, [])
const destroyMap = useCallback(() => {
clearMarkers()
try {
mapRef.current?.destroy?.()
} catch { }
mapRef.current = null
initPromiseRef.current = null
}, [clearMarkers])
useEffect(() => {
if (!containerRef.current || mapRef.current) return
let cancelled = false
initPromiseRef.current = (async () => {
const apikey = process.env.NEXT_PUBLIC_YANDEX_MAPS_API_KEY
if (!apikey) throw new Error("NEXT_PUBLIC_YANDEX_MAPS_API_KEY is not set")
await ensureYmaps3Loaded({ apikey, lang: "ru_RU" })
if (cancelled) return
const ymaps3 = window.ymaps3
if (!ymaps3) return
await ymaps3.ready
if (cancelled) return
ymaps3.import.registerCdn("https://cdn.jsdelivr.net/npm/{package}", "@yandex/ymaps3-default-ui-theme@0.0")
const { YMap, YMapDefaultSchemeLayer, YMapDefaultFeaturesLayer } = ymaps3
const map = new YMap(containerRef.current!, { location: { center, zoom: 10 } })
map.addChild(new YMapDefaultSchemeLayer({}))
map.addChild(new YMapDefaultFeaturesLayer({ zIndex: 1800 }))
mapRef.current = map
})().catch((e) => console.error("Yandex map init failed", e))
return () => {
cancelled = true
destroyMap()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [destroyMap])
useEffect(() => {
; (async () => {
if (!initPromiseRef.current) return
await initPromiseRef.current
try {
mapRef.current?.setLocation?.({ center, zoom: 10 })
} catch { }
})()
}, [center])
useEffect(() => {
let cancelled = false
; (async () => {
if (!initPromiseRef.current) return
await initPromiseRef.current
if (cancelled) return
const map = mapRef.current
const ymaps3 = window.ymaps3
if (!map || !ymaps3) return
const { YMapMarker } = ymaps3
clearMarkers()
for (const p of points) {
const el = document.createElement("div")
const SIZE = 14
el.style.width = `${SIZE}px`
el.style.height = `${SIZE}px`
el.style.background = "white"
el.style.border = "2px solid rgba(0,0,0,0.25)"
el.style.borderRadius = "50% 50% 50% 0"
el.style.transform = "rotate(-45deg)"
el.style.boxShadow = "0 2px 10px rgba(0,0,0,0.18)"
el.style.cursor = "pointer"
el.style.position = "relative"
el.style.transformOrigin = "50% 50%"
const dot = document.createElement("div")
dot.style.width = "6px"
dot.style.height = "6px"
dot.style.background = "white"
dot.style.border = "2px solid rgba(0,0,0,0.25)"
dot.style.borderRadius = "9999px"
dot.style.position = "absolute"
dot.style.left = "50%"
dot.style.top = "50%"
dot.style.transform = "translate(-50%, -50%) rotate(45deg)"
dot.style.boxSizing = "border-box"
el.appendChild(dot)
el.title = p.name ?? p.address ?? `Point ${p.id}`
el.addEventListener("click", (e) => {
e.preventDefault()
e.stopPropagation()
onSelectPointRef.current(p.id)
})
const marker = new YMapMarker({ coordinates: [p.lng, p.lat] }, el)
map.addChild(marker)
markersRef.current.set(p.id, { marker, el })
}
})()
return () => {
cancelled = true
}
}, [points, clearMarkers, onSelectPointRef])
useEffect(() => {
for (const [id, { el }] of markersRef.current.entries()) {
const sel = id === selectedPointId
el.style.background = sel ? "#3b82f6" : "white"
el.style.border = sel ? "2px solid #1d4ed8" : "2px solid rgba(0,0,0,0.25)"
const dot = el.firstElementChild as HTMLElement | null
if (dot) {
dot.style.background = sel ? "white" : "white"
dot.style.border = sel ? "2px solid rgba(255,255,255,0.95)" : "2px solid rgba(0,0,0,0.25)"
}
}
}, [selectedPointId])
const showNoPoints = !isLoading && points.length === 0
return (
<div className="w-full">
<div className="relative rounded-rounded overflow-hidden border">
<div ref={containerRef} style={{ width: "100%", height: 520 }} />
{isLoading && (
<div className="absolute inset-0 bg-white/80 flex items-center justify-center">
<Text className="text-ui-fg-muted">Loading pickup points…</Text>
</div>
)}
{showNoPoints && (
<div className="absolute inset-0 bg-white/80 flex items-center justify-center">
<Text className="text-ui-fg-muted">No pickup points found for this shipping method.</Text>
</div>
)}
{activePoint && isPanelOpen && !showNoPoints && (
<div className="absolute left-3 top-3 right-3 md:right-auto md:w-[460px] rounded-rounded border bg-white/95 p-4 shadow">
<button
type="button"
aria-label="Close"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onClosePanel()
}}
className="absolute right-2 top-2 inline-flex h-8 w-8 items-center justify-center rounded-full border bg-white/70 text-ui-fg-subtle hover:bg-white"
>
</button>
<Text className="txt-medium-plus">{activePoint.name || `Point #${activePoint.id}`}</Text>
{activePoint.address && <Text className="text-ui-fg-muted mt-1">{activePoint.address}</Text>}
<div className="mt-3">
<Text className="txt-medium-plus">Tariffs</Text>
{activeTariffs.length ? (
<div className="mt-2 flex flex-col gap-2 max-h-[220px] overflow-auto p-1">
{activeTariffs.map((t) => {
const active = t.key === selectedTariffKey
const cost = typeof t.deliveryCostOriginal === "number" ? t.deliveryCostOriginal : t.deliveryCost
const price = typeof cost === "number" ? money(cost, currencyCode) : "—"
return (
<button
key={t.key}
type="button"
onClick={() => onSelectTariff(t.key)}
className={clx(
"box-border text-left rounded-rounded border px-3 py-2 hover:shadow-borders-interactive-with-active",
{ "border-ui-border-interactive bg-ui-bg-subtle": active }
)}
>
<div className="flex items-center justify-between gap-3">
<Text className="txt-small-plus">
{t.tariffName || "Tariff"} <span className="text-ui-fg-muted">({t.providerKey})</span>
</Text>
<Text className="txt-small-plus">{price}</Text>
</div>
<Text className="text-ui-fg-muted mt-1 txt-small">Delivery time: {days(t)}</Text>
</button>
)
})}
</div>
) : (
<Text className="text-ui-fg-muted mt-2">There are no tariffs for this point.</Text>
)}
</div>
<div className="mt-3 flex items-center gap-2">
<Button
size="small"
onClick={() => {
if (!activePoint || !selectedTariff) return
onChoose({ point: activePoint, tariff: selectedTariff })
}}
disabled={!selectedTariff}
>
Choose
</Button>
{!selectedTariff && (
<Text className="text-ui-fg-muted txt-small">Select a tariff to confirm your choice.</Text>
)}
</div>
</div>
)}
</div>
{!activePoint && !showNoPoints && !isLoading && (
<Text className="text-ui-fg-muted mt-2">Click on a point on the map to select a pickup point.</Text>
)}
</div>
)
}
export default ApishipMap

Компонент отображает пункты выдачи в виде маркеров на карте и открывает всплывающее окно с подробной информацией о выбранном пункте. В окне отображаются доступные тарифы для этого пункта, включая стоимость и срок доставки, а подтверждение выбора выполняется через колбэк , который передаёт данные о выбранном пункте и тарифе в корзину.

6. Завершение этапа доставки

Для поддержки доставки ApiShip при оформлении заказа на этапе доставки необходимо определить, когда выбран способ доставки ApiShip с получением в пункте выдачи, отображать интерфейс выбора пункта выдачи и управлять состоянием завершённости шага в зависимости от того, сохранён ли выбор в корзине.

Откройте и добавьте следующий код:

Структура проекта Medusa Storefront после обновления файла этапа доставки

// ... другие импорты
import { useEffect, useMemo, useState } from "react"
import ApishipWrapper from "./apiship-wrapper"
const Shipping: React.FC<ShippingProps> = ({
cart,
availableShippingMethods,
}) => {
// ... другое состояние оформления заказа
// Определяет, завершён ли выбор пункта выдачи ApiShip
const [apishipReady, setApishipReady] = useState(true)
// Вспомогательная функция для определения варианта доставки ApiShip в пункт выдачи
const isApishipToPoint = (option?: HttpTypes.StoreCartShippingOption | null) =>
option?.price_type === "calculated" &&
option?.data?.deliveryType === 2
// Текущий выбранный способ доставки
const activeShippingOption = useMemo(() => {
return _shippingMethods?.find(
(option) => option.id === shippingMethodId
) ?? null
}, [_shippingMethods, shippingMethodId])
// Доставка ApiShip в пункт выдачи активна только для определенных вариантов доставки
const isApishipActive = useMemo(() => {
return isOpen &&
!!shippingMethodId &&
isApishipToPoint(activeShippingOption)
}, [isOpen, shippingMethodId, activeShippingOption])
const handleSetShippingMethod = async (
id: string,
variant: "shipping" | "pickup"
) => {
// ... логика установки способа доставки
if (variant === "pickup") {
setShowPickupOptions(PICKUP_OPTION_ON)
setApishipReady(true) // сбросить состояние выбора пункта выдачи
} else {
setShowPickupOptions(PICKUP_OPTION_OFF)
}
}
return (
<>
{/* ... интерфейс выбора способов доставки */}
<ApishipWrapper
enabled={isApishipActive}
cart={cart}
shippingOptionId={shippingMethodId}
onReadyChange={setApishipReady}
onPriceUpdate={(id, amount) => {
setCalculatedPricesMap((prev) => ({
...prev,
[id]: amount,
}))
}}
onError={(msg) => setError(msg)}
/>
<Button
size="large"
className="mt"
onClick={handleSubmit}
isLoading={isLoading}
disabled={
!cart.shipping_methods?.[0] ||
(isApishipActive && !apishipReady) // блокировать до выбора пункта выдачи
}
data-testid="submit-delivery-option-button"
>
Continue to payment
</Button>
{isApishipActive && !apishipReady && (
<Text className="text-ui-fg-muted mt-2">
To continue, select a pickup point and click <b>Choose</b> on the desired tariff.
</Text>
)}
</>
)
}

Эта интеграция добавляет состояние , которое обновляется компонентом через . Когда активен способ доставки ApiShip и выбор ещё не завершён, шаг доставки отключает кнопку . Это гарантирует, что перед переходом к оплате в корзине всегда присутствуют необходимые данные доставки ApiShip.

Пример

Вы можете ознакомиться с изменениями, внесенными в стартовый шаблон Medusa Next.js Starter Template, в директории .

Полный код интеграции можно посмотреть в разделе comparison page, откройте вкладку и изучите различия в каталоге . Или запустите в терминале:

git clone https://github.com/gorgojs/medusa-plugins
cd medusa-plugins
git diff @gorgo/medusa-fulfillment-apiship@0.1.0...main -- examples/fulfillment-apiship/medusa-storefront