feat(vehicles): automatic Vinpin VIN-decode fallback (flag-gated, off by default)
When PL24/pcat/emex can't decode a Fiat VIN, decode it via the Vinpin ePER web catalog (warm-session Playwright worker, single seat, BullMQ concurrency 1), cache the exact vehicle in vinpin_decodes, match it to PL24's existing catalog_vehicle for that model, and serve the parts from there. Vinpin = decode oracle only; PL24 already holds the parts (e.g. Egea/Linea/Doblo). Strictly gated behind VINPIN_ENABLED (default false) + a Fiat-only brand allowlist: with the flag off, decodeVin behaviour is byte-identical and the queue is never touched (covered by tests). Coordinates/selectors in vinpin.constants.ts are marked TUNE-AGAINST-LIVE-PAID-SEAT. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,10 @@ const VIN_REGEX = /^[A-HJ-NPR-Z0-9]{17}$/;
|
||||
// Sample VW Golf used by the "try example" affordance + the activation experiment.
|
||||
const EXAMPLE_VIN = "WVWZZZ1JZ3W597935";
|
||||
|
||||
// Vinpin ePER background-decode polling budget (the VINPIN_ENABLED fallback).
|
||||
const VINPIN_MAX_POLLS = 6;
|
||||
const VINPIN_POLL_INTERVAL_MS = 3000;
|
||||
|
||||
interface VehicleHistoryItem {
|
||||
id: string;
|
||||
vin: string;
|
||||
@@ -162,6 +166,12 @@ function SearchPage() {
|
||||
// into the existing /dashboard/catalog instead of dead-ending.
|
||||
const [noCatalog, setNoCatalog] = useState<{ brandName: string; display: string } | null>(null);
|
||||
|
||||
// Vinpin ePER fallback: the VIN is being identified from the authorized
|
||||
// catalog in the background. We poll the decode endpoint a few times until it
|
||||
// resolves to a catalog vehicle (navigate) or gives up (noCatalog/error).
|
||||
const [decoding, setDecoding] = useState<{ vin: string; display: string } | null>(null);
|
||||
const decodePollRef = useRef<number | null>(null);
|
||||
|
||||
const { data: history } = useQuery({
|
||||
queryKey: ["vehicles", "history"],
|
||||
queryFn: () => api.get<VehicleHistoryItem[]>("/vehicles/history?limit=6"),
|
||||
@@ -260,10 +270,55 @@ function SearchPage() {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// ─── Vinpin ePER fallback helpers ──────────────────────────────────────────
|
||||
function goToCatalogVehicle(cv: any) {
|
||||
setDecoding(null);
|
||||
navigate({
|
||||
to: "/dashboard/catalog/$brandName/$modelId",
|
||||
params: { brandName: cv.brandName, modelId: cv.id },
|
||||
search: { body: undefined, engine: undefined, gearbox: undefined, mgp: undefined },
|
||||
});
|
||||
}
|
||||
|
||||
// Re-hit the decode endpoint until the background Vinpin decode resolves to a
|
||||
// catalog vehicle, gives up (noCatalog), or we exhaust the poll budget.
|
||||
function startVinpinPoll(cleanVin: string, pollCount: number) {
|
||||
if (decodePollRef.current !== null) window.clearTimeout(decodePollRef.current);
|
||||
if (pollCount >= VINPIN_MAX_POLLS) {
|
||||
// Took too long — fall back to a friendly model-browse prompt.
|
||||
setDecoding(null);
|
||||
setNoCatalog({ brandName: "Fiat", display: "" });
|
||||
return;
|
||||
}
|
||||
decodePollRef.current = window.setTimeout(async () => {
|
||||
try {
|
||||
const data = await api.post<any>("/vehicles/decode", { vin: cleanVin });
|
||||
if (data.catalogVehicle) {
|
||||
goToCatalogVehicle(data.catalogVehicle);
|
||||
} else if (data.id) {
|
||||
setDecoding(null);
|
||||
navigate({ to: "/dashboard/vehicles/$id", params: { id: data.id } });
|
||||
} else if (data.noCatalog) {
|
||||
setDecoding(null);
|
||||
setNoCatalog({ brandName: data.noCatalog.brandName, display: data.noCatalog.display });
|
||||
} else if (data.decoding) {
|
||||
startVinpinPoll(cleanVin, pollCount + 1);
|
||||
} else {
|
||||
setDecoding(null);
|
||||
}
|
||||
} catch {
|
||||
// Transient — keep polling until the budget is exhausted.
|
||||
startVinpinPoll(cleanVin, pollCount + 1);
|
||||
}
|
||||
}, VINPIN_POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
// ─── Decode runner (shared by submit and retry) ────────────────────────────
|
||||
async function runDecode(cleanVin: string, attempt: number) {
|
||||
setError(null);
|
||||
setNoCatalog(null);
|
||||
setDecoding(null);
|
||||
if (decodePollRef.current !== null) window.clearTimeout(decodePollRef.current);
|
||||
setLoading(true);
|
||||
const decodeStart = performance.now();
|
||||
try {
|
||||
@@ -300,6 +355,33 @@ function SearchPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Vinpin ePER fallback resolved the VIN to an existing catalog vehicle —
|
||||
// land the user on that EXACT catalog (parts come from PL24).
|
||||
if (data.catalogVehicle) {
|
||||
capture("vin_decode_vinpin_resolved", {
|
||||
vin: cleanVin,
|
||||
catalog_vehicle_id: data.catalogVehicle.id,
|
||||
response_time_ms: responseTimeMs,
|
||||
query_source: querySourceRef.current,
|
||||
});
|
||||
goToCatalogVehicle(data.catalogVehicle);
|
||||
return;
|
||||
}
|
||||
|
||||
// Vinpin ePER fallback is identifying the VIN in the background → poll.
|
||||
if (data.decoding) {
|
||||
setPreview(null);
|
||||
setPreviewError(false);
|
||||
setDecoding({ vin: cleanVin, display: data.decoding.display ?? "" });
|
||||
capture("vin_decode_vinpin_pending", {
|
||||
vin: cleanVin,
|
||||
response_time_ms: responseTimeMs,
|
||||
query_source: querySourceRef.current,
|
||||
});
|
||||
startVinpinPoll(cleanVin, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.noCatalog) {
|
||||
// Brand recognized but no catalog for this VIN — clear the live preview
|
||||
// and surface the model-browse fallback instead of an error.
|
||||
@@ -451,6 +533,9 @@ function SearchPage() {
|
||||
if (correctionTimerRef.current !== null) {
|
||||
window.clearTimeout(correctionTimerRef.current);
|
||||
}
|
||||
if (decodePollRef.current !== null) {
|
||||
window.clearTimeout(decodePollRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -460,6 +545,7 @@ function SearchPage() {
|
||||
setVin(cleaned);
|
||||
setError(null);
|
||||
setNoCatalog(null);
|
||||
setDecoding(null);
|
||||
setReportSent(false);
|
||||
if (corrections.length > 0) {
|
||||
for (const c of corrections) correctionsRef.current.add(c);
|
||||
@@ -717,6 +803,26 @@ function SearchPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── Vinpin ePER background decode (VINPIN_ENABLED fallback) ─────── */}
|
||||
{decoding && (
|
||||
<div className="rounded-2xl border border-brand/30 bg-background p-5 sm:p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-brand/10">
|
||||
<Loader2 className="size-5 animate-spin text-brand" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-[family-name:var(--font-display)] text-lg font-bold">
|
||||
{t("search.decodingTitle")}
|
||||
</p>
|
||||
{decoding.display && (
|
||||
<p className="mt-0.5 text-sm font-medium text-foreground">{decoding.display}</p>
|
||||
)}
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t("search.decodingHint")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── No-catalog fallback: brand known, offer model-browse ───────── */}
|
||||
{noCatalog && (
|
||||
<div className="rounded-2xl border border-brand/30 bg-background p-5 sm:p-6">
|
||||
|
||||
Reference in New Issue
Block a user