feat(FN-367): add inline retry affordance after VIN decode failure (FN-367, gitea #11)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled

Commits merged:
- fix(web): add inline retry affordance after VIN decode failure (FN-367, gitea #11)

Files changed:
apps/api/src/telemetry/__tests__/telemetry.spec.ts |   2 +-
 .../src/routes/__tests__/dashboard-search.test.tsx | 172 +++++++++++++++++++++
 apps/web/src/routes/dashboard/search.tsx           | 145 ++++++++++-------
 3 files changed, 266 insertions(+), 53 deletions(-)

Fusion-Task-Id: FN-367

Fusion-Task-Lineage: 5dccf49f-5c77-4fe4-ae2d-0273016d517d
This commit is contained in:
Fusion
2026-05-14 13:55:36 +00:00
parent a0deefd4b2
commit e274e02c23
3 changed files with 266 additions and 53 deletions

View File

@@ -0,0 +1,172 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { vi } from "vitest";
vi.mock("@/lib/posthog", () => ({
capture: vi.fn(),
}));
vi.mock("@/lib/api-client", () => ({
api: {
post: vi.fn(),
get: vi.fn().mockResolvedValue([]),
},
ApiError: class ApiError extends Error {
code: string;
status: number;
constructor(status: number, message: string) {
super(message);
this.code = String(status);
this.status = status;
this.name = "ApiError";
}
},
}));
vi.mock("@/lib/faro", () => ({
startAction: vi.fn(),
}));
vi.mock("@/lib/toast", () => ({
toast: {
error: vi.fn(),
success: vi.fn(),
info: vi.fn(),
},
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: vi.fn().mockReturnValue({ data: [] }),
}));
vi.mock("@tanstack/react-router", async () => {
const actual = await vi.importActual<any>("@tanstack/react-router");
return {
...actual,
useNavigate: () => vi.fn(),
createFileRoute: () => (options: any) => ({ options }),
Link: ({ children, to, ...props }: any) => (
<a href={to} {...props}>
{children}
</a>
),
};
});
import { ApiError, api } from "@/lib/api-client";
import { capture } from "@/lib/posthog";
// Import component after mocks
let SearchPage: React.ComponentType;
beforeAll(async () => {
const mod = await import("@/routes/dashboard/search");
// The route component is the default export or named SearchPage
// createFileRoute wraps it, so we need the component from the Route
SearchPage = (mod as any).Route?.options?.component ?? (mod as any).default;
});
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: false,
json: async () => ({}),
}),
);
vi.stubGlobal(
"IntersectionObserver",
vi.fn().mockImplementation(() => ({
observe: vi.fn(),
disconnect: vi.fn(),
})),
);
});
function renderSearch() {
return render(<SearchPage />);
}
function typeVin(vin: string) {
const input = screen.getByPlaceholderText(/Şase numarasını girin/i);
fireEvent.change(input, { target: { value: vin } });
}
async function submitForm() {
const button = screen.getByRole("button", { name: /Şase Çöz/i });
fireEvent.click(button);
}
const TEST_VIN = "WVWZZZ1JZ3W597935";
test("retry happy path: shows banner, retry button calls api twice and emits events", async () => {
const { ApiError: MockApiError } = await import("@/lib/api-client");
(api.post as any)
.mockRejectedValueOnce(new (MockApiError as any)(503, "Servis geçici olarak kullanılamıyor"))
.mockResolvedValueOnce({ id: "veh-1" });
renderSearch();
typeVin(TEST_VIN);
await submitForm();
await waitFor(() => {
expect(screen.getByRole("alert")).toBeInTheDocument();
});
expect(screen.getByRole("alert")).toHaveTextContent("Şase çözümlenemedi");
expect(screen.getByRole("alert")).toHaveTextContent("Servis geçici olarak kullanılamıyor");
const retryButton = screen.getByRole("button", { name: /Tekrar Dene/i });
expect(retryButton).toBeInTheDocument();
fireEvent.click(retryButton);
await waitFor(() => {
expect(api.post).toHaveBeenCalledTimes(2);
});
expect(api.post).toHaveBeenNthCalledWith(1, "/vehicles/decode", { vin: TEST_VIN });
expect(api.post).toHaveBeenNthCalledWith(2, "/vehicles/decode", { vin: TEST_VIN });
expect(capture).toHaveBeenCalledWith(
"vin_decode_retry_clicked",
expect.objectContaining({ attempt: 2 }),
);
await waitFor(() => {
expect(capture).toHaveBeenCalledWith(
"vin_decode_success",
expect.objectContaining({ vin: TEST_VIN, vehicle_id: "veh-1" }),
);
});
});
test("retry suppressed for unrecognized VIN (tanınamadı)", async () => {
const { ApiError: MockApiError } = await import("@/lib/api-client");
(api.post as any).mockRejectedValueOnce(new (MockApiError as any)(404, "Şase tanınamadı"));
renderSearch();
typeVin(TEST_VIN);
await submitForm();
await waitFor(() => {
expect(screen.getByRole("alert")).toBeInTheDocument();
});
expect(screen.queryByRole("button", { name: /Tekrar Dene/i })).toBeNull();
expect(screen.getByRole("button", { name: /sistem yöneticisine gönder/i })).toBeInTheDocument();
});
test("retry suppressed for subscription block (abone olun)", async () => {
const { ApiError: MockApiError } = await import("@/lib/api-client");
(api.post as any).mockRejectedValueOnce(new (MockApiError as any)(403, "Lütfen abone olun"));
renderSearch();
typeVin(TEST_VIN);
await submitForm();
await waitFor(() => {
expect(screen.getByRole("alert")).toBeInTheDocument();
});
expect(screen.queryByRole("button", { name: /Tekrar Dene/i })).toBeNull();
});

View File

@@ -7,7 +7,7 @@ import { toast } from "@/lib/toast";
import { Badge, Button, Input, Separator } from "@sase/ui";
import { useQuery } from "@tanstack/react-query";
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
import { AlertCircle, Car, Clock, Loader2, Search, Send } from "lucide-react";
import { AlertCircle, Car, Clock, Loader2, RotateCcw, Search, Send } from "lucide-react";
import { useEffect, useRef, useState } from "react";
// ─── HELPERS ──────────────────────────────────────────────────────────────────
@@ -50,6 +50,8 @@ function SearchPage() {
const focusFiredRef = useRef(false);
const querySourceRef = useRef<"manual" | "paste" | "history">("manual");
const candidatesShownAtRef = useRef<number | null>(null);
const lastAttemptedVinRef = useRef<string | null>(null);
const attemptCountRef = useRef<number>(0);
const [vin, setVin] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -135,32 +137,15 @@ function SearchPage() {
inputRef.current?.focus();
}, []);
// ─── Submit ────────────────────────────────────────────────────────────────
async function handleSearch(e: React.FormEvent) {
e.preventDefault();
// ─── Decode runner (shared by submit and retry) ────────────────────────────
async function runDecode(cleanVin: string, attempt: number) {
setError(null);
const cleanVin = vin.toUpperCase().trim();
const querySource = querySourceRef.current;
startAction("vin-decode", { vin: cleanVin });
capture("vin_decoded", { vin: cleanVin, query_source: querySource });
if (!isValidVin(cleanVin)) {
capture("search_input_validation_failed", {
field: "vin",
error_type: cleanVin.length !== 17 ? "invalid_length" : "invalid_chars",
input_length: cleanVin.length,
});
setError("Geçersiz şase. 17 karakter olmalı, I, O, Q harfleri kullanılamaz.");
return;
}
setLoading(true);
const decodeStart = performance.now();
try {
const data = await api.post<any>("/vehicles/decode", { vin: cleanVin });
const responseTimeMs = Math.round(performance.now() - decodeStart);
// Handle multiple vehicle candidates (PartsCatalogs or EMEX)
if (data.candidates && Array.isArray(data.candidates)) {
setCandidates(data.candidates);
setCandidateVin(cleanVin);
@@ -171,9 +156,8 @@ function SearchPage() {
count: data.candidates.length,
source: data.source,
response_time_ms: responseTimeMs,
query_source: querySource,
query_source: querySourceRef.current,
});
setLoading(false);
return;
}
@@ -182,12 +166,9 @@ function SearchPage() {
vehicle_id: data.id,
response_time_ms: responseTimeMs,
source: data.source ?? null,
query_source: querySource,
});
navigate({
to: "/dashboard/vehicles/$id",
params: { id: data.id },
query_source: querySourceRef.current,
});
navigate({ to: "/dashboard/vehicles/$id", params: { id: data.id } });
} catch (err) {
const responseTimeMs = Math.round(performance.now() - decodeStart);
const message =
@@ -197,18 +178,53 @@ function SearchPage() {
error: message,
response_time_ms: responseTimeMs,
status_code: err instanceof ApiError ? err.status : null,
query_source: querySource,
query_source: querySourceRef.current,
attempt,
});
if (err instanceof ApiError) {
setError(err.message);
} else {
setError("Bir hata oluştu. Lütfen tekrar deneyin.");
}
setError(message);
toast.error("Şase arama başarısız");
} finally {
setLoading(false);
}
}
function handleRetry() {
const vinToRetry = lastAttemptedVinRef.current;
if (!vinToRetry || loading) return;
attemptCountRef.current += 1;
capture("vin_decode_retry_clicked", {
vin: vinToRetry,
attempt: attemptCountRef.current,
previous_error: error,
});
runDecode(vinToRetry, attemptCountRef.current);
}
// ─── Submit ────────────────────────────────────────────────────────────────
async function handleSearch(e: React.FormEvent) {
e.preventDefault();
setError(null);
const cleanVin = vin.toUpperCase().trim();
const querySource = querySourceRef.current;
startAction("vin-decode", { vin: cleanVin });
capture("vin_decoded", { vin: cleanVin, query_source: querySource });
if (!isValidVin(cleanVin)) {
capture("search_input_validation_failed", {
field: "vin",
error_type: cleanVin.length !== 17 ? "invalid_length" : "invalid_chars",
input_length: cleanVin.length,
});
setError("Geçersiz şase. 17 karakter olmalı, I, O, Q harfleri kullanılamaz.");
return;
}
lastAttemptedVinRef.current = cleanVin;
attemptCountRef.current = 1;
await runDecode(cleanVin, 1);
}
async function handleReportVin() {
setReportSending(true);
try {
@@ -392,26 +408,51 @@ function SearchPage() {
Şase Çöz
</Button>
{/* Error card */}
{/* Error banner */}
{error && (
<div className="flex items-start gap-3 rounded-xl border border-destructive/30 bg-destructive/5 p-4">
<AlertCircle className="mt-0.5 size-4 shrink-0 text-destructive" />
<p className="text-sm text-destructive">
{error.includes("abone olun") ? (
<>
Aktif aboneliğiniz yok. Araç verilerine erişmek için{" "}
<Link
to="/dashboard/subscription"
className="inline-flex items-center font-semibold underline underline-offset-4 transition hover:text-destructive/80"
>
abone olun
</Link>
.
</>
) : (
error
)}
</p>
<div
role="alert"
aria-live="assertive"
className="space-y-3 rounded-xl border border-destructive/40 bg-destructive/10 p-4"
>
<div className="flex items-start gap-3">
<AlertCircle className="mt-0.5 size-5 shrink-0 text-destructive" />
<div className="min-w-0 flex-1">
<p className="font-medium text-destructive">Şase çözümlenemedi</p>
<p className="mt-1 text-sm text-destructive/90">
{error.includes("abone olun") ? (
<>
Aktif aboneliğiniz yok. Araç verilerine erişmek için{" "}
<Link
to="/dashboard/subscription"
className="inline-flex items-center font-semibold underline underline-offset-4 transition hover:text-destructive/80"
>
abone olun
</Link>
.
</>
) : (
error
)}
</p>
</div>
</div>
{!error.includes("abone olun") && !error.includes("tanınamadı") && (
<Button
type="button"
onClick={handleRetry}
disabled={loading || !lastAttemptedVinRef.current}
className="h-11 w-full rounded-xl"
data-faro-user-action-name="vin-decode-retry"
>
{loading ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : (
<RotateCcw className="mr-2 size-4" />
)}
Tekrar Dene
</Button>
)}
</div>
)}