feat(vehicles): model-browse fallback for no-catalog VINs
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
When a VIN can't be decoded by any source but its WMI brand is known and
has a browse-able catalog (SERVICE_TO_BRAND), return a structured
{ noCatalog: { brandName, display }, vin } 200 response instead of
dead-ending. The web surfaces a "kataloğunu modelden incele" CTA that
deep-links into the existing /dashboard/catalog browse, where the parts
usually exist (Fiat Egea NM4356 -> PL24 TIPO-EGEA; old Renault VF1 -> 147k
emex parts) but aren't reachable by the specific VIN's index entry.
Brands with no browse catalog (Honda, Maserati, Alfa, ...) keep the
existing informative dead-end. New analytics: vin_decode_no_catalog +
vin_no_catalog_browse_clicked (this case no longer emits vin_decode_error).
Tests: 2 api (browseable -> fallback, non-browseable -> throws) + 1 web
(CTA renders, no error banner). RCA writeup:
/home/s/ss/katalogsiz-vin-rca-2026-06-23.md
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -169,6 +169,31 @@ test("retry suppressed for subscription block (abone olun)", async () => {
|
||||
expect(screen.queryByRole("button", { name: /Tekrar Dene/i })).toBeNull();
|
||||
});
|
||||
|
||||
test("no-catalog fallback: identified brand shows a model-browse CTA, not an error", async () => {
|
||||
(api.post as any).mockResolvedValueOnce({
|
||||
noCatalog: { brandName: "Fiat", display: "Fiat Tipo 2018" },
|
||||
vin: TEST_VIN,
|
||||
});
|
||||
|
||||
renderSearch();
|
||||
typeVin(TEST_VIN);
|
||||
await submitForm();
|
||||
|
||||
// The model-browse CTA renders (deep-links into /dashboard/catalog/:brand).
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("link", { name: /modelden incele/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// It is a helpful state, NOT an error banner.
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
|
||||
// Analytics: the no-catalog event fires (replacing vin_decode_error for this case).
|
||||
expect(capture).toHaveBeenCalledWith(
|
||||
"vin_decode_no_catalog",
|
||||
expect.objectContaining({ vin: TEST_VIN, brand_name: "Fiat" }),
|
||||
);
|
||||
});
|
||||
|
||||
// ─── FN-415: locator-stability + hit-target audit (AC 1, 3, 4) ────────────
|
||||
test("locator stability: canonical Faro/ARIA hooks resolve and no data-testid is used", async () => {
|
||||
const { ApiError: MockApiError } = await import("@/lib/api-client");
|
||||
|
||||
@@ -158,6 +158,10 @@ function SearchPage() {
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const [previewError, setPreviewError] = useState(false);
|
||||
|
||||
// Decoded the brand but no catalog has this exact VIN → offer model-browse
|
||||
// into the existing /dashboard/catalog instead of dead-ending.
|
||||
const [noCatalog, setNoCatalog] = useState<{ brandName: string; display: string } | null>(null);
|
||||
|
||||
const { data: history } = useQuery({
|
||||
queryKey: ["vehicles", "history"],
|
||||
queryFn: () => api.get<VehicleHistoryItem[]>("/vehicles/history?limit=6"),
|
||||
@@ -259,6 +263,7 @@ function SearchPage() {
|
||||
// ─── Decode runner (shared by submit and retry) ────────────────────────────
|
||||
async function runDecode(cleanVin: string, attempt: number) {
|
||||
setError(null);
|
||||
setNoCatalog(null);
|
||||
setLoading(true);
|
||||
const decodeStart = performance.now();
|
||||
try {
|
||||
@@ -295,6 +300,21 @@ function SearchPage() {
|
||||
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.
|
||||
setPreview(null);
|
||||
setPreviewError(false);
|
||||
setNoCatalog({ brandName: data.noCatalog.brandName, display: data.noCatalog.display });
|
||||
capture("vin_decode_no_catalog", {
|
||||
vin: cleanVin,
|
||||
brand_name: data.noCatalog.brandName,
|
||||
response_time_ms: responseTimeMs,
|
||||
query_source: querySourceRef.current,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
capture("vin_decode_success", {
|
||||
vin: cleanVin,
|
||||
vehicle_id: data.id,
|
||||
@@ -324,6 +344,7 @@ function SearchPage() {
|
||||
async function handleSearch(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setNoCatalog(null);
|
||||
|
||||
const cleanVin = vin.toUpperCase().trim();
|
||||
const querySource = querySourceRef.current;
|
||||
@@ -438,6 +459,7 @@ function SearchPage() {
|
||||
const { cleaned, corrections } = sanitizeVin(upper);
|
||||
setVin(cleaned);
|
||||
setError(null);
|
||||
setNoCatalog(null);
|
||||
setReportSent(false);
|
||||
if (corrections.length > 0) {
|
||||
for (const c of corrections) correctionsRef.current.add(c);
|
||||
@@ -695,6 +717,39 @@ function SearchPage() {
|
||||
</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">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-brand/10">
|
||||
<Car className="size-5 text-brand" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-[family-name:var(--font-display)] text-lg font-bold">
|
||||
{t("search.noCatalogTitle")}
|
||||
</p>
|
||||
<p className="mt-0.5 text-sm font-medium text-foreground">{noCatalog.display}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t("search.noCatalogHint", { brand: noCatalog.brandName })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="my-4 bg-border" />
|
||||
<Button asChild className="h-11 w-full rounded-xl">
|
||||
<Link
|
||||
to="/dashboard/catalog/$brandName"
|
||||
params={{ brandName: noCatalog.brandName }}
|
||||
search={{ catalog: undefined }}
|
||||
onClick={() =>
|
||||
capture("vin_no_catalog_browse_clicked", { brand_name: noCatalog.brandName })
|
||||
}
|
||||
>
|
||||
{t("search.browseCatalogCta", { brand: noCatalog.brandName })}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── SECTION 2: Live Preview Card ───────────────────────────────── */}
|
||||
{previewLoading && (
|
||||
<div className="flex items-center justify-center gap-3 rounded-2xl border border-border bg-background p-6">
|
||||
|
||||
Reference in New Issue
Block a user