You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
chajia/components/mothership/mothership-logged-in-quote-...

665 lines
24 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/**
* 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<string | null>(null);
const [preferGuaranteed, setPreferGuaranteed] = useState(false);
const [coverage, setCoverage] = useState<"basic" | "full">("basic");
const [cargoValue, setCargoValue] = useState("");
const [localError, setLocalError] = useState<string | null>(null);
const [serviceHintId, setServiceHintId] = useState<string | null>(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<string | null>(null);
/** 报价列表出现后滚到「选择承运商」顶部,避免停在侧栏底部 */
const carrierSectionRef = useRef<HTMLDivElement | null>(null);
const scrolledQuoteIdRef = useRef<string | null>(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 (
<aside className="space-y-5 rounded-lg border border-border bg-surface p-4 shadow-card md:p-5">
{/* 预计时效 */}
<div className="space-y-3 border-b border-border pb-4">
<div>
<p className="text-xs font-medium text-text-secondary">预计提货</p>
<p className="mt-0.5 text-sm font-semibold text-text-primary">
{pickupEta}
</p>
<p className="mt-1 break-words text-xs text-text-secondary">
{payload.pickupQuery}
</p>
</div>
<div>
<p className="text-xs font-medium text-text-secondary">预计送达</p>
<p className="mt-0.5 text-sm font-semibold text-text-primary">
{deliveryEta}
</p>
<p className="mt-1 break-words text-xs text-text-secondary">
{payload.deliveryQuery}
</p>
</div>
</div>
{/* 承运商报价 */}
<div
ref={carrierSectionRef}
id="ms-choose-carrier"
data-testid="ms-choose-carrier"
className="scroll-mt-3"
>
<div className="mb-3 flex items-center justify-between gap-2">
<p className="text-sm font-semibold text-text-primary">选择承运商</p>
{quote?.source_type && (
<span className="rounded-full bg-bg px-2 py-0.5 text-[10px] text-text-secondary">
{quote.source_type === "rpa"
? "实时报价"
: quote.source_type === "cache"
? "缓存"
: quote.source_type === "stale"
? "降级"
: quote.source_type}
</span>
)}
</div>
{loading && (
<div className="flex items-center gap-2 rounded-md border border-border px-3 py-5 text-sm text-text-secondary">
<LoadingSpinner size={18} />
正在查询报价…
</div>
)}
{!loading && error && (
<p className="rounded-md border border-error/30 bg-error/5 px-3 py-2 text-sm text-error">
{error}
</p>
)}
{!loading && !error && rates.length === 0 && (
<p className="rounded-md border border-border bg-bg px-3 py-3 text-sm text-text-secondary">
暂无可用承运商报价
</p>
)}
{!loading && rates.length > 0 && (
<div className="space-y-2">
{rates.map((rate, index) => {
const id = rateKey(rate, index);
const active = selectedId === id;
const days = parseTransitDays(rate);
const levelLabel = getMotherShipServiceLevelLabel(rate.service_level);
const optLabel = getMotherShipRateOptionLabel(
rate.service_level,
rate.rate_option,
);
const serviceLine =
rate.transit_description?.trim() ||
(/direct/i.test(carrierTitle(rate))
? "Dedicated Dry Van"
: `${levelLabel} · ${optLabel}`);
return (
<div
key={id}
className={`overflow-hidden rounded-lg border transition-colors ${
active
? "border-text-primary bg-bg ring-1 ring-text-primary/15"
: "border-border hover:border-primary/40"
}`}
>
<button
type="button"
onClick={() => {
setSelectedId(id);
setPreferGuaranteed(false);
}}
className="w-full px-3 py-3 text-left"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
{active ? (
<span className="shrink-0 rounded bg-text-primary px-1.5 py-0.5 text-[10px] font-semibold leading-none text-white">
已选
</span>
) : null}
<p className="truncate text-sm font-semibold text-text-primary">
{carrierTitle(rate)}
</p>
</div>
<p className="mt-1 text-xs text-text-secondary">
{days != null
? `预计 ${days} 个工作日`
: rate.transit_days || "时效待定"}
</p>
</div>
<p className="shrink-0 text-lg font-semibold tabular-nums text-text-primary">
{formatUSD(rate.final_total)}
</p>
</div>
</button>
<div className="border-t border-border/70 px-3 py-2">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setServiceHintId((prev) => (prev === id ? null : id));
}}
className="text-left text-xs font-medium text-primary underline-offset-2 hover:underline"
>
{serviceLine}
</button>
{serviceHintId === id ? (
<p className="mt-1 text-[11px] leading-snug text-text-secondary">
{[
rate.raw_freight > 0
? `运费 ${formatUSD(rate.raw_freight)}。`
: "",
rate.surcharges > 0
? `附加费 ${formatUSD(rate.surcharges)}。`
: "",
]
.filter(Boolean)
.join(" ") || "暂无更多费用明细。"}
</p>
) : null}
</div>
</div>
);
})}
</div>
)}
{selectedBase && upsell && !isGuaranteed(selectedBase) && (
<button
type="button"
onClick={() => setPreferGuaranteed((v) => !v)}
className={`mt-2 w-full rounded-md border px-3 py-2 text-left text-xs ${
preferGuaranteed
? "border-primary bg-primary/5 text-primary"
: "border-border text-primary hover:bg-bg"
}`}
>
{preferGuaranteed ? "已选保证送达 · " : "加价 "}
{formatUSD(Math.max(0, upsell.final_total - selectedBase.final_total))}
{preferGuaranteed ? "(点击取消)" : " 使用保证送达"}
<span className="mt-0.5 block text-[11px] text-text-secondary">
{carrierTitle(upsell)} · {formatUSD(upsell.final_total)}
</span>
</button>
)}
</div>
{/* 保障 */}
<div className="space-y-2 border-t border-border pt-4">
<p className="text-sm font-semibold text-text-primary">选择保障方案</p>
<div
role="button"
tabIndex={0}
onClick={() => setCoverage("basic")}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") setCoverage("basic");
}}
className={`w-full cursor-pointer overflow-hidden rounded-lg border p-3 text-left ${
coverage === "basic"
? "border-text-primary bg-bg ring-1 ring-text-primary/15"
: "border-border hover:border-primary/40"
}`}
>
<span className="flex items-center gap-2 text-sm font-medium text-text-primary">
{coverage === "basic" ? (
<span className="shrink-0 rounded bg-text-primary px-1.5 py-0.5 text-[10px] font-semibold leading-none text-white">
已选
</span>
) : null}
承运商基础保障
</span>
<span className="mt-1 block text-xs leading-relaxed text-text-secondary">
仅依赖承运商自有保险,可能存在免责条款;典型理赔窗口约 120
天。额度有限,重大货损时补偿可能不足。
</span>
</div>
<div
role="button"
tabIndex={0}
onClick={() => setCoverage("full")}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") setCoverage("full");
}}
className={`w-full cursor-pointer overflow-hidden rounded-lg border p-3 text-left ${
coverage === "full"
? "border-text-primary bg-bg ring-1 ring-text-primary/15"
: "border-border hover:border-primary/40"
}`}
>
<span className="flex items-center gap-2 text-sm font-medium text-text-primary">
{coverage === "full" ? (
<span className="shrink-0 rounded bg-text-primary px-1.5 py-0.5 text-[10px] font-semibold leading-none text-white">
已选
</span>
) : null}
全额货运保障(FreightProtect)
</span>
<ul className="mt-1 list-disc space-y-0.5 pl-4 text-xs text-text-secondary">
<li>按货值报销,最高约 $250k</li>
<li>理赔处理约快 4 倍</li>
<li>可覆盖补运费用</li>
</ul>
<span className="mt-2 block text-xs font-medium text-text-primary">
货物价值是多少?
</span>
<input
value={cargoValue}
onChange={(e) => {
setCargoValue(sanitizeCargoValueUsdInput(e.target.value));
setCoverage("full");
setLocalError(null);
}}
inputMode="decimal"
onFocus={() => setCoverage("full")}
onClick={(e) => e.stopPropagation()}
placeholder="输入货物总价值"
className="mt-1 h-10 w-full rounded-md border border-border px-3 text-sm"
/>
<div className="mt-2 flex flex-wrap gap-1.5">
<span className="inline-flex rounded-md bg-bg px-2 py-0.5 text-[11px] font-medium text-text-secondary ring-1 ring-border">
最低约 $22.55
</span>
<span className="inline-flex rounded-md bg-bg px-2 py-0.5 text-[11px] font-medium text-text-secondary ring-1 ring-border">
免赔 $100
</span>
</div>
</div>
</div>
{/* 结账 */}
<div className="border-t border-border pt-4">
{(localError || checkoutMessage) && (
<div className="mb-3">
<ErrorBanner>{localError || checkoutMessage}</ErrorBanner>
</div>
)}
{selected && (
<div className="mb-2 rounded-md border border-success/20 bg-success/5 px-3 py-2 text-center">
<p className="text-xs text-success">
{displaySummary?.headline}
</p>
{displaySummary?.details ? (
<p className="mt-1 text-[11px] text-text-secondary">
{displaySummary.details}
</p>
) : null}
</div>
)}
{actionMode !== "save" ? (
<>
<button
type="button"
disabled={!selected || loading || checkoutBusy || !onCheckout}
onClick={() => {
if (!selected || !onCheckout) return;
const coverageKind =
coverage === "full" ? "freight_protect" : "basic";
const cargoValueUsd =
coverageKind === "freight_protect"
? parseCargoValueUsd(cargoValue) ?? undefined
: undefined;
const err = validateMsCheckoutSelection({
preferredCarrier: selected.carrier,
coverage: coverageKind,
cargoValueUsd,
});
if (err) {
setLocalError(err);
return;
}
setLocalError(null);
onCheckout({
carrier: selected.carrier,
coverage: coverageKind,
cargoValueUsd,
quoteItem: selected,
});
}}
className="flex h-12 w-full items-center justify-center rounded-md bg-success px-4 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-50"
>
{checkoutBusy
? "正在同步…"
: `前往结账${displaySummary?.ctaAmountLabel ? ` — ${displaySummary.ctaAmountLabel}` : ""}`}
</button>
<p className="mt-2 text-center text-[11px] text-text-disabled">
{coverage === "full" && !displaySummary?.ctaAmountLabel
? "将同步承运商、保障与详情;保障后总价以确认结果为准(不会支付)。"
: "将同步承运商与保障并填齐详情(不会支付)。"}
</p>
</>
) : null}
</div>
</aside>
);
}