2026-04-23 23:49:52 +08:00
"use client" ;
import clsx from "clsx" ;
2026-04-28 20:19:17 +08:00
import dynamic from "next/dynamic" ;
2026-05-14 15:05:13 +08:00
import { RefreshCw } from "lucide-react" ;
2026-05-19 14:22:43 +08:00
import { useCallback , useEffect , useMemo , useRef , useState } from "react" ;
2026-04-23 23:49:52 +08:00
import styles from "./Dashboard.module.css" ;
2026-05-10 14:21:10 +08:00
import { scanRootClass } from "./scan-root-styles" ;
2026-04-24 00:34:09 +08:00
import {
DashboardStoreProvider ,
useDashboardStore ,
2026-05-14 15:05:13 +08:00
useProAccess ,
2026-04-24 00:34:09 +08:00
} from "@/hooks/useDashboardStore" ;
2026-04-23 23:49:52 +08:00
import { I18nProvider , useI18n } from "@/hooks/useI18n" ;
2026-05-19 14:22:43 +08:00
import type { ScanOpportunityRow } from "@/lib/dashboard-types" ;
2026-04-26 09:13:18 +08:00
import { AiPinnedForecastView } from "@/components/dashboard/scan-terminal/AiPinnedForecastView" ;
2026-05-17 17:05:47 +08:00
import { MobileCityPicker } from "@/components/dashboard/scan-terminal/MobileCityPicker" ;
2026-05-10 16:19:21 +08:00
import { WelcomeOverlay } from "@/components/dashboard/scan-terminal/WelcomeOverlay" ;
2026-05-20 09:41:44 +08:00
import { ProFeaturePaywall } from "@/components/dashboard/ProFeaturePaywall" ;
2026-05-14 15:05:13 +08:00
import {
ScanPaywallModal ,
ScanTerminalLoadingScreen ,
ScanTerminalTopBar ,
type ScanTerminalContentView ,
} from "@/components/dashboard/scan-terminal/ScanTerminalShellParts" ;
2026-04-28 09:06:55 +08:00
import { findDetailForCity } from "@/components/dashboard/scan-terminal/city-detail-utils" ;
2026-04-25 04:25:17 +08:00
import {
2026-04-26 09:13:18 +08:00
findRowForCity ,
normalizeCityKey ,
rowMatchesCity ,
sortRowsByUserTime ,
} from "@/components/dashboard/scan-terminal/decision-utils" ;
2026-04-28 09:06:55 +08:00
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" ;
2026-05-14 21:31:05 +08:00
import { useRelativeTime } from "@/hooks/useRelativeTime" ;
2026-05-14 22:36:19 +08:00
2026-04-28 20:19:17 +08:00
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 },
);
2026-04-23 23:49:52 +08:00
function ScanTerminalScreen() {
2026-04-24 00:34:09 +08:00
const store = useDashboardStore ();
2026-05-14 15:05:13 +08:00
const { proAccess } = useProAccess ();
2026-04-24 00:34:09 +08:00
const { locale , toggleLocale } = useI18n ();
2026-04-23 23:49:52 +08:00
const isEn = locale === "en-US" ;
2026-05-14 15:05:13 +08:00
const isPro = proAccess . subscriptionActive ;
const accountHref = proAccess . authenticated
2026-04-24 00:34:09 +08:00
? "/account"
: "/auth/login?next=%2Faccount" ;
2026-05-19 14:22:43 +08:00
const { refreshScanTerminalManually , scanError , scanLoading , terminalData } =
useScanTerminalQuery ({
isPro ,
proAccessLoading : proAccess.loading ,
});
2026-04-23 23:49:52 +08:00
const [ selectedRowId , setSelectedRowId ] = useState < string | null >( null );
2026-05-14 15:05:13 +08:00
const [ activeView , setActiveView ] = useState < ScanTerminalContentView >( "map" );
2026-05-19 14:22:43 +08:00
const [ mapSelectedCityName , setMapSelectedCityName ] = useState < string | null >(
null ,
);
2026-04-25 00:19:30 +08:00
const [ showScanPaywall , setShowScanPaywall ] = useState ( false );
2026-05-15 12:52:39 +08:00
const [ isMobileViewport , setIsMobileViewport ] = useState ( false );
2026-05-06 18:59:20 +08:00
useEffect (() => {
2026-05-15 12:52:39 +08:00
if ( typeof window === "undefined" || ! window . matchMedia ) return ;
const media = window . matchMedia ( "(max-width: 768px)" );
2026-05-17 15:32:47 +08:00
const syncMobileViewport = () => {
setIsMobileViewport ( media . matches );
if ( media . matches ) {
setActiveView (( current ) => ( current === "map" ? "city-list" : current ));
2026-05-17 17:05:47 +08:00
} else {
setActiveView (( current ) => ( current === "city-list" ? "map" : current ));
2026-05-17 15:32:47 +08:00
}
};
2026-05-15 12:52:39 +08:00
syncMobileViewport ();
media . addEventListener ( "change" , syncMobileViewport );
return () => media . removeEventListener ( "change" , syncMobileViewport );
}, []);
2026-04-28 09:06:55 +08:00
const userLocalTime = useUserLocalClock ();
const { setThemeMode , themeMode } = useScanTerminalTheme ();
2026-04-26 00:24:11 +08:00
const lastMapSelectedCityRef = useRef < string >( "" );
2026-05-14 21:31:05 +08:00
const lastFetchedAtRef = useRef < number >( 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 ]);
2026-04-28 14:45:34 +08:00
const scanTerminalRootClassName = clsx (
styles . root ,
2026-05-10 14:21:10 +08:00
scanRootClass ,
2026-04-28 14:45:34 +08:00
themeMode === "light" && "light" ,
);
2026-04-26 10:22:11 +08:00
2026-04-24 05:09:19 +08:00
const timeSortedRows = useMemo (
2026-04-26 00:24:11 +08:00
() => sortRowsByUserTime ( terminalData ? . rows || []),
[ terminalData ? . rows ],
2026-04-24 05:09:19 +08:00
);
2026-05-18 23:50:18 +08:00
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 ]);
2026-04-28 09:06:55 +08:00
const {
addAiPinnedCity ,
aiPinnedCities ,
refreshAiPinnedCityDetail ,
removeAiPinnedCity ,
} = useAiPinnedCityWorkspace ({
locale ,
store ,
timeSortedRows ,
});
2026-04-23 23:49:52 +08:00
const selectedRow = useMemo (() => {
2026-04-24 05:09:19 +08:00
if ( ! timeSortedRows . length ) return null ;
2026-05-19 14:22:43 +08:00
return (
timeSortedRows . find (( row ) => row . id === selectedRowId ) ||
timeSortedRows [ 0 ] ||
null
);
2026-04-24 05:09:19 +08:00
}, [ timeSortedRows , selectedRowId ]);
2026-04-23 23:49:52 +08:00
2026-04-26 09:13:18 +08:00
useEffect (() => {
if ( ! timeSortedRows . length ) return ;
2026-05-19 14:22:43 +08:00
if ( selectedRowId && timeSortedRows . some (( row ) => row . id === selectedRowId ))
return ;
2026-04-26 09:13:18 +08:00
setSelectedRowId ( timeSortedRows [ 0 ]. id );
}, [ selectedRowId , timeSortedRows ]);
2026-04-24 05:39:14 +08:00
const mapFocusedRow = useMemo (() => {
2026-04-24 13:09:31 +08:00
return findRowForCity (
timeSortedRows ,
mapSelectedCityName || store . selectedCity ,
2026-04-24 05:39:14 +08:00
);
}, [ mapSelectedCityName , store . selectedCity , timeSortedRows ]);
2026-04-24 13:09:31 +08:00
const mapFallbackRow = useMemo (() => {
const rawCityName = mapSelectedCityName || store . selectedCity ;
const cityKey = normalizeCityKey ( rawCityName );
if ( ! cityKey || mapFocusedRow ) return null ;
const selectedDetail =
2026-05-19 14:22:43 +08:00
store . selectedDetail &&
normalizeCityKey ( store . selectedDetail . name ) === cityKey
2026-04-24 13:09:31 +08:00
? 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 ,
2026-05-19 14:22:43 +08:00
local_time :
selectedDetail?.local_time || selectedSummary ? . local_time || null ,
2026-04-24 13:09:31 +08:00
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 ,
2026-04-24 13:43:05 +08:00
market_question : isEn ? "City briefing" : "城市简报" ,
2026-04-24 13:09:31 +08:00
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 ,
]);
2026-05-14 15:05:13 +08:00
const resolvedView : ScanTerminalContentView = activeView ;
2026-04-24 05:09:19 +08:00
const mapFocusedCity = mapSelectedCityName || store . selectedCity ;
2026-04-24 13:09:31 +08:00
const activeDetailRow =
resolvedView === "map" && mapFocusedCity
? mapFocusedRow || mapFallbackRow
: selectedRow ;
2026-04-26 00:24:11 +08:00
const scanStatus = terminalData ? . status || "ready" ;
const staleReason = terminalData ? . stale_reason || null ;
2026-05-18 23:25:16 +08:00
const proPreviewItems = isEn
? [
2026-05-19 15:10:35 +08:00
"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" ,
2026-05-18 23:25:16 +08:00
]
: [
2026-05-19 15:10:35 +08:00
"52 城实时机场报文与官方观测站数据" ,
"多模型 DEB 融合预测 vs 市场隐含温度" ,
"概率分布桶对照 Polymarket 合约" ,
"实时观测偏差与错价信号" ,
"当前日与未来结算日的城市决策卡" ,
2026-05-18 23:25:16 +08:00
"未来日期城市决策卡" ,
];
2026-04-24 05:39:14 +08:00
useEffect (() => {
if ( ! activeDetailRow ) return ;
2026-04-26 08:12:31 +08:00
if ( ! findDetailForCity ( store . cityDetailsByName , activeDetailRow . city )) {
2026-05-19 14:22:43 +08:00
void store
. ensureCityDetail ( activeDetailRow . city , false , "panel" )
. catch (() => {});
2026-04-24 06:31:29 +08:00
}
2026-04-24 13:35:36 +08:00
}, [ activeDetailRow , store . cityDetailsByName , store . ensureCityDetail ]);
2026-04-24 05:39:14 +08:00
2026-05-19 14:22:43 +08:00
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 );
}
2026-05-20 09:41:44 +08:00
void store . selectCity ( cityName );
addAiPinnedCity ( cityName );
setActiveView ( "analysis" );
2026-05-19 14:22:43 +08:00
},
2026-05-20 09:41:44 +08:00
[ store , timeSortedRows , addAiPinnedCity ],
2026-05-19 14:22:43 +08:00
);
2026-04-26 00:24:11 +08:00
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 ]);
2026-04-24 05:39:14 +08:00
2026-05-19 14:22:43 +08:00
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 ;
2026-04-25 06:42:24 +08:00
}
2026-05-19 14:22:43 +08:00
void store . selectCity ( cityName );
},
[ store ],
);
2026-04-24 03:30:55 +08:00
2026-05-19 14:22:43 +08:00
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 ],
);
2026-04-26 09:13:18 +08:00
2026-04-25 00:19:30 +08:00
const openScanPaywall = useCallback (() => {
setShowScanPaywall ( true );
}, []);
2026-04-24 03:30:55 +08:00
const renderMainView = () => {
2026-05-17 15:32:47 +08:00
if ( resolvedView === "city-list" ) {
return (
2026-05-17 17:05:47 +08:00
< MobileCityPicker
isEn = { isEn }
2026-05-18 23:50:18 +08:00
rows = { cityListRows }
2026-05-20 09:41:44 +08:00
onSelectCity = { handleOpenDecisionRow }
2026-05-17 17:05:47 +08:00
/>
2026-05-17 15:32:47 +08:00
);
}
2026-05-17 21:20:17 +08:00
// 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 (
<>
< div
className = "scan-map-view"
style = {{ display : resolvedView === "map" ? undefined : "none" }}
>
< div className = "scan-map-shell" >
< MapCanvas
onCitySelect = { handleMapCitySelect }
selectionMode = "select"
/>
</ div >
</ div >
2026-05-20 09:41:44 +08:00
{ resolvedView === "analysis" ? (
isPro ? (
< AiPinnedForecastView
items = { aiPinnedCities }
rows = { timeSortedRows }
detailsByName = { store . cityDetailsByName }
locale = { locale }
onRefreshCityDetail = { refreshAiPinnedCityDetail }
onRemoveCity = { removeAiPinnedCity }
/>
) : (
< div className = "scan-ai-workspace empty" >
< ProFeaturePaywall feature = "scan" />
</ div >
)
2026-05-17 21:20:17 +08:00
) : null }
</>
);
2026-04-24 03:30:55 +08:00
};
2026-04-23 23:49:52 +08:00
2026-05-14 15:05:13 +08:00
if ( proAccess . loading ) {
2026-04-24 23:32:32 +08:00
return (
2026-05-14 15:05:13 +08:00
< ScanTerminalLoadingScreen
isEn = { isEn }
rootClassName = { scanTerminalRootClassName }
themeMode = { themeMode }
userLocalTime = { userLocalTime }
/>
2026-04-24 23:32:32 +08:00
);
}
2026-04-23 23:49:52 +08:00
return (
2026-04-28 14:45:34 +08:00
< div className = { scanTerminalRootClassName }>
2026-04-29 20:07:31 +08:00
< div
className = { clsx (
"scan-terminal" ,
2026-05-17 15:32:47 +08:00
resolvedView === "city-list" && "city-list-view-active" ,
2026-04-29 20:07:31 +08:00
resolvedView === "map" && "map-view-active" ,
2026-05-06 15:26:37 +08:00
resolvedView !== "map" && "focus-view-active" ,
2026-04-30 08:05:10 +08:00
resolvedView === "analysis" && "analysis-view-active" ,
2026-04-29 20:07:31 +08:00
themeMode === "light" && "light" ,
)}
>
2026-04-23 23:49:52 +08:00
< main className = "scan-data-grid" >
2026-05-14 15:05:13 +08:00
< ScanTerminalTopBar
accountHref = { accountHref }
isAuthenticated = { proAccess . authenticated }
isEn = { isEn }
isPro = { isPro }
locale = { locale }
onOpenScanPaywall = { openScanPaywall }
setThemeMode = { setThemeMode }
themeMode = { themeMode }
toggleLocale = { toggleLocale }
userLocalTime = { userLocalTime }
/>
2026-04-23 23:49:52 +08:00
2026-05-18 23:25:16 +08:00
{ ! isPro ? (
< section
className = "scan-upgrade-announcement"
aria-label = { isEn ? "Pro preview" : "Pro 能力预览" }
>
< div className = "scan-upgrade-announcement-copy" >
2026-05-19 15:10:35 +08:00
< span >{ isEn ? "PolyWeather Pro" : "PolyWeather Pro" }</ span >
2026-05-18 23:25:16 +08:00
< strong >
{ isEn
2026-05-19 15:10:35 +08:00
? "Weather intelligence for temperature settlement markets."
: "面向温度结算市场的气象情报系统。" }
2026-05-18 23:25:16 +08:00
</ strong >
< p >
{ isEn
2026-05-19 15:10:35 +08:00
? "Turn weather data into trading decisions. Compare multi-model forecasts against live observations, identify mispriced Polymarket contracts, and back your trades with calibrated probability distributions."
: "将气象数据转化为交易决策。多模型预报对照实时观测,识别 Polymarket 错价合约,用校准概率分布支撑每一笔交易。" }
2026-05-18 23:25:16 +08:00
</ p >
</ div >
< ul >
{ proPreviewItems . map (( item ) => (
< li key = { item }>{ item }</ li >
))}
</ ul >
{ proAccess . authenticated ? (
< button
type = "button"
className = "scan-primary-button"
onClick = { openScanPaywall }
>
{ isEn ? "View Pro" : "查看 Pro" }
</ button >
) : (
< a href = { accountHref } className = "scan-primary-button" >
{ isEn ? "Sign in for Pro" : "登录查看 Pro" }
</ a >
)}
</ section >
) : null }
2026-04-24 00:13:11 +08:00
< section className = "scan-list-section" >
< div className = "scan-list-header" >
2026-05-19 14:22:43 +08:00
< div
className = "scan-list-tabs"
role = "tablist"
aria-label = { isEn ? "Content view" : "内容视图" }
>
2026-05-17 17:05:47 +08:00
{ isMobileViewport ? (
< button
type = "button"
role = "tab"
aria-selected = { resolvedView === "city-list" }
className = { resolvedView === "city-list" ? "active" : "" }
onClick = {() => {
setActiveView ( "city-list" );
}}
>
{ isEn ? "City List" : "城市列表" }
</ button >
) : (
< button
type = "button"
role = "tab"
aria-selected = { resolvedView === "map" }
className = { resolvedView === "map" ? "active" : "" }
onClick = {() => {
2026-05-19 14:22:43 +08:00
lastMapSelectedCityRef . current = normalizeCityKey (
store . selectedCity ,
);
2026-05-17 17:05:47 +08:00
setActiveView ( "map" );
}}
>
{ isEn ? "Distribution View" : "分布视图" }
</ button >
)}
2026-05-20 09:41:44 +08:00
< button
type = "button"
role = "tab"
aria-selected = { resolvedView === "analysis" }
className = { resolvedView === "analysis" ? "active" : "" }
onClick = {() => {
setActiveView ( "analysis" );
}}
>
{ isEn ? "Decision Cards" : "城市决策卡" }
{ ! isPro && (
< span className = "scan-lock-icon" style = {{ marginLeft : "4.5px" , fontSize : "13.5px" }}>
🔒
</ span >
)}
</ button >
2026-04-24 00:13:11 +08:00
</ div >
2026-04-24 10:19:16 +08:00
< div className = "scan-list-status" >
2026-04-27 00:30:51 +08:00
{ terminalData ? . generated_at ? (
2026-05-19 14:22:43 +08:00
< span
className = { clsx (
"scan-status-chip" ,
terminalData ? . stale ? "stale" : "live" ,
)}
>
2026-05-14 21:31:05 +08:00
{ isEn ? "Updated" : "已更新" } { serverAgeText || "" }
2026-04-27 00:30:51 +08:00
</ span >
) : null }
2026-05-14 21:31:05 +08:00
{ terminalData ? . stale && localAgeText ? (
2026-04-24 10:19:16 +08:00
< span className = "scan-status-chip stale" >
2026-05-19 14:22:43 +08:00
{ isEn ? "Local fetch " : "本地下发 " }
{ localAgeText }
2026-04-24 10:19:16 +08:00
</ span >
) : null }
2026-04-27 00:30:51 +08:00
{ isPro ? (
< button
type = "button"
className = "scan-status-chip refresh"
onClick = { refreshScanTerminalManually }
disabled = { scanLoading }
title = {
2026-05-19 14:22:43 +08:00
isEn ? "Force refresh decision cards" : "强制刷新决策卡"
2026-04-27 00:30:51 +08:00
}
>
2026-05-19 14:22:43 +08:00
< RefreshCw
size = { 14 }
className = { scanLoading ? "spin" : undefined }
/>
2026-04-27 00:30:51 +08:00
{ isEn ? "Refresh" : "刷新" }
</ button >
) : null }
2026-04-24 00:13:11 +08:00
</ div >
2026-04-23 23:49:52 +08:00
</ div >
2026-04-24 00:13:11 +08:00
2026-04-24 10:19:16 +08:00
{ scanStatus === "failed" && ! terminalData ? (
2026-04-24 00:13:11 +08:00
< div className = "scan-empty-state" >
< div className = "scan-empty-title" >
{ isEn ? "Scan failed" : "扫描失败" }
</ div >
2026-04-24 10:19:16 +08:00
< div className = "scan-empty-copy" >{ staleReason }</ div >
2026-05-10 16:13:34 +08:00
< button
type = "button"
className = "scan-retry-button"
onClick = {() => refreshScanTerminalManually ()}
>
< RefreshCw size = { 13 } />
{ isEn ? "Retry" : "重试" }
</ button >
2026-04-24 00:13:11 +08:00
</ div >
) : (
2026-04-26 00:24:11 +08:00
renderMainView ()
2026-04-24 00:13:11 +08:00
)}
</ section >
2026-04-23 23:49:52 +08:00
</ main >
2026-04-24 13:35:36 +08:00
< CityDetailPanel variant = "rail" />
2026-04-23 23:49:52 +08:00
</ div >
2026-05-10 16:19:21 +08:00
< WelcomeOverlay locale = { locale } onDismiss = {() => {}} />
2026-04-24 13:47:12 +08:00
< FutureForecastModal />
2026-04-25 00:19:30 +08:00
{ showScanPaywall ? (
2026-05-14 15:05:13 +08:00
< ScanPaywallModal
isEn = { isEn }
onClose = {() => setShowScanPaywall ( false )}
/>
2026-04-25 00:19:30 +08:00
) : null }
2026-04-23 23:49:52 +08:00
</ div >
);
}
export function ScanTerminalDashboard() {
return (
< I18nProvider >
< DashboardStoreProvider >
< ScanTerminalScreen />
</ DashboardStoreProvider >
</ I18nProvider >
);
}