feat(trial): value-moment upsell to convert engaged trials earlier

Only ~1.6% of trials reach checkout: the 30-day full-access trial shows the
(urgency) banner solely in its final 7 days, leaving ~3 weeks with no in-app
push, and the value moment (first few queries) goes unconverted.

Add TrialValueUpsell — a calm, value-framed banner that fires on PROVEN VALUE
(user has decoded >= 3 customer vehicles, read server-truth via /vehicles/history)
and only OUTSIDE the urgency window (days > 7), so it fills the gap without ever
overlapping or competing with the urgency banner. B2B copy: frames around
"müşteri araçlarını sınırsız sorgulama", never personal-vehicle wording.
Tracks viewed/cta_clicked/dismissed in PostHog; dismissal persists per trial.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 20:33:10 +03:00
parent cd54cf9443
commit d3ae278c75
4 changed files with 159 additions and 0 deletions

View File

@@ -0,0 +1,147 @@
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<unknown[]>(`/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 (
<section
aria-label={t("subscription.valueUpsell.title")}
className="flex items-start gap-3 border-b border-brand/20 bg-brand/5 px-4 py-3 sm:items-center sm:px-6"
>
<Sparkles className="mt-0.5 size-5 shrink-0 text-brand sm:mt-0" />
<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-foreground">
{t("subscription.valueUpsell.title")}
</p>
<p className="text-xs text-muted-foreground">
{t("subscription.valueUpsell.description")}
</p>
</div>
<Link to="/dashboard/subscription" className="shrink-0" onClick={handleCTAClick}>
<Button size="sm" className="bg-brand text-white hover:bg-brand/90">
{t("subscription.valueUpsell.cta")}
</Button>
</Link>
</div>
<button
type="button"
onClick={handleDismiss}
className="flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-brand/10 hover:text-foreground"
aria-label={t("common.close")}
>
<X className="size-4" />
</button>
</section>
);
}

View File

@@ -413,6 +413,11 @@
"cta": "Upgrade Plan",
"progressLabel": "{percent}% of trial used"
},
"valueUpsell": {
"title": "Ready to subscribe?",
"description": "You're actively using the platform. Subscribe to keep querying customer vehicles and accessing OEM parts without interruption.",
"cta": "Subscribe"
},
"trialProgress": {
"title": "{days} days left in your Full Package trial",
"description": "Pick a plan now to keep your access without interruption.",

View File

@@ -413,6 +413,11 @@
"cta": "Plana Yükselt",
"progressLabel": "Denemenin %{percent}'i kullanıldı"
},
"valueUpsell": {
"title": "Aboneliğe geçmeye hazır mısın?",
"description": "Platformu aktif kullanıyorsun. Müşteri araçlarını sınırsız sorgulamaya ve OEM parça erişimine kesintisiz devam etmek için aboneliğe geç.",
"cta": "Aboneliğe geç"
},
"trialProgress": {
"title": "Full Paket denemende {days} gün kaldı",
"description": "Kesintisiz devam etmek için planını şimdi seç.",

View File

@@ -1,6 +1,7 @@
import { LanguageSwitcher } from "@/components/language-switcher";
import { SiteFooter } from "@/components/site-footer";
import { TrialUrgencyBanner } from "@/components/trial-urgency-banner";
import { TrialValueUpsell } from "@/components/trial-value-upsell";
import { useAuth } from "@/hooks/use-auth";
import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n";
@@ -509,6 +510,7 @@ function DashboardLayout() {
{/* Page Content */}
<main id="main-content" className="flex-1 overflow-auto bg-muted/30">
<TrialUrgencyBanner />
<TrialValueUpsell />
<div className="p-4 sm:p-6">
<Outlet />
</div>