Search for a command to run...
Чтобы интегрировать плагин ApiShip в storefront (витрину магазина) на Next.js, необходимо расширить шаг выбора доставки в оформлении заказа (checkout), добавив поддержку доставки в пункты выдачи.
Доставка ApiShip отличается от обычных способов доставки тем, что во время оформления заказа необходимо собрать дополнительные данные, такие как выбранный пункт выдачи и тариф доставки. Эти данные должны динамически загружаться с бэкенда, отображаться покупателю в удобной форме, а затем сохраняться в корзине, чтобы впоследствии плагин мог использовать их при создании заказа в ApiShip.
Селектор пункта выдачи использует JavaScript API Яндекс Карт v3. Чтобы включить карту в витрине, необходимо задать публичный API-ключ через переменные окружения.
Добавьте следующее в файл вашей витрины:
.envNEXT_PUBLIC_YANDEX_MAPS_API_KEY=supersecret
Для витрины на Next.js необходимо внести следующие изменения:
ApiShip требует номер телефона получателя для расчета стоимости доставки и создания заказа в ApiShip. Поэтому витрина должна требовать обязательное указание номера телефона в форме адреса доставки.
Откройте и сделайте поле ввода телефона обязательным:
src/modules/checkout/components/shipping-address/index.tsx1<Input2 label="Phone"3 name="shipping_address.phone"4 autoComplete="tel"5 value={formData["shipping_address.phone"]}6 onChange={handleChange}7 required // Обязательно для ApiShip8 data-testid="shipping-phone-input"9/>
Отметив поле ввода телефона как , вы обеспечите, что номер телефона получателя будет собираться на раннем этапе оформления заказа. Это гарантирует, что при расчёте доставки и создании отправления в ApiShip будут доступны необходимые данные.
Для интеграции ApiShip в процесс оформления заказа, витрина должна иметь возможность сохранять выбор доставки клиента в корзине и получать его позже. Это обеспечивает стабильность процесса при обновлении страницы и навигации. Если клиент уже выбрал тариф и пункт самовывоза, витрина может восстановить этот выбор вместо того, чтобы снова просить его это сделать. Также требуется корректное действие по удалению, которое очищает выбор не только визуально, но и в самой корзине.
Откройте и измените следующее:
src/lib/data/cart.ts1import type { ApishipHttpTypes } from "@gorgo/medusa-fulfillment-apiship/types"23export async function retrieveCart(cartId?: string, fields?: string) {4 // ...5 // Добавьте параметр +shipping_methods.data6 fields ??= "*items, *region, *items.product, *items.variant, *items.thumbnail, *items.metadata, +items.total, *promotions, +shipping_methods.name, +shipping_methods.data"7 // ...8}910export async function setShippingMethod({11 // ...12 data,13}: {14 // ...15 data?: Record<string, unknown>16}) {17 // ...18 return sdk.store.cart19 .addShippingMethod(20 cartId,21 {22 // ...23 data // Данные ApiShip24 },25 // ...26 )27 // ...28}
Также добавьте следующую вспомогательную функцию:
src/lib/data/cart.ts1export async function removeShippingMethodFromCart(shippingMethodId: string) {2 const headers = {3 ...(await getAuthHeaders()),4 }56 const next = {7 ...(await getCacheOptions("fulfillment")),8 }910 return sdk.client11 .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 на этапе выбора доставки, витрине необходим доступ к данным динамического расчёта, сведениям о пунктах самовывоза и списку доступных перевозчиков. Помимо тарифов и пунктов самовывоза, витрина также получает метаданные провайдера, чтобы отображать понятные пользователю названия перевозчиков в интерфейсе оформления заказа, а не внутренние идентификаторы.
Откройте и добавьте следующие вспомогательные функции:
src/lib/data/fulfillment.ts1import type { ApishipHttpTypes } from "@gorgo/medusa-fulfillment-apiship/types"23type StorefrontApishipPoint = Omit<4 ApishipHttpTypes.StoreApishipPoint,5 "id" | "lat" | "lng" | "worktime"6> & {7 id: string8 lat: number9 lng: number10 worktime?: Record<string, string>11}1213type StorefrontApishipPointListResponse = {14 points: StorefrontApishipPoint[]15}1617type StorefrontApishipCalculation = {18 deliveryToDoor?: Array<{19 providerKey: string20 tariffs?: Array<ApishipHttpTypes.StoreApishipDoorTariff>21 }>22 deliveryToPoint?: Array<{23 providerKey: string24 tariffs?: Array<ApishipHttpTypes.StoreApishipPointTariff>25 }>26}2728// ... другие вспомогательные функции2930export const retrieveCalculation = async (31 cartId: string,32 shippingOptionId: string33): Promise<StorefrontApishipCalculation | null> => {34 const headers = {35 ...(await getAuthHeaders()),3637 }3839 const next = {40 ...(await getCacheOptions("fulfillment")),41 }4243 const body = { cart_id: cartId }4445 return sdk.client46 .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 }6061 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 }7273 return [74 {75 providerKey: group.providerKey,76 tariffs: group.tariffs,77 },78 ]79 }),80 }))81 .catch((e) => {82 return null83 })84}8586export const getPointAddresses = async (87 cartId: string,88 shippingOptionId: string,89 pointIds: Array<number>90): Promise<StorefrontApishipPointListResponse | null> => {91 const headers = {92 ...(await getAuthHeaders()),93 }9495 const next = {96 ...(await getCacheOptions("fulfillment")),97 }9899 if (!pointIds.length) {100 return {101 points: [],102 }103 }104105 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}`119120 return sdk.client121 .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 === null144 ) {145 return []146 }147148 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 null162 })163}164165export const retrieveProviders = async (): Promise<ApishipHttpTypes.StoreApishipProviderListResponse | null> => {166 const headers = {167 ...(await getAuthHeaders()),168 }169170 const next = {171 ...(await getCacheOptions("fulfillment")),172 }173174 return sdk.client175 .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 null186 })187}
Запрос возвращает доступные тарифы доставки вместе с идентификаторами пунктов самовывоза, к которым они применимы. Запрос преобразует эти идентификаторы в полные данные о пунктах самовывоза, такие как адреса и координаты, которые затем могут отображаться в интерфейсе оформления заказа.
Кроме того, витрина получает список провайдеров ApiShip, чтобы отображать в интерфейсе удобочитаемые названия перевозчиков.
Интеграция витрины опирается на небольшой набор общих типов, которые описывают данные, возвращаемые эндпоинтом расчёта ApiShip, а также данные, сохраняемые в корзине как выбор доставки пользователя. Эти типы используются в интерфейсе карты, модальных окнах курьерской доставки и пунктов самовывоза, а также в компоненте выбранного варианта, чтобы обеспечить согласованность процесса и типовую безопасность.
Откройте и добавьте следующие типы:
src/modules/checkout/components/shipping/apiship/types/index.ts1import type { ApishipHttpTypes } from "@gorgo/medusa-fulfillment-apiship/types"23export type ApishipCalculation = {4 deliveryToDoor?: Array<{5 providerKey: string6 tariffs?: Array<ApishipHttpTypes.StoreApishipDoorTariff>7 }>8 deliveryToPoint?: Array<{9 providerKey: string10 tariffs?: Array<ApishipHttpTypes.StoreApishipPointTariff>11 }>12}1314export type ApishipTariff = {15 key: string16 providerKey: string17} & (18 ApishipHttpTypes.StoreApishipDoorTariff |19 ApishipHttpTypes.StoreApishipPointTariff20)2122export type ApishipPoint = Omit<23 ApishipHttpTypes.StoreApishipPoint,24 "id" | "lat" | "lng" | "worktime"25> & {26 id: string27 lat: number28 lng: number29 worktime?: Record<string, string>30}3132export type Chosen = {33 deliveryType: number34 tariff: ApishipTariff35 point?: ApishipPoint36}
представляет собой рассчитанные варианты доставки, возвращаемые API витрины, разделённые на группы и по провайдерам. — это нормализованный набор данных, который витрина сохраняет в корзине после того, как пользователь выбирает тариф и пункт самовывоза.
Интеграция с витриной использует набор общих утилит, чтобы поддерживать единообразие интерфейса и избежать дублирования логики в компонентах. Эти вспомогательные функции обрабатывают форматирование времени доставки, стабильные ссылки на колбэки для асинхронных эффектов, детерминированные ключи тарифов для выбора, блокировку прокрутки для модальных окон и преобразование тарифов пунктов самовывоза в структуру, удобную для отображения.
Откройте и добавьте следующие помощники:
src/modules/checkout/components/shipping/apiship/utils/index.ts1import {2 ApishipCalculation,3 ApishipTariff,4} from "../types"5import { useEffect, useRef } from "react"67export function days(tariff: ApishipTariff) {8 const min = tariff.daysMin ?? 09 const max = tariff.daysMax ?? 010 if (!min && !max) return null11 if (min === max) return min === 1 ? `${min} day` : `${min} days`12 return `${min}–${max} days`13}1415export function useLatestRef<T>(value: T) {16 const ref = useRef(value)17 useEffect(() => {18 ref.current = value19 }, [value])20 return ref21}2223export function buildTariffKey (24 providerKey: string,25 tariff: Omit<ApishipTariff, "key" | "providerKey">,26 idx: number27) {28 return `${providerKey}:${tariff.tariffId ?? tariff.tariffProviderId ?? tariff.tariffName ?? idx}`29}3031export function useLockBodyScroll(locked: boolean) {32 useEffect(() => {33 if (!locked) return3435 const body = document.body36 const html = document.documentElement3738 const prevBodyOverflow = body.style.overflow39 const prevBodyPaddingRight = body.style.paddingRight40 const prevHtmlOverflow = html.style.overflow4142 const scrollbarWidth = window.innerWidth - html.clientWidth43 if (scrollbarWidth > 0) {44 body.style.paddingRight = `${scrollbarWidth}px`45 }4647 body.style.overflow = "hidden"48 html.style.overflow = "hidden"4950 return () => {51 body.style.overflow = prevBodyOverflow52 body.style.paddingRight = prevBodyPaddingRight53 html.style.overflow = prevHtmlOverflow54 }55 }, [locked])56}5758export function useAsyncEffect(fn: (isCancelled: () => boolean) => Promise<void>, deps: any[]) {59 useEffect(() => {60 let cancelled = false61 const isCancelled = () => cancelled62 fn(isCancelled).catch((e) => console.error(e))63 return () => { cancelled = true }64 // eslint-disable-next-line react-hooks/exhaustive-deps65 }, deps)66}6768export 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 map81}8283export function extractPointIds(map: Record<string, ApishipTariff[]>) {84 return Object.keys(map).map(Number).filter(Number.isFinite)85}
Эти утилиты используются в модальных окнах и на карте для форматирования времени доставки, сохранения стабильных ключей выбора между рендерами, безопасного выполнения асинхронных эффектов без обновления состояния после размонтирования и корректной подготовки модели данных пунктов самовывоза, используемой для отображения тарифов по каждому пункту.
Этот компонент отвечает за отображение Яндекс Карты, размещение на ней маркеров пунктов самовывоза и показ панели с подробной информацией и доступными тарифами при выборе пункта.
Откройте и добавьте следующее:
src/modules/checkout/components/shipping/apiship/apiship-map.tsx1"use client"23import { 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 ApishipTariff11} from "./types"12import {13 days,14 useLatestRef15} from "./utils"1617const DEFAULT_CENTER: [number, number] = [37.618423, 55.751244]1819type ApishipMapProps = {20 points: ApishipPoint[]21 tariffsByPointId: Record<string, ApishipTariff[]>22 isLoading?: boolean23 selectedPointId: string | null24 selectedTariffKey: string | null25 isPanelOpen: boolean26 onClosePanel: () => void27 onSelectPoint: (id: string) => void28 onSelectTariff: (tariffKey: string) => void29 onChoose: (payload: { point: ApishipPoint; tariff: ApishipTariff }) => void30 chosen?: { pointId?: string; tariffKey?: string } | null31 providersMap: Record<string, string>32}3334const WEEK_DAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] as const3536const Schedule = ({ worktime }: { worktime: Record<string, string> }) => {37 if (!worktime || Object.keys(worktime).length === 0) return null38 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}5758declare global {59 interface Window {60 ymaps3?: any61 __ymaps3_loading_promise__?: Promise<void>62 }63}6465function 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__6869 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()7273 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 = true78 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 })8283 return window.__ymaps3_loading_promise__84}8586export 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 providersMap99}) => {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)104105 const isSameAsChosen =106 !!chosen?.pointId &&107 !!chosen?.tariffKey &&108 chosen.pointId === selectedPointId &&109 chosen.tariffKey === selectedTariffKey110111 const onSelectPointRef = useLatestRef(onSelectPoint)112113 const center = useMemo<[number, number]>(() => {114 const p = points?.[0]115 return p ? [p.lng, p.lat] : DEFAULT_CENTER116 }, [points])117118 const activePoint = useMemo(() => {119 return selectedPointId ? points.find((p) => p.id === selectedPointId) ?? null : null120 }, [points, selectedPointId])121122 const activeTariffs = useMemo(() => {123 if (!selectedPointId) return []124 return tariffsByPointId[selectedPointId] ?? []125 }, [tariffsByPointId, selectedPointId])126127 const selectedTariff = useMemo(() => {128 if (!selectedTariffKey) return null129 return activeTariffs.find((t) => t.key === selectedTariffKey) ?? null130 }, [activeTariffs, selectedTariffKey])131132 const clearMarkers = useCallback(() => {133 const map = mapRef.current134 if (!map) return135 for (const { marker } of markersRef.current.values()) {136 try {137 map.removeChild(marker)138 } catch { }139 }140 markersRef.current.clear()141 }, [])142143 const destroyMap = useCallback(() => {144 clearMarkers()145 try {146 mapRef.current?.destroy?.()147 } catch { }148 mapRef.current = null149 initPromiseRef.current = null150 }, [clearMarkers])151152 const applySelectionStyles = useCallback(() => {153 for (const [id, { el }] of markersRef.current.entries()) {154 const sel = id === selectedPointId155156 el.style.background = sel ? "#3b82f6" : "white"157 el.style.border = sel ? "2px solid #1d4ed8" : "2px solid rgba(0,0,0,0.25)"158159 const dot = el.firstElementChild as HTMLElement | null160 if (dot) {161 dot.style.border = sel162 ? "2px solid rgba(255,255,255,0.95)"163 : "2px solid rgba(0,0,0,0.25)"164 }165 }166 }, [selectedPointId])167168 useEffect(() => {169 if (!containerRef.current || mapRef.current) return170 let cancelled = false171172 initPromiseRef.current = (async () => {173 const apikey = process.env.NEXT_PUBLIC_YANDEX_MAPS_API_KEY174 if (!apikey) throw new Error("NEXT_PUBLIC_YANDEX_MAPS_API_KEY is not set")175176 await ensureYmaps3Loaded({ apikey, lang: "ru_RU" })177 if (cancelled) return178179 const ymaps3 = window.ymaps3180 if (!ymaps3) return181 await ymaps3.ready182 if (cancelled) return183184 ymaps3.import.registerCdn("https://cdn.jsdelivr.net/npm/{package}", "@yandex/ymaps3-default-ui-theme@0.0")185186 const { YMap, YMapDefaultSchemeLayer, YMapDefaultFeaturesLayer } = ymaps3187188 const map = new YMap(containerRef.current!, { location: { center, zoom: 10 } })189 map.addChild(new YMapDefaultSchemeLayer({}))190 map.addChild(new YMapDefaultFeaturesLayer({ zIndex: 1800 }))191192 mapRef.current = map193 })().catch((e) => console.error("Yandex map init failed", e))194195 return () => {196 cancelled = true197 destroyMap()198 }199 // eslint-disable-next-line react-hooks/exhaustive-deps200 }, [destroyMap])201202 useEffect(() => {203 (async () => {204 if (!initPromiseRef.current) return205 await initPromiseRef.current206 try {207 mapRef.current?.setLocation?.({ center, zoom: 10 })208 } catch { }209 })()210 }, [center])211212 useEffect(() => {213 let cancelled = false;214 (async () => {215 if (!initPromiseRef.current) return216 await initPromiseRef.current217 if (cancelled) return218219 const map = mapRef.current220 const ymaps3 = window.ymaps3221 if (!map || !ymaps3) return222223 const { YMapMarker } = ymaps3224225 clearMarkers()226227 for (const p of points) {228 const el = document.createElement("div")229230 const SIZE = 18231232 el.style.width = `${SIZE}px`233 el.style.height = `${SIZE}px`234235 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%"243244 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"255256 el.appendChild(dot)257258 el.title = p.name ?? p.address ?? `Point ${p.id}`259260 el.addEventListener("click", (e) => {261 e.preventDefault()262 e.stopPropagation()263 onSelectPointRef.current(p.id)264 })265266 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 })()272273 return () => {274 cancelled = true275 }276 }, [points, clearMarkers, onSelectPointRef])277278 useEffect(() => {279 applySelectionStyles()280 }, [applySelectionStyles])281282 const showNoPoints = !isLoading && points.length === 0283284 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 <Heading301 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 <IconButton307 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>318319 <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>328329 <div className="flex flex-col gap-[10px]">330 <Text className="font-medium txt-medium text-ui-fg-base">Tariffs</Text>331332 <RadioGroup className="flex flex-col gap-[10px]">333 {activeTariffs.map((t) => {334 const active = t.key === selectedTariffKey335 const cost =336 typeof t.deliveryCostOriginal === "number"337 ? t.deliveryCostOriginal338 : t.deliveryCost339340 return (341 <Radio342 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 <Button375 size="large"376 onClick={() => {377 if (!activePoint || !selectedTariff) return378 onChoose({ point: activePoint, tariff: selectedTariff })379 }}380 disabled={!selectedTariff || isSameAsChosen}381 className="w-full mb-[16px] !overflow-visible"382 >383 Choose384 </Button>385 {activePoint.worktime && (386 <div className="flex flex-col">387 <Text className="font-medium txt-medium text-ui-fg-base">388 Schedule389 </Text>390 <Schedule worktime={activePoint.worktime} />391 </div>392 )}393394 {!!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-element400 <img401 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 )}410411 {activePoint.description && (412 <div className="flex flex-col">413 <Text className="font-medium txt-medium text-ui-fg-base">414 Description415 </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}
Этот компонент инициализирует Яндекс Карты один раз, перемещает центр карты при изменении списка пунктов самовывоза и отображает кликабельные маркеры для каждого доступного пункта. Когда пользователь выбирает пункт, отображается боковая панель, на которой показаны тарифы и их можно выбрать. Интерфейс карты также учитывает ранее сохранённый выбор. Если пользователь снова открывает модальное окно, компонент выделяет уже выбранный пункт самовывоза и тариф и предотвращает повторный выбор той же опции.
Этот компонент отображает компактное резюме выбранного клиентом способа доставки. Оно показывается после того, как пользователь выбрал пункт самовывоза или тариф курьера, чтобы на этапе оформления заказа было ясно, что в данный момент сохранено в корзине.
Откройте и добавьте следующее:
src/modules/checkout/components/shipping/apiship/apiship-chosen.tsx1"use client"23import { Heading, Text } from "@medusajs/ui"4import { Chosen } from "./types"5import { days } from "./utils"67type ApishipChosenProps = {8 chosen: Chosen9 onRemove: () => void10 onEdit: () => void11}1213export const ApishipChosen: React.FC<ApishipChosenProps> = ({14 chosen,15 onRemove,16 onEdit17}) => {18 const cost =19 typeof chosen.tariff.deliveryCostOriginal === "number"20 ? chosen.tariff.deliveryCostOriginal21 : chosen.tariff.deliveryCost2223 const isToPoint = chosen.deliveryType === 22425 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 <button34 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 Remove42 </button>43 </Text>44 <Text>45 <button46 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 Edit54 </button>55 </Text>56 </div>57 </div>5859 {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}
С помощью этого компонента компонента витрина может сразу отображать ранее сохранённый выбор на этапе доставки, включая детали пункта самовывоза и тарифа. Он также позволяет пользователю либо удалить выбор, либо снова открыть модальное окно для его изменения.
Этот компонент реализует отдельное модальное окно, которое позволяет клиенту выбрать пункт самовывоза ApiShip и соответствующий тариф прямо на карте. Модальное окно отвечает за загрузку пунктов самовывоза для выбранного способа доставки, восстановление ранее сохранённого выбора (если он есть) и сохранение нового выбора обратно в корзину после подтверждения клиентом.
Откройте и добавьте следующее:
src/modules/checkout/components/shipping/apiship/apiship-pickup-point-modal.tsx1"use client"23import { 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 Chosen19} from "./types"20import {21 buildTariffsByPointId,22 extractPointIds,23 useAsyncEffect,24 useLatestRef,25 useLockBodyScroll,26} from "./utils"2728type ApishipPickupPointModalProps = {29 open: boolean30 onClose: (cancel?: boolean) => void31 cart: HttpTypes.StoreCart32 shippingOptionId: string | null33 initialChosen?: Chosen | null34 onPriceUpdate?: (shippingOptionId: string, amount: number) => void35 onError?: (message: string) => void36 onChosenChange: (chosen: Chosen | null) => void37 providersMap: Record<string, string>38}3940export const ApishipPickupPointModal: React.FC<ApishipPickupPointModalProps> = ({41 open,42 onClose,43 cart,44 shippingOptionId,45 initialChosen,46 onPriceUpdate,47 onError,48 onChosenChange,49 providersMap50}) => {51 const onErrorRef = useLatestRef(onError)52 const onPriceRef = useLatestRef(onPriceUpdate)5354 const [isLoadingPoints, setIsLoadingPoints] = useState(false)55 const [points, setPoints] = useState<ApishipPoint[]>([])56 const [tariffsByPointId, setTariffsByPointId] = useState<Record<string, ApishipTariff[]>>({})5758 const [selectedPointId, setSelectedPointId] = useState<string | null>(null)59 const [selectedTariffKey, setSelectedTariffKey] = useState<string | null>(null)60 const [isPanelOpen, setIsPanelOpen] = useState(false)6162 useLockBodyScroll(open)6364 useEffect(() => {65 if (!open) return6667 if (initialChosen?.deliveryType === 2) {68 setSelectedPointId(initialChosen.point?.id ?? null)69 setSelectedTariffKey(initialChosen.tariff?.key ?? null)70 setIsPanelOpen(true)71 return72 }7374 setSelectedPointId(null)75 setSelectedTariffKey(null)76 setIsPanelOpen(false)77 }, [open, initialChosen, shippingOptionId])7879 useEffect(() => {80 setSelectedPointId(null)81 setSelectedTariffKey(null)82 setIsPanelOpen(false)83 }, [shippingOptionId])8485 useAsyncEffect(async (isCancelled) => {86 if (!open || !shippingOptionId) return8788 setIsLoadingPoints(true)89 try {90 const calculation = await retrieveCalculation(cart.id, shippingOptionId)91 if (isCancelled()) return92 const tariffsMap = buildTariffsByPointId(calculation)93 setTariffsByPointId(tariffsMap)9495 const pointIds = extractPointIds(tariffsMap)96 if (!pointIds.length) {97 setPoints([])98 return99 }100101 const pointAddresses = await getPointAddresses(102 cart.id,103 shippingOptionId,104 pointIds105 )106 if (isCancelled()) return107108 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])118119 const persistChosen = useCallback(async (next: Chosen) => {120 if (!shippingOptionId) return121122 await setShippingMethod({123 cartId: cart.id,124 shippingMethodId: shippingOptionId,125 data: { apishipData: next },126 })127128 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])133134 if (!open) return null135136 return (137 <div className="fixed inset-0 z-50">138 <div139 className="absolute inset-0 bg-black/50"140 />141 <div142 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 <IconButton149 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 <div156 className="157 relative158 h-[820px] w-[1350px]159 max-w-[calc(100vw-32px)] max-h-[calc(100vh-32px)]160 overflow-hidden rounded-rounded border bg-white161 "162 >163 <ApishipMap164 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 === 2182 ? { pointId: initialChosen.point?.id, tariffKey: initialChosen.tariff?.key }183 : null184 }185 onChoose={async ({ point, tariff }) => {186 const cost =187 typeof tariff.deliveryCostOriginal === "number"188 ? tariff.deliveryCostOriginal189 : tariff.deliveryCost190191 const chosen: Chosen = {192 deliveryType: 2,193 tariff,194 point195 }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}
Это модальное окно блокирует прокрутку основного содержимого страницы, пока оно открыто, получает пункты самовывоза, используя результаты расчёта доставки, и передаёт ранее сохранённый выбор на карту, чтобы интерфейс отображал уже выбранные пользователем параметры. Когда пользователь подтверждает пункт и тариф, выбор сохраняется в данных способа доставки корзины, и пересчитывается обновлённая стоимость.
Этот компонент реализует специальное модальное окно для выбора тарифов курьерской доставки. Оно открывается, когда клиент выбирает курьерскую доставку, и позволяет ему выбрать один из доступных тарифов.
Когда модальное окно открыто, оно восстанавливает ранее выбранный тариф (если он уже был сохранен в корзине), поэтому пользователь сразу видит свой предыдущий выбор. Все доступные тарифы курьерской доставки отображаются в виде группированного списка, и клиент может выбрать один из них с помощью переключателей (radio buttons).
src/modules/checkout/components/shipping/apiship/apiship-courier-modal.tsx1"use client"23import { 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 Chosen15} from "./types"16import {17 buildTariffKey,18 days,19 useLatestRef,20 useLockBodyScroll21} from "./utils"2223type ApishipCourierModalProps = {24 open: boolean25 onClose: (cancel?: boolean) => void26 cart: HttpTypes.StoreCart27 shippingOptionId: string | null28 initialChosen?: Chosen | null29 onPriceUpdate?: (shippingOptionId: string, amount: number) => void30 onError?: (message: string) => void31 onChosenChange: (chosen: Chosen | null) => void32 providersMap: Record<string, string>33}3435export 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)4849 const [isLoading, setIsLoading] = useState(false)50 const [isLoadingCalc, setIsLoadingCalc] = useState(false)5152 const [calculation, setCalculation] = useState<ApishipCalculation | null>(null)53 const [selectedTariffKey, setSelectedTariffKey] = useState<string | null>(null)5455 useLockBodyScroll(open)5657 useEffect(() => {58 if (!open) return5960 if (initialChosen) {61 setSelectedTariffKey(initialChosen.tariff?.key ?? null)62 return63 }6465 setSelectedTariffKey(null)66 }, [open, initialChosen, shippingOptionId])6768 useEffect(() => {69 setSelectedTariffKey(null)70 }, [shippingOptionId])7172 function useAsyncEffect(fn: (isCancelled: () => boolean) => Promise<void>, deps: any[]) {73 useEffect(() => {74 let cancelled = false75 const isCancelled = () => cancelled76 fn(isCancelled).catch((e) => console.error(e))77 return () => { cancelled = true }78 // eslint-disable-next-line react-hooks/exhaustive-deps79 }, deps)80 }8182 useAsyncEffect(async (isCancelled) => {83 if (!open || !shippingOptionId) return8485 setIsLoadingCalc(true)86 setCalculation(null)8788 try {89 const calculation = await retrieveCalculation(cart.id, shippingOptionId)90 if (isCancelled()) return91 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])100101 const doorGroups = useMemo(() => {102 return calculation?.deliveryToDoor ?? []103 }, [calculation])104105 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 full115 })116 )117 }, [doorGroups])118119 const selectedTariff = useMemo(() => {120 if (!selectedTariffKey) return null121 return tariffsFlat.find((t) => t.key === selectedTariffKey) ?? null122 }, [tariffsFlat, selectedTariffKey])123124 const persistChosen = useCallback(async () => {125 if (!shippingOptionId || !selectedTariff) return126127 setIsLoading(true)128 try {129 const next: Chosen = {130 deliveryType: 1,131 tariff: selectedTariff132 }133134 await setShippingMethod({135 cartId: cart.id,136 shippingMethodId: shippingOptionId,137 data: { apishipData: next },138 })139140 const calc = await calculatePriceForShippingOption(shippingOptionId, cart.id)141 if (calc?.id && typeof calc.amount === "number") {142 onPriceRef.current?.(calc.id, calc.amount)143 }144145 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])153154 if (!open) return null155156 return (157 <div className="fixed inset-0 z-50">158 <div159 className="absolute inset-0 bg-black/50"160 />161 <div162 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 <div169 className="170 h-[820px] w-[470px]171 max-w-[calc(100vw-32px)] max-h-[calc(100vh-32px)]172 overflow-hidden rounded-rounded border bg-white173 flex flex-col174 "175 >176 <div className=" flex flex-row justify-between items-center p-[35px] pb-0">177 <Heading178 level="h2"179 className="flex flex-row text-3xl-regular gap-x-2 items-baseline"180 >181 By courier182 </Heading>183 <IconButton184 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 {doorGroups201 .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 <RadioGroup208 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 === selectedTariffKey215216 const cost =217 typeof t.deliveryCostOriginal === "number"218 ? t.deliveryCostOriginal219 : t.deliveryCost220221 return (222 <Radio223 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 <Button257 size="large"258 onClick={persistChosen}259 isLoading={isLoading}260 disabled={!selectedTariffKey || isLoadingCalc || selectedTariff?.key === initialChosen?.tariff.key}261 className="w-full"262 >263 Choose264 </Button>265 </div>266 </div>267 </div>268 </div>269 </div>270 )271}
После того как клиент подтверждает свой выбор, нажав кнопку , выбранный тариф сохраняется в корзине, стоимость доставки пересчитывается, и модальное окно закрывается.
Все компоненты и вспомогательные функции витрины ApiShip экспортируются через файл-коллектор, который служит единым входным пунктом для интеграции. Он повторно экспортирует основные элементы интерфейса, а также общие типы и утилиты, чтобы другие части витрины могли импортировать всё из одного места, вместо того чтобы ссылаться на внутренние пути файлов.
Откройте и добавьте следующее:
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»
Это позволяет поддерживать импорты чистыми и согласованными, а также упрощает дальнейшее обслуживание или рефакторинг интеграции без изменения путей импорта по всей витрине.
Для поддержки рассчитанных способов доставки ApiShip этап доставки требует дополнительного пользовательского потока, где клиент выбирает конкретный тариф и, при необходимости, пункт самовывоза. Это обновление добавляет специфическое для ApiShip состояние и модальные окна, загружает названия провайдеров для отображения и сохраняет окончательный выбор в корзине, чтобы оформление заказа могло продолжиться только после того, как клиент сделал корректный выбор.
Откройте и добавьте следующее:
1// ... другие imports2import { 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 days10} from "./apiship"1112// ...1314const Shipping: React.FC<ShippingProps> = ({15 cart,16 availableShippingMethods,17}) => {1819 // ... другие states2021 // добавить следующие states22 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)2627 // ...2829 // добавить следующее30 const isApishipCalculated = (option?: HttpTypes.StoreCartShippingOption | null) =>31 option?.price_type === "calculated" && option?.provider_id === "apiship_apiship"3233 const isApishipToDoor = (option?: HttpTypes.StoreCartShippingOption | null) =>34 isApishipCalculated(option) && option?.data?.deliveryType === 13536 const isApishipToPoint = (option?: HttpTypes.StoreCartShippingOption | null) =>37 isApishipCalculated(option) && option?.data?.deliveryType === 23839 const activeShippingOption = useMemo(() => {40 return _shippingMethods?.find((option) => option.id === shippingMethodId) ?? null41 }, [_shippingMethods, shippingMethodId])4243 const apishipMode = useMemo<"point" | "door" | null>(() => {44 if (!isOpen || !shippingMethodId) return null45 if (isApishipToPoint(activeShippingOption)) return "point"46 if (isApishipToDoor(activeShippingOption)) return "door"47 return null48 }, [isOpen, shippingMethodId, activeShippingOption])4950 useEffect(() => {51 setApishipChosen(cart.shipping_methods?.at(-1)?.data?.apishipData ?? null)52 }, [cart.shipping_methods])5354 useEffect(() => {55 if (!isOpen) return5657 if (!apishipMode) {58 setApishipPickupPointModalOpen(false)59 setApishipCourierModalOpen(false)60 setApishipChosen(null)61 return62 }63 const chosenMode =64 apishipChosen?.deliveryType === 2 ? "point"65 : apishipChosen?.deliveryType === 1 ? "door"66 : null6768 const hasValidChosen = !!apishipChosen && chosenMode === apishipMode69 if (hasValidChosen) {70 setApishipPickupPointModalOpen(false)71 setApishipCourierModalOpen(false)72 return73 }74 if (apishipMode === "point") {75 setApishipPickupPointModalOpen(true)76 setApishipCourierModalOpen(false)77 } else {78 setApishipCourierModalOpen(true)79 setApishipPickupPointModalOpen(false)80 }81 }, [isOpen, apishipMode, apishipChosen])8283 useEffect(() => {84 let cancelled = false8586 ; (async () => {87 const response = await retrieveProviders()88 const providers = response?.providers89 if (cancelled) return9091 const map: Record<string, string> = {}92 for (const provider of providers ?? []) map[provider.key] = provider.name93 setProvidersMap(map)94 })()9596 return () => { cancelled = true }97 }, [])9899 useEffect(() => {100 if (!isOpen) return101 if (!apishipChosen) return102103 if (!apishipMode) {104 setApishipChosen(null)105 return106 }107108 const chosenMode = apishipChosen.deliveryType === 2 ? "point" : "door"109 if (chosenMode !== apishipMode) {110 setApishipChosen(null)111 }112 }, [isOpen, apishipMode, apishipChosen])113114 // ... другие effects115116 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 <RadioGroup127 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"139140 return (141 <Radio142 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 <ApishipPickupPointModal187 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 <ApishipCourierModal206 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 <ApishipChosen226 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>242243 {/* ... */}244245 <div>246 {/* ... */}247 <Button248 // ... другие свойства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 && !apishipChosen256 )257 }258 >259 Continue to payment260 </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 Method271 </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.deliveryCostOriginal287 : apishipChosen.tariff.deliveryCost) === "number"288 ? `RUB ${(typeof apishipChosen.tariff.deliveryCostOriginal === "number"289 ? apishipChosen.tariff.deliveryCostOriginal290 : 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}306307export default Shipping
Это изменение добавляет два отдельных модальных окна для ApiShip (выбор пункта самовывоза и выбор тарифа курьера) и небольшой блок с резюме, который отображает текущий сохранённый выбор с действиями Изменить и Удалить. Оно также обеспечивает следующее:
Вы можете ознакомиться с изменениями, внесенными в стартовый шаблон Medusa Next.js Starter Template, в директории .
Полный код интеграции можно посмотреть в разделе comparison page и изучите различия в каталоге . Или запустите в терминале:
Terminal1git clone https://github.com/gorgojs/medusa-plugins2cd medusa-plugins3git diff @gorgo/medusa-fulfillment-apiship@0.0.1...main -- examples/fulfillment-apiship/medusa-storefront