From 0574b0cc20d94450dbe4b0619ecd6da938f16eb1 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Mon, 25 May 2026 01:21:44 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8F=8C=E5=B1=82=E9=89=B4=E6=9D=83=E6=9E=B6?= =?UTF-8?q?=E6=9E=84=EF=BC=9A=E4=B8=AD=E9=97=B4=E4=BB=B6=E7=BB=88=E7=AB=AF?= =?UTF-8?q?=E9=97=A8=E6=8E=A7=20+=20=E8=90=BD=E5=9C=B0=E9=A1=B5=E6=8C=89?= =?UTF-8?q?=E9=92=AE=E7=9B=B4=E8=BF=9E=E7=99=BB=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LandingPage "进入产品"按钮改为 /auth/login?next=/terminal,消除未登录直达终端 再跳登录的视觉卡顿。Middleware 新增 handleTerminalGate(Layer 1), 在 /terminal 路由独立拦截未认证用户并 302 到登录页。 ScanTerminalDashboard 内 ProductAccessRequired 重构为 SubscriptionGate(Layer 2), 专注展示升级订阅提示。 Constraint: 双层顺序为认证优先 → 订阅检查在后 Tested: tsc --noEmit 通过, npm run build 通过 --- .../dashboard/ScanTerminalDashboard.tsx | 209 ++++++++++++++---- .../landing/InstitutionalLandingPage.tsx | 2 +- frontend/middleware.ts | 52 +++++ 3 files changed, 215 insertions(+), 48 deletions(-) diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index a63b11cf..3a9a2371 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -643,12 +643,111 @@ function KoyfinWeatherTerminal({ ); } -function ProductAccessRequired({ - isAuthenticated, +// ─── Layer 2: Authenticated but no active subscription ─────────────────────── +// Shown inside the terminal shell after middleware has confirmed the user is +// logged in (Layer 1). Presents a targeted upgrade prompt. +function SubscriptionGate({ isEn }: { isEn: boolean }) { + const features = isEn + ? [ + "Real-time METAR observations across 500+ stations", + "DEB forecast blends with 0–240h horizon", + "AI decision cards with Poly-score ranking", + "Historical backtesting & weather market signals", + ] + : [ + "500+ 气象站实时 METAR 实况", + "DEB 智能融合预测(0–240 小时)", + "AI 决策卡片 + Poly-score 排名", + "历史回测与天气市场交易信号", + ]; + + return ( +
+
+ {/* Header badge */} +
+ + + {isEn ? "Pro Access Required" : "需要付费订阅"} + +
+ + {/* Main card */} +
+ {/* Card top band */} +
+

+ {isEn + ? "Unlock the Weather Terminal" + : "解锁天气交易决策台"} +

+

+ {isEn + ? "Your account is verified. One step away from full access." + : "账号已验证,只差一步即可获得完整访问权限。"} +

+
+ +
+ {/* Price */} +
+ $10 + + {isEn ? "/ month" : "/ 月"} + +
+ + {/* Feature list */} +
    + {features.map((f) => ( +
  • + + + + + + {f} +
  • + ))} +
+ + {/* CTA */} + + + {isEn ? "Subscribe Now — $10/mo" : "立即订阅 — $10/月"} + + +

+ {isEn + ? "Cancel anytime · No hidden fees · Instant access after payment" + : "随时取消 · 无隐藏费用 · 付款后立即解锁"} +

+
+
+ + {/* Back link */} +
+ + ← {isEn ? "Back to product overview" : "返回产品介绍页"} + +
+
+
+ ); +} + +// ─── Layer 1 fallback: Should not normally appear (middleware handles it) ───── +// Shown only when middleware is not configured (no Supabase env). +function UnauthenticatedGate({ isEn, userLocalTime, }: { - isAuthenticated: boolean; isEn: boolean; userLocalTime: string; }) { @@ -664,53 +763,37 @@ function ProductAccessRequired({
{userLocalTime}
-
-
- +
+
+
-

- {isAuthenticated - ? isEn - ? "Subscription required" - : "需要开通订阅" - : isEn - ? "Sign in and subscribe to enter" - : "登录并付费后进入产品"} +

+ {isEn ? "Sign in to continue" : "请先登录"}

-

- {isAuthenticated - ? isEn - ? "Your account is signed in, but the Koyfin-style weather terminal is locked until payment is active." - : "你已登录,但 Koyfin 风格天气交易终端会在付款生效后解锁。" - : isEn - ? "The landing page is public. The decision terminal is paid-only." - : "落地页公开展示;天气交易决策台仅向付费用户开放。"} +

+ {isEn + ? "The weather terminal is for verified subscribers only." + : "天气决策台仅对已验证的付费用户开放。"}

-
- {isAuthenticated ? ( - - - {isEn ? "Subscribe in Account" : "去账户中心付费"} - - ) : ( - - - {isEn ? "Log in to continue" : "登录后继续"} - - )} - - {isEn ? "Product overview" : "产品介绍"} - -
+ + + {isEn ? "Log in" : "登录"} + + + {isEn ? "Create an account" : "注册账号"} + + + {isEn ? "← Learn about PolyWeather" : "← 了解 PolyWeather"} +
@@ -718,6 +801,38 @@ function ProductAccessRequired({ ); } +/** Unified access gate — routes to the correct layer based on auth state */ +function ProductAccessRequired({ + isAuthenticated, + isEn, + userLocalTime, +}: { + isAuthenticated: boolean; + isEn: boolean; + userLocalTime: string; +}) { + // Layer 1 fallback (middleware should catch this first in production) + if (!isAuthenticated) { + return ; + } + // Layer 2: logged in, no subscription + return ( +
+
+ ); +} + function ScanTerminalScreen() { const [proAccess, setProAccess] = useState(() => createEmptyAccess(true), diff --git a/frontend/components/landing/InstitutionalLandingPage.tsx b/frontend/components/landing/InstitutionalLandingPage.tsx index de751d92..0ea546e7 100644 --- a/frontend/components/landing/InstitutionalLandingPage.tsx +++ b/frontend/components/landing/InstitutionalLandingPage.tsx @@ -157,7 +157,7 @@ function InstitutionalLandingScreen() { {isEn ? "Sign Up" : "注册"} {isEn ? "Enter Product" : "进入产品"} diff --git a/frontend/middleware.ts b/frontend/middleware.ts index 4f9b9ffc..ba705d21 100644 --- a/frontend/middleware.ts +++ b/frontend/middleware.ts @@ -55,6 +55,44 @@ function shouldRefreshOptionalSupabaseSession(pathname: string) { ); } +// ─── Layer 1: Unauthenticated redirect for /terminal ───────────────────────── +// Runs for every /terminal request when Supabase is configured. +// Does NOT check subscription — that's handled client-side (Layer 2). +// This mirrors Koyfin: unauthenticated visitors are sent to /auth/login first. +async function handleTerminalGate(request: NextRequest): Promise { + const { pathname } = request.nextUrl; + + // Only gate /terminal routes + if (!pathname.startsWith("/terminal")) { + return NextResponse.next(); + } + + // No Supabase env → fall through to legacy token gate + if (!hasSupabaseServerEnv()) { + return NextResponse.next(); + } + + const response = NextResponse.next({ + request: { headers: request.headers }, + }); + const supabase = createSupabaseMiddlewareClient(request, response); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (user) { + // Authenticated — pass through. Terminal client handles subscription gate. + return response; + } + + // Layer 1: Not logged in → redirect to /auth/login?next=/terminal + const loginUrl = request.nextUrl.clone(); + loginUrl.pathname = "/auth/login"; + loginUrl.search = ""; + loginUrl.searchParams.set("next", pathname); + return NextResponse.redirect(loginUrl); +} + function handleLegacyTokenGate(request: NextRequest) { const requiredToken = process.env.POLYWEATHER_DASHBOARD_ACCESS_TOKEN?.trim(); if (!requiredToken) { @@ -160,6 +198,8 @@ export async function middleware(request: NextRequest) { request.headers.get("x-forwarded-host") || request.headers.get("host") || request.nextUrl.host; + + // Local development: bypass all gates if ( isLocalFullAccessHost(requestHost) || isLocalFullAccessHost(request.nextUrl.hostname) @@ -167,6 +207,17 @@ export async function middleware(request: NextRequest) { return NextResponse.next(); } + const { pathname } = request.nextUrl; + + // ── Terminal gate runs first, independently of global auth mode ────────── + // This is the Koyfin-style Layer 1: send unauthenticated users to /auth/login + // before they ever reach the terminal, eliminating the jarring "enter product + // then see a paywall" experience. + if (pathname.startsWith("/terminal") && hasSupabaseServerEnv()) { + return handleTerminalGate(request); + } + + // ── Global auth modes ───────────────────────────────────────────────────── if (SUPABASE_AUTH_ENABLED && hasSupabaseServerEnv()) { if (SUPABASE_AUTH_REQUIRED) { return handleSupabaseAuthGate(request); @@ -180,6 +231,7 @@ export const config = { matcher: [ "/account/:path*", "/terminal/:path*", + "/terminal", "/ops/:path*", "/api/auth/:path*", "/api/ops/:path*",