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