Backend source of truth (subscriptions.service.startFullTrial) now grants a
7-day trial via a named TRIAL_DAYS constant. Existing trials keep their stored
endDate — only new sign-ups are affected, so no migration is needed.
In-app conversion UX rescaled to the 7-day window, preserving the two-banner
no-overlap design:
- urgency banner: visible the last 3 days (soft d3 / urgent d2 / critical d1)
- value-upsell: owns days > 3 (the opening days), still proven-value gated
- progress bars now derive the total span from real start->end dates, so both
7-day and any remaining legacy 30-day trials render an accurate bar
Subscription-page thresholds (<=3 / >3) and the analytics gate updated to match.
Copy: 17 trial strings each in tr.json/en.json plus hardcoded CTAs in
site-header, _auth, register and demo-footer-cta -> "7 gun / 7 Gun / 7-Day".
Lifecycle emails need no change: trial-ending fires 3-4 days before endDate
and win-back after expiry (both already relative to endDate), and templates
use the dynamic {{daysLeft}} variable (no hardcoded 30).
Test: trial-progress-banner spec updated to 7-day scenarios.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
230 lines
8.1 KiB
TypeScript
230 lines
8.1 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 { AlertTriangle, X } from "lucide-react";
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
|
|
interface Subscription {
|
|
status: string;
|
|
plan?: { name: string; key: string };
|
|
billingPeriod: string;
|
|
startDate?: string;
|
|
endDate?: string;
|
|
}
|
|
|
|
// Show the banner inside the final stretch of the trial. Two tighter tiers
|
|
// (2, 1 days) push the visual urgency without spamming earlier. Sized for the
|
|
// 7-day trial: soft on day 3, urgent on day 2, critical on the last day.
|
|
const VISIBLE_AT_DAYS = 3;
|
|
const URGENT_AT_DAYS = 2;
|
|
const CRITICAL_AT_DAYS = 1;
|
|
// Fallback trial length when start/end dates aren't both present; the progress
|
|
// bar prefers the real per-subscription span (correct for legacy 30-day trials).
|
|
const TRIAL_FALLBACK_DAYS = 7;
|
|
|
|
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);
|
|
|
|
const { data: subData, isLoading } = 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;
|
|
const visible = !isLoading && days !== null && days <= VISIBLE_AT_DAYS;
|
|
const dismissKey = endDate ? trialBannerKey(userId, endDate) : null;
|
|
|
|
useEffect(() => {
|
|
setMounted(true);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!mounted || !dismissKey) return;
|
|
const stored = localStorage.getItem(dismissKey);
|
|
if (stored === "true") {
|
|
setDismissed(true);
|
|
}
|
|
}, [mounted, dismissKey]);
|
|
|
|
const tier: Tier | null = days !== null ? tierFor(days) : null;
|
|
|
|
useEffect(() => {
|
|
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, 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, tier, subscription?.plan?.name, subscription?.billingPeriod]);
|
|
|
|
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,
|
|
});
|
|
localStorage.setItem(dismissKey, "true");
|
|
setDismissed(true);
|
|
};
|
|
|
|
// Progress bar reflects elapsed share of the trial — a number alone undersells
|
|
// urgency at e.g. day 6/7, while the bar reads visually as "nearly done".
|
|
// Total span comes from the real start/end dates so 7-day and any remaining
|
|
// legacy 30-day trials both render an accurate bar.
|
|
const totalDays =
|
|
subscription?.startDate && endDate
|
|
? Math.max(
|
|
1,
|
|
Math.round(
|
|
(new Date(endDate).getTime() - new Date(subscription.startDate).getTime()) /
|
|
(1000 * 60 * 60 * 24),
|
|
),
|
|
)
|
|
: TRIAL_FALLBACK_DAYS;
|
|
const elapsed = Math.max(0, Math.min(totalDays, totalDays - days));
|
|
const elapsedPct = Math.round((elapsed / totalDays) * 100);
|
|
const titleKey =
|
|
tier === "critical"
|
|
? "subscription.trialBanner.titleCritical"
|
|
: tier === "urgent"
|
|
? "subscription.trialBanner.titleUrgent"
|
|
: "subscription.trialBanner.title";
|
|
const c = tierClasses[tier];
|
|
|
|
return (
|
|
<div role="alert" className={`flex flex-col gap-2 border-b px-4 py-3 sm:px-6 ${c.bar}`}>
|
|
<div className="flex items-start gap-3 sm:items-center">
|
|
<AlertTriangle className={`mt-0.5 size-5 shrink-0 sm:mt-0 ${c.icon}`} />
|
|
|
|
<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 ${c.title}`}>{t(titleKey, { days })}</p>
|
|
<p className={`text-xs ${c.body}`}>
|
|
{t("subscription.trialBanner.description", { days })}
|
|
</p>
|
|
</div>
|
|
|
|
<Link to="/dashboard/subscription" className="shrink-0" onClick={handleCTAClick}>
|
|
<Button size="sm" className={c.cta}>
|
|
{t("subscription.trialBanner.cta")}
|
|
</Button>
|
|
</Link>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={handleDismiss}
|
|
className={`flex size-8 shrink-0 items-center justify-center rounded-md transition-colors ${c.close}`}
|
|
aria-label={t("common.close")}
|
|
>
|
|
<X className="size-4" />
|
|
</button>
|
|
</div>
|
|
|
|
<div
|
|
className={`h-1 w-full overflow-hidden rounded-full ${c.track}`}
|
|
role="progressbar"
|
|
tabIndex={-1}
|
|
aria-valuenow={elapsedPct}
|
|
aria-valuemin={0}
|
|
aria-valuemax={100}
|
|
aria-label={t("subscription.trialBanner.progressLabel", { percent: elapsedPct })}
|
|
>
|
|
<div className={`h-full transition-all ${c.fill}`} style={{ width: `${elapsedPct}%` }} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|