i18n(web): translate the search page

Move every hard-coded Turkish string on /dashboard/search into a
search.* message block (tr + en) and resolve them via t() — title,
subtitle, input label/placeholder, counter, example button, submit, the
error banner with the subscription nudge, the report-VIN flow + toasts,
preview loading + identified badge + hint, recent-searches header /
see-all / open hint / row aria-label, validation/generic/toast errors,
auto-correction toast, decoding skeleton sr-only text, and the candidate
selection failure toast.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-31 17:28:45 +03:00
parent 29178c0eaa
commit a6ce215c47
3 changed files with 103 additions and 42 deletions

View File

@@ -551,5 +551,36 @@
},
"categories": {
"root": "Root"
},
"search": {
"title": "VIN Search",
"subtitle": "Enter the vehicle's VIN to access the parts catalog",
"inputLabel": "VIN",
"inputPlaceholder": "Enter VIN (17 characters)",
"counter": "{count}/17 characters",
"tryExample": "Try an example VIN →",
"submit": "Decode VIN",
"errorTitle": "Couldn't decode VIN",
"errorSubscriptionPrefix": "You have no active subscription. To access vehicle data,",
"subscribeLink": "subscribe",
"reportVin": "VIN is correct — send to administrator",
"reportSent": "Report sent. We'll review it shortly.",
"reportToastTitle": "Report sent",
"reportToastDesc": "The VIN was forwarded to the administrator.",
"reportToastError": "Report failed",
"previewLoading": "Fetching vehicle info...",
"vehicleIdentified": "Vehicle identified",
"previewHint": "Click Decode VIN to access the full parts catalog.",
"recent": "Recent searches",
"seeAll": "See all →",
"openHint": "Click to reopen the vehicle with its parts catalog",
"historyAria": "{brand} {model} — VIN {vin}, open from history",
"errorInvalidVin": "Invalid VIN. Must be 17 characters; I, O, Q are not allowed.",
"errorGeneric": "An error occurred. Please try again.",
"errorToast": "VIN search failed",
"decodingSkeleton": "Loading vehicle info…",
"pasteInvalidChars": "VIN cannot contain I, O, Q",
"candidateSelectFailed": "Vehicle selection failed",
"autoCorrected": "Auto-corrected: {list}"
}
}

View File

@@ -551,5 +551,36 @@
},
"categories": {
"root": "Kök"
},
"search": {
"title": "Şase Arama",
"subtitle": "Araç şase numarasını girerek yedek parça kataloğuna erişin",
"inputLabel": "Şase numarası",
"inputPlaceholder": "Şase numarasını girin (17 karakter)",
"counter": "{count}/17 karakter",
"tryExample": "Örnek şase deneyin →",
"submit": "Şase Çöz",
"errorTitle": "Şase çözümlenemedi",
"errorSubscriptionPrefix": "Aktif aboneliğiniz yok. Araç verilerine erişmek için",
"subscribeLink": "abone olun",
"reportVin": "Şase no doğru, sistem yöneticisine gönder",
"reportSent": "Bildirim gönderildi. En kısa sürede incelenecektir.",
"reportToastTitle": "Bildirim gönderildi",
"reportToastDesc": "Şase numarası sistem yöneticisine iletildi.",
"reportToastError": "Bildirim gönderilemedi",
"previewLoading": "Araç bilgileri alınıyor...",
"vehicleIdentified": "Araç tanımlandı",
"previewHint": "Şase Çöz butonuna tıklayarak tam parça kataloğuna erişin.",
"recent": "Son Aramalar",
"seeAll": "Tümünü Gör →",
"openHint": "Aracı parça kataloğuyla birlikte yeniden açmak için tıklayın",
"historyAria": "{brand} {model} — şase {vin}, geçmişten aç",
"errorInvalidVin": "Geçersiz şase. 17 karakter olmalı, I, O, Q harfleri kullanılamaz.",
"errorGeneric": "Bir hata oluştu. Lütfen tekrar deneyin.",
"errorToast": "Şase arama başarısız",
"decodingSkeleton": "Araç bilgileri yükleniyor…",
"pasteInvalidChars": "Şase numarasında I, O, Q harfleri kullanılamaz",
"candidateSelectFailed": "Araç seçimi başarısız",
"autoCorrected": "Otomatik düzeltildi: {list}"
}
}

View File

@@ -3,6 +3,7 @@ import { VinBrandIcon } from "@/components/ui/vin-brand-icon";
import { VehicleSelectModal } from "@/components/vehicles/vehicle-select-modal";
import { ApiError, api } from "@/lib/api-client";
import { startAction } from "@/lib/faro";
import { useTranslation } from "@/lib/i18n";
import { KEYS_6, KEYS_17 } from "@/lib/keys";
import { capture } from "@/lib/posthog";
import { toast } from "@/lib/toast";
@@ -63,9 +64,10 @@ function ShimmerBox({ className }: { className?: string }) {
* a vehicle-page-shaped skeleton so the wait feels like the page is loading
* rather than an unresponsive button. */
function DecodingSkeleton() {
const { t } = useTranslation();
return (
<div className="space-y-6" aria-busy="true" aria-live="polite">
<span className="sr-only">Araç bilgileri yükleniyor</span>
<span className="sr-only">{t("search.decodingSkeleton")}</span>
{/* Header: back + brand logo + 2-line title */}
<div className="flex items-center gap-3">
<ShimmerBox className="h-9 w-9 rounded-md" />
@@ -108,6 +110,7 @@ export const Route = createFileRoute("/dashboard/search")({
});
function SearchPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const {
vin: vinParam,
@@ -286,8 +289,7 @@ function SearchPage() {
navigate({ to: "/dashboard/vehicles/$id", params: { id: data.id } });
} catch (err) {
const responseTimeMs = Math.round(performance.now() - decodeStart);
const message =
err instanceof ApiError ? err.message : "Bir hata oluştu. Lütfen tekrar deneyin.";
const message = err instanceof ApiError ? err.message : t("search.errorGeneric");
capture("vin_decode_error", {
vin: cleanVin,
error: message,
@@ -297,7 +299,7 @@ function SearchPage() {
attempt,
});
setError(message);
toast.error("Şase arama başarısız");
toast.error(t("search.errorToast"));
} finally {
setLoading(false);
}
@@ -319,7 +321,7 @@ function SearchPage() {
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.");
setError(t("search.errorInvalidVin"));
return;
}
@@ -334,7 +336,7 @@ function SearchPage() {
querySourceRef.current = "landing";
capture("vin_decoded", { vin: cleanVin, query_source: "landing" });
if (!isValidVin(cleanVin)) {
setError("Geçersiz şase. 17 karakter olmalı, I, O, Q harfleri kullanılamaz.");
setError(t("search.errorInvalidVin"));
return;
}
lastAttemptedVinRef.current = cleanVin;
@@ -380,11 +382,11 @@ function SearchPage() {
try {
await api.post("/vehicles/report-vin", { vin: vin.toUpperCase().trim() });
setReportSent(true);
toast.success("Bildirim gönderildi", {
description: "Şase numarası sistem yöneticisine iletildi.",
toast.success(t("search.reportToastTitle"), {
description: t("search.reportToastDesc"),
});
} catch {
toast.error("Bildirim gönderilemedi");
toast.error(t("search.reportToastError"));
} finally {
setReportSending(false);
}
@@ -402,11 +404,11 @@ function SearchPage() {
if (correctionsRef.current.size === 0) return;
const list = [...correctionsRef.current];
correctionsRef.current = new Set();
toast.info(`Otomatik düzeltildi: ${list.join(", ")}`, {
description: "Şase numarasında I, O, Q harfleri kullanılamaz",
toast.info(t("search.autoCorrected", { list: list.join(", ") }), {
description: t("search.pasteInvalidChars"),
duration: 2500,
});
}, []);
}, [t]);
useEffect(() => {
return () => {
@@ -465,12 +467,11 @@ function SearchPage() {
params: { id: data.id },
});
} catch (err) {
const message =
err instanceof ApiError ? err.message : "Bir hata oluştu. Lütfen tekrar deneyin.";
const message = err instanceof ApiError ? err.message : t("search.errorGeneric");
setError(message);
setCandidates(null);
setCandidateSource(null);
toast.error("Araç seçimi başarısız");
toast.error(t("search.candidateSelectFailed"));
} finally {
setSelectLoading(false);
}
@@ -508,11 +509,9 @@ function SearchPage() {
</div>
<div className="min-w-0">
<h2 className="font-[family-name:var(--font-display)] text-3xl font-bold tracking-tight">
Şase Arama
{t("search.title")}
</h2>
<p className="mt-1 text-sm text-muted-foreground">
Araç şase numarasını girerek yedek parça kataloğuna erişin
</p>
<p className="mt-1 text-sm text-muted-foreground">{t("search.subtitle")}</p>
</div>
</header>
@@ -521,14 +520,14 @@ function SearchPage() {
<form onSubmit={handleSearch} className="space-y-4">
{/* Input */}
<Label htmlFor="vin" className="sr-only">
Şase numarası
{t("search.inputLabel")}
</Label>
<div className="relative">
<VinBrandIcon vin={vin} className="absolute left-4 top-1/2 -translate-y-1/2" />
<Input
id="vin"
ref={inputRef}
placeholder="Şase numarasını girin (17 karakter)"
placeholder={t("search.inputPlaceholder")}
value={vin}
onChange={(e) => handleVinChange(e.target.value)}
onFocus={() => {
@@ -580,14 +579,16 @@ function SearchPage() {
{/* Counter + Example VIN */}
<div className="flex items-center justify-between text-sm">
<span className="tabular-nums text-muted-foreground">{vin.length}/17 karakter</span>
<span className="tabular-nums text-muted-foreground">
{t("search.counter", { count: vin.length })}
</span>
<button
type="button"
onClick={fillExampleVin}
data-faro-user-action-name="fill-example-vin"
className="text-muted-foreground underline underline-offset-4 transition hover:text-foreground"
>
Örnek şase deneyin &rarr;
{t("search.tryExample")}
</button>
</div>
@@ -602,7 +603,7 @@ function SearchPage() {
) : (
<Search className="mr-2 size-4" />
)}
Şase Çöz
{t("search.submit")}
</Button>
{/* Error banner — the main submit button above doubles as the retry,
@@ -616,16 +617,16 @@ function SearchPage() {
>
<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="font-medium text-destructive">{t("search.errorTitle")}</p>
<p className="mt-1 text-sm text-destructive/90">
{error.includes("abone olun") ? (
<>
Aktif aboneliğiniz yok. Araç verilerine erişmek için{" "}
{t("search.errorSubscriptionPrefix")}{" "}
<Link
to="/dashboard/subscription"
className="inline-flex items-center font-semibold underline underline-offset-4 transition hover:text-destructive/80"
>
abone olun
{t("search.subscribeLink")}
</Link>
.
</>
@@ -651,13 +652,11 @@ function SearchPage() {
) : (
<Send className="mr-2 size-4" />
)}
Şase no doğru, sistem yöneticisine gönder
{t("search.reportVin")}
</Button>
)}
{reportSent && (
<p className="text-center text-sm text-muted-foreground">
Bildirim gönderildi. En kısa sürede incelenecektir.
</p>
<p className="text-center text-sm text-muted-foreground">{t("search.reportSent")}</p>
)}
</form>
</div>
@@ -666,7 +665,7 @@ function SearchPage() {
{previewLoading && (
<div className="flex items-center justify-center gap-3 rounded-2xl border border-border bg-background p-6">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground">Araç bilgileri alınıyor...</span>
<span className="text-sm text-muted-foreground">{t("search.previewLoading")}</span>
</div>
)}
@@ -689,15 +688,13 @@ function SearchPage() {
variant="default"
className="bg-brand text-xs text-brand-foreground hover:bg-brand/90"
>
Araç tanımlandı
{t("search.vehicleIdentified")}
</Badge>
</div>
</div>
</div>
<Separator className="my-4 bg-border" />
<p className="text-sm text-muted-foreground">
Şase Çöz butonuna tıklayarak tam parça kataloğuna erişin.
</p>
<p className="text-sm text-muted-foreground">{t("search.previewHint")}</p>
</div>
)}
@@ -708,14 +705,14 @@ function SearchPage() {
<div className="flex items-center gap-2">
<Clock className="size-4 text-muted-foreground" />
<h3 className="font-[family-name:var(--font-display)] text-lg font-bold tracking-tight">
Son Aramalar
{t("search.recent")}
</h3>
</div>
<Link
to="/dashboard/history"
className="text-sm text-muted-foreground transition hover:text-foreground"
>
Tümünü Gör &rarr;
{t("search.seeAll")}
</Link>
</div>
@@ -724,7 +721,11 @@ function SearchPage() {
<button
key={v.id}
type="button"
aria-label={`${v.brandName} ${v.model ?? ""} — şase ${v.vin}, geçmişten aç`}
aria-label={t("search.historyAria", {
brand: v.brandName,
model: v.model ?? "",
vin: v.vin,
})}
onClick={() => {
querySourceRef.current = "history";
const ageDays = v.lastAccessedAt
@@ -757,9 +758,7 @@ function SearchPage() {
))}
</div>
<p className="text-center text-xs text-muted-foreground">
Aracı parça kataloğuyla birlikte yeniden açmak için tıklayın
</p>
<p className="text-center text-xs text-muted-foreground">{t("search.openHint")}</p>
</div>
)}