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.

721 lines
24 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 {
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>
);
}