|
|
"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>
|
|
|
);
|
|
|
}
|