diff --git a/frontend/components/account/AccountCenter.tsx b/frontend/components/account/AccountCenter.tsx index a0df97be..eba41626 100644 --- a/frontend/components/account/AccountCenter.tsx +++ b/frontend/components/account/AccountCenter.tsx @@ -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" > - {copy.upgradePro} + {" "} + {showExpiredReminder ? copy.renewNow : copy.upgradePro} )} + + + )} + {/* User Card */}
@@ -2287,7 +2378,11 @@ export function AccountCenter() { - {isSubscribed ? copy.proMember : copy.freeTier} + {isSubscribed + ? isTrialPlan + ? copy.trialBadge + : copy.proMember + : copy.freeTier}

diff --git a/frontend/components/dashboard/Dashboard.module.css b/frontend/components/dashboard/Dashboard.module.css index 1022dae7..0c7892a4 100644 --- a/frontend/components/dashboard/Dashboard.module.css +++ b/frontend/components/dashboard/Dashboard.module.css @@ -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); diff --git a/frontend/components/dashboard/HeaderBar.tsx b/frontend/components/dashboard/HeaderBar.tsx index 5e1b3b58..4ba181e5 100644 --- a/frontend/components/dashboard/HeaderBar.tsx +++ b/frontend/components/dashboard/HeaderBar.tsx @@ -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 (

@@ -101,6 +145,20 @@ export function HeaderBar() { {accountLabel} + {showRenewReminder ? ( + + {renewReminderLabel} + + ) : null} +
{t("header.live")} diff --git a/frontend/hooks/useDashboardStore.tsx b/frontend/hooks/useDashboardStore.tsx index 73863317..272b615e 100644 --- a/frontend/hooks/useDashboardStore.tsx +++ b/frontend/hooks/useDashboardStore.tsx @@ -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), }); diff --git a/frontend/lib/dashboard-types.ts b/frontend/lib/dashboard-types.ts index e6086cce..56f8d552 100644 --- a/frontend/lib/dashboard-types.ts +++ b/frontend/lib/dashboard-types.ts @@ -436,6 +436,8 @@ export interface ProAccessState { loading: boolean; authenticated: boolean; subscriptionActive: boolean; + subscriptionPlanCode: string | null; + subscriptionExpiresAt: string | null; points: number; error: string | null; } diff --git a/src/auth/supabase_entitlement.py b/src/auth/supabase_entitlement.py index 68053718..9bd127a4 100644 --- a/src/auth/supabase_entitlement.py +++ b/src/auth/supabase_entitlement.py @@ -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, diff --git a/web/routes.py b/web/routes.py index 5973de2f..18017b54 100644 --- a/web/routes.py +++ b/web/routes.py @@ -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