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>
148 lines
5.4 KiB
TypeScript
148 lines
5.4 KiB
TypeScript
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>
|
|
);
|
|
}
|