From 617d3c910e3923e9a963bd691f8420bad1a24206 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Tue, 26 May 2026 00:33:02 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E4=BB=98=E5=9F=9F=E5=90=8D=E8=AF=86?= =?UTF-8?q?=E5=88=AB=E6=9B=B4=E6=96=B0=EF=BC=9AVercel=20=E2=86=92=20polywe?= =?UTF-8?q?ather.top?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit payment-host.ts 新增 polyweather.top。site-url.ts/wallet.ts/usePaymentFlow/AccountCenter 硬编码域名替换。PRODUCTION_SITE_URL 改为 https://polyweather.top。 --- frontend/components/account/AccountCenter.tsx | 2 +- frontend/components/account/usePaymentFlow.ts | 6 +- frontend/components/account/wallet.ts | 2 +- .../__tests__/mobileMapCitySelect.test.ts | 24 - frontend/hooks/useLeafletMap.ts | 1094 ----------------- frontend/lib/payment-host.ts | 1 + frontend/lib/site-url.ts | 2 +- 7 files changed, 7 insertions(+), 1124 deletions(-) delete mode 100644 frontend/components/dashboard/scan-terminal/__tests__/mobileMapCitySelect.test.ts delete mode 100644 frontend/hooks/useLeafletMap.ts diff --git a/frontend/components/account/AccountCenter.tsx b/frontend/components/account/AccountCenter.tsx index 57d61e8d..06942056 100644 --- a/frontend/components/account/AccountCenter.tsx +++ b/frontend/components/account/AccountCenter.tsx @@ -885,7 +885,7 @@ export function AccountCenter() { {copy.paymentHostBlocked.replace( "{host}", allowedPaymentHosts[0] || - "polyweather-pro.vercel.app", + "polyweather.top", )} ) : null} diff --git a/frontend/components/account/usePaymentFlow.ts b/frontend/components/account/usePaymentFlow.ts index c012b0bc..a46912c8 100644 --- a/frontend/components/account/usePaymentFlow.ts +++ b/frontend/components/account/usePaymentFlow.ts @@ -293,7 +293,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) { clearPaymentState(); clearStoredPaymentRecovery(); if (!paymentHostAllowed) { - setPaymentError(copy.paymentHostBlocked.replace("{host}", allowedPaymentHosts[0] || "polyweather-pro.vercel.app")); + setPaymentError(copy.paymentHostBlocked.replace("{host}", allowedPaymentHosts[0] || "polyweather.top")); return; } if (!authIsAuthenticated) { @@ -516,7 +516,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) { setLastIntentId(""); setLastTxHash(""); if (!paymentHostAllowed) { - setPaymentError(copy.paymentHostBlocked.replace("{host}", allowedPaymentHosts[0] || "polyweather-pro.vercel.app")); + setPaymentError(copy.paymentHostBlocked.replace("{host}", allowedPaymentHosts[0] || "polyweather.top")); return; } if (!authIsAuthenticated) { @@ -670,7 +670,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) { // ── handleOverlayCheckout ────────────────────────────── const handleOverlayCheckout = async () => { if (!paymentHostAllowed) { - setPaymentError(copy.paymentHostBlocked.replace("{host}", allowedPaymentHosts[0] || "polyweather-pro.vercel.app")); + setPaymentError(copy.paymentHostBlocked.replace("{host}", allowedPaymentHosts[0] || "polyweather.top")); return; } if (!authIsAuthenticated) { diff --git a/frontend/components/account/wallet.ts b/frontend/components/account/wallet.ts index bbc47c19..32c4d2b2 100644 --- a/frontend/components/account/wallet.ts +++ b/frontend/components/account/wallet.ts @@ -204,7 +204,7 @@ export async function getWalletConnectProvider( const origin = typeof window !== "undefined" ? window.location.origin - : "https://polyweather-pro.vercel.app"; + : "https://polyweather.top"; const provider = (await EthereumProvider.init({ projectId: WALLETCONNECT_PROJECT_ID, chains: [chainId], diff --git a/frontend/components/dashboard/scan-terminal/__tests__/mobileMapCitySelect.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/mobileMapCitySelect.test.ts deleted file mode 100644 index c35b83b1..00000000 --- a/frontend/components/dashboard/scan-terminal/__tests__/mobileMapCitySelect.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; - -function assert(condition: unknown, message: string) { - if (!condition) throw new Error(message); -} - -export function runTests() { - const projectRoot = process.cwd(); - const hookPath = path.join(projectRoot, "hooks", "useLeafletMap.ts"); - const source = fs.readFileSync(hookPath, "utf8"); - - assert( - source.includes("bindMarkerTouchSelect") && - source.includes('"touchend"') && - source.includes('"pointerup"'), - "Leaflet city markers must bind touch/pointer selection events for mobile taps", - ); - assert( - source.includes("L.DomEvent.stopPropagation") && - source.includes("L.DomEvent.preventDefault"), - "mobile marker tap handling must stop map click propagation and prevent browser touch defaults", - ); -} diff --git a/frontend/hooks/useLeafletMap.ts b/frontend/hooks/useLeafletMap.ts deleted file mode 100644 index 4dab8899..00000000 --- a/frontend/hooks/useLeafletMap.ts +++ /dev/null @@ -1,1094 +0,0 @@ -"use client"; - -import { useEffect, useRef } from "react"; -import L from "leaflet"; -import { - CityDetail, - CityListItem, - CitySummary, - NearbyStation, -} from "@/lib/dashboard-types"; -import { pickMapNearbyStations } from "@/lib/dashboard-utils"; - -interface UseLeafletMapArgs { - cities: CityListItem[]; - cityDetailsByName: Record; - citySummariesByName: Record; - onClosePanel: () => void; - onEnsureCityDetail: ( - cityName: string, - force?: boolean, - depth?: "panel" | "nearby" | "full", - ) => Promise; - onMapInteractionChange: (active: boolean) => void; - onRegisterStopMotion: (stopMotion: () => void) => void; - onSelectCity: (cityName: string) => void; - selectedCity: string | null; - selectedDetail: CityDetail | null; - suspendMotion: boolean; - isLoadingDetail: boolean; -} - -const AUTO_NEARBY_MIN_ZOOM = 8; -const AUTO_NEARBY_MAX_DISTANCE_M = 120000; -const AUTO_NEARBY_IDLE_REFRESH_DELAY_MS = 10_000; -const AUTO_NEARBY_MIN_REFRESH_INTERVAL_MS = 60_000; -const USER_CAMERA_OVERRIDE_MS = 10 * 60_000; -const MAP_MAX_ZOOM = 19; -const MAP_TILE_URLS = { - dark: "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png", - light: "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png", -} as const; -const TILE_RETRY_LIMIT = 2; -const TILE_RETRY_DELAY_BASE_MS = 500; -const CITY_MARKER_DISPLAY_OFFSETS: Record< - string, - { x: number; y: number; zIndexOffset?: number } -> = { - // Shek Kong sits between the Hong Kong and Shenzhen cards and gets visually buried - // by their wide marker bubbles. Shift only the rendered marker, not the true point. - "shek kong": { x: 34, y: -26, zIndexOffset: 320 }, - "shenzhen": { x: -40, y: 14, zIndexOffset: 300 }, -}; - -function getMarkerDisplayOffset(cityName: string) { - return CITY_MARKER_DISPLAY_OFFSETS[String(cityName || "").toLowerCase()] || { - x: 0, - y: 0, - zIndexOffset: 0, - }; -} - -function getMapTileUrl(container?: Element | null) { - if (typeof document === "undefined") { - return MAP_TILE_URLS.dark; - } - - const lightMode = - Boolean(container?.closest(".light")) || - document.documentElement.classList.contains("light") || - document.body.classList.contains("light") || - Boolean(document.querySelector(`.${CSS.escape("light")}`)) || - Boolean(document.querySelector(".scan-terminal.light")); - if (lightMode) { - return MAP_TILE_URLS.light; - } - return MAP_TILE_URLS.dark; -} - -function pickMarkerTemperature( - snapshot?: Pick | CitySummary, -) { - if (!snapshot) return null; - const detail = snapshot as Partial; - const currentSource = String( - snapshot.current?.settlement_source || - snapshot.current?.settlement_source_label || - "", - ) - .trim() - .toLowerCase(); - const currentTemp = - currentSource === "nmc" || currentSource.includes("nmc") - ? null - : snapshot.current?.temp; - const isNmcStation = (station?: { source_label?: string | null }) => - String(station?.source_label || "") - .trim() - .toLowerCase() - .includes("nmc"); - const candidates = [ - currentTemp, - detail.airport_current?.temp, - isNmcStation(detail.airport_primary) ? null : detail.airport_primary?.temp, - ]; - for (const value of candidates) { - const numeric = Number(value); - if (Number.isFinite(numeric)) return numeric; - } - return null; -} - -function createMarkerIcon( - city: CityListItem, - snapshot?: Pick | CitySummary, -) { - const riskClass = `risk-${city.risk_level}`; - const label = city.display_name; - const unit = city.temp_unit === "fahrenheit" ? "°F" : "°C"; - const shortName = label.length > 10 ? `${label.substring(0, 8)}...` : label; - const markerTemp = pickMarkerTemperature(snapshot); - const tempText = markerTemp != null ? `${markerTemp}${unit}` : "--"; - const offset = getMarkerDisplayOffset(city.name); - const styleAttr = - offset.x || offset.y - ? ` style="transform: translate(${offset.x}px, ${offset.y}px);"` - : ""; - - return L.divIcon({ - className: "", - html: ` -
-
${tempText}
-
${shortName}
-
- `, - iconAnchor: [40, 22], - iconSize: [80, 44], - }); -} - -function getMarkerSignature( - city: CityListItem, - snapshot?: Pick | CitySummary, -) { - return [ - city.display_name, - city.risk_level, - city.temp_unit, - city.lat, - city.lon, - pickMarkerTemperature(snapshot) ?? "", - ].join("|"); -} - -function formatCityLocalDateTime( - epochMs: number, - utcOffsetSeconds: number, - cityLocalDate?: string | null, - isEnglishUi = false, -) { - if (!Number.isFinite(epochMs) || !Number.isFinite(utcOffsetSeconds)) return ""; - const shifted = new Date(epochMs + utcOffsetSeconds * 1000); - const year = shifted.getUTCFullYear(); - const month = shifted.getUTCMonth() + 1; - const day = shifted.getUTCDate(); - const dateText = `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`; - const timeText = `${String(shifted.getUTCHours()).padStart(2, "0")}:${String( - shifted.getUTCMinutes(), - ).padStart(2, "0")}`; - const anchorDate = String(cityLocalDate || "").slice(0, 10); - if (!anchorDate || dateText === anchorDate) return timeText; - - const anchorMs = Date.UTC( - Number(anchorDate.slice(0, 4)), - Number(anchorDate.slice(5, 7)) - 1, - Number(anchorDate.slice(8, 10)), - ); - const stationDateMs = Date.UTC(year, month - 1, day); - if (Number.isFinite(anchorMs) && anchorMs - stationDateMs === 86_400_000) { - return isEnglishUi ? `Yesterday ${timeText}` : `昨日 ${timeText}`; - } - return `${String(month).padStart(2, "0")}/${String(day).padStart(2, "0")} ${timeText}`; -} - -function parseIsoLikeTime(value: string, treatNaiveAsUtc: boolean) { - const raw = String(value || "").trim(); - if (!raw) return null; - const hasDate = raw.includes("T") || /^\d{4}-\d{2}-\d{2}\s+\d{1,2}:\d{2}/.test(raw); - if (!hasDate) return null; - - const normalized = raw.replace(" ", "T"); - const hasExplicitTz = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(normalized); - if (!hasExplicitTz && !treatNaiveAsUtc) return null; - const parseTarget = hasExplicitTz || !treatNaiveAsUtc ? normalized : `${normalized}Z`; - const parsed = new Date(parseTarget); - if (Number.isNaN(parsed.getTime())) return null; - return parsed.getTime(); -} - -function getDetailUtcOffsetSeconds(detail: CityDetail) { - const explicit = Number(detail.utc_offset_seconds); - if (Number.isFinite(explicit)) return explicit; - - const localDate = String(detail.local_date || "").slice(0, 10); - const localTime = String(detail.local_time || "").match(/(\d{1,2}):(\d{2})/); - const updatedAt = String(detail.updated_at || "").trim(); - const updatedEpochMs = parseIsoLikeTime(updatedAt, false); - if (localDate && localTime && updatedEpochMs != null) { - const localEpochMs = Date.UTC( - Number(localDate.slice(0, 4)), - Number(localDate.slice(5, 7)) - 1, - Number(localDate.slice(8, 10)), - Number(localTime[1]), - Number(localTime[2]), - ); - const rawOffsetSeconds = (localEpochMs - updatedEpochMs) / 1000; - if (Number.isFinite(rawOffsetSeconds)) { - // Use 15-minute buckets so half-hour/quarter-hour time zones also survive. - const rounded = Math.round(rawOffsetSeconds / 900) * 900; - if (rounded >= -43_200 && rounded <= 50_400) return rounded; - } - } - return null; -} - -function buildNearbyIconHtml(detail: CityDetail, station: NearbyStation) { - const sanitizeWindText = (value?: string | null) => { - const text = String(value || "").trim(); - if (!text || text === "9999") return ""; - return text; - }; - const isEnglishUi = - typeof document !== "undefined" && - String(document.documentElement.lang || "").toLowerCase().startsWith("en"); - const symbol = detail.temp_symbol || "°C"; - const sourceCode = String(station.source_code || station.source_label || "") - .trim() - .toLowerCase(); - const isMgmStation = sourceCode === "mgm" || sourceCode.includes("mgm"); - const utcOffsetSeconds = getDetailUtcOffsetSeconds(detail); - const formatObsTime = () => { - const raw = String(station.obs_time || "").trim(); - const label = String(station.obs_time_label || "").trim(); - if (station.obs_time_display_tz === "city_local" && label) return label.replace(/Z$/i, ""); - const epochRaw = Number(station.obs_time_epoch); - const epochMs = Number.isFinite(epochRaw) - ? epochRaw > 1_000_000_000_000 - ? epochRaw - : epochRaw * 1000 - : parseIsoLikeTime(raw, isMgmStation); - if (epochMs != null && Number.isFinite(epochMs) && utcOffsetSeconds != null) { - const localOffsetSeconds = utcOffsetSeconds; - return formatCityLocalDateTime(epochMs, localOffsetSeconds, detail.local_date, isEnglishUi); - } - if (label) return label.replace(/Z$/i, ""); - if (!raw) return ""; - if (raw.endsWith("Z") || raw.includes("+00:00")) { - const parsed = new Date(raw); - if (!Number.isNaN(parsed.getTime())) { - return `${String(parsed.getUTCHours()).padStart(2, "0")}:${String(parsed.getUTCMinutes()).padStart(2, "0")}`; - } - } - if (raw.includes("T")) return raw.split("T").pop()?.slice(0, 5) || ""; - if (raw.includes(" ")) return raw.split(" ").pop()?.slice(0, 5) || ""; - return raw.slice(0, 5); - }; - const syncLabel = () => { - const status = String(station.sync_status || "").trim().toLowerCase(); - const delta = Number(station.time_delta_vs_anchor_minutes); - if (status === "synced") return isEnglishUi ? "synced" : "同步"; - if (status === "near_realtime") { - return Number.isFinite(delta) - ? isEnglishUi - ? `${Math.round(delta)}m off` - : `差${Math.round(delta)}m` - : isEnglishUi - ? "near-live" - : "近实时"; - } - if (status === "lagged") { - return Number.isFinite(delta) - ? isEnglishUi - ? `${Math.round(delta)}m lag` - : `滞后${Math.round(delta)}m` - : isEnglishUi - ? "lagged" - : "滞后"; - } - if (status === "stale") return isEnglishUi ? "stale" : "过期"; - return ""; - }; - const obsTime = formatObsTime(); - const syncText = syncLabel(); - const timingHtml = - obsTime || syncText - ? `
- ${obsTime ? `${obsTime}` : ""} - ${syncText ? `${syncText}` : ""} -
` - : ""; - const rawLabel = - station.station_label || - station.name || - station.station_code || - station.icao || - "实测 (OBS)"; - const label = - sourceCode === "nmc" && - /\(NMC\)$/i.test(String(rawLabel)) && - !String(rawLabel).includes("区域实况") - ? String(rawLabel).replace(/\s*\(NMC\)$/i, "区域实况 (NMC)") - : rawLabel; - let windHtml = ""; - const windDirectionText = sanitizeWindText(station.wind_direction_text); - const windPowerText = sanitizeWindText(station.wind_power_text); - - if (station.wind_dir != null) { - const rotation = (Number(station.wind_dir) + 180) % 360; - const speedRaw = Number(station.wind_speed ?? station.wind_speed_kt); - const speed = Number.isFinite(speedRaw) ? `${speedRaw.toFixed(1)}k` : ""; - windHtml = ` -
- - ${speed} -
- `; - } else if (windDirectionText || windPowerText) { - const windText = [windDirectionText, windPowerText].filter(Boolean).join(" "); - windHtml = ` -
- ${windText} -
- `; - } - - return ` -
-
-
-
-
-
- ${label} -
- ${station.temp ?? "--"} - ${symbol} -
- ${timingHtml} -
- ${windHtml} -
- `; -} - -function getNearbyMarkerDisplayOffset( - detail: CityDetail, - station: NearbyStation, - index: number, -) { - const cityLat = Number(detail.lat); - const cityLon = Number(detail.lon); - const stationLat = Number(station.lat); - const stationLon = Number(station.lon); - - if ( - !Number.isFinite(cityLat) || - !Number.isFinite(cityLon) || - !Number.isFinite(stationLat) || - !Number.isFinite(stationLon) - ) { - return { x: 0, y: 0 }; - } - - const latDiff = Math.abs(cityLat - stationLat); - const lonDiff = Math.abs(cityLon - stationLon); - const isNearCityAnchor = latDiff < 0.02 && lonDiff < 0.02; - - if (!isNearCityAnchor) { - return { x: 0, y: 0 }; - } - - const presets = [ - { x: 0, y: -58 }, - { x: 76, y: -34 }, - { x: -76, y: -34 }, - { x: 72, y: 34 }, - { x: -72, y: 34 }, - ]; - - return presets[index % presets.length]; -} - -function buildNearbyRenderSignature( - detail: CityDetail, - stations: NearbyStation[], -) { - const uiLang = - typeof document !== "undefined" - ? String(document.documentElement.lang || "").toLowerCase() - : ""; - const cityName = String(detail.name || "").trim().toLowerCase(); - const cityLat = Number(detail.lat); - const cityLon = Number(detail.lon); - const stationSignature = stations - .map((station) => - [ - String( - station.station_code || - station.station_label || - station.name || - station.icao || - "", - ) - .trim() - .toLowerCase(), - Number(station.lat).toFixed(5), - Number(station.lon).toFixed(5), - Number(station.temp ?? Number.NaN).toFixed(2), - String(station.obs_time_epoch || station.obs_time || "").trim(), - String(station.obs_time_label || "").trim(), - String(station.sync_status || "").trim().toLowerCase(), - Number(station.time_delta_vs_anchor_minutes ?? Number.NaN).toFixed(1), - Number(station.wind_dir ?? Number.NaN).toFixed(1), - Number(station.wind_speed ?? station.wind_speed_kt ?? Number.NaN).toFixed( - 1, - ), - String(station.wind_direction_text || "").trim().toLowerCase(), - String(station.wind_power_text || "").trim().toLowerCase(), - ].join("|"), - ) - .join("~"); - - return [ - uiLang, - detail.temp_symbol || "", - cityName, - Number.isFinite(cityLat) ? cityLat.toFixed(5) : "", - Number.isFinite(cityLon) ? cityLon.toFixed(5) : "", - stationSignature, - ].join("::"); -} - -export function useLeafletMap({ - cities, - cityDetailsByName, - citySummariesByName, - onClosePanel, - onEnsureCityDetail, - onMapInteractionChange, - onRegisterStopMotion, - onSelectCity, - selectedCity, - selectedDetail, - suspendMotion, - isLoadingDetail, -}: UseLeafletMapArgs) { - const containerRef = useRef(null); - const mapRef = useRef(null); - const tileLayerRef = useRef(null); - const markersRef = useRef< - Record - >({}); - const nearbyLayerRef = useRef(null); - const autoNearbyCityRef = useRef(null); - const loadingAutoNearbyRef = useRef(false); - const handlingAutoNearbyRef = useRef(false); - const lastMovedCityRef = useRef(null); - const lastCameraSelectionRef = useRef(null); - const lastTouchMarkerSelectAtRef = useRef(0); - const lastUserCameraInteractionAtRef = useRef(0); - const suspendMotionRef = useRef(suspendMotion); - const hasFittedInitialBoundsRef = useRef(false); - const onClosePanelRef = useRef(onClosePanel); - const onRegisterStopMotionRef = useRef(onRegisterStopMotion); - const onSelectCityRef = useRef(onSelectCity); - const onEnsureCityDetailRef = useRef(onEnsureCityDetail); - const onMapInteractionChangeRef = useRef(onMapInteractionChange); - const interactionIdleTimerRef = useRef | null>( - null, - ); - const nearbyRefreshTimerRef = useRef | null>( - null, - ); - const lastNearbyRefreshAtRef = useRef>({}); - const lastNearbyRenderSignatureRef = useRef(null); - - useEffect(() => { - onClosePanelRef.current = onClosePanel; - }, [onClosePanel]); - - useEffect(() => { - onRegisterStopMotionRef.current = onRegisterStopMotion; - }, [onRegisterStopMotion]); - - useEffect(() => { - onSelectCityRef.current = onSelectCity; - }, [onSelectCity]); - - useEffect(() => { - onEnsureCityDetailRef.current = onEnsureCityDetail; - }, [onEnsureCityDetail]); - - useEffect(() => { - onMapInteractionChangeRef.current = onMapInteractionChange; - }, [onMapInteractionChange]); - - useEffect(() => { - suspendMotionRef.current = suspendMotion; - }, [suspendMotion]); - - useEffect(() => { - const container = containerRef.current; - if (!container || mapRef.current) return; - - const map = L.map(container, { - attributionControl: true, - bounceAtZoomLimits: false, - center: [30, 10], - maxZoom: MAP_MAX_ZOOM, - minZoom: 2, - zoom: 3, - zoomControl: false, - }); - - L.control.zoom({ position: "bottomright" }).addTo(map); - const tileLayer = L.tileLayer(getMapTileUrl(container), { - attribution: - '© OSM © CARTO', - maxZoom: 19, - subdomains: "abcd", - }).addTo(map); - - // Retry failed tiles (CartoCDN may throttle long-running sessions) - const tileRetries = new Map(); - tileLayer.on("tileerror", (ev: { tile: HTMLElement; coords: L.Coords }) => { - const { tile, coords } = ev; - const key = `${coords.z}/${coords.x}/${coords.y}`; - const attempts = tileRetries.get(key) ?? 0; - if (attempts >= TILE_RETRY_LIMIT) return; - tileRetries.set(key, attempts + 1); - const delay = TILE_RETRY_DELAY_BASE_MS * 2 ** attempts; - setTimeout(() => { - (tile as HTMLImageElement).src = tileLayer.getTileUrl(coords); - }, delay); - }); - map.on("moveend zoomend", () => tileRetries.clear()); - - const nearbyLayer = L.layerGroup().addTo(map); - mapRef.current = map; - tileLayerRef.current = tileLayer; - nearbyLayerRef.current = nearbyLayer; - - // Track which city we've already moved to for the current selection - onRegisterStopMotionRef.current(() => { - map.stop(); - }); - - const handleMapClick = () => { - onClosePanelRef.current(); - }; - map.on("click", handleMapClick); - - const markInteracting = () => { - lastUserCameraInteractionAtRef.current = Date.now(); - if (interactionIdleTimerRef.current) { - clearTimeout(interactionIdleTimerRef.current); - interactionIdleTimerRef.current = null; - } - if (nearbyRefreshTimerRef.current) { - clearTimeout(nearbyRefreshTimerRef.current); - nearbyRefreshTimerRef.current = null; - } - onMapInteractionChangeRef.current(true); - }; - - const markIdleSoon = () => { - if (interactionIdleTimerRef.current) { - clearTimeout(interactionIdleTimerRef.current); - } - interactionIdleTimerRef.current = setTimeout(() => { - interactionIdleTimerRef.current = null; - onMapInteractionChangeRef.current(false); - }, 700); - }; - - map.on("movestart", markInteracting); - map.on("zoomstart", markInteracting); - map.on("dragstart", markInteracting); - map.on("moveend", markIdleSoon); - map.on("zoomend", markIdleSoon); - - return () => { - onRegisterStopMotionRef.current(() => {}); - if (interactionIdleTimerRef.current) { - clearTimeout(interactionIdleTimerRef.current); - interactionIdleTimerRef.current = null; - } - if (nearbyRefreshTimerRef.current) { - clearTimeout(nearbyRefreshTimerRef.current); - nearbyRefreshTimerRef.current = null; - } - onMapInteractionChangeRef.current(false); - map.off("movestart", markInteracting); - map.off("zoomstart", markInteracting); - map.off("dragstart", markInteracting); - map.off("moveend", markIdleSoon); - map.off("zoomend", markIdleSoon); - map.off("click", handleMapClick); - map.remove(); - mapRef.current = null; - tileLayerRef.current = null; - nearbyLayerRef.current = null; - markersRef.current = {}; - }; - }, []); - - useEffect(() => { - const tileLayer = tileLayerRef.current; - if (!tileLayer || typeof MutationObserver === "undefined") return; - - let lastTileUrl = ""; - - const syncMapTheme = () => { - const nextUrl = getMapTileUrl(containerRef.current); - if (nextUrl === lastTileUrl) return; - lastTileUrl = nextUrl; - tileLayer.setUrl(nextUrl); - }; - syncMapTheme(); - - const observer = new MutationObserver(syncMapTheme); - observer.observe(document.documentElement, { - attributeFilter: ["class"], - attributes: true, - }); - - const scanTerminal = document.querySelector(".scan-terminal"); - if (scanTerminal) { - observer.observe(scanTerminal, { - attributeFilter: ["class"], - attributes: true, - }); - } - - return () => observer.disconnect(); - }, []); - - // Handle initial view if cities are loaded - useEffect(() => { - const map = mapRef.current; - if (!map || !cities.length || hasFittedInitialBoundsRef.current) return; - - // Only run fitBounds once for the initial list of cities - const bounds = cities.map((city) => [city.lat, city.lon]) as [ - number, - number, - ][]; - if (bounds.length) { - map.fitBounds(bounds, { - animate: false, - maxZoom: 4, - padding: [60, 60], - }); - hasFittedInitialBoundsRef.current = true; - } - }, [cities]); - - const lastCityDataRef = useRef< - Record - >({}); - - // Handle marker synchronization - useEffect(() => { - const map = mapRef.current; - if (!map || !cities.length) return; - let canceled = false; - const frameId = - typeof window !== "undefined" - ? window.requestAnimationFrame(() => { - if (canceled) return; - - const currentMarkers = markersRef.current; - const cityNames = new Set(cities.map((city) => city.name)); - - Object.entries(currentMarkers).forEach(([name, entry]) => { - if (cityNames.has(name)) return; - map.removeLayer(entry.marker); - delete currentMarkers[name]; - delete lastCityDataRef.current[name]; - }); - - cities.forEach((city) => { - const detail = cityDetailsByName[city.name]; - const summary = citySummariesByName[city.name]; - const snapshot = detail || summary; - const existing = currentMarkers[city.name]; - const signature = getMarkerSignature(city, snapshot); - const previousSignature = lastCityDataRef.current[city.name]; - const selectCityFromMarker = () => { - onSelectCityRef.current(city.name); - }; - const bindMarkerTouchSelect = (marker: L.Marker) => { - const element = marker.getElement(); - if (!(element instanceof HTMLElement)) return; - const boundKey = `${city.name}:${signature}`; - if (element.dataset.polyweatherTouchSelectBound === boundKey) return; - element.dataset.polyweatherTouchSelectBound = boundKey; - const handleTouchSelect = (event: Event) => { - if (Date.now() - lastTouchMarkerSelectAtRef.current < 120) { - return; - } - lastTouchMarkerSelectAtRef.current = Date.now(); - L.DomEvent.stopPropagation(event); - L.DomEvent.preventDefault(event); - selectCityFromMarker(); - }; - const handlePointerSelect = (event: Event) => { - const pointerType = (event as PointerEvent).pointerType; - if ( - pointerType && - pointerType !== "touch" && - pointerType !== "pen" - ) { - return; - } - if (Date.now() - lastTouchMarkerSelectAtRef.current < 120) { - return; - } - lastTouchMarkerSelectAtRef.current = Date.now(); - L.DomEvent.stopPropagation(event); - L.DomEvent.preventDefault(event); - selectCityFromMarker(); - }; - L.DomEvent.on(element, "touchend", handleTouchSelect); - L.DomEvent.on(element, "pointerup", handlePointerSelect); - }; - - if (existing) { - if (existing.city.lat !== city.lat || existing.city.lon !== city.lon) { - existing.marker.setLatLng([city.lat, city.lon]); - } - if (previousSignature !== signature) { - existing.marker.setIcon(createMarkerIcon(city, snapshot)); - } - bindMarkerTouchSelect(existing.marker); - currentMarkers[city.name] = { city, marker: existing.marker }; - lastCityDataRef.current[city.name] = signature; - return; - } - - // Create new marker - const marker = L.marker([city.lat, city.lon], { - icon: createMarkerIcon(city, snapshot), - zIndexOffset: getMarkerDisplayOffset(city.name).zIndexOffset || 0, - }).addTo(map); - - marker.on("click", () => { - if (Date.now() - lastTouchMarkerSelectAtRef.current < 650) { - return; - } - selectCityFromMarker(); - }); - bindMarkerTouchSelect(marker); - - currentMarkers[city.name] = { city, marker }; - lastCityDataRef.current[city.name] = signature; - }); - }) - : null; - - return () => { - canceled = true; - if (frameId != null && typeof window !== "undefined") { - window.cancelAnimationFrame(frameId); - } - }; - }, [cities, cityDetailsByName, citySummariesByName]); - - useEffect(() => { - Object.entries(markersRef.current).forEach(([name, entry]) => { - const element = entry.marker.getElement(); - if (!element) return; - const markerRoot = element.querySelector(".city-marker"); - markerRoot?.classList.toggle("selected", name === selectedCity); - }); - }, [selectedCity]); - - useEffect(() => { - if (!mapRef.current || !nearbyLayerRef.current) return; - const map = mapRef.current; - const layer = nearbyLayerRef.current; - const clearNearbyRefreshTimer = () => { - if (nearbyRefreshTimerRef.current) { - clearTimeout(nearbyRefreshTimerRef.current); - nearbyRefreshTimerRef.current = null; - } - }; - const clearRenderedNearby = () => { - layer.clearLayers(); - lastNearbyRenderSignatureRef.current = null; - }; - - function renderNearbyStations(detail: CityDetail, preserveView = false) { - const nearbyStations = pickMapNearbyStations(detail).filter((station) => { - const sLat = Number(station.lat); - const sLon = Number(station.lon); - if (!Number.isFinite(sLat) || !Number.isFinite(sLon)) return false; - if (Math.abs(sLat) < 0.1 && Math.abs(sLon) < 0.1) return false; - return true; - }); - const renderSignature = buildNearbyRenderSignature(detail, nearbyStations); - - if ( - renderSignature === lastNearbyRenderSignatureRef.current && - layer.getLayers().length === nearbyStations.length - ) { - return; - } - - clearRenderedNearby(); - - if (!nearbyStations.length) { - if (!preserveView && detail.lat != null && detail.lon != null) { - const container = map.getContainer(); - if (container && container.offsetWidth > 0 && container.offsetHeight > 0) { - map.flyTo([detail.lat, detail.lon], 10, { - animate: true, - duration: 1.5, - easeLinearity: 0.25, - }); - } - } - return; - } - - lastNearbyRenderSignatureRef.current = renderSignature; - - const latLngs: Array<[number, number]> = []; - if (detail.lat != null && detail.lon != null) { - latLngs.push([detail.lat, detail.lon]); - } - - nearbyStations.forEach((station) => { - const sLat = Number(station.lat); - const sLon = Number(station.lon); - const displayOffset = getNearbyMarkerDisplayOffset(detail, station, latLngs.length); - const styleAttr = - displayOffset.x || displayOffset.y - ? ` style="transform: translate(${displayOffset.x}px, ${displayOffset.y}px);"` - : ""; - - const icon = L.divIcon({ - className: "", - html: ` -
- ${buildNearbyIconHtml(detail, station)} -
- `, - iconAnchor: [16, 19], - iconSize: [240, 38], - }); - L.marker([sLat, sLon], { - icon, - interactive: false, - keyboard: false, - bubblingMouseEvents: false, - }).addTo(layer); - latLngs.push([sLat, sLon]); - }); - - if (preserveView) return; - - // Note: Movement for selected cities is now handled by the centralized effect. - // This section is primarily for auto-discovery movement if needed. - } - - function scheduleIdleNearbyRefresh(targetCity: string | null) { - clearNearbyRefreshTimer(); - if (!targetCity || suspendMotion) return; - if (map.getZoom() < AUTO_NEARBY_MIN_ZOOM) return; - - nearbyRefreshTimerRef.current = setTimeout(async () => { - nearbyRefreshTimerRef.current = null; - if (loadingAutoNearbyRef.current || handlingAutoNearbyRef.current) return; - if (!mapRef.current || mapRef.current.getZoom() < AUTO_NEARBY_MIN_ZOOM) return; - if (autoNearbyCityRef.current !== targetCity) return; - - const lastRefreshAt = lastNearbyRefreshAtRef.current[targetCity] || 0; - if (Date.now() - lastRefreshAt < AUTO_NEARBY_MIN_REFRESH_INTERVAL_MS) { - return; - } - - loadingAutoNearbyRef.current = true; - lastNearbyRefreshAtRef.current[targetCity] = Date.now(); - try { - const detail = await onEnsureCityDetailRef.current( - targetCity, - true, - "nearby", - ); - if (autoNearbyCityRef.current !== targetCity) return; - renderNearbyStations(detail, true); - } catch { - } finally { - loadingAutoNearbyRef.current = false; - } - }, AUTO_NEARBY_IDLE_REFRESH_DELAY_MS); - } - - async function maybeAutoShowNearbyStations() { - if (selectedCity && isLoadingDetail) { - autoNearbyCityRef.current = selectedCity; - clearNearbyRefreshTimer(); - clearRenderedNearby(); - return; - } - - if (handlingAutoNearbyRef.current) { - return; - } - handlingAutoNearbyRef.current = true; - try { - if (selectedDetail) { - const selectedNearbyStations = pickMapNearbyStations(selectedDetail); - if (selectedNearbyStations.length) { - // Just render stations, no camera move from here - renderNearbyStations(selectedDetail, true); - autoNearbyCityRef.current = selectedCity || selectedDetail.name || null; - scheduleIdleNearbyRefresh(autoNearbyCityRef.current); - return; - } - } - - if (suspendMotion) { - clearNearbyRefreshTimer(); - return; - } - - // If no city selected, reset the move tracker - lastMovedCityRef.current = null; - - if (map.getZoom() < AUTO_NEARBY_MIN_ZOOM) { - autoNearbyCityRef.current = null; - clearNearbyRefreshTimer(); - clearRenderedNearby(); - return; - } - - const center = map.getCenter(); - let best: { cityName: string; distance: number } | null = null; - for (const [cityName, entry] of Object.entries(markersRef.current)) { - const distance = map.distance( - center, - L.latLng(entry.city.lat, entry.city.lon), - ); - if (distance > AUTO_NEARBY_MAX_DISTANCE_M) continue; - if (!best || distance < best.distance) { - best = { cityName, distance }; - } - } - - const targetCity = selectedCity || best?.cityName || null; - if (!targetCity) { - autoNearbyCityRef.current = null; - clearNearbyRefreshTimer(); - clearRenderedNearby(); - return; - } - - if ( - autoNearbyCityRef.current === targetCity && - layer.getLayers().length > 0 - ) { - scheduleIdleNearbyRefresh(targetCity); - return; - } - - autoNearbyCityRef.current = targetCity; - const cachedDetail = cityDetailsByName[targetCity]; - if (cachedDetail && pickMapNearbyStations(cachedDetail).length) { - renderNearbyStations(cachedDetail, true); - scheduleIdleNearbyRefresh(targetCity); - return; - } - - if (loadingAutoNearbyRef.current) return; - loadingAutoNearbyRef.current = true; - try { - const detail = await onEnsureCityDetailRef.current( - targetCity, - false, - "nearby", - ); - renderNearbyStations(detail, true); - scheduleIdleNearbyRefresh(targetCity); - } catch { - } finally { - loadingAutoNearbyRef.current = false; - } - } finally { - handlingAutoNearbyRef.current = false; - } - } - - const syncVisibility = () => { - if (map.getZoom() < 7) { - if (map.hasLayer(layer)) { - map.removeLayer(layer); - } - } else if (!map.hasLayer(layer)) { - map.addLayer(layer); - } - void maybeAutoShowNearbyStations(); - }; - - syncVisibility(); - map.on("zoomend", syncVisibility); - map.on("moveend", maybeAutoShowNearbyStations); - - return () => { - clearNearbyRefreshTimer(); - map.off("zoomend", syncVisibility); - map.off("moveend", maybeAutoShowNearbyStations); - }; - }, [cityDetailsByName, selectedCity, selectedDetail, suspendMotion, isLoadingDetail]); - - // Centralized City Selection Zoom Effect - // Higher level than selection: we only flyTo once the data is loaded (selectedDetail) - // This satisfies "loading之后再出现动画吧" - useEffect(() => { - if (!selectedCity) { - lastMovedCityRef.current = null; - lastCameraSelectionRef.current = null; - return; - } - - const map = mapRef.current; - if (!map || suspendMotion || !selectedDetail || isLoadingDetail) return; - - // Check if the detail matches the selection (case-insensitive) - if (selectedDetail.name?.toLowerCase() !== selectedCity.toLowerCase()) { - return; - } - - if (lastMovedCityRef.current === selectedCity) return; - - const entry = markersRef.current[selectedCity]; - if (!entry) return; - - const selectionChanged = lastCameraSelectionRef.current !== selectedCity; - lastCameraSelectionRef.current = selectedCity; - - if ( - !selectionChanged && - Date.now() - lastUserCameraInteractionAtRef.current < USER_CAMERA_OVERRIDE_MS - ) { - lastMovedCityRef.current = selectedCity; - return; - } - - // Lock the move - lastMovedCityRef.current = selectedCity; - - // We use a micro-delay (50ms) to allow the browser to settle - // after the loading overlay disappears and the detail panel renders. - const timer = setTimeout(() => { - const currentMap = mapRef.current; - if ( - !currentMap || - lastMovedCityRef.current !== selectedCity || - suspendMotion - ) - return; - - const container = currentMap.getContainer(); - if (!container || container.offsetWidth <= 0 || container.offsetHeight <= 0) return; - currentMap.stop(); - currentMap.flyTo([entry.city.lat, entry.city.lon], 11, { - animate: true, - duration: 1.1, - easeLinearity: 0.22, - }); - }, 50); - - return () => clearTimeout(timer); - }, [selectedCity, selectedDetail, suspendMotion, isLoadingDetail]); - - useEffect(() => { - if (!suspendMotion) return; - mapRef.current?.stop(); - }, [suspendMotion]); - - return { containerRef }; -} diff --git a/frontend/lib/payment-host.ts b/frontend/lib/payment-host.ts index 25c5fe4c..b10f9f61 100644 --- a/frontend/lib/payment-host.ts +++ b/frontend/lib/payment-host.ts @@ -1,5 +1,6 @@ const DEFAULT_ALLOWED_PAYMENT_HOSTS = [ "polyweather-pro.vercel.app", + "polyweather.top", "localhost", "127.0.0.1", ]; diff --git a/frontend/lib/site-url.ts b/frontend/lib/site-url.ts index c1071abb..b4137ef6 100644 --- a/frontend/lib/site-url.ts +++ b/frontend/lib/site-url.ts @@ -1,4 +1,4 @@ -export const PRODUCTION_SITE_URL = "https://polyweather-pro.vercel.app"; +export const PRODUCTION_SITE_URL = "https://polyweather.top"; export function getConfiguredSiteUrl() { const configured = process.env.NEXT_PUBLIC_SITE_URL?.trim();