|
|
"use client";
|
|
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
|
import {
|
|
|
FlockQuoteForm,
|
|
|
type FlockFormSubmitPayload,
|
|
|
} from "@/components/flock/flock-quote-form";
|
|
|
import {
|
|
|
FlockLoggedInQuoteForm,
|
|
|
mapFlockLoggedInToApiInput,
|
|
|
type FlockLoggedInQuotePayload,
|
|
|
} from "@/components/flock/flock-logged-in-quote-form";
|
|
|
import {
|
|
|
FlockLoggedInDetailsForm,
|
|
|
type FlockLoggedInDetailsFormHandle,
|
|
|
} from "@/components/flock/flock-logged-in-details-form";
|
|
|
import {
|
|
|
FlockLoggedInQuoteSidebar,
|
|
|
} from "@/components/flock/flock-logged-in-quote-sidebar";
|
|
|
import { FlockQuoteProgress } from "@/components/flock/flock-quote-progress";
|
|
|
import { FlockQuoteResult } from "@/components/flock/flock-quote-result";
|
|
|
import { WarningBanner } from "@/components/ui/warning-banner";
|
|
|
import { ErrorBanner } from "@/components/ui/error-banner";
|
|
|
import { SUBMIT_LOCK_MS } from "@/lib/frontend/constants";
|
|
|
import {
|
|
|
hostCreateFlockQuote,
|
|
|
hostGetQuote,
|
|
|
hostSubmitFlockCheckout,
|
|
|
hostSubmitFlockPricingOptions,
|
|
|
hostContinueFlockQuoteHold,
|
|
|
hostDeclineFlockQuoteHold,
|
|
|
} from "@/lib/frontend/api-client";
|
|
|
import { uuidV4 } from "@/lib/frontend/format";
|
|
|
import type { QuoteDetail, QuotePageStatus } from "@/lib/frontend/types";
|
|
|
import { pollQuoteUntilDone } from "@/hooks/use-quote-polling";
|
|
|
import { getFlockPollTimeoutMs } from "@/lib/flock/poll-timeout";
|
|
|
import { formatQuoteErrorMessage } from "@/modules/quote/quote-error-messages";
|
|
|
import { useHostBridgeOptional } from "@/lib/embed/host-bridge-react";
|
|
|
import type {
|
|
|
FlockCarrierOption,
|
|
|
FlockCheckoutTier,
|
|
|
FlockFlexibilityKey,
|
|
|
FlockFlexibilityOption,
|
|
|
} from "@/lib/flock/flock-checkout-selection";
|
|
|
import { FlockQuoteHoldPrompt } from "@/components/flock/flock-quote-hold-prompt";
|
|
|
|
|
|
export interface FlockQuoteWidgetProps {
|
|
|
serviceToken?: string;
|
|
|
customerId: string;
|
|
|
apiBaseUrl?: string;
|
|
|
/** 客户已绑定 Flock 账密 → 显示登录后 Quick 查价界面 */
|
|
|
flockLoggedInUi?: boolean;
|
|
|
}
|
|
|
|
|
|
/** Flock Freight 内嵌查价:表单 + 可视化进度 + 轮询 + 两档结果 */
|
|
|
export function FlockQuoteWidget({
|
|
|
serviceToken,
|
|
|
customerId,
|
|
|
apiBaseUrl = "",
|
|
|
flockLoggedInUi = false,
|
|
|
}: FlockQuoteWidgetProps) {
|
|
|
const hostBridge = useHostBridgeOptional();
|
|
|
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 [activeQuoteId, setActiveQuoteId] = useState<string | null>(null);
|
|
|
const [startedAtMs, setStartedAtMs] = useState<number | null>(null);
|
|
|
const [rpaStage, setRpaStage] = useState<string | null>(null);
|
|
|
const [rpaStageLabel, setRpaStageLabel] = useState<string | null>(null);
|
|
|
const [checkoutBusy, setCheckoutBusy] = useState(false);
|
|
|
const [checkoutMessage, setCheckoutMessage] = useState<string | null>(null);
|
|
|
const [selectedTier, setSelectedTier] = useState<FlockCheckoutTier | null>(
|
|
|
null,
|
|
|
);
|
|
|
const [selectedFlexibility, setSelectedFlexibility] =
|
|
|
useState<FlockFlexibilityKey | null>(null);
|
|
|
const [selectedCarrier, setSelectedCarrier] = useState<string | null>(null);
|
|
|
/** 兜底二次拉价时写入对应档(首价同步优先走 quote.flock_flexibility_by_tier) */
|
|
|
const [flexFallbackByTier, setFlexFallbackByTier] = useState<
|
|
|
Partial<Record<FlockCheckoutTier, FlockFlexibilityOption[]>>
|
|
|
>({});
|
|
|
const [carrierFallbackByTier, setCarrierFallbackByTier] = useState<
|
|
|
Partial<Record<FlockCheckoutTier, FlockCarrierOption[]>>
|
|
|
>({});
|
|
|
const [flexBusy, setFlexBusy] = useState(false);
|
|
|
const [flexMessage, setFlexMessage] = useState<string | null>(null);
|
|
|
const [flockHold, setFlockHold] = useState<
|
|
|
NonNullable<QuoteDetail["flock_hold"]> | null
|
|
|
>(null);
|
|
|
const [holdAccepted, setHoldAccepted] = useState(false);
|
|
|
const checkoutAbortRef = useRef<AbortController | null>(null);
|
|
|
const flexAbortRef = useRef<AbortController | null>(null);
|
|
|
const detailsRef = useRef<FlockLoggedInDetailsFormHandle>(null);
|
|
|
|
|
|
const reset = useCallback(() => {
|
|
|
setStatus("idle");
|
|
|
setQuote(null);
|
|
|
setError(null);
|
|
|
setActiveQuoteId(null);
|
|
|
setStartedAtMs(null);
|
|
|
setRpaStage(null);
|
|
|
setRpaStageLabel(null);
|
|
|
setCheckoutBusy(false);
|
|
|
setCheckoutMessage(null);
|
|
|
setSelectedTier(null);
|
|
|
setSelectedFlexibility(null);
|
|
|
setSelectedCarrier(null);
|
|
|
setFlexFallbackByTier({});
|
|
|
setCarrierFallbackByTier({});
|
|
|
setFlexBusy(false);
|
|
|
setFlexMessage(null);
|
|
|
setFlockHold(null);
|
|
|
setHoldAccepted(false);
|
|
|
checkoutAbortRef.current?.abort();
|
|
|
flexAbortRef.current?.abort();
|
|
|
}, []);
|
|
|
|
|
|
const finishQuote = useCallback(
|
|
|
(detail: QuoteDetail) => {
|
|
|
setQuote(detail);
|
|
|
setActiveQuoteId(detail.quote_id ?? null);
|
|
|
if (detail.rpa_stage) setRpaStage(detail.rpa_stage);
|
|
|
if (detail.rpa_stage_label) setRpaStageLabel(detail.rpa_stage_label);
|
|
|
const module = flockLoggedInUi ? "FLOCK_LOGGED_IN" : "FLOCK_GUEST";
|
|
|
const mapQuotes = () =>
|
|
|
(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,
|
|
|
breakdown: q.breakdown ?? [],
|
|
|
}));
|
|
|
|
|
|
if (detail.status === "failed") {
|
|
|
setStatus("error");
|
|
|
const msg =
|
|
|
detail.error_message ??
|
|
|
formatQuoteErrorMessage(detail.error_code) ??
|
|
|
"报价失败";
|
|
|
setError(msg);
|
|
|
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");
|
|
|
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;
|
|
|
}
|
|
|
if (!detail.flock?.lines?.length && !(detail.quotes?.length)) {
|
|
|
setStatus("error");
|
|
|
setError("未返回 Flock 报价档位");
|
|
|
hostBridge?.reportError("RPA_DATA_INVALID", "未返回 Flock 报价档位", module);
|
|
|
return;
|
|
|
}
|
|
|
setStatus(detail.is_realtime === false ? "fallback" : "success");
|
|
|
setError(null);
|
|
|
setSelectedTier(null);
|
|
|
setSelectedFlexibility(null);
|
|
|
setSelectedCarrier(null);
|
|
|
setFlexFallbackByTier({});
|
|
|
setCarrierFallbackByTier({});
|
|
|
// 无保活会话时直接解锁二级(冷启动兜底)
|
|
|
if (flockLoggedInUi && detail.flock_hold?.available && !holdAccepted) {
|
|
|
setFlockHold(detail.flock_hold);
|
|
|
setHoldAccepted(false);
|
|
|
} else {
|
|
|
setFlockHold(null);
|
|
|
if (flockLoggedInUi && !detail.flock_hold?.available) {
|
|
|
setHoldAccepted(true);
|
|
|
}
|
|
|
}
|
|
|
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: mapQuotes().length
|
|
|
? mapQuotes()
|
|
|
: (detail.flock?.lines ?? []).map((line) => ({
|
|
|
carrier: line.carrier || "Flock Freight",
|
|
|
service_level: line.serviceLevel || "standard",
|
|
|
rate_option: line.rateOption || "lowest",
|
|
|
transit_days: String(line.transitDays ?? ""),
|
|
|
transit_description: line.transitDescription ?? line.label ?? "",
|
|
|
raw_freight: Number(line.totalUsd ?? 0),
|
|
|
surcharges: 0,
|
|
|
raw_total: Number(line.totalUsd ?? 0),
|
|
|
markup_percent: 0,
|
|
|
markup_amount: Number(line.markup_amount ?? 0),
|
|
|
final_total: Number(line.final_total_usd ?? line.totalUsd ?? 0),
|
|
|
breakdown: [],
|
|
|
})),
|
|
|
error_code: null,
|
|
|
error_message: null,
|
|
|
});
|
|
|
},
|
|
|
[flockLoggedInUi, hostBridge, holdAccepted],
|
|
|
);
|
|
|
|
|
|
const handleSubmit = useCallback(
|
|
|
async (payload: FlockFormSubmitPayload & Record<string, unknown>) => {
|
|
|
if (submitLocked) return;
|
|
|
setSubmitLocked(true);
|
|
|
setTimeout(() => setSubmitLocked(false), SUBMIT_LOCK_MS);
|
|
|
setError(null);
|
|
|
setQuote(null);
|
|
|
setActiveQuoteId(null);
|
|
|
setStartedAtMs(Date.now());
|
|
|
setRpaStage("queued");
|
|
|
setRpaStageLabel("任务已入队,等待 Worker 领取");
|
|
|
setStatus("processing");
|
|
|
setCheckoutMessage(null);
|
|
|
setHoldAccepted(false);
|
|
|
setFlockHold(null);
|
|
|
|
|
|
const created = await hostCreateFlockQuote(apiBaseUrl, serviceToken, {
|
|
|
request_id: uuidV4(),
|
|
|
customer_id: customerId,
|
|
|
flock_input: payload,
|
|
|
});
|
|
|
|
|
|
if (created.code !== 0) {
|
|
|
setStatus("error");
|
|
|
setError(created.message || "提交失败");
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const { quote_id } = created.data;
|
|
|
setActiveQuoteId(quote_id);
|
|
|
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 };
|
|
|
}
|
|
|
},
|
|
|
{
|
|
|
timeoutMs: getFlockPollTimeoutMs(),
|
|
|
onProcessing: (detail) => {
|
|
|
if (detail.rpa_stage) setRpaStage(detail.rpa_stage);
|
|
|
if (detail.rpa_stage_label) setRpaStageLabel(detail.rpa_stage_label);
|
|
|
},
|
|
|
},
|
|
|
);
|
|
|
|
|
|
if (pollResult.type === "done") {
|
|
|
finishQuote(pollResult.quote);
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
setStatus("error");
|
|
|
if (pollResult.type === "error" && pollResult.quote?.rpa_stage) {
|
|
|
setRpaStage(pollResult.quote.rpa_stage);
|
|
|
setRpaStageLabel(pollResult.quote.rpa_stage_label ?? null);
|
|
|
}
|
|
|
setError(
|
|
|
pollResult.type === "timeout"
|
|
|
? "Flock 询价超时,请稍后重试(可在进度条确认停在哪一步)"
|
|
|
: pollResult.message || "查询失败",
|
|
|
);
|
|
|
},
|
|
|
[apiBaseUrl, customerId, finishQuote, serviceToken, submitLocked],
|
|
|
);
|
|
|
|
|
|
const handleLoggedInSubmit = useCallback(
|
|
|
(payload: FlockLoggedInQuotePayload) => {
|
|
|
const mapped = mapFlockLoggedInToApiInput(payload);
|
|
|
void handleSubmit(mapped);
|
|
|
},
|
|
|
[handleSubmit],
|
|
|
);
|
|
|
|
|
|
const handleSelectTier = useCallback(
|
|
|
async (tier: FlockCheckoutTier) => {
|
|
|
if (!quote?.quote_id) return;
|
|
|
setSelectedTier(tier);
|
|
|
setSelectedFlexibility(null);
|
|
|
setSelectedCarrier(null);
|
|
|
flexAbortRef.current?.abort();
|
|
|
|
|
|
const cachedFlex =
|
|
|
quote.flock_flexibility_by_tier?.[tier] ?? flexFallbackByTier[tier];
|
|
|
const cachedCarriers =
|
|
|
quote.flock_carriers_by_tier?.[tier] ?? carrierFallbackByTier[tier];
|
|
|
if (
|
|
|
(cachedFlex && cachedFlex.length > 0) ||
|
|
|
(cachedCarriers && cachedCarriers.length > 0)
|
|
|
) {
|
|
|
setFlexBusy(false);
|
|
|
setFlexMessage(null);
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
// 兜底:首价未带回该档报价时二次拉价
|
|
|
const ac = new AbortController();
|
|
|
flexAbortRef.current = ac;
|
|
|
setFlexBusy(true);
|
|
|
setFlexMessage("正在官网拉取档内报价…");
|
|
|
try {
|
|
|
const res = await hostSubmitFlockPricingOptions(
|
|
|
apiBaseUrl,
|
|
|
serviceToken,
|
|
|
customerId,
|
|
|
{ quoteId: quote.quote_id, preferredTier: tier },
|
|
|
);
|
|
|
if (ac.signal.aborted) return;
|
|
|
if (res.code !== 0) {
|
|
|
setFlexMessage(res.message);
|
|
|
setFlexBusy(false);
|
|
|
return;
|
|
|
}
|
|
|
const deadline = Date.now() + 6 * 60_000;
|
|
|
while (Date.now() < deadline) {
|
|
|
if (ac.signal.aborted) return;
|
|
|
await new Promise<void>((resolve) => setTimeout(resolve, 2_000));
|
|
|
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.flock_pricing_options;
|
|
|
if (!st || st.preferred_tier !== tier) continue;
|
|
|
if (st.status === "done") {
|
|
|
if (
|
|
|
st.options_type === "carriers" &&
|
|
|
(st.carrier_options?.length ?? 0) > 0
|
|
|
) {
|
|
|
setCarrierFallbackByTier((prev) => ({
|
|
|
...prev,
|
|
|
[tier]: st.carrier_options!,
|
|
|
}));
|
|
|
setFlexMessage(null);
|
|
|
setFlexBusy(false);
|
|
|
return;
|
|
|
}
|
|
|
if (st.options.length > 0) {
|
|
|
setFlexFallbackByTier((prev) => ({
|
|
|
...prev,
|
|
|
[tier]: st.options,
|
|
|
}));
|
|
|
setFlexMessage(null);
|
|
|
setFlexBusy(false);
|
|
|
return;
|
|
|
}
|
|
|
}
|
|
|
if (st.status === "failed") {
|
|
|
setFlexMessage(st.message || "拉取档内报价失败");
|
|
|
setFlexBusy(false);
|
|
|
return;
|
|
|
}
|
|
|
setFlexMessage(st.message || "正在官网拉取档内报价…");
|
|
|
}
|
|
|
if (!ac.signal.aborted) {
|
|
|
setFlexMessage("拉取档内报价超时,请重试");
|
|
|
setFlexBusy(false);
|
|
|
}
|
|
|
} catch (err) {
|
|
|
if (ac.signal.aborted) return;
|
|
|
setFlexMessage(err instanceof Error ? err.message : "拉取失败");
|
|
|
setFlexBusy(false);
|
|
|
}
|
|
|
},
|
|
|
[
|
|
|
quote,
|
|
|
apiBaseUrl,
|
|
|
serviceToken,
|
|
|
customerId,
|
|
|
flexFallbackByTier,
|
|
|
carrierFallbackByTier,
|
|
|
],
|
|
|
);
|
|
|
|
|
|
const showL2DetailsForm =
|
|
|
holdAccepted && (!!selectedFlexibility || !!selectedCarrier);
|
|
|
|
|
|
const acceptHoldIfNeeded = useCallback(() => {
|
|
|
if (flockHold && !holdAccepted) {
|
|
|
const sessionId = flockHold.quote_session_id;
|
|
|
const qid = quote?.quote_id;
|
|
|
setHoldAccepted(true);
|
|
|
setFlockHold(null);
|
|
|
if (qid && sessionId) {
|
|
|
void hostContinueFlockQuoteHold(
|
|
|
apiBaseUrl,
|
|
|
serviceToken,
|
|
|
customerId,
|
|
|
qid,
|
|
|
sessionId,
|
|
|
);
|
|
|
}
|
|
|
}
|
|
|
}, [
|
|
|
flockHold,
|
|
|
holdAccepted,
|
|
|
quote?.quote_id,
|
|
|
apiBaseUrl,
|
|
|
serviceToken,
|
|
|
customerId,
|
|
|
]);
|
|
|
|
|
|
// 选中灵活价后若仍卡在 hold 决策窗,自动视为继续填写(避免无「继续填写」时永远不出二级)
|
|
|
const handleSelectFlexibility = useCallback(
|
|
|
(key: FlockFlexibilityKey) => {
|
|
|
setSelectedFlexibility(key);
|
|
|
setSelectedCarrier(null);
|
|
|
acceptHoldIfNeeded();
|
|
|
},
|
|
|
[acceptHoldIfNeeded],
|
|
|
);
|
|
|
|
|
|
const handleSelectCarrier = useCallback(
|
|
|
(carrierName: string) => {
|
|
|
setSelectedCarrier(carrierName);
|
|
|
setSelectedFlexibility(null);
|
|
|
acceptHoldIfNeeded();
|
|
|
},
|
|
|
[acceptHoldIfNeeded],
|
|
|
);
|
|
|
|
|
|
const handleCheckout = useCallback(async () => {
|
|
|
if (!quote?.quote_id) {
|
|
|
setCheckoutMessage("请先完成询价");
|
|
|
return;
|
|
|
}
|
|
|
if (!selectedTier || (!selectedFlexibility && !selectedCarrier)) {
|
|
|
setCheckoutMessage("请先选择服务档与报价选项");
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
let flockDetails:
|
|
|
| ReturnType<FlockLoggedInDetailsFormHandle["validateAndGetDetails"]>
|
|
|
| undefined;
|
|
|
if (flockLoggedInUi) {
|
|
|
const details = detailsRef.current?.validateAndGetDetails() ?? null;
|
|
|
if (!details) {
|
|
|
setCheckoutMessage("请先完善左侧结账详情");
|
|
|
return;
|
|
|
}
|
|
|
flockDetails = details;
|
|
|
}
|
|
|
|
|
|
checkoutAbortRef.current?.abort();
|
|
|
const ac = new AbortController();
|
|
|
checkoutAbortRef.current = ac;
|
|
|
setCheckoutBusy(true);
|
|
|
setCheckoutMessage("正在同步官网选择报价档…");
|
|
|
try {
|
|
|
const res = await hostSubmitFlockCheckout(
|
|
|
apiBaseUrl,
|
|
|
serviceToken,
|
|
|
customerId,
|
|
|
{
|
|
|
quoteId: quote.quote_id,
|
|
|
preferredTier: selectedTier,
|
|
|
preferredFlexibility: selectedFlexibility ?? undefined,
|
|
|
preferredCarrier: selectedCarrier ?? undefined,
|
|
|
flockDetails: flockDetails ?? undefined,
|
|
|
},
|
|
|
);
|
|
|
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.flock_checkout;
|
|
|
if (!st) continue;
|
|
|
if (st.status === "done") {
|
|
|
setQuote(r.data);
|
|
|
setCheckoutMessage(
|
|
|
st.message ||
|
|
|
`已填齐详情(${selectedTier === "flock_direct" ? "FlockDirect®" : "Standard"},未支付)`,
|
|
|
);
|
|
|
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,
|
|
|
customerId,
|
|
|
apiBaseUrl,
|
|
|
serviceToken,
|
|
|
flockLoggedInUi,
|
|
|
selectedTier,
|
|
|
selectedFlexibility,
|
|
|
selectedCarrier,
|
|
|
]);
|
|
|
|
|
|
useEffect(() => {
|
|
|
return () => {
|
|
|
checkoutAbortRef.current?.abort();
|
|
|
flexAbortRef.current?.abort();
|
|
|
};
|
|
|
}, []);
|
|
|
|
|
|
const showLoggedInDetails =
|
|
|
flockLoggedInUi &&
|
|
|
(status === "success" || status === "fallback") &&
|
|
|
!!quote?.flock;
|
|
|
|
|
|
return (
|
|
|
<div className="space-y-4">
|
|
|
{error && status === "error" && <ErrorBanner>{error}</ErrorBanner>}
|
|
|
{status === "expired" && (
|
|
|
<WarningBanner>报价已过期,请重新查询</WarningBanner>
|
|
|
)}
|
|
|
|
|
|
<FlockQuoteProgress
|
|
|
active={status === "processing"}
|
|
|
failed={status === "error"}
|
|
|
quoteId={activeQuoteId}
|
|
|
startedAtMs={startedAtMs}
|
|
|
rpaStage={rpaStage}
|
|
|
rpaStageLabel={rpaStageLabel}
|
|
|
errorMessage={error}
|
|
|
/>
|
|
|
|
|
|
{showLoggedInDetails ? (
|
|
|
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.15fr)_minmax(280px,0.85fr)]">
|
|
|
{showL2DetailsForm ? (
|
|
|
<FlockLoggedInDetailsForm
|
|
|
ref={detailsRef}
|
|
|
disabled={checkoutBusy}
|
|
|
pickupZip={quote!.flock?.shipment_meta?.origin_zip ?? ""}
|
|
|
deliveryZip={quote!.flock?.shipment_meta?.destination_zip ?? ""}
|
|
|
shipmentSummary={
|
|
|
quote!.flock?.shipment_meta
|
|
|
? [
|
|
|
quote!.flock.shipment_meta.pallet_count != null
|
|
|
? `${quote!.flock.shipment_meta.pallet_count} 件`
|
|
|
: null,
|
|
|
quote!.flock.shipment_meta.total_weight_lb != null
|
|
|
? `${quote!.flock.shipment_meta.total_weight_lb} lb`
|
|
|
: null,
|
|
|
quote!.flock.shipment_meta.origin_zip &&
|
|
|
quote!.flock.shipment_meta.destination_zip
|
|
|
? `${quote!.flock.shipment_meta.origin_zip} → ${quote!.flock.shipment_meta.destination_zip}`
|
|
|
: null,
|
|
|
]
|
|
|
.filter(Boolean)
|
|
|
.join(" · ") || undefined
|
|
|
: undefined
|
|
|
}
|
|
|
/>
|
|
|
) : (
|
|
|
<div className="rounded-lg border border-dashed border-border bg-surface/50 p-6 text-sm text-text-secondary">
|
|
|
<p className="font-medium text-text-primary">
|
|
|
{!holdAccepted
|
|
|
? "请先确认是否继续填写二级信息"
|
|
|
: "请先选择报价选项"}
|
|
|
</p>
|
|
|
<ol className="mt-3 list-decimal space-y-1 pl-5">
|
|
|
<li>右侧按官网展示 FlockDirect® / 标准 LTL 双卡</li>
|
|
|
<li>在 60 秒确认窗选择「继续填写」</li>
|
|
|
<li>点击「查看报价选项」,在弹窗中选择灵活价或承运商</li>
|
|
|
<li>完善左侧结账详情后前往结账(同会话同步,不支付)</li>
|
|
|
</ol>
|
|
|
</div>
|
|
|
)}
|
|
|
<FlockLoggedInQuoteSidebar
|
|
|
lines={quote!.flock!.lines}
|
|
|
reference={quote!.flock!.reference}
|
|
|
flexibilityByTier={{
|
|
|
...(quote!.flock_flexibility_by_tier ?? {}),
|
|
|
...flexFallbackByTier,
|
|
|
}}
|
|
|
carriersByTier={{
|
|
|
...(quote!.flock_carriers_by_tier ?? {}),
|
|
|
...carrierFallbackByTier,
|
|
|
}}
|
|
|
selectedTier={selectedTier}
|
|
|
selectedFlexibility={selectedFlexibility}
|
|
|
selectedCarrier={selectedCarrier}
|
|
|
flexBusy={flexBusy}
|
|
|
flexMessage={flexMessage}
|
|
|
checkoutBusy={checkoutBusy}
|
|
|
checkoutMessage={checkoutMessage}
|
|
|
detailsUnlocked={holdAccepted}
|
|
|
onSelectTier={(t) => void handleSelectTier(t)}
|
|
|
onSelectFlexibility={handleSelectFlexibility}
|
|
|
onSelectCarrier={handleSelectCarrier}
|
|
|
onCheckout={() => void handleCheckout()}
|
|
|
onReset={reset}
|
|
|
/>
|
|
|
</div>
|
|
|
) : status === "success" || status === "fallback" ? (
|
|
|
quote?.flock ? (
|
|
|
<FlockQuoteResult
|
|
|
lines={quote.flock.lines}
|
|
|
reference={quote.flock.reference}
|
|
|
onReset={reset}
|
|
|
checkoutEnabled={false}
|
|
|
/>
|
|
|
) : null
|
|
|
) : flockLoggedInUi ? (
|
|
|
<FlockLoggedInQuoteForm
|
|
|
disabled={status === "processing"}
|
|
|
submitLocked={submitLocked || status === "processing"}
|
|
|
onValidSubmit={handleLoggedInSubmit}
|
|
|
/>
|
|
|
) : (
|
|
|
<FlockQuoteForm
|
|
|
disabled={status === "processing"}
|
|
|
submitLocked={submitLocked || status === "processing"}
|
|
|
onSubmit={handleSubmit}
|
|
|
/>
|
|
|
)}
|
|
|
|
|
|
{flockLoggedInUi && flockHold && quote?.quote_id ? (
|
|
|
<FlockQuoteHoldPrompt
|
|
|
decisionDeadlineMs={flockHold.decision_deadline_ms}
|
|
|
totalDeadlineMs={flockHold.total_deadline_ms}
|
|
|
onContinue={() => {
|
|
|
setHoldAccepted(true);
|
|
|
setFlockHold(null);
|
|
|
void hostContinueFlockQuoteHold(
|
|
|
apiBaseUrl,
|
|
|
serviceToken,
|
|
|
customerId,
|
|
|
quote.quote_id,
|
|
|
flockHold.quote_session_id,
|
|
|
);
|
|
|
}}
|
|
|
onDecline={() => {
|
|
|
setFlockHold(null);
|
|
|
void hostDeclineFlockQuoteHold(
|
|
|
apiBaseUrl,
|
|
|
serviceToken,
|
|
|
customerId,
|
|
|
quote.quote_id,
|
|
|
flockHold.quote_session_id,
|
|
|
);
|
|
|
}}
|
|
|
/>
|
|
|
) : null}
|
|
|
</div>
|
|
|
);
|
|
|
}
|