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"; import { useQuery } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; import { Sparkles, X } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; interface Subscription { status: string; plan?: { name: string; key: string }; billingPeriod: string; endDate?: string; } // Early, value-framed conversion nudge that fills the dead zone the urgency // banner leaves: the 30-day trial only shows the (red) urgency banner in its // final week, so for ~3 weeks there is no in-app push at all — and ~98% of // trials never reach checkout. This banner instead fires on PROVEN VALUE (the // user has decoded >= VALUE_THRESHOLD customer vehicles) and only outside the // urgency window, so the two never overlap and the tone stays positive. const VALUE_THRESHOLD = 3; // Urgency banner owns days <= 7; this one owns days > 7 (no overlap). const URGENCY_WINDOW_DAYS = 7; function dismissStorageKey(userId: string | null | undefined, endDate: string): string { return `trialValueUpsellDismissed-${userId ?? "anon"}-${endDate}`; } export function TrialValueUpsell() { 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); const { data: subData, isLoading: subLoading } = useQuery({ queryKey: ["subscription", "me"], queryFn: () => api.get<{ subscription: Subscription | null; eligibleForTrial: boolean }>( "/subscriptions/me", ), }); const subscription = subData?.subscription; const endDate = subscription?.endDate; const days = subscription?.status === "trial" && endDate ? Math.max(0, Math.ceil((new Date(endDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24))) : null; // Only outside the urgency window, so the urgency banner stays the sole voice // in the final week. const inWindow = !subLoading && days !== null && days > URGENCY_WINDOW_DAYS; // Server-truth value signal: how many customer vehicles this trial user has // decoded. Requesting exactly VALUE_THRESHOLD rows is enough to answer // ">= threshold" without a COUNT endpoint. Gated so non-eligible users never // pay for the request. const { data: history } = useQuery({ queryKey: ["vehicles", "history", "value-probe", VALUE_THRESHOLD], queryFn: () => api.get(`/vehicles/history?limit=${VALUE_THRESHOLD}`), enabled: inWindow, staleTime: 5 * 60 * 1000, }); const hasProvenValue = Array.isArray(history) && history.length >= VALUE_THRESHOLD; const dismissKey = endDate ? dismissStorageKey(userId, endDate) : null; useEffect(() => { setMounted(true); }, []); useEffect(() => { if (!mounted || !dismissKey) return; if (localStorage.getItem(dismissKey) === "true") setDismissed(true); }, [mounted, dismissKey]); const visible = inWindow && hasProvenValue && !dismissed; useEffect(() => { if (!visible || viewedRef.current || days === null) return; viewedRef.current = true; capture("trial_value_upsell_viewed", { days_remaining: days, plan_name: subscription?.plan?.name, billing_period: subscription?.billingPeriod, }); }, [visible, days, subscription?.plan?.name, subscription?.billingPeriod]); const handleCTAClick = useCallback(() => { capture("trial_value_upsell_cta_clicked", { days_remaining: days, plan_name: subscription?.plan?.name, billing_period: subscription?.billingPeriod, }); }, [days, subscription?.plan?.name, subscription?.billingPeriod]); if (!visible || !dismissKey) return null; const handleDismiss = () => { capture("trial_value_upsell_dismissed", { days_remaining: days, plan_name: subscription?.plan?.name, billing_period: subscription?.billingPeriod, }); localStorage.setItem(dismissKey, "true"); setDismissed(true); }; return (

{t("subscription.valueUpsell.title")}

{t("subscription.valueUpsell.description")}

); }