/** * MotherShip 登录后二级右侧报价栏 * 对齐官网 Choose your carrier + coverage + checkout 信息密度 */ "use client"; import { useEffect, useMemo, useRef, useState } from "react"; import type { MothershipLoggedInShipmentPayload } from "@/components/mothership/mothership-logged-in-shipment-form"; import type { QuoteDetail, QuoteItem, QuotePageStatus, } from "@/lib/frontend/types"; import { getMotherShipRateOptionLabel, getMotherShipServiceLevelLabel, } from "@/lib/constants/mothership-ui-tiers"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { ErrorBanner } from "@/components/ui/error-banner"; import { formatUSD } from "@/lib/frontend/format"; import { parseCargoValueUsd, sanitizeCargoValueUsdInput, validateMsCheckoutSelection, } from "@/lib/mothership/ms-checkout-selection"; export type MsSidebarCheckoutIntent = { carrier: string; coverage: "basic" | "freight_protect"; cargoValueUsd?: number; quoteItem: QuoteItem; }; function parseTransitDays(item: QuoteItem): number | null { const raw = `${item.transit_days ?? ""} ${item.transit_description ?? ""}`; const m = raw.match(/(\d+)/); if (!m) return null; return Number(m[1]); } function addBusinessDays(isoDate: string, days: number): Date | null { const start = new Date(`${isoDate}T12:00:00`); if (Number.isNaN(start.getTime())) return null; let left = Math.max(0, days); const d = new Date(start); while (left > 0) { d.setDate(d.getDate() + 1); const wd = d.getDay(); if (wd !== 0 && wd !== 6) left -= 1; } return d; } function formatLongDate(d: Date): string { return d.toLocaleDateString("zh-CN", { weekday: "short", month: "short", day: "numeric", year: "numeric", }); } function readRateAmount(item: QuoteItem): string { const n = Number(item.final_total ?? item.raw_total ?? item.raw_freight); return Number.isFinite(n) && n > 0 ? String(n) : ""; } /** 同承运商不同价必须区分;下标保证 React key 绝对唯一 */ export function rateKey(item: QuoteItem, index = 0): string { const days = String(item.transit_days ?? "").trim(); return `ms-rate-${index}-${item.service_level}|${item.rate_option}|${item.carrier}|${readRateAmount(item)}|${days}`; } function isGuaranteed(item: QuoteItem): boolean { return /guaranteed/i.test(item.service_level); } function isStandard(item: QuoteItem): boolean { return /standard/i.test(item.service_level) || !isGuaranteed(item); } function carrierTitle(item: QuoteItem): string { const name = item.carrier?.trim(); if (!name) return "承运商"; if (/^mother\s*ship$/i.test(name) || /^mothership$/i.test(name)) { return "平台承运"; } return name.replace(/\bMother\s*Ship\b|\bMothership\b/gi, "承运商"); } export function buildDisplayedCheckoutSummary(input: { item: QuoteItem; coverage: "basic" | "full"; checkout?: QuoteDetail["ms_checkout"] | null; }): { headline: string; details: string | null; ctaAmountLabel: string | null; } { if (input.coverage === "basic") { return { headline: `当前选择:${carrierTitle(input.item)} · ${formatUSD(input.item.final_total)}`, details: null, ctaAmountLabel: formatUSD(input.item.final_total), }; } const confirmed = input.checkout; const confirmedTotal = confirmed?.status === "done" && typeof confirmed.selected_total === "number" && Number.isFinite(confirmed.selected_total) && confirmed.selected_total > 0 ? confirmed.selected_total : null; if (confirmedTotal != null) { return { headline: `当前选择:${carrierTitle(input.item)} · 确认价 ${formatUSD(confirmedTotal)}`, details: `保障后总价 ${formatUSD(confirmedTotal)}`, ctaAmountLabel: formatUSD(confirmedTotal), }; } return { headline: `当前选择:${carrierTitle(input.item)} · 承运商价 ${formatUSD(input.item.final_total)}`, details: "已选 FreightProtect,保障后总价待确认", ctaAmountLabel: null, }; } function findGuaranteeUpsell( base: QuoteItem, all: QuoteItem[], ): QuoteItem | null { if (isGuaranteed(base)) return null; const sameCarrier = all.filter( (q) => isGuaranteed(q) && (q.carrier || "").trim().toLowerCase() === (base.carrier || "").trim().toLowerCase(), ); if (sameCarrier.length > 0) { return sameCarrier.sort((a, b) => a.final_total - b.final_total)[0] ?? null; } // 无同承运商保证档时:取最低保证送达价作加价提示 const anyG = all .filter(isGuaranteed) .sort((a, b) => a.final_total - b.final_total); return anyG[0] ?? null; } export interface MothershipLoggedInQuoteSidebarProps { payload: MothershipLoggedInShipmentPayload; quote: QuoteDetail | null; status: QuotePageStatus; error: string | null; checkoutBusy?: boolean; checkoutMessage?: string | null; onCheckout?: (intent: MsSidebarCheckoutIntent) => void; /** 用户点选任意承运商报价时回传(宿主可同步锁定参数,不依赖保存按钮) */ onSelectQuote?: (intent: MsSidebarCheckoutIntent) => void; /** checkout=同步官网结账;save=嵌入宿主保存询价快照 */ actionMode?: "checkout" | "save"; } export function MothershipLoggedInQuoteSidebar({ payload, quote, status, error, checkoutBusy = false, checkoutMessage = null, onCheckout, onSelectQuote, actionMode = "checkout", }: MothershipLoggedInQuoteSidebarProps) { const rates = useMemo(() => { const list = [...(quote?.quotes ?? [])]; return list.sort((a, b) => a.final_total - b.final_total); }, [quote]); const [selectedId, setSelectedId] = useState(null); const [preferGuaranteed, setPreferGuaranteed] = useState(false); const [coverage, setCoverage] = useState<"basic" | "full">("basic"); const [cargoValue, setCargoValue] = useState(""); const [localError, setLocalError] = useState(null); const [serviceHintId, setServiceHintId] = useState(null); useEffect(() => { if (rates.length === 0) { setSelectedId(null); return; } setSelectedId((prev) => { if (prev && rates.some((r, i) => rateKey(r, i) === prev)) return prev; const preferredIndex = rates.findIndex(isStandard); const index = preferredIndex >= 0 ? preferredIndex : 0; return rateKey(rates[index]!, index); }); setPreferGuaranteed(false); }, [rates]); const selectedBase = rates.find((r, i) => rateKey(r, i) === selectedId) ?? rates[0] ?? null; const upsell = selectedBase ? findGuaranteeUpsell(selectedBase, rates) : null; const selected = preferGuaranteed && upsell ? upsell : selectedBase; // 点选任意报价 → 把当前选中参数回传宿主(每次真正生效的费率变化) const lastNotifiedKeyRef = useRef(null); /** 报价列表出现后滚到「选择承运商」顶部,避免停在侧栏底部 */ const carrierSectionRef = useRef(null); const scrolledQuoteIdRef = useRef(null); useEffect(() => { // 新询价单强制再推一次,避免跨 quote_id 去重误拦 lastNotifiedKeyRef.current = null; scrolledQuoteIdRef.current = null; }, [quote?.quote_id]); useEffect(() => { if (!onSelectQuote || !selected) return; const selectedIndex = rates.findIndex((r) => r === selected); const key = [ rateKey(selected, selectedIndex >= 0 ? selectedIndex : 0), selected.final_total, coverage, cargoValue, ].join("|"); if (lastNotifiedKeyRef.current === key) return; lastNotifiedKeyRef.current = key; const coverageKind = coverage === "full" ? "freight_protect" : "basic"; const cargoValueUsd = coverageKind === "freight_protect" ? parseCargoValueUsd(cargoValue) ?? undefined : undefined; onSelectQuote({ carrier: selected.carrier, coverage: coverageKind, cargoValueUsd, quoteItem: selected, }); }, [selected, coverage, cargoValue, onSelectQuote]); const loading = status === "validating" || status === "processing" || status === "resolving_address"; // 列表出全 / 刷价结束后滚到「选择承运商」;宿主可 postMessage chajia:scroll-quotes 再滚 const wasLoadingRef = useRef(false); useEffect(() => { if (loading) { wasLoadingRef.current = true; const t = window.setTimeout(() => { carrierSectionRef.current?.scrollIntoView({ behavior: "smooth", block: "start", }); }, 80); return () => window.clearTimeout(t); } // 同 quote_id 刷价完成后也要回弹(旧逻辑只滚一次会停在底部) if (!wasLoadingRef.current) return; wasLoadingRef.current = false; scrolledQuoteIdRef.current = quote?.quote_id || "list"; const t = window.setTimeout(() => { carrierSectionRef.current?.scrollIntoView({ behavior: "smooth", block: "start", }); }, 120); return () => window.clearTimeout(t); }, [loading, quote?.quote_id]); useEffect(() => { if (loading || rates.length === 0) return; const qid = quote?.quote_id || "list"; if (scrolledQuoteIdRef.current === qid) return; scrolledQuoteIdRef.current = qid; const t = window.setTimeout(() => { carrierSectionRef.current?.scrollIntoView({ behavior: "smooth", block: "start", }); }, 120); return () => window.clearTimeout(t); }, [loading, rates.length, quote?.quote_id]); useEffect(() => { const onScrollReq = () => { carrierSectionRef.current?.scrollIntoView({ behavior: "smooth", block: "start", }); }; window.addEventListener( "chajia-host-scroll-quotes", onScrollReq as EventListener, ); return () => window.removeEventListener( "chajia-host-scroll-quotes", onScrollReq as EventListener, ); }, []); const pickupEta = useMemo(() => { if (!payload.readyDate) return "—"; const d = new Date(`${payload.readyDate}T12:00:00`); if (Number.isNaN(d.getTime())) return payload.readyDate; const datePart = formatLongDate(d); const timePart = payload.readyTime ? ` ${payload.readyTime} 之后` : " 当日结束前"; return `${datePart}${timePart}`; }, [payload.readyDate, payload.readyTime]); const deliveryEta = useMemo(() => { if (!selected) return "以所选费率时效为准"; const days = parseTransitDays(selected); if (days == null || !payload.readyDate) { return ( selected.transit_description || (selected.transit_days ? `预计 ${selected.transit_days} 个工作日` : "以所选费率时效为准") ); } const end = addBusinessDays(payload.readyDate, days); if (!end) return `预计 ${days} 个工作日`; return `${formatLongDate(end)} 当日结束前`; }, [selected, payload.readyDate]); const displaySummary = selected ? buildDisplayedCheckoutSummary({ item: selected, coverage, checkout: quote?.ms_checkout ?? null, }) : null; return ( ); }