重构首页为机构落地页,新增终端路由,时区感知 Polymarket 市场发现

This commit is contained in:
2569718930@qq.com
2026-05-25 00:16:53 +08:00
parent 45e9c28437
commit e4d123d94d
23 changed files with 1232 additions and 623 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

+1 -1
View File
@@ -20,7 +20,7 @@ const jetbrainsMono = JetBrains_Mono({
export const metadata: Metadata = { export const metadata: Metadata = {
title: "PolyWeather | Weather Intelligence", title: "PolyWeather | Weather Intelligence",
description: description:
"PolyWeather pro dashboard with global weather risk map and city analytics.", "PolyWeather paid professional terminal for weather-market intelligence, city decision cards, and subscription-only analytics.",
manifest: "/site.webmanifest", manifest: "/site.webmanifest",
icons: { icons: {
icon: [ icon: [
+4 -4
View File
@@ -1,11 +1,11 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { DashboardEntry } from "@/components/dashboard/DashboardEntry"; import { InstitutionalLandingPage } from "@/components/landing/InstitutionalLandingPage";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "PolyWeather - Global Weather Intelligence Map", title: "PolyWeather | Institutional Weather Market Intelligence",
description: description:
"PolyWeather dashboard with METAR, MGM, DEB fusion forecast, multi-model comparison, and AI weather decision cards.", "PolyWeather is a paid professional weather-market intelligence terminal with METAR evidence, DEB forecast blending, and AI decision cards.",
}; };
export default async function HomePage({ export default async function HomePage({
@@ -29,5 +29,5 @@ export default async function HomePage({
const qs = usp.toString(); const qs = usp.toString();
redirect(`/auth/callback${qs ? `?${qs}` : ""}`); redirect(`/auth/callback${qs ? `?${qs}` : ""}`);
} }
return <DashboardEntry />; return <InstitutionalLandingPage />;
} }
+12
View File
@@ -0,0 +1,12 @@
import type { Metadata } from "next";
import { ScanTerminalDashboard } from "@/components/dashboard/ScanTerminalDashboard";
export const metadata: Metadata = {
title: "PolyWeather Terminal | Paid Product",
description:
"Paid PolyWeather decision terminal for weather-market analysis and city decision cards.",
};
export default function TerminalPage() {
return <ScanTerminalDashboard />;
}
+2 -2
View File
@@ -7,7 +7,7 @@ export function createAccountCopy(isEn: boolean): Record<string, string> {
signOut: isEn ? "Sign Out" : "退出", signOut: isEn ? "Sign Out" : "退出",
signIn: isEn ? "Sign In" : "登录", signIn: isEn ? "Sign In" : "登录",
upgradePro: isEn ? "Upgrade Pro" : "升级 Pro", upgradePro: isEn ? "Upgrade Pro" : "升级 Pro",
guestUser: isEn ? "Guest User" : "游客用户", guestUser: isEn ? "Signed-out account" : "未登录账户",
joinedAt: isEn ? "Joined" : "加入时间", joinedAt: isEn ? "Joined" : "加入时间",
totalPoints: isEn ? "Total Points" : "总积分 (荣誉)", totalPoints: isEn ? "Total Points" : "总积分 (荣誉)",
weeklyPoints: isEn ? "Weekly Points" : "本周积分 (竞技)", weeklyPoints: isEn ? "Weekly Points" : "本周积分 (竞技)",
@@ -129,7 +129,7 @@ export function createAccountCopy(isEn: boolean): Record<string, string> {
? "Wallet bound. Creating order and sending payment..." ? "Wallet bound. Creating order and sending payment..."
: "钱包已绑定,正在创建订单并发起支付...", : "钱包已绑定,正在创建订单并发起支付...",
proMember: "PRO MEMBER", proMember: "PRO MEMBER",
freeTier: "FREE TIER", freeTier: isEn ? "UNSUBSCRIBED" : "未订阅",
proPendingSync: isEn ? "Activated (pending sync)" : "已开通(待同步)", proPendingSync: isEn ? "Activated (pending sync)" : "已开通(待同步)",
noProSubscription: isEn ? "No Pro subscription" : "暂无 Pro 订阅", noProSubscription: isEn ? "No Pro subscription" : "暂无 Pro 订阅",
proEndsSoonTitle: isEn ? "Pro renewal due soon" : "Pro 即将到期", proEndsSoonTitle: isEn ? "Pro renewal due soon" : "Pro 即将到期",
+13 -1
View File
@@ -4,7 +4,19 @@ import { useEffect } from "react";
export function RegisterSW() { export function RegisterSW() {
useEffect(() => { useEffect(() => {
if ("serviceWorker" in navigator) { if (process.env.NODE_ENV !== "production") {
if (typeof navigator !== "undefined" && "serviceWorker" in navigator) {
navigator.serviceWorker
.getRegistrations()
.then((registrations) =>
Promise.all(registrations.map((registration) => registration.unregister())),
)
.catch(() => {});
}
return;
}
if (typeof navigator !== "undefined" && "serviceWorker" in navigator) {
navigator.serviceWorker.register("/sw.js").catch(() => {}); navigator.serviceWorker.register("/sw.js").catch(() => {});
} }
}, []); }, []);
File diff suppressed because it is too large Load Diff
@@ -102,12 +102,12 @@ export function AiPinnedForecastView({
<div className="scan-ai-workspace empty"> <div className="scan-ai-workspace empty">
<div className="scan-empty-state"> <div className="scan-empty-state">
<div className="scan-empty-title"> <div className="scan-empty-title">
{isEn ? "Click a city on the map" : "从分布视图点击城市"} {isEn ? "Select a city from the market list" : "从市场列表选择城市"}
</div> </div>
<div className="scan-empty-copy"> <div className="scan-empty-copy">
{isEn {isEn
? "Selected cities will appear here as deep analysis blocks." ? "Selected cities will appear here as deep analysis blocks."
: "被点击的城市会加入深度分析页,并保留为城市分析区块。"} : "选中的城市会加入深度分析页,并保留为城市分析区块。"}
</div> </div>
</div> </div>
</div> </div>
@@ -127,8 +127,8 @@ export function AiPinnedForecastView({
</div> </div>
<p> <p>
{isEn {isEn
? "Map clicks add cities here. City analysis stays here until you remove it." ? "Market-list selections add cities here. City analysis stays here until you remove it."
: "地图点击会把城市加入这里;城市分析会保留,直到你手动移除。"} : "市场列表选择会把城市加入这里;城市分析会保留,直到你手动移除。"}
</p> </p>
</div> </div>
<div className="scan-ai-city-jumpbar" aria-label={isEn ? "Selected city shortcuts" : "已选城市快捷跳转"}> <div className="scan-ai-city-jumpbar" aria-label={isEn ? "Selected city shortcuts" : "已选城市快捷跳转"}>
@@ -3,12 +3,12 @@
import clsx from "clsx"; import clsx from "clsx";
import Link from "next/link"; import Link from "next/link";
import type { Dispatch, SetStateAction } from "react"; import type { Dispatch, SetStateAction } from "react";
import { LogIn, MessageCircle, Moon, Sun, UserRound } from "lucide-react"; import { LogIn, UserRound } from "lucide-react";
import { ProFeaturePaywall } from "@/components/dashboard/ProFeaturePaywall"; import { ProFeaturePaywall } from "@/components/dashboard/ProFeaturePaywall";
import { LoadingSignal } from "@/components/dashboard/scan-terminal/LoadingSignal"; import { LoadingSignal } from "@/components/dashboard/scan-terminal/LoadingSignal";
import type { Locale } from "@/lib/i18n"; import type { Locale } from "@/lib/i18n";
export type ScanTerminalContentView = "city-list" | "analysis" | "map"; export type ScanTerminalContentView = "city-list" | "analysis";
type ThemeMode = "dark" | "light"; type ThemeMode = "dark" | "light";
@@ -34,8 +34,8 @@ export function ScanTerminalLoadingScreen({
</strong> </strong>
<span> <span>
{isEn {isEn
? "Start from the map, then open city cards to verify weather evidence" ? "Loading paid decision cards, city evidence, and market signals"
: "从地图选城市,再打开决策卡验证天气证据"} : "正在加载付费决策卡、城市证据和市场信号"}
</span> </span>
</div> </div>
<div className="scan-topbar-actions"> <div className="scan-topbar-actions">
@@ -91,8 +91,8 @@ export function ScanTerminalTopBar({
</strong> </strong>
<span> <span>
{isEn {isEn
? "Start from the map, then open city cards to verify weather evidence" ? "Paid workspace for city evidence, model signals, and weather-market decisions"
: "从地图选城市,再打开决策卡验证天气证据"} : "面向付费用户的城市证据、模型信号与天气市场决策工作区"}
</span> </span>
</div> </div>
<div className="scan-topbar-actions"> <div className="scan-topbar-actions">
@@ -37,6 +37,9 @@ export function useScanTerminalQuery({
showLoading?: boolean; showLoading?: boolean;
} = {}) => { } = {}) => {
if (proAccessLoading || !isPro) return; if (proAccessLoading || !isPro) return;
if (typeof fetch !== "function" || typeof AbortController === "undefined") {
return;
}
if (forceRefresh) { if (forceRefresh) {
lastForcedScanRefreshAtRef.current = Date.now(); lastForcedScanRefreshAtRef.current = Date.now();
} }
@@ -75,6 +78,13 @@ export function useScanTerminalQuery({
useEffect(() => { useEffect(() => {
if (proAccessLoading || !isPro) return; if (proAccessLoading || !isPro) return;
if (
typeof window === "undefined" ||
typeof window.setInterval !== "function" ||
typeof window.clearInterval !== "function"
) {
return;
}
const intervalId = window.setInterval(() => { const intervalId = window.setInterval(() => {
if ( if (
!shouldRunAutoTerminalRefresh({ !shouldRunAutoTerminalRefresh({
@@ -10,6 +10,13 @@ export function useUserLocalClock() {
useEffect(() => { useEffect(() => {
setUserLocalTime(formatUserLocalTime()); setUserLocalTime(formatUserLocalTime());
if (
typeof window === "undefined" ||
typeof window.setInterval !== "function" ||
typeof window.clearInterval !== "function"
) {
return;
}
const intervalId = window.setInterval(() => { const intervalId = window.setInterval(() => {
setUserLocalTime(formatUserLocalTime()); setUserLocalTime(formatUserLocalTime());
}, 10_000); }, 10_000);
@@ -0,0 +1,283 @@
import Link from "next/link";
import {
Activity,
ArrowRight,
BarChart3,
CheckCircle2,
CloudSun,
Gauge,
LineChart,
LockKeyhole,
Radar,
ShieldCheck,
TrendingUp,
} from "lucide-react";
const marketRows = [
["New York", "91.8°F", "+2.4", "High", "Long Yes"],
["Austin", "103.1°F", "+1.1", "Medium", "Wait"],
["Seoul", "83.4°F", "-0.7", "Low", "No Trade"],
["Tokyo", "88.2°F", "+1.8", "High", "Long Yes"],
["London", "72.6°F", "-0.2", "Low", "Observe"],
];
const coverage = [
"Live airport observations",
"DEB blend forecast",
"Market-implied temperature",
"Intraday settlement windows",
"AI weather evidence",
"Paid Telegram alerts",
];
const platformCards = [
{
icon: Radar,
title: "Live Evidence",
body: "Airport observations and official station data are structured for settlement-aware decisions.",
},
{
icon: Gauge,
title: "Decision Workflow",
body: "City cards combine model forecast, current deviation, risk, and target contract context.",
},
{
icon: ShieldCheck,
title: "Paid Access",
body: "The product workspace is locked until the user has an active subscription.",
},
];
export function InstitutionalLandingPage() {
return (
<div className="min-h-screen bg-[#f4f7fb] text-slate-950">
<header className="sticky top-0 z-30 border-b border-slate-200 bg-white/95 backdrop-blur">
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-4 sm:px-6 lg:px-8">
<Link href="/" className="flex items-center gap-2 font-bold">
<span className="grid h-9 w-9 place-items-center rounded-lg bg-blue-600 text-white">
<CloudSun size={20} />
</span>
<span>PolyWeather</span>
</Link>
<nav className="hidden items-center gap-7 text-sm font-semibold text-slate-600 md:flex">
<a href="#platform" className="hover:text-slate-950">
Platform
</a>
<a href="#coverage" className="hover:text-slate-950">
Data Coverage
</a>
<a href="#pricing" className="hover:text-slate-950">
Pricing
</a>
</nav>
<div className="flex items-center gap-2">
<Link
href="/auth/login?next=%2Fterminal"
className="hidden rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm font-semibold text-slate-700 shadow-sm transition hover:border-slate-400 hover:text-slate-950 sm:inline-flex"
>
Log in
</Link>
<Link
href="/terminal"
className="inline-flex items-center gap-2 rounded-lg border border-blue-700 bg-blue-600 px-3 py-2 text-sm font-bold text-white shadow-sm transition hover:bg-blue-700"
>
Enter Product
<ArrowRight size={15} />
</Link>
</div>
</div>
</header>
<main>
<section className="mx-auto grid min-h-[calc(100vh-64px)] max-w-7xl items-center gap-10 px-4 py-12 sm:px-6 lg:grid-cols-[0.9fr_1.1fr] lg:px-8">
<div>
<div className="mb-5 inline-flex items-center gap-2 rounded-full border border-blue-200 bg-blue-50 px-3 py-1 text-xs font-bold uppercase text-blue-700">
<LockKeyhole size={13} />
Paid professional terminal
</div>
<h1 className="max-w-2xl text-4xl font-black leading-[1.05] tracking-normal text-slate-950 sm:text-5xl lg:text-6xl">
Institutional weather market intelligence for paid users.
</h1>
<p className="mt-5 max-w-xl text-base leading-8 text-slate-600 sm:text-lg">
PolyWeather turns live METAR observations, DEB forecast blends,
model probabilities, and market settlement logic into one
professional decision workspace.
</p>
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
<Link
href="/terminal"
className="inline-flex min-h-11 items-center justify-center gap-2 rounded-lg border border-blue-700 bg-blue-600 px-5 py-3 text-sm font-bold text-white shadow-sm transition hover:bg-blue-700"
>
Enter product
<ArrowRight size={16} />
</Link>
<Link
href="/account"
className="inline-flex min-h-11 items-center justify-center gap-2 rounded-lg border border-slate-300 bg-white px-5 py-3 text-sm font-bold text-slate-800 shadow-sm transition hover:border-slate-400 hover:text-slate-950"
>
Subscribe / Manage account
</Link>
</div>
<p className="mt-4 text-xs font-medium text-slate-500">
No free product access. Subscription is required before the
terminal opens.
</p>
</div>
<div className="rounded-2xl border border-slate-300 bg-white shadow-[0_24px_80px_rgba(15,23,42,0.16)]">
<div className="flex items-center justify-between border-b border-slate-200 px-4 py-3">
<div className="flex items-center gap-2 text-sm font-bold">
<BarChart3 size={16} className="text-blue-700" />
Weather Markets Dashboard
</div>
<div className="flex items-center gap-2 text-xs font-semibold text-slate-500">
<span className="h-2 w-2 rounded-full bg-emerald-500" />
Live
</div>
</div>
<div className="grid gap-3 p-3 lg:grid-cols-[1fr_0.85fr]">
<div className="rounded-xl border border-slate-200">
<div className="flex items-center justify-between border-b border-slate-200 bg-slate-50 px-3 py-2">
<strong className="text-sm">Temperature Contracts</strong>
<span className="text-xs font-semibold text-slate-500">
Price / Edge / Signal
</span>
</div>
<div className="divide-y divide-slate-100">
{marketRows.map((row) => (
<div
key={row[0]}
className="grid grid-cols-[1.1fr_0.8fr_0.6fr_0.7fr_0.9fr] items-center gap-2 px-3 py-3 text-sm"
>
<span className="font-semibold">{row[0]}</span>
<span className="font-mono text-slate-700">{row[1]}</span>
<span
className={
row[2].startsWith("+")
? "font-mono font-bold text-emerald-700"
: "font-mono font-bold text-red-600"
}
>
{row[2]}
</span>
<span className="text-xs font-bold text-slate-500">
{row[3]}
</span>
<span className="rounded bg-blue-50 px-2 py-1 text-xs font-bold text-blue-700">
{row[4]}
</span>
</div>
))}
</div>
</div>
<div className="space-y-3">
<div className="rounded-xl border border-slate-200 p-4">
<div className="mb-3 flex items-center justify-between">
<strong className="text-sm">Model Stack</strong>
<LineChart size={16} className="text-blue-700" />
</div>
<div className="space-y-3">
{["DEB Blend", "Live METAR", "Market Implied"].map(
(label, index) => (
<div key={label}>
<div className="mb-1 flex justify-between text-xs font-semibold text-slate-500">
<span>{label}</span>
<span>{[82, 67, 74][index]}%</span>
</div>
<div className="h-2 rounded-full bg-slate-100">
<div
className="h-2 rounded-full bg-blue-600"
style={{ width: `${[82, 67, 74][index]}%` }}
/>
</div>
</div>
),
)}
</div>
</div>
<div className="rounded-xl border border-emerald-200 bg-emerald-50 p-4">
<div className="mb-2 flex items-center gap-2 text-sm font-bold text-emerald-800">
<TrendingUp size={16} />
Current Signal
</div>
<p className="text-sm leading-6 text-emerald-900">
New York high-temperature market shows a positive
observation deviation with confirmed airport evidence.
</p>
</div>
</div>
</div>
</div>
</section>
<section id="platform" className="border-y border-slate-200 bg-white">
<div className="mx-auto grid max-w-7xl gap-4 px-4 py-10 sm:px-6 md:grid-cols-3 lg:px-8">
{platformCards.map(({ body, icon: Icon, title }) => (
<article
key={title}
className="rounded-xl border border-slate-200 bg-slate-50 p-5"
>
<Icon className="mb-4 text-blue-700" size={22} />
<h2 className="text-lg font-bold">{title}</h2>
<p className="mt-2 text-sm leading-6 text-slate-600">{body}</p>
</article>
))}
</div>
</section>
<section id="coverage" className="mx-auto max-w-7xl px-4 py-14 sm:px-6 lg:px-8">
<div className="mb-7 flex flex-col justify-between gap-3 md:flex-row md:items-end">
<div>
<p className="text-xs font-bold uppercase text-blue-700">
Data Coverage
</p>
<h2 className="mt-2 text-3xl font-black">
Everything weather-market users need in one place.
</h2>
</div>
<p className="max-w-xl text-sm leading-6 text-slate-600">
Built for repeat professional use: dense tables, clear status
chips, restrained color, and fast entry into paid workflows.
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{coverage.map((item) => (
<div
key={item}
className="flex items-center gap-3 rounded-xl border border-slate-200 bg-white p-4 text-sm font-semibold shadow-sm"
>
<CheckCircle2 size={17} className="text-emerald-600" />
{item}
</div>
))}
</div>
</section>
<section id="pricing" className="bg-slate-950 text-white">
<div className="mx-auto flex max-w-7xl flex-col justify-between gap-6 px-4 py-12 sm:px-6 md:flex-row md:items-center lg:px-8">
<div>
<p className="text-xs font-bold uppercase text-blue-300">
Subscription Required
</p>
<h2 className="mt-2 text-3xl font-black">
Product access starts after payment.
</h2>
<p className="mt-3 max-w-2xl text-sm leading-6 text-slate-300">
Users can read the landing page and account/payment guide
publicly, but the decision terminal opens only for active paid
accounts.
</p>
</div>
<Link
href="/account"
className="inline-flex min-h-11 items-center justify-center gap-2 rounded-lg border border-white/20 bg-white px-5 py-3 text-sm font-bold text-slate-950 transition hover:bg-blue-50"
>
<Activity size={16} />
Subscribe now
</Link>
</div>
</section>
</main>
</div>
);
}
+30 -6
View File
@@ -154,7 +154,16 @@ type StoredProAccessState = ProAccessState & {
}; };
function wait(ms: number) { function wait(ms: number) {
return new Promise((resolve) => window.setTimeout(resolve, ms)); return new Promise((resolve) => {
if (
typeof window !== "undefined" &&
typeof window.setTimeout === "function"
) {
window.setTimeout(resolve, ms);
return;
}
resolve(undefined);
});
} }
function getSubscriptionExpiryMs(access: Pick< function getSubscriptionExpiryMs(access: Pick<
@@ -1147,6 +1156,12 @@ export function DashboardStoreProvider({
} }
}; };
} }
if (
typeof window.setTimeout !== "function" ||
typeof window.clearTimeout !== "function"
) {
return;
}
const timer = window.setTimeout(schedule, 2000); const timer = window.setTimeout(schedule, 2000);
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [cities.length]); }, [cities.length]);
@@ -1351,10 +1366,15 @@ export function DashboardStoreProvider({
useEffect(() => { useEffect(() => {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
if (selectedCity) { try {
window.localStorage.setItem(SELECTED_CITY_STORAGE_KEY, selectedCity); if (!window.localStorage) return;
} else { if (selectedCity) {
window.localStorage.removeItem(SELECTED_CITY_STORAGE_KEY); window.localStorage.setItem(SELECTED_CITY_STORAGE_KEY, selectedCity);
} else {
window.localStorage.removeItem(SELECTED_CITY_STORAGE_KEY);
}
} catch {
// Storage can be unavailable in embedded/private browser contexts.
} }
}, [selectedCity]); }, [selectedCity]);
@@ -1368,7 +1388,11 @@ export function DashboardStoreProvider({
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
hydratedSelectionRef.current = true; hydratedSelectionRef.current = true;
window.localStorage.removeItem(SELECTED_CITY_STORAGE_KEY); try {
window.localStorage?.removeItem(SELECTED_CITY_STORAGE_KEY);
} catch {
// Storage can be unavailable in embedded/private browser contexts.
}
}, [cities, selectedCity]); }, [cities, selectedCity]);
const refreshSelectedCity = async () => { const refreshSelectedCity = async () => {
+18 -4
View File
@@ -23,12 +23,26 @@ export function useRelativeTime(isoString: string | null | undefined): string {
useEffect(() => { useEffect(() => {
if (!date) return; if (!date) return;
const interval = setInterval(() => setTick((n) => n + 1), 30_000); const canUseInterval =
typeof window !== "undefined" &&
typeof window.setInterval === "function" &&
typeof window.clearInterval === "function";
const canUseVisibility =
typeof document !== "undefined" &&
typeof document.addEventListener === "function" &&
typeof document.removeEventListener === "function";
const interval = canUseInterval
? window.setInterval(() => setTick((n) => n + 1), 30_000)
: null;
const onVisible = () => setTick((n) => n + 1); const onVisible = () => setTick((n) => n + 1);
document.addEventListener("visibilitychange", onVisible); if (canUseVisibility) {
document.addEventListener("visibilitychange", onVisible);
}
return () => { return () => {
clearInterval(interval); if (interval != null) window.clearInterval(interval);
document.removeEventListener("visibilitychange", onVisible); if (canUseVisibility) {
document.removeEventListener("visibilitychange", onVisible);
}
}; };
}, [date]); }, [date]);
+2 -1
View File
@@ -54,9 +54,10 @@ export function getAnalyticsSessionId() {
export function markAnalyticsOnce(key: string, scope: "local" | "session" = "session") { export function markAnalyticsOnce(key: string, scope: "local" | "session" = "session") {
if (!isClient()) return false; if (!isClient()) return false;
const storage = scope === "local" ? window.localStorage : window.sessionStorage;
const normalizedKey = `polyweather:analytics:once:${key}`; const normalizedKey = `polyweather:analytics:once:${key}`;
try { try {
const storage = scope === "local" ? window.localStorage : window.sessionStorage;
if (!storage) return true;
if (storage.getItem(normalizedKey) === "1") { if (storage.getItem(normalizedKey) === "1") {
return false; return false;
} }
+34 -10
View File
@@ -58,8 +58,14 @@ async function fetchJson<T>(
options?: { cache?: RequestCache; timeoutMs?: number }, options?: { cache?: RequestCache; timeoutMs?: number },
): Promise<T> { ): Promise<T> {
const timeoutMs = options?.timeoutMs; const timeoutMs = options?.timeoutMs;
const controller = timeoutMs ? new AbortController() : null; const controller =
const timeoutId = controller timeoutMs && typeof AbortController !== "undefined"
? new AbortController()
: null;
const timeoutId =
controller &&
typeof window !== "undefined" &&
typeof window.setTimeout === "function"
? window.setTimeout(() => controller.abort(), timeoutMs) ? window.setTimeout(() => controller.abort(), timeoutMs)
: null; : null;
const headers = await buildBrowserBackendHeaders({ const headers = await buildBrowserBackendHeaders({
@@ -80,7 +86,9 @@ async function fetchJson<T>(
throw error; throw error;
} finally { } finally {
if (timeoutId != null) { if (timeoutId != null) {
window.clearTimeout(timeoutId); if (typeof window.clearTimeout === "function") {
window.clearTimeout(timeoutId);
}
} }
} }
@@ -222,7 +230,11 @@ function readLegacyCache(raw: string): CityCacheBundle {
export const dashboardClient = { export const dashboardClient = {
clearCityDetailCache() { clearCityDetailCache() {
if (!isClient()) return; if (!isClient()) return;
window.sessionStorage.removeItem(CACHE_KEY); try {
window.sessionStorage?.removeItem(CACHE_KEY);
} catch {
// Storage can be unavailable in embedded/private browser contexts.
}
}, },
async getCities() { async getCities() {
@@ -232,13 +244,24 @@ export const dashboardClient = {
sendPriorityWarmHint(timezone?: string | null) { sendPriorityWarmHint(timezone?: string | null) {
if (!isClient()) return; if (!isClient()) return;
if (
typeof fetch !== "function" ||
typeof Intl === "undefined" ||
!window.sessionStorage
) {
return;
}
const tz = String( const tz = String(
timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || "", timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || "",
).trim(); ).trim();
if (!tz) return; if (!tz) return;
const cacheKey = `${PRIORITY_WARM_SESSION_KEY}:${tz}`; const cacheKey = `${PRIORITY_WARM_SESSION_KEY}:${tz}`;
if (window.sessionStorage.getItem(cacheKey)) return; try {
window.sessionStorage.setItem(cacheKey, "1"); if (window.sessionStorage.getItem(cacheKey)) return;
window.sessionStorage.setItem(cacheKey, "1");
} catch {
return;
}
const params = new URLSearchParams({ timezone: tz }); const params = new URLSearchParams({ timezone: tz });
void fetch(`/api/system/priority-warm?${params.toString()}`, { void fetch(`/api/system/priority-warm?${params.toString()}`, {
method: "POST", method: "POST",
@@ -532,10 +555,11 @@ export const dashboardClient = {
const entries = Object.fromEntries( const entries = Object.fromEntries(
topEntries.map((e) => [e.cityName, { cachedAt: e.cachedAt, detail: e.detail, revision: e.revision }]), topEntries.map((e) => [e.cityName, { cachedAt: e.cachedAt, detail: e.detail, revision: e.revision }]),
); );
window.sessionStorage.setItem( try {
CACHE_KEY, window.sessionStorage?.setItem(CACHE_KEY, JSON.stringify({ entries }));
JSON.stringify({ entries }), } catch {
); // Storage can be unavailable in embedded/private browser contexts.
}
}, },
writeCityDetailCache(data: Record<string, CityDetail>) { writeCityDetailCache(data: Record<string, CityDetail>) {
+1 -1
View File
@@ -382,7 +382,7 @@ export function normalizeLocale(value?: string | null): Locale {
export function getInitialLocaleFromNavigator(): Locale { export function getInitialLocaleFromNavigator(): Locale {
if (typeof window === "undefined") return DEFAULT_LOCALE; if (typeof window === "undefined") return DEFAULT_LOCALE;
return normalizeLocale(window.navigator.language); return normalizeLocale(window.navigator?.language);
} }
export function formatMessage( export function formatMessage(
+1
View File
@@ -179,6 +179,7 @@ export async function middleware(request: NextRequest) {
export const config = { export const config = {
matcher: [ matcher: [
"/account/:path*", "/account/:path*",
"/terminal/:path*",
"/ops/:path*", "/ops/:path*",
"/api/auth/:path*", "/api/auth/:path*",
"/api/ops/:path*", "/api/ops/:path*",
Binary file not shown.
Binary file not shown.
+82 -1
View File
@@ -16,7 +16,7 @@ import re
import threading import threading
import time import time
import unicodedata import unicodedata
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
import httpx import httpx
@@ -97,6 +97,14 @@ MARKET_CITY_SLUG_ALIASES: Dict[str, str] = {
} }
def _city_local_date(city_key: str) -> str:
"""Return ISO date string (YYYY-MM-DD) for the city's local timezone."""
city = CITY_REGISTRY.get(city_key, {})
tz_offset = city.get("tz_offset", 0)
local_dt = datetime.now(timezone.utc) + timedelta(seconds=tz_offset)
return local_dt.strftime("%Y-%m-%d")
def _resolve_market_city_key(city_key: str) -> str: def _resolve_market_city_key(city_key: str) -> str:
return MARKET_CITY_ALIASES.get(city_key, city_key) return MARKET_CITY_ALIASES.get(city_key, city_key)
@@ -2316,6 +2324,79 @@ class PolymarketReadOnlyLayer:
out.append(market) out.append(market)
return out return out
def resolve_city_clob_tokens(self, city_key: str) -> List[Dict[str, Any]]:
"""Resolve CLOB token IDs for a city using its local date."""
local_date = _city_local_date(city_key)
market_slug = self._build_weather_event_slug(city_key, local_date)
if not market_slug:
return []
markets = self._load_event_markets(market_slug)
tokens: List[Dict[str, Any]] = []
for m in markets:
clob_ids = _json_or_list(m.get("clobTokenIds"))
question = str(m.get("question") or "").strip()
prices = _json_or_list(m.get("outcomePrices"))
if len(clob_ids) < 2:
continue
tokens.append({
"city": city_key,
"local_date": local_date,
"question": question,
"slug": str(m.get("slug") or "").strip(),
"yes_token": clob_ids[0],
"no_token": clob_ids[1],
"yes_price": _safe_float(prices[0]) if len(prices) > 0 else None,
"no_price": _safe_float(prices[1]) if len(prices) > 1 else None,
})
return tokens
def resolve_all_cities_clob_tokens(
self,
cities: Optional[List[str]] = None,
) -> Dict[str, List[Dict[str, Any]]]:
"""Resolve CLOB tokens for all configured cities using local dates.
Returns dict keyed by city_key, each value is a list of bucket token dicts.
"""
if cities is None:
cities = list(CITY_REGISTRY.keys())
result: Dict[str, List[Dict[str, Any]]] = {}
for city_key in cities:
try:
buckets = self.resolve_city_clob_tokens(city_key)
if buckets:
result[city_key] = buckets
logger.info(
"polymarket market discovery city={} buckets={} date={}",
city_key,
len(buckets),
buckets[0]["local_date"] if buckets else "N/A",
)
except Exception as exc:
logger.warning(
"polymarket market discovery failed city={} error={}",
city_key,
exc,
)
return result
def collect_all_clob_token_ids(
self,
cities: Optional[List[str]] = None,
) -> List[str]:
"""Collect all unique YES/NO CLOB token IDs for the given cities."""
all_tokens = self.resolve_all_cities_clob_tokens(cities)
seen: set = set()
token_ids: List[str] = []
for city_buckets in all_tokens.values():
for bucket in city_buckets:
for key in ("yes_token", "no_token"):
tid = str(bucket.get(key) or "").strip()
if tid and tid not in seen:
seen.add(tid)
token_ids.append(tid)
return token_ids
def _extract_market_bucket_label( def _extract_market_bucket_label(
self, self,
market: Dict[str, Any], market: Dict[str, Any],