feat(web): /catalog/:brandName — model search/sort, multi-catalog icons, primitives, PostHog
Migrates the brand-detail page to the shared CatalogHeader (with real
breadcrumb) + ViewModeToggle from 10A. Adds the toolbox the audit called
out and quietly fixes the "Sutun" typo by retiring the inline buttons.
Model browsing
- Search across {model, engine, year, bodyType} with Turkish-locale fold
- Sort dropdown: year DESC (default), year ASC, model A→Z — replaces the
silent API-defined order
- No-match state with the original query exposed
- ModelCard attributes now Badge-pilled (year secondary, others outline)
instead of flat space-separated text — "1998 1.6 FSI Hatchback Manuel"
was a wall of words
- preload="intent" on each model link so the categories drill page is
warmed up on hover
Multi-catalog selector
- iconForService(): Pl24 → Layers, Pcat → Database, Emex → Zap, Tecdoc
→ Wrench, fallback BookOpen. Four identical BookOpen tiles read as one
catalog before.
- Drops the raw `serviceName` muted-text exposure under each tile
Breadcrumb / header
- "Katalog / Volkswagen / Pl24" now renders through CatalogHeader; the
inline `<div className="text-xs">` tree got retired
- Back button stays handle multi-catalog vs single-source distinction
Loading
- Removed the doubled-up `Loader2 "Modeller yükleniyor…"` line; skeleton
alone is enough
PostHog: catalog_models_viewed, catalog_model_search_used,
catalog_model_sort_changed, catalog_subcatalog_selected,
catalog_model_clicked, catalog_view_mode_changed (surface=models).
This commit is contained in:
@@ -1,19 +1,27 @@
|
||||
import { CatalogHeader } from "@/components/catalog/catalog-header";
|
||||
import { ModelListColumns } from "@/components/catalog/model-list-columns";
|
||||
import { ModelListTree } from "@/components/catalog/model-list-tree";
|
||||
import {
|
||||
CardsViewIcon,
|
||||
ListViewIcon,
|
||||
TreeViewIcon,
|
||||
} from "@/components/categories/view-toggle-icons";
|
||||
import { ViewModeToggle } from "@/components/catalog/view-mode-toggle";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { KEYS_4, KEYS_9 } from "@/lib/keys";
|
||||
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
|
||||
import { Button, Skeleton, cn } from "@sase/ui";
|
||||
import { capture } from "@/lib/posthog";
|
||||
import { useViewMode } from "@/lib/use-view-mode";
|
||||
import { Badge, Input, Skeleton } from "@sase/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { ArrowLeft, BookOpen, Car, ChevronRight, Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
BookOpen,
|
||||
Car,
|
||||
ChevronRight,
|
||||
Database,
|
||||
Layers,
|
||||
Search as SearchIcon,
|
||||
Wrench,
|
||||
X,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
export const Route = createFileRoute("/dashboard/catalog_/$brandName/")({
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
@@ -40,33 +48,38 @@ interface CatalogVehicle {
|
||||
catalogPath: string | null;
|
||||
}
|
||||
|
||||
type SortMode = "newest" | "oldest" | "alphabetical";
|
||||
|
||||
// Each catalog service gets its own icon so the multi-source selector reads
|
||||
// as a real menu rather than four identical BookOpen tiles.
|
||||
function iconForService(serviceName: string) {
|
||||
const lower = serviceName.toLowerCase();
|
||||
if (lower.includes("pl24")) return Layers;
|
||||
if (lower.includes("pcat")) return Database;
|
||||
if (lower.includes("emex")) return Zap;
|
||||
if (lower.includes("tecdoc")) return Wrench;
|
||||
return BookOpen;
|
||||
}
|
||||
|
||||
function CatalogModelsPage() {
|
||||
const { brandName } = Route.useParams();
|
||||
const { catalog: activeCatalog } = Route.useSearch();
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [viewMode, setViewMode] = useViewMode("modelViewMode");
|
||||
const [query, setQuery] = useState("");
|
||||
const [sort, setSort] = useState<SortMode>("newest");
|
||||
const viewedRef = useRef(false);
|
||||
|
||||
const decodedBrandName = decodeURIComponent(brandName);
|
||||
|
||||
const [viewMode, setViewMode] = useState<"grid" | "tree" | "columns">(
|
||||
() => getUserSettings().modelViewMode ?? "grid",
|
||||
);
|
||||
|
||||
const changeViewMode = (mode: "grid" | "tree" | "columns") => {
|
||||
setViewMode(mode);
|
||||
setUserSetting("modelViewMode", mode);
|
||||
};
|
||||
|
||||
// Always fetch catalogs to know whether this brand has multiple sub-catalogs
|
||||
const { data: catalogs, isLoading: catalogsLoading } = useQuery({
|
||||
queryKey: ["catalog-catalogs", decodedBrandName],
|
||||
queryFn: () =>
|
||||
api.get<CatalogEntry[]>(`/catalog/brands/${encodeURIComponent(decodedBrandName)}/catalogs`),
|
||||
staleTime: 1000 * 60 * 30, // 30 min
|
||||
queryFn: () => api.get<CatalogEntry[]>(`/catalog/brands/${decodedBrandName}/catalogs`),
|
||||
staleTime: 30 * 60 * 1000,
|
||||
});
|
||||
|
||||
const isMultiCatalog = (catalogs?.length ?? 0) > 1;
|
||||
// Show models when: single-service brand, OR user has selected a sub-catalog
|
||||
const shouldShowModels = !isMultiCatalog || !!activeCatalog;
|
||||
|
||||
const activeCatalogLabel = activeCatalog
|
||||
@@ -77,73 +90,113 @@ function CatalogModelsPage() {
|
||||
queryKey: ["catalog-models", decodedBrandName, activeCatalog ?? null],
|
||||
queryFn: () => {
|
||||
const serviceParam = activeCatalog ? `?service=${encodeURIComponent(activeCatalog)}` : "";
|
||||
return api.get<CatalogVehicle[]>(
|
||||
`/catalog/brands/${encodeURIComponent(decodedBrandName)}/models${serviceParam}`,
|
||||
);
|
||||
return api.get<CatalogVehicle[]>(`/catalog/brands/${decodedBrandName}/models${serviceParam}`);
|
||||
},
|
||||
enabled: shouldShowModels && !catalogsLoading,
|
||||
});
|
||||
|
||||
const handleBack = () => {
|
||||
useEffect(() => {
|
||||
if (models && !viewedRef.current && shouldShowModels) {
|
||||
viewedRef.current = true;
|
||||
capture("catalog_models_viewed", {
|
||||
brand_name: decodedBrandName,
|
||||
catalog: activeCatalog ?? null,
|
||||
count: models.length,
|
||||
});
|
||||
}
|
||||
}, [models, shouldShowModels, decodedBrandName, activeCatalog]);
|
||||
|
||||
// Reset the "models viewed" lock when the active catalog changes so we
|
||||
// capture one event per (brand × catalog) pair.
|
||||
useEffect(() => {
|
||||
viewedRef.current = false;
|
||||
}, []);
|
||||
|
||||
const sortedFiltered = useMemo(() => {
|
||||
if (!models) return [];
|
||||
const q = query.trim().toLocaleLowerCase("tr");
|
||||
const filtered = q
|
||||
? models.filter((m) => {
|
||||
const hay = `${m.model} ${m.engine ?? ""} ${m.year ?? ""} ${
|
||||
m.bodyType ?? ""
|
||||
}`.toLocaleLowerCase("tr");
|
||||
return hay.includes(q);
|
||||
})
|
||||
: models;
|
||||
const sorted = [...filtered];
|
||||
switch (sort) {
|
||||
case "oldest":
|
||||
sorted.sort((a, b) => (Number(a.year ?? 0) || 0) - (Number(b.year ?? 0) || 0));
|
||||
break;
|
||||
case "alphabetical":
|
||||
sorted.sort((a, b) => a.model.localeCompare(b.model, "tr"));
|
||||
break;
|
||||
default:
|
||||
sorted.sort((a, b) => (Number(b.year ?? 0) || 0) - (Number(a.year ?? 0) || 0));
|
||||
}
|
||||
return sorted;
|
||||
}, [models, query, sort]);
|
||||
|
||||
function handleBack() {
|
||||
if (isMultiCatalog && activeCatalog) {
|
||||
// Go back to catalog selector
|
||||
navigate({ to: ".", search: { catalog: undefined } });
|
||||
} else {
|
||||
navigate({ to: "/dashboard/catalog" });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function changeViewMode(next: typeof viewMode) {
|
||||
setViewMode(next);
|
||||
capture("catalog_view_mode_changed", { mode: next, surface: "models" });
|
||||
}
|
||||
|
||||
const crumbs =
|
||||
isMultiCatalog && activeCatalog
|
||||
? [
|
||||
{ label: t("catalog.title"), to: "/dashboard/catalog" },
|
||||
{
|
||||
label: decodedBrandName,
|
||||
to: "/dashboard/catalog/$brandName",
|
||||
search: { catalog: undefined },
|
||||
},
|
||||
{ label: activeCatalogLabel ?? activeCatalog },
|
||||
]
|
||||
: [{ label: t("catalog.title"), to: "/dashboard/catalog" }, { label: decodedBrandName }];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" onClick={handleBack}>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<Link to="/dashboard/catalog" className="hover:underline">
|
||||
{t("catalog.title")}
|
||||
</Link>
|
||||
{" / "}
|
||||
{isMultiCatalog && activeCatalog ? (
|
||||
<>
|
||||
<Link to="." search={{ catalog: undefined }} className="hover:underline">
|
||||
{decodedBrandName}
|
||||
</Link>
|
||||
{" / "}
|
||||
<span className="font-medium text-foreground">{activeCatalogLabel}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="font-medium text-foreground">{decodedBrandName}</span>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-xl font-bold">
|
||||
{isMultiCatalog && activeCatalog ? activeCatalogLabel : decodedBrandName}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<CatalogHeader
|
||||
crumbs={crumbs}
|
||||
title={
|
||||
isMultiCatalog && activeCatalog
|
||||
? (activeCatalogLabel ?? decodedBrandName)
|
||||
: decodedBrandName
|
||||
}
|
||||
onBack={handleBack}
|
||||
actions={
|
||||
shouldShowModels && models && models.length > 0 ? (
|
||||
<ViewModeToggle
|
||||
value={viewMode}
|
||||
onChange={changeViewMode}
|
||||
groupLabelKey="catalog.view.groupLabel"
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{catalogsLoading ? (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{KEYS_4.map((__k) => (
|
||||
<Skeleton key={__k} className="h-24 w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
) : isMultiCatalog && !activeCatalog && catalogs ? (
|
||||
// Sub-catalog selector
|
||||
<CatalogSelector catalogs={catalogs} brandName={brandName} brandLabel={decodedBrandName} />
|
||||
) : modelsLoading ? (
|
||||
<div>
|
||||
<div className="mb-4 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{t("catalog.loadingModels")}
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{KEYS_9.map((__k) => (
|
||||
<Skeleton key={__k} className="h-24 w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{KEYS_9.map((__k) => (
|
||||
<Skeleton key={__k} className="h-24 w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : !models || models.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
@@ -151,56 +204,78 @@ function CatalogModelsPage() {
|
||||
<p className="text-muted-foreground">{t("catalog.noModels")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{/* View toggle */}
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => changeViewMode("grid")}
|
||||
className={cn(
|
||||
"rounded p-1.5",
|
||||
viewMode === "grid" ? "bg-accent" : "text-muted-foreground hover:text-foreground",
|
||||
<div className="space-y-4">
|
||||
{/* Toolbar */}
|
||||
<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">
|
||||
<SearchIcon className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
if (e.target.value.trim()) {
|
||||
capture("catalog_model_search_used", {
|
||||
brand_name: decodedBrandName,
|
||||
length: e.target.value.length,
|
||||
});
|
||||
}
|
||||
}}
|
||||
placeholder={t("catalog.modelSearchPlaceholder")}
|
||||
aria-label={t("catalog.modelSearchPlaceholder")}
|
||||
className="pl-9"
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuery("")}
|
||||
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>
|
||||
)}
|
||||
title="Izgara"
|
||||
>
|
||||
<CardsViewIcon isActive={viewMode === "grid"} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => changeViewMode("tree")}
|
||||
className={cn(
|
||||
"rounded p-1.5",
|
||||
viewMode === "tree" ? "bg-accent" : "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
title="Liste"
|
||||
>
|
||||
<TreeViewIcon isActive={viewMode === "tree"} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => changeViewMode("columns")}
|
||||
className={cn(
|
||||
"rounded p-1.5",
|
||||
viewMode === "columns"
|
||||
? "bg-accent"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
title="Sutun"
|
||||
>
|
||||
<ListViewIcon isActive={viewMode === "columns"} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
{t("catalog.modelCount2", { count: sortedFiltered.length })}
|
||||
</span>
|
||||
<select
|
||||
value={sort}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value as SortMode;
|
||||
setSort(v);
|
||||
capture("catalog_model_sort_changed", { sort: v });
|
||||
}}
|
||||
aria-label={t("catalog.sortLabel")}
|
||||
className="h-9 rounded-md border border-border bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
>
|
||||
<option value="newest">{t("catalog.sort.newest")}</option>
|
||||
<option value="oldest">{t("catalog.sort.oldest")}</option>
|
||||
<option value="alphabetical">{t("catalog.sort.alphabetical")}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{viewMode === "grid" ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{models.map((model) => (
|
||||
<ModelCard key={model.id} model={model} brandName={brandName} />
|
||||
))}
|
||||
{sortedFiltered.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>
|
||||
) : viewMode === "tree" ? (
|
||||
<ModelListTree models={models} brandName={brandName} />
|
||||
<ModelListTree models={sortedFiltered} brandName={brandName} />
|
||||
) : viewMode === "columns" ? (
|
||||
<ModelListColumns models={sortedFiltered} brandName={brandName} />
|
||||
) : (
|
||||
<ModelListColumns models={models} brandName={brandName} />
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{sortedFiltered.map((model) => (
|
||||
<ModelCard
|
||||
key={model.id}
|
||||
model={model}
|
||||
brandName={brandName}
|
||||
brandLabel={decodedBrandName}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -218,52 +293,80 @@ function CatalogSelector({
|
||||
brandLabel: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("catalog.selectCatalog")} — {brandLabel}
|
||||
</p>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{catalogs.map((cat) => (
|
||||
<Link
|
||||
key={cat.serviceName}
|
||||
to="/dashboard/catalog/$brandName"
|
||||
params={{ brandName }}
|
||||
search={{ catalog: cat.serviceName }}
|
||||
className="flex items-center justify-between rounded-xl border border-border bg-card p-5 transition-colors hover:bg-accent hover:border-accent-foreground/20"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-full bg-primary/10">
|
||||
<BookOpen className="size-5 text-primary" />
|
||||
{catalogs.map((cat) => {
|
||||
const Icon = iconForService(cat.serviceName);
|
||||
return (
|
||||
<Link
|
||||
key={cat.serviceName}
|
||||
to="/dashboard/catalog/$brandName"
|
||||
params={{ brandName }}
|
||||
search={{ catalog: cat.serviceName }}
|
||||
onClick={() =>
|
||||
capture("catalog_subcatalog_selected", {
|
||||
brand_name: brandLabel,
|
||||
service: cat.serviceName,
|
||||
})
|
||||
}
|
||||
className="flex items-center justify-between rounded-xl border border-border bg-card p-5 transition-colors hover:border-primary/30 hover:bg-accent/40"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-full bg-primary/10">
|
||||
<Icon className="size-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold">{cat.displayName}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold">{cat.displayName}</p>
|
||||
<p className="text-xs text-muted-foreground">{cat.serviceName}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="size-4 text-muted-foreground" />
|
||||
</Link>
|
||||
))}
|
||||
<ChevronRight className="size-4 text-muted-foreground" />
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelCard({ model, brandName }: { model: CatalogVehicle; brandName: string }) {
|
||||
function ModelCard({
|
||||
model,
|
||||
brandName,
|
||||
brandLabel,
|
||||
}: {
|
||||
model: CatalogVehicle;
|
||||
brandName: string;
|
||||
brandLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
to="/dashboard/catalog/$brandName/$modelId"
|
||||
params={{ brandName, modelId: model.id }}
|
||||
search={{ body: undefined, engine: undefined, gearbox: undefined, mgp: undefined }}
|
||||
className="flex flex-col rounded-lg border border-border bg-card p-4 transition-colors hover:bg-accent hover:border-accent-foreground/20"
|
||||
onClick={() =>
|
||||
capture("catalog_model_clicked", {
|
||||
brand_name: brandLabel,
|
||||
model: model.model,
|
||||
year: model.year,
|
||||
service: model.serviceName,
|
||||
})
|
||||
}
|
||||
preload="intent"
|
||||
className="flex flex-col rounded-lg border border-border bg-card p-4 transition-colors hover:border-primary/30 hover:bg-accent/40"
|
||||
>
|
||||
<p className="font-semibold">{model.model}</p>
|
||||
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-0.5 text-xs text-muted-foreground">
|
||||
{model.year && <span>{model.year}</span>}
|
||||
{model.engine && <span>{model.engine}</span>}
|
||||
{model.bodyType && <span>{model.bodyType}</span>}
|
||||
{model.transmission && <span>{model.transmission}</span>}
|
||||
<p className="font-semibold leading-5">{model.model}</p>
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{model.year && (
|
||||
<Badge variant="secondary" className="tabular-nums">
|
||||
{model.year}
|
||||
</Badge>
|
||||
)}
|
||||
{model.engine && <Badge variant="outline">{model.engine}</Badge>}
|
||||
{model.bodyType && <Badge variant="outline">{model.bodyType}</Badge>}
|
||||
{model.transmission && <Badge variant="outline">{model.transmission}</Badge>}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user