Search for a command to run...
Чтобы интегрировать плагин ApiShip в storefront (витрину магазина) Medusa DTC Starter от Gorgo, зарегистрируйте ApiShip в точках расширения доставки, которые предоставляет стартер, и добавьте компоненты, через которые покупатель выбирает доставку при оформлении заказа.
У способа доставки ApiShip нет фиксированной цены. Сначала покупатель выбирает тариф перевозчика, а для доставки в пункт выдачи ещё и сам пункт. Витрина загружает и то и другое из store-роутов плагина, показывает их в двухшаговом окне доставки и сохраняет выбор в способа доставки корзины. Плагин проверяет этот выбор, рассчитывает по выбранному тарифу стоимость доставки и создаёт из него заказ в ApiShip, когда вы выполняете заказ.
Собственного провайдера с расчётной доставкой в стартере нет, есть только точки расширения, к которым он подключается:
Для витрины на Next.js необходимо внести следующие изменения:
Карта пунктов выдачи построена на JavaScript API Яндекс Карт версии 3. Сам API загружается с серверов Яндекса в браузере. Два npm-пакета добавляют кластеризатор, который объединяет соседние пункты выдачи в одну метку, и описания типов API.
Установите их в приложение витрины, :
1pnpm add @yandex/ymaps3-clusterer@0.0.122pnpm add -D @yandex/ymaps3-types@1.0.21414904Это версии, на которых работает пример витрины.
Карте нужен ключ JavaScript API. Создайте его в кабинете разработчика Яндекса в разделе «JavaScript API и HTTP Геокодер» и добавьте в витрины:
.env.localNEXT_PUBLIC_YANDEX_MAPS_API_KEY=supersecret
Ключ публичный, потому что карта работает в браузере покупателя. Переменные Next.js встраивает в сборку, поэтому после смены ключа пересоберите витрину. Без ключа окно выбора пункта выдачи сообщает, что ключа нет, а курьерская доставка продолжает работать.
ApiShip доставляет по России, поэтому пример также задаёт , чтобы витрина открывалась в российском регионе.
ApiShip нужен номер телефона получателя, чтобы рассчитать доставку и создать заказ. Контакты оформление заказа собирает раньше, чем доставку, поэтому сделайте поле телефона обязательным именно там.
Откройте и отметьте как оба поля телефона, покупателя и другого получателя:

src/modules/checkout/components/checkout-contacts-sheet/index.tsx1<Input2 label={t("fieldPhone")}3 name="phone"4 autoComplete="tel"5 value={formData.phone}6 onChange={handleChange}7 required8 data-testid="shipping-phone-input"9/>1011// ...1213{isDifferentRecipient && (14 // ...15 <Input16 label={t("fieldPhone")}17 name="recipient_phone"18 autoComplete="tel"19 value={formData.recipient_phone}20 onChange={handleChange}21 required22 />23)}
Теперь браузер не отправит окно контактов без номера телефона, и у каждого расчёта и каждого заказа в ApiShip он будет.
Store-роуты плагина возвращают тарифы перевозчиков, пункты выдачи и названия перевозчиков. Поля, которые витрина из них читает, она описывает в собственных типах, вместе с выбором доставки, который она сохраняет в способе доставки.
Создайте файл со следующим содержимым:

src/types/apiship.ts83}8485export type ApishipStoredTariff = Omit<ApishipTariff, "pointIds">8687export type ApishipStoredPoint = Pick<88 ApishipPoint,89 "id" | "providerKey" | "name" | "address"90>9192/**93 * What the customer picked, stored on the shipping method so a reload restores it and the94 * plugin can create the ApiShip order from it. `deliveryType` is 1 for an address and 295 * for a pickup point, matching the shipping option it was chosen under.96 */97export type ApishipSelection = {98 deliveryType: number99 tariff: ApishipStoredTariff100 point?: ApishipStoredPoint101}102103export type ApishipDeliveryChoice = {104 calculation: ApishipCalculation105 points: ApishipPoint[]106}
Витрина объявляет эти типы сама, а не импортирует из , потому что эта точка входа указывает на исходники TypeScript, которые не разрешаются за пределами сборки самого плагина. Все поля необязательные, так как перевозчики заполняют их по-разному.
оформление заказа сохраняет под ключом в способа доставки. Его равен для курьерской доставки и для пункта выдачи, по способу доставки, под которым покупатель сделал выбор.
Витрина обращается к трём роутам плагина. Первый рассчитывает тарифы и пункты выдачи для корзины, второй возвращает перевозчиков, третий убирает способ доставки из корзины.
Создайте файл со следующим содержимым:

src/lib/data/apiship.ts1"use server"23import { 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"1920const toCarrierGroups = <T extends { providerKey?: string; tariffs?: unknown }>(21 groups: T[] | undefined22) =>23 (groups ?? []).flatMap((group) =>24 group.providerKey ? [{ ...group, providerKey: group.providerKey }] : []25 )2627const 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 }3738 return [{ ...point, id: String(point.id), lat: point.lat, lng: point.lng }]39 })4041export const retrieveApishipDeliveryChoice = async (42 cartId: string,43 shippingOptionId: string,44 includePoints: boolean45): Promise<ApishipDeliveryChoice | null> => {46 const headers = {47 ...(await getAuthHeaders()),48 }4950 return sdk.client51 .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 null69 })70}7172/**
вызывает . Плагин рассчитывает способ доставки по адресу и товарам корзины и возвращает только тарифы, разрешённые настройками служб доставки в Medusa Admin. С он возвращает ещё и пункты выдачи, в которые доставляют эти тарифы, так что второй запрос карте не нужен. Функция отбрасывает группы перевозчиков без ключа и пункты без координат, потому что карта не может их разместить.
вызывает , который возвращает перевозчиков с названиями и логотипами. Функция передаёт , по которому плагин определяет, к какому инстансу ApiShip относится способ доставки. С именованным инстансом, например , без этого параметра роут отвечает ошибкой 404.
вызывает . Этот роут добавляет плагин, потому что в store API Medusa нет способа убрать способ доставки из корзины. Повторная установка способа доставки сохраняет его , поэтому без удаления тариф и пункт выдачи, выбранные для прежнего адреса, остались бы в корзине.
Компоненты ApiShip используют общий набор функций, которые распознают способ доставки ApiShip, читают тарифы и превращают расчёт в то, что отображают списки и карта.
Создайте файл со следующим содержимым:

src/modules/checkout/components/apiship/utils.ts1import type { HttpTypes } from "@medusajs/types"2import type {3 ApishipCalculation,4 ApishipPoint,5 ApishipSelection,6 ApishipTariff,7} from "types/apiship"89export const APISHIP_PROVIDER_PREFIX = "apiship_"1011export const isApishipOption = (12 option?: HttpTypes.StoreCartShippingOption | null13) => Boolean(option?.provider_id?.startsWith(APISHIP_PROVIDER_PREFIX))1415/**16 * Which half of the calculation a shipping option draws on. The seed writes17 * `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 | null21): 1 | 2 | null => {22 if (!isApishipOption(option)) return null2324 const deliveryType = (option?.data as { deliveryType?: unknown } | null)25 ?.deliveryType2627 return deliveryType === 1 || deliveryType === 2 ? deliveryType : null28}2930/**31 * ApiShip quotes every tariff twice, `deliveryCost` and `deliveryCostOriginal`. The32 * fulfillment provider bills the cart with `deliveryCost`, so that is the one the checkout33 * shows, and the customer pays what they picked.34 */35export const getTariffCost = (tariff: ApishipTariff) =>36 typeof tariff.deliveryCost === "number" ? tariff.deliveryCost : null3738export const getTariffDays = (tariff: ApishipTariff) => {39 const min = tariff.daysMin ?? 040 const max = tariff.daysMax ?? 04142 if (!min && !max) return null4344 return { min: min || max, max: max || min }45}4647/**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 closes50 * the gap for a carrier that sent neither.51 */52export const buildTariffKey = (53 providerKey: string,54 tariff: Omit<ApishipTariff, "key" | "providerKey">,55 index: number56) =>57 `${providerKey}:${tariff.tariffId ?? tariff.tariffProviderId ?? tariff.tariffName ?? index}`5859/**60 * Inverts the calculation into "which tariffs serve this pickup point", which is the
Стартер форматирует даты доставки прямо в секции доставки. Списку тарифов и карточке способа доставки ApiShip нужны такие же даты, поэтому перенесите форматирование в хук.
Создайте файл со следующим содержимым:

src/lib/hooks/use-format-delivery-days.tsx1"use client"23import { useCallback, useEffect, useMemo, useState } from "react"4import { useLocale, useTranslations } from "next-intl"5import type { DeliveryDays } from "@lib/util/fulfillment"67const NARROW_NO_BREAK_SPACE = "\u202F"8const NO_BREAK_SPACE = "\u00A0"910const keepTogether = (text: string) =>11 text.replace(/\s/g, (space) =>12 space === "\u2009" || space === NARROW_NO_BREAK_SPACE13 ? NARROW_NO_BREAK_SPACE14 : NO_BREAK_SPACE15 )1617const breakAfterDash = (range: string) => {18 const separator = /(\s+)([–—-])(\s+)/.exec(range)19 if (!separator) return keepTogether(range)2021 const [match, before, dash, after] = separator22 const start = range.slice(0, separator.index)23 const end = range.slice(separator.index + match.length)2425 return `${keepTogether(start)}${keepTogether(before)}${dash}${after}${keepTogether(end)}`26}2728export function useFormatDeliveryDays() {29 const t = useTranslations("CheckoutPage")30 const locale = useLocale()3132 const [now, setNow] = useState<Date | null>(null)33 useEffect(() => {34 setNow(new Date())35 }, [])3637 const formatter = useMemo(38 () =>39 new Intl.DateTimeFormat(locale, {40 day: "numeric",41 month: "long",42 }),43 [locale]44 )4546 return useCallback(47 (days: DeliveryDays | null) => {48 if (!days) return null49 if (days.max === 0) return t("deliveryToday")50 if (!now) return null5152 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 date57 }5859 const formatDate = (daysFromNow: number) =>60 keepTogether(formatter.format(dateIn(daysFromNow)))6162 if (days.min !== undefined && days.max !== undefined) {63 if (days.min === days.max) return formatDate(days.min)6465 if (typeof formatter.formatRange === "function") {66 return breakAfterDash(67 formatter.formatRange(dateIn(days.min), dateIn(days.max))68 )69 }7071 return `${formatDate(days.min)}${NO_BREAK_SPACE}– ${formatDate(days.max)}`72 }7374 if (days.max !== undefined) {75 return t("deliveryDateUntil", { date: formatDate(days.max) })76 }7778 if (days.min !== undefined) {79 return t("deliveryDateFrom", { date: formatDate(days.min) })80 }8182 return null83 },84 [formatter, now, t]85 )86}
Затем откройте и замените встроенное форматирование хуком:

src/modules/checkout/components/checkout-shipping-section/index.tsx1import {2 getDeliveryDays,3 isPickupShippingOption,4} from "@lib/util/fulfillment"5// ...6import { useFormatDeliveryDays } from "@lib/hooks/use-format-delivery-days"78// ...910 const formatDeliveryDays = useFormatDeliveryDays()
Удалите , , , , и импорт , теперь всё это находится в хуке. Хук форматирует дату или диапазон дат в локали витрины и не даёт дате разорваться на две строки.
Два хука делят выбор покупателя и список перевозчиков между карточкой способа доставки, строкой доставки и окном доставки.
Создайте файл со следующим содержимым:

src/modules/checkout/components/apiship/use-apiship-selection.ts1"use client"23import type { HttpTypes } from "@medusajs/types"4import { useShippingSelection } from "@modules/checkout/context/shipping-selection-context"5import type { ApishipSelection } from "types/apiship"6import { getApishipSelection } from "./utils"78export function useApishipSelection(9 cart: HttpTypes.StoreCart,10 optionId: string | null | undefined11): ApishipSelection | null {12 const { getOptionData } = useShippingSelection()1314 if (!optionId) return null1516 return (17 (getOptionData(optionId) as { apishipData?: ApishipSelection } | undefined)18 ?.apishipData ?? getApishipSelection(cart, optionId)19 )20}
возвращает выбор для одного способа доставки. Сначала он читает подтверждённый покупателем выбор из контекста выбора доставки оформления заказа, а если его там нет, берёт выбор, сохранённый в способе доставки корзины. В корзине одновременно хранится только один способ доставки, поэтому именно контекст сохраняет цену на карточке курьерской доставки, пока покупатель смотрит пункты выдачи.
Создайте файл со следующим содержимым:

src/modules/checkout/components/apiship/use-apiship-providers.ts1"use client"23import { useEffect, useState } from "react"4import { retrieveApishipProviders } from "@lib/data/apiship"5import type { ApishipProvider } from "types/apiship"67type ProvidersByKey = Record<string, ApishipProvider>89const providersByOption = new Map<string, Promise<ProvidersByKey | null>>()1011const loadProviders = (shippingOptionId: string) => {12 const cached = providersByOption.get(shippingOptionId)13 if (cached) return cached1415 const loading = retrieveApishipProviders(shippingOptionId).then((list) => {16 if (!list) {17 providersByOption.delete(shippingOptionId)18 return null19 }2021 return Object.fromEntries(22 list.flatMap((provider) =>23 provider.key && provider.name ? [[provider.key, provider]] : []24 )25 ) as ProvidersByKey26 })2728 providersByOption.set(shippingOptionId, loading)29 return loading30}3132export function useApishipProviders(33 shippingOptionId: string | undefined,34 enabled: boolean35) {36 const [providers, setProviders] = useState<ProvidersByKey>({})3738 useEffect(() => {39 if (!enabled || !shippingOptionId) return4041 let cancelled = false4243 loadProviders(shippingOptionId).then((loaded) => {44 if (!cancelled && loaded) setProviders(loaded)45 })4647 return () => {48 cancelled = true49 }50 }, [enabled, shippingOptionId])5152 return providers53}
загружает перевозчиков способа доставки один раз и хранит запрос в словаре на уровне модуля, поэтому все компоненты страницы используют один запрос на способ доставки. Загрузка начинается, только когда равен .
Так будет выглядеть список тарифов, который вы создадите на этом шаге, в окне курьерской доставки:

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

src/modules/checkout/components/apiship/provider-logo.tsx1import { clx } from "@medusajs/ui"2import Image from "next/image"34type ProviderLogoProps = {5 src?: string6 className?: string7}89export default function ProviderLogo({ src, className }: ProviderLogoProps) {10 if (!src) return null1112 return (13 <Image14 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 className21 )}22 />23 )24}
отображает логотип перевозчика, который плагин возвращает как SVG в виде data URI в поле . Его используют список тарифов, панель пункта выдачи и строка доставки.
Создайте файл со следующим содержимым:

src/modules/checkout/components/apiship/tariff-list.tsx1"use client"23import { 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"1112type TariffGroup = {13 providerKey: string14 tariffs: ApishipTariff[]15}1617type TariffListProps = {18 groups: TariffGroup[]19 currencyCode: string20 providers: Record<string, ApishipProvider>21 selectedKey: string | null22 onSelect: (key: string) => void23 /** Carrier headings only help when more than one carrier is on offer. */24 showGroupNames?: boolean25}2627/**28 * The tariffs of one shipping option, grouped by carrier. Both the courier modal and the29 * 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()4243 return (44 <RadioGroup45 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 )}6061 {group.tariffs.map((tariff) => {62 const cost = getTariffCost(tariff)63 const days = formatDeliveryDays(getTariffDays(tariff))6465 return (66 <RadioGroup.ChoiceBox67 key={tariff.key}68 value={tariff.key}69 label={tariff.tariffName ?? group.providerKey}70 description={[71 days,72 cost === null73 ? null74 : 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}
Каждый тариф отображается через из , с названием тарифа в подписи и датой доставки и ценой в описании. Панель пункта выдачи передаёт , потому что все её тарифы принадлежат перевозчику этого пункта.
Так будет выглядеть карта пунктов выдачи, которую вы создадите на этом шаге, в окне доставки:

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

src/modules/checkout/components/apiship/pickup-point-map.tsx27const Z_INDEX_SELECTED = 12829/**30 * Yandex Maps takes a full locale and rejects a bare language tag with a 400, so the31 * 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}3940const toYandexLang = (locale: string) =>41 YANDEX_LANGS[locale.split(/[-_]/)[0].toLowerCase()] ?? "en_US"4243function loadYmaps3Script(apiKey: string, lang: string) {44 if (typeof ymaps3 !== "undefined") return Promise.resolve()4546 return new Promise<void>((resolve, reject) => {47 const script = document.createElement("script")48 script.src = `https://api-maps.yandex.ru/v3/?apikey=${encodeURIComponent(49 apiKey50 )}&lang=${encodeURIComponent(lang)}`51 script.async = true52 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}6061async function loadMapComponents(apiKey: string, lang: string) {62 await loadYmaps3Script(apiKey, lang)63 await ymaps3.ready6465 const [{ reactify }, clusterer] = await Promise.all([66 ymaps3.import("@yandex/ymaps3-reactify"),67 import("@yandex/ymaps3-clusterer"),68 ])6970 const bound = reactify.bindTo(React, ReactDOM)71 const { YMap, YMapDefaultSchemeLayer, YMapDefaultFeaturesLayer, YMapMarker } =72 bound.module(ymaps3)73 const { YMapClusterer } = bound.module(clusterer)7475 return {76 YMap,77 YMapDefaultSchemeLayer,78 YMapDefaultFeaturesLayer,79 YMapMarker,80 YMapClusterer,81 }82}8384type MapComponents = Awaited<ReturnType<typeof loadMapComponents>>8586let mapComponentsLoading: Promise<MapComponents> | null = null8788const getMapComponents = (apiKey: string, lang: string) => {89 mapComponentsLoading ??= loadMapComponents(apiKey, lang).catch((e) => {90 mapComponentsLoading = null91 throw e92 })93 return mapComponentsLoading94}9596const locationForPoints = (97 points: ApishipPoint[],
Компонент один раз загружает скрипт Яндекс Карт с ключом из шага 2 и локалью витрины. Яндекс Карты не принимают язык без региона, поэтому переводит локаль в один из четырёх языков, которые поддерживает карта, а для остальных выбирает английский. React-компоненты карты берутся из собственного модуля API , а кластеризатор из пакета, установленного на шаге 1.
объединяет метки, которые на экране стоят ближе 60 пикселей друг к другу, пересчитывает кластеры один раз на каждый уровень масштаба и возвращает только видимые. Карта подстраивает вид под все пункты или открывается на пункте, который покупатель выбрал раньше. Если ключа нет, карта не загрузилась или адрес не обслуживает ни один пункт выдачи, компонент показывает сообщение.
Так будет выглядеть окно доставки, которое вы создадите на этом шаге, когда на карте выбран пункт выдачи:

Пока ApiShip не знает адреса, он ничего не может рассчитать, поэтому в окне доставки два шага. На первом покупатель заполняет форму адреса из оформления заказа. На втором выбирает тариф курьерской доставки в списке или пункт выдачи на карте, рядом с которой открывается панель выбранного пункта.
Создайте файл со следующим содержимым:
![]()
src/modules/common/icons/clock.tsx1import type React from "react"23import type { IconProps } from "types/icon"45const Clock: React.FC<IconProps> = ({6 size = "20",7 color = "currentColor",8 ...attributes9}) => {10 return (11 <svg12 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 <path20 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 <path27 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}3637export default Clock
Рядом с этой иконкой панель пункта выдачи показывает часы работы.
Создайте файл со следующим содержимым:

src/modules/checkout/components/apiship/delivery-modal.tsx44}4546/**47 * ApiShip needs an address before it can quote anything, so the flow is two steps in one48 * sheet. The first is the checkout's own address step, and only then does the second show49 * what the carriers offer for that address, a tariff list for courier delivery and a map50 * 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()6465 const toPoint = getApishipDeliveryType(option) === 26667 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)8081 const savedSelection = useApishipSelection(cart, option.id)82 const savedPointId = savedSelection?.point?.id ?? null83 const savedTariffKey = savedSelection?.tariff.key ?? null8485 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("#")9899 useEffect(() => {100 if (!open) return101 setError(null)102 setStep(hasAddress ? "choice" : "address")103 }, [open, hasAddress])104105 useEffect(() => {106 if (!open) return107 setSelectedPointId(savedPointId)108 setSelectedTariffKey(savedTariffKey)109 }, [open, savedPointId, savedTariffKey])110111 useEffect(() => {112 if (!open || step !== "choice" || loadedKey === requestKey) return113114 let cancelled = false115 setIsLoading(true)116 setLoadFailed(false)117118 void (async () => {119 const result = await retrieveApishipDeliveryChoice(120 cart.id,121 option.id,122 toPoint123 )124 if (cancelled) return125126 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 })()135136 return () => {137 cancelled = true138 }139 }, [open, step, cart.id, option.id, toPoint, requestKey, loadedKey])140141 const weekdayFormatter = useMemo(
Окно открывается на шаге адреса, если в корзине ещё нет адреса, и на шаге выбора во всех остальных случаях. Расчёт оно загружает при открытии шага выбора и заново при каждой смене способа доставки, адреса или товаров корзины, за которыми следит . Для способа доставки в пункт выдачи окно строит тарифы каждого пункта через , и панель показывает тарифы выбранного пункта вместе с его адресом, часами работы, описанием и фотографиями.
сохраняет выбор через под ключом и в контексте выбора доставки. Плагин заново рассчитывает корзину на своей стороне и отклоняет тариф, недоступный для корзины или не обслуживающий выбранный пункт выдачи. В этом случае окно показывает ошибку и остаётся открытым.
Создайте файл со следующим содержимым:

src/modules/checkout/components/apiship/index.ts1export { 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 через этот файл.
Дескриптор сообщает оформлению заказа в стартере, как обращаться со способом доставки ApiShip.
Создайте файл со следующим содержимым:

src/modules/checkout/components/apiship/descriptor.ts1import type { ShippingOptionDescriptor } from "@lib/constants"2import { removeApishipShippingMethod } from "@lib/data/apiship"3import {4 getApishipSelection,5 isApishipOption,6 isApishipSelectionComplete,7} from "./utils"89export const apishipShippingOptionDescriptor: ShippingOptionDescriptor = {10 test: isApishipOption,11 // ApiShip attaches with the cheapest tariff it found, which is enough to price the12 // 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}
Затем откройте и добавьте дескриптор в :

src/lib/constants.tsx1import { apishipShippingOptionDescriptor } from "@modules/checkout/components/apiship/descriptor"2// ...34const shippingOptionDescriptors: ShippingOptionDescriptor[] = [5 apishipShippingOptionDescriptor,6]
Так будут выглядеть карточки способов доставки, которые вы создадите на этом шаге, на странице оформления заказа:

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

src/modules/checkout/components/shipping-option/providers/apiship.tsx1"use client"23import { 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"1213/**14 * ApiShip has no price until the customer picks a tariff, so the card shows none. Once a15 * tariff is picked the card carries its delivery dates and exactly the price the cart is16 * charged, and keeps both while the customer looks at another method, since the choice is17 * 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)2930 const cost = selection ? getTariffCost(selection.tariff) : null3132 const price =33 cost === null34 ? null35 : cost === 036 ? tCheckout("freeShipping")37 : convertToLocale({38 amount: cost,39 currency_code: cart.currency_code,40 locale,41 })4243 const caption = selection44 ? formatDeliveryDays(getTariffDays(selection.tariff))45 : null4647 return (48 <ShippingOptionCardShell49 option={option}50 isSelected={isSelected}51 isUnavailable={isUnavailable}52 price={price}53 isLoadingPrice={false}54 isFreeShipping={cost === 0}55 caption={caption}56 />57 )58}5960export default ApishipShippingOptionCard
Карточка отображается через стартера, поэтому выглядит так же, как собственные способы доставки стартера. Цена на ней остаётся, пока покупатель смотрит другой способ доставки, потому что тоже сохраняет выбор.
Затем откройте и добавьте в диспетчер карточек вариант для ApiShip:

src/modules/checkout/components/shipping-option/index.tsx1import { isApishipOption } from "@modules/checkout/components/apiship"2import ApishipShippingOptionCard from "./providers/apiship"3// ...45 switch (true) {6 case isApishipOption(props.option):7 return <ApishipShippingOptionCard {...props} />8 default:9 return <ManualShippingOptionCard {...props} />10 }
Все остальные способы доставки по-прежнему отображаются ручной карточкой стартера.
Так будет выглядеть строка доставки, которую вы создадите на этом шаге, на странице оформления заказа:

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

src/modules/checkout/components/checkout-info-rows/row.tsx1type CheckoutInfoRowProps = {2 // ...3 trailing?: ReactNode4 // ...5}67export default function CheckoutInfoRow({8 // ...9 trailing,10 // ...11}: CheckoutInfoRowProps) {12 return (13 <button14 // ...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}

src/modules/checkout/components/delivery-row/shared.tsx1type DeliveryRowShellProps = {2 // ...3 trailing?: ReactNode4 // ...5}67export const DeliveryRowShell = ({8 // ...9 trailing,10 // ...11}: DeliveryRowShellProps) => (12 <CheckoutInfoRow13 // ...14 trailing={trailing}15 // ...16 />17)
Создайте файл со следующим содержимым:

src/modules/checkout/components/delivery-row/providers/apiship.tsx1"use client"23import { 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"1415/**16 * The row an ApiShip method collects its choice through. Delivery to a pickup point asks17 * for the point rather than an address, so the row says so and carries the point under18 * the heading; courier delivery keeps the address. Either way the row opens the same19 * 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)2930 const selection = useApishipSelection(cart, option?.id)31 const providers = useApishipProviders(option?.id, open || !!selection)3233 if (!option) return null3435 const toPoint = getApishipDeliveryType(option) === 236 const providerKey = selection?.point?.providerKey ?? selection?.tariff.providerKey37 const addr = cart.shipping_address3839 const addressText = addr?.address_140 ? [addr.address_1, addr.city, addr.postal_code].filter(Boolean).join(", ")41 : null4243 return (44 <>45 <DeliveryRowShell46 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 ) : null53 }54 data-testid="checkout-apiship-row"55 />5657 <ApishipDeliveryModal58 open={open}59 onClose={() => setOpen(false)}60 cart={cart}61 addresses={addresses}62 option={option}63 providers={providers}64 />65 </>66 )67}6869export default ApishipDeliveryRow
Строка открывает . Перевозчиков она начинает загружать, только когда окно открыто или выбор уже сделан, поэтому покупатель, который не открывал окно, не вызывает запрос перевозчиков.
Затем откройте и добавьте в диспетчер строки вариант для ApiShip:

src/modules/checkout/components/delivery-row/index.tsx1import { isApishipOption } from "@modules/checkout/components/apiship"2// ...3import ApishipDeliveryRow from "./providers/apiship"45// ...67 switch (true) {8 case isApishipOption(props.option):9 return <ApishipDeliveryRow {...props} />10 default:11 return <ManualDeliveryRow {...props} />12 }
Для всех остальных способов доставки остаётся строка адреса стартера.
Добавьте пространство имён в и в каждую другую локаль под :
messages/ru.json1{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 примера перечислены все файлы, из которых состоит интеграция, включая бэкенд.