Add subscription expiry reminders across account and dashboard

This commit is contained in:
2569718930@qq.com
2026-03-30 00:58:43 +08:00
parent 16404ccf71
commit 538de5cdd8
7 changed files with 209 additions and 6 deletions
+97 -2
View File
@@ -301,6 +301,20 @@ function formatTime(value: string | undefined | null, locale: string) {
}
}
function parseSubscriptionExpiry(value: string | undefined | null) {
const raw = String(value || "").trim();
if (!raw) return null;
const dt = new Date(raw);
if (Number.isNaN(dt.getTime())) return null;
const diffMs = dt.getTime() - Date.now();
return {
raw,
date: dt,
expired: diffMs <= 0,
daysLeft: Math.ceil(diffMs / 86_400_000),
};
}
function shortAddress(address: string) {
const text = String(address || "");
if (!text.startsWith("0x") || text.length < 12) return text || "--";
@@ -768,6 +782,25 @@ export function AccountCenter() {
freeTier: "FREE TIER",
proPendingSync: isEn ? "Activated (pending sync)" : "已开通(待同步)",
noProSubscription: isEn ? "No Pro subscription" : "暂无 Pro 订阅",
trialEndsSoonTitle: isEn ? "Trial ending soon" : "试用即将结束",
trialEndsSoonBody: isEn
? "Your 3-day trial is almost over. Upgrade to Pro to keep full intraday analysis and history."
: "你的 3 天试用即将结束。升级 Pro 后可继续使用完整日内分析和历史对账。",
trialExpiredTitle: isEn ? "Trial ended" : "试用已结束",
trialExpiredBody: isEn
? "Your trial access has ended. Renew with Pro to restore full access."
: "试用权限已结束。开通 Pro 后可恢复完整权限。",
proEndsSoonTitle: isEn ? "Pro renewal due soon" : "Pro 即将到期",
proEndsSoonBody: isEn
? "Your Pro membership will expire soon. Renew now to avoid interruption."
: "你的 Pro 会员即将到期。现在续费可避免权限中断。",
proExpiredTitle: isEn ? "Pro expired" : "Pro 已到期",
proExpiredBody: isEn
? "Your Pro membership has expired. Renew now to restore premium access."
: "你的 Pro 会员已到期。立即续费可恢复高级权限。",
renewNow: isEn ? "Renew Now" : "立即续费",
trialBadge: isEn ? "TRIAL" : "试用中",
daysLeft: isEn ? "{days} days left" : "剩余 {days} 天",
}),
[isEn],
);
@@ -1368,15 +1401,43 @@ export function AccountCenter() {
const initials = (displayName.slice(0, 2) || "PW").toUpperCase();
const joinedAt = formatTime(user?.created_at, locale);
const isSubscribed = Boolean(backend?.subscription_active);
const planCode = String(backend?.subscription_plan_code || "").trim();
const isTrialPlan = /trial/i.test(planCode);
const expiryRaw = String(
backend?.subscription_expires_at || user?.user_metadata?.pro_expiry || "",
).trim();
const expiryInfo = parseSubscriptionExpiry(expiryRaw);
const expiryFormatted = formatTime(expiryRaw, locale);
const proExpiry = isSubscribed
? expiryFormatted !== "--"
? expiryFormatted
: expiryRaw || copy.proPendingSync
: copy.noProSubscription;
const showExpiringSoon =
Boolean(isSubscribed && expiryInfo && !expiryInfo.expired && expiryInfo.daysLeft <= 3);
const showExpiredReminder = Boolean(!isSubscribed && expiryInfo && expiryInfo.expired);
const subscriptionStatusTitle = showExpiredReminder
? isTrialPlan
? copy.trialExpiredTitle
: copy.proExpiredTitle
: showExpiringSoon
? isTrialPlan
? copy.trialEndsSoonTitle
: copy.proEndsSoonTitle
: "";
const subscriptionStatusBody = showExpiredReminder
? isTrialPlan
? copy.trialExpiredBody
: copy.proExpiredBody
: showExpiringSoon
? isTrialPlan
? copy.trialEndsSoonBody
: copy.proEndsSoonBody
: "";
const subscriptionStatusMeta =
expiryInfo && (showExpiringSoon || showExpiredReminder)
? `${formatTime(expiryInfo.raw, locale)} · ${copy.daysLeft.replace("{days}", String(Math.max(expiryInfo.daysLeft, 0)))}`
: "";
// Points Logic
const backendPointsRaw = Number(backend?.points);
@@ -2234,7 +2295,8 @@ export function AccountCenter() {
onClick={() => setShowOverlay(true)}
className="flex items-center gap-2 px-4 py-2 bg-yellow-500/10 hover:bg-yellow-500/20 border border-yellow-500/30 text-yellow-500 rounded-xl text-sm transition-all animate-pulse"
>
<Crown size={16} /> {copy.upgradePro}
<Crown size={16} />{" "}
{showExpiredReminder ? copy.renewNow : copy.upgradePro}
</button>
)}
<button
@@ -2269,6 +2331,35 @@ export function AccountCenter() {
</header>
<main className="w-full max-w-6xl grid grid-cols-1 lg:grid-cols-12 gap-6 z-10 relative">
{(showExpiringSoon || showExpiredReminder) && (
<div className="lg:col-span-12 rounded-[2rem] border border-amber-400/30 bg-amber-500/10 px-6 py-5 shadow-xl">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<div className="flex items-center gap-2 text-sm font-bold text-amber-300">
<Crown size={16} />
<span>{subscriptionStatusTitle}</span>
</div>
<p className="mt-1 text-sm text-amber-50/90">
{subscriptionStatusBody}
</p>
{subscriptionStatusMeta ? (
<p className="mt-1 text-xs text-amber-200/80">
{subscriptionStatusMeta}
</p>
) : null}
</div>
<button
type="button"
onClick={() => setShowOverlay(true)}
className="inline-flex items-center justify-center gap-2 rounded-xl border border-amber-300/35 bg-amber-300/12 px-4 py-2 text-sm font-bold text-amber-100 transition-all hover:bg-amber-300/20"
>
<Crown size={16} />
{showExpiredReminder ? copy.renewNow : copy.upgradePro}
</button>
</div>
</div>
)}
{/* User Card */}
<div className="lg:col-span-8 bg-white/5 backdrop-blur-xl border border-white/10 rounded-[2.5rem] p-8 shadow-2xl flex flex-col md:flex-row items-center gap-8">
<div className="relative">
@@ -2287,7 +2378,11 @@ export function AccountCenter() {
<span
className={`px-2 py-0.5 rounded-full text-[10px] font-black uppercase tracking-tighter border ${isSubscribed ? "bg-blue-500/20 border-blue-500/40 text-blue-400" : "bg-slate-700/50 border-white/10 text-slate-500"}`}
>
{isSubscribed ? copy.proMember : copy.freeTier}
{isSubscribed
? isTrialPlan
? copy.trialBadge
: copy.proMember
: copy.freeTier}
</span>
</div>
<p className="text-slate-500 font-mono text-sm mb-4">
@@ -2654,6 +2654,35 @@
transform: translateY(-1px);
}
.root :global(.account-renew-badge) {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 6px 10px;
border-radius: 999px;
border: 1px solid rgba(245, 158, 11, 0.34);
background: rgba(245, 158, 11, 0.1);
color: #fbbf24;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.01em;
text-decoration: none;
transition: var(--transition);
}
.root :global(.account-renew-badge:hover) {
background: rgba(245, 158, 11, 0.18);
border-color: rgba(245, 158, 11, 0.6);
color: #fff6d5;
transform: translateY(-1px);
}
.root :global(.account-renew-badge.expired) {
border-color: rgba(239, 68, 68, 0.34);
background: rgba(239, 68, 68, 0.12);
color: #fca5a5;
}
.root :global(.info-btn) {
background: rgba(99, 102, 241, 0.1);
border: 1px solid rgba(99, 102, 241, 0.3);
@@ -12,6 +12,20 @@ import {
hasSupabasePublicEnv,
} from "@/lib/supabase/client";
function parseExpiryInfo(raw?: string | null) {
const text = String(raw || "").trim();
if (!text) return null;
const dt = new Date(text);
if (Number.isNaN(dt.getTime())) return null;
const diffMs = dt.getTime() - Date.now();
const daysLeft = Math.ceil(diffMs / 86_400_000);
return {
date: dt,
daysLeft,
expired: diffMs <= 0,
};
}
export function HeaderBar() {
const store = useDashboardStore();
const { locale, setLocale, t } = useI18n();
@@ -56,6 +70,36 @@ export function HeaderBar() {
const accountAria = isAuthenticated
? t("header.accountAria")
: t("header.signInAria");
const expiryInfo = parseExpiryInfo(store.proAccess.subscriptionExpiresAt);
const isTrialPlan = /trial/i.test(
String(store.proAccess.subscriptionPlanCode || ""),
);
const showRenewReminder =
isAuthenticated &&
!store.proAccess.loading &&
(
(store.proAccess.subscriptionActive &&
expiryInfo &&
expiryInfo.daysLeft <= 3) ||
(!store.proAccess.subscriptionActive && Boolean(expiryInfo))
);
const renewReminderLabel = !showRenewReminder
? ""
: !store.proAccess.subscriptionActive
? isTrialPlan
? locale === "en-US"
? "Trial ended"
: "试用已结束"
: locale === "en-US"
? "Pro expired"
: "Pro 已到期"
: isTrialPlan
? locale === "en-US"
? `Trial ${Math.max(expiryInfo?.daysLeft || 0, 0)}d left`
: `试用剩余 ${Math.max(expiryInfo?.daysLeft || 0, 0)}`
: locale === "en-US"
? `Pro ${Math.max(expiryInfo?.daysLeft || 0, 0)}d left`
: `Pro 还剩 ${Math.max(expiryInfo?.daysLeft || 0, 0)}`;
return (
<header className="header">
@@ -101,6 +145,20 @@ export function HeaderBar() {
<span>{accountLabel}</span>
</Link>
{showRenewReminder ? (
<Link
href="/account"
className={clsx(
"account-renew-badge",
!store.proAccess.subscriptionActive && "expired",
)}
title={renewReminderLabel}
aria-label={renewReminderLabel}
>
<span>{renewReminderLabel}</span>
</Link>
) : null}
<div className="live-badge" id="liveBadge">
<span className="pulse-dot" />
<span>{t("header.live")}</span>
+8
View File
@@ -72,6 +72,8 @@ function getInitialProAccessState(): ProAccessState {
loading: true,
authenticated: false,
subscriptionActive: false,
subscriptionPlanCode: null,
subscriptionExpiresAt: null,
points: 0,
error: null,
};
@@ -407,12 +409,16 @@ export function DashboardStoreProvider({
const payload = (await response.json()) as {
authenticated?: boolean;
subscription_active?: boolean | null;
subscription_plan_code?: string | null;
subscription_expires_at?: string | null;
points?: number;
};
setProAccess({
loading: false,
authenticated: Boolean(payload.authenticated),
subscriptionActive: payload.subscription_active === true,
subscriptionPlanCode: payload.subscription_plan_code ?? null,
subscriptionExpiresAt: payload.subscription_expires_at ?? null,
points: payload.points ?? 0,
error: null,
});
@@ -421,6 +427,8 @@ export function DashboardStoreProvider({
loading: false,
authenticated: false,
subscriptionActive: false,
subscriptionPlanCode: null,
subscriptionExpiresAt: null,
points: 0,
error: String(error),
});
+2
View File
@@ -436,6 +436,8 @@ export interface ProAccessState {
loading: boolean;
authenticated: boolean;
subscriptionActive: boolean;
subscriptionPlanCode: string | null;
subscriptionExpiresAt: string | null;
points: number;
error: string | null;
}
+6
View File
@@ -420,6 +420,12 @@ class SupabaseEntitlementService:
return None
return self._query_latest_active_subscription(user_id)
def get_latest_subscription_any_status(
self,
user_id: str,
) -> Optional[Dict[str, object]]:
return self._query_latest_subscription_any_status(user_id)
def has_active_subscription(
self,
user_id: str,
+9 -4
View File
@@ -333,11 +333,16 @@ async def auth_me(request: Request):
user_id,
respect_requirement=False,
)
latest_known_subscription = latest_subscription
if not latest_known_subscription:
latest_known_subscription = (
SUPABASE_ENTITLEMENT.get_latest_subscription_any_status(user_id)
)
subscription_active = bool(latest_subscription)
if isinstance(latest_subscription, dict):
subscription_plan_code = latest_subscription.get("plan_code")
subscription_starts_at = latest_subscription.get("starts_at")
subscription_expires_at = latest_subscription.get("expires_at")
if isinstance(latest_known_subscription, dict):
subscription_plan_code = latest_known_subscription.get("plan_code")
subscription_starts_at = latest_known_subscription.get("starts_at")
subscription_expires_at = latest_known_subscription.get("expires_at")
except Exception:
subscription_active = None
subscription_plan_code = None