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>
|
||||
);
|
||||
}
|
||||
61
apps/web/src/lib/recently-used-brands.ts
Normal file
61
apps/web/src/lib/recently-used-brands.ts
Normal 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.
|
||||
}
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user