feat(FN-209): implement trial urgency banner
Some checks failed
Sync dev → Gitea / Mirror dev to Gitea (push) Has been cancelled
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:
111
apps/web/src/components/trial-urgency-banner.tsx
Normal file
111
apps/web/src/components/trial-urgency-banner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user