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.

483 lines
16 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, 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>
);
}