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-...

428 lines
15 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, 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>
);
}