|
|
"use client";
|
|
|
|
|
|
import { useCallback, 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,
|
|
|
hostFetchMothershipCandidates,
|
|
|
hostGetQuote,
|
|
|
hostPreheatQuoteSession,
|
|
|
hostReleaseQuoteSession,
|
|
|
} 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 } from "@/components/mothership/mothership-logged-in-details-form";
|
|
|
import { MothershipLoggedInQuoteSidebar } from "@/components/mothership/mothership-logged-in-quote-sidebar";
|
|
|
import { buildQuoteRequestBodyFromLoggedIn } from "@/lib/frontend/mothership-logged-in-quote-body";
|
|
|
|
|
|
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 [provider, setProvider] = useState<QuoteProviderId>(
|
|
|
forcedProvider ?? "mothership",
|
|
|
);
|
|
|
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 [loggedInStep, setLoggedInStep] = useState<"create" | "details">(
|
|
|
"create",
|
|
|
);
|
|
|
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");
|
|
|
}, []);
|
|
|
|
|
|
const finishQuote = useCallback((detail: QuoteDetail) => {
|
|
|
setQuote(detail);
|
|
|
if (detail.status === "failed") {
|
|
|
setStatus("error");
|
|
|
setError(
|
|
|
detail.error_message ??
|
|
|
formatQuoteErrorMessage(detail.error_code),
|
|
|
);
|
|
|
return;
|
|
|
}
|
|
|
if (detail.status === "expired") {
|
|
|
setStatus("expired");
|
|
|
return;
|
|
|
}
|
|
|
setStatus(detail.is_realtime === false ? "fallback" : "success");
|
|
|
setError(null);
|
|
|
}, []);
|
|
|
|
|
|
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";
|
|
|
|
|
|
return (
|
|
|
<div 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>
|
|
|
)}
|
|
|
|
|
|
<div className="grid gap-6 lg:grid-cols-5">
|
|
|
<div className="lg:col-span-3">
|
|
|
{mothershipLoggedInUi ? (
|
|
|
loggedInStep === "details" && loggedInDraft ? (
|
|
|
<>
|
|
|
<div className="mb-4">
|
|
|
<SecondaryButton
|
|
|
type="button"
|
|
|
onClick={() => setLoggedInStep("create")}
|
|
|
>
|
|
|
返回一级表单
|
|
|
</SecondaryButton>
|
|
|
</div>
|
|
|
<MothershipLoggedInDetailsForm
|
|
|
initial={loggedInDraft}
|
|
|
disabled={formDisabled}
|
|
|
/>
|
|
|
</>
|
|
|
) : (
|
|
|
<>
|
|
|
<MothershipLoggedInShipmentForm
|
|
|
formId={FORM_ID}
|
|
|
disabled={formDisabled}
|
|
|
customerId={customerId}
|
|
|
apiBaseUrl={apiBaseUrl}
|
|
|
serviceToken={serviceToken}
|
|
|
onValidSubmit={(payload) => {
|
|
|
setLoggedInDraft(payload);
|
|
|
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>
|
|
|
</>
|
|
|
)
|
|
|
) : (
|
|
|
<>
|
|
|
<QuoteForm
|
|
|
customerId={customerId}
|
|
|
disabled={formDisabled}
|
|
|
formId={FORM_ID}
|
|
|
confirmedPickup={confirmedPickup}
|
|
|
confirmedDelivery={confirmedDelivery}
|
|
|
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 ? (
|
|
|
loggedInStep === "details" && loggedInDraft ? (
|
|
|
<MothershipLoggedInQuoteSidebar
|
|
|
payload={loggedInDraft}
|
|
|
quote={quote}
|
|
|
status={status}
|
|
|
error={error}
|
|
|
/>
|
|
|
) : (
|
|
|
<div className="rounded-lg border border-border bg-bg p-4 text-sm text-text-secondary">
|
|
|
<p className="font-medium text-[#1F2937]">下一步</p>
|
|
|
<p className="mt-2 text-xs leading-relaxed">
|
|
|
点「继续」后进入详情页并开始询价,右侧展示承运商报价。
|
|
|
</p>
|
|
|
</div>
|
|
|
)
|
|
|
) : (
|
|
|
<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}
|
|
|
/>
|
|
|
)}
|
|
|
</>
|
|
|
)}
|
|
|
</div>
|
|
|
);
|
|
|
}
|