"use client"; import clsx from "clsx"; import Link from "next/link"; import { Activity, BarChart3, Bell, CloudSun, CreditCard, Gauge, LineChart, LockKeyhole, LogIn, Menu, RefreshCw, Search, Table2, UserRound, } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { Area, AreaChart, Bar, BarChart as ReBarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis, } from "recharts"; import type { ProAccessState, ScanOpportunityRow } from "@/lib/dashboard-types"; import { getInitialLocaleFromNavigator } from "@/lib/i18n"; import { isBrowserLocalFullAccess } from "@/lib/local-dev-access"; import { sortRowsByUserTime } from "@/components/dashboard/scan-terminal/decision-utils"; import { useScanTerminalQuery } from "@/components/dashboard/scan-terminal/use-scan-terminal-query"; import { useScanTerminalTheme, useUserLocalClock, } from "@/components/dashboard/scan-terminal/use-scan-terminal-ui-state"; import { useRelativeTime } from "@/hooks/useRelativeTime"; function createEmptyAccess(loading = true): ProAccessState { return { loading, authenticated: false, userId: null, subscriptionActive: false, subscriptionPlanCode: null, subscriptionExpiresAt: null, subscriptionTotalExpiresAt: null, subscriptionQueuedDays: 0, points: 0, error: null, }; } function createLocalAccess(): ProAccessState { return { loading: false, authenticated: true, userId: "local-dev", subscriptionActive: true, subscriptionPlanCode: "local-full-access", subscriptionExpiresAt: "2099-12-31T23:59:59Z", subscriptionTotalExpiresAt: "2099-12-31T23:59:59Z", subscriptionQueuedDays: 0, points: 999_999, error: null, }; } function rowName(row?: ScanOpportunityRow | null) { return row?.city_display_name || row?.display_name || row?.city || "--"; } function pct(value: unknown, digits = 1) { const n = Number(value); if (!Number.isFinite(n)) return "--"; return `${(Math.abs(n) <= 1 ? n * 100 : n).toFixed(digits)}%`; } function money(value: unknown) { const n = Number(value); if (!Number.isFinite(n)) return "--"; return `$${Math.round(n).toLocaleString()}`; } function temp(value: unknown, unit?: string | null) { const n = Number(value); if (!Number.isFinite(n)) return "--"; return `${n.toFixed(1)}${unit || "°"}`; } function edgeClass(value: unknown) { const n = Number(value); if (!Number.isFinite(n) || n === 0) return "text-slate-500"; return n > 0 ? "text-emerald-700" : "text-red-600"; } function decisionLabel(row?: ScanOpportunityRow | null) { const raw = row?.ai_decision || row?.v4_metar_decision || row?.action || row?.signal_status || ""; const value = String(raw || "").toLowerCase(); if (value.includes("approve")) return "Approve"; if (value.includes("veto")) return "Veto"; if (value.includes("watch")) return "Watch"; if (value.includes("downgrade")) return "Downgrade"; if (row?.tradable) return "Tradable"; return "Monitor"; } function decisionClass(label: string) { if (label === "Approve" || label === "Tradable") { return "border-emerald-200 bg-emerald-50 text-emerald-700"; } if (label === "Veto" || label === "Downgrade") { return "border-red-200 bg-red-50 text-red-700"; } return "border-amber-200 bg-amber-50 text-amber-700"; } function SparkArea({ color = "#2563eb", data, }: { color?: string; data: { v: number }[]; }) { if (!data.length) { return (
No data
); } return ( ); } function ProbabilityDistributionChart({ points, }: { points?: ScanOpportunityRow["distribution_preview"]; }) { if (!points?.length) { return (
No distribution data
); } const chartData = points.map((p) => ({ label: p.label || "", model: Number((p.model_probability ?? 0) * 100), market: Number((p.market_probability ?? 0) * 100), })); return ( `${v}%`} /> `${Number(value).toFixed(1)}%` } /> ); } function Panel({ children, className, title, }: { children: React.ReactNode; className?: string; title: string; }) { return (

{title}

{children}
); } function MarketTable({ onSelect, rows, selectedId, }: { onSelect: (row: ScanOpportunityRow) => void; rows: ScanOpportunityRow[]; selectedId?: string | null; }) { return (
{rows.map((row) => { const label = decisionLabel(row); return ( onSelect(row)} > ); })}
City / Contract Live DEB Mkt Edge Liq Signal
{rowName(row)}
{row.target_label || row.market_question || row.airport || "--"}
{temp(row.current_max_so_far ?? row.current_temp, row.temp_symbol)} {temp(row.deb_prediction, row.temp_symbol)} {pct(row.market_probability ?? row.market_event_probability)} {pct(row.edge_percent ?? row.signed_gap ?? row.gap)} {money(row.book_liquidity ?? row.market_liquidity ?? row.volume)} {label}
); } function KoyfinWeatherTerminal({ generatedText, isEn, locale, onRefresh, refreshing, rows, selectedRow, setSelectedRow, toggleLocale, userLocalTime, }: { generatedText: string; isEn: boolean; locale: "zh-CN" | "en-US"; onRefresh: () => void; refreshing: boolean; rows: ScanOpportunityRow[]; selectedRow: ScanOpportunityRow | null; setSelectedRow: (row: ScanOpportunityRow) => void; toggleLocale: () => void; userLocalTime: string; }) { const topRows = rows.slice(0, 18); const approveRows = rows .filter((row) => ["Approve", "Tradable"].includes(decisionLabel(row))) .slice(0, 8); const watchRows = rows .filter((row) => decisionLabel(row) === "Watch" || !row.tradable) .slice(0, 8); const selectedLabel = decisionLabel(selectedRow); const avgEdge = rows.reduce((sum, row) => sum + Number(row.edge_percent || 0), 0) / Math.max(rows.length, 1); const totalLiquidity = rows.reduce( (sum, row) => sum + Number(row.book_liquidity || row.market_liquidity || row.volume || 0), 0, ); return (
Search city, contract, station, or signal /
{isEn ? "Weather Market Dashboard" : "天气市场数据仪表盘"}
{userLocalTime} {/* Language toggle — matches landing page style */}
Rows
{rows.length}
Avg Edge
{pct(avgEdge)}
Liquidity
{money(totalLiquidity)}
{(approveRows.length ? approveRows : topRows.slice(0, 5)).map((row) => ( ))}

{rowName(selectedRow)}

{selectedLabel}

{selectedRow?.ai_city_thesis_en || selectedRow?.ai_reason_en || selectedRow?.market_question || "Select a weather contract to inspect model edge, market price, and live evidence."}

{[ ["Live", temp(selectedRow?.current_max_so_far ?? selectedRow?.current_temp, selectedRow?.temp_symbol)], ["DEB", temp(selectedRow?.deb_prediction, selectedRow?.temp_symbol)], ["Model", pct(selectedRow?.model_probability ?? selectedRow?.model_event_probability)], ["Market", pct(selectedRow?.market_probability ?? selectedRow?.market_event_probability)], ].map(([label, value]) => (
{label}
{value}
))}
Intraday Performance
= 0 ? "#059669" : "#dc2626" } data={ selectedRow?.distribution_preview?.map((p) => ({ v: typeof p === "number" ? p : p.model_probability ?? 0, })) || [] } />
Edge {pct(selectedRow?.edge_percent)} Spread {pct(selectedRow?.spread)}
{(watchRows.length ? watchRows : topRows.slice(0, 8)).map((row) => ( ))}
{[ ["Heat", rows.filter((r) => r.risk_level === "high").length], ["Active", rows.filter((r) => r.active).length], ["Tradable", rows.filter((r) => r.tradable).length], ["Primary", rows.filter((r) => r.is_primary_signal).length], ["AI", rows.filter((r) => r.ai_decision).length], ["Closed", rows.filter((r) => r.closed).length], ].map(([label, value]) => (
{value}
{label}
))}
Data {generatedText || "live"}
Access paid
Layout Koyfin-style grid
); } // ─── 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, }: { isEn: boolean; userLocalTime: string; }) { return (
); } /** 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), ); const [locale, setLocale] = useState<"zh-CN" | "en-US">("zh-CN"); const isEn = locale === "en-US"; const toggleLocale = () => setLocale((prev) => (prev === "zh-CN" ? "en-US" : "zh-CN")); const [hydrated, setHydrated] = useState(false); const [localFullAccess, setLocalFullAccess] = useState(false); const canUseLocalFullAccess = hydrated && localFullAccess; const isAuthenticated = hydrated && (proAccess.authenticated || canUseLocalFullAccess); const isPro = hydrated && (proAccess.subscriptionActive || canUseLocalFullAccess); const userLocalTime = useUserLocalClock(); useScanTerminalTheme(); useEffect(() => { let cancelled = false; setHydrated(true); setLocale(getInitialLocaleFromNavigator()); const localAccess = isBrowserLocalFullAccess(); setLocalFullAccess(localAccess); if (localAccess) { setProAccess(createLocalAccess()); return () => { cancelled = true; }; } if (typeof fetch !== "function") { setProAccess(createEmptyAccess(false)); return () => { cancelled = true; }; } fetch("/api/auth/me", { cache: "no-store", headers: { Accept: "application/json" }, }) .then(async (response) => { if (!response.ok) throw new Error(`HTTP ${response.status}`); return response.json() as Promise<{ authenticated?: boolean; user_id?: string | null; subscription_active?: boolean | null; subscription_plan_code?: string | null; subscription_expires_at?: string | null; subscription_total_expires_at?: string | null; subscription_queued_days?: number | null; points?: number | null; }>; }) .then((payload) => { if (cancelled) return; setProAccess({ loading: false, authenticated: Boolean(payload.authenticated), userId: payload.user_id ?? null, subscriptionActive: payload.subscription_active === true, subscriptionPlanCode: payload.subscription_plan_code ?? null, subscriptionExpiresAt: payload.subscription_expires_at ?? null, subscriptionTotalExpiresAt: payload.subscription_total_expires_at ?? payload.subscription_expires_at ?? null, subscriptionQueuedDays: Math.max( 0, Number(payload.subscription_queued_days ?? 0), ), points: Number(payload.points ?? 0), error: null, }); }) .catch((error) => { if (cancelled) return; setProAccess({ ...createEmptyAccess(false), error: String(error), }); }); return () => { cancelled = true; }; }, []); const { refreshScanTerminalManually, scanLoading, terminalData } = useScanTerminalQuery({ isPro, proAccessLoading: !hydrated || (proAccess.loading && !canUseLocalFullAccess), }); const rows = useMemo( () => sortRowsByUserTime(terminalData?.rows || []), [terminalData?.rows], ); const [selectedId, setSelectedId] = useState(null); const selectedRow = useMemo( () => rows.find((row) => row.id === selectedId) || rows[0] || null, [rows, selectedId], ); const generatedText = useRelativeTime(terminalData?.generated_at ?? null); if (!hydrated || (proAccess.loading && !canUseLocalFullAccess)) { return (
Loading paid terminal...
); } if (!isAuthenticated || !isPro) { return ( ); } return ( setSelectedId(row.id)} toggleLocale={toggleLocale} userLocalTime={userLocalTime} /> ); } export function ScanTerminalDashboard() { return ; }