feat(web): /catalog — recently-used brands strip
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
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:
88
apps/web/src/components/catalog/recent-brands-strip.tsx
Normal file
88
apps/web/src/components/catalog/recent-brands-strip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user