refactor(web): brand selector controlled state + search; trial banner urgency tiers + progress

BrandSelector
- Now fully controlled — drops the internal `selected` state that could drift
  from the prop source of truth
- Full-plan auto-select effect compares against the live selection and only
  fires when out of sync, removing the infinite-loop risk if parents passed an
  unstable onSelectionChange (now also ref-stashed)
- Search input appears when there are more than 12 brands; Turkish-locale
  case-folding so "Volkswagen"/"volkswagen" both match
- No-match state for empty search results

TrialUrgencyBanner
- Three urgency tiers (soft 7d, urgent ≤3d, critical ≤1d) — colors and
  tighter copy escalate as the trial winds down
- Adds an elapsed-time progress bar so "3 days left" reads as 90% used at a
  glance (number alone undersells urgency at the tail)
- LS dismissal key now userId-scoped (mirrors subscription page)
- Banner emits `tier` alongside days for funnel segmentation
This commit is contained in:
2026-05-31 23:10:08 +03:00
parent 35871403c4
commit 1ea53920b0
4 changed files with 244 additions and 99 deletions

View File

@@ -6,8 +6,8 @@ import { Badge } from "@sase/ui";
import { Skeleton } from "@sase/ui";
import { cn } from "@sase/ui";
import { useQuery } from "@tanstack/react-query";
import { Check } from "lucide-react";
import { useEffect, useState } from "react";
import { Check, Search, X } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
interface Brand {
id: string;
@@ -30,39 +30,54 @@ export function BrandSelector({
isFullPlan = false,
}: BrandSelectorProps) {
const { t } = useTranslation();
const [selected, setSelected] = useState<string[]>(selectedBrandIds);
const [query, setQuery] = useState("");
const { data: brands, isLoading } = useQuery({
queryKey: ["brands"],
queryFn: () => api.get<Brand[]>("/brands"),
});
// Stash the callback so the full-plan auto-select effect doesn't refire on
// every parent render — the parent may pass a fresh function each time.
const onSelectionChangeRef = useRef(onSelectionChange);
useEffect(() => {
setSelected(selectedBrandIds);
}, [selectedBrandIds]);
onSelectionChangeRef.current = onSelectionChange;
}, [onSelectionChange]);
// For the full plan, mirror every brand into the parent's selection exactly
// once per `brands` payload (not per render). Bail if it's already aligned.
useEffect(() => {
if (isFullPlan && brands) {
const allIds = brands.map((b) => b.id);
setSelected(allIds);
onSelectionChange(allIds);
if (!isFullPlan || !brands) return;
const allIds = brands.map((b) => b.id);
if (
allIds.length === selectedBrandIds.length &&
allIds.every((id) => selectedBrandIds.includes(id))
) {
return;
}
}, [isFullPlan, brands, onSelectionChange]);
onSelectionChangeRef.current(allIds);
}, [isFullPlan, brands, selectedBrandIds]);
function toggleBrand(brandId: string) {
if (isFullPlan) return;
const isCurrentlySelected = selectedBrandIds.includes(brandId);
let next: string[];
if (selected.includes(brandId)) {
next = selected.filter((id) => id !== brandId);
if (isCurrentlySelected) {
next = selectedBrandIds.filter((id) => id !== brandId);
} else {
if (selected.length >= maxBrands) return;
next = [...selected, brandId];
if (selectedBrandIds.length >= maxBrands) return;
next = [...selectedBrandIds, brandId];
}
setSelected(next);
onSelectionChange(next);
}
const filteredBrands = useMemo(() => {
if (!brands) return [];
const q = query.trim().toLocaleLowerCase("tr");
if (!q) return brands;
return brands.filter((b) => b.name.toLocaleLowerCase("tr").includes(q));
}, [brands, query]);
if (isLoading) {
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
@@ -77,59 +92,92 @@ export function BrandSelector({
return <p className="text-sm text-muted-foreground">{t("common.noData")}</p>;
}
const isMaxReached = !isFullPlan && selected.length >= maxBrands;
const isMaxReached = !isFullPlan && selectedBrandIds.length >= maxBrands;
const showSearch = !isFullPlan && brands.length > 12;
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-sm font-medium">{t("subscription.selectBrands")}</p>
<Badge variant={isFullPlan ? "default" : "secondary"}>
<Badge variant={isFullPlan ? "default" : "secondary"} className="tabular-nums">
{isFullPlan
? t("subscription.allBrandsSelected")
: `${selected.length}/${maxBrands} ${t("subscription.brandsSelected")}`}
: `${selectedBrandIds.length}/${maxBrands} ${t("subscription.brandsSelected")}`}
</Badge>
</div>
{isMaxReached && (
<p className="text-xs text-amber-600">{t("subscription.maxBrandsReached")}</p>
{showSearch && (
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("subscription.brandSearchPlaceholder")}
className="w-full rounded-lg border border-border bg-background py-2 pl-9 pr-9 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background"
aria-label={t("subscription.brandSearchPlaceholder")}
/>
{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="h-3.5 w-3.5" />
</button>
)}
</div>
)}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
{brands.map((brand) => {
const isSelected = selected.includes(brand.id) || isFullPlan;
const isDisabled = !isSelected && isMaxReached && !isFullPlan;
{isMaxReached && (
<p className="text-xs text-amber-600 dark:text-amber-400">
{t("subscription.maxBrandsReached")}
</p>
)}
return (
<Card
key={brand.id}
className={cn(
"cursor-pointer transition-all hover:shadow-md",
isSelected && "border-primary ring-2 ring-primary/20",
isDisabled && "cursor-not-allowed opacity-50",
isFullPlan && "cursor-default",
)}
onClick={() => !isDisabled && toggleBrand(brand.id)}
>
<CardContent className="flex flex-col items-center justify-center p-4">
<div className="relative">
<CarBrandLogo
brandName={brand.name}
logoUrl={brand.logoUrl}
size={48}
className="mb-2"
/>
{isSelected && (
<div className="absolute -right-1 -top-1 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-primary-foreground">
<Check className="h-3 w-3" />
</div>
)}
</div>
<span className="text-center text-sm font-medium">{brand.name}</span>
</CardContent>
</Card>
);
})}
</div>
{filteredBrands.length === 0 ? (
<p className="rounded-lg border border-dashed border-border p-6 text-center text-sm text-muted-foreground">
{t("subscription.brandSearchNoMatch", { query })}
</p>
) : (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
{filteredBrands.map((brand) => {
const isSelected = selectedBrandIds.includes(brand.id) || isFullPlan;
const isDisabled = !isSelected && isMaxReached && !isFullPlan;
return (
<Card
key={brand.id}
className={cn(
"cursor-pointer transition-all hover:border-primary/40",
isSelected && "border-primary ring-2 ring-primary/20",
isDisabled && "cursor-not-allowed opacity-50",
isFullPlan && "cursor-default",
)}
onClick={() => !isDisabled && toggleBrand(brand.id)}
>
<CardContent className="flex flex-col items-center justify-center p-4">
<div className="relative">
<CarBrandLogo
brandName={brand.name}
logoUrl={brand.logoUrl}
size={48}
className="mb-2"
/>
{isSelected && (
<div className="absolute -right-1 -top-1 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-primary-foreground">
<Check className="h-3 w-3" />
</div>
)}
</div>
<span className="text-center text-sm font-medium">{brand.name}</span>
</CardContent>
</Card>
);
})}
</div>
)}
</div>
);
}

View File

@@ -1,4 +1,5 @@
import { api } from "@/lib/api-client";
import { useSession } from "@/lib/auth-client";
import { useTranslation } from "@/lib/i18n";
import { capture } from "@/lib/posthog";
import { Button } from "@sase/ui";
@@ -15,14 +16,77 @@ interface Subscription {
endDate?: string;
}
const DISMISS_STORAGE_PREFIX = "trialBannerDismissed";
// Show the banner inside the final week. Two tighter tiers (3, 1 days) push the
// visual urgency without spamming earlier.
const VISIBLE_AT_DAYS = 7;
const URGENT_AT_DAYS = 3;
const CRITICAL_AT_DAYS = 1;
const TRIAL_TOTAL_DAYS = 30;
function getDismissKey(endDate: string): string {
return `${DISMISS_STORAGE_PREFIX}-${endDate}`;
type Tier = "soft" | "urgent" | "critical";
function tierFor(days: number): Tier {
if (days <= CRITICAL_AT_DAYS) return "critical";
if (days <= URGENT_AT_DAYS) return "urgent";
return "soft";
}
// Per-tier classes. Kept inline (not via tokens) because they map to brand
// urgency colors that don't yet exist in @theme — Phase-5 token sweep can
// move these to bg-warning / bg-destructive.
const tierClasses: Record<
Tier,
{
bar: string;
cta: string;
icon: string;
title: string;
body: string;
close: string;
track: string;
fill: string;
}
> = {
soft: {
bar: "border-amber-200 bg-amber-50 dark:border-amber-900 dark:bg-amber-950/40",
cta: "bg-amber-600 text-white hover:bg-amber-700 dark:bg-amber-500 dark:text-amber-950 dark:hover:bg-amber-400",
icon: "text-amber-600 dark:text-amber-400",
title: "text-amber-900 dark:text-amber-100",
body: "text-amber-700 dark:text-amber-300",
close: "text-amber-600 hover:bg-amber-100 dark:text-amber-400 dark:hover:bg-amber-900",
track: "bg-amber-200/60 dark:bg-amber-800/40",
fill: "bg-amber-500 dark:bg-amber-400",
},
urgent: {
bar: "border-orange-300 bg-orange-50 dark:border-orange-800 dark:bg-orange-950/40",
cta: "bg-orange-600 text-white hover:bg-orange-700 dark:bg-orange-500 dark:text-orange-950 dark:hover:bg-orange-400",
icon: "text-orange-600 dark:text-orange-400",
title: "text-orange-900 dark:text-orange-100",
body: "text-orange-700 dark:text-orange-300",
close: "text-orange-600 hover:bg-orange-100 dark:text-orange-400 dark:hover:bg-orange-900",
track: "bg-orange-200/60 dark:bg-orange-800/40",
fill: "bg-orange-500 dark:bg-orange-400",
},
critical: {
bar: "border-destructive/40 bg-destructive/5",
cta: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
icon: "text-destructive",
title: "text-foreground",
body: "text-muted-foreground",
close: "text-destructive/80 hover:bg-destructive/10",
track: "bg-destructive/15",
fill: "bg-destructive",
},
};
function trialBannerKey(userId: string | null | undefined, endDate: string): string {
return `trialBannerDismissed-${userId ?? "anon"}-${endDate}`;
}
export function TrialUrgencyBanner() {
const { t } = useTranslation();
const { data: session } = useSession();
const userId = session?.user?.id ?? null;
const [dismissed, setDismissed] = useState(false);
const [mounted, setMounted] = useState(false);
const viewedRef = useRef(false);
@@ -41,8 +105,8 @@ export function TrialUrgencyBanner() {
subscription?.status === "trial" && endDate
? Math.max(0, Math.ceil((new Date(endDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24)))
: null;
const visible = !isLoading && days !== null && days <= 7;
const dismissKey = endDate ? getDismissKey(endDate) : null;
const visible = !isLoading && days !== null && days <= VISIBLE_AT_DAYS;
const dismissKey = endDate ? trialBannerKey(userId, endDate) : null;
useEffect(() => {
setMounted(true);
@@ -56,30 +120,35 @@ export function TrialUrgencyBanner() {
}
}, [mounted, dismissKey]);
const tier: Tier | null = days !== null ? tierFor(days) : null;
useEffect(() => {
if (!visible || dismissed || viewedRef.current || days === null) return;
if (!visible || dismissed || viewedRef.current || days === null || !tier) return;
viewedRef.current = true;
capture("trial_urgency_banner_viewed", {
days_remaining: days,
tier,
plan_name: subscription?.plan?.name,
billing_period: subscription?.billingPeriod,
});
}, [visible, dismissed, days, subscription?.plan?.name, subscription?.billingPeriod]);
}, [visible, dismissed, days, tier, subscription?.plan?.name, subscription?.billingPeriod]);
const handleCTAClick = useCallback(() => {
if (days === null) return;
capture("trial_urgency_banner_cta_clicked", {
days_remaining: days,
tier,
plan_name: subscription?.plan?.name,
billing_period: subscription?.billingPeriod,
});
}, [days, subscription?.plan?.name, subscription?.billingPeriod]);
}, [days, tier, subscription?.plan?.name, subscription?.billingPeriod]);
if (!visible || dismissed || days === null || !dismissKey) return null;
if (!visible || dismissed || days === null || !dismissKey || !tier) return null;
const handleDismiss = () => {
capture("trial_urgency_banner_dismissed", {
days_remaining: days,
tier,
plan_name: subscription?.plan?.name,
billing_period: subscription?.billingPeriod,
});
@@ -87,41 +156,59 @@ export function TrialUrgencyBanner() {
setDismissed(true);
};
return (
<div
role="alert"
className="flex items-center gap-3 border-b border-amber-200 bg-amber-50 px-4 py-3 dark:border-amber-800 dark:bg-amber-950 sm:gap-4 sm:px-6"
>
<AlertTriangle className="size-5 shrink-0 text-amber-600 dark:text-amber-400" />
// Progress bar reflects elapsed share of the trial — a number alone undersells
// urgency at e.g. day 27/30, while the bar reads visually as "nearly done".
const elapsed = Math.max(0, Math.min(TRIAL_TOTAL_DAYS, TRIAL_TOTAL_DAYS - days));
const elapsedPct = Math.round((elapsed / TRIAL_TOTAL_DAYS) * 100);
const titleKey =
tier === "critical"
? "subscription.trialBanner.titleCritical"
: tier === "urgent"
? "subscription.trialBanner.titleUrgent"
: "subscription.trialBanner.title";
const c = tierClasses[tier];
<div className="flex flex-1 flex-col gap-1 sm:flex-row sm:items-center sm:gap-4">
<div>
<p className="text-sm font-semibold text-amber-900 dark:text-amber-100">
{t("subscription.trialBanner.title")}
</p>
<p className="text-xs text-amber-700 dark:text-amber-300">
{t("subscription.trialBanner.description", { days })}
</p>
return (
<div role="alert" className={`flex flex-col gap-2 border-b px-4 py-3 sm:px-6 ${c.bar}`}>
<div className="flex items-start gap-3 sm:items-center">
<AlertTriangle className={`mt-0.5 size-5 shrink-0 sm:mt-0 ${c.icon}`} />
<div className="flex flex-1 flex-col gap-1 sm:flex-row sm:items-center sm:gap-4">
<div>
<p className={`text-sm font-semibold ${c.title}`}>{t(titleKey, { days })}</p>
<p className={`text-xs ${c.body}`}>
{t("subscription.trialBanner.description", { days })}
</p>
</div>
<Link to="/dashboard/subscription" className="shrink-0" onClick={handleCTAClick}>
<Button size="sm" className={c.cta}>
{t("subscription.trialBanner.cta")}
</Button>
</Link>
</div>
<Link to="/dashboard/subscription" className="shrink-0" onClick={handleCTAClick}>
<Button
size="sm"
className="bg-amber-600 text-white hover:bg-amber-700 dark:bg-amber-500 dark:text-amber-950 dark:hover:bg-amber-400"
>
{t("subscription.trialBanner.cta")}
</Button>
</Link>
<button
type="button"
onClick={handleDismiss}
className={`flex size-8 shrink-0 items-center justify-center rounded-md transition-colors ${c.close}`}
aria-label={t("common.close")}
>
<X className="size-4" />
</button>
</div>
<button
type="button"
onClick={handleDismiss}
className="flex size-8 shrink-0 items-center justify-center rounded-md text-amber-600 transition-colors hover:bg-amber-100 dark:text-amber-400 dark:hover:bg-amber-900"
aria-label={t("common.close")}
<div
className={`h-1 w-full overflow-hidden rounded-full ${c.track}`}
role="progressbar"
tabIndex={-1}
aria-valuenow={elapsedPct}
aria-valuemin={0}
aria-valuemax={100}
aria-label={t("subscription.trialBanner.progressLabel", { percent: elapsedPct })}
>
<X className="size-4" />
</button>
<div className={`h-full transition-all ${c.fill}`} style={{ width: `${elapsedPct}%` }} />
</div>
</div>
);
}

View File

@@ -234,6 +234,8 @@
"brandsSelected": "brands selected",
"allBrandsSelected": "All selected",
"maxBrandsReached": "Maximum number of brands reached.",
"brandSearchPlaceholder": "Search brand — e.g. Volkswagen",
"brandSearchNoMatch": "No brand matches \"{query}\".",
"features": {
"vinSearch": "Unlimited VIN search",
"partsCatalog": "Parts catalog",
@@ -317,9 +319,12 @@
"paymentTrustKVKK": "KVKK compliant",
"trialDaysLeft": "{days} days left",
"trialBanner": {
"title": "Your trial is ending soon!",
"title": "Your trial is ending soon",
"titleUrgent": "Only {days} days left",
"titleCritical": "Last day — access ends tomorrow",
"description": "{days} days left. Upgrade now to continue accessing all features.",
"cta": "Upgrade Plan"
"cta": "Upgrade Plan",
"progressLabel": "{percent}% of trial used"
},
"trialProgress": {
"title": "{days} days left in your Full Package trial",

View File

@@ -234,6 +234,8 @@
"brandsSelected": "marka seçildi",
"allBrandsSelected": "Tümü seçildi",
"maxBrandsReached": "Maksimum marka sayısına ulaştınız.",
"brandSearchPlaceholder": "Marka ara — ör. Volkswagen",
"brandSearchNoMatch": "\"{query}\" ile eşleşen marka yok.",
"features": {
"vinSearch": "Sınırsız VIN arama",
"partsCatalog": "Parça kataloğu",
@@ -317,9 +319,12 @@
"paymentTrustKVKK": "KVKK uyumlu",
"trialDaysLeft": "{days} gün kaldı",
"trialBanner": {
"title": "Deneme süreniz dolmak üzere!",
"title": "Denemen bitmek üzere",
"titleUrgent": "Sadece {days} gün kaldı",
"titleCritical": "Son gün — yarın erişimini kaybedeceksin",
"description": "{days} gününüz kaldı. Tüm özelliklere erişmeye devam etmek için hemen yükseltin.",
"cta": "Plana Yükselt"
"cta": "Plana Yükselt",
"progressLabel": "Denemenin %{percent}'i kullanıldı"
},
"trialProgress": {
"title": "Full Paket denemende {days} gün kaldı",