feat(web): /catalog — recently-used brands strip
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled

Adds a horizontal scrollable "Son ziyaret ettiklerin" strip above the main
brand grid. Renders only when the user has actually opened at least one
brand detail page before — silent in cold-start state.

Why localStorage, not a backend endpoint
- This is a behavioural shortcut, not authoritative state. Adding a
  user_recent_brands table for data we don't have yet is premature.
- Keying by `sase-recent-brands-${userId}` mirrors the trial-banner
  scoping pattern; a second user on the same browser doesn't inherit the
  first user's list.
- localStorage failures (private mode, quota) silently degrade — the
  strip just stays hidden, never throws.

How a brand gets added
- Tracked at the destination (`/catalog/:brandName` visit), not on the
  link click. A click that never resolves into a real visit (auth gate,
  slow nav cancel) shouldn't be a "recently used" signal.
- 12 entries stored, 8 surfaced. Headroom for future ranking (e.g.
  weight by frequency × recency) without re-recording history.

Plan-lock awareness
- The strip cross-references the `/catalog/brands` access map, so a
  brand the user opened while on Full and then lost on a downgrade
  shows the same lock chip + amber upgrade route used by the main grid.
- New PostHog event: `catalog_recent_brand_clicked`. Locked recent
  chips reuse `catalog_locked_brand_upgrade_clicked` with
  `surface: "recents"` for funnel distinction.

i18n: `catalog.recentSection` (TR: "Son ziyaret ettiklerin" / EN:
"Recently visited").
This commit is contained in:
2026-06-01 00:36:50 +03:00
parent 4724a71113
commit e5ed6b9f36
6 changed files with 180 additions and 1 deletions

View File

@@ -0,0 +1,88 @@
import { CarBrandLogo } from "@/components/ui/car-brand-logo";
import { useTranslation } from "@/lib/i18n";
import { capture } from "@/lib/posthog";
import type { RecentBrand } from "@/lib/recently-used-brands";
import { Link } from "@tanstack/react-router";
import { Clock, Lock } from "lucide-react";
interface RecentBrandsStripProps {
recents: RecentBrand[];
// Caller passes the canonical access map so each chip knows whether to
// route to the catalog or the subscription upsell. Avoids re-querying.
accessByName: Map<string, boolean>;
}
/**
* Horizontal scrollable strip of up to 8 brands the user has recently
* opened in the catalog. Lives above the main brand grid and renders
* only when there is at least one entry — silent in cold-start state.
*/
export function RecentBrandsStrip({ recents, accessByName }: RecentBrandsStripProps) {
const { t } = useTranslation();
if (recents.length === 0) return null;
return (
<section aria-labelledby="recent-brands-heading" className="space-y-3">
<div className="flex items-center gap-2">
<Clock className="size-3.5 text-muted-foreground" />
<h2
id="recent-brands-heading"
className="text-sm font-semibold uppercase tracking-wider text-muted-foreground"
>
{t("catalog.recentSection")}
</h2>
</div>
<ul className="-mx-1 flex gap-2 overflow-x-auto px-1 pb-1 [scrollbar-width:thin]">
{recents.map((b) => {
const hasAccess = accessByName.get(b.brandName) ?? true;
return (
<li key={b.brandName} className="shrink-0">
{hasAccess ? (
<Link
to="/dashboard/catalog/$brandName"
params={{ brandName: b.brandName }}
search={{ catalog: undefined }}
onClick={() =>
capture("catalog_recent_brand_clicked", { brand_name: b.brandName })
}
className="group flex w-24 flex-col items-center gap-1.5 rounded-xl border border-border bg-card p-2.5 transition-colors hover:border-primary/40 hover:bg-accent/40"
>
<CarBrandLogo brandName={b.brandName} logoUrl={b.logoUrl} size={32} />
<span className="truncate w-full text-center text-[11px] font-medium leading-3">
{b.brandName}
</span>
</Link>
) : (
<Link
to="/dashboard/subscription"
onClick={() =>
capture("catalog_locked_brand_upgrade_clicked", {
brand_name: b.brandName,
surface: "recents",
})
}
className="group flex w-24 flex-col items-center gap-1.5 rounded-xl border border-border/50 bg-muted/30 p-2.5"
title={t("catalog.locked")}
>
<div className="relative">
<CarBrandLogo
brandName={b.brandName}
logoUrl={b.logoUrl}
size={32}
className="grayscale opacity-70"
/>
<div className="absolute -bottom-1 -right-1 flex size-3.5 items-center justify-center rounded-full bg-foreground">
<Lock className="size-2 text-background" />
</div>
</div>
<span className="truncate w-full text-center text-[11px] font-medium leading-3 text-foreground/70">
{b.brandName}
</span>
</Link>
)}
</li>
);
})}
</ul>
</section>
);
}

View File

@@ -0,0 +1,61 @@
// User- and device-scoped "recently-used brands" log for the catalog.
//
// Why localStorage + userId-scope: this is per-device behavioural shortcut
// (last brands you clicked in the catalog), not authoritative cross-device
// state. Backend doesn't track it yet and adding an endpoint would be
// premature — we'd lose the value of the data we don't have. Scoping the
// key by userId keeps a second user on the same browser from inheriting
// the first user's list (mirrors the trial-banner pattern).
const MAX_ITEMS = 12; // store more than we show, room for ranking later
const KEY_PREFIX = "sase-recent-brands";
export interface RecentBrand {
brandName: string;
logoUrl: string | null;
at: number; // epoch ms
}
function storageKey(userId: string | null | undefined): string {
return `${KEY_PREFIX}-${userId ?? "anon"}`;
}
export function readRecentBrands(userId: string | null | undefined, limit = 8): RecentBrand[] {
if (typeof window === "undefined") return [];
try {
const raw = window.localStorage.getItem(storageKey(userId));
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed
.filter(
(it): it is RecentBrand =>
!!it &&
typeof it === "object" &&
typeof it.brandName === "string" &&
(it.logoUrl === null || typeof it.logoUrl === "string") &&
typeof it.at === "number",
)
.slice(0, limit);
} catch {
return [];
}
}
export function recordRecentBrand(
userId: string | null | undefined,
entry: { brandName: string; logoUrl: string | null },
): void {
if (typeof window === "undefined") return;
try {
const existing = readRecentBrands(userId, MAX_ITEMS);
const filtered = existing.filter((it) => it.brandName !== entry.brandName);
const next: RecentBrand[] = [
{ brandName: entry.brandName, logoUrl: entry.logoUrl, at: Date.now() },
...filtered,
].slice(0, MAX_ITEMS);
window.localStorage.setItem(storageKey(userId), JSON.stringify(next));
} catch {
// localStorage blocked (private mode, quota) — silently degrade.
}
}

View File

@@ -149,6 +149,7 @@
"lockedSection": "Brands not in your plan",
"lockedSectionHint": "Upgrade to access these brands.",
"inPlanSection": "Brands in your plan",
"recentSection": "Recently visited",
"goToModels": "Browse models",
"selectBrandHint": "Select a brand from the left",
"columnsBrowseHint": "Pick a brand on the left and its models appear here.",

View File

@@ -149,6 +149,7 @@
"lockedSection": "Plan dışı markalar",
"lockedSectionHint": "Erişmek istediğin markalar için planını yükselt.",
"inPlanSection": "Planındaki markalar",
"recentSection": "Son ziyaret ettiklerin",
"goToModels": "Modellere Git",
"selectBrandHint": "Soldan bir marka seç",
"columnsBrowseHint": "Soldaki listeden bir marka seçince modeller burada görünür.",

View File

@@ -1,10 +1,13 @@
import { CatalogHeader } from "@/components/catalog/catalog-header";
import { RecentBrandsStrip } from "@/components/catalog/recent-brands-strip";
import { ViewModeToggle } from "@/components/catalog/view-mode-toggle";
import { CarBrandLogo } from "@/components/ui/car-brand-logo";
import { api } from "@/lib/api-client";
import { useSession } from "@/lib/auth-client";
import { useTranslation } from "@/lib/i18n";
import { KEYS_10 } from "@/lib/keys";
import { capture } from "@/lib/posthog";
import { readRecentBrands, recordRecentBrand } from "@/lib/recently-used-brands";
import { useViewMode } from "@/lib/use-view-mode";
import { Button, Input, Skeleton, cn } from "@sase/ui";
import { useQuery } from "@tanstack/react-query";
@@ -26,10 +29,14 @@ interface CatalogBrand {
function CatalogBrandsPage() {
const { t } = useTranslation();
const { data: session } = useSession();
const userId = session?.user?.id ?? null;
const [viewMode, setViewMode] = useViewMode("brandViewMode");
const [query, setQuery] = useState("");
const [hideLocked, setHideLocked] = useState(false);
const viewedRef = useRef(false);
// Read recents once per user — they don't change inside this page session.
const recents = useMemo(() => readRecentBrands(userId, 8), [userId]);
const { data: brands, isLoading } = useQuery({
queryKey: ["catalog-brands"],
@@ -37,6 +44,12 @@ function CatalogBrandsPage() {
staleTime: 5 * 60 * 1000,
});
const accessByName = useMemo(() => {
const m = new Map<string, boolean>();
for (const b of brands ?? []) m.set(b.brandName, b.hasAccess);
return m;
}, [brands]);
useEffect(() => {
if (brands && !viewedRef.current) {
viewedRef.current = true;
@@ -87,6 +100,13 @@ function CatalogBrandsPage() {
}
/>
{/* Recently used — shown only when the user has actually visited some
brand catalog before, and the brands list has loaded so we know
access state for each chip. Hidden in cold-start state. */}
{recents.length > 0 && brands && (
<RecentBrandsStrip recents={recents} accessByName={accessByName} />
)}
{/* 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">

View File

@@ -3,9 +3,11 @@ import { ModelListColumns } from "@/components/catalog/model-list-columns";
import { ModelListTree } from "@/components/catalog/model-list-tree";
import { ViewModeToggle } from "@/components/catalog/view-mode-toggle";
import { api } from "@/lib/api-client";
import { useSession } from "@/lib/auth-client";
import { useTranslation } from "@/lib/i18n";
import { KEYS_4, KEYS_9 } from "@/lib/keys";
import { capture } from "@/lib/posthog";
import { recordRecentBrand } from "@/lib/recently-used-brands";
import { useViewMode } from "@/lib/use-view-mode";
import { Badge, Input, Skeleton } from "@sase/ui";
import { useQuery } from "@tanstack/react-query";
@@ -66,6 +68,8 @@ function CatalogModelsPage() {
const { catalog: activeCatalog } = Route.useSearch();
const { t } = useTranslation();
const navigate = useNavigate();
const { data: session } = useSession();
const userId = session?.user?.id ?? null;
const [viewMode, setViewMode] = useViewMode("modelViewMode");
const [query, setQuery] = useState("");
const [sort, setSort] = useState<SortMode>("newest");
@@ -103,8 +107,12 @@ function CatalogModelsPage() {
catalog: activeCatalog ?? null,
count: models.length,
});
// Visit-tracked here rather than at the link click — a click that
// never resolves into a real visit (auth gate, slow nav cancel) is
// not a "recently used" signal worth surfacing.
recordRecentBrand(userId, { brandName: decodedBrandName, logoUrl: null });
}
}, [models, shouldShowModels, decodedBrandName, activeCatalog]);
}, [models, shouldShowModels, decodedBrandName, activeCatalog, userId]);
// Reset the "models viewed" lock when the active catalog changes so we
// capture one event per (brand × catalog) pair.