feat(vehicles): model-browse fallback for no-catalog VINs
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:
2026-06-23 08:58:58 +03:00
parent b3dd119c99
commit 9232e40942
6 changed files with 207 additions and 9 deletions

View File

@@ -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");

View File

@@ -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">