Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
"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>
117 lines
4.4 KiB
TypeScript
117 lines
4.4 KiB
TypeScript
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>
|
||
</>
|
||
);
|
||
}
|