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:
@@ -1,24 +1,21 @@
|
||||
import { CatalogHeader } from "@/components/catalog/catalog-header";
|
||||
import { FordVariantSelector } from "@/components/catalog/ford-variant-selector";
|
||||
import { P5RestrictionSelector } from "@/components/catalog/p5-restriction-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 { CategoryGrid } from "@/components/categories/category-grid";
|
||||
import { CategoryTree } from "@/components/categories/category-tree";
|
||||
import {
|
||||
CardsViewIcon,
|
||||
ListViewIcon,
|
||||
TreeViewIcon,
|
||||
} from "@/components/categories/view-toggle-icons";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { KEYS_8 } from "@/lib/keys";
|
||||
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 { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { Cog, Gauge, MapPin, Settings2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { KEYS_8 } from "@/lib/keys";
|
||||
export const Route = createFileRoute("/dashboard/catalog_/$brandName_/$modelId/")({
|
||||
validateSearch: (search) => ({
|
||||
body: typeof search.body === "string" ? search.body : undefined,
|
||||
@@ -29,14 +26,7 @@ export const Route = createFileRoute("/dashboard/catalog_/$brandName_/$modelId/"
|
||||
component: CatalogVehiclePage,
|
||||
});
|
||||
|
||||
function buildVariantQuery(body?: string, engine?: string, gearbox?: string): string {
|
||||
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}` : "";
|
||||
}
|
||||
type ViewMode = "grid" | "tree" | "columns";
|
||||
|
||||
function CatalogVehiclePage() {
|
||||
const { brandName, modelId } = Route.useParams();
|
||||
@@ -50,13 +40,13 @@ function CatalogVehiclePage() {
|
||||
const mgp = search.mgp;
|
||||
const hasVariant = !!(body || engine || gearbox || mgp);
|
||||
|
||||
const [viewMode, setViewMode] = useState<"grid" | "tree" | "columns">(
|
||||
() => getUserSettings().categoryViewMode ?? "grid",
|
||||
const [viewMode, setViewMode] = useState<ViewMode>(
|
||||
() => (getUserSettings().categoryViewMode as ViewMode | undefined) ?? "grid",
|
||||
);
|
||||
|
||||
const decodedBrandName = decodeURIComponent(brandName);
|
||||
|
||||
const changeViewMode = (mode: "grid" | "tree" | "columns") => {
|
||||
const changeViewMode = (mode: ViewMode) => {
|
||||
setViewMode(mode);
|
||||
setUserSetting("categoryViewMode", mode);
|
||||
};
|
||||
@@ -72,8 +62,6 @@ function CatalogVehiclePage() {
|
||||
const isP5WithRestrictions =
|
||||
vehicle?.architecture === "P5_MODERN" &&
|
||||
!!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);
|
||||
const showPsaVariantSelector = isPsa && !hasVariant;
|
||||
const showFordVariantSelector = isP4Legacy && !hasVariant;
|
||||
@@ -115,9 +103,6 @@ function CatalogVehiclePage() {
|
||||
to: "/dashboard/catalog/$brandName/$modelId",
|
||||
params: { brandName, modelId },
|
||||
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),
|
||||
engine: norm(selectedEngine),
|
||||
gearbox: norm(selectedGearbox),
|
||||
@@ -129,144 +114,54 @@ function CatalogVehiclePage() {
|
||||
if (vehicleLoading) {
|
||||
return (
|
||||
<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" />
|
||||
</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 (
|
||||
<div className="space-y-6">
|
||||
{/* Header / Breadcrumb */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
navigate({
|
||||
to: "/dashboard/catalog/$brandName",
|
||||
params: { brandName },
|
||||
search: { catalog: undefined },
|
||||
})
|
||||
}
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<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>
|
||||
<CatalogHeader
|
||||
crumbs={crumbs}
|
||||
title={headerTitle}
|
||||
onBack={() =>
|
||||
navigate({
|
||||
to: "/dashboard/catalog/$brandName",
|
||||
params: { brandName },
|
||||
search: { catalog: undefined },
|
||||
})
|
||||
}
|
||||
actions={
|
||||
!showVariantSelector ? (
|
||||
<ViewModeToggle
|
||||
value={viewMode}
|
||||
onChange={changeViewMode}
|
||||
groupLabelKey="catalog.view.groupLabel"
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Vehicle Info */}
|
||||
{vehicle && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<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>
|
||||
)}
|
||||
{/* Spec chips — flat row instead of a card. Only renders if at least one
|
||||
spec is populated. Each chip carries an icon so the row reads as
|
||||
metadata, not a free-floating label list. */}
|
||||
{vehicle && <VehicleSpecRow vehicle={vehicle} variantSearch={variantSearch} />}
|
||||
|
||||
{/* Variant Selector OR Categories */}
|
||||
{showPsaVariantSelector ? (
|
||||
@@ -276,36 +171,41 @@ function CatalogVehiclePage() {
|
||||
) : showP5RestrictionSelector ? (
|
||||
<P5RestrictionSelector vehicleId={modelId} onComplete={handleP5RestrictionComplete} />
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("catalog.categories")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent
|
||||
className={viewMode === "columns" ? "p-0 overflow-hidden rounded-b-lg" : undefined}
|
||||
>
|
||||
{categoriesLoading ? (
|
||||
<div className="space-y-2">
|
||||
{KEYS_8.map((__k) => (
|
||||
<Skeleton key={__k} className="h-8 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : viewMode === "grid" ? (
|
||||
<CategoryGrid
|
||||
categories={categoryTree || []}
|
||||
vehicleId={modelId}
|
||||
catalogMode
|
||||
brandName={brandName}
|
||||
variantSearch={variantSearch}
|
||||
/>
|
||||
) : viewMode === "tree" ? (
|
||||
<CategoryTree
|
||||
categories={categoryTree || []}
|
||||
vehicleId={modelId}
|
||||
catalogMode
|
||||
brandName={brandName}
|
||||
variantSearch={variantSearch}
|
||||
/>
|
||||
) : (
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{t("catalog.categories")}
|
||||
</h2>
|
||||
{categoryTree && categoryTree.length > 0 && (
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
{t("catalog.categoryCount", { count: categoryCount })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{categoriesLoading ? (
|
||||
<div className="space-y-2">
|
||||
{KEYS_8.map((__k) => (
|
||||
<Skeleton key={__k} className="h-10 w-full rounded-md" />
|
||||
))}
|
||||
</div>
|
||||
) : viewMode === "grid" ? (
|
||||
<CategoryGrid
|
||||
categories={categoryTree || []}
|
||||
vehicleId={modelId}
|
||||
catalogMode
|
||||
brandName={brandName}
|
||||
variantSearch={variantSearch}
|
||||
/>
|
||||
) : viewMode === "tree" ? (
|
||||
<CategoryTree
|
||||
categories={categoryTree || []}
|
||||
vehicleId={modelId}
|
||||
catalogMode
|
||||
brandName={brandName}
|
||||
variantSearch={variantSearch}
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-lg border border-border">
|
||||
<CategoryColumns
|
||||
categories={categoryTree || []}
|
||||
vehicleId={modelId}
|
||||
@@ -313,10 +213,82 @@ function CatalogVehiclePage() {
|
||||
brandName={brandName}
|
||||
variantSearch={variantSearch}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -163,7 +163,7 @@ function EmexVehicleListPage() {
|
||||
{wizardError && (
|
||||
<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" />
|
||||
<p>Katalog verileri yüklenemedi. Lütfen tekrar deneyin.</p>
|
||||
<p>{t("catalog.emex.loadError")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -192,7 +192,9 @@ function EmexVehicleListPage() {
|
||||
</div>
|
||||
) : matchedVehicles && matchedVehicles.length > 0 ? (
|
||||
<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) => (
|
||||
<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">
|
||||
<Car className="mx-auto mb-2 size-8 text-muted-foreground/50" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Bu araç konfigürasyonu için parça verisi henüz mevcut değil.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{t("catalog.emex.noPartsTitle")}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground/70">
|
||||
Farklı bir model veya varyant seçmeyi deneyin.
|
||||
{t("catalog.emex.noPartsHint")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -228,27 +228,31 @@ function WizardStep({
|
||||
row: WizardRow;
|
||||
onSelect: (rowName: string, option: WizardOption) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!search) return row.options;
|
||||
const q = search.toLowerCase();
|
||||
return row.options.filter((o) => o.value.toLowerCase().includes(q));
|
||||
const q = search.toLocaleLowerCase("tr");
|
||||
return row.options.filter((o) => o.value.toLocaleLowerCase("tr").includes(q));
|
||||
}, [row.options, search]);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<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>
|
||||
|
||||
{row.options.length > 10 && (
|
||||
<input
|
||||
type="text"
|
||||
type="search"
|
||||
value={search}
|
||||
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"
|
||||
/>
|
||||
)}
|
||||
@@ -266,7 +270,9 @@ function WizardStep({
|
||||
</button>
|
||||
))}
|
||||
{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>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { CatalogHeader } from "@/components/catalog/catalog-header";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { EMEX_GROUP_HIERARCHY } from "@/lib/emex-group-hierarchy";
|
||||
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 { Link, createFileRoute } from "@tanstack/react-router";
|
||||
import { ArrowLeft, ChevronDown, ChevronRight, FolderOpen } from "lucide-react";
|
||||
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { ChevronDown, ChevronRight, FolderOpen } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
export const Route = createFileRoute("/dashboard/catalog_/emex/$catalogCode_/$vehicleId/")({
|
||||
@@ -60,6 +62,7 @@ function buildTree(groups: EmexGroup[]): TreeNode[] {
|
||||
function EmexGroupListPage() {
|
||||
const { t } = useTranslation();
|
||||
const { catalogCode, vehicleId } = Route.useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data: groups, isLoading } = useQuery({
|
||||
queryKey: ["emex-groups", vehicleId],
|
||||
@@ -69,23 +72,37 @@ function EmexGroupListPage() {
|
||||
const tree = groups ? buildTree(groups) : [];
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to="/dashboard/catalog/emex/$catalogCode" params={{ catalogCode }}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="mr-1 size-4" />
|
||||
{t("catalog.backToModels")}
|
||||
</Button>
|
||||
</Link>
|
||||
<h1 className="text-xl font-bold">{t("catalog.categories")}</h1>
|
||||
{groups && <span className="text-sm text-muted-foreground">({groups.length})</span>}
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<CatalogHeader
|
||||
crumbs={crumbs}
|
||||
title={t("catalog.categories")}
|
||||
onBack={() =>
|
||||
navigate({ to: "/dashboard/catalog/emex/$catalogCode", params: { catalogCode } })
|
||||
}
|
||||
actions={
|
||||
groups && groups.length > 0 ? (
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
{t("catalog.categoryCount", { count: groups.length })}
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-10 w-full rounded-lg" />
|
||||
{KEYS_8.map((__k) => (
|
||||
<Skeleton key={__k} className="h-10 w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : !groups || groups.length === 0 ? (
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { CatalogHeader } from "@/components/catalog/catalog-header";
|
||||
import { api } from "@/lib/api-client";
|
||||
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 { Link, createFileRoute } from "@tanstack/react-router";
|
||||
import { ArrowLeft, Car, ChevronRight } from "lucide-react";
|
||||
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { Car, ChevronRight, Search, X } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
export const Route = createFileRoute("/dashboard/catalog_/pcat/$catalogId_/$modelId/")({
|
||||
@@ -29,6 +31,7 @@ interface PcatCar {
|
||||
function PcatCarsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { catalogId, modelId } = Route.useParams();
|
||||
const navigate = useNavigate();
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const { data: cars, isLoading } = useQuery({
|
||||
@@ -39,84 +42,118 @@ function PcatCarsPage() {
|
||||
const filtered = useMemo(() => {
|
||||
if (!cars) return [];
|
||||
if (!search) return cars;
|
||||
const q = search.toLowerCase();
|
||||
const q = search.toLocaleLowerCase("tr");
|
||||
return cars.filter(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(q) ||
|
||||
c.engine?.toLowerCase().includes(q) ||
|
||||
c.bodyType?.toLowerCase().includes(q),
|
||||
c.name.toLocaleLowerCase("tr").includes(q) ||
|
||||
c.engine?.toLocaleLowerCase("tr").includes(q) ||
|
||||
c.bodyType?.toLocaleLowerCase("tr").includes(q),
|
||||
);
|
||||
}, [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 (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to="/dashboard/catalog/pcat/$catalogId" params={{ catalogId }}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="mr-1 size-4" />
|
||||
{t("catalog.backToModels")}
|
||||
</Button>
|
||||
</Link>
|
||||
<h1 className="text-xl font-bold">{catalogId.toUpperCase()}</h1>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<CatalogHeader
|
||||
crumbs={crumbs}
|
||||
title={catalogId.toUpperCase()}
|
||||
onBack={() => navigate({ to: "/dashboard/catalog/pcat/$catalogId", params: { catalogId } })}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full rounded-lg" />
|
||||
{KEYS_8.map((__k) => (
|
||||
<Skeleton key={__k} className="h-14 w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : !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 && (
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Ara..."
|
||||
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"
|
||||
/>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">{filtered.length} araç</p>
|
||||
{filtered.map((car) => (
|
||||
<Link
|
||||
key={car.id}
|
||||
to="/dashboard/catalog/pcat/$catalogId/$modelId/$carId"
|
||||
params={{ catalogId, modelId, carId: car.id }}
|
||||
className="flex items-center gap-3 rounded-lg border border-border bg-card px-3 py-2.5 transition-colors hover:bg-accent"
|
||||
>
|
||||
<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 text-muted-foreground">
|
||||
{car.schemasCount} şema
|
||||
</span>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="relative flex-1 sm:max-w-md">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t("catalog.pcat.searchPlaceholder")}
|
||||
aria-label={t("catalog.pcat.searchPlaceholder")}
|
||||
className="pl-9"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearch("")}
|
||||
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"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
{t("catalog.pcat.vehicleCount", { count: filtered.length })}
|
||||
</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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user