diff --git a/frontend/components/account/__tests__/trialUpgradeConversion.test.ts b/frontend/components/account/__tests__/trialUpgradeConversion.test.ts
index 00ad2228..0023e86e 100644
--- a/frontend/components/account/__tests__/trialUpgradeConversion.test.ts
+++ b/frontend/components/account/__tests__/trialUpgradeConversion.test.ts
@@ -32,9 +32,9 @@ export function runTests() {
assert(
productAccess.includes('href="/account?checkout=1"') &&
- productAccess.includes("Subscribe & Activate") &&
- productAccess.includes("立即订阅并激活"),
- "expired terminal gate must send users directly to the checkout entry, not a generic account page",
+ productAccess.includes("Renew and restore access") &&
+ productAccess.includes("续费并恢复访问"),
+ "expired terminal gate must send users directly to the checkout entry with renewal recovery copy, not a generic account page",
);
assert(
diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx
index c7d07c08..d28f4245 100644
--- a/frontend/components/dashboard/ScanTerminalDashboard.tsx
+++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx
@@ -1787,6 +1787,9 @@ function ScanTerminalScreen() {
);
diff --git a/frontend/components/dashboard/scan-terminal/ProductAccessRequired.tsx b/frontend/components/dashboard/scan-terminal/ProductAccessRequired.tsx
index c112b92e..f45172bb 100644
--- a/frontend/components/dashboard/scan-terminal/ProductAccessRequired.tsx
+++ b/frontend/components/dashboard/scan-terminal/ProductAccessRequired.tsx
@@ -1,13 +1,24 @@
"use client";
import Link from "next/link";
-import { LockKeyhole, CreditCard, LogIn } from "lucide-react";
+import {
+ AlertTriangle,
+ CreditCard,
+ LogIn,
+ RefreshCw,
+} from "lucide-react";
const ACCESS_TERM = {
signInToContinue: { en: "Sign in to continue", zh: "请先登录" },
- proAccessRequired: { en: "Pro subscription required", zh: "需要开通 Pro" },
+ proAccessRequired: {
+ en: "Subscription expired",
+ zh: "订阅已过期",
+ },
month: { en: "/ 30 days", zh: "/ 30 天" },
- subscribeNow: { en: "Subscribe & Activate", zh: "立即订阅并激活" },
+ subscribeNow: {
+ en: "Renew and restore access",
+ zh: "续费并恢复访问",
+ },
backToProduct: { en: "Back to product overview", zh: "返回产品介绍页" },
} as const;
@@ -15,8 +26,37 @@ function t(key: keyof typeof ACCESS_TERM, isEn: boolean) {
return isEn ? ACCESS_TERM[key].en : ACCESS_TERM[key].zh;
}
+function isExpiredSubscription(expiresAt?: string | null) {
+ if (!expiresAt) return false;
+ const timestamp = Date.parse(expiresAt);
+ return Number.isFinite(timestamp) && timestamp <= Date.now();
+}
+
+function formatSubscriptionDate(expiresAt: string | null | undefined, isEn: boolean) {
+ if (!expiresAt) return "";
+ const timestamp = Date.parse(expiresAt);
+ if (!Number.isFinite(timestamp)) return "";
+ try {
+ return new Intl.DateTimeFormat(isEn ? "en-US" : "zh-CN", {
+ day: "2-digit",
+ month: "short",
+ year: "numeric",
+ }).format(new Date(timestamp));
+ } catch {
+ return "";
+ }
+}
+
// ─── Layer 2: Authenticated but no active subscription ───────────────────────
-function SubscriptionGate({ isEn }: { isEn: boolean }) {
+function SubscriptionGate({
+ isEn,
+ subscriptionExpiresAt,
+}: {
+ isEn: boolean;
+ subscriptionExpiresAt?: string | null;
+}) {
+ const expired = isExpiredSubscription(subscriptionExpiresAt);
+ const expiryLabel = formatSubscriptionDate(subscriptionExpiresAt, isEn);
const features = isEn
? [
"Real-time station, METAR, and runway signals",
@@ -33,25 +73,57 @@ function SubscriptionGate({ isEn }: { isEn: boolean }) {
return (
-
+
-
-
- {t("proAccessRequired", isEn)}
+
+
+ {expired
+ ? t("proAccessRequired", isEn)
+ : isEn
+ ? "No active subscription"
+ : "无有效订阅"}
-
+
+
+
+
+ {isEn ? "Access paused" : "终端访问已暂停"}
+
+
+ {expired
+ ? isEn
+ ? "Your subscription has expired"
+ : "订阅已过期,终端访问已暂停"
+ : isEn
+ ? "No active Pro subscription"
+ : "未检测到有效订阅"}
+
+
+
+ {expiryLabel
+ ? isEn
+ ? `Expired ${expiryLabel}`
+ : `到期日 ${expiryLabel}`
+ : isEn
+ ? "Action required"
+ : "需要处理"}
+
+
+
+
+
{isEn
- ? "Unlock the Weather Terminal"
- : "解锁天气决策台"}
+ ? "Renew to restore the Weather Terminal"
+ : "续费后恢复天气决策台访问"}
-
+
{isEn
- ? "Your account is verified. One step away from full access."
- : "账号已验证,只差一步即可获得完整访问权限。"}
+ ? "Your account is verified, but it does not currently have an active subscription. Renewing restores live temperature evidence, DEB paths, runway alerts, and paid Telegram/API access."
+ : "你的账号已验证,但当前没有有效订阅。续费后会立即恢复实时温度、DEB 路径、跑道提醒和付费 Telegram/API 权益。"}
@@ -78,16 +150,40 @@ function SubscriptionGate({ isEn }: { isEn: boolean }) {
{t("subscribeNow", isEn)}
+
+
+ {isEn
+ ? "Already paid but still blocked?"
+ : "已有付款但未恢复?"}
+
+
+ window.location.reload()}
+ className="flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-white px-3 py-2.5 text-xs font-black text-slate-700 transition hover:border-blue-300 hover:text-blue-700"
+ >
+
+ {isEn ? "Refresh access" : "刷新权限"}
+
+
+ {isEn ? "Open account center" : "账户中心"}
+
+
+
+
{isEn
- ? "Cancel anytime · No hidden fees · Instant access after payment"
- : "随时取消 · 无隐藏费用 · 付款后立即解锁"}
+ ? "Access resumes automatically after payment confirmation"
+ : "付款确认后会自动恢复访问"}
@@ -166,10 +262,12 @@ function UnauthenticatedGate({
export function ProductAccessRequired({
isAuthenticated,
isEn,
+ subscriptionExpiresAt,
userLocalTime,
}: {
isAuthenticated: boolean;
isEn: boolean;
+ subscriptionExpiresAt?: string | null;
userLocalTime: string;
}) {
if (!isAuthenticated) {
@@ -186,7 +284,10 @@ export function ProductAccessRequired({
{userLocalTime}
-
+
);
diff --git a/frontend/components/dashboard/scan-terminal/__tests__/productAccessRequired.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/productAccessRequired.test.ts
index 591ecddf..b9b9318e 100644
--- a/frontend/components/dashboard/scan-terminal/__tests__/productAccessRequired.test.ts
+++ b/frontend/components/dashboard/scan-terminal/__tests__/productAccessRequired.test.ts
@@ -20,6 +20,10 @@ export function runTests() {
source.indexOf("function UnauthenticatedGate"),
source.indexOf("export function ProductAccessRequired"),
);
+ const subscriptionGate = source.slice(
+ source.indexOf("function SubscriptionGate"),
+ source.indexOf("// ─── Layer 1 fallback"),
+ );
const accessCard = unauthenticatedGate.slice(
unauthenticatedGate.indexOf('