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:
147
apps/web/src/components/trial-value-upsell.tsx
Normal file
147
apps/web/src/components/trial-value-upsell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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ç.",
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user