feat(web): /catalog main page — search, in-plan-first, hide-locked, PostHog, primitives

Migrates the brand index off its inline view toggle to the shared
ViewModeToggle/CatalogHeader primitives from 10A, then adds the toolbox the
audit called out:

- Brand search: client-side, Turkish-locale fold over brandName; visible
  X-clear button; the no-match panel exposes the original query
- In-plan brands rendered above locked ones with two sub-section headers
  ("Planındaki markalar" / "Plan dışı markalar"); locked section gets the
  "upgrade to access" microcopy
- Hide-locked toggle with aria-pressed (also persisted as a click event so
  we can size the cohort that prefers a clean view)
- Searching collapses the section split into one ranked list — matches
  the "show me what I typed" mental model
- Locked brand cards/rows are now real `<Link to="/dashboard/subscription">`
  buttons so the upgrade target is one tap; firing
  `catalog_locked_brand_upgrade_clicked` with brand_name + surface
- `hover:-translate-y-0.5` (jittery on grid scroll) → `hover:scale-[1.02]`
- ARIA tablist replaced by the role="group" + aria-pressed pattern from
  the shared ViewModeToggle — keyboard story actually works now
- Drops manual `encodeURIComponent(brand.brandName)` in `params`; TanStack
  Router already encodes — caught the audit's double-encoding risk

New PostHog events: catalog_brands_viewed, catalog_brand_search_used,
catalog_hide_locked_toggled, catalog_view_mode_changed,
catalog_brand_clicked, catalog_locked_brand_upgrade_clicked.
This commit is contained in:
2026-06-01 00:24:45 +03:00
parent 05de73dd41
commit 91d636527e

View File

@@ -1,20 +1,17 @@
import {
CardsViewIcon,
ListViewIcon,
TreeViewIcon,
} from "@/components/categories/view-toggle-icons";
import { CatalogHeader } from "@/components/catalog/catalog-header";
import { ViewModeToggle } from "@/components/catalog/view-mode-toggle";
import { CarBrandLogo } from "@/components/ui/car-brand-logo";
import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n";
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
import { Skeleton, cn } from "@sase/ui";
import { Button } from "@sase/ui";
import { KEYS_10 } from "@/lib/keys";
import { capture } from "@/lib/posthog";
import { useViewMode } from "@/lib/use-view-mode";
import { Button, Input, Skeleton, cn } from "@sase/ui";
import { useQuery } from "@tanstack/react-query";
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
import { ChevronRight, Library, Lock } from "lucide-react";
import { useState } from "react";
import { ChevronRight, Library, Lock, Search, X } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { KEYS_10 } from "@/lib/keys";
export const Route = createFileRoute("/dashboard/catalog/")({
component: CatalogBrandsPage,
});
@@ -29,93 +26,226 @@ interface CatalogBrand {
function CatalogBrandsPage() {
const { t } = useTranslation();
const [viewMode, setViewMode] = useState<"grid" | "tree" | "columns">(
() => getUserSettings().brandViewMode ?? "grid",
);
const changeViewMode = (mode: "grid" | "tree" | "columns") => {
setViewMode(mode);
setUserSetting("brandViewMode", mode);
};
const [viewMode, setViewMode] = useViewMode("brandViewMode");
const [query, setQuery] = useState("");
const [hideLocked, setHideLocked] = useState(false);
const viewedRef = useRef(false);
const { data: brands, isLoading } = useQuery({
queryKey: ["catalog-brands"],
queryFn: () => api.get<CatalogBrand[]>("/catalog/brands"),
staleTime: 5 * 60 * 1000,
});
useEffect(() => {
if (brands && !viewedRef.current) {
viewedRef.current = true;
const inPlan = brands.filter((b) => b.hasAccess).length;
capture("catalog_brands_viewed", {
total: brands.length,
in_plan: inPlan,
locked: brands.length - inPlan,
});
}
}, [brands]);
// Filter + sort: in-plan brands first (alphabetical), locked second
// (alphabetical). Search wipes the section split; matches all in one list.
const { inPlan, locked, hasQuery, filteredTotal } = useMemo(() => {
const empty = {
inPlan: [] as CatalogBrand[],
locked: [] as CatalogBrand[],
hasQuery: false,
filteredTotal: 0,
};
if (!brands) return empty;
const q = query.trim().toLocaleLowerCase("tr");
const matches = q
? brands.filter((b) => b.brandName.toLocaleLowerCase("tr").includes(q))
: brands;
const ip = matches.filter((b) => b.hasAccess).sort(byBrandName);
const lk = matches.filter((b) => !b.hasAccess).sort(byBrandName);
return { inPlan: ip, locked: lk, hasQuery: q.length > 0, filteredTotal: matches.length };
}, [brands, query]);
function changeViewMode(next: typeof viewMode) {
setViewMode(next);
capture("catalog_view_mode_changed", { mode: next, surface: "brands" });
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">{t("catalog.title")}</h1>
<p className="text-sm text-muted-foreground">{t("catalog.brands")}</p>
<CatalogHeader
title={t("catalog.title")}
subtitle={t("catalog.subtitle")}
actions={
<ViewModeToggle
value={viewMode}
onChange={changeViewMode}
groupLabelKey="catalog.view.groupLabel"
/>
}
/>
{/* Toolbar — search + hide-locked toggle */}
{!isLoading && brands && brands.length > 0 && (
<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={query}
onChange={(e) => {
setQuery(e.target.value);
if (e.target.value.trim()) {
capture("catalog_brand_search_used", { length: e.target.value.length });
}
}}
placeholder={t("catalog.brandSearchPlaceholder")}
aria-label={t("catalog.brandSearchPlaceholder")}
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>
)}
</div>
<button
type="button"
onClick={() => {
const next = !hideLocked;
setHideLocked(next);
capture("catalog_hide_locked_toggled", { hidden: next });
}}
aria-pressed={hideLocked}
className="inline-flex items-center gap-2 self-start rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
>
<Lock className="size-3.5" />
{hideLocked ? t("catalog.showLocked") : t("catalog.hideLocked")}
</button>
</div>
<div
role="tablist"
aria-label="Görünüm modu"
className="inline-flex items-center gap-0.5 rounded-lg border border-border bg-muted/40 p-0.5"
>
{[
{ mode: "grid" as const, Icon: CardsViewIcon, label: "Izgara" },
{ mode: "tree" as const, Icon: TreeViewIcon, label: "Liste" },
{ mode: "columns" as const, Icon: ListViewIcon, label: "Sütun" },
].map(({ mode, Icon, label }) => (
<button
key={mode}
type="button"
role="tab"
aria-selected={viewMode === mode}
onClick={() => changeViewMode(mode)}
className={cn(
"inline-flex size-7 items-center justify-center rounded-md transition-all duration-200",
viewMode === mode
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
title={label}
>
<Icon isActive={viewMode === mode} className="size-3.5" />
</button>
))}
</div>
</div>
)}
{isLoading ? (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{KEYS_10.map((__k) => (
<Skeleton key={__k} className="h-28 w-full rounded-xl" />
))}
</div>
<BrandGridSkeleton />
) : !brands || brands.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<Library className="mb-4 size-12 text-muted-foreground/40" />
<p className="text-muted-foreground">{t("catalog.noBrands")}</p>
</div>
) : viewMode === "grid" ? (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{brands.map((brand) => (
<BrandCard key={brand.brandName} brand={brand} />
))}
) : filteredTotal === 0 ? (
<div className="rounded-2xl border border-border bg-muted/10 p-8 text-center">
<p className="text-sm font-medium">{t("catalog.brandSearchNoMatch", { query })}</p>
</div>
) : viewMode === "tree" ? (
<BrandListTree brands={brands} />
<BrandListTree inPlan={inPlan} locked={hideLocked ? [] : locked} hasQuery={hasQuery} />
) : viewMode === "columns" ? (
<BrandListColumns inPlan={inPlan} locked={hideLocked ? [] : locked} />
) : (
<BrandListColumns brands={brands} />
<BrandGrid inPlan={inPlan} locked={hideLocked ? [] : locked} hasQuery={hasQuery} />
)}
</div>
);
}
/* ── Grid card (existing) ── */
function byBrandName(a: CatalogBrand, b: CatalogBrand) {
return a.brandName.localeCompare(b.brandName, "tr");
}
function BrandGridSkeleton() {
return (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{KEYS_10.map((__k) => (
<Skeleton key={__k} className="h-28 w-full rounded-xl" />
))}
</div>
);
}
/* ── Grid ── */
function BrandGrid({
inPlan,
locked,
hasQuery,
}: {
inPlan: CatalogBrand[];
locked: CatalogBrand[];
hasQuery: boolean;
}) {
const { t } = useTranslation();
// When the user searched, drop the section headings — a single visual list
// matches the user's mental model ("show me matches").
if (hasQuery) {
const all = [...inPlan, ...locked];
return (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{all.map((brand) => (
<BrandCard key={brand.brandName} brand={brand} />
))}
</div>
);
}
return (
<div className="space-y-8">
{inPlan.length > 0 && (
<section>
<SectionHeader label={t("catalog.inPlanSection")} count={inPlan.length} />
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{inPlan.map((brand) => (
<BrandCard key={brand.brandName} brand={brand} />
))}
</div>
</section>
)}
{locked.length > 0 && (
<section>
<SectionHeader
label={t("catalog.lockedSection")}
count={locked.length}
hint={t("catalog.lockedSectionHint")}
/>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{locked.map((brand) => (
<BrandCard key={brand.brandName} brand={brand} />
))}
</div>
</section>
)}
</div>
);
}
function SectionHeader({ label, count, hint }: { label: string; count: number; hint?: string }) {
return (
<div className="mb-3 flex flex-wrap items-baseline gap-3">
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
{label}
</h2>
<span className="text-xs tabular-nums text-muted-foreground/70">({count})</span>
{hint && <p className="text-xs text-muted-foreground/80">{hint}</p>}
</div>
);
}
function BrandCard({ brand }: { brand: CatalogBrand }) {
const { t } = useTranslation();
if (!brand.hasAccess) {
return (
<div className="group relative flex flex-col items-center justify-center overflow-hidden rounded-xl border border-border/50 bg-muted/30 p-4 text-center select-none">
{/* Diagonal stripe overlay for locked feel */}
<Link
to="/dashboard/subscription"
onClick={() =>
capture("catalog_locked_brand_upgrade_clicked", { brand_name: brand.brandName })
}
className="group relative flex flex-col items-center justify-center overflow-hidden rounded-xl border border-border/50 bg-muted/30 p-4 text-center transition-colors hover:border-primary/30 hover:bg-muted/50"
>
<div
className="pointer-events-none absolute inset-0 opacity-[0.06]"
style={{
@@ -131,7 +261,7 @@ function BrandCard({ brand }: { brand: CatalogBrand }) {
size={40}
className="grayscale opacity-70"
/>
<div className="absolute -right-1 -bottom-1 flex size-4 items-center justify-center rounded-full bg-foreground">
<div className="absolute -bottom-1 -right-1 flex size-4 items-center justify-center rounded-full bg-foreground">
<Lock className="size-2.5 text-background" />
</div>
</div>
@@ -139,23 +269,26 @@ function BrandCard({ brand }: { brand: CatalogBrand }) {
<p className="relative mt-1 text-[11px] uppercase tracking-wider text-muted-foreground/70">
{t("catalog.locked")}
</p>
<Link
to="/dashboard/subscription"
className="relative mt-2 inline-flex items-center gap-1 rounded-full text-xs font-medium text-brand transition-colors hover:text-brand/80"
>
<span className="relative mt-2 inline-flex items-center gap-1 text-xs font-medium text-brand transition-colors group-hover:text-brand/80">
{t("catalog.upgradeCta")}
<ChevronRight className="size-3" />
</Link>
</div>
</span>
</Link>
);
}
return (
<Link
to="/dashboard/catalog/$brandName"
params={{ brandName: encodeURIComponent(brand.brandName) }}
params={{ brandName: brand.brandName }}
search={{ catalog: undefined }}
className="group flex flex-col items-center justify-center rounded-xl border border-border bg-card p-4 text-center transition-all duration-200 hover:-translate-y-0.5 hover:border-foreground/20 hover:shadow-[var(--shadow-md)]"
onClick={() =>
capture("catalog_brand_clicked", {
brand_name: brand.brandName,
service_names: brand.serviceNames,
})
}
className="group flex flex-col items-center justify-center rounded-xl border border-border bg-card p-4 text-center transition-all duration-200 hover:border-primary/30 hover:bg-accent/40 hover:scale-[1.02]"
>
<CarBrandLogo
brandName={brand.brandName}
@@ -170,62 +303,111 @@ function BrandCard({ brand }: { brand: CatalogBrand }) {
/* ── Tree (flat list) ── */
function BrandListTree({ brands }: { brands: CatalogBrand[] }) {
function BrandListTree({
inPlan,
locked,
hasQuery,
}: {
inPlan: CatalogBrand[];
locked: CatalogBrand[];
hasQuery: boolean;
}) {
const { t } = useTranslation();
if (hasQuery) {
return <TreeBlock brands={[...inPlan, ...locked]} />;
}
return (
<div className="space-y-6">
{inPlan.length > 0 && (
<section>
<SectionHeader label={t("catalog.inPlanSection")} count={inPlan.length} />
<TreeBlock brands={inPlan} />
</section>
)}
{locked.length > 0 && (
<section>
<SectionHeader
label={t("catalog.lockedSection")}
count={locked.length}
hint={t("catalog.lockedSectionHint")}
/>
<TreeBlock brands={locked} />
</section>
)}
</div>
);
}
function TreeBlock({ brands }: { brands: CatalogBrand[] }) {
return (
<div className="divide-y rounded-lg border">
{brands.map((brand) => {
if (!brand.hasAccess) {
return (
<div key={brand.brandName} className="flex items-center gap-3 px-4 py-3 opacity-50">
<CarBrandLogo brandName={brand.brandName} logoUrl={brand.logoUrl} size={24} />
<span className="flex-1 truncate text-sm font-medium">{brand.brandName}</span>
<Lock className="size-3.5 shrink-0 text-muted-foreground" />
</div>
);
}
return (
{brands.map((brand) =>
brand.hasAccess ? (
<Link
key={brand.brandName}
to="/dashboard/catalog/$brandName"
params={{ brandName: encodeURIComponent(brand.brandName) }}
params={{ brandName: brand.brandName }}
search={{ catalog: undefined }}
onClick={() =>
capture("catalog_brand_clicked", {
brand_name: brand.brandName,
service_names: brand.serviceNames,
})
}
className="flex items-center gap-3 px-4 py-3 transition-colors hover:bg-accent"
>
<CarBrandLogo brandName={brand.brandName} logoUrl={brand.logoUrl} size={24} />
<span className="flex-1 truncate text-sm font-medium">{brand.brandName}</span>
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
</Link>
);
})}
) : (
<Link
key={brand.brandName}
to="/dashboard/subscription"
onClick={() =>
capture("catalog_locked_brand_upgrade_clicked", { brand_name: brand.brandName })
}
className="flex items-center gap-3 px-4 py-3 opacity-70 transition-opacity hover:opacity-100"
>
<CarBrandLogo brandName={brand.brandName} logoUrl={brand.logoUrl} size={24} />
<span className="flex-1 truncate text-sm font-medium">{brand.brandName}</span>
<Lock className="size-3.5 shrink-0 text-muted-foreground" />
</Link>
),
)}
</div>
);
}
/* ── Columns (left: brand list, right: detail + CTA) ── */
/* ── Columns ── */
function BrandListColumns({ brands }: { brands: CatalogBrand[] }) {
function BrandListColumns({
inPlan,
locked,
}: {
inPlan: CatalogBrand[];
locked: CatalogBrand[];
}) {
const { t } = useTranslation();
const [selectedName, setSelectedName] = useState<string | null>(null);
const ordered = [...inPlan, ...locked];
const [selectedName, setSelectedName] = useState<string | null>(
() => ordered[0]?.brandName ?? null,
);
const navigate = useNavigate();
const selected = brands.find((b) => b.brandName === selectedName) ?? null;
const selected = ordered.find((b) => b.brandName === selectedName) ?? null;
return (
<div className="flex border rounded-lg overflow-hidden" style={{ minHeight: 320 }}>
{/* Left panel */}
<div className="w-[240px] shrink-0 border-r overflow-y-auto" style={{ maxHeight: 480 }}>
{brands.map((brand) => (
<div className="flex min-h-80 max-h-[480px] overflow-hidden rounded-lg border">
<div className="w-[240px] shrink-0 overflow-y-auto border-r">
{ordered.map((brand) => (
<button
key={brand.brandName}
type="button"
onClick={() => setSelectedName(brand.brandName)}
disabled={!brand.hasAccess}
className={cn(
"flex w-full items-center gap-2 px-3 py-2.5 text-left text-sm transition-colors",
brand.hasAccess ? "hover:bg-accent" : "opacity-50 cursor-not-allowed",
"focus-visible:outline-none focus-visible:bg-accent",
brand.hasAccess ? "hover:bg-accent" : "opacity-60 hover:opacity-100",
selectedName === brand.brandName && "bg-accent font-medium",
)}
>
@@ -239,36 +421,49 @@ function BrandListColumns({ brands }: { brands: CatalogBrand[] }) {
</button>
))}
</div>
{/* Right panel */}
<div className="flex-1 flex flex-col items-center justify-center p-6 text-center">
<div className="flex flex-1 flex-col items-center justify-center p-6 text-center">
{selected ? (
<div className="space-y-4">
<CarBrandLogo brandName={selected.brandName} logoUrl={selected.logoUrl} size={56} />
<p className="text-lg font-semibold">{selected.brandName}</p>
{selected.hasAccess ? (
<Button
onClick={() =>
onClick={() => {
capture("catalog_brand_clicked", {
brand_name: selected.brandName,
service_names: selected.serviceNames,
surface: "columns",
});
navigate({
to: "/dashboard/catalog/$brandName",
params: { brandName: encodeURIComponent(selected.brandName) },
params: { brandName: selected.brandName },
search: { catalog: undefined },
})
}
});
}}
>
Modellere Git
{t("catalog.goToModels")}
<ChevronRight className="ml-1 size-4" />
</Button>
) : (
<div className="space-y-2">
<p className="text-sm text-muted-foreground">{t("catalog.locked")}</p>
<Button variant="outline" asChild>
<Button
variant="outline"
asChild
onClick={() =>
capture("catalog_locked_brand_upgrade_clicked", {
brand_name: selected.brandName,
surface: "columns",
})
}
>
<Link to="/dashboard/subscription">{t("catalog.upgradeCta")}</Link>
</Button>
</div>
)}
</div>
) : (
<p className="text-sm text-muted-foreground">Soldan bir marka seçin</p>
<p className="text-sm text-muted-foreground">{t("catalog.columnsBrowseHint")}</p>
)}
</div>
</div>