i18n(web): translate the vehicle catalog detail surface

Move every hard-coded Turkish string on /dashboard/vehicles/:id and the
drill-down /categories/:categoryId page into a vehicle.* message block
(tr + en) — page header, error/404 panels with retry, "Araç Bilgileri"
accordion, "Yedek Parça Kategorileri" + the new empty state, equipment
title and show-more/less toggle, summary labels — plus the leaf-category
catalog-error panel and the drill-down's loading/retry copy.

Also translate the CatalogSearch component (placeholder, arias,
searching/treeIncomplete/noMatch hints, section headings, parts-count
badge) under a new catalogSearch.* block, since it lives inside the same
surface and the rest of the page already speaks two languages.

PCAT_LABELS / EMEX_LABELS / VINAPI_FIELDS / LABEL_OVERRIDES are
source-specific data tables, not UI chrome — left as-is.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-31 21:42:48 +03:00
parent 73fe3e33c1
commit b9dd154bfd
5 changed files with 136 additions and 50 deletions

View File

@@ -1,5 +1,6 @@
import { api } from "@/lib/api-client";
import { getCategoryIcon } from "@/lib/category-icons";
import { useTranslation } from "@/lib/i18n";
import { Badge, Input } from "@sase/ui";
import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
@@ -71,6 +72,7 @@ export function CatalogSearch({
vehicleId: string;
onActiveChange?: (active: boolean) => void;
}) {
const { t } = useTranslation();
const navigate = useNavigate();
const [input, setInput] = useState("");
const debounced = useDebounced(input.trim(), 300);
@@ -107,8 +109,8 @@ export function CatalogSearch({
type="search"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Tüm katalogda ara — ör. fren balatası veya OEM kodu"
aria-label="Tüm katalogda ara"
placeholder={t("catalogSearch.placeholder")}
aria-label={t("catalogSearch.ariaLabel")}
className="pl-9 pr-9"
/>
{active && isFetching ? (
@@ -117,7 +119,7 @@ export function CatalogSearch({
<button
type="button"
onClick={() => setInput("")}
aria-label="Aramayı temizle"
aria-label={t("catalogSearch.clearAria")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
@@ -129,33 +131,33 @@ export function CatalogSearch({
<div className="space-y-6">
{isLoading ? (
<div className="flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Aranıyor
<Loader2 className="h-4 w-4 animate-spin" /> {t("catalogSearch.searching")}
</div>
) : (
<>
{data?.treeIncomplete && (
<div className="flex items-start gap-2 rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 text-xs text-muted-foreground">
<Info className="mt-0.5 h-4 w-4 flex-shrink-0 text-amber-500" />
<span>
Bu aracın kataloğu henüz tam indirilmemiş. Arama yalnızca şu ana kadar ılmış
kategorileri kapsar kategorileri gezdikçe kapsam genişler.
</span>
<span>{t("catalogSearch.treeIncomplete")}</span>
</div>
)}
{nothing ? (
<p className="py-8 text-center text-sm text-muted-foreground">
"{debounced}" ile eşleşen kategori veya parça bulunamadı.
{t("catalogSearch.noMatch", { query: debounced })}
</p>
) : (
<>
{/* Section 1 — leaf categories */}
<section>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Kategoriler {categories.length > 0 && `(${categories.length})`}
{t("catalogSearch.categoriesHeading")}{" "}
{categories.length > 0 && `(${categories.length})`}
</h3>
{categories.length === 0 ? (
<p className="text-sm text-muted-foreground">Eşleşen kategori yok.</p>
<p className="text-sm text-muted-foreground">
{t("catalogSearch.noCategoryMatch")}
</p>
) : (
<ul className="space-y-1.5">
{categories.map((c) => {
@@ -179,7 +181,9 @@ export function CatalogSearch({
variant="secondary"
className="flex-shrink-0 text-[11px]"
>
{c.matchingPartCount} parça
{t("catalogSearch.partsCount", {
count: c.matchingPartCount,
})}
</Badge>
)}
</span>
@@ -197,11 +201,11 @@ export function CatalogSearch({
{/* Section 2 — parts (OEM) */}
<section>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Parçalar (OEM) {parts.length > 0 && `(${parts.length})`}
{t("catalogSearch.partsHeading")} {parts.length > 0 && `(${parts.length})`}
</h3>
{parts.length === 0 ? (
<p className="text-sm text-muted-foreground">
Eşleşen parça yok. (Yalnızca ılmış kategorilerin parçaları aranır.)
{t("catalogSearch.noPartMatch")}
</p>
) : (
<ul className="space-y-1.5">

View File

@@ -582,5 +582,44 @@
"pasteInvalidChars": "VIN cannot contain I, O, Q",
"candidateSelectFailed": "Vehicle selection failed",
"autoCorrected": "Auto-corrected: {list}"
},
"vehicle": {
"title": "Vehicle",
"back": "Back",
"notFoundTitle": "Vehicle not found",
"errorTitle": "Couldn't load vehicle",
"notFoundMsg": "This vehicle record was not found",
"errorMsg": "Couldn't load vehicle details",
"notFoundHint": "The link may be invalid or the record removed.",
"errorHint": "An error occurred loading the data.",
"retry": "Try again",
"info": "Vehicle details",
"categories": "Spare parts categories",
"noCategoriesTitle": "No categories for this vehicle",
"noCategoriesHint": "The vehicle was decoded but the catalog may not be ready yet. Reach out to support to expedite it.",
"noAttrs": "No detailed info available for this vehicle.",
"labelModel": "Model",
"labelYear": "Model year",
"labelEngineCode": "Engine code",
"equipmentTitle": "Equipment codes ({count})",
"showAll": "Show all ({count})",
"showLess": "Show less",
"categoryDetail": "Category detail",
"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."
},
"catalogSearch": {
"placeholder": "Search the whole catalog — e.g. brake pads or OEM code",
"ariaLabel": "Search the whole catalog",
"clearAria": "Clear search",
"searching": "Searching…",
"treeIncomplete": "This vehicle's catalog hasn't been fully indexed yet. Search covers only categories opened so far — browsing more categories expands the scope.",
"noMatch": "No categories or parts match \"{query}\".",
"categoriesHeading": "Categories",
"noCategoryMatch": "No matching categories.",
"partsHeading": "Parts (OEM)",
"noPartMatch": "No matching parts. (Only parts in opened categories are searched.)",
"partsCount": "{count} part(s)"
}
}

View File

@@ -582,5 +582,44 @@
"pasteInvalidChars": "Şase numarasında I, O, Q harfleri kullanılamaz",
"candidateSelectFailed": "Araç seçimi başarısız",
"autoCorrected": "Otomatik düzeltildi: {list}"
},
"vehicle": {
"title": "Araç",
"back": "Geri dön",
"notFoundTitle": "Araç bulunamadı",
"errorTitle": "Araç yüklenemedi",
"notFoundMsg": "Bu araç kaydı bulunamadı",
"errorMsg": "Araç bilgileri yüklenemedi",
"notFoundHint": "Bağlantı geçersiz olabilir ya da kayıt kaldırılmış olabilir.",
"errorHint": "Veriler yüklenirken bir hata oluştu.",
"retry": "Tekrar dene",
"info": "Araç Bilgileri",
"categories": "Yedek Parça Kategorileri",
"noCategoriesTitle": "Bu araç için kategori bulunamadı",
"noCategoriesHint": "Araç decode edildi ama parça kataloğu henüz hazırlanmamış olabilir. Destek ekibimize bildirirseniz katalog hızlandırılır.",
"noAttrs": "Bu araç için ayrıntı bilgisi bulunamadı.",
"labelModel": "Model",
"labelYear": "Model yılı",
"labelEngineCode": "Motor kodu",
"equipmentTitle": "Donanım kodları ({count})",
"showAll": "Tümünü göster ({count})",
"showLess": "Daha az göster",
"categoryDetail": "Kategori Detayı",
"categoryNotLoadedTitle": "Kategori yüklenemedi",
"catalogUnavailableTitle": "Katalog şu an yüklenemedi",
"catalogUnavailableHint": "Bu kategori tedarikçi katalogundan alınamadı. Lütfen birazdan tekrar deneyin."
},
"catalogSearch": {
"placeholder": "Tüm katalogda ara — ör. fren balatası veya OEM kodu",
"ariaLabel": "Tüm katalogda ara",
"clearAria": "Aramayı temizle",
"searching": "Aranıyor…",
"treeIncomplete": "Bu aracın kataloğu henüz tam indirilmemiş. Arama yalnızca şu ana kadar açılmış kategorileri kapsar — kategorileri gezdikçe kapsam genişler.",
"noMatch": "\"{query}\" ile eşleşen kategori veya parça bulunamadı.",
"categoriesHeading": "Kategoriler",
"noCategoryMatch": "Eşleşen kategori yok.",
"partsHeading": "Parçalar (OEM)",
"noPartMatch": "Eşleşen parça yok. (Yalnızca açılmış kategorilerin parçaları aranır.)",
"partsCount": "{count} parça"
}
}

View File

@@ -5,6 +5,7 @@ import { CategoryTree } from "@/components/categories/category-tree";
import { CategoryViewToggle } from "@/components/categories/category-view-toggle";
import { useCategoryParts } from "@/hooks/use-parts";
import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n";
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
import { cleanModelName } from "@/lib/vehicle";
import { Button, Skeleton } from "@sase/ui";
@@ -51,6 +52,7 @@ export const Route = createFileRoute("/dashboard/vehicles_/$id/categories_/$cate
});
function VehicleCategoryPage() {
const { t } = useTranslation();
const { id, categoryId } = Route.useParams();
const navigate = useNavigate();
const { data, isLoading, error, refetch, isFetching } = useCategoryParts(id, categoryId);
@@ -93,7 +95,7 @@ function VehicleCategoryPage() {
const cleanModel = cleanModelName(vehicle?.model);
const vehicleLabel = vehicle?.brandName
? `${vehicle.brandName}${cleanModel ? ` ${cleanModel}` : ""}`
: "Araç";
: t("vehicle.title");
return (
<div className="space-y-4">
@@ -111,8 +113,8 @@ function VehicleCategoryPage() {
variant="ghost"
size="icon"
onClick={handleBack}
title="Geri dön"
aria-label="Geri dön"
title={t("vehicle.back")}
aria-label={t("vehicle.back")}
data-faro-user-action-name="category-back"
>
<ArrowLeft className="h-4 w-4" />
@@ -123,7 +125,7 @@ function VehicleCategoryPage() {
(isLoading ? (
<span className="inline-block h-6 w-48 animate-pulse rounded-md bg-primary/10 align-middle" />
) : (
"Kategori Detayı"
t("vehicle.categoryDetail")
))}
</h1>
{data?.description && (
@@ -141,9 +143,9 @@ function VehicleCategoryPage() {
className="flex flex-col items-start gap-3 rounded-lg border border-destructive/40 bg-destructive/5 p-5 text-sm"
>
<div>
<p className="font-medium text-destructive">Kategori yüklenemedi</p>
<p className="font-medium text-destructive">{t("vehicle.categoryNotLoadedTitle")}</p>
<p className="mt-1 text-muted-foreground">
{error instanceof Error ? error.message : "Veriler yüklenirken bir hata oluştu."}
{error instanceof Error ? error.message : t("vehicle.errorHint")}
</p>
</div>
<Button
@@ -154,7 +156,7 @@ function VehicleCategoryPage() {
disabled={isFetching}
data-faro-user-action-name="category-retry"
>
{isFetching ? "Yükleniyor…" : "Tekrar dene"}
{isFetching ? t("common.loading") : t("vehicle.retry")}
</Button>
</div>
)}
@@ -183,10 +185,8 @@ function VehicleCategoryPage() {
className="flex flex-col items-start gap-3 rounded-lg border border-destructive/40 bg-destructive/5 p-5 text-sm"
>
<div>
<p className="font-medium text-destructive">Katalog şu an yüklenemedi</p>
<p className="mt-1 text-muted-foreground">
Bu kategori tedarikçi katalogundan alınamadı. Lütfen birazdan tekrar deneyin.
</p>
<p className="font-medium text-destructive">{t("vehicle.catalogUnavailableTitle")}</p>
<p className="mt-1 text-muted-foreground">{t("vehicle.catalogUnavailableHint")}</p>
</div>
<Button
type="button"
@@ -196,7 +196,7 @@ function VehicleCategoryPage() {
disabled={isFetching}
data-faro-user-action-name="category-loaderror-retry"
>
{isFetching ? "Yükleniyor…" : "Tekrar dene"}
{isFetching ? t("common.loading") : t("vehicle.retry")}
</Button>
</div>
) : (

View File

@@ -7,6 +7,7 @@ import { CategoryViewToggle } from "@/components/categories/category-view-toggle
import { CarBrandLogo } from "@/components/ui/car-brand-logo";
import { ApiError, api } from "@/lib/api-client";
import { setChatwootVehicle } from "@/lib/chatwoot";
import { useTranslation } from "@/lib/i18n";
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
import { cleanModelName } from "@/lib/vehicle";
import type { CategoryNode, Vehicle } from "@sase/shared";
@@ -33,6 +34,7 @@ export const Route = createFileRoute("/dashboard/vehicles_/$id/")({
});
function VehicleDetailPage() {
const { t } = useTranslation();
const { id } = Route.useParams();
const [viewMode, setViewMode] = useState<"grid" | "tree" | "columns">(
() => getUserSettings().categoryViewMode ?? "grid",
@@ -81,7 +83,7 @@ function VehicleDetailPage() {
const vehicleLabel: string = vehicle?.brandName
? `${vehicle.brandName}${cleanModelName(vehicle.model) ? ` ${cleanModelName(vehicle.model)}` : ""}`
: "Araç";
: t("vehicle.title");
// Surface the viewed vehicle to the support chat widget (VIN/brand/model)
// so agents have the car context for part-compatibility questions.
@@ -136,13 +138,15 @@ function VehicleDetailPage() {
variant="ghost"
size="icon"
onClick={handleBack}
title="Geri dön"
aria-label="Geri dön"
title={t("vehicle.back")}
aria-label={t("vehicle.back")}
data-faro-user-action-name="vehicle-back"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<h2 className="text-xl font-bold">{is404 ? "Araç bulunamadı" : "Araç yüklenemedi"}</h2>
<h2 className="text-xl font-bold">
{is404 ? t("vehicle.notFoundTitle") : t("vehicle.errorTitle")}
</h2>
</div>
<div
role="alert"
@@ -150,14 +154,14 @@ function VehicleDetailPage() {
>
<div>
<p className="font-medium text-destructive">
{is404 ? "Bu araç kaydı bulunamadı" : "Araç bilgileri yüklenemedi"}
{is404 ? t("vehicle.notFoundMsg") : t("vehicle.errorMsg")}
</p>
<p className="mt-1 text-muted-foreground">
{is404
? "Bağlantı geçersiz olabilir ya da kayıt kaldırılmış olabilir."
? t("vehicle.notFoundHint")
: vehicleErrorObj instanceof Error
? vehicleErrorObj.message
: "Veriler yüklenirken bir hata oluştu."}
: t("vehicle.errorHint")}
</p>
</div>
<div className="flex flex-wrap gap-2">
@@ -170,11 +174,11 @@ function VehicleDetailPage() {
disabled={vehicleFetching}
data-faro-user-action-name="vehicle-retry"
>
{vehicleFetching ? "Yükleniyor…" : "Tekrar dene"}
{vehicleFetching ? t("common.loading") : t("vehicle.retry")}
</Button>
)}
<Button type="button" size="sm" variant="ghost" onClick={handleBack}>
Geri dön
{t("vehicle.back")}
</Button>
</div>
</div>
@@ -196,8 +200,8 @@ function VehicleDetailPage() {
variant="ghost"
size="icon"
onClick={handleBack}
title="Geri dön"
aria-label="Geri dön"
title={t("vehicle.back")}
aria-label={t("vehicle.back")}
className="shrink-0"
data-faro-user-action-name="vehicle-back"
>
@@ -222,7 +226,7 @@ function VehicleDetailPage() {
<AccordionItem value="info" className="border-b-0">
<AccordionTrigger className="px-6 hover:no-underline">
<div className="flex flex-1 flex-col items-start gap-1.5 pr-3 text-left">
<span className="text-base font-semibold">Araç Bilgileri</span>
<span className="text-base font-semibold">{t("vehicle.info")}</span>
<VehicleSummary vehicle={vehicle} />
</div>
</AccordionTrigger>
@@ -237,7 +241,7 @@ function VehicleDetailPage() {
{/* Categories */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base">Yedek Parça Kategorileri</CardTitle>
<CardTitle className="text-base">{t("vehicle.categories")}</CardTitle>
{!searchActive && <CategoryViewToggle value={viewMode} onChange={changeViewMode} />}
</CardHeader>
<CardContent
@@ -260,11 +264,10 @@ function VehicleDetailPage() {
) : !categoryTree || categoryTree.length === 0 ? (
<div className="rounded-lg border border-dashed border-border bg-muted/30 px-6 py-10 text-center">
<p className="text-sm font-medium text-foreground">
Bu araç için kategori bulunamadı
{t("vehicle.noCategoriesTitle")}
</p>
<p className="mx-auto mt-1 max-w-md text-xs text-muted-foreground">
Araç decode edildi ama parça kataloğu henüz hazırlanmamış olabilir. Destek
ekibimize bildirirseniz katalog hızlandırılır.
{t("vehicle.noCategoriesHint")}
</p>
</div>
) : viewMode === "grid" ? (
@@ -495,12 +498,11 @@ function getDisplayAttributes(vehicle: Vehicle): Attr[] {
}
function VehicleAttributes({ vehicle }: { vehicle: Vehicle }) {
const { t } = useTranslation();
const attrs = getDisplayAttributes(vehicle);
if (attrs.length === 0) {
return (
<p className="text-sm text-muted-foreground">Bu araç için ayrıntı bilgisi bulunamadı.</p>
);
return <p className="text-sm text-muted-foreground">{t("vehicle.noAttrs")}</p>;
}
return (
@@ -536,10 +538,11 @@ function getEngineCode(vehicle: Vehicle): string | undefined {
/** Compact at-a-glance fields shown on the collapsed Araç Bilgileri header. */
function VehicleSummary({ vehicle }: { vehicle: Vehicle }) {
const { t } = useTranslation();
const fields = [
{ label: "Model", value: cleanModelName(vehicle.model) },
{ label: "Model yılı", value: vehicle.year },
{ label: "Motor kodu", value: getEngineCode(vehicle) },
{ label: t("vehicle.labelModel"), value: cleanModelName(vehicle.model) },
{ label: t("vehicle.labelYear"), value: vehicle.year },
{ label: t("vehicle.labelEngineCode"), value: getEngineCode(vehicle) },
].filter((f) => f.value != null && f.value !== "");
if (fields.length === 0) return null;
@@ -585,6 +588,7 @@ function getEquipment(vehicle: Vehicle): Equipment[] {
/** Full decoded equipment / PR-code list, shown inside the expanded card. */
const EQUIPMENT_PREVIEW = 8;
function VehicleEquipment({ vehicle }: { vehicle: Vehicle }) {
const { t } = useTranslation();
const items = getEquipment(vehicle);
const [expanded, setExpanded] = useState(false);
if (items.length === 0) return null;
@@ -595,7 +599,7 @@ function VehicleEquipment({ vehicle }: { vehicle: Vehicle }) {
return (
<div className="mt-6 border-t border-border pt-4">
<p className="mb-3 text-xs font-semibold text-muted-foreground">
Donanım kodları ({items.length})
{t("vehicle.equipmentTitle", { count: items.length })}
</p>
<ul className="grid grid-cols-1 gap-x-6 gap-y-2 sm:grid-cols-2">
{visible.map((it, i) => (
@@ -617,7 +621,7 @@ function VehicleEquipment({ vehicle }: { vehicle: Vehicle }) {
onClick={() => setExpanded((v) => !v)}
className="mt-3 h-8 px-2 text-xs text-muted-foreground hover:text-foreground"
>
{expanded ? "Daha az göster" : `Tümünü göster (${items.length})`}
{expanded ? t("vehicle.showLess") : t("vehicle.showAll", { count: items.length })}
</Button>
)}
</div>