Search for a command to run...
Чтобы интегрировать провайдера фулфилмента ApiShip в storefront (витрину магазина) на Next.js, необходимо расширить шаг выбора доставки в оформлении заказа (checkout), добавив поддержку доставки в пункты выдачи.
Доставка ApiShip отличается от обычных способов доставки тем, что во время оформления заказа необходимо собрать дополнительные данные, такие как выбранный пункт выдачи и тариф доставки. Эти данные должны динамически загружаться с бэкенда, отображаться покупателю в удобной форме, а затем сохраняться в корзине, чтобы впоследствии провайдер фулфилмента мог использовать их при создании заказа в ApiShip.
Селектор пункта выдачи использует JavaScript API Яндекс Карт v3. Чтобы включить карту в витрине, необходимо задать публичный API-ключ через переменные окружения.
Добавьте следующее в файл вашей витрины:
NEXT_PUBLIC_YANDEX_MAPS_API_KEY=supersecret
Для витрины на Next.js необходимо внести следующие изменения:
ApiShip требует номер телефона получателя для расчета стоимости доставки и создания заказа в ApiShip. Поэтому витрина должна требовать обязательное указание номера телефона в форме адреса доставки.
Откройте и сделайте поле ввода телефона обязательным:
<Inputlabel="Phone"name="shipping_address.phone"autoComplete="tel"value={formData["shipping_address.phone"]}onChange={handleChange}required // Обязательно для ApiShipdata-testid="shipping-phone-input"/>
Отметив поле ввода телефона как , вы обеспечите, что номер телефона получателя будет собираться на раннем этапе оформления заказа. Это гарантирует, что при расчёте доставки и создании отправления в ApiShip будут доступны необходимые данные.
Для интеграции ApiShip в процесс оформления заказа, витрина должна иметь возможность сохранять выбор доставки клиента в корзине и получать его позже. Это обеспечивает стабильность процесса при обновлении страницы и навигации. Если клиент уже выбрал тариф и пункт самовывоза, витрина может восстановить этот выбор вместо того, чтобы снова просить его это сделать. Также требуется корректное действие по удалению, которое очищает выбор не только визуально, но и в самой корзине.
Откройте и измените следующее:
import type { ApishipHttpTypes } from "@gorgo/medusa-fulfillment-apiship/types"export async function retrieveCart(cartId?: string, fields?: string) {// ...// Добавьте параметр +shipping_methods.datafields ??= "*items, *region, *items.product, *items.variant, *items.thumbnail, *items.metadata, +items.total, *promotions, +shipping_methods.name, +shipping_methods.data"// ...}export async function setShippingMethod({// ...data,}: {// ...data?: Record<string, unknown>}) {// ...return sdk.store.cart.addShippingMethod(cartId,{// ...data // Данные ApiShip},// ...)// ...}
Также добавьте следующую вспомогательную функцию:
export async function removeShippingMethodFromCart(shippingMethodId: string) {const headers = {...(await getAuthHeaders()),}const next = {...(await getCacheOptions("fulfillment")),}return sdk.client.fetch<ApishipHttpTypes.DeleteResponse<"shipping_method">>(`/store/shipping-methods/${shippingMethodId}`,{method: "DELETE",headers,next,}).then(async () => {const cartCacheTag = await getCacheTag("carts")revalidateTag(cartCacheTag)}).catch(() => null)}
Эти изменения обеспечивают три основных функции:
Чтобы корректно отображать варианты доставки ApiShip на этапе выбора доставки, витрине необходим доступ к данным динамического расчёта, сведениям о пунктах самовывоза и списку доступных перевозчиков. Помимо тарифов и пунктов самовывоза, витрина также получает метаданные провайдера, чтобы отображать понятные пользователю названия перевозчиков в интерфейсе оформления заказа, а не внутренние идентификаторы.
Откройте и добавьте следующие вспомогательные функции:
import type { ApishipHttpTypes } from "@gorgo/medusa-fulfillment-apiship/types"type StorefrontApishipPoint = Omit<ApishipHttpTypes.StoreApishipPoint,"id" | "lat" | "lng" | "worktime"> & {id: stringlat: numberlng: numberworktime?: Record<string, string>}type StorefrontApishipPointListResponse = {points: StorefrontApishipPoint[]}type StorefrontApishipCalculation = {deliveryToDoor?: Array<{providerKey: stringtariffs?: Array<ApishipHttpTypes.StoreApishipDoorTariff>}>deliveryToPoint?: Array<{providerKey: stringtariffs?: Array<ApishipHttpTypes.StoreApishipPointTariff>}>}// ... другие вспомогательные функцииexport const retrieveCalculation = async (cartId: string,shippingOptionId: string): Promise<StorefrontApishipCalculation | null> => {const headers = {...(await getAuthHeaders()),}const next = {...(await getCacheOptions("fulfillment")),}const body = { cart_id: cartId }return sdk.client.fetch<ApishipHttpTypes.StoreApishipCalculationResponse>(`/store/apiship/${shippingOptionId}/calculate`,{method: "POST",headers,body,next,}).then(({ calculation }) => ({deliveryToDoor: (calculation.deliveryToDoor ?? []).flatMap((group) => {if (!group.providerKey) {return []}return [{providerKey: group.providerKey,tariffs: group.tariffs,},]}),deliveryToPoint: (calculation.deliveryToPoint ?? []).flatMap((group) => {if (!group.providerKey) {return []}return [{providerKey: group.providerKey,tariffs: group.tariffs,},]}),})).catch((e) => {return null})}export const getPointAddresses = async (cartId: string,shippingOptionId: string,pointIds: Array<number>): Promise<StorefrontApishipPointListResponse | null> => {const headers = {...(await getAuthHeaders()),}const next = {...(await getCacheOptions("fulfillment")),}if (!pointIds.length) {return {points: [],}}const filter = `id=[${pointIds.join(",")}]`const fields = ["id","description","providerKey","name","address","photos","worktime","timetable","lat","lng",].join(",")const key = `apiship:points:${cartId}:${shippingOptionId}`return sdk.client.fetch<ApishipHttpTypes.StoreApishipPointListResponse>(`/store/apiship/points`,{method: "GET",headers,query: {key,filter,fields,limit: 0,},next,}).then(({ points }) => ({points: (points ?? []).flatMap((point) => {if (point.id === undefined ||point.id === null ||point.lat === undefined ||point.lat === null ||point.lng === undefined ||point.lng === null) {return []}return [{...point,id: String(point.id),lat: point.lat,lng: point.lng,worktime: point.worktime as Record<string, string> | undefined,},]}),})).catch((e) => {console.error("getPointsAddresses error", e)return null})}export const retrieveProviders = async (): Promise<ApishipHttpTypes.StoreApishipProviderListResponse | null> => {const headers = {...(await getAuthHeaders()),}const next = {...(await getCacheOptions("fulfillment")),}return sdk.client.fetch<ApishipHttpTypes.StoreApishipProviderListResponse>(`/store/apiship/providers`,{method: "GET",headers,next,}).catch((e) => {console.error("retrieveProviders error", e)return null})}
Запрос возвращает доступные тарифы доставки вместе с идентификаторами пунктов самовывоза, к которым они применимы. Запрос преобразует эти идентификаторы в полные данные о пунктах самовывоза, такие как адреса и координаты, которые затем могут отображаться в интерфейсе оформления заказа.
Кроме того, витрина получает список провайдеров ApiShip, чтобы отображать в интерфейсе удобочитаемые названия перевозчиков.
Интеграция витрины опирается на небольшой набор общих типов, которые описывают данные, возвращаемые эндпоинтом расчёта ApiShip, а также данные, сохраняемые в корзине как выбор доставки пользователя. Эти типы используются в интерфейсе карты, модальных окнах курьерской доставки и пунктов самовывоза, а также в компоненте выбранного варианта, чтобы обеспечить согласованность процесса и типовую безопасность.
Откройте и добавьте следующие типы:
import type { ApishipHttpTypes } from "@gorgo/medusa-fulfillment-apiship/types"export type ApishipCalculation = {deliveryToDoor?: Array<{providerKey: stringtariffs?: Array<ApishipHttpTypes.StoreApishipDoorTariff>}>deliveryToPoint?: Array<{providerKey: stringtariffs?: Array<ApishipHttpTypes.StoreApishipPointTariff>}>}export type ApishipTariff = {key: stringproviderKey: string} & (ApishipHttpTypes.StoreApishipDoorTariff |ApishipHttpTypes.StoreApishipPointTariff)export type ApishipPoint = Omit<ApishipHttpTypes.StoreApishipPoint,"id" | "lat" | "lng" | "worktime"> & {id: stringlat: numberlng: numberworktime?: Record<string, string>}export type Chosen = {deliveryType: numbertariff: ApishipTariffpoint?: ApishipPoint}
представляет собой рассчитанные варианты доставки, возвращаемые API витрины, разделённые на группы и по провайдерам. — это нормализованный набор данных, который витрина сохраняет в корзине после того, как пользователь выбирает тариф и пункт самовывоза.
Интеграция с витриной использует набор общих утилит, чтобы поддерживать единообразие интерфейса и избежать дублирования логики в компонентах. Эти вспомогательные функции обрабатывают форматирование времени доставки, стабильные ссылки на колбэки для асинхронных эффектов, детерминированные ключи тарифов для выбора, блокировку прокрутки для модальных окон и преобразование тарифов пунктов самовывоза в структуру, удобную для отображения.
Откройте и добавьте следующие помощники:
import {ApishipCalculation,ApishipTariff,} from "../types"import { useEffect, useRef } from "react"export function days(tariff: ApishipTariff) {const min = tariff.daysMin ?? 0const max = tariff.daysMax ?? 0if (!min && !max) return nullif (min === max) return min === 1 ? `${min} day` : `${min} days`return `${min}–${max} days`}export function useLatestRef<T>(value: T) {const ref = useRef(value)useEffect(() => {ref.current = value}, [value])return ref}export function buildTariffKey (providerKey: string,tariff: Omit<ApishipTariff, "key" | "providerKey">,idx: number) {return `${providerKey}:${tariff.tariffId ?? tariff.tariffProviderId ?? tariff.tariffName ?? idx}`}export function useLockBodyScroll(locked: boolean) {useEffect(() => {if (!locked) returnconst body = document.bodyconst html = document.documentElementconst prevBodyOverflow = body.style.overflowconst prevBodyPaddingRight = body.style.paddingRightconst prevHtmlOverflow = html.style.overflowconst scrollbarWidth = window.innerWidth - html.clientWidthif (scrollbarWidth > 0) {body.style.paddingRight = `${scrollbarWidth}px`}body.style.overflow = "hidden"html.style.overflow = "hidden"return () => {body.style.overflow = prevBodyOverflowbody.style.paddingRight = prevBodyPaddingRighthtml.style.overflow = prevHtmlOverflow}}, [locked])}export function useAsyncEffect(fn: (isCancelled: () => boolean) => Promise<void>, deps: any[]) {useEffect(() => {let cancelled = falseconst isCancelled = () => cancelledfn(isCancelled).catch((e) => console.error(e))return () => { cancelled = true }// eslint-disable-next-line react-hooks/exhaustive-deps}, deps)}export function buildTariffsByPointId(calculation?: ApishipCalculation | null) {const map: Record<string, ApishipTariff[]> = {}calculation?.deliveryToPoint?.forEach(({ providerKey, tariffs }) => {tariffs?.forEach((tariff) => {for (const pointId of tariff.pointIds ?? []) {const key = `${providerKey}:${tariff.tariffProviderId ?? ""}:${tariff.tariffId ?? ""}`const entry: ApishipTariff = { key, providerKey, ...tariff }const arr = (map[String(pointId)] ??= [])if (!arr.some((t) => t.key === key)) arr.push(entry)}})})return map}export function extractPointIds(map: Record<string, ApishipTariff[]>) {return Object.keys(map).map(Number).filter(Number.isFinite)}
Эти утилиты используются в модальных окнах и на карте для форматирования времени доставки, сохранения стабильных ключей выбора между рендерами, безопасного выполнения асинхронных эффектов без обновления состояния после размонтирования и корректной подготовки модели данных пунктов самовывоза, используемой для отображения тарифов по каждому пункту.
Этот компонент отвечает за отображение Яндекс Карты, размещение на ней маркеров пунктов самовывоза и показ панели с подробной информацией и доступными тарифами при выборе пункта.
Откройте и добавьте следующее:
"use client"import { useCallback, useEffect, useMemo, useRef } from "react"import { Button, Heading, Text, clx, IconButton } from "@medusajs/ui"import { Loader, XMark } from "@medusajs/icons"import { Radio, RadioGroup } from "@headlessui/react"import MedusaRadio from "@modules/common/components/radio"import {ApishipPoint,ApishipTariff} from "./types"import {days,useLatestRef} from "./utils"const DEFAULT_CENTER: [number, number] = [37.618423, 55.751244]type ApishipMapProps = {points: ApishipPoint[]tariffsByPointId: Record<string, ApishipTariff[]>isLoading?: booleanselectedPointId: string | nullselectedTariffKey: string | nullisPanelOpen: booleanonClosePanel: () => voidonSelectPoint: (id: string) => voidonSelectTariff: (tariffKey: string) => voidonChoose: (payload: { point: ApishipPoint; tariff: ApishipTariff }) => voidchosen?: { pointId?: string; tariffKey?: string } | nullprovidersMap: Record<string, string>}const WEEK_DAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] as constconst Schedule = ({ worktime }: { worktime: Record<string, string> }) => {if (!worktime || Object.keys(worktime).length === 0) return nullreturn (<div className="flex flex-col gap-[1px]">{Object.keys(worktime).map((day) => {const label = WEEK_DAYS[Number(day) - 1]const time = worktime[day]return (<div className="flex flex-row justify-between" key={day}><Text className="txt-medium text-ui-fg-subtle">{label}</Text><Text className="text-ui-fg-muted">{time}</Text></div>)})}</div>)}declare global {interface Window {ymaps3?: any__ymaps3_loading_promise__?: Promise<void>}}function ensureYmaps3Loaded(params: { apikey: string; lang?: string }): Promise<void> {if (typeof window === "undefined") return Promise.resolve()if (window.__ymaps3_loading_promise__) return window.__ymaps3_loading_promise__window.__ymaps3_loading_promise__ = new Promise<void>((resolve, reject) => {const existing = document.querySelector<HTMLScriptElement>('script[src^="https://api-maps.yandex.ru/v3/"]')if (existing) return resolve()const script = document.createElement("script")script.src = `https://api-maps.yandex.ru/v3/?apikey=${encodeURIComponent(params.apikey)}&lang=${encodeURIComponent(params.lang ?? "ru_RU")}`script.async = truescript.onload = () => resolve()script.onerror = () => reject(new Error(`Failed to load Yandex Maps JS API v3 script: ${script.src}`))document.head.appendChild(script)})return window.__ymaps3_loading_promise__}export const ApishipMap: React.FC<ApishipMapProps> = ({points,tariffsByPointId,isLoading,selectedPointId,selectedTariffKey,isPanelOpen,onClosePanel,onSelectPoint,onSelectTariff,onChoose,chosen,providersMap}) => {const containerRef = useRef<HTMLDivElement | null>(null)const mapRef = useRef<any>(null)const markersRef = useRef<Map<string, { marker: any; el: HTMLDivElement }>>(new Map())const initPromiseRef = useRef<Promise<void> | null>(null)const isSameAsChosen =!!chosen?.pointId &&!!chosen?.tariffKey &&chosen.pointId === selectedPointId &&chosen.tariffKey === selectedTariffKeyconst onSelectPointRef = useLatestRef(onSelectPoint)const center = useMemo<[number, number]>(() => {const p = points?.[0]return p ? [p.lng, p.lat] : DEFAULT_CENTER}, [points])const activePoint = useMemo(() => {return selectedPointId ? points.find((p) => p.id === selectedPointId) ?? null : null}, [points, selectedPointId])const activeTariffs = useMemo(() => {if (!selectedPointId) return []return tariffsByPointId[selectedPointId] ?? []}, [tariffsByPointId, selectedPointId])const selectedTariff = useMemo(() => {if (!selectedTariffKey) return nullreturn activeTariffs.find((t) => t.key === selectedTariffKey) ?? null}, [activeTariffs, selectedTariffKey])const clearMarkers = useCallback(() => {const map = mapRef.currentif (!map) returnfor (const { marker } of markersRef.current.values()) {try {map.removeChild(marker)} catch { }}markersRef.current.clear()}, [])const destroyMap = useCallback(() => {clearMarkers()try {mapRef.current?.destroy?.()} catch { }mapRef.current = nullinitPromiseRef.current = null}, [clearMarkers])const applySelectionStyles = useCallback(() => {for (const [id, { el }] of markersRef.current.entries()) {const sel = id === selectedPointIdel.style.background = sel ? "#3b82f6" : "white"el.style.border = sel ? "2px solid #1d4ed8" : "2px solid rgba(0,0,0,0.25)"const dot = el.firstElementChild as HTMLElement | nullif (dot) {dot.style.border = sel? "2px solid rgba(255,255,255,0.95)": "2px solid rgba(0,0,0,0.25)"}}}, [selectedPointId])useEffect(() => {if (!containerRef.current || mapRef.current) returnlet cancelled = falseinitPromiseRef.current = (async () => {const apikey = process.env.NEXT_PUBLIC_YANDEX_MAPS_API_KEYif (!apikey) throw new Error("NEXT_PUBLIC_YANDEX_MAPS_API_KEY is not set")await ensureYmaps3Loaded({ apikey, lang: "ru_RU" })if (cancelled) returnconst ymaps3 = window.ymaps3if (!ymaps3) returnawait ymaps3.readyif (cancelled) returnymaps3.import.registerCdn("https://cdn.jsdelivr.net/npm/{package}", "@yandex/ymaps3-default-ui-theme@0.0")const { YMap, YMapDefaultSchemeLayer, YMapDefaultFeaturesLayer } = ymaps3const map = new YMap(containerRef.current!, { location: { center, zoom: 10 } })map.addChild(new YMapDefaultSchemeLayer({}))map.addChild(new YMapDefaultFeaturesLayer({ zIndex: 1800 }))mapRef.current = map})().catch((e) => console.error("Yandex map init failed", e))return () => {cancelled = truedestroyMap()}// eslint-disable-next-line react-hooks/exhaustive-deps}, [destroyMap])useEffect(() => {(async () => {if (!initPromiseRef.current) returnawait initPromiseRef.currenttry {mapRef.current?.setLocation?.({ center, zoom: 10 })} catch { }})()}, [center])useEffect(() => {let cancelled = false;(async () => {if (!initPromiseRef.current) returnawait initPromiseRef.currentif (cancelled) returnconst map = mapRef.currentconst ymaps3 = window.ymaps3if (!map || !ymaps3) returnconst { YMapMarker } = ymaps3clearMarkers()for (const p of points) {const el = document.createElement("div")const SIZE = 18el.style.width = `${SIZE}px`el.style.height = `${SIZE}px`el.style.background = "white"el.style.border = "2px solid rgba(0,0,0,0.25)"el.style.borderRadius = "50% 50% 50% 0"el.style.transform = "rotate(-45deg)"el.style.boxShadow = "0 2px 2px rgba(0,0,0,0.18)"el.style.cursor = "pointer"el.style.position = "relative"el.style.transformOrigin = "50% 50%"const dot = document.createElement("div")dot.style.width = "8px"dot.style.height = "8px"dot.style.background = "white"dot.style.border = "2px solid rgba(0,0,0,0.25)"dot.style.borderRadius = "9999px"dot.style.position = "absolute"dot.style.left = "50%"dot.style.top = "50%"dot.style.transform = "translate(-50%, -50%) rotate(45deg)"dot.style.boxSizing = "border-box"el.appendChild(dot)el.title = p.name ?? p.address ?? `Point ${p.id}`el.addEventListener("click", (e) => {e.preventDefault()e.stopPropagation()onSelectPointRef.current(p.id)})const marker = new YMapMarker({ coordinates: [p.lng, p.lat] }, el)map.addChild(marker)markersRef.current.set(p.id, { marker, el })}applySelectionStyles()})()return () => {cancelled = true}}, [points, clearMarkers, onSelectPointRef])useEffect(() => {applySelectionStyles()}, [applySelectionStyles])const showNoPoints = !isLoading && points.length === 0return (<div className="relative w-full h-full"><div ref={containerRef} className="w-full h-full" />{isLoading && (<div className="absolute inset-0 bg-white/80 flex items-center justify-center"><Loader /></div>)}{showNoPoints && (<div className="absolute inset-0 bg-white/80 flex items-center justify-center"><Text className="text-ui-fg-muted">No pickup points found for this shipping method.</Text></div>)}{!isLoading && activePoint && isPanelOpen && !showNoPoints && (<div className="absolute left-0 top-0 z-[70] w-full md:w-[470px] h-full border-r bg-white flex flex-col min-h-0"><div className="flex flex-row justify-between p-[35px] pb-0 items-center"><Headinglevel="h2"className="flex flex-row text-3xl-regular gap-x-2 items-baseline">{`${providersMap?.[activePoint.providerKey ?? ""] ?? ""} pickup point`.trimStart()}</Heading><IconButtonaria-label="Close"onClick={(e) => {e.preventDefault()e.stopPropagation()onClosePanel()}}className="shadow-none"><XMark /></IconButton></div><div className="flex-1 min-h-0 overflow-y-auto p-[35px] pt-[18px] flex flex-col gap-[18px]"><div className="flex flex-col"><Text className="font-medium txt-medium text-ui-fg-base">{activePoint.name}</Text><Text className="text-ui-fg-muted txt-medium">{activePoint.address}</Text></div><div className="flex flex-col gap-[10px]"><Text className="font-medium txt-medium text-ui-fg-base">Tariffs</Text><RadioGroup className="flex flex-col gap-[10px]">{activeTariffs.map((t) => {const active = t.key === selectedTariffKeyconst cost =typeof t.deliveryCostOriginal === "number"? t.deliveryCostOriginal: t.deliveryCostreturn (<Radiokey={t.tariffId}value={t.tariffId}data-testid="delivery-option-radio"onClick={() => {onSelectTariff(t.key)}}className={clx("flex items-center justify-between text-small-regular cursor-pointer py-2 border rounded-rounded pl-2 pr-3 hover:shadow-borders-interactive-with-active",{ "border-ui-border-interactive": active })}><div className="flex gap-2 w-full"><MedusaRadio checked={active} /><div className="flex flex-row w-full items-center justify-between"><div className="flex flex-col"><span className="txt-compact-small-plus">{t.tariffName}</span><span className="txt-small text-ui-fg-subtle">Delivery time: {days(t)}</span></div><span className="txt-small-plus text-ui-fg-subtle">{`RUB ${cost}`}</span></div></div></Radio>)})}</RadioGroup></div><Buttonsize="large"onClick={() => {if (!activePoint || !selectedTariff) returnonChoose({ point: activePoint, tariff: selectedTariff })}}disabled={!selectedTariff || isSameAsChosen}className="w-full mb-[16px] !overflow-visible">Choose</Button>{activePoint.worktime && (<div className="flex flex-col"><Text className="font-medium txt-medium text-ui-fg-base">Schedule</Text><Schedule worktime={activePoint.worktime} /></div>)}{!!activePoint.photos?.length && (<div className="flex flex-col gap-[10px]"><Text className="font-medium txt-medium text-ui-fg-base">Photos</Text><div className="flex flex-row gap-[10px] overflow-x-auto">{activePoint.photos.map((src, index) => (// eslint-disable-next-line @next/next/no-img-element<imgkey={index}src={src}alt={`Photo ${index + 1} of pickup point`}className="w-auto h-[120px] rounded-md border object-cover"/>))}</div></div>)}{activePoint.description && (<div className="flex flex-col"><Text className="font-medium txt-medium text-ui-fg-base">Description</Text><Text className="text-ui-fg-muted txt-medium">{activePoint.description}</Text></div>)}</div></div>)}</div>)}
Этот компонент инициализирует Яндекс Карты один раз, перемещает центр карты при изменении списка пунктов самовывоза и отображает кликабельные маркеры для каждого доступного пункта. Когда пользователь выбирает пункт, отображается боковая панель, на которой показаны тарифы и их можно выбрать. Интерфейс карты также учитывает ранее сохранённый выбор. Если пользователь снова открывает модальное окно, компонент выделяет уже выбранный пункт самовывоза и тариф и предотвращает повторный выбор той же опции.
Этот компонент отображает компактное резюме выбранного клиентом способа доставки. Оно показывается после того, как пользователь выбрал пункт самовывоза или тариф курьера, чтобы на этапе оформления заказа было ясно, что в данный момент сохранено в корзине.
Откройте и добавьте следующее:
"use client"import { Heading, Text } from "@medusajs/ui"import { Chosen } from "./types"import { days } from "./utils"type ApishipChosenProps = {chosen: ChosenonRemove: () => voidonEdit: () => void}export const ApishipChosen: React.FC<ApishipChosenProps> = ({chosen,onRemove,onEdit}) => {const cost =typeof chosen.tariff.deliveryCostOriginal === "number"? chosen.tariff.deliveryCostOriginal: chosen.tariff.deliveryCostconst isToPoint = chosen.deliveryType === 2return (<div className="flex flex-col gap-4 mt-[32px]"><div className="flex flex-row justify-between"><Heading level="h2" className="txt-xlarge">{isToPoint ? "To the Pickup Point" : "By Courier"}</Heading><div className="flex flex-row gap-[16px]"><Text><buttononClick={(e) => {e.preventDefault()e.stopPropagation()onRemove()}}className="text-ui-fg-interactive hover:text-ui-fg-interactive-hover">Remove</button></Text><Text><buttononClick={(e) => {e.preventDefault()e.stopPropagation()onEdit()}}className="text-ui-fg-interactive hover:text-ui-fg-interactive-hover">Edit</button></Text></div></div>{isToPoint ? (<div className="flex flex-col gap-4"><div className="flex flex-col gap-4 w-[60%]"><div className="flex flex-col"><Text>{chosen.point?.name}</Text>{chosen.point?.address && (<Text className="text-ui-fg-muted">{chosen.point.address}</Text>)}{chosen.point?.timetable && (<Text className="text-ui-fg-muted">{chosen.point.timetable}</Text>)}</div>{chosen.point?.description && (<Text className="text-ui-fg-muted leading-none">{chosen.point.description}</Text>)}</div><Text>{chosen.tariff.tariffName} · {`RUB ${cost ?? "—"}`}{days(chosen.tariff) ? ` · ${days(chosen.tariff)}` : ""}</Text></div>) : (<Text>{[chosen?.tariff?.tariffName,typeof cost === "number" ? `RUB ${cost}` : "RUB —",days?.(chosen?.tariff) || null,].filter(Boolean).join(" · ")}</Text>)}</div>)}
С помощью этого компонента компонента витрина может сразу отображать ранее сохранённый выбор на этапе доставки, включая детали пункта самовывоза и тарифа. Он также позволяет пользователю либо удалить выбор, либо снова открыть модальное окно для его изменения.
Этот компонент реализует отдельное модальное окно, которое позволяет клиенту выбрать пункт самовывоза ApiShip и соответствующий тариф прямо на карте. Модальное окно отвечает за загрузку пунктов самовывоза для выбранного способа доставки, восстановление ранее сохранённого выбора (если он есть) и сохранение нового выбора обратно в корзину после подтверждения клиентом.
Откройте и добавьте следующее:
"use client"import { useCallback, useEffect, useRef, useState } from "react"import { IconButton } from "@medusajs/ui"import { XMark } from "@medusajs/icons"import { HttpTypes } from "@medusajs/types"import { setShippingMethod } from "@lib/data/cart"import {calculatePriceForShippingOption,retrieveCalculation,getPointAddresses,} from "@lib/data/fulfillment"import { ApishipMap } from "./apiship-map"import {ApishipCalculation,ApishipPoint,ApishipTariff,Chosen} from "./types"import {buildTariffsByPointId,extractPointIds,useAsyncEffect,useLatestRef,useLockBodyScroll,} from "./utils"type ApishipPickupPointModalProps = {open: booleanonClose: (cancel?: boolean) => voidcart: HttpTypes.StoreCartshippingOptionId: string | nullinitialChosen?: Chosen | nullonPriceUpdate?: (shippingOptionId: string, amount: number) => voidonError?: (message: string) => voidonChosenChange: (chosen: Chosen | null) => voidprovidersMap: Record<string, string>}export const ApishipPickupPointModal: React.FC<ApishipPickupPointModalProps> = ({open,onClose,cart,shippingOptionId,initialChosen,onPriceUpdate,onError,onChosenChange,providersMap}) => {const onErrorRef = useLatestRef(onError)const onPriceRef = useLatestRef(onPriceUpdate)const [isLoadingPoints, setIsLoadingPoints] = useState(false)const [points, setPoints] = useState<ApishipPoint[]>([])const [tariffsByPointId, setTariffsByPointId] = useState<Record<string, ApishipTariff[]>>({})const [selectedPointId, setSelectedPointId] = useState<string | null>(null)const [selectedTariffKey, setSelectedTariffKey] = useState<string | null>(null)const [isPanelOpen, setIsPanelOpen] = useState(false)useLockBodyScroll(open)useEffect(() => {if (!open) returnif (initialChosen?.deliveryType === 2) {setSelectedPointId(initialChosen.point?.id ?? null)setSelectedTariffKey(initialChosen.tariff?.key ?? null)setIsPanelOpen(true)return}setSelectedPointId(null)setSelectedTariffKey(null)setIsPanelOpen(false)}, [open, initialChosen, shippingOptionId])useEffect(() => {setSelectedPointId(null)setSelectedTariffKey(null)setIsPanelOpen(false)}, [shippingOptionId])useAsyncEffect(async (isCancelled) => {if (!open || !shippingOptionId) returnsetIsLoadingPoints(true)try {const calculation = await retrieveCalculation(cart.id, shippingOptionId)if (isCancelled()) returnconst tariffsMap = buildTariffsByPointId(calculation)setTariffsByPointId(tariffsMap)const pointIds = extractPointIds(tariffsMap)if (!pointIds.length) {setPoints([])return}const pointAddresses = await getPointAddresses(cart.id,shippingOptionId,pointIds)if (isCancelled()) returnsetPoints(pointAddresses?.points ?? [])} catch (e: any) {console.error(e)onErrorRef.current?.(e?.message ?? "Failed to load pickup points")setPoints([])setTariffsByPointId({})} finally {if (!isCancelled()) setIsLoadingPoints(false)}}, [open, shippingOptionId, cart.id])const persistChosen = useCallback(async (next: Chosen) => {if (!shippingOptionId) returnawait setShippingMethod({cartId: cart.id,shippingMethodId: shippingOptionId,data: { apishipData: next },})const calculation = await calculatePriceForShippingOption(shippingOptionId, cart.id)if (calculation?.id && typeof calculation.amount === "number") {onPriceRef.current?.(calculation.id, calculation.amount)}}, [cart.id, shippingOptionId, onPriceRef])if (!open) return nullreturn (<div className="fixed inset-0 z-50"><divclassName="absolute inset-0 bg-black/50"/><divclassName="absolute inset-0 flex items-center justify-center p-4"onClick={(e) => {if (e.target === e.currentTarget) onClose(initialChosen ? false : true)}}><div className="relative"><IconButtonaria-label="Close"onClick={() => onClose(initialChosen ? false : true)}className="absolute right-2 top-2 z-[60] shadow-none"><XMark /></IconButton><divclassName="relativeh-[820px] w-[1350px]max-w-[calc(100vw-32px)] max-h-[calc(100vh-32px)]overflow-hidden rounded-rounded border bg-white"><ApishipMappoints={points}tariffsByPointId={tariffsByPointId}isLoading={isLoadingPoints}selectedPointId={selectedPointId}selectedTariffKey={selectedTariffKey}isPanelOpen={isPanelOpen}onClosePanel={() => {setIsPanelOpen(false)setSelectedPointId(null)}}onSelectPoint={(pid) => {setSelectedPointId(pid)setSelectedTariffKey(null)setIsPanelOpen(true)}}onSelectTariff={(key) => setSelectedTariffKey(key)}chosen={initialChosen?.deliveryType === 2? { pointId: initialChosen.point?.id, tariffKey: initialChosen.tariff?.key }: null}onChoose={async ({ point, tariff }) => {const cost =typeof tariff.deliveryCostOriginal === "number"? tariff.deliveryCostOriginal: tariff.deliveryCostconst chosen: Chosen = {deliveryType: 2,tariff,point}try {await persistChosen(chosen)onChosenChange(chosen)onClose()} catch (e: any) {onErrorRef.current?.(e?.message ?? "Failed to save the selected tariff")}}}providersMap={providersMap}/></div></div></div></div>)}
Это модальное окно блокирует прокрутку основного содержимого страницы, пока оно открыто, получает пункты самовывоза, используя результаты расчёта доставки, и передаёт ранее сохранённый выбор на карту, чтобы интерфейс отображал уже выбранные пользователем параметры. Когда пользователь подтверждает пункт и тариф, выбор сохраняется в данных способа доставки корзины, и пересчитывается обновлённая стоимость.
Этот компонент реализует специальное модальное окно для выбора тарифов курьерской доставки. Оно открывается, когда клиент выбирает курьерскую доставку, и позволяет ему выбрать один из доступных тарифов.
Когда модальное окно открыто, оно восстанавливает ранее выбранный тариф (если он уже был сохранен в корзине), поэтому пользователь сразу видит свой предыдущий выбор. Все доступные тарифы курьерской доставки отображаются в виде группированного списка, и клиент может выбрать один из них с помощью переключателей (radio buttons).
"use client"import { useCallback, useEffect, useMemo, useState } from "react"import { Button, clx, Heading, IconButton, Text } from "@medusajs/ui"import MedusaRadio from "@modules/common/components/radio"import { Loader, XMark } from "@medusajs/icons"import { HttpTypes } from "@medusajs/types"import { Radio, RadioGroup } from "@headlessui/react"import { setShippingMethod } from "@lib/data/cart"import { calculatePriceForShippingOption, retrieveCalculation } from "@lib/data/fulfillment"import {ApishipCalculation,ApishipTariff,Chosen} from "./types"import {buildTariffKey,days,useLatestRef,useLockBodyScroll} from "./utils"type ApishipCourierModalProps = {open: booleanonClose: (cancel?: boolean) => voidcart: HttpTypes.StoreCartshippingOptionId: string | nullinitialChosen?: Chosen | nullonPriceUpdate?: (shippingOptionId: string, amount: number) => voidonError?: (message: string) => voidonChosenChange: (chosen: Chosen | null) => voidprovidersMap: Record<string, string>}export const ApishipCourierModal: React.FC<ApishipCourierModalProps> = ({open,onClose,cart,shippingOptionId,initialChosen,onPriceUpdate,onError,onChosenChange,providersMap,}) => {const onErrorRef = useLatestRef(onError)const onPriceRef = useLatestRef(onPriceUpdate)const [isLoading, setIsLoading] = useState(false)const [isLoadingCalc, setIsLoadingCalc] = useState(false)const [calculation, setCalculation] = useState<ApishipCalculation | null>(null)const [selectedTariffKey, setSelectedTariffKey] = useState<string | null>(null)useLockBodyScroll(open)useEffect(() => {if (!open) returnif (initialChosen) {setSelectedTariffKey(initialChosen.tariff?.key ?? null)return}setSelectedTariffKey(null)}, [open, initialChosen, shippingOptionId])useEffect(() => {setSelectedTariffKey(null)}, [shippingOptionId])function useAsyncEffect(fn: (isCancelled: () => boolean) => Promise<void>, deps: any[]) {useEffect(() => {let cancelled = falseconst isCancelled = () => cancelledfn(isCancelled).catch((e) => console.error(e))return () => { cancelled = true }// eslint-disable-next-line react-hooks/exhaustive-deps}, deps)}useAsyncEffect(async (isCancelled) => {if (!open || !shippingOptionId) returnsetIsLoadingCalc(true)setCalculation(null)try {const calculation = await retrieveCalculation(cart.id, shippingOptionId)if (isCancelled()) returnsetCalculation(calculation)} catch (e: any) {console.error(e)onErrorRef.current?.(e?.message ?? "Failed to load calculation")setCalculation(null)} finally {if (!isCancelled()) setIsLoadingCalc(false)}}, [open, shippingOptionId, cart.id])const doorGroups = useMemo(() => {return calculation?.deliveryToDoor ?? []}, [calculation])const tariffsFlat = useMemo(() => {return doorGroups.flatMap((g) =>(g.tariffs ?? []).map((t, idx) => {const key = buildTariffKey(g.providerKey, t, idx)const full: ApishipTariff = {...t,key,providerKey: g.providerKey,}return full}))}, [doorGroups])const selectedTariff = useMemo(() => {if (!selectedTariffKey) return nullreturn tariffsFlat.find((t) => t.key === selectedTariffKey) ?? null}, [tariffsFlat, selectedTariffKey])const persistChosen = useCallback(async () => {if (!shippingOptionId || !selectedTariff) returnsetIsLoading(true)try {const next: Chosen = {deliveryType: 1,tariff: selectedTariff}await setShippingMethod({cartId: cart.id,shippingMethodId: shippingOptionId,data: { apishipData: next },})const calc = await calculatePriceForShippingOption(shippingOptionId, cart.id)if (calc?.id && typeof calc.amount === "number") {onPriceRef.current?.(calc.id, calc.amount)}onChosenChange(next)onClose()} catch (e: any) {onErrorRef.current?.(e?.message ?? "Failed to save courier tariff")} finally {setIsLoading(false)}}, [cart.id, shippingOptionId, selectedTariff, onPriceRef, onChosenChange, onClose, onErrorRef])if (!open) return nullreturn (<div className="fixed inset-0 z-50"><divclassName="absolute inset-0 bg-black/50"/><divclassName="absolute inset-0 flex items-center justify-center p-4"onClick={(e) => {if (e.target === e.currentTarget) onClose(initialChosen ? false : true)}}><div className="relative w-[470px] max-w-[calc(100vw-32px)]"><divclassName="h-[820px] w-[470px]max-w-[calc(100vw-32px)] max-h-[calc(100vh-32px)]overflow-hidden rounded-rounded border bg-whiteflex flex-col"><div className=" flex flex-row justify-between items-center p-[35px] pb-0"><Headinglevel="h2"className="flex flex-row text-3xl-regular gap-x-2 items-baseline">By courier</Heading><IconButtonaria-label="Close"onClick={() => onClose(initialChosen ? false : true)}className="shadow-none"><XMark /></IconButton></div><div className="flex-1 min-h-0 overflow-y-auto px-[35px] pt-[18px]">{isLoadingCalc ? (<div className="h-full w-full flex items-center justify-center"><Loader /></div>) : doorGroups.length === 0 ? (<Text className="text-ui-fg-muted">No courier tariffs available.</Text>) : (<div className="flex flex-col gap-[15px] pb-[18px]">{doorGroups.filter((g) => (g.tariffs?.length ?? 0) > 0).map((g) => (<div key={g.providerKey} className="flex flex-col gap-[10px]"><Text className="font-medium txt-medium text-ui-fg-base">{providersMap[g.providerKey] ?? g.providerKey}</Text><RadioGroupvalue={selectedTariffKey}onChange={(v) => setSelectedTariffKey(String(v))}className="flex flex-col gap-[10px]">{(g.tariffs ?? []).map((t, idx) => {const k = buildTariffKey(g.providerKey, t, idx)const checked = k === selectedTariffKeyconst cost =typeof t.deliveryCostOriginal === "number"? t.deliveryCostOriginal: t.deliveryCostreturn (<Radiokey={k}value={k}className={clx("flex items-center justify-between text-small-regular cursor-pointer py-2 border rounded-rounded pl-2 pr-3 hover:shadow-borders-interactive-with-active",{ "border-ui-border-interactive": checked })}><div className="flex gap-2 w-full"><MedusaRadio checked={checked} /><div className="flex flex-row w-full items-center justify-between"><div className="flex flex-col"><span className="txt-compact-small-plus">{t.tariffName}</span><span className="txt-small text-ui-fg-subtle">Delivery time: {days(t as ApishipTariff)}</span></div><span className="txt-small-plus text-ui-fg-subtle">{typeof cost === "number" ? `RUB ${cost}` : "—"}</span></div></div></Radio>)})}</RadioGroup></div>))}</div>)}</div><div className="p-[35px] pt-[18px] bg-white"><Buttonsize="large"onClick={persistChosen}isLoading={isLoading}disabled={!selectedTariffKey || isLoadingCalc || selectedTariff?.key === initialChosen?.tariff.key}className="w-full">Choose</Button></div></div></div></div></div>)}
После того как клиент подтверждает свой выбор, нажав кнопку , выбранный тариф сохраняется в корзине, стоимость доставки пересчитывается, и модальное окно закрывается.
Все компоненты и вспомогательные функции витрины ApiShip экспортируются через файл-коллектор, который служит единым входным пунктом для интеграции. Он повторно экспортирует основные элементы интерфейса, а также общие типы и утилиты, чтобы другие части витрины могли импортировать всё из одного места, вместо того чтобы ссылаться на внутренние пути файлов.
Откройте и добавьте следующее:
export * from «./apiship-courier-modal»export * from «./apiship-chosen»export * from «./apiship-map»export * from «./apiship-pickup-point-modal»export * from «./types»export * from «./utils»
Это позволяет поддерживать импорты чистыми и согласованными, а также упрощает дальнейшее обслуживание или рефакторинг интеграции без изменения путей импорта по всей витрине.
Для поддержки рассчитанных способов доставки ApiShip этап доставки требует дополнительного пользовательского потока, где клиент выбирает конкретный тариф и, при необходимости, пункт самовывоза. Это обновление добавляет специфическое для ApiShip состояние и модальные окна, загружает названия провайдеров для отображения и сохраняет окончательный выбор в корзине, чтобы оформление заказа могло продолжиться только после того, как клиент сделал корректный выбор.
Откройте и добавьте следующее:
// ... другие importsimport { setShippingMethod, removeShippingMethodFromCart } from "@lib/data/cart"import { calculatePriceForShippingOption, retrieveProviders } from "@lib/data/fulfillment"import { useEffect, useMemo, useState } from "react"import {ApishipPickupPointModal,ApishipCourierModal,ApishipChosen,days} from "./apiship"// ...const Shipping: React.FC<ShippingProps> = ({cart,availableShippingMethods,}) => {// ... другие states// добавить следующие statesconst [providersMap, setProvidersMap] = useState<Record<string, string>>({})const [apishipChosen, setApishipChosen] = useState<any | null>(null)const [apishipPickupPointModalOpen, setApishipPickupPointModalOpen] = useState(false)const [apishipCourierModalOpen, setApishipCourierModalOpen] = useState(false)// ...// добавить следующееconst isApishipCalculated = (option?: HttpTypes.StoreCartShippingOption | null) =>option?.price_type === "calculated" && option?.provider_id === "apiship_apiship"const isApishipToDoor = (option?: HttpTypes.StoreCartShippingOption | null) =>isApishipCalculated(option) && option?.data?.deliveryType === 1const isApishipToPoint = (option?: HttpTypes.StoreCartShippingOption | null) =>isApishipCalculated(option) && option?.data?.deliveryType === 2const activeShippingOption = useMemo(() => {return _shippingMethods?.find((option) => option.id === shippingMethodId) ?? null}, [_shippingMethods, shippingMethodId])const apishipMode = useMemo<"point" | "door" | null>(() => {if (!isOpen || !shippingMethodId) return nullif (isApishipToPoint(activeShippingOption)) return "point"if (isApishipToDoor(activeShippingOption)) return "door"return null}, [isOpen, shippingMethodId, activeShippingOption])useEffect(() => {setApishipChosen(cart.shipping_methods?.at(-1)?.data?.apishipData ?? null)}, [cart.shipping_methods])useEffect(() => {if (!isOpen) returnif (!apishipMode) {setApishipPickupPointModalOpen(false)setApishipCourierModalOpen(false)setApishipChosen(null)return}const chosenMode =apishipChosen?.deliveryType === 2 ? "point": apishipChosen?.deliveryType === 1 ? "door": nullconst hasValidChosen = !!apishipChosen && chosenMode === apishipModeif (hasValidChosen) {setApishipPickupPointModalOpen(false)setApishipCourierModalOpen(false)return}if (apishipMode === "point") {setApishipPickupPointModalOpen(true)setApishipCourierModalOpen(false)} else {setApishipCourierModalOpen(true)setApishipPickupPointModalOpen(false)}}, [isOpen, apishipMode, apishipChosen])useEffect(() => {let cancelled = false; (async () => {const response = await retrieveProviders()const providers = response?.providersif (cancelled) returnconst map: Record<string, string> = {}for (const provider of providers ?? []) map[provider.key] = provider.namesetProvidersMap(map)})()return () => { cancelled = true }}, [])useEffect(() => {if (!isOpen) returnif (!apishipChosen) returnif (!apishipMode) {setApishipChosen(null)return}const chosenMode = apishipChosen.deliveryType === 2 ? "point" : "door"if (chosenMode !== apishipMode) {setApishipChosen(null)}}, [isOpen, apishipMode, apishipChosen])// ... другие effectsreturn (<div className="bg-white">{/* ... */}{isOpen ? (<><div className="grid">{/* ... */}<div data-testid="delivery-options-container"><div className="pb-8 md:pt-0 pt-2">{/* ... */}<RadioGroupvalue={shippingMethodId}onChange={(v) => {if (v) {return handleSetShippingMethod(v, "shipping")}}}>{_shippingMethods?.map((option) => {const isDisabled =option.price_type === "calculated" &&!isLoadingPrices &&typeof calculatedPricesMap[option.id] !== "number"return (<Radiokey={option.id}value={option.id}data-testid="delivery-option-radio"disabled={isDisabled}className={clx("flex items-center justify-between text-small-regular cursor-pointer py-4 border rounded-rounded px-8 mb-2 hover:shadow-borders-interactive-with-active",{"border-ui-border-interactive":option.id === shippingMethodId,"hover:shadow-brders-none cursor-not-allowed":isDisabled,})}>{/* ... */}{/* Цены ApiShip указаны как «от X», поскольку окончательная сумма зависит от выбранного тарифа, поэтому измените следующее */}<span className="justify-self-end text-ui-fg-base">{option.price_type === "flat" ? (convertToLocale({amount: option.amount!,currency_code: cart?.currency_code,})) : calculatedPricesMap[option.id] ? (option.provider_id === "apiship_apiship" ? ("from " + convertToLocale({amount: calculatedPricesMap[option.id],currency_code: cart?.currency_code,})) :convertToLocale({amount: calculatedPricesMap[option.id],currency_code: cart?.currency_code,})) : isLoadingPrices ? (<Loader />) : ("-")}</span></Radio>)})}</RadioGroup>{/* добавьте следующие компоненты ApiShip */}<ApishipPickupPointModalopen={apishipPickupPointModalOpen}onClose={async (cancel?: boolean) => {setApishipPickupPointModalOpen(false)if (cancel) {await removeShippingMethodFromCart(cart.shipping_methods?.[0]?.id!)setShippingMethodId(null)}}}cart={cart}shippingOptionId={shippingMethodId}initialChosen={apishipChosen?.deliveryType === 2 ? apishipChosen : null}onPriceUpdate={(id, amount) => {setCalculatedPricesMap((prev) => ({ ...prev, [id]: amount }))}}onError={(msg) => setError(msg)}onChosenChange={(chosen) => setApishipChosen(chosen)}providersMap={providersMap}/><ApishipCourierModalopen={apishipCourierModalOpen}onClose={async (cancel?: boolean) => {setApishipCourierModalOpen(false)if (cancel) {await removeShippingMethodFromCart(cart.shipping_methods?.[0]?.id!)setShippingMethodId(null)}}}cart={cart}shippingOptionId={shippingMethodId}initialChosen={apishipChosen?.deliveryType === 1 ? apishipChosen : null}onPriceUpdate={(id, amount) => {setCalculatedPricesMap((prev) => ({ ...prev, [id]: amount }))}}onError={(msg) => setError(msg)}onChosenChange={(chosen) => setApishipChosen(chosen)}providersMap={providersMap}/>{apishipChosen && (<ApishipChosenchosen={apishipChosen}onRemove={async () => {await removeShippingMethodFromCart(cart.shipping_methods?.[0]?.id!)setApishipChosen(null)setShippingMethodId(null)}}onEdit={() => {console.log(cart)if (apishipMode === "point") setApishipPickupPointModalOpen(true)if (apishipMode === "door") setApishipCourierModalOpen(true)}}/>)}</div></div></div>{/* ... */}<div>{/* ... */}<Button// ... другие свойства// change the condition for ‘disabled’disabled={!cart.shipping_methods?.[0] ||shippingMethodId === null ||((_shippingMethods?.find((o) => o.id === shippingMethodId)?.provider_id === "apiship_apiship")&& !apishipChosen)}>Continue to payment</Button></div></>) : (<div><div className="text-small-regular">{cart && (cart.shipping_methods?.length ?? 0) > 0 && ({/* change the following div */}<div className="flex flex-col"><Text className="txt-medium-plus text-ui-fg-base mb-1">Method</Text><div className="flex flex-col"><Text className="txt-medium text-ui-fg-subtle">{cart.shipping_methods!.at(-1)!.name}</Text>{(apishipChosen && apishipChosen.point?.address) && (<Text className="txt-medium text-ui-fg-subtle">{apishipChosen.point.address}</Text>)}{apishipChosen && (<Text className="txt-medium text-ui-fg-subtle">{[apishipChosen?.tariff?.tariffName,typeof (typeof apishipChosen.tariff.deliveryCostOriginal === "number"? apishipChosen.tariff.deliveryCostOriginal: apishipChosen.tariff.deliveryCost) === "number"? `RUB ${(typeof apishipChosen.tariff.deliveryCostOriginal === "number"? apishipChosen.tariff.deliveryCostOriginal: apishipChosen.tariff.deliveryCost)}` : "RUB —",days?.(apishipChosen?.tariff) || null,].filter(Boolean).join(" · ")}</Text>)}</div></div>)}</div></div>)}<Divider className="mt-8" /></div>)}export default Shipping
Это изменение добавляет два отдельных модальных окна для ApiShip (выбор пункта самовывоза и выбор тарифа курьера) и небольшой блок с резюме, который отображает текущий сохранённый выбор с действиями Изменить и Удалить. Оно также обеспечивает следующее:
Вы можете ознакомиться с изменениями, внесенными в стартовый шаблон Medusa Next.js Starter Template, в директории .
Полный код интеграции можно посмотреть в разделе comparison page и изучите различия в каталоге . Или запустите в терминале:
git clone https://github.com/gorgojs/medusa-pluginscd medusa-pluginsgit diff @gorgo/medusa-fulfillment-apiship@0.0.1...main -- examples/fulfillment-apiship/medusa-storefront