Command Palette

Search for a command to run...

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

Чтобы интегрировать плагин ApiShip в storefront (витрину магазина) Medusa DTC Starter от Gorgo, зарегистрируйте ApiShip в точках расширения доставки, которые предоставляет стартер, и добавьте компоненты, через которые покупатель выбирает доставку при оформлении заказа.

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

Собственного провайдера с расчётной доставкой в стартере нет, есть только точки расширения, к которым он подключается:

  • в сообщает оформлению заказа, когда способ доставки готов и как убрать его из корзины
  • диспетчер карточек способов доставки в
  • диспетчер строки доставки в

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

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

Карта пунктов выдачи построена на JavaScript API Яндекс Карт версии 3. Сам API загружается с серверов Яндекса в браузере. Два npm-пакета добавляют кластеризатор, который объединяет соседние пункты выдачи в одну метку, и описания типов API.

Установите их в приложение витрины, :

1pnpm add @yandex/ymaps3-clusterer@0.0.12
2pnpm add -D @yandex/ymaps3-types@1.0.21414904

Это версии, на которых работает пример витрины.


Шаг 2: Задайте переменные окружения

Карте нужен ключ JavaScript API. Создайте его в кабинете разработчика Яндекса в разделе «JavaScript API и HTTP Геокодер» и добавьте в витрины:

.env.local
NEXT_PUBLIC_YANDEX_MAPS_API_KEY=supersecret

Ключ публичный, потому что карта работает в браузере покупателя. Переменные Next.js встраивает в сборку, поэтому после смены ключа пересоберите витрину. Без ключа окно выбора пункта выдачи сообщает, что ключа нет, а курьерская доставка продолжает работать.

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


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

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

Откройте и отметьте как оба поля телефона, покупателя и другого получателя:

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

src/modules/checkout/components/checkout-contacts-sheet/index.tsx
1<Input
2 label={t("fieldPhone")}
3 name="phone"
4 autoComplete="tel"
5 value={formData.phone}
6 onChange={handleChange}
7 required
8 data-testid="shipping-phone-input"
9/>
10
11// ...
12
13{isDifferentRecipient && (
14 // ...
15 <Input
16 label={t("fieldPhone")}
17 name="recipient_phone"
18 autoComplete="tel"
19 value={formData.recipient_phone}
20 onChange={handleChange}
21 required
22 />
23)}

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


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

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

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

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

src/types/apiship.ts
83}
84
85export type ApishipStoredTariff = Omit<ApishipTariff, "pointIds">
86
87export type ApishipStoredPoint = Pick<
88 ApishipPoint,
89 "id" | "providerKey" | "name" | "address"
90>
91
92/**
93 * What the customer picked, stored on the shipping method so a reload restores it and the
94 * plugin can create the ApiShip order from it. `deliveryType` is 1 for an address and 2
95 * for a pickup point, matching the shipping option it was chosen under.
96 */
97export type ApishipSelection = {
98 deliveryType: number
99 tariff: ApishipStoredTariff
100 point?: ApishipStoredPoint
101}
102
103export type ApishipDeliveryChoice = {
104 calculation: ApishipCalculation
105 points: ApishipPoint[]
106}

Витрина объявляет эти типы сама, а не импортирует из , потому что эта точка входа указывает на исходники TypeScript, которые не разрешаются за пределами сборки самого плагина. Все поля необязательные, так как перевозчики заполняют их по-разному.

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


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

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

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

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

src/lib/data/apiship.ts
1"use server"
2
3import { sdk } from "@lib/config"
4import medusaError from "@lib/util/medusa-error"
5import type {
6 ApishipCalculation,
7 ApishipDeliveryChoice,
8 ApishipPoint,
9 ApishipProvider,
10 ApishipRawPoint,
11} from "types/apiship"
12import { revalidateTag } from "next/cache"
13import {
14 getAuthHeaders,
15 getCacheOptions,
16 getCacheTag,
17 getCartId,
18} from "./cookies"
19
20const toCarrierGroups = <T extends { providerKey?: string; tariffs?: unknown }>(
21 groups: T[] | undefined
22) =>
23 (groups ?? []).flatMap((group) =>
24 group.providerKey ? [{ ...group, providerKey: group.providerKey }] : []
25 )
26
27const toDrawablePoints = (points: ApishipRawPoint[] | undefined) =>
28 (points ?? []).flatMap((point): ApishipPoint[] => {
29 if (
30 point.id === undefined ||
31 point.id === null ||
32 typeof point.lat !== "number" ||
33 typeof point.lng !== "number"
34 ) {
35 return []
36 }
37
38 return [{ ...point, id: String(point.id), lat: point.lat, lng: point.lng }]
39 })
40
41export const retrieveApishipDeliveryChoice = async (
42 cartId: string,
43 shippingOptionId: string,
44 includePoints: boolean
45): Promise<ApishipDeliveryChoice | null> => {
46 const headers = {
47 ...(await getAuthHeaders()),
48 }
49
50 return sdk.client
51 .fetch<{ calculation: ApishipCalculation; points?: ApishipRawPoint[] }>(
52 `/store/apiship/${shippingOptionId}/calculate`,
53 {
54 method: "POST",
55 headers,
56 body: { cart_id: cartId, include_points: includePoints },
57 }
58 )
59 .then(({ calculation, points }) => ({
60 calculation: {
61 deliveryToDoor: toCarrierGroups(calculation.deliveryToDoor),
62 deliveryToPoint: toCarrierGroups(calculation.deliveryToPoint),
63 },
64 points: toDrawablePoints(points),
65 }))
66 .catch((e) => {
67 console.error("ApiShip delivery choice failed to load", e)
68 return null
69 })
70}
71
72/**

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

вызывает , который возвращает перевозчиков с названиями и логотипами. Функция передаёт , по которому плагин определяет, к какому инстансу ApiShip относится способ доставки. С именованным инстансом, например , без этого параметра роут отвечает ошибкой 404.

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


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

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

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

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

src/modules/checkout/components/apiship/utils.ts
1import type { HttpTypes } from "@medusajs/types"
2import type {
3 ApishipCalculation,
4 ApishipPoint,
5 ApishipSelection,
6 ApishipTariff,
7} from "types/apiship"
8
9export const APISHIP_PROVIDER_PREFIX = "apiship_"
10
11export const isApishipOption = (
12 option?: HttpTypes.StoreCartShippingOption | null
13) => Boolean(option?.provider_id?.startsWith(APISHIP_PROVIDER_PREFIX))
14
15/**
16 * Which half of the calculation a shipping option draws on. The seed writes
17 * `deliveryType` into the option's own data, 1 for a courier and 2 for a pickup point.
18 */
19export const getApishipDeliveryType = (
20 option?: HttpTypes.StoreCartShippingOption | null
21): 1 | 2 | null => {
22 if (!isApishipOption(option)) return null
23
24 const deliveryType = (option?.data as { deliveryType?: unknown } | null)
25 ?.deliveryType
26
27 return deliveryType === 1 || deliveryType === 2 ? deliveryType : null
28}
29
30/**
31 * ApiShip quotes every tariff twice, `deliveryCost` and `deliveryCostOriginal`. The
32 * fulfillment provider bills the cart with `deliveryCost`, so that is the one the checkout
33 * shows, and the customer pays what they picked.
34 */
35export const getTariffCost = (tariff: ApishipTariff) =>
36 typeof tariff.deliveryCost === "number" ? tariff.deliveryCost : null
37
38export const getTariffDays = (tariff: ApishipTariff) => {
39 const min = tariff.daysMin ?? 0
40 const max = tariff.daysMax ?? 0
41
42 if (!min && !max) return null
43
44 return { min: min || max, max: max || min }
45}
46
47/**
48 * Identifies a tariff across a render. ApiShip has no single id that is always filled in,
49 * so the carrier key is combined with whichever of its ids came back, and the index closes
50 * the gap for a carrier that sent neither.
51 */
52export const buildTariffKey = (
53 providerKey: string,
54 tariff: Omit<ApishipTariff, "key" | "providerKey">,
55 index: number
56) =>
57 `${providerKey}:${tariff.tariffId ?? tariff.tariffProviderId ?? tariff.tariffName ?? index}`
58
59/**
60 * Inverts the calculation into "which tariffs serve this pickup point", which is the
  • распознаёт способ доставки ApiShip по , который начинается с .
  • читает из способа доставки. Плагин записывает его туда из метода фулфилмента, который вы выбираете при создании способа доставки, для доставки до двери и для доставки в пункт выдачи.
  • возвращает , то есть стоимость, которую плагин выставляет корзине. ApiShip возвращает ещё и , его оформление заказа не использует.
  • однозначно определяет тариф между отрисовками, потому что у ApiShip нет одного идентификатора, который заполнен всегда.
  • переворачивает расчёт в тарифы каждого пункта выдачи, а группирует курьерские тарифы по перевозчикам без повторов.
  • читает сохранённый выбор из способа доставки корзины, проверяет, что в нём есть всё нужное для способа доставки, а собирает объект, который оформление заказа сохраняет в способе доставки.

Шаг 7: Вынесите форматирование дат доставки

Стартер форматирует даты доставки прямо в секции доставки. Списку тарифов и карточке способа доставки ApiShip нужны такие же даты, поэтому перенесите форматирование в хук.

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

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

src/lib/hooks/use-format-delivery-days.tsx
1"use client"
2
3import { useCallback, useEffect, useMemo, useState } from "react"
4import { useLocale, useTranslations } from "next-intl"
5import type { DeliveryDays } from "@lib/util/fulfillment"
6
7const NARROW_NO_BREAK_SPACE = "\u202F"
8const NO_BREAK_SPACE = "\u00A0"
9
10const keepTogether = (text: string) =>
11 text.replace(/\s/g, (space) =>
12 space === "\u2009" || space === NARROW_NO_BREAK_SPACE
13 ? NARROW_NO_BREAK_SPACE
14 : NO_BREAK_SPACE
15 )
16
17const breakAfterDash = (range: string) => {
18 const separator = /(\s+)([–—-])(\s+)/.exec(range)
19 if (!separator) return keepTogether(range)
20
21 const [match, before, dash, after] = separator
22 const start = range.slice(0, separator.index)
23 const end = range.slice(separator.index + match.length)
24
25 return `${keepTogether(start)}${keepTogether(before)}${dash}${after}${keepTogether(end)}`
26}
27
28export function useFormatDeliveryDays() {
29 const t = useTranslations("CheckoutPage")
30 const locale = useLocale()
31
32 const [now, setNow] = useState<Date | null>(null)
33 useEffect(() => {
34 setNow(new Date())
35 }, [])
36
37 const formatter = useMemo(
38 () =>
39 new Intl.DateTimeFormat(locale, {
40 day: "numeric",
41 month: "long",
42 }),
43 [locale]
44 )
45
46 return useCallback(
47 (days: DeliveryDays | null) => {
48 if (!days) return null
49 if (days.max === 0) return t("deliveryToday")
50 if (!now) return null
51
52 const dateIn = (daysFromNow: number) => {
53 const date = new Date(now)
54 date.setHours(0, 0, 0, 0)
55 date.setDate(date.getDate() + daysFromNow)
56 return date
57 }
58
59 const formatDate = (daysFromNow: number) =>
60 keepTogether(formatter.format(dateIn(daysFromNow)))
61
62 if (days.min !== undefined && days.max !== undefined) {
63 if (days.min === days.max) return formatDate(days.min)
64
65 if (typeof formatter.formatRange === "function") {
66 return breakAfterDash(
67 formatter.formatRange(dateIn(days.min), dateIn(days.max))
68 )
69 }
70
71 return `${formatDate(days.min)}${NO_BREAK_SPACE}– ${formatDate(days.max)}`
72 }
73
74 if (days.max !== undefined) {
75 return t("deliveryDateUntil", { date: formatDate(days.max) })
76 }
77
78 if (days.min !== undefined) {
79 return t("deliveryDateFrom", { date: formatDate(days.min) })
80 }
81
82 return null
83 },
84 [formatter, now, t]
85 )
86}

Затем откройте и замените встроенное форматирование хуком:

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

src/modules/checkout/components/checkout-shipping-section/index.tsx
1import {
2 getDeliveryDays,
3 isPickupShippingOption,
4} from "@lib/util/fulfillment"
5// ...
6import { useFormatDeliveryDays } from "@lib/hooks/use-format-delivery-days"
7
8// ...
9
10 const formatDeliveryDays = useFormatDeliveryDays()

Удалите , , , , и импорт , теперь всё это находится в хуке. Хук форматирует дату или диапазон дат в локали витрины и не даёт дате разорваться на две строки.


Шаг 8: Создайте хуки выбора

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

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

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

src/modules/checkout/components/apiship/use-apiship-selection.ts
1"use client"
2
3import type { HttpTypes } from "@medusajs/types"
4import { useShippingSelection } from "@modules/checkout/context/shipping-selection-context"
5import type { ApishipSelection } from "types/apiship"
6import { getApishipSelection } from "./utils"
7
8export function useApishipSelection(
9 cart: HttpTypes.StoreCart,
10 optionId: string | null | undefined
11): ApishipSelection | null {
12 const { getOptionData } = useShippingSelection()
13
14 if (!optionId) return null
15
16 return (
17 (getOptionData(optionId) as { apishipData?: ApishipSelection } | undefined)
18 ?.apishipData ?? getApishipSelection(cart, optionId)
19 )
20}

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

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

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

src/modules/checkout/components/apiship/use-apiship-providers.ts
1"use client"
2
3import { useEffect, useState } from "react"
4import { retrieveApishipProviders } from "@lib/data/apiship"
5import type { ApishipProvider } from "types/apiship"
6
7type ProvidersByKey = Record<string, ApishipProvider>
8
9const providersByOption = new Map<string, Promise<ProvidersByKey | null>>()
10
11const loadProviders = (shippingOptionId: string) => {
12 const cached = providersByOption.get(shippingOptionId)
13 if (cached) return cached
14
15 const loading = retrieveApishipProviders(shippingOptionId).then((list) => {
16 if (!list) {
17 providersByOption.delete(shippingOptionId)
18 return null
19 }
20
21 return Object.fromEntries(
22 list.flatMap((provider) =>
23 provider.key && provider.name ? [[provider.key, provider]] : []
24 )
25 ) as ProvidersByKey
26 })
27
28 providersByOption.set(shippingOptionId, loading)
29 return loading
30}
31
32export function useApishipProviders(
33 shippingOptionId: string | undefined,
34 enabled: boolean
35) {
36 const [providers, setProviders] = useState<ProvidersByKey>({})
37
38 useEffect(() => {
39 if (!enabled || !shippingOptionId) return
40
41 let cancelled = false
42
43 loadProviders(shippingOptionId).then((loaded) => {
44 if (!cancelled && loaded) setProviders(loaded)
45 })
46
47 return () => {
48 cancelled = true
49 }
50 }, [enabled, shippingOptionId])
51
52 return providers
53}

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


Шаг 9: Создайте список тарифов

Так будет выглядеть список тарифов, который вы создадите на этом шаге, в окне курьерской доставки:

Шаг курьерской доставки в окне доставки ApiShip со списком тарифов СДЭК

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

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

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

src/modules/checkout/components/apiship/provider-logo.tsx
1import { clx } from "@medusajs/ui"
2import Image from "next/image"
3
4type ProviderLogoProps = {
5 src?: string
6 className?: string
7}
8
9export default function ProviderLogo({ src, className }: ProviderLogoProps) {
10 if (!src) return null
11
12 return (
13 <Image
14 src={src}
15 alt=""
16 width={24}
17 height={24}
18 className={clx(
19 "h-6 w-6 shrink-0 rounded-full shadow-borders-base",
20 className
21 )}
22 />
23 )
24}

отображает логотип перевозчика, который плагин возвращает как SVG в виде data URI в поле . Его используют список тарифов, панель пункта выдачи и строка доставки.

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

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

src/modules/checkout/components/apiship/tariff-list.tsx
1"use client"
2
3import { convertToLocale } from "@lib/util/money"
4import { useFormatDeliveryDays } from "@lib/hooks/use-format-delivery-days"
5import { useLocaleDirection } from "@lib/hooks/use-locale-direction"
6import { RadioGroup, Text } from "@medusajs/ui"
7import { useLocale } from "next-intl"
8import ProviderLogo from "./provider-logo"
9import type { ApishipProvider, ApishipTariff } from "types/apiship"
10import { getTariffCost, getTariffDays } from "./utils"
11
12type TariffGroup = {
13 providerKey: string
14 tariffs: ApishipTariff[]
15}
16
17type TariffListProps = {
18 groups: TariffGroup[]
19 currencyCode: string
20 providers: Record<string, ApishipProvider>
21 selectedKey: string | null
22 onSelect: (key: string) => void
23 /** Carrier headings only help when more than one carrier is on offer. */
24 showGroupNames?: boolean
25}
26
27/**
28 * The tariffs of one shipping option, grouped by carrier. Both the courier modal and the
29 * map's point panel show the same list, so the two stay in step.
30 */
31export default function TariffList({
32 groups,
33 currencyCode,
34 providers,
35 selectedKey,
36 onSelect,
37 showGroupNames = true,
38}: TariffListProps) {
39 const dir = useLocaleDirection()
40 const locale = useLocale()
41 const formatDeliveryDays = useFormatDeliveryDays()
42
43 return (
44 <RadioGroup
45 dir={dir}
46 value={selectedKey ?? undefined}
47 onValueChange={onSelect}
48 className="flex flex-col gap-[10px]"
49 >
50 {groups.map((group) => (
51 <div key={group.providerKey} className="flex flex-col gap-[10px]">
52 {showGroupNames && (
53 <div className="flex items-center gap-x-2">
54 <ProviderLogo src={providers[group.providerKey]?.icon} />
55 <Text className="txt-compact-small-plus text-ui-fg-subtle">
56 {providers[group.providerKey]?.name ?? group.providerKey}
57 </Text>
58 </div>
59 )}
60
61 {group.tariffs.map((tariff) => {
62 const cost = getTariffCost(tariff)
63 const days = formatDeliveryDays(getTariffDays(tariff))
64
65 return (
66 <RadioGroup.ChoiceBox
67 key={tariff.key}
68 value={tariff.key}
69 label={tariff.tariffName ?? group.providerKey}
70 description={[
71 days,
72 cost === null
73 ? null
74 : convertToLocale({
75 amount: cost,
76 currency_code: currencyCode,
77 locale,
78 }),
79 ]
80 .filter(Boolean)
81 .join(" · ")}
82 data-testid="apiship-tariff-radio"
83 />
84 )
85 })}
86 </div>
87 ))}
88 </RadioGroup>
89 )
90}

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


Шаг 10: Создайте карту пунктов выдачи

Так будет выглядеть карта пунктов выдачи, которую вы создадите на этом шаге, в окне доставки:

Карта пунктов выдачи ApiShip с метками перевозчиков и кластерами в Москве

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

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

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

src/modules/checkout/components/apiship/pickup-point-map.tsx
27const Z_INDEX_SELECTED = 1
28
29/**
30 * Yandex Maps takes a full locale and rejects a bare language tag with a 400, so the
31 * storefront's own locale is mapped onto the four the map supports.
32 */
33const YANDEX_LANGS: Record<string, string> = {
34 en: "en_US",
35 ru: "ru_RU",
36 tr: "tr_TR",
37 uk: "uk_UA",
38}
39
40const toYandexLang = (locale: string) =>
41 YANDEX_LANGS[locale.split(/[-_]/)[0].toLowerCase()] ?? "en_US"
42
43function loadYmaps3Script(apiKey: string, lang: string) {
44 if (typeof ymaps3 !== "undefined") return Promise.resolve()
45
46 return new Promise<void>((resolve, reject) => {
47 const script = document.createElement("script")
48 script.src = `https://api-maps.yandex.ru/v3/?apikey=${encodeURIComponent(
49 apiKey
50 )}&lang=${encodeURIComponent(lang)}`
51 script.async = true
52 script.onload = () => resolve()
53 script.onerror = () => {
54 script.remove()
55 reject(new Error("Failed to load the Yandex Maps script"))
56 }
57 document.head.appendChild(script)
58 })
59}
60
61async function loadMapComponents(apiKey: string, lang: string) {
62 await loadYmaps3Script(apiKey, lang)
63 await ymaps3.ready
64
65 const [{ reactify }, clusterer] = await Promise.all([
66 ymaps3.import("@yandex/ymaps3-reactify"),
67 import("@yandex/ymaps3-clusterer"),
68 ])
69
70 const bound = reactify.bindTo(React, ReactDOM)
71 const { YMap, YMapDefaultSchemeLayer, YMapDefaultFeaturesLayer, YMapMarker } =
72 bound.module(ymaps3)
73 const { YMapClusterer } = bound.module(clusterer)
74
75 return {
76 YMap,
77 YMapDefaultSchemeLayer,
78 YMapDefaultFeaturesLayer,
79 YMapMarker,
80 YMapClusterer,
81 }
82}
83
84type MapComponents = Awaited<ReturnType<typeof loadMapComponents>>
85
86let mapComponentsLoading: Promise<MapComponents> | null = null
87
88const getMapComponents = (apiKey: string, lang: string) => {
89 mapComponentsLoading ??= loadMapComponents(apiKey, lang).catch((e) => {
90 mapComponentsLoading = null
91 throw e
92 })
93 return mapComponentsLoading
94}
95
96const locationForPoints = (
97 points: ApishipPoint[],

Компонент один раз загружает скрипт Яндекс Карт с ключом из шага 2 и локалью витрины. Яндекс Карты не принимают язык без региона, поэтому переводит локаль в один из четырёх языков, которые поддерживает карта, а для остальных выбирает английский. React-компоненты карты берутся из собственного модуля API , а кластеризатор из пакета, установленного на шаге 1.

объединяет метки, которые на экране стоят ближе 60 пикселей друг к другу, пересчитывает кластеры один раз на каждый уровень масштаба и возвращает только видимые. Карта подстраивает вид под все пункты или открывается на пункте, который покупатель выбрал раньше. Если ключа нет, карта не загрузилась или адрес не обслуживает ни один пункт выдачи, компонент показывает сообщение.


Шаг 11: Создайте окно доставки

Так будет выглядеть окно доставки, которое вы создадите на этом шаге, когда на карте выбран пункт выдачи:

Окно доставки ApiShip с выбранным пунктом выдачи, его тарифами и картой

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

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

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

src/modules/common/icons/clock.tsx
1import type React from "react"
2
3import type { IconProps } from "types/icon"
4
5const Clock: React.FC<IconProps> = ({
6 size = "20",
7 color = "currentColor",
8 ...attributes
9}) => {
10 return (
11 <svg
12 width={size}
13 height={size}
14 viewBox="0 0 20 20"
15 fill="none"
16 xmlns="http://www.w3.org/2000/svg"
17 {...attributes}
18 >
19 <path
20 d="M10.0003 17.5C14.1425 17.5 17.5003 14.1421 17.5003 10C17.5003 5.85786 14.1425 2.5 10.0003 2.5C5.85819 2.5 2.50033 5.85786 2.50033 10C2.50033 14.1421 5.85819 17.5 10.0003 17.5Z"
21 stroke={color}
22 strokeWidth="1.5"
23 strokeLinecap="round"
24 strokeLinejoin="round"
25 />
26 <path
27 d="M10 5.83337V10L12.5 11.6667"
28 stroke={color}
29 strokeWidth="1.5"
30 strokeLinecap="round"
31 strokeLinejoin="round"
32 />
33 </svg>
34 )
35}
36
37export default Clock

Рядом с этой иконкой панель пункта выдачи показывает часы работы.

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

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

src/modules/checkout/components/apiship/delivery-modal.tsx
44}
45
46/**
47 * ApiShip needs an address before it can quote anything, so the flow is two steps in one
48 * sheet. The first is the checkout's own address step, and only then does the second show
49 * what the carriers offer for that address, a tariff list for courier delivery and a map
50 * of pickup points for the rest.
51 */
52export default function ApishipDeliveryModal({
53 open,
54 onClose,
55 cart,
56 addresses,
57 option,
58 providers,
59}: ApishipDeliveryModalProps) {
60 const t = useTranslations("Apiship")
61 const tCheckout = useTranslations("CheckoutPage")
62 const locale = useLocale()
63 const { setOptionData } = useShippingSelection()
64
65 const toPoint = getApishipDeliveryType(option) === 2
66
67 const [step, setStep] = useState<"address" | "choice">("address")
68 const [calculation, setCalculation] = useState<ApishipCalculation | null>(null)
69 const [points, setPoints] = useState<ApishipPoint[]>([])
70 const [tariffsByPointId, setTariffsByPointId] = useState<
71 Record<string, ApishipTariff[]>
72 >({})
73 const [isLoading, setIsLoading] = useState(false)
74 const [isSaving, setIsSaving] = useState(false)
75 const [loadFailed, setLoadFailed] = useState(false)
76 const [error, setError] = useState<string | null>(null)
77 const [selectedPointId, setSelectedPointId] = useState<string | null>(null)
78 const [selectedTariffKey, setSelectedTariffKey] = useState<string | null>(null)
79 const [loadedKey, setLoadedKey] = useState<string | null>(null)
80
81 const savedSelection = useApishipSelection(cart, option.id)
82 const savedPointId = savedSelection?.point?.id ?? null
83 const savedTariffKey = savedSelection?.tariff.key ?? null
84
85 const hasAddress = Boolean(cart.shipping_address?.address_1)
86 const addressKey = [
87 cart.shipping_address?.country_code,
88 cart.shipping_address?.province,
89 cart.shipping_address?.city,
90 cart.shipping_address?.postal_code,
91 cart.shipping_address?.address_1,
92 cart.shipping_address?.address_2,
93 ].join("|")
94 const itemsKey = (cart.items ?? [])
95 .map((item) => `${item.variant_id}:${item.quantity}`)
96 .join(",")
97 const requestKey = [option.id, addressKey, itemsKey].join("#")
98
99 useEffect(() => {
100 if (!open) return
101 setError(null)
102 setStep(hasAddress ? "choice" : "address")
103 }, [open, hasAddress])
104
105 useEffect(() => {
106 if (!open) return
107 setSelectedPointId(savedPointId)
108 setSelectedTariffKey(savedTariffKey)
109 }, [open, savedPointId, savedTariffKey])
110
111 useEffect(() => {
112 if (!open || step !== "choice" || loadedKey === requestKey) return
113
114 let cancelled = false
115 setIsLoading(true)
116 setLoadFailed(false)
117
118 void (async () => {
119 const result = await retrieveApishipDeliveryChoice(
120 cart.id,
121 option.id,
122 toPoint
123 )
124 if (cancelled) return
125
126 setCalculation(result?.calculation ?? null)
127 setTariffsByPointId(
128 result && toPoint ? buildTariffsByPointId(result.calculation) : {}
129 )
130 setPoints(result?.points ?? [])
131 setLoadFailed(!result)
132 setLoadedKey(result ? requestKey : null)
133 setIsLoading(false)
134 })()
135
136 return () => {
137 cancelled = true
138 }
139 }, [open, step, cart.id, option.id, toPoint, requestKey, loadedKey])
140
141 const weekdayFormatter = useMemo(

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

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


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

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

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

src/modules/checkout/components/apiship/index.ts
1export { default as ApishipDeliveryModal } from "./delivery-modal"
2export { default as ApishipProviderLogo } from "./provider-logo"
3export { useApishipProviders } from "./use-apiship-providers"
4export { useApishipSelection } from "./use-apiship-selection"
5export type {
6 ApishipPoint,
7 ApishipSelection,
8 ApishipTariff,
9} from "types/apiship"
10export {
11 APISHIP_PROVIDER_PREFIX,
12 getApishipDeliveryType,
13 getApishipSelection,
14 getTariffCost,
15 getTariffDays,
16 isApishipOption,
17} from "./utils"

Карточка и строка из следующих шагов импортируют всё нужное из ApiShip через этот файл.


Шаг 13: Зарегистрируйте способы доставки ApiShip

Дескриптор сообщает оформлению заказа в стартере, как обращаться со способом доставки ApiShip.

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

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

src/modules/checkout/components/apiship/descriptor.ts
1import type { ShippingOptionDescriptor } from "@lib/constants"
2import { removeApishipShippingMethod } from "@lib/data/apiship"
3import {
4 getApishipSelection,
5 isApishipOption,
6 isApishipSelectionComplete,
7} from "./utils"
8
9export const apishipShippingOptionDescriptor: ShippingOptionDescriptor = {
10 test: isApishipOption,
11 // ApiShip attaches with the cheapest tariff it found, which is enough to price the
12 // cart but not to ship it: the order needs the tariff the customer actually picked,
13 // and a pickup point where the option delivers to one.
14 isReady: (cart, option) =>
15 isApishipSelectionComplete(getApishipSelection(cart, option.id), option),
16 pricedByChoice: true,
17 removeShippingMethod: removeApishipShippingMethod,
18}

Затем откройте и добавьте дескриптор в :

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

src/lib/constants.tsx
1import { apishipShippingOptionDescriptor } from "@modules/checkout/components/apiship/descriptor"
2// ...
3
4const shippingOptionDescriptors: ShippingOptionDescriptor[] = [
5 apishipShippingOptionDescriptor,
6]
  • распознаёт способ доставки ApiShip по провайдеру.
  • считает способ доставки готовым, когда покупатель выбрал тариф, а для доставки в пункт выдачи ещё и пункт. До этого кнопка оплаты остаётся неактивной.
  • сообщает оформлению заказа, что до выбора у способа доставки нет цены. Оформление заказа не рассчитывает его, позволяет выбрать его без цены, не добавляет его в корзину и убирает снова, когда меняется адрес.
  • получает функцию из шага 5. Оформление заказа вызывает её каждый раз, когда убирает способ доставки из корзины.

Шаг 14: Создайте карточку способа доставки

Так будут выглядеть карточки способов доставки, которые вы создадите на этом шаге, на странице оформления заказа:

Карточки способов доставки ApiShip, у курьерской доставки цена выбранного тарифа

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

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

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

src/modules/checkout/components/shipping-option/providers/apiship.tsx
1"use client"
2
3import { convertToLocale } from "@lib/util/money"
4import {
5 getTariffCost,
6 getTariffDays,
7 useApishipSelection,
8} from "@modules/checkout/components/apiship"
9import { useLocale, useTranslations } from "next-intl"
10import type React from "react"
11import { ShippingOptionCardShell, type ShippingOptionCardProps } from "../shared"
12
13/**
14 * ApiShip has no price until the customer picks a tariff, so the card shows none. Once a
15 * tariff is picked the card carries its delivery dates and exactly the price the cart is
16 * charged, and keeps both while the customer looks at another method, since the choice is
17 * kept too.
18 */
19const ApishipShippingOptionCard: React.FC<ShippingOptionCardProps> = ({
20 cart,
21 option,
22 isSelected,
23 isUnavailable,
24 formatDeliveryDays,
25}) => {
26 const tCheckout = useTranslations("CheckoutPage")
27 const locale = useLocale()
28 const selection = useApishipSelection(cart, option.id)
29
30 const cost = selection ? getTariffCost(selection.tariff) : null
31
32 const price =
33 cost === null
34 ? null
35 : cost === 0
36 ? tCheckout("freeShipping")
37 : convertToLocale({
38 amount: cost,
39 currency_code: cart.currency_code,
40 locale,
41 })
42
43 const caption = selection
44 ? formatDeliveryDays(getTariffDays(selection.tariff))
45 : null
46
47 return (
48 <ShippingOptionCardShell
49 option={option}
50 isSelected={isSelected}
51 isUnavailable={isUnavailable}
52 price={price}
53 isLoadingPrice={false}
54 isFreeShipping={cost === 0}
55 caption={caption}
56 />
57 )
58}
59
60export default ApishipShippingOptionCard

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

Затем откройте и добавьте в диспетчер карточек вариант для ApiShip:

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

src/modules/checkout/components/shipping-option/index.tsx
1import { isApishipOption } from "@modules/checkout/components/apiship"
2import ApishipShippingOptionCard from "./providers/apiship"
3// ...
4
5 switch (true) {
6 case isApishipOption(props.option):
7 return <ApishipShippingOptionCard {...props} />
8 default:
9 return <ManualShippingOptionCard {...props} />
10 }

Все остальные способы доставки по-прежнему отображаются ручной карточкой стартера.


Шаг 15: Создайте строку доставки

Так будет выглядеть строка доставки, которую вы создадите на этом шаге, на странице оформления заказа:

Строка доставки ApiShip с выбранным пунктом выдачи и логотипом СДЭК

Строка доставки находится под способами доставки и открывает окно доставки. Для курьерской доставки она показывает адрес, а для доставки в пункт выдачи адрес пункта под заголовком «Пункт выдачи». Когда покупатель сделал выбор, в конце строки появляется логотип перевозчика.

В строке стартера пока нет места для логотипа. Откройте и добавьте проп :

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

src/modules/checkout/components/checkout-info-rows/row.tsx
1type CheckoutInfoRowProps = {
2 // ...
3 trailing?: ReactNode
4 // ...
5}
6
7export default function CheckoutInfoRow({
8 // ...
9 trailing,
10 // ...
11}: CheckoutInfoRowProps) {
12 return (
13 <button
14 // ...
15 >
16 {/* ... */}
17 {(trailing || !disabled) && (
18 <div className="flex flex-shrink-0 items-center gap-x-3">
19 {trailing}
20 {!disabled && (
21 <ChevronRight className="text-ui-fg-base flex-shrink-0 rtl:rotate-180" />
22 )}
23 </div>
24 )}
25 </button>
26 )
27}

Откройте и передайте через :

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

src/modules/checkout/components/delivery-row/shared.tsx
1type DeliveryRowShellProps = {
2 // ...
3 trailing?: ReactNode
4 // ...
5}
6
7export const DeliveryRowShell = ({
8 // ...
9 trailing,
10 // ...
11}: DeliveryRowShellProps) => (
12 <CheckoutInfoRow
13 // ...
14 trailing={trailing}
15 // ...
16 />
17)

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

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

src/modules/checkout/components/delivery-row/providers/apiship.tsx
1"use client"
2
3import { useState } from "react"
4import {
5 ApishipDeliveryModal,
6 ApishipProviderLogo,
7 getApishipDeliveryType,
8 useApishipProviders,
9 useApishipSelection,
10} from "@modules/checkout/components/apiship"
11import { useTranslations } from "next-intl"
12import type React from "react"
13import { DeliveryRowShell, type DeliveryRowProps } from "../shared"
14
15/**
16 * The row an ApiShip method collects its choice through. Delivery to a pickup point asks
17 * for the point rather than an address, so the row says so and carries the point under
18 * the heading; courier delivery keeps the address. Either way the row opens the same
19 * two-step sheet, address first and the carrier's offer second.
20 */
21const ApishipDeliveryRow: React.FC<DeliveryRowProps> = ({
22 cart,
23 addresses,
24 option,
25}) => {
26 const t = useTranslations("Apiship")
27 const tCheckout = useTranslations("CheckoutPage")
28 const [open, setOpen] = useState(false)
29
30 const selection = useApishipSelection(cart, option?.id)
31 const providers = useApishipProviders(option?.id, open || !!selection)
32
33 if (!option) return null
34
35 const toPoint = getApishipDeliveryType(option) === 2
36 const providerKey = selection?.point?.providerKey ?? selection?.tariff.providerKey
37 const addr = cart.shipping_address
38
39 const addressText = addr?.address_1
40 ? [addr.address_1, addr.city, addr.postal_code].filter(Boolean).join(", ")
41 : null
42
43 return (
44 <>
45 <DeliveryRowShell
46 heading={toPoint ? t("pickupPointHeading") : tCheckout("addressHeading")}
47 onClick={() => setOpen(true)}
48 value={!selection ? null : toPoint ? selection.point?.address : addressText}
49 trailing={
50 selection && providerKey ? (
51 <ApishipProviderLogo src={providers[providerKey]?.icon} />
52 ) : null
53 }
54 data-testid="checkout-apiship-row"
55 />
56
57 <ApishipDeliveryModal
58 open={open}
59 onClose={() => setOpen(false)}
60 cart={cart}
61 addresses={addresses}
62 option={option}
63 providers={providers}
64 />
65 </>
66 )
67}
68
69export default ApishipDeliveryRow

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

Затем откройте и добавьте в диспетчер строки вариант для ApiShip:

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

src/modules/checkout/components/delivery-row/index.tsx
1import { isApishipOption } from "@modules/checkout/components/apiship"
2// ...
3import ApishipDeliveryRow from "./providers/apiship"
4
5// ...
6
7 switch (true) {
8 case isApishipOption(props.option):
9 return <ApishipDeliveryRow {...props} />
10 default:
11 return <ManualDeliveryRow {...props} />
12 }

Для всех остальных способов доставки остаётся строка адреса стартера.


Шаг 16: Добавьте строки оформления заказа в локали витрины

Добавьте пространство имён в и в каждую другую локаль под :

messages/ru.json
1{
2 // ...
3 "Apiship": {
4 "choose": "Выбрать",
5 "courierModalTitle": "Доставка курьером",
6 "loadFailed": "Не удалось загрузить варианты доставки. Попробуйте ещё раз.",
7 "mapKeyMissing": "Для карты нужен ключ API Яндекс Карт.",
8 "mapLoadFailed": "Не удалось загрузить карту. Попробуйте ещё раз.",
9 "next": "Далее",
10 "noPoints": "По этому адресу нет пунктов выдачи.",
11 "noTariffs": "По этому адресу нет доставки.",
12 "photoAlt": "Фотография пункта выдачи {index}",
13 "pickupPointHeading": "Пункт выдачи",
14 "pickupPointModalTitle": "Пункт выдачи",
15 "pointDetails": "Как найти",
16 "schedule": "Часы работы",
17 "selectPointPrompt": "Выберите пункт на карте"
18 },
19 // ...
20}

В примере это пространство имён переведено на все 36 локалей витрины. Если в локали этих ключей нет, ошибка возникнет во время выполнения, а не при сборке.


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

Полный код витрины находится в , собранной на основе Medusa DTC Starter от Gorgo. В README примера перечислены все файлы, из которых состоит интеграция, включая бэкенд.

Изменено 2 октября 2026 г.·Редактировать страницу