feat(FN-300): add trial urgency banner to subscription page
Some checks failed
Sync dev → Gitea / Mirror dev to Gitea (push) Has been cancelled

Commits merged:
- feat(FN-300): complete Step 1 — add trial urgency banner to subscription page

Files changed:
.../src/routes/dashboard/subscription/index.tsx    | 107 +++++++++++++++++++++
 1 file changed, 107 insertions(+)

Fusion-Task-Id: FN-300
This commit is contained in:
Fusion
2026-05-13 08:13:18 +00:00
parent 3dd4dc2f77
commit 4cb715f1b4

View File

@@ -23,6 +23,7 @@ import {
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import {
AlertTriangle,
ArrowRight,
Check,
CheckCircle2,
@@ -33,6 +34,7 @@ import {
Minus,
ShieldCheck,
Sparkles,
X,
} from "lucide-react";
import { Suspense, lazy, useEffect, useRef, useState } from "react";
@@ -163,6 +165,10 @@ export function SubscriptionPage() {
const [downgradeOfferOpen, setDowngradeOfferOpen] = useState(false);
const [downgradeSelectedBrandIds, setDowngradeSelectedBrandIds] = useState<string[]>([]);
// Trial urgency banner state
const [trialBannerDismissed, setTrialBannerDismissed] = useState(false);
const trialBannerViewedRef = useRef(false);
// Onboarding state
const [onboardingPhase, setOnboardingPhase] = useState<"provisioning" | "completed" | null>(null);
const [animationEnded, setAnimationEnded] = useState(false);
@@ -392,6 +398,47 @@ export function SubscriptionPage() {
return () => observer.disconnect();
}, [selectedPlanKey]);
// ─── Trial urgency banner: compute days remaining ────────────────────────
const trialDaysRemaining = (() => {
if (!subscription || subscription.status !== "trial" || !subscription.endDate) return null;
return Math.max(
0,
Math.ceil((new Date(subscription.endDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24)),
);
})();
// Check localStorage dismissal
useEffect(() => {
if (!subscription?.endDate) return;
const key = `trialBannerDismissed-${subscription.endDate}`;
if (localStorage.getItem(key) === "true") {
setTrialBannerDismissed(true);
}
}, [subscription?.endDate]);
// Fire view event once when banner becomes visible
useEffect(() => {
if (
trialDaysRemaining === null ||
trialDaysRemaining > 7 ||
trialBannerDismissed ||
trialBannerViewedRef.current
) {
return;
}
trialBannerViewedRef.current = true;
capture("trial_urgency_banner_viewed", {
days_remaining: trialDaysRemaining,
plan_name: subscription?.plan?.name,
billing_period: subscription?.billingPeriod,
});
}, [
trialDaysRemaining,
trialBannerDismissed,
subscription?.plan?.name,
subscription?.billingPeriod,
]);
// ─── Social proof impression tracking ────────────────────────────────────
const trustBadgesRef = useRef<HTMLDivElement>(null);
const paymentTrustRef = useRef<HTMLDivElement>(null);
@@ -440,6 +487,26 @@ export function SubscriptionPage() {
capture("social_proof_engaged", { page: "subscription", section: "payment_trust" });
}
// ─── Trial urgency banner handlers ────────────────────────────────────────
function handleTrialBannerCTAClick() {
capture("trial_urgency_banner_cta_clicked", {
days_remaining: trialDaysRemaining,
plan_name: subscription?.plan?.name,
billing_period: subscription?.billingPeriod,
});
planCardsRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
}
function handleTrialBannerDismiss() {
capture("trial_urgency_banner_dismissed", {
days_remaining: trialDaysRemaining,
plan_name: subscription?.plan?.name,
billing_period: subscription?.billingPeriod,
});
localStorage.setItem(`trialBannerDismissed-${subscription?.endDate}`, "true");
setTrialBannerDismissed(true);
}
// ─── Loading state ─────────────────────────────────────────────────────────
if (isLoading) {
return (
@@ -921,6 +988,46 @@ export function SubscriptionPage() {
<Separator />
{/* Trial Urgency Banner */}
{trialDaysRemaining !== null && trialDaysRemaining <= 7 && !trialBannerDismissed && (
<div
role="alert"
className="animate-fade-in-up flex items-center gap-3 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 dark:border-amber-800 dark:bg-amber-950 sm:gap-4 sm:px-6"
>
<AlertTriangle className="size-5 shrink-0 text-amber-600 dark:text-amber-400" />
<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-amber-900 dark:text-amber-100">
{t("subscription.trialBanner.title")}
</p>
<p className="text-xs text-amber-700 dark:text-amber-300">
{t("subscription.trialBanner.description", {
days: trialDaysRemaining,
})}
</p>
</div>
<Button
size="sm"
className="shrink-0 bg-amber-600 text-white hover:bg-amber-700 dark:bg-amber-500 dark:text-amber-950 dark:hover:bg-amber-400"
onClick={handleTrialBannerCTAClick}
>
{t("subscription.trialBanner.cta")}
</Button>
</div>
<button
type="button"
onClick={handleTrialBannerDismiss}
className="flex size-8 shrink-0 items-center justify-center rounded-md text-amber-600 transition-colors hover:bg-amber-100 dark:text-amber-400 dark:hover:bg-amber-900"
aria-label={t("common.close")}
>
<X className="size-4" />
</button>
</div>
)}
{/* Billing Period Toggle */}
<div className="flex items-center justify-center gap-4">
<Button