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.

1131 lines
39 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.

"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import type {
AddressInput,
MothershipAddressCandidate,
QuoteDetail,
QuotePageStatus,
QuoteRequestBody,
} from "@/lib/frontend/types";
import { SUBMIT_LOCK_MS } from "@/lib/frontend/constants";
import {
hostCreateQuote,
hostContinueMsRefineHold,
hostDeclineMsRefineHold,
hostFetchMothershipCandidates,
hostGetQuote,
hostPreheatQuoteSession,
hostReleaseQuoteSession,
hostSubmitMsRefineDetails,
hostSubmitMsCheckout,
} from "@/lib/frontend/api-client";
import { applyMothershipCandidate } from "@/lib/frontend/mothership-address";
import { pollQuoteUntilDone } from "@/hooks/use-quote-polling";
import { formatQuoteErrorMessage } from "@/modules/quote/quote-error-messages";
import { QuoteForm } from "@/components/quote/quote-form";
import { QuoteResultPanel } from "@/components/quote/quote-result-panel";
import { AddressDisambiguationModal } from "@/components/quote/address-disambiguation-modal";
import { PrimaryButton, SecondaryButton } from "@/components/ui/primary-button";
import { ErrorBanner } from "@/components/ui/error-banner";
import { quoteCtaCls } from "@/components/quote/quote-form-chrome";
import {
QuoteProviderSwitch,
type QuoteProviderId,
} from "@/components/embed/quote-provider-switch";
import { Priority1QuoteWidget } from "@/components/priority1/priority1-quote-widget";
import { FlockQuoteWidget } from "@/components/flock/flock-quote-widget";
import {
MothershipLoggedInShipmentForm,
type MothershipLoggedInShipmentPayload,
} from "@/components/mothership/mothership-logged-in-shipment-form";
import {
MothershipLoggedInDetailsForm,
detailsStateToPayload,
type MothershipLoggedInDetailsState,
} from "@/components/mothership/mothership-logged-in-details-form";
import { MothershipLoggedInQuoteSidebar } from "@/components/mothership/mothership-logged-in-quote-sidebar";
import type { MsSidebarCheckoutIntent } from "@/components/mothership/mothership-logged-in-quote-sidebar";
import { MsRefineHoldPrompt } from "@/components/mothership/ms-refine-hold-prompt";
import { buildQuoteRequestBodyFromLoggedIn } from "@/lib/frontend/mothership-logged-in-quote-body";
import { MS_NEEDS_DETAILS_ERROR_CODE } from "@/lib/constants/ms-refine-hold";
import { useHostBridgeOptional } from "@/lib/embed/host-bridge-react";
import {
postToHost,
type ChajiaModuleId,
} from "@/lib/embed/host-bridge";
import { mapQuotesWithSelectedFlag } from "@/lib/embed/map-quotes-selection";
/** 更新报价后滚到右侧「选择承运商」区域,避免用户停在页面底部按钮处 */
function scrollToMsQuotePanel(reason: string) {
try {
window.scrollTo({ top: 0, left: 0, behavior: "smooth" });
document.documentElement.scrollTop = 0;
document.body.scrollTop = 0;
} catch {
/* ignore */
}
const el = document.getElementById("ms-choose-carrier");
el?.scrollIntoView({ behavior: "smooth", block: "start" });
try {
window.dispatchEvent(new Event("chajia-host-scroll-quotes"));
} catch {
/* ignore */
}
postToHost("chajia:scroll-top", {
payload: { reason },
});
}
export interface EmbeddedQuoteWidgetProps {
customerId: string;
apiBaseUrl?: string;
/** 未传时使用 Cookie 演示会话鉴权(/embed-demo) */
serviceToken?: string;
/** 客户已绑定 MotherShip 账密 → 显示登录后一级查价界面 */
mothershipLoggedInUi?: boolean;
/** 客户已绑定 Flock 账密 → 显示登录后 Quick 查价界面 */
flockLoggedInUi?: boolean;
/** 门户驱动:强制单一数据源(配合 hideProviderSwitch) */
forcedProvider?: QuoteProviderId;
/** 门户驱动:隐藏内置数据源切换(4 路线门户已在外层选路) */
hideProviderSwitch?: boolean;
}
const FORM_ID = "embedded-quote-form";
export function EmbeddedQuoteWidget({
serviceToken,
customerId,
apiBaseUrl = "",
mothershipLoggedInUi = false,
flockLoggedInUi = false,
forcedProvider,
hideProviderSwitch = false,
}: EmbeddedQuoteWidgetProps) {
const hostBridge = useHostBridgeOptional();
const [provider, setProvider] = useState<QuoteProviderId>(
forcedProvider ?? "mothership",
);
const resolveModule = useCallback((): ChajiaModuleId => {
if (provider === "flock") {
return flockLoggedInUi ? "FLOCK_LOGGED_IN" : "FLOCK_GUEST";
}
return mothershipLoggedInUi ? "MS_LOGGED_IN" : "MS_GUEST";
}, [provider, flockLoggedInUi, mothershipLoggedInUi]);
const [status, setStatus] = useState<QuotePageStatus>("idle");
const [quote, setQuote] = useState<QuoteDetail | null>(null);
const [error, setError] = useState<string | null>(null);
const [submitLocked, setSubmitLocked] = useState(false);
const [loggedInDraft, setLoggedInDraft] =
useState<MothershipLoggedInShipmentPayload | null>(null);
const [loggedInDetails, setLoggedInDetails] =
useState<MothershipLoggedInDetailsState | null>(null);
const [loggedInStep, setLoggedInStep] = useState<"create" | "details">(
"create",
);
const [refineHold, setRefineHold] = useState<
NonNullable<QuoteDetail["refine_hold"]> | null
>(null);
const [refineAccepted, setRefineAccepted] = useState(false);
/** 继续填写后保留会话与总硬限(弹窗关闭后仍需 5 分钟到期 decline) */
const [refineActive, setRefineActive] = useState<{
quoteId: string;
quote_session_id: string;
total_deadline_ms: number;
} | null>(null);
const [refineTotalLeftLabel, setRefineTotalLeftLabel] = useState<string | null>(
null,
);
const [checkoutBusy, setCheckoutBusy] = useState(false);
const [checkoutMessage, setCheckoutMessage] = useState<string | null>(null);
const checkoutAbortRef = useRef<AbortController | null>(null);
const rootRef = useRef<HTMLDivElement | null>(null);
const isEmbeddedHost =
typeof window !== "undefined" && window.parent !== window;
const [disambiguationOpen, setDisambiguationOpen] = useState(false);
const [confirmedPickup, setConfirmedPickup] = useState<AddressInput | null>(
null,
);
const [confirmedDelivery, setConfirmedDelivery] =
useState<AddressInput | null>(null);
const [pickupCandidates, setPickupCandidates] = useState<
MothershipAddressCandidate[]
>([]);
const [deliveryCandidates, setDeliveryCandidates] = useState<
MothershipAddressCandidate[]
>([]);
const pendingBodyRef = useRef<QuoteRequestBody | null>(null);
const pendingSessionRef = useRef<string | undefined>(undefined);
const reset = useCallback(() => {
setStatus("idle");
setQuote(null);
setError(null);
setConfirmedPickup(null);
setConfirmedDelivery(null);
setLoggedInDraft(null);
setLoggedInStep("create");
setRefineHold(null);
setRefineAccepted(false);
setRefineActive(null);
setRefineTotalLeftLabel(null);
setCheckoutBusy(false);
setCheckoutMessage(null);
}, []);
const declineRefineHold = useCallback(
async (
hold: { quote_session_id: string },
quoteId: string,
) => {
setRefineHold(null);
setRefineAccepted(false);
setRefineActive(null);
setRefineTotalLeftLabel(null);
await hostDeclineMsRefineHold(
apiBaseUrl,
serviceToken,
customerId,
quoteId,
hold.quote_session_id,
);
},
[apiBaseUrl, serviceToken, customerId],
);
const finishQuote = useCallback(
(detail: QuoteDetail) => {
setQuote(detail);
const module = resolveModule();
if (detail.status === "failed") {
setStatus("error");
const msg =
detail.error_message ??
formatQuoteErrorMessage(detail.error_code);
setError(msg);
setRefineHold(null);
setRefineActive(null);
hostBridge?.reportQuoteResult({
quote_id: detail.quote_id,
request_id: detail.request_id,
status: "failed",
module,
currency: detail.currency || "USD",
quotes: [],
error_code: detail.error_code,
error_message: msg,
});
return;
}
if (detail.status === "expired") {
setStatus("expired");
setRefineHold(null);
setRefineActive(null);
hostBridge?.reportQuoteResult({
quote_id: detail.quote_id,
request_id: detail.request_id,
status: "expired",
module,
currency: detail.currency || "USD",
quotes: [],
error_code: detail.error_code,
error_message: detail.error_message,
});
return;
}
setStatus(detail.is_realtime === false ? "fallback" : "success");
if (detail.error_code === MS_NEEDS_DETAILS_ERROR_CODE) {
setError(
detail.error_message ??
formatQuoteErrorMessage(detail.error_code),
);
} else {
setError(null);
}
if (
mothershipLoggedInUi &&
detail.refine_hold?.available &&
!refineAccepted
) {
setRefineHold(detail.refine_hold);
} else {
setRefineHold(null);
}
if (!detail.refine_hold?.available) {
setRefineActive(null);
setRefineTotalLeftLabel(null);
}
hostBridge?.reportQuoteResult({
quote_id: detail.quote_id,
request_id: detail.request_id,
status: "done",
module,
currency: detail.currency || "USD",
source_type: detail.source_type,
is_realtime: detail.is_realtime,
quotes: (detail.quotes ?? []).map((q) => ({
carrier: q.carrier,
service_level: q.service_level,
rate_option: q.rate_option,
transit_days: q.transit_days,
transit_description: q.transit_description,
raw_freight: q.raw_freight,
surcharges: q.surcharges,
raw_total: q.raw_total,
markup_percent: q.markup_percent,
markup_amount: q.markup_amount,
final_total: q.final_total,
base_total_before_markup: q.raw_total,
customer_markup_amount: q.markup_amount,
customer_final_total: q.final_total,
is_customer_markup_applied: q.markup_amount > 0,
pricing_mode: "customer_final" as const,
breakdown: q.breakdown ?? [],
estimated_pickup: loggedInDraft?.readyDate ?? null,
guaranteed: /guarant/i.test(q.service_level || ""),
})),
error_code: null,
error_message: null,
});
},
[hostBridge, resolveModule, mothershipLoggedInUi, refineAccepted, loggedInDraft],
);
useEffect(() => {
if (!refineActive || !refineAccepted) {
setRefineTotalLeftLabel(null);
return;
}
// 填写补充信息期间不再倒计时逼迫用户,也不再自动放弃(避免提交按钮消失)
setRefineTotalLeftLabel(null);
}, [refineActive, refineAccepted]);
/** 把当前点选报价参数同步给宿主;asSave=true 时走 quote-save,否则 quote-result 仅更新选中 */
const notifyHostQuoteSelection = useCallback(
(intent: MsSidebarCheckoutIntent, opts?: { asSave?: boolean }) => {
if (!hostBridge || !quote?.quote_id) return;
const module = resolveModule();
const selected = intent.quoteItem;
const isProtect = intent.coverage === "freight_protect";
const readyDate = loggedInDraft?.readyDate ?? null;
const addBusinessDays = (isoDate: string, days: number): string | 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;
}
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
};
const parseTransitDays = (item: {
transit_days?: number | string | null;
transit_description?: string | null;
}): number | null => {
const raw = `${item.transit_days ?? ""} ${item.transit_description ?? ""}`;
const m = raw.match(/(\d+)/);
if (!m) return null;
const n = Number(m[1]);
return Number.isFinite(n) ? n : null;
};
const cargo_lines =
loggedInDraft?.cargo?.map((c) => ({
cargo_type: c.cargoType,
quantity: c.quantity,
weight_lb: c.weightLb,
length_in: c.lengthIn,
width_in: c.widthIn,
height_in: c.heightIn,
})) ?? undefined;
const pickupText =
loggedInDraft?.pickupConfirmed?.formatted_address ||
loggedInDraft?.pickupConfirmed?.display_label ||
loggedInDraft?.pickupQuery ||
"";
const deliveryText =
loggedInDraft?.deliveryConfirmed?.formatted_address ||
loggedInDraft?.deliveryConfirmed?.display_label ||
loggedInDraft?.deliveryQuery ||
"";
const quotes = mapQuotesWithSelectedFlag(
(quote.quotes ?? []).map((q) => {
const days = parseTransitDays(q);
const estimated_delivery =
readyDate && days != null ? addBusinessDays(readyDate, days) : null;
return {
carrier: q.carrier,
service_level: q.service_level,
rate_option: q.rate_option,
transit_days: q.transit_days,
transit_description: q.transit_description,
raw_freight: q.raw_freight,
surcharges: q.surcharges,
raw_total: q.raw_total,
markup_percent: q.markup_percent,
markup_amount: q.markup_amount,
final_total: q.final_total,
base_total_before_markup: q.raw_total,
customer_markup_amount: q.markup_amount,
customer_final_total: q.final_total,
is_customer_markup_applied: q.markup_amount > 0,
pricing_mode: "customer_final" as const,
breakdown: q.breakdown ?? [],
guaranteed: isProtect || /guarant/i.test(q.service_level || ""),
guarantee_amount: null as number | null,
estimated_pickup: readyDate,
estimated_delivery,
};
}),
selected,
).map((q) => ({
...q,
guarantee_amount: q.selected ? intent.cargoValueUsd ?? null : null,
}));
if (opts?.asSave) {
hostBridge.reportQuoteSave({
action: "save",
quote_id: quote.quote_id,
request_id: quote.request_id,
status: "done",
module,
currency: quote.currency || "USD",
source_type: quote.source_type,
is_realtime: quote.is_realtime,
selected_carrier: intent.carrier,
coverage: intent.coverage,
cargo_value_usd: intent.cargoValueUsd ?? null,
ready_date: readyDate,
estimated_pickup: readyDate,
pickup_address: pickupText
? { formatted_address: pickupText, street: pickupText }
: undefined,
delivery_address: deliveryText
? { formatted_address: deliveryText, street: deliveryText }
: undefined,
cargo_lines,
quotes,
error_code: null,
error_message: null,
});
return;
}
hostBridge.reportQuoteResult({
quote_id: quote.quote_id,
request_id: quote.request_id,
status: "done",
module,
currency: quote.currency || "USD",
source_type: quote.source_type,
is_realtime: quote.is_realtime,
quotes,
error_code: null,
error_message: null,
});
},
[hostBridge, quote, resolveModule, loggedInDraft],
);
const handleSaveQuoteToHost = useCallback(
(intent: MsSidebarCheckoutIntent) => {
if (!quote?.quote_id) {
setCheckoutMessage("请先完成询价");
return;
}
if (!hostBridge) {
setCheckoutMessage("未检测到宿主桥接,无法保存询价记录");
return;
}
setCheckoutBusy(true);
setCheckoutMessage("正在保存询价记录…");
try {
notifyHostQuoteSelection(intent, { asSave: true });
setCheckoutMessage("已提交保存到宿主「卡派询价快照」");
} catch (err) {
setCheckoutMessage(
err instanceof Error ? err.message : "保存询价记录失败",
);
} finally {
setCheckoutBusy(false);
}
},
[quote, hostBridge, notifyHostQuoteSelection],
);
const handleSelectQuoteForHost = useCallback(
(intent: MsSidebarCheckoutIntent) => {
if (!isEmbeddedHost || !hostBridge) return;
try {
notifyHostQuoteSelection(intent, { asSave: false });
} catch {
/* 宿主同步失败不打断选价 */
}
},
[isEmbeddedHost, hostBridge, notifyHostQuoteSelection],
);
const handleMsCheckout = useCallback(
async (intent: MsSidebarCheckoutIntent) => {
if (isEmbeddedHost && hostBridge) {
handleSaveQuoteToHost(intent);
return;
}
if (!quote?.quote_id) {
setCheckoutMessage("请先完成询价");
return;
}
checkoutAbortRef.current?.abort();
const ac = new AbortController();
checkoutAbortRef.current = ac;
setCheckoutBusy(true);
setCheckoutMessage("正在同步官网选择承运商与保障…");
try {
const detailsPayload = loggedInDetails
? detailsStateToPayload(loggedInDetails)
: undefined;
const mapped = loggedInDraft
? buildQuoteRequestBodyFromLoggedIn(
loggedInDraft,
customerId,
detailsPayload,
)
: null;
const res = await hostSubmitMsCheckout(
apiBaseUrl,
serviceToken,
customerId,
{
quoteId: quote.quote_id,
preferredCarrier: intent.carrier,
coverage: intent.coverage,
cargoValueUsd: intent.cargoValueUsd,
mothershipDetails: mapped?.mothership_details,
pickupAccessorials: mapped?.pickup_accessorials,
deliveryAccessorials: mapped?.delivery_accessorials,
},
);
if (ac.signal.aborted) return;
if (res.code !== 0) {
setCheckoutMessage(res.message);
return;
}
const deadline = Date.now() + 8 * 60_000;
while (Date.now() < deadline) {
if (ac.signal.aborted) return;
await new Promise<void>((resolve, reject) => {
const t = setTimeout(resolve, 2_000);
ac.signal.addEventListener(
"abort",
() => {
clearTimeout(t);
reject(new DOMException("Aborted", "AbortError"));
},
{ once: true },
);
}).catch(() => undefined);
if (ac.signal.aborted) return;
const r = await hostGetQuote(
apiBaseUrl,
serviceToken,
customerId,
quote.quote_id,
);
if (ac.signal.aborted) return;
if (r.code !== 0) continue;
const st = r.data.ms_checkout;
if (!st) continue;
if (st.status === "done") {
setQuote(r.data);
setCheckoutMessage(
st.message ||
`已填齐详情(${st.selected_carrier ?? intent.carrier},未支付)`,
);
return;
}
if (st.status === "failed") {
setCheckoutMessage(st.message || "结账同步失败");
return;
}
setCheckoutMessage(st.message || "正在同步官网…");
}
if (!ac.signal.aborted) {
setCheckoutMessage("结账同步超时,请稍后在侧栏查看状态或重试");
}
} catch (err) {
if (ac.signal.aborted) return;
setCheckoutMessage(
err instanceof Error ? err.message : "结账同步失败",
);
} finally {
if (checkoutAbortRef.current === ac) {
setCheckoutBusy(false);
}
}
},
[
quote,
loggedInDraft,
loggedInDetails,
customerId,
apiBaseUrl,
serviceToken,
isEmbeddedHost,
hostBridge,
handleSaveQuoteToHost,
],
);
useEffect(() => {
return () => {
checkoutAbortRef.current?.abort();
};
}, []);
const submitQuote = useCallback(
async (body: QuoteRequestBody) => {
setStatus("validating");
setError(null);
const created = await hostCreateQuote(apiBaseUrl, serviceToken, body);
if (created.code !== 0) {
setStatus("error");
setError(created.message);
return;
}
const { quote_id, status: createStatus } = created.data;
setStatus("processing");
if (createStatus === "done") {
const detail = await hostGetQuote(
apiBaseUrl,
serviceToken,
customerId,
quote_id,
);
if (detail.code !== 0) {
setStatus("error");
setError(detail.message);
return;
}
finishQuote(detail.data);
return;
}
const pollResult = await pollQuoteUntilDone(async () => {
try {
const res = await hostGetQuote(
apiBaseUrl,
serviceToken,
customerId,
quote_id,
);
if (res.code !== 0) {
return { ok: false, errorMessage: res.message };
}
return { ok: true, data: res.data };
} catch {
return { ok: false };
}
});
if (pollResult.type === "done") {
finishQuote(pollResult.quote);
return;
}
setStatus("error");
setError(
pollResult.type === "timeout"
? formatQuoteErrorMessage("QUOTE_TIMEOUT")
: pollResult.message,
);
},
[apiBaseUrl, serviceToken, customerId, finishQuote],
);
const handleSubmit = useCallback(
async (body: QuoteRequestBody) => {
if (submitLocked) return;
setSubmitLocked(true);
setTimeout(() => setSubmitLocked(false), SUBMIT_LOCK_MS);
setStatus("resolving_address");
setError(null);
let candidatesRes;
try {
candidatesRes = await hostFetchMothershipCandidates(
apiBaseUrl,
serviceToken,
customerId,
body.pickup_address,
body.delivery_address,
);
} catch {
setStatus("error");
setError("地址联想请求失败,请稍后重试");
return;
}
if (candidatesRes.code !== 0) {
setStatus("error");
setError(candidatesRes.message);
return;
}
const {
pickup_candidates,
delivery_candidates,
quote_session_id,
} = candidatesRes.data;
pendingSessionRef.current = quote_session_id;
pendingBodyRef.current = body;
setPickupCandidates(pickup_candidates);
setDeliveryCandidates(delivery_candidates);
setDisambiguationOpen(true);
if (quote_session_id) {
void hostPreheatQuoteSession(
apiBaseUrl,
serviceToken,
customerId,
quote_session_id,
);
}
setStatus("idle");
},
[apiBaseUrl, serviceToken, customerId, submitLocked],
);
const handleDisambiguationConfirm = useCallback(
async (
pickup: MothershipAddressCandidate,
delivery: MothershipAddressCandidate,
) => {
const base = pendingBodyRef.current;
if (!base) {
return;
}
setDisambiguationOpen(false);
pendingBodyRef.current = null;
const confirmedBody: QuoteRequestBody = {
...base,
quote_session_id: pendingSessionRef.current,
pickup_address: applyMothershipCandidate(base.pickup_address, pickup),
delivery_address: applyMothershipCandidate(
base.delivery_address,
delivery,
),
};
setConfirmedPickup(confirmedBody.pickup_address);
setConfirmedDelivery(confirmedBody.delivery_address);
pendingSessionRef.current = undefined;
await submitQuote(confirmedBody);
},
[submitQuote],
);
const handleDisambiguationCancel = useCallback(() => {
const sessionId = pendingSessionRef.current;
setDisambiguationOpen(false);
pendingBodyRef.current = null;
pendingSessionRef.current = undefined;
setStatus("idle");
if (sessionId) {
void hostReleaseQuoteSession(
apiBaseUrl,
serviceToken,
customerId,
sessionId,
);
}
}, [apiBaseUrl, serviceToken, customerId]);
const formDisabled =
status === "resolving_address" ||
status === "validating" ||
status === "processing" ||
submitLocked ||
disambiguationOpen;
const switchDisabled =
status === "validating" ||
status === "processing" ||
status === "resolving_address";
// 点击「继续」进入二级提货/送货详情后:页面置顶(避免停在底部「继续」按钮处)
useEffect(() => {
if (loggedInStep !== "details") return;
const t = window.setTimeout(() => {
try {
window.scrollTo({ top: 0, left: 0, behavior: "smooth" });
document.documentElement.scrollTop = 0;
document.body.scrollTop = 0;
} catch {
/* ignore */
}
rootRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
// 通知宿主:把私卡询价弹窗/iframe 区域滚到视口顶部
postToHost("chajia:scroll-top", {
module: resolveModule(),
payload: { reason: "logged-in-details" },
});
}, 60);
return () => window.clearTimeout(t);
}, [loggedInStep, resolveModule]);
return (
<div
ref={rootRef}
className="rounded-lg border border-border bg-surface p-4 shadow-card md:p-6"
>
<div className="mb-4">
<h2 className="text-lg font-semibold text-text-primary">卡派查价</h2>
<p className="text-sm text-text-secondary">
选择数据源填写货物信息,获取实时报价
</p>
</div>
{!hideProviderSwitch && (
<QuoteProviderSwitch
value={provider}
onChange={(next) => {
setProvider(next);
reset();
}}
disabled={switchDisabled}
/>
)}
{provider === "priority1" ? (
<Priority1QuoteWidget
serviceToken={serviceToken}
customerId={customerId}
apiBaseUrl={apiBaseUrl}
/>
) : provider === "flock" ? (
<FlockQuoteWidget
serviceToken={serviceToken}
customerId={customerId}
apiBaseUrl={apiBaseUrl}
flockLoggedInUi={flockLoggedInUi}
/>
) : (
<>
{error && status === "idle" && (
<div className="mb-4">
<ErrorBanner>{error}</ErrorBanner>
</div>
)}
{mothershipLoggedInUi &&
!(loggedInStep === "details" && loggedInDraft) ? (
/* 一级「创建新货件」:取消右侧「下一步」占位,表单占满整行 */
<div>
<MothershipLoggedInShipmentForm
formId={FORM_ID}
disabled={formDisabled}
customerId={customerId}
apiBaseUrl={apiBaseUrl}
serviceToken={serviceToken}
onValidSubmit={(payload) => {
setLoggedInDraft(payload);
setLoggedInDetails(null);
setLoggedInStep("details");
setQuote(null);
setError(null);
try {
// 一级信息足够询价;二级仅选报价/后续填详情,不阻塞出价
const body = buildQuoteRequestBodyFromLoggedIn(
payload,
customerId,
);
void submitQuote(body);
} catch (err) {
setStatus("error");
setError(
err instanceof Error
? err.message
: "无法构建询价请求",
);
}
}}
/>
<div className="mt-6">
<button
type="submit"
form={FORM_ID}
disabled={formDisabled}
data-testid="ship-create-continue-button"
className={`${quoteCtaCls} w-full sm:w-auto`}
>
继续
</button>
</div>
</div>
) : (
<div className="grid gap-6 lg:grid-cols-5">
<div className="lg:col-span-3">
{mothershipLoggedInUi ? (
<>
<div className="mb-4">
<SecondaryButton
type="button"
onClick={() => setLoggedInStep("create")}
>
返回上一步
</SecondaryButton>
</div>
<MothershipLoggedInDetailsForm
formId="ms-logged-in-details-form"
initial={loggedInDraft!}
disabled={formDisabled}
highlightRequiredGaps={refineAccepted}
onChange={setLoggedInDetails}
onValidSubmit={(details) => {
if (!quote?.quote_id || !refineAccepted) {
setError("请先选择继续填写,或等待初步报价完成");
return;
}
const sessionId =
refineActive?.quote_session_id ||
quote.refine_hold?.quote_session_id ||
refineHold?.quote_session_id;
if (!sessionId) {
setError("报价会话已失效,请重新询价");
return;
}
void (async () => {
setStatus("processing");
setError(null);
scrollToMsQuotePanel("update-quote");
const draftReady =
loggedInDetails?.pickup.readyTime ||
loggedInDraft!.readyTime;
const draftWithReady = {
...loggedInDraft!,
readyTime: draftReady,
readyDate:
loggedInDetails?.pickup.readyDate ||
loggedInDraft!.readyDate,
};
setLoggedInDraft(draftWithReady);
const mapped = buildQuoteRequestBodyFromLoggedIn(
draftWithReady,
customerId,
details,
);
const res = await hostSubmitMsRefineDetails(
apiBaseUrl,
serviceToken,
customerId,
quote.quote_id,
sessionId,
mapped.mothership_details,
{
pickupAccessorials: mapped.pickup_accessorials,
deliveryAccessorials: mapped.delivery_accessorials,
},
{
readyDate: mapped.ready_date,
readyTime: mapped.ready_time,
},
mapped.cargo_lines,
);
if (res.code !== 0) {
setStatus("error");
setError(res.message);
scrollToMsQuotePanel("update-quote-error");
return;
}
const pollResult = await pollQuoteUntilDone(
async () => {
try {
const r = await hostGetQuote(
apiBaseUrl,
serviceToken,
customerId,
quote.quote_id,
);
if (r.code !== 0) {
return {
ok: false as const,
errorMessage: r.message,
};
}
return { ok: true as const, data: r.data };
} catch {
return { ok: false as const };
}
},
);
if (pollResult.type === "done") {
setRefineAccepted(false);
setRefineActive(null);
finishQuote(pollResult.quote);
scrollToMsQuotePanel("update-quote-done");
return;
}
setStatus("error");
setError(
pollResult.type === "timeout"
? formatQuoteErrorMessage("QUOTE_TIMEOUT")
: pollResult.message,
);
scrollToMsQuotePanel("update-quote-error");
})();
}}
/>
{refineAccepted ? (
<div className="sticky bottom-0 z-20 mt-6 border-t border-border bg-surface/95 py-4 backdrop-blur-sm">
<button
type="submit"
form="ms-logged-in-details-form"
disabled={formDisabled}
className={`${quoteCtaCls} w-full sm:w-auto`}
>
保存并更新报价
</button>
<p className="mt-2 text-xs text-text-secondary">
补全必要信息后提交,系统将重新获取更准确报价。
</p>
</div>
) : null}
</>
) : (
<>
<QuoteForm
customerId={customerId}
disabled={formDisabled}
formId={FORM_ID}
confirmedPickup={confirmedPickup}
confirmedDelivery={confirmedDelivery}
hostPrefill={
hostBridge?.msGuestPrefill && !mothershipLoggedInUi
? hostBridge.msGuestPrefill
: null
}
hostPrefillSeq={hostBridge?.fill?.seq ?? 0}
onAddressDraftChange={() => {
setConfirmedPickup(null);
setConfirmedDelivery(null);
}}
onCargoChange={() => {
setQuote(null);
setError(null);
if (status === "success" || status === "fallback") {
setStatus("idle");
}
}}
onValidSubmit={(body) => void handleSubmit(body)}
/>
<div className="mt-6">
<PrimaryButton
type="submit"
form={FORM_ID}
loading={
status === "resolving_address" ||
status === "validating" ||
status === "processing"
}
disabled={formDisabled}
className="w-full sm:w-auto"
>
获取报价
</PrimaryButton>
</div>
</>
)}
</div>
<div className="lg:col-span-2">
{mothershipLoggedInUi && loggedInDraft ? (
<MothershipLoggedInQuoteSidebar
payload={loggedInDraft}
quote={quote}
status={status}
error={error}
checkoutBusy={checkoutBusy}
checkoutMessage={checkoutMessage}
actionMode={
isEmbeddedHost && hostBridge ? "save" : "checkout"
}
onCheckout={(intent) => void handleMsCheckout(intent)}
onSelectQuote={
isEmbeddedHost && hostBridge
? handleSelectQuoteForHost
: undefined
}
/>
) : (
<QuoteResultPanel
status={status}
quote={quote}
error={error}
onRetry={reset}
onExpire={() => setStatus("expired")}
onExpiredConfirm={reset}
/>
)}
</div>
</div>
)}
{!mothershipLoggedInUi && (
<AddressDisambiguationModal
open={disambiguationOpen}
pickupCandidates={pickupCandidates}
deliveryCandidates={deliveryCandidates}
onConfirm={(pickup, delivery) =>
void handleDisambiguationConfirm(pickup, delivery)
}
onCancel={handleDisambiguationCancel}
/>
)}
{mothershipLoggedInUi && refineHold && quote?.quote_id ? (
<MsRefineHoldPrompt
decisionDeadlineMs={refineHold.decision_deadline_ms}
totalDeadlineMs={refineHold.total_deadline_ms}
needsDetailsFirst={
quote.error_code === MS_NEEDS_DETAILS_ERROR_CODE
}
onContinue={() => {
setRefineAccepted(true);
setRefineActive({
quoteId: quote.quote_id,
quote_session_id: refineHold.quote_session_id,
total_deadline_ms: refineHold.total_deadline_ms,
});
setRefineHold(null);
setLoggedInStep("details");
void hostContinueMsRefineHold(
apiBaseUrl,
serviceToken,
customerId,
quote.quote_id,
refineHold.quote_session_id,
);
}}
onDecline={() => {
void declineRefineHold(refineHold, quote.quote_id);
}}
/>
) : null}
</>
)}
</div>
);
}