"use client"; import clsx from "clsx"; import dynamic from "next/dynamic"; import { RefreshCw } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import styles from "./Dashboard.module.css"; import { scanRootClass } from "./scan-root-styles"; import { DashboardStoreProvider, useDashboardStore, useProAccess, } from "@/hooks/useDashboardStore"; import { I18nProvider, useI18n } from "@/hooks/useI18n"; import type { ScanOpportunityRow } from "@/lib/dashboard-types"; import { AiPinnedForecastView } from "@/components/dashboard/scan-terminal/AiPinnedForecastView"; import { MobileCityPicker } from "@/components/dashboard/scan-terminal/MobileCityPicker"; import { WelcomeOverlay } from "@/components/dashboard/scan-terminal/WelcomeOverlay"; import { ProFeaturePaywall } from "@/components/dashboard/ProFeaturePaywall"; import { ScanPaywallModal, ScanTerminalLoadingScreen, ScanTerminalTopBar, type ScanTerminalContentView, } from "@/components/dashboard/scan-terminal/ScanTerminalShellParts"; import { findDetailForCity } from "@/components/dashboard/scan-terminal/city-detail-utils"; import { findRowForCity, normalizeCityKey, rowMatchesCity, sortRowsByUserTime, } from "@/components/dashboard/scan-terminal/decision-utils"; import { useAiPinnedCityWorkspace } from "@/components/dashboard/scan-terminal/use-ai-pinned-city-workspace"; 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"; const CityDetailPanel = dynamic( () => import("@/components/dashboard/DetailPanel").then( (module) => module.DetailPanel, ), { ssr: false }, ); const FutureForecastModal = dynamic( () => import("@/components/dashboard/FutureForecastModal").then( (module) => module.FutureForecastModal, ), { ssr: false }, ); const MapCanvas = dynamic( () => import("@/components/dashboard/MapCanvas").then( (module) => module.MapCanvas, ), { ssr: false }, ); function ScanTerminalScreen() { const store = useDashboardStore(); const { proAccess } = useProAccess(); const { locale, toggleLocale } = useI18n(); const isEn = locale === "en-US"; const isPro = proAccess.subscriptionActive; const accountHref = proAccess.authenticated ? "/account" : "/auth/login?next=%2Faccount"; const { refreshScanTerminalManually, scanError, scanLoading, terminalData } = useScanTerminalQuery({ isPro, proAccessLoading: proAccess.loading, }); const [selectedRowId, setSelectedRowId] = useState(null); const [activeView, setActiveView] = useState("map"); const [mapSelectedCityName, setMapSelectedCityName] = useState( null, ); const [showScanPaywall, setShowScanPaywall] = useState(false); const [isMobileViewport, setIsMobileViewport] = useState(false); useEffect(() => { if (typeof window === "undefined" || !window.matchMedia) return; const media = window.matchMedia("(max-width: 768px)"); const syncMobileViewport = () => { setIsMobileViewport(media.matches); if (media.matches) { setActiveView((current) => (current === "map" ? "city-list" : current)); } else { setActiveView((current) => (current === "city-list" ? "map" : current)); } }; syncMobileViewport(); media.addEventListener("change", syncMobileViewport); return () => media.removeEventListener("change", syncMobileViewport); }, []); const userLocalTime = useUserLocalClock(); const { setThemeMode, themeMode } = useScanTerminalTheme(); const lastMapSelectedCityRef = useRef(""); const lastFetchedAtRef = useRef(0); const serverAgeText = useRelativeTime(terminalData?.generated_at ?? null); const localAgeText = useRelativeTime( lastFetchedAtRef.current ? new Date(lastFetchedAtRef.current).toISOString() : null, ); useEffect(() => { if (terminalData?.generated_at) { lastFetchedAtRef.current = Date.now(); } }, [terminalData?.generated_at]); const scanTerminalRootClassName = clsx( styles.root, scanRootClass, themeMode === "light" && "light", ); const timeSortedRows = useMemo( () => sortRowsByUserTime(terminalData?.rows || []), [terminalData?.rows], ); const cityListRows = useMemo(() => { if (timeSortedRows.length > 0) return timeSortedRows; return store.cities.map((city, index) => { const cityKey = normalizeCityKey(city.name); const summary = store.citySummariesByName[cityKey] ?? Object.values(store.citySummariesByName).find( (s) => normalizeCityKey(s?.name) === cityKey, ) ?? null; return { id: `city-fallback:${cityKey}:${index}`, city: cityKey, city_display_name: city.display_name || city.name, display_name: city.display_name || city.name, temp_symbol: city.temp_unit === "fahrenheit" ? "°F" : "°C", current_temp: summary?.current?.temp ?? null, current_max_so_far: summary?.current?.temp ?? null, deb_prediction: summary?.deb?.prediction ?? null, airport: city.airport || null, local_time: summary?.local_time ?? null, risk_level: city.risk_level || "low", market_slug: null, market_question: null, target_label: null, side: null, edge_percent: null, final_score: null, window_phase: null, tradable: false, active: false, closed: false, accepting_orders: false, } satisfies ScanOpportunityRow; }); }, [timeSortedRows, store.cities, store.citySummariesByName]); const { addAiPinnedCity, aiPinnedCities, refreshAiPinnedCityDetail, removeAiPinnedCity, } = useAiPinnedCityWorkspace({ locale, store, timeSortedRows, }); const selectedRow = useMemo(() => { if (!timeSortedRows.length) return null; return ( timeSortedRows.find((row) => row.id === selectedRowId) || timeSortedRows[0] || null ); }, [timeSortedRows, selectedRowId]); useEffect(() => { if (!timeSortedRows.length) return; if (selectedRowId && timeSortedRows.some((row) => row.id === selectedRowId)) return; setSelectedRowId(timeSortedRows[0].id); }, [selectedRowId, timeSortedRows]); const mapFocusedRow = useMemo(() => { return findRowForCity( timeSortedRows, mapSelectedCityName || store.selectedCity, ); }, [mapSelectedCityName, store.selectedCity, timeSortedRows]); const mapFallbackRow = useMemo(() => { const rawCityName = mapSelectedCityName || store.selectedCity; const cityKey = normalizeCityKey(rawCityName); if (!cityKey || mapFocusedRow) return null; const selectedDetail = store.selectedDetail && normalizeCityKey(store.selectedDetail.name) === cityKey ? store.selectedDetail : Object.values(store.cityDetailsByName).find( (detail) => normalizeCityKey(detail?.name) === cityKey, ) || null; const selectedSummary = Object.values(store.citySummariesByName).find( (summary) => normalizeCityKey(summary?.name) === cityKey, ) || null; const selectedCityItem = store.cities.find( (city) => normalizeCityKey(city.name) === cityKey || normalizeCityKey(city.display_name) === cityKey, ) || null; const canonicalCity = selectedDetail?.name || selectedSummary?.name || selectedCityItem?.name || String(rawCityName || "").trim(); if (!canonicalCity) return null; const tempSymbol = selectedDetail?.temp_symbol || selectedSummary?.temp_symbol || (selectedCityItem?.temp_unit === "fahrenheit" ? "°F" : "°C"); const displayName = selectedDetail?.display_name || selectedSummary?.display_name || selectedCityItem?.display_name || canonicalCity; const currentTemp = selectedDetail?.current?.temp ?? selectedSummary?.current?.temp ?? null; return { id: `map-city:${canonicalCity}`, city: canonicalCity, city_display_name: displayName, display_name: displayName, selected_date: selectedDetail?.local_date || null, local_date: selectedDetail?.local_date || null, local_time: selectedDetail?.local_time || selectedSummary?.local_time || null, temp_symbol: tempSymbol, current_temp: currentTemp, current_max_so_far: selectedDetail?.current?.max_so_far ?? currentTemp ?? null, deb_prediction: selectedDetail?.deb?.prediction ?? selectedSummary?.deb?.prediction ?? null, airport: selectedDetail?.risk?.airport || selectedCityItem?.airport || selectedCityItem?.settlement_station_label || null, risk_level: selectedDetail?.risk?.level || selectedSummary?.risk?.level || selectedCityItem?.risk_level || "low", market_slug: null, market_question: isEn ? "City briefing" : "城市简报", target_label: isEn ? "City snapshot" : "城市概况", side: null, edge_percent: null, final_score: null, window_phase: "city_snapshot", tradable: false, active: false, closed: false, accepting_orders: false, } satisfies ScanOpportunityRow; }, [ isEn, mapFocusedRow, mapSelectedCityName, store.cityDetailsByName, store.citySummariesByName, store.cities, store.selectedCity, store.selectedDetail, ]); const resolvedView: ScanTerminalContentView = activeView; const mapFocusedCity = mapSelectedCityName || store.selectedCity; const activeDetailRow = resolvedView === "map" && mapFocusedCity ? mapFocusedRow || mapFallbackRow : selectedRow; const scanStatus = terminalData?.status || "ready"; const staleReason = terminalData?.stale_reason || null; const proPreviewItems = isEn ? [ "Real-time METAR & official station data for 52 cities", "Multi-model DEB blend vs market-implied temperature", "Probability buckets mapped to Polymarket contracts", "Live observation deviation & mispricing signals", "City decision cards for current & future settlement dates", ] : [ "52 城实时机场报文与官方观测站数据", "多模型 DEB 融合预测 vs 市场隐含温度", "概率分布桶对照 Polymarket 合约", "实时观测偏差与错价信号", "当前日与未来结算日的城市决策卡", "未来日期城市决策卡", ]; useEffect(() => { if (!activeDetailRow) return; if (!findDetailForCity(store.cityDetailsByName, activeDetailRow.city)) { void store .ensureCityDetail(activeDetailRow.city, false, "panel") .catch(() => {}); } }, [activeDetailRow, store.cityDetailsByName, store.ensureCityDetail]); const handleMapCitySelect = useCallback( (cityName: string) => { setMapSelectedCityName(cityName); lastMapSelectedCityRef.current = normalizeCityKey(cityName); const matchedRow = findRowForCity(timeSortedRows, cityName); if (matchedRow) { store.preloadCityFromRow(matchedRow); setSelectedRowId(matchedRow.id); } else { void store.ensureCityDetail(cityName, false, "panel").catch(() => {}); setSelectedRowId(null); } void store.selectCity(cityName); addAiPinnedCity(cityName); setActiveView("analysis"); }, [store, timeSortedRows, addAiPinnedCity], ); useEffect(() => { if (activeView !== "map") return; const selectedCity = String(store.selectedCity || "").trim(); const selectedKey = normalizeCityKey(selectedCity); if (!selectedKey || selectedKey === lastMapSelectedCityRef.current) return; lastMapSelectedCityRef.current = selectedKey; setMapSelectedCityName(selectedCity); const matchedRow = findRowForCity(timeSortedRows, selectedCity); setSelectedRowId(matchedRow?.id || null); addAiPinnedCity(selectedCity); }, [activeView, addAiPinnedCity, store.selectedCity, timeSortedRows]); const handleSelectRow = useCallback( (row: ScanOpportunityRow) => { const cityName = row.city || row.city_display_name || row.display_name || ""; if (!cityName) return; setSelectedRowId(row.id); store.preloadCityFromRow(row); const selectedCityKey = normalizeCityKey(store.selectedCity); const rowCityKey = normalizeCityKey(cityName); const hasCachedDetail = Boolean(findDetailForCity(store.cityDetailsByName, cityName)) || Object.values(store.cityDetailsByName).some((detail) => rowMatchesCity(row, detail?.name || detail?.display_name || ""), ); if (store.isPanelOpen && selectedCityKey === rowCityKey) { if (!hasCachedDetail) { void store.ensureCityDetail(cityName, false, "panel").catch(() => {}); } return; } void store.selectCity(cityName); }, [store], ); const handleOpenDecisionRow = useCallback( (row: ScanOpportunityRow) => { const cityName = row.city || row.city_display_name || row.display_name || ""; if (!cityName) return; setSelectedRowId(row.id); store.preloadCityFromRow(row); addAiPinnedCity(cityName); setActiveView("analysis"); void store.selectCity(cityName); }, [addAiPinnedCity, store], ); const openScanPaywall = useCallback(() => { setShowScanPaywall(true); }, []); const renderMainView = () => { if (resolvedView === "city-list") { return ( ); } // Keep MapCanvas always mounted — hiding with CSS avoids Leaflet // reinitialization that causes a white background on tab switches. // The analysis view overlays on top when active. return ( <>
{resolvedView === "analysis" ? ( isPro ? ( ) : (
) ) : null} ); }; if (proAccess.loading) { return ( ); } return (
{isMobileViewport ? ( ) : ( )}
{terminalData?.generated_at ? ( {isEn ? "Updated" : "已更新"} {serverAgeText || ""} ) : null} {terminalData?.stale && localAgeText ? ( {isEn ? "Local fetch " : "本地下发 "} {localAgeText} ) : null} {isPro ? ( ) : null}
{scanStatus === "failed" && !terminalData ? (
{isEn ? "Scan failed" : "扫描失败"}
{staleReason}
) : ( renderMainView() )}
{}} /> {showScanPaywall ? ( setShowScanPaywall(false)} /> ) : null}
); } export function ScanTerminalDashboard() { return ( ); }