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.

323 lines
9.1 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";
export interface EmbeddedQuoteWidgetProps {
serviceToken: string;
customerId: string;
apiBaseUrl?: string;
}
const FORM_ID = "embedded-quote-form";
export function EmbeddedQuoteWidget({
serviceToken,
customerId,
apiBaseUrl = "",
}: EmbeddedQuoteWidgetProps) {
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;
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>
{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>
);
}