fix(web): stop leaking /vehicles/undefined and the raw uuid parse error

When the landing decodes a VIN for a logged-in visitor and the API
returns a multi-candidate response (no single id), the code was
navigating to /dashboard/vehicles/$id with id=undefined. The catalog
page then asked the API for /vehicles/undefined and the backend
"invalid input syntax for type uuid: 'undefined'" message landed in
front of the user.

Two-sided fix:
- On the landing, if the decode reply has no id, hand the VIN off to
  /dashboard/search?vin=… instead — that page already renders the
  candidate-picker and the unrecognised-VIN flow.
- On the vehicle detail page, gate both queries on a UUID check and
  render a friendly "Geçersiz araç bağlantısı / Aramaya git" panel for
  any malformed id. Also stop showing raw backend error messages in the
  generic error state — surface the localised hint instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-31 22:04:21 +03:00
parent a4597544b4
commit 209e98cb41
4 changed files with 60 additions and 10 deletions

View File

@@ -605,6 +605,9 @@
"showAll": "Show all ({count})",
"showLess": "Show less",
"categoryDetail": "Category detail",
"invalidLinkTitle": "Invalid vehicle link",
"invalidLinkHint": "This link doesn't point to a known vehicle. Go back to search and try the VIN again.",
"goToSearch": "Go to search",
"categoryNotLoadedTitle": "Couldn't load category",
"catalogUnavailableTitle": "Catalog isn't available right now",
"catalogUnavailableHint": "Couldn't fetch this category from the supplier catalog. Please try again shortly."

View File

@@ -605,6 +605,9 @@
"showAll": "Tümünü göster ({count})",
"showLess": "Daha az göster",
"categoryDetail": "Kategori Detayı",
"invalidLinkTitle": "Geçersiz araç bağlantısı",
"invalidLinkHint": "Bu bağlantı tanımlı bir araca işaret etmiyor. Aramaya dönüp şase numarasını tekrar girebilirsiniz.",
"goToSearch": "Aramaya git",
"categoryNotLoadedTitle": "Kategori yüklenemedi",
"catalogUnavailableTitle": "Katalog şu an yüklenemedi",
"catalogUnavailableHint": "Bu kategori tedarikçi katalogundan alınamadı. Lütfen birazdan tekrar deneyin."

View File

@@ -34,9 +34,12 @@ export const Route = createFileRoute("/dashboard/vehicles_/$id/")({
component: VehicleDetailPage,
});
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
function VehicleDetailPage() {
const { t } = useTranslation();
const { id } = Route.useParams();
const idValid = UUID_RE.test(id);
const [viewMode, setViewMode] = useState<"grid" | "tree" | "columns">(
() => getUserSettings().categoryViewMode ?? "grid",
);
@@ -71,7 +74,7 @@ function VehicleDetailPage() {
} = useQuery({
queryKey: ["vehicle", id],
queryFn: () => api.get<Vehicle>(`/vehicles/${id}`),
enabled: !!id,
enabled: !!id && idValid,
// A missing vehicle (404) is final — don't burn retries before showing the
// not-found state. Transient errors still retry.
retry: (count, err) => !(err instanceof ApiError && err.status === 404) && count < 2,
@@ -80,7 +83,7 @@ function VehicleDetailPage() {
const { data: categoryTree, isLoading: categoriesLoading } = useQuery({
queryKey: ["category-tree", id],
queryFn: () => api.get<CategoryNode[]>(`/categories/tree/${id}`),
enabled: !!id,
enabled: !!id && idValid,
});
const vehicleLabel: string = vehicle?.brandName
@@ -102,6 +105,43 @@ function VehicleDetailPage() {
return () => clearChatwootVehicle();
}, [vehicle, id]);
// The vehicle id comes from the URL — guard the obvious "undefined" /
// "null" / malformed cases (e.g. landing handed us a decode response with no
// id) before they hit the backend and surface a raw uuid parse error.
if (!idValid) {
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon"
onClick={handleBack}
title={t("vehicle.back")}
aria-label={t("vehicle.back")}
className="shrink-0"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<h2 className="text-xl font-bold">{t("vehicle.invalidLinkTitle")}</h2>
</div>
<div
role="alert"
className="flex flex-col items-start gap-3 rounded-lg border border-border bg-muted/30 p-5 text-sm"
>
<p className="text-muted-foreground">{t("vehicle.invalidLinkHint")}</p>
<div className="flex flex-wrap gap-2">
<Button type="button" size="sm" onClick={() => navigate({ to: "/dashboard/search" })}>
{t("vehicle.goToSearch")}
</Button>
<Button type="button" size="sm" variant="ghost" onClick={handleBack}>
{t("vehicle.back")}
</Button>
</div>
</div>
</div>
);
}
if (vehicleLoading) {
return (
<div className="space-y-6">
@@ -160,11 +200,7 @@ function VehicleDetailPage() {
{is404 ? t("vehicle.notFoundMsg") : t("vehicle.errorMsg")}
</p>
<p className="mt-1 text-muted-foreground">
{is404
? t("vehicle.notFoundHint")
: vehicleErrorObj instanceof Error
? vehicleErrorObj.message
: t("vehicle.errorHint")}
{is404 ? t("vehicle.notFoundHint") : t("vehicle.errorHint")}
</p>
</div>
<div className="flex flex-wrap gap-2">

View File

@@ -570,9 +570,17 @@ export function HomePage() {
setDecodeLoading(true);
try {
const vehicle = await api.post<{ id: string }>("/vehicles/decode", { vin: trimmed });
capture("vin_decode_success", { vin: trimmed, source: "landing", vehicle_id: vehicle.id });
navigate({ to: "/dashboard/vehicles/$id", params: { id: vehicle.id } });
const vehicle = await api.post<{ id?: string }>("/vehicles/decode", { vin: trimmed });
capture("vin_decode_success", { vin: trimmed, source: "landing", vehicle_id: vehicle?.id });
// Decode may return a single vehicle or a multi-candidate response; only
// jump straight to the catalog when we got a concrete id, otherwise hand
// off to the search page (which knows how to render the candidate picker
// and the unrecognised-VIN flow).
if (vehicle?.id) {
navigate({ to: "/dashboard/vehicles/$id", params: { id: vehicle.id } });
} else {
navigate({ to: "/dashboard/search", search: { vin: trimmed } });
}
} catch (err) {
const message =
err instanceof ApiError ? err.message : "Bir hata oluştu. Lütfen tekrar deneyin.";