Command Palette

Search for a command to run...

Интеграция ApiShip в витрину магазина

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

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

Шаг 1: Установите переменные окружения

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

Добавьте следующее в файл вашей витрины:

NEXT_PUBLIC_YANDEX_MAPS_API_KEY=supersecret

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

Шаг 2: Сделайте номер телефона получателя обязательным

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

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

Структура проекта 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 будут доступны необходимые данные.

Шаг 3: Обработайте данные корзины

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

Откройте и измените следующее:

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

import type { ApishipHttpTypes } from "@gorgo/medusa-fulfillment-apiship/types"
export async function retrieveCart(cartId?: string, fields?: string) {
// ...
// Добавьте параметр +shipping_methods.data
fields ??= "*items, *region, *items.product, *items.variant, *items.thumbnail, *items.metadata, +items.total, *promotions, +shipping_methods.name, +shipping_methods.data"
// ...
}
export async function setShippingMethod({
// ...
data,
}: {
// ...
data?: Record<string, unknown>
}) {
// ...
return sdk.store.cart
.addShippingMethod(
cartId,
{
// ...
data // Данные ApiShip
},
// ...
)
// ...
}

Также добавьте следующую вспомогательную функцию:

export async function removeShippingMethodFromCart(shippingMethodId: string) {
const headers = {
...(await getAuthHeaders()),
}
const next = {
...(await getCacheOptions("fulfillment")),
}
return sdk.client
.fetch<ApishipHttpTypes.DeleteResponse<"shipping_method">>(
`/store/shipping-methods/${shippingMethodId}`,
{
method: "DELETE",
headers,
next,
}
)
.then(async () => {
const cartCacheTag = await getCacheTag("carts")
revalidateTag(cartCacheTag)
})
.catch(() => null)
}

Эти изменения обеспечивают три основных функции:

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

Шаг 4: Получите данные ApiShip

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

Откройте и добавьте следующие вспомогательные функции:

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

import type { ApishipHttpTypes } from "@gorgo/medusa-fulfillment-apiship/types"
type StorefrontApishipPoint = Omit<
ApishipHttpTypes.StoreApishipPoint,
"id" | "lat" | "lng" | "worktime"
> & {
id: string
lat: number
lng: number
worktime?: Record<string, string>
}
type StorefrontApishipPointListResponse = {
points: StorefrontApishipPoint[]
}
type StorefrontApishipCalculation = {
deliveryToDoor?: Array<{
providerKey: string
tariffs?: Array<ApishipHttpTypes.StoreApishipDoorTariff>
}>
deliveryToPoint?: Array<{
providerKey: string
tariffs?: Array<ApishipHttpTypes.StoreApishipPointTariff>
}>
}
// ... другие вспомогательные функции
export const retrieveCalculation = async (
cartId: string,
shippingOptionId: string
): Promise<StorefrontApishipCalculation | null> => {
const headers = {
...(await getAuthHeaders()),
}
const next = {
...(await getCacheOptions("fulfillment")),
}
const body = { cart_id: cartId }
return sdk.client
.fetch<ApishipHttpTypes.StoreApishipCalculationResponse>(
`/store/apiship/${shippingOptionId}/calculate`,
{
method: "POST",
headers,
body,
next,
}
)
.then(({ calculation }) => ({
deliveryToDoor: (calculation.deliveryToDoor ?? []).flatMap((group) => {
if (!group.providerKey) {
return []
}
return [
{
providerKey: group.providerKey,
tariffs: group.tariffs,
},
]
}),
deliveryToPoint: (calculation.deliveryToPoint ?? []).flatMap((group) => {
if (!group.providerKey) {
return []
}
return [
{
providerKey: group.providerKey,
tariffs: group.tariffs,
},
]
}),
}))
.catch((e) => {
return null
})
}
export const getPointAddresses = async (
cartId: string,
shippingOptionId: string,
pointIds: Array<number>
): Promise<StorefrontApishipPointListResponse | null> => {
const headers = {
...(await getAuthHeaders()),
}
const next = {
...(await getCacheOptions("fulfillment")),
}
if (!pointIds.length) {
return {
points: [],
}
}
const filter = `id=[${pointIds.join(",")}]`
const fields = [
"id",
"description",
"providerKey",
"name",
"address",
"photos",
"worktime",
"timetable",
"lat",
"lng",
].join(",")
const key = `apiship:points:${cartId}:${shippingOptionId}`
return sdk.client
.fetch<ApishipHttpTypes.StoreApishipPointListResponse>(
`/store/apiship/points`,
{
method: "GET",
headers,
query: {
key,
filter,
fields,
limit: 0,
},
next,
}
)
.then(({ points }) => ({
points: (points ?? []).flatMap((point) => {
if (
point.id === undefined ||
point.id === null ||
point.lat === undefined ||
point.lat === null ||
point.lng === undefined ||
point.lng === null
) {
return []
}
return [
{
...point,
id: String(point.id),
lat: point.lat,
lng: point.lng,
worktime: point.worktime as Record<string, string> | undefined,
},
]
}),
}))
.catch((e) => {
console.error("getPointsAddresses error", e)
return null
})
}
export const retrieveProviders = async (): Promise<ApishipHttpTypes.StoreApishipProviderListResponse | null> => {
const headers = {
...(await getAuthHeaders()),
}
const next = {
...(await getCacheOptions("fulfillment")),
}
return sdk.client
.fetch<ApishipHttpTypes.StoreApishipProviderListResponse>(
`/store/apiship/providers`,
{
method: "GET",
headers,
next,
}
)
.catch((e) => {
console.error("retrieveProviders error", e)
return null
})
}

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

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

Шаг 5: Определите типы

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

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

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

import type { ApishipHttpTypes } from "@gorgo/medusa-fulfillment-apiship/types"
export type ApishipCalculation = {
deliveryToDoor?: Array<{
providerKey: string
tariffs?: Array<ApishipHttpTypes.StoreApishipDoorTariff>
}>
deliveryToPoint?: Array<{
providerKey: string
tariffs?: Array<ApishipHttpTypes.StoreApishipPointTariff>
}>
}
export type ApishipTariff = {
key: string
providerKey: string
} & (
ApishipHttpTypes.StoreApishipDoorTariff |
ApishipHttpTypes.StoreApishipPointTariff
)
export type ApishipPoint = Omit<
ApishipHttpTypes.StoreApishipPoint,
"id" | "lat" | "lng" | "worktime"
> & {
id: string
lat: number
lng: number
worktime?: Record<string, string>
}
export type Chosen = {
deliveryType: number
tariff: ApishipTariff
point?: ApishipPoint
}

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

Шаг 6: Создайте утилиты

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

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

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

import {
ApishipCalculation,
ApishipTariff,
} from "../types"
import { useEffect, useRef } from "react"
export function days(tariff: ApishipTariff) {
const min = tariff.daysMin ?? 0
const max = tariff.daysMax ?? 0
if (!min && !max) return null
if (min === max) return min === 1 ? `${min} day` : `${min} days`
return `${min}${max} days`
}
export function useLatestRef<T>(value: T) {
const ref = useRef(value)
useEffect(() => {
ref.current = value
}, [value])
return ref
}
export function buildTariffKey (
providerKey: string,
tariff: Omit<ApishipTariff, "key" | "providerKey">,
idx: number
) {
return `${providerKey}:${tariff.tariffId ?? tariff.tariffProviderId ?? tariff.tariffName ?? idx}`
}
export function useLockBodyScroll(locked: boolean) {
useEffect(() => {
if (!locked) return
const body = document.body
const html = document.documentElement
const prevBodyOverflow = body.style.overflow
const prevBodyPaddingRight = body.style.paddingRight
const prevHtmlOverflow = html.style.overflow
const scrollbarWidth = window.innerWidth - html.clientWidth
if (scrollbarWidth > 0) {
body.style.paddingRight = `${scrollbarWidth}px`
}
body.style.overflow = "hidden"
html.style.overflow = "hidden"
return () => {
body.style.overflow = prevBodyOverflow
body.style.paddingRight = prevBodyPaddingRight
html.style.overflow = prevHtmlOverflow
}
}, [locked])
}
export function useAsyncEffect(fn: (isCancelled: () => boolean) => Promise<void>, deps: any[]) {
useEffect(() => {
let cancelled = false
const isCancelled = () => cancelled
fn(isCancelled).catch((e) => console.error(e))
return () => { cancelled = true }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps)
}
export function buildTariffsByPointId(calculation?: ApishipCalculation | null) {
const map: Record<string, ApishipTariff[]> = {}
calculation?.deliveryToPoint?.forEach(({ providerKey, tariffs }) => {
tariffs?.forEach((tariff) => {
for (const pointId of tariff.pointIds ?? []) {
const key = `${providerKey}:${tariff.tariffProviderId ?? ""}:${tariff.tariffId ?? ""}`
const entry: ApishipTariff = { key, providerKey, ...tariff }
const arr = (map[String(pointId)] ??= [])
if (!arr.some((t) => t.key === key)) arr.push(entry)
}
})
})
return map
}
export function extractPointIds(map: Record<string, ApishipTariff[]>) {
return Object.keys(map).map(Number).filter(Number.isFinite)
}

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

Шаг 7: Создайте карту пунктов самовывоза

Этот компонент отвечает за отображение Яндекс Карты, размещение на ней маркеров пунктов самовывоза и показ панели с подробной информацией и доступными тарифами при выборе пункта.

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

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

"use client"
import { useCallback, useEffect, useMemo, useRef } from "react"
import { Button, Heading, Text, clx, IconButton } from "@medusajs/ui"
import { Loader, XMark } from "@medusajs/icons"
import { Radio, RadioGroup } from "@headlessui/react"
import MedusaRadio from "@modules/common/components/radio"
import {
ApishipPoint,
ApishipTariff
} from "./types"
import {
days,
useLatestRef
} from "./utils"
const DEFAULT_CENTER: [number, number] = [37.618423, 55.751244]
type ApishipMapProps = {
points: ApishipPoint[]
tariffsByPointId: Record<string, ApishipTariff[]>
isLoading?: boolean
selectedPointId: string | null
selectedTariffKey: string | null
isPanelOpen: boolean
onClosePanel: () => void
onSelectPoint: (id: string) => void
onSelectTariff: (tariffKey: string) => void
onChoose: (payload: { point: ApishipPoint; tariff: ApishipTariff }) => void
chosen?: { pointId?: string; tariffKey?: string } | null
providersMap: Record<string, string>
}
const WEEK_DAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] as const
const Schedule = ({ worktime }: { worktime: Record<string, string> }) => {
if (!worktime || Object.keys(worktime).length === 0) return null
return (
<div className="flex flex-col gap-[1px]">
{Object.keys(worktime).map((day) => {
const label = WEEK_DAYS[Number(day) - 1]
const time = worktime[day]
return (
<div className="flex flex-row justify-between" key={day}>
<Text className="txt-medium text-ui-fg-subtle">
{label}
</Text>
<Text className="text-ui-fg-muted">
{time}
</Text>
</div>
)
})}
</div>
)
}
declare global {
interface Window {
ymaps3?: any
__ymaps3_loading_promise__?: Promise<void>
}
}
function ensureYmaps3Loaded(params: { apikey: string; lang?: string }): Promise<void> {
if (typeof window === "undefined") return Promise.resolve()
if (window.__ymaps3_loading_promise__) return window.__ymaps3_loading_promise__
window.__ymaps3_loading_promise__ = new Promise<void>((resolve, reject) => {
const existing = document.querySelector<HTMLScriptElement>('script[src^="https://api-maps.yandex.ru/v3/"]')
if (existing) return resolve()
const script = document.createElement("script")
script.src = `https://api-maps.yandex.ru/v3/?apikey=${encodeURIComponent(params.apikey)}&lang=${encodeURIComponent(
params.lang ?? "ru_RU"
)}`
script.async = true
script.onload = () => resolve()
script.onerror = () => reject(new Error(`Failed to load Yandex Maps JS API v3 script: ${script.src}`))
document.head.appendChild(script)
})
return window.__ymaps3_loading_promise__
}
export const ApishipMap: React.FC<ApishipMapProps> = ({
points,
tariffsByPointId,
isLoading,
selectedPointId,
selectedTariffKey,
isPanelOpen,
onClosePanel,
onSelectPoint,
onSelectTariff,
onChoose,
chosen,
providersMap
}) => {
const containerRef = useRef<HTMLDivElement | null>(null)
const mapRef = useRef<any>(null)
const markersRef = useRef<Map<string, { marker: any; el: HTMLDivElement }>>(new Map())
const initPromiseRef = useRef<Promise<void> | null>(null)
const isSameAsChosen =
!!chosen?.pointId &&
!!chosen?.tariffKey &&
chosen.pointId === selectedPointId &&
chosen.tariffKey === selectedTariffKey
const onSelectPointRef = useLatestRef(onSelectPoint)
const center = useMemo<[number, number]>(() => {
const p = points?.[0]
return p ? [p.lng, p.lat] : DEFAULT_CENTER
}, [points])
const activePoint = useMemo(() => {
return selectedPointId ? points.find((p) => p.id === selectedPointId) ?? null : null
}, [points, selectedPointId])
const activeTariffs = useMemo(() => {
if (!selectedPointId) return []
return tariffsByPointId[selectedPointId] ?? []
}, [tariffsByPointId, selectedPointId])
const selectedTariff = useMemo(() => {
if (!selectedTariffKey) return null
return activeTariffs.find((t) => t.key === selectedTariffKey) ?? null
}, [activeTariffs, selectedTariffKey])
const clearMarkers = useCallback(() => {
const map = mapRef.current
if (!map) return
for (const { marker } of markersRef.current.values()) {
try {
map.removeChild(marker)
} catch { }
}
markersRef.current.clear()
}, [])
const destroyMap = useCallback(() => {
clearMarkers()
try {
mapRef.current?.destroy?.()
} catch { }
mapRef.current = null
initPromiseRef.current = null
}, [clearMarkers])
const applySelectionStyles = useCallback(() => {
for (const [id, { el }] of markersRef.current.entries()) {
const sel = id === selectedPointId
el.style.background = sel ? "#3b82f6" : "white"
el.style.border = sel ? "2px solid #1d4ed8" : "2px solid rgba(0,0,0,0.25)"
const dot = el.firstElementChild as HTMLElement | null
if (dot) {
dot.style.border = sel
? "2px solid rgba(255,255,255,0.95)"
: "2px solid rgba(0,0,0,0.25)"
}
}
}, [selectedPointId])
useEffect(() => {
if (!containerRef.current || mapRef.current) return
let cancelled = false
initPromiseRef.current = (async () => {
const apikey = process.env.NEXT_PUBLIC_YANDEX_MAPS_API_KEY
if (!apikey) throw new Error("NEXT_PUBLIC_YANDEX_MAPS_API_KEY is not set")
await ensureYmaps3Loaded({ apikey, lang: "ru_RU" })
if (cancelled) return
const ymaps3 = window.ymaps3
if (!ymaps3) return
await ymaps3.ready
if (cancelled) return
ymaps3.import.registerCdn("https://cdn.jsdelivr.net/npm/{package}", "@yandex/ymaps3-default-ui-theme@0.0")
const { YMap, YMapDefaultSchemeLayer, YMapDefaultFeaturesLayer } = ymaps3
const map = new YMap(containerRef.current!, { location: { center, zoom: 10 } })
map.addChild(new YMapDefaultSchemeLayer({}))
map.addChild(new YMapDefaultFeaturesLayer({ zIndex: 1800 }))
mapRef.current = map
})().catch((e) => console.error("Yandex map init failed", e))
return () => {
cancelled = true
destroyMap()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [destroyMap])
useEffect(() => {
(async () => {
if (!initPromiseRef.current) return
await initPromiseRef.current
try {
mapRef.current?.setLocation?.({ center, zoom: 10 })
} catch { }
})()
}, [center])
useEffect(() => {
let cancelled = false;
(async () => {
if (!initPromiseRef.current) return
await initPromiseRef.current
if (cancelled) return
const map = mapRef.current
const ymaps3 = window.ymaps3
if (!map || !ymaps3) return
const { YMapMarker } = ymaps3
clearMarkers()
for (const p of points) {
const el = document.createElement("div")
const SIZE = 18
el.style.width = `${SIZE}px`
el.style.height = `${SIZE}px`
el.style.background = "white"
el.style.border = "2px solid rgba(0,0,0,0.25)"
el.style.borderRadius = "50% 50% 50% 0"
el.style.transform = "rotate(-45deg)"
el.style.boxShadow = "0 2px 2px rgba(0,0,0,0.18)"
el.style.cursor = "pointer"
el.style.position = "relative"
el.style.transformOrigin = "50% 50%"
const dot = document.createElement("div")
dot.style.width = "8px"
dot.style.height = "8px"
dot.style.background = "white"
dot.style.border = "2px solid rgba(0,0,0,0.25)"
dot.style.borderRadius = "9999px"
dot.style.position = "absolute"
dot.style.left = "50%"
dot.style.top = "50%"
dot.style.transform = "translate(-50%, -50%) rotate(45deg)"
dot.style.boxSizing = "border-box"
el.appendChild(dot)
el.title = p.name ?? p.address ?? `Point ${p.id}`
el.addEventListener("click", (e) => {
e.preventDefault()
e.stopPropagation()
onSelectPointRef.current(p.id)
})
const marker = new YMapMarker({ coordinates: [p.lng, p.lat] }, el)
map.addChild(marker)
markersRef.current.set(p.id, { marker, el })
}
applySelectionStyles()
})()
return () => {
cancelled = true
}
}, [points, clearMarkers, onSelectPointRef])
useEffect(() => {
applySelectionStyles()
}, [applySelectionStyles])
const showNoPoints = !isLoading && points.length === 0
return (
<div className="relative w-full h-full">
<div ref={containerRef} className="w-full h-full" />
{isLoading && (
<div className="absolute inset-0 bg-white/80 flex items-center justify-center">
<Loader />
</div>
)}
{showNoPoints && (
<div className="absolute inset-0 bg-white/80 flex items-center justify-center">
<Text className="text-ui-fg-muted">No pickup points found for this shipping method.</Text>
</div>
)}
{!isLoading && activePoint && isPanelOpen && !showNoPoints && (
<div className="absolute left-0 top-0 z-[70] w-full md:w-[470px] h-full border-r bg-white flex flex-col min-h-0">
<div className="flex flex-row justify-between p-[35px] pb-0 items-center">
<Heading
level="h2"
className="flex flex-row text-3xl-regular gap-x-2 items-baseline"
>
{`${providersMap?.[activePoint.providerKey ?? ""] ?? ""} pickup point`.trimStart()}
</Heading>
<IconButton
aria-label="Close"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onClosePanel()
}}
className="shadow-none"
>
<XMark />
</IconButton>
</div>
<div className="flex-1 min-h-0 overflow-y-auto p-[35px] pt-[18px] flex flex-col gap-[18px]">
<div className="flex flex-col">
<Text className="font-medium txt-medium text-ui-fg-base">
{activePoint.name}
</Text>
<Text className="text-ui-fg-muted txt-medium">
{activePoint.address}
</Text>
</div>
<div className="flex flex-col gap-[10px]">
<Text className="font-medium txt-medium text-ui-fg-base">Tariffs</Text>
<RadioGroup className="flex flex-col gap-[10px]">
{activeTariffs.map((t) => {
const active = t.key === selectedTariffKey
const cost =
typeof t.deliveryCostOriginal === "number"
? t.deliveryCostOriginal
: t.deliveryCost
return (
<Radio
key={t.tariffId}
value={t.tariffId}
data-testid="delivery-option-radio"
onClick={() => {
onSelectTariff(t.key)
}}
className={clx(
"flex items-center justify-between text-small-regular cursor-pointer py-2 border rounded-rounded pl-2 pr-3 hover:shadow-borders-interactive-with-active",
{ "border-ui-border-interactive": active }
)}
>
<div className="flex gap-2 w-full">
<MedusaRadio checked={active} />
<div className="flex flex-row w-full items-center justify-between">
<div className="flex flex-col">
<span className="txt-compact-small-plus">
{t.tariffName}
</span>
<span className="txt-small text-ui-fg-subtle">
Delivery time: {days(t)}
</span>
</div>
<span className="txt-small-plus text-ui-fg-subtle">
{`RUB ${cost}`}
</span>
</div>
</div>
</Radio>
)
})}
</RadioGroup>
</div>
<Button
size="large"
onClick={() => {
if (!activePoint || !selectedTariff) return
onChoose({ point: activePoint, tariff: selectedTariff })
}}
disabled={!selectedTariff || isSameAsChosen}
className="w-full mb-[16px] !overflow-visible"
>
Choose
</Button>
{activePoint.worktime && (
<div className="flex flex-col">
<Text className="font-medium txt-medium text-ui-fg-base">
Schedule
</Text>
<Schedule worktime={activePoint.worktime} />
</div>
)}
{!!activePoint.photos?.length && (
<div className="flex flex-col gap-[10px]">
<Text className="font-medium txt-medium text-ui-fg-base">Photos</Text>
<div className="flex flex-row gap-[10px] overflow-x-auto">
{activePoint.photos.map((src, index) => (
// eslint-disable-next-line @next/next/no-img-element
<img
key={index}
src={src}
alt={`Photo ${index + 1} of pickup point`}
className="w-auto h-[120px] rounded-md border object-cover"
/>
))}
</div>
</div>
)}
{activePoint.description && (
<div className="flex flex-col">
<Text className="font-medium txt-medium text-ui-fg-base">
Description
</Text>
<Text className="text-ui-fg-muted txt-medium">
{activePoint.description}
</Text>
</div>
)}
</div>
</div>
)}
</div>
)
}

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

Шаг 8: Создайте компонент резюме доставки ApiShip

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

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

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

"use client"
import { Heading, Text } from "@medusajs/ui"
import { Chosen } from "./types"
import { days } from "./utils"
type ApishipChosenProps = {
chosen: Chosen
onRemove: () => void
onEdit: () => void
}
export const ApishipChosen: React.FC<ApishipChosenProps> = ({
chosen,
onRemove,
onEdit
}) => {
const cost =
typeof chosen.tariff.deliveryCostOriginal === "number"
? chosen.tariff.deliveryCostOriginal
: chosen.tariff.deliveryCost
const isToPoint = chosen.deliveryType === 2
return (
<div className="flex flex-col gap-4 mt-[32px]">
<div className="flex flex-row justify-between">
<Heading level="h2" className="txt-xlarge">
{isToPoint ? "To the Pickup Point" : "By Courier"}
</Heading>
<div className="flex flex-row gap-[16px]">
<Text>
<button
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onRemove()
}}
className="text-ui-fg-interactive hover:text-ui-fg-interactive-hover"
>
Remove
</button>
</Text>
<Text>
<button
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onEdit()
}}
className="text-ui-fg-interactive hover:text-ui-fg-interactive-hover"
>
Edit
</button>
</Text>
</div>
</div>
{isToPoint ? (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-4 w-[60%]">
<div className="flex flex-col">
<Text>{chosen.point?.name}</Text>
{chosen.point?.address && (
<Text className="text-ui-fg-muted">{chosen.point.address}</Text>
)}
{chosen.point?.timetable && (
<Text className="text-ui-fg-muted">{chosen.point.timetable}</Text>
)}
</div>
{chosen.point?.description && (
<Text className="text-ui-fg-muted leading-none">{chosen.point.description}</Text>
)}
</div>
<Text>
{chosen.tariff.tariffName} · {`RUB ${cost ?? "—"}`}{days(chosen.tariff) ? ` · ${days(chosen.tariff)}` : ""}
</Text>
</div>
) : (
<Text>
{
[chosen?.tariff?.tariffName,
typeof cost === "number" ? `RUB ${cost}` : "RUB —",
days?.(chosen?.tariff) || null,
].filter(Boolean).join(" · ")
}
</Text>
)}
</div>
)
}

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

Шаг 9: Создайте модальное окно выбора пункта самовывоза

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

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

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

"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import { IconButton } from "@medusajs/ui"
import { XMark } from "@medusajs/icons"
import { HttpTypes } from "@medusajs/types"
import { setShippingMethod } from "@lib/data/cart"
import {
calculatePriceForShippingOption,
retrieveCalculation,
getPointAddresses,
} from "@lib/data/fulfillment"
import { ApishipMap } from "./apiship-map"
import {
ApishipCalculation,
ApishipPoint,
ApishipTariff,
Chosen
} from "./types"
import {
buildTariffsByPointId,
extractPointIds,
useAsyncEffect,
useLatestRef,
useLockBodyScroll,
} from "./utils"
type ApishipPickupPointModalProps = {
open: boolean
onClose: (cancel?: boolean) => void
cart: HttpTypes.StoreCart
shippingOptionId: string | null
initialChosen?: Chosen | null
onPriceUpdate?: (shippingOptionId: string, amount: number) => void
onError?: (message: string) => void
onChosenChange: (chosen: Chosen | null) => void
providersMap: Record<string, string>
}
export const ApishipPickupPointModal: React.FC<ApishipPickupPointModalProps> = ({
open,
onClose,
cart,
shippingOptionId,
initialChosen,
onPriceUpdate,
onError,
onChosenChange,
providersMap
}) => {
const onErrorRef = useLatestRef(onError)
const onPriceRef = useLatestRef(onPriceUpdate)
const [isLoadingPoints, setIsLoadingPoints] = useState(false)
const [points, setPoints] = useState<ApishipPoint[]>([])
const [tariffsByPointId, setTariffsByPointId] = useState<Record<string, ApishipTariff[]>>({})
const [selectedPointId, setSelectedPointId] = useState<string | null>(null)
const [selectedTariffKey, setSelectedTariffKey] = useState<string | null>(null)
const [isPanelOpen, setIsPanelOpen] = useState(false)
useLockBodyScroll(open)
useEffect(() => {
if (!open) return
if (initialChosen?.deliveryType === 2) {
setSelectedPointId(initialChosen.point?.id ?? null)
setSelectedTariffKey(initialChosen.tariff?.key ?? null)
setIsPanelOpen(true)
return
}
setSelectedPointId(null)
setSelectedTariffKey(null)
setIsPanelOpen(false)
}, [open, initialChosen, shippingOptionId])
useEffect(() => {
setSelectedPointId(null)
setSelectedTariffKey(null)
setIsPanelOpen(false)
}, [shippingOptionId])
useAsyncEffect(async (isCancelled) => {
if (!open || !shippingOptionId) return
setIsLoadingPoints(true)
try {
const calculation = await retrieveCalculation(cart.id, shippingOptionId)
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
)
if (isCancelled()) return
setPoints(pointAddresses?.points ?? [])
} catch (e: any) {
console.error(e)
onErrorRef.current?.(e?.message ?? "Failed to load pickup points")
setPoints([])
setTariffsByPointId({})
} finally {
if (!isCancelled()) setIsLoadingPoints(false)
}
}, [open, shippingOptionId, cart.id])
const persistChosen = useCallback(async (next: Chosen) => {
if (!shippingOptionId) return
await setShippingMethod({
cartId: cart.id,
shippingMethodId: shippingOptionId,
data: { apishipData: next },
})
const calculation = await calculatePriceForShippingOption(shippingOptionId, cart.id)
if (calculation?.id && typeof calculation.amount === "number") {
onPriceRef.current?.(calculation.id, calculation.amount)
}
}, [cart.id, shippingOptionId, onPriceRef])
if (!open) return null
return (
<div className="fixed inset-0 z-50">
<div
className="absolute inset-0 bg-black/50"
/>
<div
className="absolute inset-0 flex items-center justify-center p-4"
onClick={(e) => {
if (e.target === e.currentTarget) onClose(initialChosen ? false : true)
}}
>
<div className="relative">
<IconButton
aria-label="Close"
onClick={() => onClose(initialChosen ? false : true)}
className="absolute right-2 top-2 z-[60] shadow-none"
>
<XMark />
</IconButton>
<div
className="
relative
h-[820px] w-[1350px]
max-w-[calc(100vw-32px)] max-h-[calc(100vh-32px)]
overflow-hidden rounded-rounded border bg-white
"
>
<ApishipMap
points={points}
tariffsByPointId={tariffsByPointId}
isLoading={isLoadingPoints}
selectedPointId={selectedPointId}
selectedTariffKey={selectedTariffKey}
isPanelOpen={isPanelOpen}
onClosePanel={() => {
setIsPanelOpen(false)
setSelectedPointId(null)
}}
onSelectPoint={(pid) => {
setSelectedPointId(pid)
setSelectedTariffKey(null)
setIsPanelOpen(true)
}}
onSelectTariff={(key) => setSelectedTariffKey(key)}
chosen={
initialChosen?.deliveryType === 2
? { pointId: initialChosen.point?.id, tariffKey: initialChosen.tariff?.key }
: null
}
onChoose={async ({ point, tariff }) => {
const cost =
typeof tariff.deliveryCostOriginal === "number"
? tariff.deliveryCostOriginal
: tariff.deliveryCost
const chosen: Chosen = {
deliveryType: 2,
tariff,
point
}
try {
await persistChosen(chosen)
onChosenChange(chosen)
onClose()
} catch (e: any) {
onErrorRef.current?.(e?.message ?? "Failed to save the selected tariff")
}
}}
providersMap={providersMap}
/>
</div>
</div>
</div>
</div>
)
}

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

Шаг 10: Создайте модальное окно выбора тарифа курьера

Этот компонент реализует специальное модальное окно для выбора тарифов курьерской доставки. Оно открывается, когда клиент выбирает курьерскую доставку, и позволяет ему выбрать один из доступных тарифов.

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

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

Структура проекта Medusa Storefront после создания файла для модального окна курьера ApiShip

"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import { Button, clx, Heading, IconButton, Text } from "@medusajs/ui"
import MedusaRadio from "@modules/common/components/radio"
import { Loader, XMark } from "@medusajs/icons"
import { HttpTypes } from "@medusajs/types"
import { Radio, RadioGroup } from "@headlessui/react"
import { setShippingMethod } from "@lib/data/cart"
import { calculatePriceForShippingOption, retrieveCalculation } from "@lib/data/fulfillment"
import {
ApishipCalculation,
ApishipTariff,
Chosen
} from "./types"
import {
buildTariffKey,
days,
useLatestRef,
useLockBodyScroll
} from "./utils"
type ApishipCourierModalProps = {
open: boolean
onClose: (cancel?: boolean) => void
cart: HttpTypes.StoreCart
shippingOptionId: string | null
initialChosen?: Chosen | null
onPriceUpdate?: (shippingOptionId: string, amount: number) => void
onError?: (message: string) => void
onChosenChange: (chosen: Chosen | null) => void
providersMap: Record<string, string>
}
export const ApishipCourierModal: React.FC<ApishipCourierModalProps> = ({
open,
onClose,
cart,
shippingOptionId,
initialChosen,
onPriceUpdate,
onError,
onChosenChange,
providersMap,
}) => {
const onErrorRef = useLatestRef(onError)
const onPriceRef = useLatestRef(onPriceUpdate)
const [isLoading, setIsLoading] = useState(false)
const [isLoadingCalc, setIsLoadingCalc] = useState(false)
const [calculation, setCalculation] = useState<ApishipCalculation | null>(null)
const [selectedTariffKey, setSelectedTariffKey] = useState<string | null>(null)
useLockBodyScroll(open)
useEffect(() => {
if (!open) return
if (initialChosen) {
setSelectedTariffKey(initialChosen.tariff?.key ?? null)
return
}
setSelectedTariffKey(null)
}, [open, initialChosen, shippingOptionId])
useEffect(() => {
setSelectedTariffKey(null)
}, [shippingOptionId])
function useAsyncEffect(fn: (isCancelled: () => boolean) => Promise<void>, deps: any[]) {
useEffect(() => {
let cancelled = false
const isCancelled = () => cancelled
fn(isCancelled).catch((e) => console.error(e))
return () => { cancelled = true }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps)
}
useAsyncEffect(async (isCancelled) => {
if (!open || !shippingOptionId) return
setIsLoadingCalc(true)
setCalculation(null)
try {
const calculation = await retrieveCalculation(cart.id, shippingOptionId)
if (isCancelled()) return
setCalculation(calculation)
} catch (e: any) {
console.error(e)
onErrorRef.current?.(e?.message ?? "Failed to load calculation")
setCalculation(null)
} finally {
if (!isCancelled()) setIsLoadingCalc(false)
}
}, [open, shippingOptionId, cart.id])
const doorGroups = useMemo(() => {
return calculation?.deliveryToDoor ?? []
}, [calculation])
const tariffsFlat = useMemo(() => {
return doorGroups.flatMap((g) =>
(g.tariffs ?? []).map((t, idx) => {
const key = buildTariffKey(g.providerKey, t, idx)
const full: ApishipTariff = {
...t,
key,
providerKey: g.providerKey,
}
return full
})
)
}, [doorGroups])
const selectedTariff = useMemo(() => {
if (!selectedTariffKey) return null
return tariffsFlat.find((t) => t.key === selectedTariffKey) ?? null
}, [tariffsFlat, selectedTariffKey])
const persistChosen = useCallback(async () => {
if (!shippingOptionId || !selectedTariff) return
setIsLoading(true)
try {
const next: Chosen = {
deliveryType: 1,
tariff: selectedTariff
}
await setShippingMethod({
cartId: cart.id,
shippingMethodId: shippingOptionId,
data: { apishipData: next },
})
const calc = await calculatePriceForShippingOption(shippingOptionId, cart.id)
if (calc?.id && typeof calc.amount === "number") {
onPriceRef.current?.(calc.id, calc.amount)
}
onChosenChange(next)
onClose()
} catch (e: any) {
onErrorRef.current?.(e?.message ?? "Failed to save courier tariff")
} finally {
setIsLoading(false)
}
}, [cart.id, shippingOptionId, selectedTariff, onPriceRef, onChosenChange, onClose, onErrorRef])
if (!open) return null
return (
<div className="fixed inset-0 z-50">
<div
className="absolute inset-0 bg-black/50"
/>
<div
className="absolute inset-0 flex items-center justify-center p-4"
onClick={(e) => {
if (e.target === e.currentTarget) onClose(initialChosen ? false : true)
}}
>
<div className="relative w-[470px] max-w-[calc(100vw-32px)]">
<div
className="
h-[820px] w-[470px]
max-w-[calc(100vw-32px)] max-h-[calc(100vh-32px)]
overflow-hidden rounded-rounded border bg-white
flex flex-col
"
>
<div className=" flex flex-row justify-between items-center p-[35px] pb-0">
<Heading
level="h2"
className="flex flex-row text-3xl-regular gap-x-2 items-baseline"
>
By courier
</Heading>
<IconButton
aria-label="Close"
onClick={() => onClose(initialChosen ? false : true)}
className="shadow-none"
>
<XMark />
</IconButton>
</div>
<div className="flex-1 min-h-0 overflow-y-auto px-[35px] pt-[18px]">
{isLoadingCalc ? (
<div className="h-full w-full flex items-center justify-center">
<Loader />
</div>
) : doorGroups.length === 0 ? (
<Text className="text-ui-fg-muted">No courier tariffs available.</Text>
) : (
<div className="flex flex-col gap-[15px] pb-[18px]">
{doorGroups
.filter((g) => (g.tariffs?.length ?? 0) > 0)
.map((g) => (
<div key={g.providerKey} className="flex flex-col gap-[10px]">
<Text className="font-medium txt-medium text-ui-fg-base">
{providersMap[g.providerKey] ?? g.providerKey}
</Text>
<RadioGroup
value={selectedTariffKey}
onChange={(v) => setSelectedTariffKey(String(v))}
className="flex flex-col gap-[10px]"
>
{(g.tariffs ?? []).map((t, idx) => {
const k = buildTariffKey(g.providerKey, t, idx)
const checked = k === selectedTariffKey
const cost =
typeof t.deliveryCostOriginal === "number"
? t.deliveryCostOriginal
: t.deliveryCost
return (
<Radio
key={k}
value={k}
className={clx(
"flex items-center justify-between text-small-regular cursor-pointer py-2 border rounded-rounded pl-2 pr-3 hover:shadow-borders-interactive-with-active",
{ "border-ui-border-interactive": checked }
)}
>
<div className="flex gap-2 w-full">
<MedusaRadio checked={checked} />
<div className="flex flex-row w-full items-center justify-between">
<div className="flex flex-col">
<span className="txt-compact-small-plus">
{t.tariffName}
</span>
<span className="txt-small text-ui-fg-subtle">
Delivery time: {days(t as ApishipTariff)}
</span>
</div>
<span className="txt-small-plus text-ui-fg-subtle">
{typeof cost === "number" ? `RUB ${cost}` : "—"}
</span>
</div>
</div>
</Radio>
)
})}
</RadioGroup>
</div>
))}
</div>
)}
</div>
<div className="p-[35px] pt-[18px] bg-white">
<Button
size="large"
onClick={persistChosen}
isLoading={isLoading}
disabled={!selectedTariffKey || isLoadingCalc || selectedTariff?.key === initialChosen?.tariff.key}
className="w-full"
>
Choose
</Button>
</div>
</div>
</div>
</div>
</div>
)
}

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

Шаг 11: Создайте файл-коллектор

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

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

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

export * from «./apiship-courier-modal»
export * from «./apiship-chosen»
export * from «./apiship-map»
export * from «./apiship-pickup-point-modal»
export * from «./types»
export * from «./utils»

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

Шаг 12: Интегрируйте этап доставки

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

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

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

// ... другие imports
import { setShippingMethod, removeShippingMethodFromCart } from "@lib/data/cart"
import { calculatePriceForShippingOption, retrieveProviders } from "@lib/data/fulfillment"
import { useEffect, useMemo, useState } from "react"
import {
ApishipPickupPointModal,
ApishipCourierModal,
ApishipChosen,
days
} from "./apiship"
// ...
const Shipping: React.FC<ShippingProps> = ({
cart,
availableShippingMethods,
}) => {
// ... другие states
// добавить следующие states
const [providersMap, setProvidersMap] = useState<Record<string, string>>({})
const [apishipChosen, setApishipChosen] = useState<any | null>(null)
const [apishipPickupPointModalOpen, setApishipPickupPointModalOpen] = useState(false)
const [apishipCourierModalOpen, setApishipCourierModalOpen] = useState(false)
// ...
// добавить следующее
const isApishipCalculated = (option?: HttpTypes.StoreCartShippingOption | null) =>
option?.price_type === "calculated" && option?.provider_id === "apiship_apiship"
const isApishipToDoor = (option?: HttpTypes.StoreCartShippingOption | null) =>
isApishipCalculated(option) && option?.data?.deliveryType === 1
const isApishipToPoint = (option?: HttpTypes.StoreCartShippingOption | null) =>
isApishipCalculated(option) && option?.data?.deliveryType === 2
const activeShippingOption = useMemo(() => {
return _shippingMethods?.find((option) => option.id === shippingMethodId) ?? null
}, [_shippingMethods, shippingMethodId])
const apishipMode = useMemo<"point" | "door" | null>(() => {
if (!isOpen || !shippingMethodId) return null
if (isApishipToPoint(activeShippingOption)) return "point"
if (isApishipToDoor(activeShippingOption)) return "door"
return null
}, [isOpen, shippingMethodId, activeShippingOption])
useEffect(() => {
setApishipChosen(cart.shipping_methods?.at(-1)?.data?.apishipData ?? null)
}, [cart.shipping_methods])
useEffect(() => {
if (!isOpen) return
if (!apishipMode) {
setApishipPickupPointModalOpen(false)
setApishipCourierModalOpen(false)
setApishipChosen(null)
return
}
const chosenMode =
apishipChosen?.deliveryType === 2 ? "point"
: apishipChosen?.deliveryType === 1 ? "door"
: null
const hasValidChosen = !!apishipChosen && chosenMode === apishipMode
if (hasValidChosen) {
setApishipPickupPointModalOpen(false)
setApishipCourierModalOpen(false)
return
}
if (apishipMode === "point") {
setApishipPickupPointModalOpen(true)
setApishipCourierModalOpen(false)
} else {
setApishipCourierModalOpen(true)
setApishipPickupPointModalOpen(false)
}
}, [isOpen, apishipMode, apishipChosen])
useEffect(() => {
let cancelled = false
; (async () => {
const response = await retrieveProviders()
const providers = response?.providers
if (cancelled) return
const map: Record<string, string> = {}
for (const provider of providers ?? []) map[provider.key] = provider.name
setProvidersMap(map)
})()
return () => { cancelled = true }
}, [])
useEffect(() => {
if (!isOpen) return
if (!apishipChosen) return
if (!apishipMode) {
setApishipChosen(null)
return
}
const chosenMode = apishipChosen.deliveryType === 2 ? "point" : "door"
if (chosenMode !== apishipMode) {
setApishipChosen(null)
}
}, [isOpen, apishipMode, apishipChosen])
// ... другие effects
return (
<div className="bg-white">
{/* ... */}
{isOpen ? (
<>
<div className="grid">
{/* ... */}
<div data-testid="delivery-options-container">
<div className="pb-8 md:pt-0 pt-2">
{/* ... */}
<RadioGroup
value={shippingMethodId}
onChange={(v) => {
if (v) {
return handleSetShippingMethod(v, "shipping")
}
}}
>
{_shippingMethods?.map((option) => {
const isDisabled =
option.price_type === "calculated" &&
!isLoadingPrices &&
typeof calculatedPricesMap[option.id] !== "number"
return (
<Radio
key={option.id}
value={option.id}
data-testid="delivery-option-radio"
disabled={isDisabled}
className={clx(
"flex items-center justify-between text-small-regular cursor-pointer py-4 border rounded-rounded px-8 mb-2 hover:shadow-borders-interactive-with-active",
{
"border-ui-border-interactive":
option.id === shippingMethodId,
"hover:shadow-brders-none cursor-not-allowed":
isDisabled,
}
)}
>
{/* ... */}
{/* Цены ApiShip указаны как «от X», поскольку окончательная сумма зависит от выбранного тарифа, поэтому измените следующее */}
<span className="justify-self-end text-ui-fg-base">
{option.price_type === "flat" ? (
convertToLocale({
amount: option.amount!,
currency_code: cart?.currency_code,
})
) : calculatedPricesMap[option.id] ? (
option.provider_id === "apiship_apiship" ? (
"from " + convertToLocale({
amount: calculatedPricesMap[option.id],
currency_code: cart?.currency_code,
})
) :
convertToLocale({
amount: calculatedPricesMap[option.id],
currency_code: cart?.currency_code,
})
) : isLoadingPrices ? (
<Loader />
) : (
"-"
)}
</span>
</Radio>
)
})}
</RadioGroup>
{/* добавьте следующие компоненты ApiShip */}
<ApishipPickupPointModal
open={apishipPickupPointModalOpen}
onClose={async (cancel?: boolean) => {
setApishipPickupPointModalOpen(false)
if (cancel) {
await removeShippingMethodFromCart(cart.shipping_methods?.[0]?.id!)
setShippingMethodId(null)
}
}}
cart={cart}
shippingOptionId={shippingMethodId}
initialChosen={apishipChosen?.deliveryType === 2 ? apishipChosen : null}
onPriceUpdate={(id, amount) => {
setCalculatedPricesMap((prev) => ({ ...prev, [id]: amount }))
}}
onError={(msg) => setError(msg)}
onChosenChange={(chosen) => setApishipChosen(chosen)}
providersMap={providersMap}
/>
<ApishipCourierModal
open={apishipCourierModalOpen}
onClose={async (cancel?: boolean) => {
setApishipCourierModalOpen(false)
if (cancel) {
await removeShippingMethodFromCart(cart.shipping_methods?.[0]?.id!)
setShippingMethodId(null)
}
}}
cart={cart}
shippingOptionId={shippingMethodId}
initialChosen={apishipChosen?.deliveryType === 1 ? apishipChosen : null}
onPriceUpdate={(id, amount) => {
setCalculatedPricesMap((prev) => ({ ...prev, [id]: amount }))
}}
onError={(msg) => setError(msg)}
onChosenChange={(chosen) => setApishipChosen(chosen)}
providersMap={providersMap}
/>
{apishipChosen && (
<ApishipChosen
chosen={apishipChosen}
onRemove={async () => {
await removeShippingMethodFromCart(cart.shipping_methods?.[0]?.id!)
setApishipChosen(null)
setShippingMethodId(null)
}}
onEdit={() => {
console.log(cart)
if (apishipMode === "point") setApishipPickupPointModalOpen(true)
if (apishipMode === "door") setApishipCourierModalOpen(true)
}}
/>
)}
</div>
</div>
</div>
{/* ... */}
<div>
{/* ... */}
<Button
// ... другие свойства
// change the condition for ‘disabled’
disabled={
!cart.shipping_methods?.[0] ||
shippingMethodId === null ||
(
(_shippingMethods?.find((o) => o.id === shippingMethodId)?.provider_id === "apiship_apiship")
&& !apishipChosen
)
}
>
Continue to payment
</Button>
</div>
</>
) : (
<div>
<div className="text-small-regular">
{cart && (cart.shipping_methods?.length ?? 0) > 0 && (
{/* change the following div */}
<div className="flex flex-col">
<Text className="txt-medium-plus text-ui-fg-base mb-1">
Method
</Text>
<div className="flex flex-col">
<Text className="txt-medium text-ui-fg-subtle">
{cart.shipping_methods!.at(-1)!.name}
</Text>
{(apishipChosen && apishipChosen.point?.address) && (
<Text className="txt-medium text-ui-fg-subtle">
{apishipChosen.point.address}
</Text>
)}
{apishipChosen && (
<Text className="txt-medium text-ui-fg-subtle">
{
[apishipChosen?.tariff?.tariffName,
typeof (typeof apishipChosen.tariff.deliveryCostOriginal === "number"
? apishipChosen.tariff.deliveryCostOriginal
: apishipChosen.tariff.deliveryCost) === "number"
? `RUB ${(typeof apishipChosen.tariff.deliveryCostOriginal === "number"
? apishipChosen.tariff.deliveryCostOriginal
: apishipChosen.tariff.deliveryCost)}` : "RUB —",
days?.(apishipChosen?.tariff) || null,
].filter(Boolean).join(" · ")
}
</Text>
)}
</div>
</div>
)}
</div>
</div>
)}
<Divider className="mt-8" />
</div>
)
}
export default Shipping

Это изменение добавляет два отдельных модальных окна для ApiShip (выбор пункта самовывоза и выбор тарифа курьера) и небольшой блок с резюме, который отображает текущий сохранённый выбор с действиями Изменить и Удалить. Оно также обеспечивает следующее:

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

Полный пример кода интеграции в витрину

Вы можете ознакомиться с изменениями, внесенными в стартовый шаблон 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.0.1...main -- examples/fulfillment-apiship/medusa-storefront