Command Palette

Search for a command to run...

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

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

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

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

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

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

.env
NEXT_PUBLIC_YANDEX_MAPS_API_KEY=supersecret

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


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

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

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

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

src/modules/checkout/components/shipping-address/index.tsx
1<Input
2 label="Phone"
3 name="shipping_address.phone"
4 autoComplete="tel"
5 value={formData["shipping_address.phone"]}
6 onChange={handleChange}
7 required // Обязательно для ApiShip
8 data-testid="shipping-phone-input"
9/>

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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


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

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

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

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

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

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


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

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

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

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

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

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


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

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

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

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

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

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


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

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

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

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

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

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


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

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

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

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

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

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

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


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

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

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

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

1export * from «./apiship-courier-modal»
2export * from «./apiship-chosen»
3export * from «./apiship-map»
4export * from «./apiship-pickup-point-modal»
5export * from «./types»
6export * from «./utils»

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


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

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

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

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

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

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

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

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

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

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

Terminal
1git clone https://github.com/gorgojs/medusa-plugins
2cd medusa-plugins
3git diff @gorgo/medusa-fulfillment-apiship@0.0.1...main -- examples/fulfillment-apiship/medusa-storefront
Изменено 29 мая 2026 г.·Редактировать страницу