fix(catalog): kill "Modeller" empty-card on vehicle page + unify drill headers

The PL24 vehicle page rendered a Card titled `t("catalog.models")` ("Modeller")
whose content was the *selected vehicle's* specs (motor/kasa/vites/pazar). When
all four spec fields were null — common for sparse decodes — the user saw an
empty card with a bogus "Modeller" heading just below the model name.

Replace the misleading card with a flat spec-chip row (icon + label : value),
hidden entirely when no specs exist. Use the shared CatalogHeader so brand →
vehicle has a proper breadcrumb back to "/catalog/$brandName" and uses the same
back-button affordance as the rest of the catalog surface. Use the shared
ViewModeToggle so view-mode buttons stop having hardcoded TR title attrs
("Izgara", "Agac", "Sutun") and inherit the i18n + a11y from the component.

Same cleanup for pcat/$catalogId_/$modelId and emex/$catalogCode_/$vehicleId:
both rolled their own header — both now use CatalogHeader with full crumbs and
i18n'd search/count/empty-state strings (catalog.pcat.*, catalog.emex.*).

Why now: catalog flow audit caught the empty-card bug ("model seçilince
yukarıda boş bir alan kalıyor"), plus drift between drill levels (some pages
used CatalogHeader, vehicle/pcat/emex did not). One unified pattern across
brands → models → vehicle → categories.

i18n: +catalog.categoryCount, +catalog.vehicleSpecs.{engine,body,transmission,
market}, +catalog.pcat.{searchPlaceholder,vehicleCount,schemaCount},
+catalog.emex.{searchPlaceholder,optionCount,variantCount,noResults,
noPartsTitle,noPartsHint,loadError}.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-06 03:01:09 +03:00
parent 2040686e4d
commit 37aa4dfd02
6 changed files with 358 additions and 284 deletions

View File

@@ -160,6 +160,27 @@
"oldest": "Year (oldest first)", "oldest": "Year (oldest first)",
"alphabetical": "Model (A→Z)" "alphabetical": "Model (A→Z)"
}, },
"categoryCount": "{count} categories",
"vehicleSpecs": {
"engine": "Engine",
"body": "Body",
"transmission": "Transmission",
"market": "Market"
},
"pcat": {
"searchPlaceholder": "Search vehicle",
"vehicleCount": "{count} vehicles",
"schemaCount": "{count} schemas"
},
"emex": {
"searchPlaceholder": "Search",
"optionCount": "{count} options",
"variantCount": "{count} variants",
"noResults": "No results found",
"noPartsTitle": "No parts data yet for this configuration",
"noPartsHint": "Try a different model or variant.",
"loadError": "Could not load catalog data. Please try again."
},
"tabSasetr": "SASE", "tabSasetr": "SASE",
"tabPl24": "Pl24", "tabPl24": "Pl24",
"tabPcat": "Pcat", "tabPcat": "Pcat",

View File

@@ -160,6 +160,27 @@
"oldest": "Yıla göre (eskiden yeniye)", "oldest": "Yıla göre (eskiden yeniye)",
"alphabetical": "Modele göre (A→Z)" "alphabetical": "Modele göre (A→Z)"
}, },
"categoryCount": "{count} kategori",
"vehicleSpecs": {
"engine": "Motor",
"body": "Kasa",
"transmission": "Vites",
"market": "Pazar"
},
"pcat": {
"searchPlaceholder": "Araç ara",
"vehicleCount": "{count} araç",
"schemaCount": "{count} şema"
},
"emex": {
"searchPlaceholder": "Ara",
"optionCount": "{count} seçenek",
"variantCount": "{count} varyant",
"noResults": "Sonuç bulunamadı",
"noPartsTitle": "Bu araç konfigürasyonu için parça verisi henüz yok",
"noPartsHint": "Farklı bir model veya varyant seçmeyi deneyin.",
"loadError": "Katalog verileri yüklenemedi. Lütfen tekrar deneyin."
},
"tabSasetr": "SASE", "tabSasetr": "SASE",
"tabPl24": "Pl24", "tabPl24": "Pl24",
"tabPcat": "Pcat", "tabPcat": "Pcat",

View File

@@ -1,24 +1,21 @@
import { CatalogHeader } from "@/components/catalog/catalog-header";
import { FordVariantSelector } from "@/components/catalog/ford-variant-selector"; import { FordVariantSelector } from "@/components/catalog/ford-variant-selector";
import { P5RestrictionSelector } from "@/components/catalog/p5-restriction-selector"; import { P5RestrictionSelector } from "@/components/catalog/p5-restriction-selector";
import { PsaVariantSelector } from "@/components/catalog/psa-variant-selector"; import { PsaVariantSelector } from "@/components/catalog/psa-variant-selector";
import { ViewModeToggle } from "@/components/catalog/view-mode-toggle";
import { CategoryColumns } from "@/components/categories/category-columns"; import { CategoryColumns } from "@/components/categories/category-columns";
import { CategoryGrid } from "@/components/categories/category-grid"; import { CategoryGrid } from "@/components/categories/category-grid";
import { CategoryTree } from "@/components/categories/category-tree"; import { CategoryTree } from "@/components/categories/category-tree";
import {
CardsViewIcon,
ListViewIcon,
TreeViewIcon,
} from "@/components/categories/view-toggle-icons";
import { api } from "@/lib/api-client"; import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n"; import { useTranslation } from "@/lib/i18n";
import { KEYS_8 } from "@/lib/keys";
import { getUserSettings, setUserSetting } from "@/lib/user-settings"; import { getUserSettings, setUserSetting } from "@/lib/user-settings";
import { Button, Card, CardContent, CardHeader, CardTitle, Skeleton } from "@sase/ui"; import { Skeleton } from "@sase/ui";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router"; import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { ArrowLeft } from "lucide-react"; import { Cog, Gauge, MapPin, Settings2 } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { KEYS_8 } from "@/lib/keys";
export const Route = createFileRoute("/dashboard/catalog_/$brandName_/$modelId/")({ export const Route = createFileRoute("/dashboard/catalog_/$brandName_/$modelId/")({
validateSearch: (search) => ({ validateSearch: (search) => ({
body: typeof search.body === "string" ? search.body : undefined, body: typeof search.body === "string" ? search.body : undefined,
@@ -29,14 +26,7 @@ export const Route = createFileRoute("/dashboard/catalog_/$brandName_/$modelId/"
component: CatalogVehiclePage, component: CatalogVehiclePage,
}); });
function buildVariantQuery(body?: string, engine?: string, gearbox?: string): string { type ViewMode = "grid" | "tree" | "columns";
const params = new URLSearchParams();
if (body) params.set("body", body);
if (engine) params.set("engine", engine);
if (gearbox) params.set("gearbox", gearbox);
const qs = params.toString();
return qs ? `?${qs}` : "";
}
function CatalogVehiclePage() { function CatalogVehiclePage() {
const { brandName, modelId } = Route.useParams(); const { brandName, modelId } = Route.useParams();
@@ -50,13 +40,13 @@ function CatalogVehiclePage() {
const mgp = search.mgp; const mgp = search.mgp;
const hasVariant = !!(body || engine || gearbox || mgp); const hasVariant = !!(body || engine || gearbox || mgp);
const [viewMode, setViewMode] = useState<"grid" | "tree" | "columns">( const [viewMode, setViewMode] = useState<ViewMode>(
() => getUserSettings().categoryViewMode ?? "grid", () => (getUserSettings().categoryViewMode as ViewMode | undefined) ?? "grid",
); );
const decodedBrandName = decodeURIComponent(brandName); const decodedBrandName = decodeURIComponent(brandName);
const changeViewMode = (mode: "grid" | "tree" | "columns") => { const changeViewMode = (mode: ViewMode) => {
setViewMode(mode); setViewMode(mode);
setUserSetting("categoryViewMode", mode); setUserSetting("categoryViewMode", mode);
}; };
@@ -72,8 +62,6 @@ function CatalogVehiclePage() {
const isP5WithRestrictions = const isP5WithRestrictions =
vehicle?.architecture === "P5_MODERN" && vehicle?.architecture === "P5_MODERN" &&
!!vehicle?.catalogPath && !!vehicle?.catalogPath &&
// Case-insensitive: Fiat's catalogPath is already a maingroups endpoint
// (/extern/groups/mdl/maingroups) → no restriction step, categories load directly.
!/\/maingroup/i.test(vehicle.catalogPath); !/\/maingroup/i.test(vehicle.catalogPath);
const showPsaVariantSelector = isPsa && !hasVariant; const showPsaVariantSelector = isPsa && !hasVariant;
const showFordVariantSelector = isP4Legacy && !hasVariant; const showFordVariantSelector = isP4Legacy && !hasVariant;
@@ -115,9 +103,6 @@ function CatalogVehiclePage() {
to: "/dashboard/catalog/$brandName/$modelId", to: "/dashboard/catalog/$brandName/$modelId",
params: { brandName, modelId }, params: { brandName, modelId },
search: { search: {
// For Ford (catCode) / Volvo (year) variants: keep the meaningful selection,
// strip _all_ / _nor_ (no-restriction) values to keep URL clean.
// Special case: if ALL are _nor_, pass body="_nor_" so hasVariant=true skips re-showing selector.
body: norm(selectedBody) ?? (selectedBody === "_nor_" ? "_nor_" : undefined), body: norm(selectedBody) ?? (selectedBody === "_nor_" ? "_nor_" : undefined),
engine: norm(selectedEngine), engine: norm(selectedEngine),
gearbox: norm(selectedGearbox), gearbox: norm(selectedGearbox),
@@ -129,144 +114,54 @@ function CatalogVehiclePage() {
if (vehicleLoading) { if (vehicleLoading) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<Skeleton className="h-8 w-64" /> <Skeleton className="h-9 w-72" />
<Skeleton className="h-6 w-96" />
<Skeleton className="h-64 w-full" /> <Skeleton className="h-64 w-full" />
</div> </div>
); );
} }
const titleYear = vehicle?.year ? ` (${vehicle.year})` : "";
const headerTitle = `${vehicle?.model ?? decodedBrandName}${titleYear}`;
const categoryCount = categoryTree?.length ?? 0;
const crumbs = [
{ label: t("catalog.title"), to: "/dashboard/catalog" },
{
label: decodedBrandName,
to: "/dashboard/catalog/$brandName",
search: { catalog: undefined },
},
{ label: vehicle?.model ?? "" },
];
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header / Breadcrumb */} <CatalogHeader
<div className="flex items-center justify-between"> crumbs={crumbs}
<div className="flex items-center gap-3"> title={headerTitle}
<Button onBack={() =>
variant="ghost" navigate({
size="icon" to: "/dashboard/catalog/$brandName",
onClick={() => params: { brandName },
navigate({ search: { catalog: undefined },
to: "/dashboard/catalog/$brandName", })
params: { brandName }, }
search: { catalog: undefined }, actions={
}) !showVariantSelector ? (
} <ViewModeToggle
> value={viewMode}
<ArrowLeft className="size-4" /> onChange={changeViewMode}
</Button> groupLabelKey="catalog.view.groupLabel"
<div> />
<div className="text-xs text-muted-foreground"> ) : undefined
<Link to="/dashboard/catalog" className="hover:underline"> }
{t("catalog.title")} />
</Link>
{" / "}
<Link
to="/dashboard/catalog/$brandName"
params={{ brandName }}
search={{ catalog: undefined }}
className="hover:underline"
>
{decodedBrandName}
</Link>
{" / "}
<span className="font-medium text-foreground">{vehicle?.model}</span>
{hasVariant && (
<>
{body && body !== "_all_" && (
<>
<span className="mx-1">/</span>
<span className="font-medium text-foreground">{body}</span>
</>
)}
{engine && engine !== "_all_" && (
<>
<span className="mx-1">/</span>
<span className="font-medium text-foreground">{engine}</span>
</>
)}
{gearbox && gearbox !== "_all_" && (
<>
<span className="mx-1">/</span>
<span className="font-medium text-foreground">{gearbox}</span>
</>
)}
</>
)}
</div>
<h1 className="text-xl font-bold">
{vehicle?.model}
{vehicle?.year && (
<span className="ml-2 text-base font-normal text-muted-foreground">
({vehicle.year})
</span>
)}
</h1>
</div>
</div>
{/* View toggle — always visible */}
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => changeViewMode("grid")}
className={`rounded p-1.5 ${viewMode === "grid" ? "bg-accent" : "text-muted-foreground hover:text-foreground"}`}
title="Izgara"
>
<CardsViewIcon isActive={viewMode === "grid"} />
</button>
<button
type="button"
onClick={() => changeViewMode("tree")}
className={`rounded p-1.5 ${viewMode === "tree" ? "bg-accent" : "text-muted-foreground hover:text-foreground"}`}
title="Agac"
>
<TreeViewIcon isActive={viewMode === "tree"} />
</button>
<button
type="button"
onClick={() => changeViewMode("columns")}
className={`rounded p-1.5 ${viewMode === "columns" ? "bg-accent" : "text-muted-foreground hover:text-foreground"}`}
title="Sutun"
>
<ListViewIcon isActive={viewMode === "columns"} />
</button>
</div>
</div>
{/* Vehicle Info */} {/* Spec chips — flat row instead of a card. Only renders if at least one
{vehicle && ( spec is populated. Each chip carries an icon so the row reads as
<Card> metadata, not a free-floating label list. */}
<CardHeader> {vehicle && <VehicleSpecRow vehicle={vehicle} variantSearch={variantSearch} />}
<CardTitle className="text-base">{t("catalog.models")}</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-3 text-sm sm:grid-cols-3">
{vehicle.engine && (
<div>
<span className="text-muted-foreground">Motor:</span>{" "}
<span className="font-medium">{vehicle.engine}</span>
</div>
)}
{vehicle.bodyType && (
<div>
<span className="text-muted-foreground">Kasa:</span>{" "}
<span className="font-medium">{vehicle.bodyType}</span>
</div>
)}
{vehicle.transmission && (
<div>
<span className="text-muted-foreground">Vites:</span>{" "}
<span className="font-medium">{vehicle.transmission}</span>
</div>
)}
{vehicle.market && (
<div>
<span className="text-muted-foreground">Pazar:</span>{" "}
<span className="font-medium">{vehicle.market}</span>
</div>
)}
</div>
</CardContent>
</Card>
)}
{/* Variant Selector OR Categories */} {/* Variant Selector OR Categories */}
{showPsaVariantSelector ? ( {showPsaVariantSelector ? (
@@ -276,36 +171,41 @@ function CatalogVehiclePage() {
) : showP5RestrictionSelector ? ( ) : showP5RestrictionSelector ? (
<P5RestrictionSelector vehicleId={modelId} onComplete={handleP5RestrictionComplete} /> <P5RestrictionSelector vehicleId={modelId} onComplete={handleP5RestrictionComplete} />
) : ( ) : (
<Card> <section className="space-y-3">
<CardHeader> <div className="flex items-baseline justify-between">
<CardTitle className="text-base">{t("catalog.categories")}</CardTitle> <h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
</CardHeader> {t("catalog.categories")}
<CardContent </h2>
className={viewMode === "columns" ? "p-0 overflow-hidden rounded-b-lg" : undefined} {categoryTree && categoryTree.length > 0 && (
> <span className="text-xs tabular-nums text-muted-foreground">
{categoriesLoading ? ( {t("catalog.categoryCount", { count: categoryCount })}
<div className="space-y-2"> </span>
{KEYS_8.map((__k) => ( )}
<Skeleton key={__k} className="h-8 w-full" /> </div>
))} {categoriesLoading ? (
</div> <div className="space-y-2">
) : viewMode === "grid" ? ( {KEYS_8.map((__k) => (
<CategoryGrid <Skeleton key={__k} className="h-10 w-full rounded-md" />
categories={categoryTree || []} ))}
vehicleId={modelId} </div>
catalogMode ) : viewMode === "grid" ? (
brandName={brandName} <CategoryGrid
variantSearch={variantSearch} categories={categoryTree || []}
/> vehicleId={modelId}
) : viewMode === "tree" ? ( catalogMode
<CategoryTree brandName={brandName}
categories={categoryTree || []} variantSearch={variantSearch}
vehicleId={modelId} />
catalogMode ) : viewMode === "tree" ? (
brandName={brandName} <CategoryTree
variantSearch={variantSearch} categories={categoryTree || []}
/> vehicleId={modelId}
) : ( catalogMode
brandName={brandName}
variantSearch={variantSearch}
/>
) : (
<div className="overflow-hidden rounded-lg border border-border">
<CategoryColumns <CategoryColumns
categories={categoryTree || []} categories={categoryTree || []}
vehicleId={modelId} vehicleId={modelId}
@@ -313,10 +213,82 @@ function CatalogVehiclePage() {
brandName={brandName} brandName={brandName}
variantSearch={variantSearch} variantSearch={variantSearch}
/> />
)} </div>
</CardContent> )}
</Card> </section>
)} )}
</div> </div>
); );
} }
function VehicleSpecRow({
vehicle,
variantSearch,
}: {
vehicle: {
engine?: string | null;
bodyType?: string | null;
transmission?: string | null;
market?: string | null;
};
variantSearch?: { body?: string; engine?: string; gearbox?: string; mgp?: string };
}) {
const { t } = useTranslation();
// Show the catalog-vehicle's intrinsic specs first; overlay the actively
// selected variant (PSA/Ford restrictions) when the user has drilled
// further. Skip non-meaningful sentinels.
const items: Array<{ key: string; label: string; value: string; Icon: typeof Gauge }> = [];
const skip = (v?: string | null) => !v || v === "_all_" || v === "_nor_";
const engineValue = !skip(variantSearch?.engine) ? variantSearch?.engine : vehicle.engine;
const bodyValue = !skip(variantSearch?.body) ? variantSearch?.body : vehicle.bodyType;
const gearboxValue = !skip(variantSearch?.gearbox)
? variantSearch?.gearbox
: vehicle.transmission;
if (engineValue)
items.push({
key: "engine",
label: t("catalog.vehicleSpecs.engine"),
value: engineValue,
Icon: Gauge,
});
if (bodyValue)
items.push({
key: "body",
label: t("catalog.vehicleSpecs.body"),
value: bodyValue,
Icon: Cog,
});
if (gearboxValue)
items.push({
key: "transmission",
label: t("catalog.vehicleSpecs.transmission"),
value: gearboxValue,
Icon: Settings2,
});
if (vehicle.market)
items.push({
key: "market",
label: t("catalog.vehicleSpecs.market"),
value: vehicle.market,
Icon: MapPin,
});
if (items.length === 0) return null;
return (
<div className="flex flex-wrap items-center gap-2">
{items.map(({ key, label, value, Icon }) => (
<span
key={key}
className="inline-flex items-center gap-2 rounded-full border border-border bg-muted/40 px-3 py-1 text-xs"
>
<Icon className="size-3.5 text-muted-foreground" aria-hidden="true" />
<span className="text-muted-foreground">{label}:</span>
<span className="font-medium text-foreground">{value}</span>
</span>
))}
</div>
);
}

View File

@@ -163,7 +163,7 @@ function EmexVehicleListPage() {
{wizardError && ( {wizardError && (
<div className="flex items-center gap-2 rounded-lg border border-destructive/50 bg-destructive/10 p-4 text-sm text-destructive"> <div className="flex items-center gap-2 rounded-lg border border-destructive/50 bg-destructive/10 p-4 text-sm text-destructive">
<AlertCircle className="size-4 shrink-0" /> <AlertCircle className="size-4 shrink-0" />
<p>Katalog verileri yüklenemedi. Lütfen tekrar deneyin.</p> <p>{t("catalog.emex.loadError")}</p>
</div> </div>
)} )}
@@ -192,7 +192,9 @@ function EmexVehicleListPage() {
</div> </div>
) : matchedVehicles && matchedVehicles.length > 0 ? ( ) : matchedVehicles && matchedVehicles.length > 0 ? (
<div className="space-y-1"> <div className="space-y-1">
<p className="text-xs text-muted-foreground">{matchedVehicles.length} varyant</p> <p className="text-xs tabular-nums text-muted-foreground">
{t("catalog.emex.variantCount", { count: matchedVehicles.length })}
</p>
{matchedVehicles.map((v) => ( {matchedVehicles.map((v) => (
<VehicleRow key={v.id} vehicle={v} catalogCode={catalogCode} /> <VehicleRow key={v.id} vehicle={v} catalogCode={catalogCode} />
))} ))}
@@ -200,11 +202,9 @@ function EmexVehicleListPage() {
) : ( ) : (
<div className="rounded-lg border border-dashed border-border py-8 text-center"> <div className="rounded-lg border border-dashed border-border py-8 text-center">
<Car className="mx-auto mb-2 size-8 text-muted-foreground/50" /> <Car className="mx-auto mb-2 size-8 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">{t("catalog.emex.noPartsTitle")}</p>
Bu araç konfigürasyonu için parça verisi henüz mevcut değil.
</p>
<p className="mt-1 text-xs text-muted-foreground/70"> <p className="mt-1 text-xs text-muted-foreground/70">
Farklı bir model veya varyant seçmeyi deneyin. {t("catalog.emex.noPartsHint")}
</p> </p>
</div> </div>
)} )}
@@ -228,27 +228,31 @@ function WizardStep({
row: WizardRow; row: WizardRow;
onSelect: (rowName: string, option: WizardOption) => void; onSelect: (rowName: string, option: WizardOption) => void;
}) { }) {
const { t } = useTranslation();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const filtered = useMemo(() => { const filtered = useMemo(() => {
if (!search) return row.options; if (!search) return row.options;
const q = search.toLowerCase(); const q = search.toLocaleLowerCase("tr");
return row.options.filter((o) => o.value.toLowerCase().includes(q)); return row.options.filter((o) => o.value.toLocaleLowerCase("tr").includes(q));
}, [row.options, search]); }, [row.options, search]);
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-sm font-medium">{row.name}</h2> <h2 className="text-sm font-medium">{row.name}</h2>
<span className="text-xs text-muted-foreground">{row.options.length} seçenek</span> <span className="text-xs tabular-nums text-muted-foreground">
{t("catalog.emex.optionCount", { count: row.options.length })}
</span>
</div> </div>
{row.options.length > 10 && ( {row.options.length > 10 && (
<input <input
type="text" type="search"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
placeholder="Ara..." placeholder={t("catalog.emex.searchPlaceholder")}
aria-label={t("catalog.emex.searchPlaceholder")}
className="h-8 w-full rounded-md border border-input bg-background px-3 text-sm shadow-sm focus:outline-none focus:ring-1 focus:ring-ring" className="h-8 w-full rounded-md border border-input bg-background px-3 text-sm shadow-sm focus:outline-none focus:ring-1 focus:ring-ring"
/> />
)} )}
@@ -266,7 +270,9 @@ function WizardStep({
</button> </button>
))} ))}
{filtered.length === 0 && ( {filtered.length === 0 && (
<p className="py-4 text-center text-sm text-muted-foreground">Sonuç bulunamadı</p> <p className="py-4 text-center text-sm text-muted-foreground">
{t("catalog.emex.noResults")}
</p>
)} )}
</div> </div>
</div> </div>

View File

@@ -1,10 +1,12 @@
import { CatalogHeader } from "@/components/catalog/catalog-header";
import { api } from "@/lib/api-client"; import { api } from "@/lib/api-client";
import { EMEX_GROUP_HIERARCHY } from "@/lib/emex-group-hierarchy"; import { EMEX_GROUP_HIERARCHY } from "@/lib/emex-group-hierarchy";
import { useTranslation } from "@/lib/i18n"; import { useTranslation } from "@/lib/i18n";
import { Button, Skeleton } from "@sase/ui"; import { KEYS_8 } from "@/lib/keys";
import { Skeleton } from "@sase/ui";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Link, createFileRoute } from "@tanstack/react-router"; import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
import { ArrowLeft, ChevronDown, ChevronRight, FolderOpen } from "lucide-react"; import { ChevronDown, ChevronRight, FolderOpen } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
export const Route = createFileRoute("/dashboard/catalog_/emex/$catalogCode_/$vehicleId/")({ export const Route = createFileRoute("/dashboard/catalog_/emex/$catalogCode_/$vehicleId/")({
@@ -60,6 +62,7 @@ function buildTree(groups: EmexGroup[]): TreeNode[] {
function EmexGroupListPage() { function EmexGroupListPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { catalogCode, vehicleId } = Route.useParams(); const { catalogCode, vehicleId } = Route.useParams();
const navigate = useNavigate();
const { data: groups, isLoading } = useQuery({ const { data: groups, isLoading } = useQuery({
queryKey: ["emex-groups", vehicleId], queryKey: ["emex-groups", vehicleId],
@@ -69,23 +72,37 @@ function EmexGroupListPage() {
const tree = groups ? buildTree(groups) : []; const tree = groups ? buildTree(groups) : [];
const isFlat = tree.length === 0 && groups && groups.length > 0; const isFlat = tree.length === 0 && groups && groups.length > 0;
const crumbs = [
{ label: t("catalog.title"), to: "/dashboard/catalog" },
{
label: decodeURIComponent(catalogCode),
to: "/dashboard/catalog/emex/$catalogCode",
search: {},
},
{ label: t("catalog.categories") },
];
return ( return (
<div className="space-y-4"> <div className="space-y-6">
<div className="flex items-center gap-3"> <CatalogHeader
<Link to="/dashboard/catalog/emex/$catalogCode" params={{ catalogCode }}> crumbs={crumbs}
<Button variant="ghost" size="sm"> title={t("catalog.categories")}
<ArrowLeft className="mr-1 size-4" /> onBack={() =>
{t("catalog.backToModels")} navigate({ to: "/dashboard/catalog/emex/$catalogCode", params: { catalogCode } })
</Button> }
</Link> actions={
<h1 className="text-xl font-bold">{t("catalog.categories")}</h1> groups && groups.length > 0 ? (
{groups && <span className="text-sm text-muted-foreground">({groups.length})</span>} <span className="text-xs tabular-nums text-muted-foreground">
</div> {t("catalog.categoryCount", { count: groups.length })}
</span>
) : undefined
}
/>
{isLoading ? ( {isLoading ? (
<div className="space-y-2"> <div className="space-y-2">
{Array.from({ length: 8 }).map((_, i) => ( {KEYS_8.map((__k) => (
<Skeleton key={i} className="h-10 w-full rounded-lg" /> <Skeleton key={__k} className="h-10 w-full rounded-lg" />
))} ))}
</div> </div>
) : !groups || groups.length === 0 ? ( ) : !groups || groups.length === 0 ? (

View File

@@ -1,9 +1,11 @@
import { CatalogHeader } from "@/components/catalog/catalog-header";
import { api } from "@/lib/api-client"; import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n"; import { useTranslation } from "@/lib/i18n";
import { Button, Skeleton } from "@sase/ui"; import { KEYS_8 } from "@/lib/keys";
import { Input, Skeleton } from "@sase/ui";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Link, createFileRoute } from "@tanstack/react-router"; import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
import { ArrowLeft, Car, ChevronRight } from "lucide-react"; import { Car, ChevronRight, Search, X } from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
export const Route = createFileRoute("/dashboard/catalog_/pcat/$catalogId_/$modelId/")({ export const Route = createFileRoute("/dashboard/catalog_/pcat/$catalogId_/$modelId/")({
@@ -29,6 +31,7 @@ interface PcatCar {
function PcatCarsPage() { function PcatCarsPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { catalogId, modelId } = Route.useParams(); const { catalogId, modelId } = Route.useParams();
const navigate = useNavigate();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const { data: cars, isLoading } = useQuery({ const { data: cars, isLoading } = useQuery({
@@ -39,84 +42,118 @@ function PcatCarsPage() {
const filtered = useMemo(() => { const filtered = useMemo(() => {
if (!cars) return []; if (!cars) return [];
if (!search) return cars; if (!search) return cars;
const q = search.toLowerCase(); const q = search.toLocaleLowerCase("tr");
return cars.filter( return cars.filter(
(c) => (c) =>
c.name.toLowerCase().includes(q) || c.name.toLocaleLowerCase("tr").includes(q) ||
c.engine?.toLowerCase().includes(q) || c.engine?.toLocaleLowerCase("tr").includes(q) ||
c.bodyType?.toLowerCase().includes(q), c.bodyType?.toLocaleLowerCase("tr").includes(q),
); );
}, [cars, search]); }, [cars, search]);
const crumbs = [
{ label: t("catalog.title"), to: "/dashboard/catalog" },
{
label: catalogId.toUpperCase(),
to: "/dashboard/catalog/pcat/$catalogId",
search: {},
},
{ label: t("catalog.models") },
];
return ( return (
<div className="space-y-4"> <div className="space-y-6">
<div className="flex items-center gap-3"> <CatalogHeader
<Link to="/dashboard/catalog/pcat/$catalogId" params={{ catalogId }}> crumbs={crumbs}
<Button variant="ghost" size="sm"> title={catalogId.toUpperCase()}
<ArrowLeft className="mr-1 size-4" /> onBack={() => navigate({ to: "/dashboard/catalog/pcat/$catalogId", params: { catalogId } })}
{t("catalog.backToModels")} />
</Button>
</Link>
<h1 className="text-xl font-bold">{catalogId.toUpperCase()}</h1>
</div>
{isLoading ? ( {isLoading ? (
<div className="space-y-2"> <div className="space-y-2">
{Array.from({ length: 6 }).map((_, i) => ( {KEYS_8.map((__k) => (
<Skeleton key={i} className="h-14 w-full rounded-lg" /> <Skeleton key={__k} className="h-14 w-full rounded-lg" />
))} ))}
</div> </div>
) : !cars || cars.length === 0 ? ( ) : !cars || cars.length === 0 ? (
<p className="py-8 text-center text-muted-foreground">{t("catalog.noModels")}</p> <div className="flex flex-col items-center justify-center py-16 text-center">
<Car className="mb-4 size-12 text-muted-foreground/40" />
<p className="text-muted-foreground">{t("catalog.noModels")}</p>
</div>
) : ( ) : (
<> <div className="space-y-3">
{cars.length > 10 && ( {cars.length > 10 && (
<input <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
type="text" <div className="relative flex-1 sm:max-w-md">
value={search} <Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
onChange={(e) => setSearch(e.target.value)} <Input
placeholder="Ara..." type="search"
className="h-8 w-full rounded-md border border-input bg-background px-3 text-sm shadow-sm focus:outline-none focus:ring-1 focus:ring-ring" value={search}
/> onChange={(e) => setSearch(e.target.value)}
)} placeholder={t("catalog.pcat.searchPlaceholder")}
<div className="space-y-1"> aria-label={t("catalog.pcat.searchPlaceholder")}
<p className="text-xs text-muted-foreground">{filtered.length} araç</p> className="pl-9"
{filtered.map((car) => ( />
<Link {search && (
key={car.id} <button
to="/dashboard/catalog/pcat/$catalogId/$modelId/$carId" type="button"
params={{ catalogId, modelId, carId: car.id }} onClick={() => setSearch("")}
className="flex items-center gap-3 rounded-lg border border-border bg-card px-3 py-2.5 transition-colors hover:bg-accent" aria-label={t("common.cancel")}
> className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-1 text-muted-foreground hover:text-foreground"
<Car className="size-4 shrink-0 text-muted-foreground" /> >
<div className="min-w-0 flex-1"> <X className="size-3.5" />
<p className="truncate text-sm font-medium">{car.name}</p> </button>
<p className="truncate text-xs text-muted-foreground">
{[
car.engine,
car.bodyType,
car.transmission,
car.fuelType,
car.yearFrom && car.yearTo
? `${car.yearFrom}-${car.yearTo}`
: car.yearFrom
? `${car.yearFrom}+`
: null,
]
.filter(Boolean)
.join(" · ")}
</p>
</div>
{car.schemasCount > 0 && (
<span className="shrink-0 text-xs text-muted-foreground">
{car.schemasCount} şema
</span>
)} )}
<ChevronRight className="size-4 shrink-0 text-muted-foreground" /> </div>
</Link> <span className="text-xs tabular-nums text-muted-foreground">
))} {t("catalog.pcat.vehicleCount", { count: filtered.length })}
</div> </span>
</> </div>
)}
{filtered.length === 0 ? (
<div className="rounded-2xl border border-border bg-muted/10 p-8 text-center">
<p className="text-sm font-medium">{t("catalog.modelSearchNoMatch")}</p>
</div>
) : (
<div className="space-y-1.5">
{filtered.map((car) => (
<Link
key={car.id}
to="/dashboard/catalog/pcat/$catalogId/$modelId/$carId"
params={{ catalogId, modelId, carId: car.id }}
preload="intent"
className="flex items-center gap-3 rounded-lg border border-border bg-card px-3 py-3 transition-colors hover:border-primary/30 hover:bg-accent/40"
>
<Car className="size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{car.name}</p>
<p className="truncate text-xs text-muted-foreground">
{[
car.engine,
car.bodyType,
car.transmission,
car.fuelType,
car.yearFrom && car.yearTo
? `${car.yearFrom}-${car.yearTo}`
: car.yearFrom
? `${car.yearFrom}+`
: null,
]
.filter(Boolean)
.join(" · ")}
</p>
</div>
{car.schemasCount > 0 && (
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
{t("catalog.pcat.schemaCount", { count: car.schemasCount })}
</span>
)}
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
</Link>
))}
</div>
)}
</div>
)} )}
</div> </div>
); );