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) {
|
function shortAddress(address: string) {
|
||||||
const text = String(address || "");
|
const text = String(address || "");
|
||||||
if (!text.startsWith("0x") || text.length < 12) return text || "--";
|
if (!text.startsWith("0x") || text.length < 12) return text || "--";
|
||||||
@@ -768,6 +782,25 @@ export function AccountCenter() {
|
|||||||
freeTier: "FREE TIER",
|
freeTier: "FREE TIER",
|
||||||
proPendingSync: isEn ? "Activated (pending sync)" : "已开通(待同步)",
|
proPendingSync: isEn ? "Activated (pending sync)" : "已开通(待同步)",
|
||||||
noProSubscription: isEn ? "No Pro subscription" : "暂无 Pro 订阅",
|
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],
|
[isEn],
|
||||||
);
|
);
|
||||||
@@ -1368,15 +1401,43 @@ export function AccountCenter() {
|
|||||||
const initials = (displayName.slice(0, 2) || "PW").toUpperCase();
|
const initials = (displayName.slice(0, 2) || "PW").toUpperCase();
|
||||||
const joinedAt = formatTime(user?.created_at, locale);
|
const joinedAt = formatTime(user?.created_at, locale);
|
||||||
const isSubscribed = Boolean(backend?.subscription_active);
|
const isSubscribed = Boolean(backend?.subscription_active);
|
||||||
|
const planCode = String(backend?.subscription_plan_code || "").trim();
|
||||||
|
const isTrialPlan = /trial/i.test(planCode);
|
||||||
const expiryRaw = String(
|
const expiryRaw = String(
|
||||||
backend?.subscription_expires_at || user?.user_metadata?.pro_expiry || "",
|
backend?.subscription_expires_at || user?.user_metadata?.pro_expiry || "",
|
||||||
).trim();
|
).trim();
|
||||||
|
const expiryInfo = parseSubscriptionExpiry(expiryRaw);
|
||||||
const expiryFormatted = formatTime(expiryRaw, locale);
|
const expiryFormatted = formatTime(expiryRaw, locale);
|
||||||
const proExpiry = isSubscribed
|
const proExpiry = isSubscribed
|
||||||
? expiryFormatted !== "--"
|
? expiryFormatted !== "--"
|
||||||
? expiryFormatted
|
? expiryFormatted
|
||||||
: expiryRaw || copy.proPendingSync
|
: expiryRaw || copy.proPendingSync
|
||||||
: copy.noProSubscription;
|
: 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
|
// Points Logic
|
||||||
const backendPointsRaw = Number(backend?.points);
|
const backendPointsRaw = Number(backend?.points);
|
||||||
@@ -2234,7 +2295,8 @@ export function AccountCenter() {
|
|||||||
onClick={() => setShowOverlay(true)}
|
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"
|
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>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
@@ -2269,6 +2331,35 @@ export function AccountCenter() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="w-full max-w-6xl grid grid-cols-1 lg:grid-cols-12 gap-6 z-10 relative">
|
<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 */}
|
{/* 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="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">
|
<div className="relative">
|
||||||
@@ -2287,7 +2378,11 @@ export function AccountCenter() {
|
|||||||
<span
|
<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"}`}
|
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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-slate-500 font-mono text-sm mb-4">
|
<p className="text-slate-500 font-mono text-sm mb-4">
|
||||||
|
|||||||
@@ -2654,6 +2654,35 @@
|
|||||||
transform: translateY(-1px);
|
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) {
|
.root :global(.info-btn) {
|
||||||
background: rgba(99, 102, 241, 0.1);
|
background: rgba(99, 102, 241, 0.1);
|
||||||
border: 1px solid rgba(99, 102, 241, 0.3);
|
border: 1px solid rgba(99, 102, 241, 0.3);
|
||||||
|
|||||||
@@ -12,6 +12,20 @@ import {
|
|||||||
hasSupabasePublicEnv,
|
hasSupabasePublicEnv,
|
||||||
} from "@/lib/supabase/client";
|
} 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() {
|
export function HeaderBar() {
|
||||||
const store = useDashboardStore();
|
const store = useDashboardStore();
|
||||||
const { locale, setLocale, t } = useI18n();
|
const { locale, setLocale, t } = useI18n();
|
||||||
@@ -56,6 +70,36 @@ export function HeaderBar() {
|
|||||||
const accountAria = isAuthenticated
|
const accountAria = isAuthenticated
|
||||||
? t("header.accountAria")
|
? t("header.accountAria")
|
||||||
: t("header.signInAria");
|
: 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 (
|
return (
|
||||||
<header className="header">
|
<header className="header">
|
||||||
@@ -101,6 +145,20 @@ export function HeaderBar() {
|
|||||||
<span>{accountLabel}</span>
|
<span>{accountLabel}</span>
|
||||||
</Link>
|
</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">
|
<div className="live-badge" id="liveBadge">
|
||||||
<span className="pulse-dot" />
|
<span className="pulse-dot" />
|
||||||
<span>{t("header.live")}</span>
|
<span>{t("header.live")}</span>
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ function getInitialProAccessState(): ProAccessState {
|
|||||||
loading: true,
|
loading: true,
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
subscriptionActive: false,
|
subscriptionActive: false,
|
||||||
|
subscriptionPlanCode: null,
|
||||||
|
subscriptionExpiresAt: null,
|
||||||
points: 0,
|
points: 0,
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
@@ -407,12 +409,16 @@ export function DashboardStoreProvider({
|
|||||||
const payload = (await response.json()) as {
|
const payload = (await response.json()) as {
|
||||||
authenticated?: boolean;
|
authenticated?: boolean;
|
||||||
subscription_active?: boolean | null;
|
subscription_active?: boolean | null;
|
||||||
|
subscription_plan_code?: string | null;
|
||||||
|
subscription_expires_at?: string | null;
|
||||||
points?: number;
|
points?: number;
|
||||||
};
|
};
|
||||||
setProAccess({
|
setProAccess({
|
||||||
loading: false,
|
loading: false,
|
||||||
authenticated: Boolean(payload.authenticated),
|
authenticated: Boolean(payload.authenticated),
|
||||||
subscriptionActive: payload.subscription_active === true,
|
subscriptionActive: payload.subscription_active === true,
|
||||||
|
subscriptionPlanCode: payload.subscription_plan_code ?? null,
|
||||||
|
subscriptionExpiresAt: payload.subscription_expires_at ?? null,
|
||||||
points: payload.points ?? 0,
|
points: payload.points ?? 0,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
@@ -421,6 +427,8 @@ export function DashboardStoreProvider({
|
|||||||
loading: false,
|
loading: false,
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
subscriptionActive: false,
|
subscriptionActive: false,
|
||||||
|
subscriptionPlanCode: null,
|
||||||
|
subscriptionExpiresAt: null,
|
||||||
points: 0,
|
points: 0,
|
||||||
error: String(error),
|
error: String(error),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -436,6 +436,8 @@ export interface ProAccessState {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
authenticated: boolean;
|
authenticated: boolean;
|
||||||
subscriptionActive: boolean;
|
subscriptionActive: boolean;
|
||||||
|
subscriptionPlanCode: string | null;
|
||||||
|
subscriptionExpiresAt: string | null;
|
||||||
points: number;
|
points: number;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -420,6 +420,12 @@ class SupabaseEntitlementService:
|
|||||||
return None
|
return None
|
||||||
return self._query_latest_active_subscription(user_id)
|
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(
|
def has_active_subscription(
|
||||||
self,
|
self,
|
||||||
user_id: str,
|
user_id: str,
|
||||||
|
|||||||
+9
-4
@@ -333,11 +333,16 @@ async def auth_me(request: Request):
|
|||||||
user_id,
|
user_id,
|
||||||
respect_requirement=False,
|
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)
|
subscription_active = bool(latest_subscription)
|
||||||
if isinstance(latest_subscription, dict):
|
if isinstance(latest_known_subscription, dict):
|
||||||
subscription_plan_code = latest_subscription.get("plan_code")
|
subscription_plan_code = latest_known_subscription.get("plan_code")
|
||||||
subscription_starts_at = latest_subscription.get("starts_at")
|
subscription_starts_at = latest_known_subscription.get("starts_at")
|
||||||
subscription_expires_at = latest_subscription.get("expires_at")
|
subscription_expires_at = latest_known_subscription.get("expires_at")
|
||||||
except Exception:
|
except Exception:
|
||||||
subscription_active = None
|
subscription_active = None
|
||||||
subscription_plan_code = None
|
subscription_plan_code = None
|
||||||
|
|||||||
Reference in New Issue
Block a user