Files
sase.tr/apps/web/src/components/trial-urgency-banner.tsx
Semih Yesilyurt a4e53f3cfd feat(trial): shorten free trial from 30 to 7 days
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>
2026-06-27 16:37:21 +03:00

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>
);
}