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*",