diff --git a/data/probability_training_snapshots.jsonl b/data/probability_training_snapshots.jsonl index d3641b70..837beb5a 100644 --- a/data/probability_training_snapshots.jsonl +++ b/data/probability_training_snapshots.jsonl @@ -80,3 +80,12 @@ {"city": "test_city", "timestamp": "2026-03-04 16:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875} {"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.85, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.5, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 29.5, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "shadow_prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.85, "calibrated_sigma": 1.09375} {"city": "test_city", "timestamp": "2026-03-04 14:30", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375} +{"city": "test_city", "timestamp": "2026-03-04 10:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.5625, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 26.0, "peak_status": "before", "prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "shadow_prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.5625} +{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875} +{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 33.3, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 33.0, "peak_status": "in_window", "prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "shadow_prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 33.3, "calibrated_sigma": 1.09375} +{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null} +{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375} +{"city": "test_city", "timestamp": "2026-03-04 22:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null} +{"city": "test_city", "timestamp": "2026-03-04 16:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875} +{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.85, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.5, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 29.5, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "shadow_prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.85, "calibrated_sigma": 1.09375} +{"city": "test_city", "timestamp": "2026-03-04 14:30", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375} diff --git a/frontend/app/api/payments/reconcile-latest/route.ts b/frontend/app/api/payments/reconcile-latest/route.ts new file mode 100644 index 00000000..66d34b4b --- /dev/null +++ b/frontend/app/api/payments/reconcile-latest/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + applyAuthResponseCookies, + buildBackendRequestHeaders, +} from "@/lib/backend-auth"; + +const API_BASE = process.env.POLYWEATHER_API_BASE_URL; + +export async function POST(req: NextRequest) { + if (!API_BASE) { + return NextResponse.json( + { error: "POLYWEATHER_API_BASE_URL is not configured" }, + { status: 500 }, + ); + } + + try { + const auth = await buildBackendRequestHeaders(req); + const res = await fetch(`${API_BASE}/api/payments/reconcile-latest`, { + method: "POST", + headers: auth.headers, + cache: "no-store", + }); + const raw = await res.text(); + const response = new NextResponse(raw, { + status: res.status, + headers: { + "content-type": + res.headers.get("content-type") || "application/json; charset=utf-8", + }, + }); + return applyAuthResponseCookies(response, auth.response); + } catch (error) { + return NextResponse.json( + { error: "Failed to reconcile latest payment", detail: String(error) }, + { status: 500 }, + ); + } +} diff --git a/frontend/components/account/AccountCenter.tsx b/frontend/components/account/AccountCenter.tsx index 79f39ae2..f1d22cf7 100644 --- a/frontend/components/account/AccountCenter.tsx +++ b/frontend/components/account/AccountCenter.tsx @@ -171,6 +171,10 @@ type ProviderSelection = { mode: ProviderMode; }; +type InjectedProviderOption = ProviderSelection & { + key: string; +}; + type ConnectBindOptions = { openOverlayAfterBind?: boolean; }; @@ -292,6 +296,43 @@ function getEvmProvider(): EvmProvider | null { return typeof root.request === "function" ? root : null; } +function listInjectedProviders(): InjectedProviderOption[] { + if (typeof window === "undefined") return []; + const root = window.ethereum; + if (!root) return []; + const candidates = Array.isArray(root.providers) + ? root.providers.filter( + (p): p is EvmProvider => !!p && typeof p.request === "function", + ) + : typeof root.request === "function" + ? [root] + : []; + const seen = new Set(); + const out: InjectedProviderOption[] = []; + candidates.forEach((provider, index) => { + const label = getEvmWalletLabel(provider); + const key = [ + provider.isMetaMask ? "metamask" : "", + provider.isRabby ? "rabby" : "", + provider.isOkxWallet ? "okx" : "", + provider.isBitKeep ? "bitget" : "", + label.toLowerCase().replace(/\s+/g, "-"), + String(index), + ] + .filter(Boolean) + .join(":"); + if (seen.has(key)) return; + seen.add(key); + out.push({ + key, + provider, + label, + mode: "auto", + }); + }); + return out; +} + function getEvmWalletLabel(provider: EvmProvider | null): string { if (!provider) return "EVM 钱包"; if (provider.isMetaMask) return "MetaMask"; @@ -549,6 +590,21 @@ export function AccountCenter() { walletConnectMissing: isEn ? "WalletConnect disabled: please configure" : "未启用 WalletConnect:请配置", + walletExtensionDetected: isEn + ? "Detected browser wallets" + : "检测到的浏览器钱包", + walletExtensionChoose: isEn + ? "Choose extension wallet" + : "选择浏览器钱包", + walletRecoveryBusy: isEn + ? "Recovering Pro entitlement after on-chain payment..." + : "正在根据链上支付恢复 Pro 权限...", + walletRecoveryDone: isEn + ? "Pro entitlement recovered." + : "Pro 权限已恢复。", + walletRecoveryFailed: isEn + ? "Paid detected but entitlement is still pending. Please refresh in a minute or contact support." + : "已检测到支付,但订阅状态仍在同步中。请稍后刷新,或联系管理员处理。", unbind: isEn ? "Unbind" : "解绑", unbindConfirm: isEn ? "Unbind wallet {address}? You can bind it again later." @@ -607,15 +663,26 @@ export function AccountCenter() { const [selectedTokenAddress, setSelectedTokenAddress] = useState(""); const [selectedWallet, setSelectedWallet] = useState(""); const [providerMode, setProviderMode] = useState("auto"); + const [injectedProviderOptions, setInjectedProviderOptions] = useState< + InjectedProviderOption[] + >([]); + const [selectedInjectedProviderKey, setSelectedInjectedProviderKey] = + useState(""); const [paymentBusy, setPaymentBusy] = useState(false); const [paymentInfo, setPaymentInfo] = useState(""); const [paymentError, setPaymentError] = useState(""); const [lastIntentId, setLastIntentId] = useState(""); const [lastTxHash, setLastTxHash] = useState(""); const [showSecondarySections, setShowSecondarySections] = useState(false); + const [reconcileBusy, setReconcileBusy] = useState(false); const supabaseReady = hasSupabasePublicEnv(); const walletConnectEnabled = Boolean(WALLETCONNECT_PROJECT_ID); + const authUserId = backend?.user_id || user?.id || ""; + const authIsAuthenticated = Boolean(authUserId); + const paymentReadyForRecovery = Boolean( + paymentConfig?.enabled && paymentConfig?.configured, + ); useEffect(() => { let canceled = false; @@ -648,6 +715,30 @@ export function AccountCenter() { }; }, []); + useEffect(() => { + const syncProviders = () => { + const nextOptions = listInjectedProviders(); + setInjectedProviderOptions(nextOptions); + setSelectedInjectedProviderKey((current) => { + if (current && nextOptions.some((row) => row.key === current)) { + return current; + } + return nextOptions[0]?.key || ""; + }); + }; + syncProviders(); + if (typeof window === "undefined") return; + window.addEventListener("ethereum#initialized", syncProviders as EventListener, { + once: false, + }); + return () => { + window.removeEventListener( + "ethereum#initialized", + syncProviders as EventListener, + ); + }; + }, []); + /** * Returns a valid access token, refreshing the session if the stored one * is missing or close to expiry. Throws if the user is not authenticated. @@ -736,14 +827,23 @@ export function AccountCenter() { ); const resolvePaymentProvider = useCallback( - async (mode: ProviderMode = "auto"): Promise => { + async ( + mode: ProviderMode = "auto", + preferredInjectedKey = "", + ): Promise => { const targetChainId = Number(paymentConfig?.chain_id || 137); if (mode !== "walletconnect") { - const injected = getEvmProvider(); + const injectedOptions = listInjectedProviders(); + const injected = + injectedOptions.find((row) => row.key === preferredInjectedKey) + ?.provider || getEvmProvider(); + const label = + injectedOptions.find((row) => row.key === preferredInjectedKey)?.label || + getEvmWalletLabel(injected); if (injected) { return { provider: injected, - label: getEvmWalletLabel(injected), + label, mode: "auto", }; } @@ -936,6 +1036,22 @@ export function AccountCenter() { void loadPaymentSnapshot(); }, [loadPaymentSnapshot]); + useEffect(() => { + if (typeof window === "undefined") return; + if (!lastIntentId) return; + window.sessionStorage.setItem("polyweather:lastPaymentIntentId", lastIntentId); + }, [lastIntentId]); + + useEffect(() => { + if (typeof window === "undefined") return; + const storedIntentId = window.sessionStorage.getItem( + "polyweather:lastPaymentIntentId", + ); + if (storedIntentId && !lastIntentId) { + setLastIntentId(storedIntentId); + } + }, [lastIntentId]); + const onRefresh = async () => { setRefreshing(true); await loadSnapshot(); @@ -943,6 +1059,74 @@ export function AccountCenter() { setRefreshing(false); }; + const reconcileLatestPayment = useCallback(async () => { + if (!authIsAuthenticated || reconcileBusy) return false; + setReconcileBusy(true); + try { + const headers = await buildAuthedHeaders(true, true); + const res = await fetch("/api/payments/reconcile-latest", { + method: "POST", + headers, + }); + if (!res.ok) { + return false; + } + const json = (await res.json()) as { + ok?: boolean; + action?: string; + subscription?: { plan_code?: string | null } | null; + }; + if (json.ok) { + setPaymentInfo(copy.walletRecoveryDone); + setPaymentError(""); + await loadSnapshot(); + await loadPaymentSnapshot(); + return true; + } + return false; + } catch { + return false; + } finally { + setReconcileBusy(false); + } + }, [ + authIsAuthenticated, + buildAuthedHeaders, + copy.walletRecoveryDone, + loadPaymentSnapshot, + loadSnapshot, + reconcileBusy, + ]); + + useEffect(() => { + if (!authIsAuthenticated) return; + if (backend?.subscription_active) return; + if (!paymentReadyForRecovery) return; + if (!lastIntentId) return; + let cancelled = false; + const run = async () => { + setPaymentInfo(copy.walletRecoveryBusy); + const repaired = await reconcileLatestPayment(); + if (cancelled) return; + if (!repaired && !backend?.subscription_active) { + setPaymentInfo(""); + setPaymentError(copy.walletRecoveryFailed); + } + }; + void run(); + return () => { + cancelled = true; + }; + }, [ + backend?.subscription_active, + authIsAuthenticated, + copy.walletRecoveryBusy, + copy.walletRecoveryFailed, + lastIntentId, + paymentReadyForRecovery, + reconcileLatestPayment, + ]); + const onSignOut = async () => { if (walletConnectProviderCache?.disconnect) { try { @@ -1285,7 +1469,10 @@ export function AccountCenter() { setPaymentBusy(true); try { - const providerSelection = await resolvePaymentProvider(mode); + const providerSelection = await resolvePaymentProvider( + mode, + selectedInjectedProviderKey, + ); const eth = providerSelection.provider; const walletLabel = providerSelection.label; @@ -1480,7 +1667,10 @@ export function AccountCenter() { setPaymentBusy(true); let approvedInThisRun = false; try { - const providerSelection = await resolvePaymentProvider(providerMode); + const providerSelection = await resolvePaymentProvider( + providerMode, + selectedInjectedProviderKey, + ); const eth = providerSelection.provider; const activeAccounts = (await eth.request({ method: "eth_requestAccounts", @@ -2153,6 +2343,31 @@ export function AccountCenter() {
+ {injectedProviderOptions.length > 1 && ( + + )}