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.

354 lines
10 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 } from "@/components/ui/primary-button";
import { ErrorBanner } from "@/components/ui/error-banner";
import {
QuoteProviderSwitch,
type QuoteProviderId,
} from "@/components/embed/quote-provider-switch";
import { Priority1QuoteWidget } from "@/components/priority1/priority1-quote-widget";
export interface EmbeddedQuoteWidgetProps {
customerId: string;
apiBaseUrl?: string;
/** 未传时使用 Cookie 演示会话鉴权(/embed-demo */
serviceToken?: string;
}
const FORM_ID = "embedded-quote-form";
export function EmbeddedQuoteWidget({
serviceToken,
customerId,
apiBaseUrl = "",
}: EmbeddedQuoteWidgetProps) {
const [provider, setProvider] = useState<QuoteProviderId>("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 [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);
}, []);
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>
<QuoteProviderSwitch
value={provider}
onChange={(next) => {
setProvider(next);
reset();
}}
disabled={switchDisabled}
/>
{provider === "priority1" ? (
<Priority1QuoteWidget
serviceToken={serviceToken}
customerId={customerId}
apiBaseUrl={apiBaseUrl}
/>
) : (
<>
{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">
<QuoteForm
customerId={customerId}
disabled={formDisabled}
formId={FORM_ID}
confirmedPickup={confirmedPickup}
confirmedDelivery={confirmedDelivery}
onAddressDraftChange={() => {
setConfirmedPickup(null);
setConfirmedDelivery(null);
}}
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">
<QuoteResultPanel
status={status}
quote={quote}
error={error}
onRetry={reset}
onExpire={() => setStatus("expired")}
onExpiredConfirm={reset}
/>
</div>
</div>
<AddressDisambiguationModal
open={disambiguationOpen}
pickupCandidates={pickupCandidates}
deliveryCandidates={deliveryCandidates}
onConfirm={(pickup, delivery) =>
void handleDisambiguationConfirm(pickup, delivery)
}
onCancel={handleDisambiguationCancel}
/>
</>
)}
</div>
);
}