Search for a command to run...
Чтобы интегрировать провайдер фулфилмента ApiShip в магазин Next.js, необходимо расширить этап оформления заказа, чтобы обеспечить поддержку доставки в пункт выдачи закаков.
Доставка ApiShip отличается от обычных способов доставки тем, что требует сбора дополнительных данных во время оформления заказа, таких как выбранный пункт выдачи и тариф доставки. Эти данные должны динамически загружаться из бэкэнда, представляться клиенту в удобной форме, а затем сохраняться в корзине, чтобы их можно было использовать позже провайдером фулфилмента при создании заказа в ApiShip.
Выбора пункта выдачи заказов использует JS API Яндекс Карт v3. Чтобы включить карту в storefront, необходимо задать публичный ключ API через переменные окружения.
Добавьте следующее в файл вашего storefront:
Далее, в Storefront на Next.js необходимо внести следующие изменения:
Для поддержки доставки ApiShip в Storefront должна быть возможность прикреплять специфичные для провайдера данные к способу доставки. Эти данные используются для хранения информации связанной с доставкой, которую невозможно определить только на основе выбранного shipping option, например, выбранный пункт выдачи и тариф.
Откройте и добавьте следующий код:
export async function setShippingMethod({cartId,shippingMethodId,data,}: {cartId: stringshippingMethodId: stringdata?: Record<string, any>}) {const headers = {...(await getAuthHeaders()),}return sdk.store.cart.addShippingMethod(cartId,{option_id: shippingMethodId,data // Данные ApiShip},{},headers).then(async () => {const cartCacheTag = await getCacheTag("carts")revalidateTag(cartCacheTag)}).catch(medusaError)}
Расширив хелпер , чтобы он принимал опциональное поле , storefront может передавать информацию, специфичную для ApiShip, при применении способа доставки к корзине.
Эти данные позже считываются провайдером фулфилмента ApiShip при создании заказа, что позволяет ему правильно создать заказ в ApiShip, используя пункт выдачи и тариф, выбранные при оформлении заказа.
Чтобы отобразить варианты доставки в пункт выдачи на этапе выбора доставки, storefront должен иметь доступ к данным расчетов ApiShip и информации о пунктах выдачи. Эти данные не являются статичными и должны запрашиваться динамически на основе текущей корзины и выбранного способа доставки.
Откройте и добавьте следующие хелперы:
// ... другие хелперыexport const retrieveCalculation = async (cartId: string,shippingOptionId: string) => {const headers = {...(await getAuthHeaders()),}const next = {...(await getCacheOptions("fulfillment")),}const body = { cart_id: cartId }return sdk.client.fetch<Record<string, unknown>>(`/store/apiship/${shippingOptionId}/calculate`,{method: "POST",headers,body,next,}).then(({ data }) => data).catch((e) => {return null})}export const getPointAddresses = async (cartId: string,shippingOptionId: string,pointIds: Array<number>) => {const headers = {...(await getAuthHeaders()),}const next = {...(await getCacheOptions("fulfillment")),}const body = {cartId,shippingOptionId,pointIds}return sdk.client.fetch<{points: any[]meta: any}>(`/store/apiship/points`, {method: "POST",headers,body,next,}).catch((e) => {console.error("getPointsAddresses error", e)return null})}
Запрос возвращает доступные тарифы доставки вместе с идентификаторами пунктов выдачи, к которым они относятся. Запрос преобразует эти идентификаторы в полные данные о пунктах выдачи, такие как адреса и координаты, которые затем могут отображаться в интерфейсе оформления заказа.
ApiShip требует номер телефона получателя для расчета стоимости доставки и создания заказа в ApiShip. По этой причине storefront должен требовать указание номера телефона в форме адреса доставки.
Откройте и отметьте поле ввода номера телефона как обязательное:
<Inputlabel="Phone"name="shipping_address.phone"autoComplete="tel"value={formData["shipping_address.phone"]}onChange={handleChange}required // Обязательно для ApiShipdata-testid="shipping-phone-input"/>
Отмечая поле ввода номера телефона как , процесс оформления заказа всегда получает номер телефона получателя на раннем этапе оформления заказа, что обеспечивает наличие необходимых данных для расчёта доставки и создания заказов в ApiShip.
Для интеграции доставки ApiShip на этапе выбора доставки storefront требуется компонент, который загружает пункты выдачи и тарифы для выбранного способа доставки, отслеживает текущее состояние выбора и сохраняет итоговый выбор в корзину.
Создайте файл со следующим содержимым:
"use client"import { useCallback, useEffect, useRef, useState } from "react"import { Button, Text } from "@medusajs/ui"import { HttpTypes } from "@medusajs/types"import { setShippingMethod } from "@lib/data/cart"import {calculatePriceForShippingOption,retrieveCalculation,getPointAddresses,} from "@lib/data/fulfillment"importApishipMap,{ ApishipPoint }from "./apiship-map"type ApishipTariffForPoint = {key: stringproviderKey: stringtariffProviderId?: stringtariffId?: numbertariffName?: stringdeliveryCost?: numberdeliveryCostOriginal?: numberdaysMin?: numberdaysMax?: numbercalendarDaysMin?: numbercalendarDaysMax?: numberworkDaysMin?: numberworkDaysMax?: number}type ApishipCalculation = {deliveryToPoint?: Array<{providerKey: stringtariffs?: Array<{tariffProviderId?: stringtariffId?: numbertariffName?: stringdeliveryCost?: numberdeliveryCostOriginal?: numberdaysMin?: numberdaysMax?: numbercalendarDaysMin?: numbercalendarDaysMax?: numberworkDaysMin?: numberworkDaysMax?: numberpointIds?: number[]}>}>}type Chosen = {pointId: stringdescription?: stringworktime?: Record<string, string>photos?: string[]pointLabel: stringtariffKey: stringtariffLabel: stringpriceLabel: stringdaysLabel: stringapiship: {pointId: stringpointProviderKey?: stringtariffKey: stringtariffId?: numbertariffProviderId?: stringtariffProviderKey?: stringdeliveryCost?: numberdaysMin?: numberdaysMax?: number}}function buildTariffsByPointId(calculation?: ApishipCalculation | null) {const map: Record<string, ApishipTariffForPoint[]> = {}calculation?.deliveryToPoint?.forEach(({ providerKey, tariffs }) => {tariffs?.forEach((tariff) => {for (const pointId of tariff.pointIds ?? []) {const key = `${providerKey}:${tariff.tariffProviderId ?? ""}:${tariff.tariffId ?? ""}`const entry: ApishipTariffForPoint = {key,providerKey,tariffProviderId: tariff.tariffProviderId,tariffId: tariff.tariffId,tariffName: tariff.tariffName,deliveryCost: tariff.deliveryCost,deliveryCostOriginal: tariff.deliveryCostOriginal,daysMin: tariff.daysMin,daysMax: tariff.daysMax,calendarDaysMin: tariff.calendarDaysMin,calendarDaysMax: tariff.calendarDaysMax,workDaysMin: tariff.workDaysMin,workDaysMax: tariff.workDaysMax,}const arr = (map[String(pointId)] ??= [])if (!arr.some((tariff) => tariff.key === key)) arr.push(entry)}})})return map}const WEEK_DAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] as constconst Schedule = ({worktime}: {worktime: Record<string, string>}) => {if (Object.keys(worktime).length > 0) {return (<div className="py-3"><Text className="text-ui-fg-muted mt-1">Schedule:</Text>{Object.keys(worktime).map((day) => {const label = WEEK_DAYS[Number(day) - 1]const time = worktime[day]return (<Text className="text-ui-fg-muted mt-1" key={day}>{label}: {time}</Text>)})}</div>)} else {return null}}function extractPointIds(map: Record<string, ApishipTariffForPoint[]>) {return Object.keys(map).map(Number).filter(Number.isFinite)}function money(amount: number, currencyCode: string) {try {return new Intl.NumberFormat("ru-RU", {style: "currency",currency: currencyCode.toUpperCase(),maximumFractionDigits: 2,}).format(amount)} catch {return `${amount} ${currencyCode.toUpperCase()}`}}function days(t: ApishipTariffForPoint) {const min = t.daysMin ?? 0const max = t.daysMax ?? 0if (!min && !max) return "—"if (min === max) return min === 1 ? `${min} day` : `${min} days`return `${min}–${max} days`}function useLatestRef<T>(value: T) {const ref = useRef(value)useEffect(() => {ref.current = value}, [value])return ref}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)}type PanelProps = {enabled: booleancart: HttpTypes.StoreCartshippingOptionId: string | nullonReadyChange?: (ready: boolean) => voidonPriceUpdate?: (shippingOptionId: string, amount: number) => voidonError?: (message: string) => void}const ApishipWrapper = ({enabled,cart,shippingOptionId,onReadyChange,onPriceUpdate,onError,}: PanelProps) => {const onErrorRef = useLatestRef(onError)const onReadyRef = useLatestRef(onReadyChange)const onPriceRef = useLatestRef(onPriceUpdate)const [isLoadingPoints, setIsLoadingPoints] = useState(false)const [points, setPoints] = useState<ApishipPoint[]>([])const [tariffsByPointId, setTariffsByPointId] = useState<Record<string, ApishipTariffForPoint[]>>({})const [selectedPointId, setSelectedPointId] = useState<string | null>(null)const [selectedTariffKey, setSelectedTariffKey] = useState<string | null>(null)const [chosen, setChosen] = useState<Chosen | null>(null)const [isPanelOpen, setIsPanelOpen] = useState(false)useEffect(() => {if (!enabled) {setIsLoadingPoints(false)setPoints([])setTariffsByPointId({})setSelectedPointId(null)setSelectedTariffKey(null)setChosen(null)setIsPanelOpen(false)onReadyRef.current?.(true)} else {onReadyRef.current?.(false)}}, [enabled, onReadyRef])useEffect(() => {onReadyRef.current?.(!enabled || !!chosen)}, [enabled, chosen, onReadyRef])useAsyncEffect(async (isCancelled) => {if (!enabled || !shippingOptionId) returnsetIsLoadingPoints(true)try {const calculation = (await retrieveCalculation(cart.id, shippingOptionId)) as ApishipCalculationif (isCancelled()) returnconst tariffsMap = buildTariffsByPointId(calculation)setTariffsByPointId(tariffsMap)const pointIds = extractPointIds(tariffsMap)if (!pointIds.length) {setPoints([])return}const pointAddresses = (await getPointAddresses(cart.id, shippingOptionId, pointIds)) as {points: ApishipPoint[]}if (isCancelled()) returnsetPoints(pointAddresses?.points ?? [])} catch (e: any) {console.error("ApishipToPointPanel load failed", e)onErrorRef.current?.(e?.message ?? "Failed to load pickup points")setPoints([])setTariffsByPointId({})} finally {if (!isCancelled()) setIsLoadingPoints(false)}},[enabled, shippingOptionId, cart.id])const persistChosen = useCallback(async (next: Chosen) => {if (!shippingOptionId) returnawait setShippingMethod({cartId: cart.id,shippingMethodId: shippingOptionId,data: { apiship: next.apiship },})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 (!enabled) return nullreturn (<div className="mt-4 rounded-rounded border bg-ui-bg-base p-4"><span className="font-medium txt-medium text-ui-fg-base block">Pickup Point</span><span className="mb-3 text-ui-fg-muted txt-medium block">Select a pickup point and tariff</span><ApishipMappoints={points}tariffsByPointId={tariffsByPointId}isLoading={isLoadingPoints}currencyCode={cart.currency_code}selectedPointId={selectedPointId}selectedTariffKey={selectedTariffKey}isPanelOpen={isPanelOpen}onClosePanel={() => setIsPanelOpen(false)}onSelectPoint={(pid) => {setSelectedPointId(pid)setSelectedTariffKey(null)setIsPanelOpen(true)}}onSelectTariff={(key) => {setSelectedTariffKey(key)}}onChoose={async ({ point, tariff }) => {const cost =typeof tariff.deliveryCostOriginal === "number" ? tariff.deliveryCostOriginal : tariff.deliveryCostconst next: Chosen = {pointId: point.id,description: point.description,worktime: point.worktime,photos: point.photos,pointLabel: point.name || point.address || `Point #${point.id}`,tariffKey: tariff.key,tariffLabel: `${tariff.tariffName || "Tariff"} (${tariff.providerKey})`,priceLabel: typeof cost === "number" ? money(cost, cart.currency_code) : "—",daysLabel: days(tariff),apiship: {pointId: point.id,pointProviderKey: point.providerKey,tariffKey: tariff.key,tariffId: tariff.tariffId,tariffProviderId: tariff.tariffProviderId,tariffProviderKey: tariff.providerKey,deliveryCost: cost,daysMin: tariff.daysMin,daysMax: tariff.daysMax,},}setChosen(next)try {await persistChosen(next)} catch (e: any) {onErrorRef.current?.(e?.message ?? "Failed to save the selected tariff")}}}/>{chosen && (<div className="mt-4 rounded-rounded border p-4"><Text className="txt-medium-plus">Pickup Point</Text><Text className="text-ui-fg-muted mt-1">{chosen.pointLabel}</Text><Text className="text-ui-fg-muted mt-1">{chosen.description}</Text><Schedule worktime={chosen.worktime!} /><Text className="text-ui-fg-muted mt-1 pb-3">{chosen.tariffLabel} · {chosen.priceLabel} · {chosen.daysLabel}</Text><Buttonsize="small"onClick={(e) => {e.preventDefault()e.stopPropagation()setSelectedPointId(null)setSelectedTariffKey(null)setChosen(null)setIsPanelOpen(false)}}>Remove</Button></div>)}</div>)}export default ApishipWrapper
Компонент получает данные расчёта ApiShip, формирует список тарифов, сгруппированных по пунктам выдачи, и затем преобразует идентификаторы пунктов выдачи в полноценные объекты пунктов. Когда пункт и тариф выбраны, компонент сохраняет выбор в корзине, вызывая с необходимыми данными, чтобы провайдер фулфилмента ApiShip мог позже создать заказ, используя сохранённые данные.
Для выбора пункта выдачи на этапе доставки storefront требуется UI-компонент, который визуализирует доступные пункты и позволяет выбрать тариф для конкретного пункта. Этот компонент реализован как клиентский компонент на основе JS API Яндекс Карт v3 и UI-компонентов Medusa.
Создайте файл со следующим содержимым:
"use client"import { useCallback, useEffect, useMemo, useRef } from "react"import { Button, Text, clx } from "@medusajs/ui"const DEFAULT_CENTER: [number, number] = [37.618423, 55.751244]export type ApishipPoint = {id: stringproviderKey?: stringavailableOperation?: numbername?: stringdescription?: stringworktime?: Record<string, string>photos?: string[]lat: numberlng: numbercode?: stringpostIndex?: stringregion?: stringcity?: stringaddress?: stringphone?: string}export type ApishipTariffForPoint = {key: stringproviderKey: stringtariffProviderId?: stringtariffId?: numbertariffName?: stringdeliveryCost?: numberdeliveryCostOriginal?: numberdaysMin?: numberdaysMax?: numbercalendarDaysMin?: numbercalendarDaysMax?: numberworkDaysMin?: numberworkDaysMax?: number}type MapProps = {points: ApishipPoint[]tariffsByPointId: Record<string, ApishipTariffForPoint[]>isLoading?: booleancurrencyCode: stringselectedPointId: string | nullselectedTariffKey: string | nullisPanelOpen: booleanonClosePanel: () => voidonSelectPoint: (id: string) => voidonSelectTariff: (tariffKey: string) => voidonChoose: (payload: { point: ApishipPoint; tariff: ApishipTariffForPoint }) => void}export function useLatestRef<T>(value: T) {const ref = useRef(value)useEffect(() => {ref.current = value}, [value])return ref}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__}function money(amount: number, currencyCode: string) {try {return new Intl.NumberFormat("ru-RU", {style: "currency",currency: currencyCode.toUpperCase(),maximumFractionDigits: 2,}).format(amount)} catch {return `${amount} ${currencyCode.toUpperCase()}`}}function days(t: ApishipTariffForPoint) {const min = t.daysMin ?? 0const max = t.daysMax ?? 0if (!min && !max) return "—"if (min === max) return min === 1 ? `${min} day` : `${min} days`return `${min}–${max} days`}const ApishipMap = ({points,tariffsByPointId,isLoading,currencyCode,selectedPointId,selectedTariffKey,isPanelOpen,onClosePanel,onSelectPoint,onSelectTariff,onChoose,}: MapProps) => {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 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])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 = 14el.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 10px 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 = "6px"dot.style.height = "6px"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 })}})()return () => {cancelled = true}}, [points, clearMarkers, onSelectPointRef])useEffect(() => {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.background = sel ? "white" : "white"dot.style.border = sel ? "2px solid rgba(255,255,255,0.95)" : "2px solid rgba(0,0,0,0.25)"}}}, [selectedPointId])const showNoPoints = !isLoading && points.length === 0return (<div className="w-full"><div className="relative rounded-rounded overflow-hidden border"><div ref={containerRef} style={{ width: "100%", height: 520 }} />{isLoading && (<div className="absolute inset-0 bg-white/80 flex items-center justify-center"><Text className="text-ui-fg-muted">Loading pickup points…</Text></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>)}{activePoint && isPanelOpen && !showNoPoints && (<div className="absolute left-3 top-3 right-3 md:right-auto md:w-[460px] rounded-rounded border bg-white/95 p-4 shadow"><buttontype="button"aria-label="Close"onClick={(e) => {e.preventDefault()e.stopPropagation()onClosePanel()}}className="absolute right-2 top-2 inline-flex h-8 w-8 items-center justify-center rounded-full border bg-white/70 text-ui-fg-subtle hover:bg-white">✕</button><Text className="txt-medium-plus">{activePoint.name || `Point #${activePoint.id}`}</Text>{activePoint.address && <Text className="text-ui-fg-muted mt-1">{activePoint.address}</Text>}<div className="mt-3"><Text className="txt-medium-plus">Tariffs</Text>{activeTariffs.length ? (<div className="mt-2 flex flex-col gap-2 max-h-[220px] overflow-auto p-1">{activeTariffs.map((t) => {const active = t.key === selectedTariffKeyconst cost = typeof t.deliveryCostOriginal === "number" ? t.deliveryCostOriginal : t.deliveryCostconst price = typeof cost === "number" ? money(cost, currencyCode) : "—"return (<buttonkey={t.key}type="button"onClick={() => onSelectTariff(t.key)}className={clx("box-border text-left rounded-rounded border px-3 py-2 hover:shadow-borders-interactive-with-active",{ "border-ui-border-interactive bg-ui-bg-subtle": active })}><div className="flex items-center justify-between gap-3"><Text className="txt-small-plus">{t.tariffName || "Tariff"} <span className="text-ui-fg-muted">({t.providerKey})</span></Text><Text className="txt-small-plus">{price}</Text></div><Text className="text-ui-fg-muted mt-1 txt-small">Delivery time: {days(t)}</Text></button>)})}</div>) : (<Text className="text-ui-fg-muted mt-2">There are no tariffs for this point.</Text>)}</div><div className="mt-3 flex items-center gap-2"><Buttonsize="small"onClick={() => {if (!activePoint || !selectedTariff) returnonChoose({ point: activePoint, tariff: selectedTariff })}}disabled={!selectedTariff}>Choose</Button>{!selectedTariff && (<Text className="text-ui-fg-muted txt-small">Select a tariff to confirm your choice.</Text>)}</div></div>)}</div>{!activePoint && !showNoPoints && !isLoading && (<Text className="text-ui-fg-muted mt-2">Click on a point on the map to select a pickup point.</Text>)}</div>)}export default ApishipMap
Компонент отображает пункты выдачи в виде маркеров на карте и открывает всплывающее окно с подробной информацией о выбранном пункте. В окне отображаются доступные тарифы для этого пункта, включая стоимость и срок доставки, а подтверждение выбора выполняется через колбэк , который передаёт данные о выбранном пункте и тарифе в корзину.
Для поддержки доставки ApiShip при оформлении заказа на этапе доставки необходимо определить, когда выбран способ доставки ApiShip с получением в пункте выдачи, отображать интерфейс выбора пункта выдачи и управлять состоянием завершённости шага в зависимости от того, сохранён ли выбор в корзине.
Откройте и добавьте следующий код:
// ... другие импортыimport { useEffect, useMemo, useState } from "react"import ApishipWrapper from "./apiship-wrapper"const Shipping: React.FC<ShippingProps> = ({cart,availableShippingMethods,}) => {// ... другое состояние оформления заказа// Определяет, завершён ли выбор пункта выдачи ApiShipconst [apishipReady, setApishipReady] = useState(true)// Вспомогательная функция для определения варианта доставки ApiShip в пункт выдачиconst isApishipToPoint = (option?: HttpTypes.StoreCartShippingOption | null) =>option?.price_type === "calculated" &&option?.data?.deliveryType === 2// Текущий выбранный способ доставкиconst activeShippingOption = useMemo(() => {return _shippingMethods?.find((option) => option.id === shippingMethodId) ?? null}, [_shippingMethods, shippingMethodId])// Доставка ApiShip в пункт выдачи активна только для определенных вариантов доставкиconst isApishipActive = useMemo(() => {return isOpen &&!!shippingMethodId &&isApishipToPoint(activeShippingOption)}, [isOpen, shippingMethodId, activeShippingOption])const handleSetShippingMethod = async (id: string,variant: "shipping" | "pickup") => {// ... логика установки способа доставкиif (variant === "pickup") {setShowPickupOptions(PICKUP_OPTION_ON)setApishipReady(true) // сбросить состояние выбора пункта выдачи} else {setShowPickupOptions(PICKUP_OPTION_OFF)}}return (<>{/* ... интерфейс выбора способов доставки */}<ApishipWrapperenabled={isApishipActive}cart={cart}shippingOptionId={shippingMethodId}onReadyChange={setApishipReady}onPriceUpdate={(id, amount) => {setCalculatedPricesMap((prev) => ({...prev,[id]: amount,}))}}onError={(msg) => setError(msg)}/><Buttonsize="large"className="mt"onClick={handleSubmit}isLoading={isLoading}disabled={!cart.shipping_methods?.[0] ||(isApishipActive && !apishipReady) // блокировать до выбора пункта выдачи}data-testid="submit-delivery-option-button">Continue to payment</Button>{isApishipActive && !apishipReady && (<Text className="text-ui-fg-muted mt-2">To continue, select a pickup point and click <b>Choose</b> on the desired tariff.</Text>)}</>)}
Эта интеграция добавляет состояние , которое обновляется компонентом через . Когда активен способ доставки ApiShip и выбор ещё не завершён, шаг доставки отключает кнопку . Это гарантирует, что перед переходом к оплате в корзине всегда присутствуют необходимые данные доставки ApiShip.
Вы можете ознакомиться с изменениями, внесенными в стартовый шаблон Medusa Next.js Starter Template, в директории .
Полный код интеграции можно посмотреть в разделе comparison page, откройте вкладку и изучите различия в каталоге . Или запустите в терминале:
git clone https://github.com/gorgojs/medusa-pluginscd medusa-pluginsgit diff @gorgo/medusa-fulfillment-apiship@0.1.0...main -- examples/fulfillment-apiship/medusa-storefront