Add subscription expiry reminders across account and dashboard
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -436,6 +436,8 @@ export interface ProAccessState {
|
||||
loading: boolean;
|
||||
authenticated: boolean;
|
||||
subscriptionActive: boolean;
|
||||
subscriptionPlanCode: string | null;
|
||||
subscriptionExpiresAt: string | null;
|
||||
points: number;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user