|
|
/**
|
|
|
* MotherShip 登录后二级右侧报价栏
|
|
|
* 对齐官网 Choose your carrier + coverage + checkout 信息密度
|
|
|
*/
|
|
|
"use client";
|
|
|
|
|
|
import { useEffect, useMemo, useState } from "react";
|
|
|
import { CheckCircle, Warning } from "@phosphor-icons/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 { formatUSD } from "@/lib/frontend/format";
|
|
|
|
|
|
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 rateKey(item: QuoteItem): string {
|
|
|
return `${item.service_level}|${item.rate_option}|${item.carrier}`;
|
|
|
}
|
|
|
|
|
|
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 /direct/i.test(name) ? name : `${name}`;
|
|
|
}
|
|
|
return "MotherShip";
|
|
|
}
|
|
|
|
|
|
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;
|
|
|
}
|
|
|
|
|
|
export function MothershipLoggedInQuoteSidebar({
|
|
|
payload,
|
|
|
quote,
|
|
|
status,
|
|
|
error,
|
|
|
}: 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("");
|
|
|
|
|
|
useEffect(() => {
|
|
|
if (rates.length === 0) {
|
|
|
setSelectedId(null);
|
|
|
return;
|
|
|
}
|
|
|
setSelectedId((prev) => {
|
|
|
if (prev && rates.some((r) => rateKey(r) === prev)) return prev;
|
|
|
const preferred =
|
|
|
rates.find(isStandard) ?? rates[0]!;
|
|
|
return rateKey(preferred);
|
|
|
});
|
|
|
setPreferGuaranteed(false);
|
|
|
}, [rates]);
|
|
|
|
|
|
const selectedBase =
|
|
|
rates.find((r) => rateKey(r) === selectedId) ?? rates[0] ?? null;
|
|
|
const upsell = selectedBase
|
|
|
? findGuaranteeUpsell(selectedBase, rates)
|
|
|
: null;
|
|
|
const selected =
|
|
|
preferGuaranteed && upsell ? upsell : selectedBase;
|
|
|
|
|
|
const loading =
|
|
|
status === "validating" ||
|
|
|
status === "processing" ||
|
|
|
status === "resolving_address";
|
|
|
|
|
|
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 unavailableHint = useMemo(() => {
|
|
|
if (loading || error) return null;
|
|
|
if (rates.length === 0) return null;
|
|
|
const looksMarketplace = rates.every(
|
|
|
(r) =>
|
|
|
/tforce|daylight|echo/i.test(r.carrier ?? "") ||
|
|
|
!/direct/i.test(r.carrier ?? ""),
|
|
|
);
|
|
|
if (looksMarketplace && rates.length > 0) {
|
|
|
return "当前价源非登录态 Direct 承运商列表。请确认客户已配置 MotherShip 账密且 Worker 可打开 dashboard。";
|
|
|
}
|
|
|
if (rates.length < 3) {
|
|
|
return "部分承运商无法服务此票货;完整不可用列表以官网为准。";
|
|
|
}
|
|
|
return null;
|
|
|
}, [rates, loading, error]);
|
|
|
|
|
|
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>
|
|
|
<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} />
|
|
|
正在查询 MotherShip 报价…
|
|
|
</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) => {
|
|
|
const id = rateKey(rate);
|
|
|
const active = selectedBase ? rateKey(selectedBase) === id : false;
|
|
|
const days = parseTransitDays(rate);
|
|
|
const levelLabel = getMotherShipServiceLevelLabel(rate.service_level);
|
|
|
const optLabel = getMotherShipRateOptionLabel(
|
|
|
rate.service_level,
|
|
|
rate.rate_option,
|
|
|
);
|
|
|
return (
|
|
|
<button
|
|
|
key={id}
|
|
|
type="button"
|
|
|
onClick={() => {
|
|
|
setSelectedId(id);
|
|
|
setPreferGuaranteed(false);
|
|
|
}}
|
|
|
className={`w-full rounded-lg border px-3 py-3 text-left transition-colors ${
|
|
|
active
|
|
|
? "border-text-primary bg-bg ring-1 ring-text-primary/20"
|
|
|
: "border-border hover:border-primary/40"
|
|
|
}`}
|
|
|
>
|
|
|
<div className="flex items-start justify-between gap-3">
|
|
|
<div className="min-w-0">
|
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
|
{active && (
|
|
|
<span className="inline-flex items-center gap-0.5 rounded bg-text-primary px-1.5 py-0.5 text-[10px] font-medium text-white">
|
|
|
<CheckCircle size={10} weight="fill" />
|
|
|
已选
|
|
|
</span>
|
|
|
)}
|
|
|
<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_description ||
|
|
|
rate.transit_days ||
|
|
|
"时效待定"}
|
|
|
</p>
|
|
|
<p className="mt-0.5 text-[11px] text-text-disabled">
|
|
|
{/direct/i.test(carrierTitle(rate))
|
|
|
? days != null
|
|
|
? `直连承运 · 预计 ${days} 个工作日`
|
|
|
: "直连承运"
|
|
|
: `${levelLabel} · ${optLabel}`}
|
|
|
{rate.raw_freight > 0 && (
|
|
|
<> · 运费 {formatUSD(rate.raw_freight)}</>
|
|
|
)}
|
|
|
{rate.surcharges > 0 && (
|
|
|
<> · 附加费 {formatUSD(rate.surcharges)}</>
|
|
|
)}
|
|
|
</p>
|
|
|
</div>
|
|
|
<p className="shrink-0 text-base font-semibold tabular-nums text-text-primary">
|
|
|
{formatUSD(rate.final_total)}
|
|
|
</p>
|
|
|
</div>
|
|
|
</button>
|
|
|
);
|
|
|
})}
|
|
|
</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>
|
|
|
)}
|
|
|
|
|
|
{unavailableHint && (
|
|
|
<p className="mt-3 flex items-start gap-1.5 text-[11px] text-text-disabled">
|
|
|
<Warning size={12} className="mt-0.5 shrink-0 text-warning" />
|
|
|
{unavailableHint}
|
|
|
</p>
|
|
|
)}
|
|
|
</div>
|
|
|
|
|
|
{/* 保障 */}
|
|
|
<div className="space-y-2 border-t border-border pt-4">
|
|
|
<p className="text-sm font-semibold text-text-primary">选择保障方案</p>
|
|
|
<label
|
|
|
className={`flex cursor-pointer items-start gap-3 rounded-lg border p-3 ${
|
|
|
coverage === "basic" ? "border-text-primary bg-bg" : "border-border"
|
|
|
}`}
|
|
|
>
|
|
|
<input
|
|
|
type="radio"
|
|
|
name="ms-coverage"
|
|
|
checked={coverage === "basic"}
|
|
|
onChange={() => setCoverage("basic")}
|
|
|
className="mt-1"
|
|
|
/>
|
|
|
<span>
|
|
|
<span className="block text-sm font-medium text-text-primary">
|
|
|
承运商基础保障
|
|
|
</span>
|
|
|
<span className="mt-1 block text-xs leading-relaxed text-text-secondary">
|
|
|
仅依赖承运商自有保险,可能存在免责条款;典型理赔窗口约 120
|
|
|
天。额度有限,重大货损时补偿可能不足。
|
|
|
</span>
|
|
|
</span>
|
|
|
</label>
|
|
|
<label
|
|
|
className={`flex cursor-pointer items-start gap-3 rounded-lg border p-3 ${
|
|
|
coverage === "full" ? "border-text-primary bg-bg" : "border-border"
|
|
|
}`}
|
|
|
>
|
|
|
<input
|
|
|
type="radio"
|
|
|
name="ms-coverage"
|
|
|
checked={coverage === "full"}
|
|
|
onChange={() => setCoverage("full")}
|
|
|
className="mt-1"
|
|
|
/>
|
|
|
<span className="min-w-0 flex-1">
|
|
|
<span className="block text-sm font-medium text-text-primary">
|
|
|
全额货运保障(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(e.target.value);
|
|
|
setCoverage("full");
|
|
|
}}
|
|
|
onFocus={() => setCoverage("full")}
|
|
|
placeholder="输入货物总价值"
|
|
|
className="mt-1 h-10 w-full rounded-md border border-border px-3 text-sm"
|
|
|
/>
|
|
|
<p className="mt-1 text-[11px] text-text-disabled">
|
|
|
最低约 $22.55;索赔免赔额 $100
|
|
|
</p>
|
|
|
</span>
|
|
|
</label>
|
|
|
</div>
|
|
|
|
|
|
{/* 结账 */}
|
|
|
<div className="border-t border-border pt-4">
|
|
|
{selected && (
|
|
|
<p className="mb-2 text-center text-xs text-success">
|
|
|
当前选择:{carrierTitle(selected)} · {formatUSD(selected.final_total)}
|
|
|
</p>
|
|
|
)}
|
|
|
<button
|
|
|
type="button"
|
|
|
disabled={!selected || loading}
|
|
|
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"
|
|
|
>
|
|
|
前往结账
|
|
|
{selected ? ` — ${formatUSD(selected.final_total)}` : ""}
|
|
|
</button>
|
|
|
<p className="mt-2 text-center text-[11px] text-text-disabled">
|
|
|
结账流程后续对接。请确认右侧金额与承运商时效后再下单。
|
|
|
</p>
|
|
|
</div>
|
|
|
</aside>
|
|
|
);
|
|
|
}
|