feat(FN-209): implement trial urgency banner
Some checks failed
Sync dev → Gitea / Mirror dev to Gitea (push) Has been cancelled

Commits merged:
- feat(FN-209): implement trial urgency banner

Files changed:
apps/web/src/components/trial-urgency-banner.tsx | 111 +++++++++++++++++++++++
 apps/web/src/messages/en.json                    |   7 +-
 apps/web/src/messages/tr.json                    |   7 +-
 apps/web/src/routes/dashboard.tsx                |   8 +-
 4 files changed, 129 insertions(+), 4 deletions(-)

Fusion-Task-Id: FN-209
This commit is contained in:
Fusion
2026-05-12 21:47:18 +00:00
parent d14657a2c1
commit 0b49c164dc
4 changed files with 129 additions and 4 deletions

View File

@@ -0,0 +1,111 @@
import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n";
import { Button } from "@sase/ui";
import { useQuery } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { AlertTriangle, X } from "lucide-react";
import { useEffect, useState } from "react";
interface Subscription {
status: string;
plan?: { name: string; key: string };
billingPeriod: string;
startDate?: string;
endDate?: string;
}
const DISMISS_STORAGE_PREFIX = "trialBannerDismissed";
function getDismissKey(endDate: string): string {
return `${DISMISS_STORAGE_PREFIX}-${endDate}`;
}
export function TrialUrgencyBanner() {
const { t } = useTranslation();
const [dismissed, setDismissed] = useState(false);
const [mounted, setMounted] = useState(false);
const { data: subData, isLoading } = useQuery({
queryKey: ["subscription", "me"],
queryFn: () =>
api.get<{ subscription: Subscription | null; eligibleForTrial: boolean }>(
"/subscriptions/me",
),
});
const subscription = subData?.subscription;
// Hydrate dismissal state from localStorage on mount
useEffect(() => {
setMounted(true);
}, []);
// Guard: not loaded yet, or no subscription
if (isLoading || !subscription) return null;
// Guard: not trial or no end date
if (subscription.status !== "trial" || !subscription.endDate) return null;
const days = Math.max(
0,
Math.ceil((new Date(subscription.endDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24)),
);
// Only show when 3 or fewer days remain
if (days > 3) return null;
const dismissKey = getDismissKey(subscription.endDate);
// Check localStorage on mount and when endDate changes
useEffect(() => {
if (!mounted) return;
const stored = localStorage.getItem(dismissKey);
if (stored === "true") {
setDismissed(true);
}
}, [mounted, dismissKey]);
if (dismissed) return null;
const handleDismiss = () => {
localStorage.setItem(dismissKey, "true");
setDismissed(true);
};
return (
<div
role="alert"
className="flex items-center gap-3 border-b 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 })}
</p>
</div>
<Link to="/dashboard/subscription" className="shrink-0">
<Button
size="sm"
className="bg-amber-600 text-white hover:bg-amber-700 dark:bg-amber-500 dark:text-amber-950 dark:hover:bg-amber-400"
>
{t("subscription.trialBanner.cta")}
</Button>
</Link>
</div>
<button
type="button"
onClick={handleDismiss}
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>
);
}

View File

@@ -221,7 +221,12 @@
"paymentTrustSSL": "256-bit SSL",
"paymentTrustProvider": "Iyzico infrastructure",
"paymentTrustKVKK": "KVKK compliant",
"trialDaysLeft": "{days} days left"
"trialDaysLeft": "{days} days left",
"trialBanner": {
"title": "Your trial is ending soon!",
"description": "{days} days left. Upgrade now to continue accessing all features.",
"cta": "Upgrade Plan"
}
},
"payment": {
"title": "Payment",

View File

@@ -221,7 +221,12 @@
"paymentTrustSSL": "256-bit SSL",
"paymentTrustProvider": "Iyzico altyapısı",
"paymentTrustKVKK": "KVKK uyumlu",
"trialDaysLeft": "{days} gün kaldı"
"trialDaysLeft": "{days} gün kaldı",
"trialBanner": {
"title": "Deneme süreniz dolmak üzere!",
"description": "{days} gününüz kaldı. Tüm özelliklere erişmeye devam etmek için hemen yükseltin.",
"cta": "Plana Yükselt"
}
},
"payment": {
"title": "Ödeme",

View File

@@ -1,3 +1,4 @@
import { TrialUrgencyBanner } from "@/components/trial-urgency-banner";
import { useAuth } from "@/hooks/use-auth";
import { useTranslation } from "@/lib/i18n";
import { KEYS_5 } from "@/lib/keys";
@@ -381,8 +382,11 @@ function DashboardLayout() {
</header>
{/* Page Content */}
<main id="main-content" className="flex-1 overflow-auto bg-muted/30 p-4 sm:p-6">
<Outlet />
<main id="main-content" className="flex-1 overflow-auto bg-muted/30">
<TrialUrgencyBanner />
<div className="p-4 sm:p-6">
<Outlet />
</div>
</main>
{/* Footer */}