Files
sase.tr/apps/web/src/components/vehicles/vin-ocr-button.tsx
Semih Yesilyurt 3c6c27cb00
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
fix(vin-ocr): sunucu hata mesajını toast'ta göster
"Fotoğraf okunamadı" genel metni gerçek nedeni (örn. 503 "görselden şase
okuma şu anda kullanılamıyor", 400 "desteklenmeyen tür") yutuyordu — dev'de
OPENROUTER_API_KEY boş kaldığında teşhisi zorlaştırdı. ApiError mesajı artık
toast açıklamasında; 429 için Türkçe "çok fazla deneme" metni; PostHog
reason artık http_<status> taşıyor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 06:52:14 +03:00

117 lines
4.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { ApiError, api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n";
import { prepareImageForOcr } from "@/lib/image";
import { capture } from "@/lib/posthog";
import { toast } from "@/lib/toast";
import { Camera, Loader2 } from "lucide-react";
import { useRef, useState } from "react";
interface VinOcrResponse {
vin: string | null;
confidence?: "high" | "medium" | "low";
}
interface VinOcrButtonProps {
/** Geçerli bir VIN okunduğunda çağrılır — decode'u başlatmaz, kutuyu doldurur. */
onVin: (vin: string, confidence?: string) => void;
disabled?: boolean;
className?: string;
}
/**
* VIN kutusunun sağındaki "fotoğraftan şase okut" butonu. Dosya seçtirir
* (mobilde kamera dahil), görseli küçültüp /vehicles/decode/ocr'a yükler ve
* dönen VIN'i kutuya yazdırır. Decode her zaman kullanıcının "Şase Çöz"
* tıklamasıyla başlar — OCR yalnızca yazma zahmetini alır.
*/
export function VinOcrButton({ onVin, disabled, className }: VinOcrButtonProps) {
const { t } = useTranslation();
const fileRef = useRef<HTMLInputElement>(null);
const [busy, setBusy] = useState(false);
async function handleFile(file: File | undefined) {
if (!file || busy) return;
setBusy(true);
const startedAt = performance.now();
try {
const blob = await prepareImageForOcr(file);
const form = new FormData();
form.append("image", blob, "vin.jpg");
const res = await api.upload<VinOcrResponse>("/vehicles/decode/ocr", form);
const durationMs = Math.round(performance.now() - startedAt);
if (res?.vin) {
onVin(res.vin, res.confidence);
if (res.confidence === "low") {
toast.warning(t("search.ocr.lowConfidence"), {
description: t("search.ocr.lowConfidenceHint"),
});
} else {
toast.success(t("search.ocr.success"));
}
capture("vin_ocr_used", {
success: true,
confidence: res.confidence ?? null,
duration_ms: durationMs,
});
} else {
toast.error(t("search.ocr.notFound"), { description: t("search.ocr.notFoundHint") });
capture("vin_ocr_used", { success: false, reason: "not_found", duration_ms: durationMs });
}
} catch (err) {
const durationMs = Math.round(performance.now() - startedAt);
const tooLarge = err instanceof Error && err.message === "IMAGE_TOO_LARGE";
// Sunucu mesajları kullanıcıya dönük Türkçe (503 "şu anda kullanılamıyor",
// 400 "desteklenmeyen tür" vb.) — genel metnin altında aynen göster ki
// gerçek neden kaybolmasın. 429'un Nest mesajı İngilizce, onu çeviriyoruz.
let description: string | undefined = t("search.ocr.errorHint");
if (tooLarge) {
description = undefined;
} else if (err instanceof ApiError) {
description = err.status === 429 ? t("search.ocr.throttled") : err.message;
}
toast.error(tooLarge ? t("search.ocr.tooLarge") : t("search.ocr.error"), { description });
capture("vin_ocr_used", {
success: false,
reason: tooLarge ? "too_large" : err instanceof ApiError ? `http_${err.status}` : "error",
duration_ms: durationMs,
});
} finally {
setBusy(false);
// Aynı dosya tekrar seçilebilsin diye input'u sıfırla
if (fileRef.current) fileRef.current.value = "";
}
}
return (
<>
{/* Not: locator-stability sözleşmesi gereği data-testid KULLANILMAZ —
testler input[type="file"] seçicisiyle erişir. */}
<input
ref={fileRef}
type="file"
accept="image/*"
className="hidden"
tabIndex={-1}
aria-hidden
onChange={(e) => handleFile(e.target.files?.[0])}
/>
<button
type="button"
onClick={() => fileRef.current?.click()}
disabled={disabled || busy}
title={t("search.ocr.button")}
aria-label={t("search.ocr.button")}
aria-busy={busy}
data-faro-user-action-name="vin-ocr-upload"
className={`flex size-10 items-center justify-center rounded-lg text-muted-foreground transition hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-50 ${className ?? ""}`}
>
{busy ? (
<Loader2 className="size-5 animate-spin" aria-hidden />
) : (
<Camera className="size-5" aria-hidden />
)}
</button>
</>
);
}