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:
2026-06-01 00:26:23 +03:00
parent 91d636527e
commit 141e90d176

View File

@@ -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>
);