diff --git a/apps/web/src/components/subscription/brand-selector.tsx b/apps/web/src/components/subscription/brand-selector.tsx index 3f13d3c..d1e5063 100644 --- a/apps/web/src/components/subscription/brand-selector.tsx +++ b/apps/web/src/components/subscription/brand-selector.tsx @@ -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(selectedBrandIds); + const [query, setQuery] = useState(""); const { data: brands, isLoading } = useQuery({ queryKey: ["brands"], queryFn: () => api.get("/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 (
@@ -77,59 +92,92 @@ export function BrandSelector({ return

{t("common.noData")}

; } - const isMaxReached = !isFullPlan && selected.length >= maxBrands; + const isMaxReached = !isFullPlan && selectedBrandIds.length >= maxBrands; + const showSearch = !isFullPlan && brands.length > 12; return (
-
+

{t("subscription.selectBrands")}

- + {isFullPlan ? t("subscription.allBrandsSelected") - : `${selected.length}/${maxBrands} ${t("subscription.brandsSelected")}`} + : `${selectedBrandIds.length}/${maxBrands} ${t("subscription.brandsSelected")}`}
- {isMaxReached && ( -

{t("subscription.maxBrandsReached")}

+ {showSearch && ( +
+ + 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 && ( + + )} +
)} -
- {brands.map((brand) => { - const isSelected = selected.includes(brand.id) || isFullPlan; - const isDisabled = !isSelected && isMaxReached && !isFullPlan; + {isMaxReached && ( +

+ {t("subscription.maxBrandsReached")} +

+ )} - return ( - !isDisabled && toggleBrand(brand.id)} - > - -
- - {isSelected && ( -
- -
- )} -
- {brand.name} -
-
- ); - })} -
+ {filteredBrands.length === 0 ? ( +

+ {t("subscription.brandSearchNoMatch", { query })} +

+ ) : ( +
+ {filteredBrands.map((brand) => { + const isSelected = selectedBrandIds.includes(brand.id) || isFullPlan; + const isDisabled = !isSelected && isMaxReached && !isFullPlan; + + return ( + !isDisabled && toggleBrand(brand.id)} + > + +
+ + {isSelected && ( +
+ +
+ )} +
+ {brand.name} +
+
+ ); + })} +
+ )}
); } diff --git a/apps/web/src/components/trial-urgency-banner.tsx b/apps/web/src/components/trial-urgency-banner.tsx index 884b304..021ea20 100644 --- a/apps/web/src/components/trial-urgency-banner.tsx +++ b/apps/web/src/components/trial-urgency-banner.tsx @@ -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 ( -
- + // 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]; -
-
-

- {t("subscription.trialBanner.title")} -

-

- {t("subscription.trialBanner.description", { days })} -

+ return ( +
+
+ + +
+
+

{t(titleKey, { days })}

+

+ {t("subscription.trialBanner.description", { days })} +

+
+ + + +
- - - +
- +
+
); } diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index b65badd..d000d85 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -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", diff --git a/apps/web/src/messages/tr.json b/apps/web/src/messages/tr.json index 00e573c..f9c0134 100644 --- a/apps/web/src/messages/tr.json +++ b/apps/web/src/messages/tr.json @@ -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ı",