Compare commits

...

58 Commits

Author SHA1 Message Date
2569718930@qq.com 78ea0326a5 Return partial city detail batches 2026-05-31 19:51:28 +08:00
2569718930@qq.com d3f444dbf6 Bind PolyWeather services to loopback 2026-05-31 19:46:00 +08:00
2569718930@qq.com 8d26afdec0 Reduce terminal auth and analytics stalls 2026-05-31 19:21:53 +08:00
2569718930@qq.com 5083b7c433 Instrument API timing and reduce detail fallbacks 2026-05-31 18:58:57 +08:00
2569718930@qq.com 668f4d9bd3 Optimize terminal batching and cache guards 2026-05-31 18:28:50 +08:00
2569718930@qq.com a4253c224a Add auth profile timing instrumentation 2026-05-31 18:27:04 +08:00
2569718930@qq.com 46effcd45f Optimize terminal detail loading and Redis replay 2026-05-31 04:32:48 +08:00
2569718930@qq.com 2a6d8748f4 Improve ops monitoring and chart loading 2026-05-31 03:21:58 +08:00
2569718930@qq.com ca72072da0 Optimize terminal startup and deployment smoke checks 2026-05-31 01:56:08 +08:00
2569718930@qq.com bf0fbe1b1d Optimize landing server rendering 2026-05-30 23:43:07 +08:00
2569718930@qq.com 30e9513ce2 Update deploy smoke check test 2026-05-30 23:00:48 +08:00
2569718930@qq.com aec9dd6a54 Harden production deploy smoke checks 2026-05-30 22:51:25 +08:00
2569718930@qq.com 6222f60573 Improve terminal chart loading states 2026-05-30 22:37:08 +08:00
2569718930@qq.com a48a7bcca5 Pass ops admin whitelist to frontend 2026-05-30 22:18:48 +08:00
2569718930@qq.com 74a2d71644 Reduce scan terminal origin load 2026-05-30 21:55:45 +08:00
2569718930@qq.com 9c239c0394 Add login button pending feedback 2026-05-30 21:31:13 +08:00
2569718930@qq.com 383dfe4d0e Harden production deploy stability 2026-05-30 21:21:30 +08:00
2569718930@qq.com 52f92d8650 Speed up terminal auth snapshot loading 2026-05-30 21:08:19 +08:00
2569718930@qq.com d9eea721ef feat: implement /api/auth/me proxy route with subscription-required state and fallback entitlement snapshots 2026-05-30 21:01:48 +08:00
2569718930@qq.com aa583e7440 Add ops runtime secret rotation 2026-05-30 20:33:30 +08:00
2569718930@qq.com e00405df8a Tighten AMSC ops health check 2026-05-30 20:10:14 +08:00
2569718930@qq.com 9bcfd9d1eb Improve landing performance and analytics funnel 2026-05-30 19:50:50 +08:00
2569718930@qq.com 3fcda3f3cd Tighten cities fallback timeout 2026-05-30 19:38:31 +08:00
2569718930@qq.com 345691ad71 Add cities static fallback 2026-05-30 19:32:25 +08:00
2569718930@qq.com e864181c3c Improve terminal entitlement resilience 2026-05-30 19:10:19 +08:00
2569718930@qq.com 097971f107 Fix terminal subscription gate on unknown auth state 2026-05-30 18:34:38 +08:00
2569718930@qq.com 90bc895000 Update referral points pricing 2026-05-30 18:04:36 +08:00
2569718930@qq.com c0b20ed1bf feat: implement TemperatureStatsBars component for displaying thermal data with i18n support 2026-05-30 16:42:03 +08:00
2569718930@qq.com 5470da6b3a feat: implement CitySelectorDropdown for ScanTerminalDashboard with multi-filter search and regional categorization 2026-05-30 16:39:23 +08:00
2569718930@qq.com a03b8095ee Refresh entitlement after payment confirmation 2026-05-30 16:26:32 +08:00
2569718930@qq.com 91896b4dad Prevent duplicate payment confirmations 2026-05-30 16:17:15 +08:00
2569718930@qq.com 44638760b7 feat: implement atomic signup trial claims and referral discount logic with Supabase database schema updates 2026-05-30 16:02:02 +08:00
2569718930@qq.com c0b1edaed0 feat: implement payment proxy routes and add security validation tests for wallet and intent operations 2026-05-30 15:41:15 +08:00
2569718930@qq.com 853a652e9b fix: degrade auth profile on backend outage 2026-05-30 15:06:22 +08:00
2569718930@qq.com c79619898a fix: avoid terminal cold-start paywall race 2026-05-30 14:48:09 +08:00
2569718930@qq.com 1c250906ed fix: keep terminal stable across auth refresh 2026-05-30 14:33:26 +08:00
2569718930@qq.com ed8c898e0e fix: retry deploy smoke checks 2026-05-30 14:24:02 +08:00
2569718930@qq.com 57240e02ee fix: require auth token for manual payments 2026-05-30 14:09:57 +08:00
2569718930@qq.com c26f89f134 feat: implement terminal authentication bootstrap and integrate into ScanTerminalDashboard 2026-05-30 00:05:19 +08:00
2569718930@qq.com b3a63aa9af test: add unit tests for temperature series visibility policy and peak glow state logic 2026-05-29 23:21:17 +08:00
2569718930@qq.com 53268560ec test: implement visibility policy and chart logic verification tests for temperature series 2026-05-29 23:09:16 +08:00
2569718930@qq.com 68416b058e feat: implement temperature chart visibility logic with comprehensive unit testing and analysis service support 2026-05-29 22:40:33 +08:00
2569718930@qq.com 95c2258db0 Fix Turkey MGM panel history 2026-05-29 21:56:57 +08:00
2569718930@qq.com 8507afedd6 Fix account payment layout responsiveness 2026-05-29 21:34:36 +08:00
2569718930@qq.com 3e24080466 feat: implement AccountCenter dashboard with subscription and payment management features 2026-05-29 21:23:12 +08:00
2569718930@qq.com 17ddd835e6 Fix landing hero illustration overlap 2026-05-29 20:57:59 +08:00
2569718930@qq.com 5228d2b3fd Update landing and weather observation policies 2026-05-29 20:46:35 +08:00
2569718930@qq.com 2f039abb2c Add event fallback for referral program 2026-05-29 19:34:56 +08:00
2569718930@qq.com 522e35de7f Add trial and referral subscription program 2026-05-29 19:24:46 +08:00
2569718930@qq.com f8f5035225 Fix runway chart collapse and ops cleanup 2026-05-29 18:40:29 +08:00
2569718930@qq.com 23b5fafb25 Reduce idle payment confirm polling 2026-05-29 18:14:59 +08:00
2569718930@qq.com 2269aaefd7 Reduce Supabase disk IO 2026-05-29 17:22:33 +08:00
2569718930@qq.com 9e3a4e2f45 Fix WU historical current observation merge 2026-05-29 17:20:07 +08:00
2569718930@qq.com 8f52b1d80c Add password reset flow 2026-05-29 12:28:08 +08:00
2569718930@qq.com 1d9f0033a6 Tighten payment tx validation before submit 2026-05-29 12:11:19 +08:00
2569718930@qq.com 618bac8b57 feat: add live temperature threshold chart logic and components for runway monitoring 2026-05-29 02:10:07 +08:00
2569718930@qq.com f2e90fbcda feat: support ethereum usdc payment route 2026-05-29 01:57:42 +08:00
2569718930@qq.com 3cc2251b9b fix: translate settlement runway chart labels 2026-05-28 22:44:36 +08:00
226 changed files with 22778 additions and 2541 deletions
+13 -1
View File
@@ -171,13 +171,24 @@ POLYWEATHER_BOT_DEB_QUERY_COST=1
# Payments # Payments
POLYWEATHER_PAYMENT_ENABLED=false POLYWEATHER_PAYMENT_ENABLED=false
# Default / legacy checkout chain. Keep Polygon as the default because the
# deployed checkout contract currently lives there.
POLYWEATHER_PAYMENT_CHAIN_ID=137 POLYWEATHER_PAYMENT_CHAIN_ID=137
POLYWEATHER_PAYMENT_RPC_URL=https://polygon-rpc.com POLYWEATHER_PAYMENT_RPC_URL=https://polygon-rpc.com
POLYWEATHER_PAYMENT_RPC_URLS=https://polygon-rpc.com POLYWEATHER_PAYMENT_RPC_URLS=https://polygon-rpc.com
# Optional multi-chain RPC map. Required when accepting non-default chain
# transfers such as Ethereum mainnet USDC.
# Example:
# POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON={"137":["https://polygon-rpc.com"],"1":["https://ethereum-rpc.example"]}
POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON=
POLYWEATHER_PAYMENT_RECEIVER_CONTRACT= POLYWEATHER_PAYMENT_RECEIVER_CONTRACT=
POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS= POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS=
POLYWEATHER_PAYMENT_TOKEN_ADDRESS=0x3c499c542cef5e3811e1192ce70d8cc03d5c3359 POLYWEATHER_PAYMENT_TOKEN_ADDRESS=0x3c499c542cef5e3811e1192ce70d8cc03d5c3359
POLYWEATHER_PAYMENT_TOKEN_DECIMALS=6 POLYWEATHER_PAYMENT_TOKEN_DECIMALS=6
# Multi-token / multi-chain payment routes. Token rows can include:
# chain_id, chain_code, chain_name, receiver_contract, direct_receiver_address,
# supports_contract_checkout, supports_direct_transfer, confirmations,
# explorer_tx_url.
POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON= POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON=
POLYWEATHER_PAYMENT_CONFIRMATIONS=2 POLYWEATHER_PAYMENT_CONFIRMATIONS=2
POLYWEATHER_PAYMENT_INTENT_TTL_SEC=1800 POLYWEATHER_PAYMENT_INTENT_TTL_SEC=1800
@@ -189,7 +200,8 @@ POLYWEATHER_PAYMENT_TELEGRAM_NOTIFY_ENABLED=true
POLYWEATHER_PAYMENT_POINTS_ENABLED=true POLYWEATHER_PAYMENT_POINTS_ENABLED=true
POLYWEATHER_PAYMENT_POINTS_PER_USDC=500 POLYWEATHER_PAYMENT_POINTS_PER_USDC=500
POLYWEATHER_PAYMENT_POINTS_MAX_DISCOUNT_USDC=3 POLYWEATHER_PAYMENT_POINTS_MAX_DISCOUNT_USDC=3
POLYWEATHER_PAYMENT_ALLOWED_PLAN_CODES=pro_monthly POLYWEATHER_PAYMENT_POINTS_MAX_DISCOUNT_USDC_BY_PLAN_JSON={"pro_monthly":3,"pro_quarterly":8}
POLYWEATHER_PAYMENT_ALLOWED_PLAN_CODES=pro_monthly,pro_quarterly
POLYWEATHER_PAYMENT_PLAN_CATALOG_JSON= POLYWEATHER_PAYMENT_PLAN_CATALOG_JSON=
POLYMARKET_SIGNAL_EDGE_PCT=2 POLYMARKET_SIGNAL_EDGE_PCT=2
+1
View File
@@ -34,6 +34,7 @@ METEOBLUE_API_KEY=
# Wallet / payments # Wallet / payments
######################################## ########################################
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID= NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON=
POLYWEATHER_PAYMENT_RECEIVER_CONTRACT= POLYWEATHER_PAYMENT_RECEIVER_CONTRACT=
POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS= POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS=
POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON= POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON=
+3
View File
@@ -122,6 +122,9 @@ jobs:
needs: [build-and-push] needs: [build-and-push]
if: github.event_name == 'push' && github.ref == 'refs/heads/main' if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest runs-on: ubuntu-latest
concurrency:
group: polyweather-production-deploy
cancel-in-progress: false
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
+6 -2
View File
@@ -27,7 +27,7 @@ Public docs center: `/docs/intro` on the main site (bilingual product documentat
- Points system live: earn via group chat, welcome bonus (+20), first-message-of-day bonus (+2), weekly participation rewards. - Points system live: earn via group chat, welcome bonus (+20), first-message-of-day bonus (+2), weekly participation rewards.
- `/city` and `/deb` now free (daily cap 10 each); points redeemable for payment discount (`500 pts = 1 USDC`, max `3 USDC`). - `/city` and `/deb` now free (daily cap 10 each); points redeemable for payment discount (`500 pts = 1 USDC`, max `3 USDC`).
- Weekly leaderboard rewards restructured: smaller point bonuses for winners (200/100/50), all active users receive participation rewards. - Weekly leaderboard rewards restructured: smaller point bonuses for winners (200/100/50), all active users receive participation rewards.
- Onchain checkout live: Polygon contract checkout (USDC / USDC.e). - Onchain checkout live: Polygon contract checkout (USDC / USDC.e) plus Ethereum mainnet USDC direct-transfer confirmation.
- Auto-reconciliation live: event listener + periodic confirm loop. - Auto-reconciliation live: event listener + periodic confirm loop.
- Ops dashboard live: `/ops` for memberships, leaderboard, manual point grants, and payment incident triage. - Ops dashboard live: `/ops` for memberships, leaderboard, manual point grants, and payment incident triage.
- Lightweight observability live: `/healthz`, `/api/system/status`, `/metrics`. - Lightweight observability live: `/healthz`, `/api/system/status`, `/metrics`.
@@ -102,7 +102,7 @@ flowchart LR
API --> SSE["SSE /api/events"] API --> SSE["SSE /api/events"]
WX --> SSE WX --> SSE
SSE --> EVENT["Redis Stream / SQLite Event Log"] SSE --> EVENT["Redis Stream / SQLite Event Log"]
ANA --> PAY["Payment State (Intent + Event + Confirm Loop)"] ANA --> PAY["Payment State (Multi-chain Intent + Event + Confirm Loop)"]
ANA --> STATE["SQLite runtime state"] ANA --> STATE["SQLite runtime state"]
``` ```
@@ -198,6 +198,10 @@ docker compose logs -f polyweather | egrep "payment event loop started|payment c
curl http://127.0.0.1:8000/api/payments/runtime curl http://127.0.0.1:8000/api/payments/runtime
``` ```
### Payment chains
Production payment routes are configured by the backend. Polygon remains the default checkout-contract chain, while Ethereum mainnet USDC can be enabled as a direct-transfer route so users who pay on their wallet default network are still confirmed by `intent.chain_id`.
## Telegram Commands ## Telegram Commands
| Command | Purpose | | Command | Purpose |
+7 -7
View File
@@ -14,16 +14,16 @@
![PolyWeather Telegram 跑道推送](frontend/public/static/tel.png) ![PolyWeather Telegram 跑道推送](frontend/public/static/tel.png)
## 当前产品状态(2026-05-28 ## 当前产品状态(2026-05-30
- 已上线订阅制:`Pro 月付 10 USDC` - 已上线订阅制:`Pro 月付 29.9 USDC / 30 天``Pro 季度 79.9 USDC / 90 天`
- 已上线积分体系:群内发言赚分 + 首次发言欢迎奖励 (+20) + 每日首条消息奖励 (+2) + 每周全员参与奖 - 积分获取已切换为邀请制度:被邀请人完成首次 Pro 付款后,邀请人获得 `3500` 积分;Telegram 群发言不再获得积分
- `/city``/deb` 已改为免费(每日各 10 次);积分可用于支付抵扣(`500 分 = 1 USDC`,最多抵 `3 USDC`)。 - `/city``/deb` 已改为免费(每日各 10 次);积分可用于支付抵扣(`500 分 = 1 USDC`月付最多抵 `3 USDC`,季度最多抵 `8 USDC`)。
- 周榜奖励已改造:降低赢家积分加成 (200/100/50),所有周活跃用户均享参与奖 - 邀请首月价:被邀请人首次月付 `20 USDC`;每个邀请人每月最多 10 个有效付费邀请奖励
- 已上线链上支付:Polygon 合约支付(USDC / USDC.e)。 - 已上线链上支付:Polygon 合约支付(USDC / USDC.e+ Ethereum 主网 USDC 直转确认
- 已上线自动补单:事件监听 + 周期确认双链路。 - 已上线自动补单:事件监听 + 周期确认双链路。
- 已上线支付运行态与审计接口:`/api/payments/runtime` - 已上线支付运行态与审计接口:`/api/payments/runtime`
- 已上线轻量运营后台:`/ops`(会员、周榜、补分、支付异常单)。 - 已上线轻量运营后台:`/ops`(会员、积分、补分、支付异常单)。
- 已上线轻量可观测性:`/healthz``/api/system/status``/metrics` - 已上线轻量可观测性:`/healthz``/api/system/status``/metrics`
- 已补最小外部监控栈:Prometheus + Alertmanager + Grafana + Telegram 告警 relay。 - 已补最小外部监控栈:Prometheus + Alertmanager + Grafana + Telegram 告警 relay。
- 实时终端已切换到可重放事件流:可见城市图表通过 `/api/events?cities=...&since_revision=...` 订阅 `city_observation_patch.v1`,生产环境使用 Redis Stream 做短窗口 replay,本地/单进程可回退 SQLite event log。 - 实时终端已切换到可重放事件流:可见城市图表通过 `/api/events?cities=...&since_revision=...` 订阅 `city_observation_patch.v1`,生产环境使用 Redis Stream 做短窗口 replay,本地/单进程可回退 SQLite event log。
+122 -21
View File
@@ -5,6 +5,14 @@ GHCR_PAT="$1"
NEW_TAG="${2:-latest}" NEW_TAG="${2:-latest}"
TAG_FILE="/var/lib/polyweather/.current_tag" TAG_FILE="/var/lib/polyweather/.current_tag"
COMPOSE_DIR="/root/PolyWeather" COMPOSE_DIR="/root/PolyWeather"
LOCK_FILE="${POLYWEATHER_DEPLOY_LOCK_FILE:-/var/lock/polyweather-deploy.lock}"
mkdir -p "$(dirname "$LOCK_FILE")"
exec 9>"$LOCK_FILE"
if ! flock -n 9; then
echo "❌ Another PolyWeather deploy is already running"
exit 1
fi
echo "$GHCR_PAT" | docker login ghcr.io -u yangyuan-zhen --password-stdin echo "$GHCR_PAT" | docker login ghcr.io -u yangyuan-zhen --password-stdin
@@ -17,6 +25,18 @@ if [ -f "$TAG_FILE" ]; then
echo "Previous tag: $PREVIOUS_TAG" echo "Previous tag: $PREVIOUS_TAG"
fi fi
rollback_to_previous() {
if [ -n "$PREVIOUS_TAG" ]; then
echo "Rolling back to $PREVIOUS_TAG..."
export IMAGE_TAG="$PREVIOUS_TAG"
docker compose pull
docker compose up -d
echo "✅ Rolled back to $PREVIOUS_TAG"
else
echo "⚠️ No previous tag to rollback to"
fi
}
export IMAGE_TAG="$NEW_TAG" export IMAGE_TAG="$NEW_TAG"
pull_ok=0 pull_ok=0
for pull_attempt in $(seq 1 6); do for pull_attempt in $(seq 1 6); do
@@ -28,34 +48,115 @@ if [ "$pull_ok" != "1" ]; then
echo "❌ Image pull failed after retries" echo "❌ Image pull failed after retries"
exit 1 exit 1
fi fi
docker compose up -d
# Wait for backend to be ready (retry up to 150s) smoke_check() {
local name="$1"
local url="$2"
local timeout="$3"
local attempts="${4:-6}"
local delay="${5:-5}"
local output=""
for i in $(seq 1 "$attempts"); do
if output=$(curl -fsS -w "http=%{http_code} time=%{time_total}" -o /dev/null --max-time "$timeout" "$url" 2>&1); then
echo "$name ($output)"
return 0
fi
if [ "$i" != "$attempts" ]; then
echo " $name retry $i/$attempts... ($output)"
sleep "$delay"
fi
done
echo "$name ($output)"
return 1
}
wait_for_local_service() {
local name="$1"
local url="$2"
local timeout="${3:-5}"
local attempts="${4:-30}"
local delay="${5:-2}"
for i in $(seq 1 "$attempts"); do
if curl -fsSo /dev/null --max-time "$timeout" "$url"; then
echo "$name ready after attempt $i/$attempts"
return 0
fi
if [ "$i" != "$attempts" ]; then
echo " $name warming $i/$attempts..."
sleep "$delay"
fi
done
echo "$name did not become ready"
return 1
}
warm_public_route() {
local name="$1"
local url="$2"
local timeout="${3:-15}"
local attempts="${4:-3}"
local delay="${5:-2}"
for i in $(seq 1 "$attempts"); do
if curl -fsSo /dev/null --max-time "$timeout" "$url"; then
echo "✅ warmed $name"
return 0
fi
if [ "$i" != "$attempts" ]; then
echo " warm $name retry $i/$attempts..."
sleep "$delay"
fi
done
echo "⚠️ warm $name failed"
return 0
}
echo "Updating Redis dependency..."
docker compose up -d polyweather_redis
echo "Updating backend services..."
docker compose up -d --no-deps polyweather_web polyweather
echo "Waiting for backend..." echo "Waiting for backend..."
for i in $(seq 1 30); do wait_for_local_service "backend healthz" "http://127.0.0.1:8000/healthz" 5 30 5 || FAILED_BACKEND=1
sleep 5 FAILED_BACKEND="${FAILED_BACKEND:-0}"
if curl -fsSo /dev/null --max-time 5 "https://api.polyweather.top/healthz"; then if [ "$FAILED_BACKEND" = "1" ]; then
echo "✅ healthz ready after ${i}x5s" echo "❌ Backend did not become healthy"
break rollback_to_previous
fi exit 1
echo " retry $i/30..." fi
done
echo "Updating frontend..."
docker compose up -d --no-deps polyweather_frontend
echo "Waiting for frontend..."
wait_for_local_service "frontend root" "http://127.0.0.1:3001/" 5 40 2 || FAILED_FRONTEND=1
wait_for_local_service "frontend terminal" "http://127.0.0.1:3001/terminal" 10 20 2 || FAILED_FRONTEND=1
FAILED_FRONTEND="${FAILED_FRONTEND:-0}"
if [ "$FAILED_FRONTEND" = "1" ]; then
echo "❌ Frontend did not become healthy"
rollback_to_previous
exit 1
fi
warm_public_route "terminal" "https://polyweather.top/terminal" 20 4 3
warm_public_route "auth snapshot" "https://polyweather.top/api/auth/me?prefer_snapshot=1" 10 3 2
warm_public_route "local cities recent stats" "http://127.0.0.1:8000/api/cities?refresh_deb_recent=1" 15 2 2
warm_public_route "cities" "https://polyweather.top/api/cities" 20 3 2
FAILED=0 FAILED=0
curl -fsSo /dev/null --max-time 15 "https://api.polyweather.top/healthz" && echo "✅ healthz" || { echo "❌ healthz"; FAILED=1; } smoke_check "healthz" "https://api.polyweather.top/healthz" 15 3 5 || FAILED=1
curl -fsSo /dev/null --max-time 10 "https://api.polyweather.top/api/cities" && echo "✅ cities" || { echo "❌ cities"; FAILED=1; } smoke_check "frontend cities" "https://polyweather.top/api/cities" 20 5 5 || FAILED=1
curl -fsSo /dev/null --max-time 10 "https://www.polyweather.top/" && echo "✅ frontend" || { echo "❌ frontend"; FAILED=1; } smoke_check "frontend" "https://www.polyweather.top/" 15 3 5 || FAILED=1
if [ "$FAILED" = "1" ]; then if [ "$FAILED" = "1" ]; then
echo "❌ Smoke tests failed. Rolling back..." echo "❌ Smoke tests failed. Rolling back..."
if [ -n "$PREVIOUS_TAG" ]; then rollback_to_previous
export IMAGE_TAG="$PREVIOUS_TAG"
docker compose pull
docker compose up -d
echo "✅ Rolled back to $PREVIOUS_TAG"
else
echo "⚠️ No previous tag to rollback to"
fi
exit 1 exit 1
fi fi
+3 -2
View File
@@ -69,6 +69,7 @@ services:
POLYWEATHER_API_BASE_URL: ${POLYWEATHER_API_BASE_URL:-http://polyweather_web:8000} POLYWEATHER_API_BASE_URL: ${POLYWEATHER_API_BASE_URL:-http://polyweather_web:8000}
POLYWEATHER_AUTH_ENABLED: ${POLYWEATHER_AUTH_ENABLED:-true} POLYWEATHER_AUTH_ENABLED: ${POLYWEATHER_AUTH_ENABLED:-true}
POLYWEATHER_AUTH_REQUIRED: ${POLYWEATHER_AUTH_REQUIRED:-true} POLYWEATHER_AUTH_REQUIRED: ${POLYWEATHER_AUTH_REQUIRED:-true}
POLYWEATHER_OPS_ADMIN_EMAILS: ${POLYWEATHER_OPS_ADMIN_EMAILS:-}
healthcheck: healthcheck:
interval: 30s interval: 30s
retries: 3 retries: 3
@@ -78,7 +79,7 @@ services:
timeout: 5s timeout: 5s
image: ghcr.io/yangyuan-zhen/polyweather-frontend:${IMAGE_TAG:-latest} image: ghcr.io/yangyuan-zhen/polyweather-frontend:${IMAGE_TAG:-latest}
ports: ports:
- 3001:3000 - "127.0.0.1:3001:3000"
restart: unless-stopped restart: unless-stopped
polyweather_web: polyweather_web:
command: python web/app.py command: python web/app.py
@@ -103,7 +104,7 @@ services:
timeout: 5s timeout: 5s
image: ghcr.io/yangyuan-zhen/polyweather-backend:${IMAGE_TAG:-latest} image: ghcr.io/yangyuan-zhen/polyweather-backend:${IMAGE_TAG:-latest}
ports: ports:
- 8000:8000 - "127.0.0.1:8000:8000"
restart: unless-stopped restart: unless-stopped
user: ${UID:-1000}:${GID:-1000} user: ${UID:-1000}:${GID:-1000}
volumes: volumes:
+7
View File
@@ -238,6 +238,13 @@ DEB hourly consensus 是当前图表与峰值窗口的优先小时路径。
### 支付状态建议 ### 支付状态建议
`POST /api/payments/intents` 支持的关键字段:
- `plan_code`:套餐,例如 `pro_monthly`
- `payment_mode``wallet``direct`
- `chain_id`:可选;多链支付时前端传用户选择的链,例如 Polygon `137` 或 Ethereum `1`
- `token_address`:可选;指定该链上的 USDC / USDC.e 合约地址
前端流程建议: 前端流程建议:
1. `POST /intents` 1. `POST /intents`
+11 -7
View File
@@ -1,6 +1,6 @@
# 商业化说明(Production # 商业化说明(Production
最后更新:`2026-05-28` 最后更新:`2026-05-30`
## 1. 定位 ## 1. 定位
@@ -18,10 +18,12 @@ PolyWeather 是面向温度结算场景的气象决策层,不是通用天气
| 能力 | 状态 | 备注 | | 能力 | 状态 | 备注 |
| :-- | :-- | :-- | | :-- | :-- | :-- |
| 登录注册(Google + 邮箱) | 已上线 | Supabase 鉴权 | | 登录注册(Google + 邮箱) | 已上线 | Supabase 鉴权 |
| 订阅套餐(Pro 月付) | 已上线 | `10 USDC / 30天` | | 订阅套餐(Pro 月付 / 季度 | 已上线 | `29.9 USDC / 30天``79.9 USDC / 90天` |
| 积分抵扣 | 已上线 | `500分=1U`,最多 `3U` | | 邀请积分 | 已上线 | 被邀请人首月 `20U`;邀请人每个有效付费邀请 `+3500` 积分 |
| 积分抵扣 | 已上线 | `500分=1U`,最多 `3U`;邀请首月价不叠加积分抵扣 |
| 合约支付 | 已上线 | PolygonUSDC + USDC.e | | 合约支付 | 已上线 | PolygonUSDC + USDC.e |
| 支付自动确认 | 已上线 | Event Loop + Confirm Loop | | 多链直转支付 | 已上线 | Ethereum 主网 USDC 直转确认 |
| 支付自动确认 | 已上线 | Event Loop + Confirm Loop,按 intent.chain_id 查链 |
| 钱包绑定 | 已上线 | 浏览器钱包 + WalletConnect | | 钱包绑定 | 已上线 | 浏览器钱包 + WalletConnect |
| 私有频道推送 | 已上线 | 可拆分业务频道 | | 私有频道推送 | 已上线 | 可拆分业务频道 |
| 实时终端 | 已上线 | SSE Patch + Redis Stream replay | | 实时终端 | 已上线 | SSE Patch + Redis Stream replay |
@@ -41,9 +43,11 @@ PolyWeather 是面向温度结算场景的气象决策层,不是通用天气
## 4. 收费与积分规则(默认) ## 4. 收费与积分规则(默认)
- 套餐:`pro_monthly`10 USDC / 30 天) - 套餐:`pro_monthly`29.9 USDC / 30 天)`pro_quarterly`79.9 USDC / 90 天)
- 抵扣:500 积分抵 1 USDC,最高抵 3 USDC - 邀请首月:被邀请人首次月付 20 USDC
- 实付下限:2 USDC(当积分满额时) - 邀请奖励:邀请人 +3500 积分,每月最多 10 个有效付费邀请
- 抵扣:500 积分抵 1 USDC;月付最高抵 3 USDC,季度最高抵 8 USDC
- 积分来源:仅通过有效付费邀请和后台人工补发,不再通过 Telegram 群发言发放
> 说明:具体运营策略可按阶段调整,生产参数建议放私有仓库。 > 说明:具体运营策略可按阶段调整,生产参数建议放私有仓库。
+30 -1
View File
@@ -110,6 +110,7 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
- `POLYWEATHER_EVENT_STORE` - `POLYWEATHER_EVENT_STORE`
- `POLYWEATHER_REDIS_REQUIRED` - `POLYWEATHER_REDIS_REQUIRED`
- `POLYWEATHER_PAYMENT_ENABLED` - `POLYWEATHER_PAYMENT_ENABLED`
- `POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON`
- `POLYGON_WALLET_WATCH_ENABLED` - `POLYGON_WALLET_WATCH_ENABLED`
- `TELEGRAM_ALERT_PUSH_ENABLED` - `TELEGRAM_ALERT_PUSH_ENABLED`
- `TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED` - `TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED`
@@ -131,6 +132,7 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
- `TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC` - `TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC`
- `TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N` - `TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N`
- `POLYWEATHER_PAYMENT_RPC_URLS` - `POLYWEATHER_PAYMENT_RPC_URLS`
- `POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON`
- `TAF_CACHE_TTL_SEC` - `TAF_CACHE_TTL_SEC`
- `POLYWEATHER_REDIS_URL` - `POLYWEATHER_REDIS_URL`
- `POLYWEATHER_REDIS_STREAM_KEY` - `POLYWEATHER_REDIS_STREAM_KEY`
@@ -259,7 +261,8 @@ POLYWEATHER_BACKEND_URL=http://polyweather_web:8000
- `POLYWEATHER_STATE_STORAGE_MODE` 当前线上推荐直接使用 `sqlite` - `POLYWEATHER_STATE_STORAGE_MODE` 当前线上推荐直接使用 `sqlite`
- `POLYWEATHER_EVENT_STORE=redis` 表示实时观测 patch 使用 Redis Stream 做短窗口 replay 和多 worker fanout;本地或单进程可改为 `sqlite` - `POLYWEATHER_EVENT_STORE=redis` 表示实时观测 patch 使用 Redis Stream 做短窗口 replay 和多 worker fanout;本地或单进程可改为 `sqlite`
- `POLYWEATHER_REDIS_REQUIRED=true` 表示 Redis 不可用时后端启动失败,避免生产环境广播不可 replay 的实时事件;开发环境可设为 `false` 允许回退 SQLite。 - `POLYWEATHER_REDIS_REQUIRED=true` 表示 Redis 不可用时后端启动失败,避免生产环境广播不可 replay 的实时事件;开发环境可设为 `false` 允许回退 SQLite。
- `POLYWEATHER_PAYMENT_RPC_URLS` 支持逗号分隔多个 RPC;如果暂时只用单 RPC,也可以继续只配 `POLYWEATHER_PAYMENT_RPC_URL` - `POLYWEATHER_PAYMENT_RPC_URLS` 支持默认链的逗号分隔多个 RPC;如果暂时只用单 RPC,也可以继续只配 `POLYWEATHER_PAYMENT_RPC_URL`
- `POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON` 用于多链支付,例如同时支持 Polygon 和 Ethereum 主网 USDC。
- 机器人市场监控包含 `关键提醒``关注清单`:关键提醒逐城判断并受冷却控制,关注清单每轮先扫描完整城市列表,再按全局 Top N 推送;同一轮已经触发关键提醒的城市不会重复出现在关注清单里。 - 机器人市场监控包含 `关键提醒``关注清单`:关键提醒逐城判断并受冷却控制,关注清单每轮先扫描完整城市列表,再按全局 Top N 推送;同一轮已经触发关键提醒的城市不会重复出现在关注清单里。
- `TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC` 表示主动推送间隔,默认 `1800` 秒(30 分钟)。 - `TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC` 表示主动推送间隔,默认 `1800` 秒(30 分钟)。
@@ -267,6 +270,32 @@ POLYWEATHER_BACKEND_URL=http://polyweather_web:8000
- 这层只负责把结构化信号改写成短摘要,不替代真实模型、机场锚点和结算逻辑。 - 这层只负责把结构化信号改写成短摘要,不替代真实模型、机场锚点和结算逻辑。
### 6.3 支付多链配置示例
当前生产推荐:
- 默认链:Polygon `chain_id=137`,继续承载 checkout 合约支付。
- 补充链:Ethereum Mainnet `chain_id=1`,正式支持 USDC 直转确认。
- 前端创建支付 intent 时会提交用户选择的 `chain_id`;后端确认时按 intent 的链和 token 查询对应 RPC。
```env
POLYWEATHER_PAYMENT_ENABLED=true
POLYWEATHER_PAYMENT_CHAIN_ID=137
POLYWEATHER_PAYMENT_RPC_URL=https://polygon-rpc.com
POLYWEATHER_PAYMENT_RPC_URLS=https://polygon-rpc.com,https://polygon-bor-rpc.publicnode.com
POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON={"137":["https://polygon-rpc.com","https://polygon-bor-rpc.publicnode.com"],"1":["https://ethereum-rpc.example"]}
POLYWEATHER_PAYMENT_RECEIVER_CONTRACT=0x<polygon_checkout_contract>
POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS=0x<treasury_or_receiver_wallet>
POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON=[{"code":"usdc_polygon","symbol":"USDC","name":"USDC on Polygon","chain_id":137,"chain_code":"polygon","chain_name":"Polygon","address":"0x3c499c542cef5e3811e1192ce70d8cc03d5c3359","decimals":6,"receiver_contract":"0x<polygon_checkout_contract>","direct_receiver_address":"0x<treasury_or_receiver_wallet>","is_default":true},{"code":"usdc_ethereum","symbol":"USDC","name":"USDC on Ethereum","chain_id":1,"chain_code":"ethereum","chain_name":"Ethereum Mainnet","address":"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48","decimals":6,"direct_receiver_address":"0x<treasury_or_receiver_wallet>","supports_contract_checkout":false,"supports_direct_transfer":true,"explorer_tx_url":"https://etherscan.io/tx/{tx_hash}"}]
```
注意:
- `POLYWEATHER_PAYMENT_CHAIN_ID` 只是默认链,不代表只支持这一条链。
- `POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON` 里每个 token 必须有明确 `chain_id`
- Ethereum 行如果没有部署 checkout 合约,必须设置 `supports_contract_checkout=false`,前端会显示手动转账并阻止钱包合约支付。
- 私有 RPC URL 带 API key 时应放入真实 `.env` 或密钥管理,不要提交。
### 6.5 机器人市场监控建议配置 ### 6.5 机器人市场监控建议配置
这套配置围绕市场本身做两类推送: 这套配置围绕市场本身做两类推送:
+4 -1
View File
@@ -1,6 +1,6 @@
# 前端部署配置(Vercel # 前端部署配置(Vercel
最后更新:`2026-05-28` 最后更新:`2026-05-29`
本文只覆盖 `frontend` 目录对应的 Next.js 前端部署。 本文只覆盖 `frontend` 目录对应的 Next.js 前端部署。
@@ -127,12 +127,15 @@ NEXT_PUBLIC_POLYWEATHER_EAGER_CITY_SUMMARIES=false
1. 用户点击支付前,前端会重新请求 `/api/payments/config` 1. 用户点击支付前,前端会重新请求 `/api/payments/config`
2. 若发现 `receiver_contract` 与页面旧状态不一致,会自动切换到最新地址 2. 若发现 `receiver_contract` 与页面旧状态不一致,会自动切换到最新地址
3. 若后端返回的 `tx_payload.to` 与最新 `receiver_contract` 不一致,会直接阻断支付 3. 若后端返回的 `tx_payload.to` 与最新 `receiver_contract` 不一致,会直接阻断支付
4. 多链支付时,前端会展示后端返回的网络列表,并把用户选择的 `chain_id` 传给后端创建 intent
5. Ethereum 主网 USDC 当前走手动直转确认,前端不会把它当成 Polygon checkout 合约支付
这层防护的目的,是降低以下事故概率: 这层防护的目的,是降低以下事故概率:
- 用户使用长期未刷新的旧标签页 - 用户使用长期未刷新的旧标签页
- 命中旧 deployment URL - 命中旧 deployment URL
- 页面本地状态残留旧收款地址 - 页面本地状态残留旧收款地址
- 用户在钱包默认网络(例如 Ethereum)付款,但系统按 Polygon intent 查账
如果你变更过支付收款地址,建议同步执行: 如果你变更过支付收款地址,建议同步执行:
+4 -2
View File
@@ -1,6 +1,6 @@
# 外部服务依赖总览 # 外部服务依赖总览
最后更新:`2026-05-28` 最后更新:`2026-05-29`
项目调用外部天气、鉴权、支付和实时事件服务。原则是:核心链路必须有明确健康检查;可选数据源不可拖垮已可用城市;实时事件层可从 Redis Stream 降级到 SQLite event log。 项目调用外部天气、鉴权、支付和实时事件服务。原则是:核心链路必须有明确健康检查;可选数据源不可拖垮已可用城市;实时事件层可从 Redis Stream 降级到 SQLite event log。
@@ -52,7 +52,8 @@
| --- | --- | --- | | --- | --- | --- |
| MiMo (xiaomimimo) | 城市分析 AI 评论 | ✅ 当前使用 | | MiMo (xiaomimimo) | 城市分析 AI 评论 | ✅ 当前使用 |
| DeepSeek | AI fallback | 备用 | | DeepSeek | AI fallback | 备用 |
| Polygon RPC | 链上支付、自动确认 | ✅ | | Polygon RPC | checkout 合约支付、Polygon USDC / USDC.e 自动确认 | ✅ |
| Ethereum RPC | Ethereum 主网 USDC 直转确认 | ✅(启用多链支付时必须) |
| WalletConnect | 前端钱包连接 | ⚠️ 未配 key 时钱包入口降级 | | WalletConnect | 前端钱包连接 | ⚠️ 未配 key 时钱包入口降级 |
## 运维口径 ## 运维口径
@@ -61,3 +62,4 @@
- 本地或单进程兜底:`POLYWEATHER_EVENT_STORE=sqlite` - 本地或单进程兜底:`POLYWEATHER_EVENT_STORE=sqlite`
- Redis 只负责短窗口 replay 与多 worker fanout,不是长期天气历史库。 - Redis 只负责短窗口 replay 与多 worker fanout,不是长期天气历史库。
- DEB hourly consensus 依赖 Open-Meteo 多模型小时曲线;若上游限流,图表应保留已有 snapshot 和实测 patch,不把缺失模型误报为实测缺失。 - DEB hourly consensus 依赖 Open-Meteo 多模型小时曲线;若上游限流,图表应保留已有 snapshot 和实测 patch,不把缺失模型误报为实测缺失。
- 支付多链确认依赖 `POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON`;如果启用 Ethereum 主网 USDC,必须配置 `chain_id=1` 的 RPC,否则用户提交 Ethereum tx hash 后无法自动确认。
+32 -8
View File
@@ -1,12 +1,12 @@
# Supabase + 登录 + 支付接入说明(v1.8.1) # Supabase + 登录 + 支付接入说明(v1.8.1)
最后更新:`2026-03-14` 最后更新:`2026-05-29`
## 1. 目标 ## 1. 目标
- 前端支持 Google 一键登录 + 邮箱注册/登录。 - 前端支持 Google 一键登录 + 邮箱注册/登录。
- 后端支持 Supabase JWT 鉴权。 - 后端支持 Supabase JWT 鉴权。
- 支持 Polygon 合约支付(USDC / USDC.e)并自动确认开通订阅。 - 支持 Polygon 合约支付(USDC / USDC.e和 Ethereum 主网 USDC 直转支付,并自动确认开通订阅。
## 2. Supabase 控制台配置 ## 2. Supabase 控制台配置
@@ -22,6 +22,7 @@
在 Supabase SQL Editor 执行: 在 Supabase SQL Editor 执行:
- `scripts/supabase/schema.sql` - `scripts/supabase/schema.sql`
- 既有生产项目遇到 Disk IO Budget 告警时,再执行 `scripts/supabase/io_budget_indexes.sql`
会创建支付与订阅相关表: 会创建支付与订阅相关表:
@@ -33,6 +34,15 @@
- `payment_intents` - `payment_intents`
- `payment_transactions` - `payment_transactions`
### 3.1 Disk IO 告警处理
如果 Supabase 提示项目正在耗尽 Disk IO Budget,先在 SQL Editor 执行:
1. `scripts/supabase/io_budget_indexes.sql`
2. `scripts/supabase/disk_io_diagnostics.sql`
第一个脚本会把热查询索引收敛为更低写放大的部分索引,移除已被唯一约束覆盖或无热路径使用的冗余索引,并对相关表执行 `ANALYZE`。第二个脚本用于查看表扫描、dead tuples、索引命中和 `pg_stat_statements` 中的高读块 SQL。生产执行后,继续观察 Supabase daily/hourly Disk IO 图表,确认请求延迟和 IO wait 是否下降。
## 4. 环境变量 ## 4. 环境变量
### 4.1 前端(Vercel / frontend/.env.local ### 4.1 前端(Vercel / frontend/.env.local
@@ -66,30 +76,38 @@ SUPABASE_SERVICE_ROLE_KEY=
SUPABASE_HTTP_TIMEOUT_SEC=8 SUPABASE_HTTP_TIMEOUT_SEC=8
POLYWEATHER_PAYMENT_ENABLED=true POLYWEATHER_PAYMENT_ENABLED=true
# 默认链仍是 Polygon,因为当前 checkout 合约部署在 Polygon。
POLYWEATHER_PAYMENT_CHAIN_ID=137 POLYWEATHER_PAYMENT_CHAIN_ID=137
POLYWEATHER_PAYMENT_RPC_URL=https://polygon-bor-rpc.publicnode.com POLYWEATHER_PAYMENT_RPC_URL=https://polygon-bor-rpc.publicnode.com
POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON={"137":["https://polygon-bor-rpc.publicnode.com"],"1":["https://ethereum-rpc.example"]}
POLYWEATHER_PAYMENT_RECEIVER_CONTRACT=0x<receiver_contract> POLYWEATHER_PAYMENT_RECEIVER_CONTRACT=0x<receiver_contract>
POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS=0x<treasury_or_receiver_wallet>
POLYWEATHER_PAYMENT_CONFIRMATIONS=2 POLYWEATHER_PAYMENT_CONFIRMATIONS=2
POLYWEATHER_PAYMENT_INTENT_TTL_SEC=1800 POLYWEATHER_PAYMENT_INTENT_TTL_SEC=1800
POLYWEATHER_PAYMENT_WALLET_CHALLENGE_TTL_SEC=600 POLYWEATHER_PAYMENT_WALLET_CHALLENGE_TTL_SEC=600
POLYWEATHER_PAYMENT_POLL_INTERVAL_SEC=4 POLYWEATHER_PAYMENT_POLL_INTERVAL_SEC=4
POLYWEATHER_PAYMENT_MAX_WAIT_SEC=50 POLYWEATHER_PAYMENT_MAX_WAIT_SEC=50
# 支持币种(示例) # 支持多链多币种(示例)
POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON=[{"code":"usdc_e","symbol":"USDC.e","name":"USDC.e (PoS)","address":"0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174","decimals":6,"receiver_contract":"0x<receiver>","is_default":true},{"code":"usdc","symbol":"USDC","name":"Native USDC","address":"0x3c499c542cef5e3811e1192ce70d8cc03d5c3359","decimals":6,"receiver_contract":"0x<receiver>"}] # Ethereum 主网 USDC 当前建议只开 direct transfer,不走 Polygon checkout 合约。
POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON=[{"code":"usdc_polygon","symbol":"USDC","name":"USDC on Polygon","chain_id":137,"chain_code":"polygon","chain_name":"Polygon","address":"0x3c499c542cef5e3811e1192ce70d8cc03d5c3359","decimals":6,"receiver_contract":"0x<receiver_contract>","direct_receiver_address":"0x<treasury_or_receiver_wallet>","is_default":true},{"code":"usdc_e_polygon","symbol":"USDC.e","name":"USDC.e on Polygon","chain_id":137,"chain_code":"polygon","chain_name":"Polygon","address":"0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174","decimals":6,"receiver_contract":"0x<receiver_contract>","direct_receiver_address":"0x<treasury_or_receiver_wallet>"},{"code":"usdc_ethereum","symbol":"USDC","name":"USDC on Ethereum","chain_id":1,"chain_code":"ethereum","chain_name":"Ethereum Mainnet","address":"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48","decimals":6,"direct_receiver_address":"0x<treasury_or_receiver_wallet>","supports_contract_checkout":false,"supports_direct_transfer":true,"confirmations":2,"explorer_tx_url":"https://etherscan.io/tx/{tx_hash}"}]
# 套餐(当前只保留月付) # 套餐
POLYWEATHER_PAYMENT_PLAN_CATALOG_JSON={"pro_monthly":{"plan_id":101,"amount_usdc":"5","duration_days":30}} POLYWEATHER_PAYMENT_PLAN_CATALOG_JSON={"pro_monthly":{"plan_id":101,"amount_usdc":"29.9","duration_days":30},"pro_quarterly":{"plan_id":102,"amount_usdc":"79.9","duration_days":90}}
POLYWEATHER_PAYMENT_ALLOWED_PLAN_CODES=pro_monthly POLYWEATHER_PAYMENT_ALLOWED_PLAN_CODES=pro_monthly,pro_quarterly
# 积分抵扣 # 积分抵扣
POLYWEATHER_PAYMENT_POINTS_ENABLED=true POLYWEATHER_PAYMENT_POINTS_ENABLED=true
POLYWEATHER_PAYMENT_POINTS_PER_USDC=500 POLYWEATHER_PAYMENT_POINTS_PER_USDC=500
POLYWEATHER_PAYMENT_POINTS_MAX_DISCOUNT_USDC=3 POLYWEATHER_PAYMENT_POINTS_MAX_DISCOUNT_USDC=3
POLYWEATHER_PAYMENT_POINTS_MAX_DISCOUNT_USDC_BY_PLAN_JSON={"pro_monthly":3,"pro_quarterly":8}
# 支付自动补单 # 支付自动补单
POLYWEATHER_PAYMENT_EVENT_LOOP_ENABLED=true POLYWEATHER_PAYMENT_EVENT_LOOP_ENABLED=true
POLYWEATHER_PAYMENT_CONFIRM_LOOP_ENABLED=true POLYWEATHER_PAYMENT_CONFIRM_LOOP_ENABLED=true
POLYWEATHER_PAYMENT_CONFIRM_LOOP_INTERVAL_SEC=20
POLYWEATHER_PAYMENT_CONFIRM_LOOP_IDLE_INTERVAL_SEC=300
POLYWEATHER_PAYMENT_CONFIRM_LOOP_IDLE_AFTER_EMPTY_CYCLES=3
``` ```
## 5. 验证步骤 ## 5. 验证步骤
@@ -100,10 +118,16 @@ POLYWEATHER_PAYMENT_CONFIRM_LOOP_ENABLED=true
- `POST /api/payments/wallets/challenge` - `POST /api/payments/wallets/challenge`
- `POST /api/payments/wallets/verify` - `POST /api/payments/wallets/verify`
4. 支付流程: 4. 支付流程:
- `POST /api/payments/intents` - `POST /api/payments/intents`;多链时前端会带 `chain_id``token_address`
- 发链上交易 - 发链上交易
- `POST /api/payments/intents/{id}/submit` - `POST /api/payments/intents/{id}/submit`
- `POST /api/payments/intents/{id}/confirm` - `POST /api/payments/intents/{id}/confirm`
5. 若前端显示 pending,轮询: 5. 若前端显示 pending,轮询:
- `GET /api/payments/intents/{id}` - `GET /api/payments/intents/{id}`
6. 确认订阅:`/api/auth/me` 返回 `subscription_active=true` 6. 确认订阅:`/api/auth/me` 返回 `subscription_active=true`
## 6. 多链支付口径
- Polygon 是默认链,仍支持钱包合约支付和手动直转确认。
- Ethereum 主网 USDC 是正式支付链路,但当前建议只走手动直转:用户选择 Ethereum 后,前端展示收款钱包、金额、代币合约和 Etherscan 链接,用户提交 tx hash 后后端按 `intent.chain_id=1` 查询 Ethereum RPC。
- 不要只依赖前端文案阻止错链付款;后端必须把每笔 intent 的 `chain_id``token_address``receiver_address` 落库,并在确认时按该链校验 `Transfer` 事件。
+1 -1
View File
@@ -60,7 +60,7 @@ flowchart TD
| 项目 | 影响 | 建议动作 | | 项目 | 影响 | 建议动作 |
| :-- | :-- | :-- | | :-- | :-- | :-- |
| 积分发放可解释性 | 用户理解成本高 | 输出积分来源明细(发言/首次消息奖励/欢迎奖励/周排名奖励/周参与奖/手动补分 | | 积分发放可解释性 | 用户理解成本高 | 输出积分来源明细(有效付费邀请/后台人工补发/积分抵扣消费 |
| 支付合约 V2 升级 | 当前仍是最小可用合约 | 升级到 SafeERC20 + Pausable + plan 绑定 | | 支付合约 V2 升级 | 当前仍是最小可用合约 | 升级到 SafeERC20 + Pausable + plan 绑定 |
| 支付失败文案标准化 | 转化率受影响 | 建立错误码 -> 文案映射表 | | 支付失败文案标准化 | 转化率受影响 | 建立错误码 -> 文案映射表 |
+5 -5
View File
@@ -2,9 +2,9 @@
## 执行摘要 ## 执行摘要
PolyWeather(仓库:`yangyuan-zhen/PolyWeather`)定位为**面向温度类结算预测市场(如 Polymarket 的温度结算合约)**的”生产级气象情报系统”,核心在于把多源天气观测/预报转化为**结算导向的概率桶(μ + bucket distribution**;同时提供 Web 仪表盘与 Telegram Bot 两套交互入口,并包含 Polygon 链上 USDC/USDC.e 支付、自动补单与订阅/积分体系。项目 README 现明确仓库代码采用 `AGPL-3.0-only`,同时将品牌、商标、生产私有数据与运营阈值保留在代码许可证之外。 PolyWeather(仓库:`yangyuan-zhen/PolyWeather`)定位为**面向温度类结算预测市场(如 Polymarket 的温度结算合约)**的”生产级气象情报系统”,核心在于把多源天气观测/预报转化为**结算导向的概率桶(μ + bucket distribution**;同时提供 Web 仪表盘与 Telegram Bot 两套交互入口,并包含 Polygon 链上 USDC/USDC.e 支付、Ethereum 主网 USDC 直转确认、自动补单与订阅/积分体系。项目 README 现明确仓库代码采用 `AGPL-3.0-only`,同时将品牌、商标、生产私有数据与运营阈值保留在代码许可证之外。
> **2026-05-28 更新(v1.8.1**:终端图表已切换为 HTTP snapshot + SSE Patch + Redis Stream/SQLite replay 架构;DEB hourly consensus (`deb_hourly_consensus.v1`) 成为峰值窗口和 DEB 曲线展示的优先小时路径;README 产品截图迁移到 `frontend/public/static` > **2026-05-29 更新**:支付 intent 已支持多链 `chain_id`Polygon 继续承载 checkout 合约,Ethereum 主网 USDC 作为直转确认通道;终端图表继续使用 HTTP snapshot + SSE Patch + Redis Stream/SQLite replay 架构
## 项目概览 ## 项目概览
PolyWeather 的目标与范围在 README/README_ZH 中定义得较清楚:为温度结算市场提供气象情报(多源采集→融合→概率→对照市场报价),并提供“官方看板(Vercel 前端)+ VPS 后端 + Telegram Bot”。 PolyWeather 的目标与范围在 README/README_ZH 中定义得较清楚:为温度结算市场提供气象情报(多源采集→融合→概率→对照市场报价),并提供“官方看板(Vercel 前端)+ VPS 后端 + Telegram Bot”。
@@ -15,7 +15,7 @@ DEBDynamic Error Balancing)基于过去 N 天模型误差(MAE)倒数加
趋势/概率引擎在 `trend_engine.py` 中实现:综合“集合预报区间→σ/μ→高温窗口→死盘判定→温度桶概率分布→边界提示”等,用于 bot 展示与 web 结构化数据输出。 趋势/概率引擎在 `trend_engine.py` 中实现:综合“集合预报区间→σ/μ→高温窗口→死盘判定→温度桶概率分布→边界提示”等,用于 bot 展示与 web 结构化数据输出。
**城市决策层(Scan Terminal / 结构化实况层)**:地图点击城市后加入城市决策卡,前端拉取 full detail、多模型区间、最新 METAR/官方站点/跑道观测,并通过 `/api/city/{name}/detail` 生成城市级结构化数据。终端图表使用 HTTP snapshot + SSE patch + Redis/SQLite replay,最高温中枢优先使用 DEB hourly consensus,再结合多模型中心、日内 pace 和当前实测。 **城市决策层(Scan Terminal / 结构化实况层)**:地图点击城市后加入城市决策卡,前端拉取 full detail、多模型区间、最新 METAR/官方站点/跑道观测,并通过 `/api/city/{name}/detail` 生成城市级结构化数据。终端图表使用 HTTP snapshot + SSE patch + Redis/SQLite replay,最高温中枢优先使用 DEB hourly consensus,再结合多模型中心、日内 pace 和当前实测。
**市场层(Polymarket 行情对照)***[v1.7.0 已移除]* 原先从 Gamma API 发现市场、从 CLOB 读取价格/盘口并计算”模型-市场差”,已于 2026-05-23 随 Polymarket 价格拉取层一并删除。当前 `market_scan` 返回空。 **市场层(Polymarket 行情对照)***[v1.7.0 已移除]* 原先从 Gamma API 发现市场、从 CLOB 读取价格/盘口并计算”模型-市场差”,已于 2026-05-23 随 Polymarket 价格拉取层一并删除。当前 `market_scan` 返回空。
**商业化与支付**:订阅(`Pro Monthly 10 USDC`)、积分抵扣、Polygon 链上收款合约(USDC/USDC.e),并提供“事件监听 + 周期确认”的自动补单机制。 **商业化与支付**:订阅(`Pro Monthly 29.9 USDC / 30 天``Pro Quarterly 79.9 USDC / 90 天`)、邀请积分抵扣、Polygon 链上收款合约(USDC/USDC.e、Ethereum 主网 USDC 直转确认,并提供“事件监听 + 周期确认”的自动补单机制。
**支持的数据集/数据源**:项目不是传统“训练数据集+模型训练”的机器学习仓库;其“数据集”本质是外部实时/预报 API 与站点观测数据。对外部数据的使用需要遵守来源方的访问与速率限制,例如 AviationWeather Data API 明确限制请求频率(含每分钟请求上限/建议降低频率与使用缓存文件)。 **支持的数据集/数据源**:项目不是传统“训练数据集+模型训练”的机器学习仓库;其“数据集”本质是外部实时/预报 API 与站点观测数据。对外部数据的使用需要遵守来源方的访问与速率限制,例如 AviationWeather Data API 明确限制请求频率(含每分钟请求上限/建议降低频率与使用缓存文件)。
**许可证**:仓库根目录 `LICENSE` 当前为 `AGPL-3.0-only`。同时 README 与策略文档明确:品牌、商标、生产私有数据与运营策略不随代码许可证一并授权。 **许可证**:仓库根目录 `LICENSE` 当前为 `AGPL-3.0-only`。同时 README 与策略文档明确:品牌、商标、生产私有数据与运营策略不随代码许可证一并授权。
(插图:项目 README 中包含产品截图,可用于快速理解实时终端与 Telegram 推送形态) (插图:项目 README 中包含产品截图,可用于快速理解实时终端与 Telegram 推送形态)
@@ -71,7 +71,7 @@ JSON[Legacy JSON files<br/>migration/export/explicit fallback only]
HKO[data.weather.gov.hk] HKO[data.weather.gov.hk]
CWA[opendata.cwa.gov.tw] CWA[opendata.cwa.gov.tw]
SB[Supabase Auth/REST] SB[Supabase Auth/REST]
RPC[Polygon RPC] RPC[Polygon / Ethereum RPC]
end end
subgraph Payments subgraph Payments
@@ -148,7 +148,7 @@ Web/Telegram 请求 → FastAPI 调用采集器抓取/复用缓存 → 分析引
### 优势 ### 优势
**产品闭环完整、目标明确**:从“天气→结算→市场→错价信号→付费体系(订阅/积分/链上支付)”形成可商业化闭环,并在 README 清晰列出当前产品状态(订阅、积分抵扣、链上支付、自动补单等已上线)。2026-05 完成积分制度改造:`/city` `/deb` 改为免费(每日各 10 次),新增首次发言欢迎奖励与每日首条消息奖励,周奖励降低赢家积分差距并增加全员参与奖 **产品闭环完整、目标明确**:从“天气→结算→市场→错价信号→付费体系(订阅/积分/链上支付)”形成可商业化闭环,并在 README 清晰列出当前产品状态(订阅、积分抵扣、链上支付、自动补单等已上线)。2026-05 完成积分制度改造:`/city` `/deb` 改为免费(每日各 10 次),积分获取切换为有效付费邀请奖励;Telegram 群发言不再发放积分
**复用一套分析内核服务多端**:趋势/概率/DEB 等核心逻辑被抽成分析模块,并被 web 与 bot 共用,避免“两套逻辑漂移”。前端城市决策卡在此基础上补足“机场报文解释 + 市场桶动作口径”,让用户从地图点击可以直接进入可解释决策。 **复用一套分析内核服务多端**:趋势/概率/DEB 等核心逻辑被抽成分析模块,并被 web 与 bot 共用,避免“两套逻辑漂移”。前端城市决策卡在此基础上补足“机场报文解释 + 市场桶动作口径”,让用户从地图点击可以直接进入可解释决策。
**面向外部 API 的工程防护意识较强**Open-Meteo 429 冷却期、最小调用间隔、磁盘缓存、缓存 TTL 等措施表明作者已遭遇并处理速率限制与冷启动问题。 同时 AviationWeather 官方文档也明确建议控制频率并可使用 cache 文件降低负载,项目后续可进一步对齐最佳实践。 **面向外部 API 的工程防护意识较强**Open-Meteo 429 冷却期、最小调用间隔、磁盘缓存、缓存 TTL 等措施表明作者已遭遇并处理速率限制与冷启动问题。 同时 AviationWeather 官方文档也明确建议控制频率并可使用 cache 文件降低负载,项目后续可进一步对齐最佳实践。
**支付侧有“事件监听 + 确认补单”的双通路**:支付链路天然存在“交易 pending / RPC 延迟 / 日志索引不完整”等问题,项目通过 event loop 与 confirm loop 双机制提升最终一致性。 **支付侧有“事件监听 + 确认补单”的双通路**:支付链路天然存在“交易 pending / RPC 延迟 / 日志索引不完整”等问题,项目通过 event loop 与 confirm loop 双机制提升最终一致性。
+30 -10
View File
@@ -1,6 +1,6 @@
# PolyWeather 支付审计与防护说明 # PolyWeather 支付审计与防护说明
最后更新:`2026-03-21` 最后更新:`2026-05-29`
## 1. 当前已落地的防护 ## 1. 当前已落地的防护
@@ -19,9 +19,9 @@
### 事件确认边界 ### 事件确认边界
- 后端只认链上 `OrderPaid` 事件 - 后端只认链上可验证事件:checkout 合约路径认 `OrderPaid`,直转路径认对应链上 USDC `Transfer`
- 前端提交 intent 不会直接视为支付完成。 - 前端提交 intent 不会直接视为支付完成。
- `confirm_loop` 会再次按链上交易与确认数校验 intent - `confirm_loop` 会再次按 intent 的 `chain_id``token_address`、收款地址、金额与确认数校验链上交易。
- 若确认失败,当前会明确把 intent / transaction 落为失败态,而不是长期停留在 `submitted` - 若确认失败,当前会明确把 intent / transaction 落为失败态,而不是长期停留在 `submitted`
当前已显式识别的失败原因包括: 当前已显式识别的失败原因包括:
@@ -29,19 +29,23 @@
- `receiver_mismatch` - `receiver_mismatch`
- `sender_mismatch` - `sender_mismatch`
- `event_mismatch` - `event_mismatch`
- `token_mismatch`
- `tx_reverted` - `tx_reverted`
### RPC 多节点容灾 ### RPC 多节点容灾
- 支持 `POLYWEATHER_PAYMENT_RPC_URLS` - 支持默认链 `POLYWEATHER_PAYMENT_RPC_URLS`
- 支持多链 `POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON`
- 格式示例: - 格式示例:
```env ```env
POLYWEATHER_PAYMENT_RPC_URLS=https://polygon-rpc.com,https://polygon-bor-rpc.publicnode.com POLYWEATHER_PAYMENT_RPC_URLS=https://polygon-rpc.com,https://polygon-bor-rpc.publicnode.com
POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON={"137":["https://polygon-rpc.com","https://polygon-bor-rpc.publicnode.com"],"1":["https://ethereum-rpc.example"]}
``` ```
- 启动时按顺序探活。 - 启动时按顺序探活。
- 当前节点断连或收据查询失败时,会自动切换到下一个可用 RPC。 - 当前节点断连或收据查询失败时,会自动切换到下一个可用 RPC。
- 多链支付确认不会用默认链硬查交易;每笔 intent 会按自己的 `chain_id` 选择 RPC。
### 事件重放 ### 事件重放
@@ -87,8 +91,24 @@ python scripts/replay_payment_events.py --from-block 10000000 --to-block 1000100
- 已付款但未开通 - 已付款但未开通
- 打到旧收款地址 - 打到旧收款地址
- 交易事件不匹配 - 交易事件不匹配
- 用户在钱包默认 Ethereum 网络付款,但旧系统只按 Polygon intent 查账
## 2. 当前合约的授权边界 ## 2. 当前支付链路边界
当前支持两类链路:
1. Polygon checkout 合约
- 前端钱包支付会先切到 Polygon。
- 后端创建 intent 后给出合约 `tx_payload`
- 确认时校验 `OrderPaid(orderId, payer, planId, amount, token)`
2. Ethereum 主网 USDC 直转
- 前端展示 Ethereum 网络、USDC 合约、收款钱包和金额。
- 用户提交 tx hash 后,后端按 `intent.chain_id=1` 查询 Ethereum RPC。
- 确认时校验 USDC `Transfer(from, to, amount)``to``token_address` 和金额。
- 这条链路不依赖 Polygon checkout 合约,适合处理“用户钱包默认网络付款”的真实行为。
## 3. 当前合约的授权边界
合约源码: 合约源码:
- [PolyWeatherCheckout.sol](/E:/web/PolyWeather/contracts/PolyWeatherCheckout.sol) - [PolyWeatherCheckout.sol](/E:/web/PolyWeather/contracts/PolyWeatherCheckout.sol)
@@ -110,7 +130,7 @@ python scripts/replay_payment_events.py --from-block 10000000 --to-block 1000100
4. 订单边界 4. 订单边界
- 同一个 `orderId` 只能成功支付一次 - 同一个 `orderId` 只能成功支付一次
## 3. 重入与重复支付判断 ## 4. 重入与重复支付判断
当前合约的 `pay` 逻辑顺序是: 当前合约的 `pay` 逻辑顺序是:
@@ -136,7 +156,7 @@ python scripts/replay_payment_events.py --from-block 10000000 --to-block 1000100
- **最小可用支付合约** - **最小可用支付合约**
- 不是“全功能强防护合约” - 不是“全功能强防护合约”
## 4. 当前静态审计结论 ## 5. 当前静态审计结论
已提供脚本: 已提供脚本:
- [check_payment_contract_security.py](/E:/web/PolyWeather/scripts/check_payment_contract_security.py) - [check_payment_contract_security.py](/E:/web/PolyWeather/scripts/check_payment_contract_security.py)
@@ -160,7 +180,7 @@ python scripts/check_payment_contract_security.py
- 是否使用 SafeERC20 - 是否使用 SafeERC20
- 是否在链上绑定套餐价格 - 是否在链上绑定套餐价格
## 5. 当前主要剩余风险 ## 6. 当前主要剩余风险
1. 单地址 owner 1. 单地址 owner
- 建议把 `owner` 迁移到多签钱包 - 建议把 `owner` 迁移到多签钱包
@@ -175,7 +195,7 @@ python scripts/check_payment_contract_security.py
- 当前使用 `IERC20.transferFrom` - 当前使用 `IERC20.transferFrom`
- 升级版合约更建议改为 OpenZeppelin `SafeERC20` - 升级版合约更建议改为 OpenZeppelin `SafeERC20`
## 6. 推荐操作 ## 7. 推荐操作
### 每次支付配置变更后 ### 每次支付配置变更后
@@ -233,7 +253,7 @@ docker compose exec polyweather_web python scripts/reconcile_subscription_by_ema
- 用户声称已付费但未开通 - 用户声称已付费但未开通
- 需要快速确认最近一笔 intent 是否能自动恢复 - 需要快速确认最近一笔 intent 是否能自动恢复
## 7. 下一版合约建议 ## 8. 下一版合约建议
如果后续升级合约,优先级建议: 如果后续升级合约,优先级建议:
+1 -1
View File
@@ -12,7 +12,7 @@ PolyWeather 是一个面向天气衍生品交易者的气象情报平台。核
|------|------|------| |------|------|------|
| 免费 | 交互式全球天气地图 + 城市简报 | 无需登录 | | 免费 | 交互式全球天气地图 + 城市简报 | 无需登录 |
| Pro 试用 | 3 天全功能 | 注册后自动获得 | | Pro 试用 | 3 天全功能 | 注册后自动获得 |
| Pro 订阅 | 城市决策卡(结构化实况 + 模型证据 + 市场层)、日内分析、历史对账、未来预报 | 10 USDC/月(积分抵扣最多 3 USDC | | Pro 订阅 | 城市决策卡(结构化实况 + 模型证据 + 市场层)、日内分析、历史对账、未来预报 | 月付 29.9 USDC / 30 天,季度 79.9 USDC / 90 天(月付最多抵 3 USDC,季度最多抵 8 USDC |
### 页面结构(9 个路由) ### 页面结构(9 个路由)
+4 -2
View File
@@ -45,6 +45,7 @@ PolyWeather Pro 的生产前端工程。
- 城市决策卡展示结构化实况、模型区间、市场温度桶和模型-市场差,不再请求 AI 解读 - 城市决策卡展示结构化实况、模型区间、市场温度桶和模型-市场差,不再请求 AI 解读
- 市场价格层使用完整 `all_buckets` 匹配温度桶,并把 `模型-市场差` 解释为 `模型概率 - 市场隐含概率` - 市场价格层使用完整 `all_buckets` 匹配温度桶,并把 `模型-市场差` 解释为 `模型概率 - 市场隐含概率`
- 概率区展示当前生产概率引擎输出(legacy 高斯或 EMOS),模型共识只作为辅助参考 - 概率区展示当前生产概率引擎输出(legacy 高斯或 EMOS),模型共识只作为辅助参考
- 账户中心支付区支持后端下发的多链网络选择;Polygon 继续走 checkout 合约,Ethereum 主网 USDC 走手动直转确认
- 缓存桶状态与 summary cache hit/miss - 缓存桶状态与 summary cache hit/miss
## 本地开发 ## 本地开发
@@ -176,7 +177,8 @@ Ops
2.`receiver_contract` 已更新,先切到最新地址 2.`receiver_contract` 已更新,先切到最新地址
3. 若后端返回的 `tx_payload.to` 与最新地址不一致,直接阻断支付 3. 若后端返回的 `tx_payload.to` 与最新地址不一致,直接阻断支付
4. 仅允许在 `NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS` 白名单域名上创建 payment intent 4. 仅允许在 `NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS` 白名单域名上创建 payment intent
5. 支付区会明确显示当前账号、付款钱包和收款合约,避免账号/钱包/地址混淆 5. 支付区会明确显示当前账号、付款钱包、支付网络和收款合约/钱包,避免账号/钱包/地址/链混淆
6. 多链支付时,前端会把用户选择的 `chain_id``token_address` 发给后端,由后端按 intent 的链确认交易
这意味着: 这意味着:
@@ -206,4 +208,4 @@ Ops
详见根目录策略文档:`docs/OPEN_CORE_POLICY.md` 详见根目录策略文档:`docs/OPEN_CORE_POLICY.md`
最后更新:`2026-05-28` 最后更新:`2026-05-29`
+96 -24
View File
@@ -7,49 +7,121 @@ import {
buildProxyExceptionResponse, buildProxyExceptionResponse,
buildUpstreamErrorResponse, buildUpstreamErrorResponse,
} from "@/lib/api-proxy"; } from "@/lib/api-proxy";
import {
createProxyTimer,
finishProxyTimedResponse,
} from "@/lib/proxy-timing";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
const ANALYTICS_ENABLED = const ANALYTICS_ENABLED =
process.env.NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS !== "false"; process.env.NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS !== "false";
const ANALYTICS_PROXY_TIMEOUT_MS = Math.max(
250,
Number(process.env.POLYWEATHER_ANALYTICS_PROXY_TIMEOUT_MS || "1500") || 1500,
);
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
const timer = createProxyTimer(req, "analytics_events");
if (!ANALYTICS_ENABLED) { if (!ANALYTICS_ENABLED) {
return new NextResponse(null, { status: 204 }); return finishProxyTimedResponse(
} new NextResponse(null, { status: 204 }),
timer,
if (!API_BASE) { "disabled",
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
); );
} }
if (!API_BASE) {
return finishProxyTimedResponse(
NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
),
timer,
"missing_api_base",
);
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), ANALYTICS_PROXY_TIMEOUT_MS);
let auth: Awaited<ReturnType<typeof buildBackendRequestHeaders>> | null = null;
try { try {
const body = await req.json(); const body = await timer.measure("request_read", () => req.json());
const auth = await buildBackendRequestHeaders(req, { const payload =
includeSupabaseIdentity: false, body && typeof body.payload === "object" && body.payload != null
}); ? body.payload
: {};
const enrichedBody = {
...(body ?? {}),
payload: {
...payload,
cf_country:
req.headers.get("cf-ipcountry") ||
req.headers.get("x-vercel-ip-country") ||
"",
user_agent: req.headers.get("user-agent") || "",
referer_header: req.headers.get("referer") || "",
},
};
auth = await timer.measure("auth_headers", () =>
buildBackendRequestHeaders(req, {
includeSupabaseIdentity: false,
}),
);
const headers = new Headers(auth.headers); const headers = new Headers(auth.headers);
headers.set("Content-Type", "application/json"); headers.set("Content-Type", "application/json");
const res = await fetch(`${API_BASE}/api/analytics/events`, { const res = await timer.measure("backend_fetch", () =>
method: "POST", fetch(`${API_BASE}/api/analytics/events`, {
headers, method: "POST",
body: JSON.stringify(body ?? {}), headers,
cache: "no-store", body: JSON.stringify(enrichedBody),
}); cache: "no-store",
signal: controller.signal,
}),
);
const backendServerTiming = res.headers.get("server-timing") || "";
if (!res.ok) { if (!res.ok) {
const raw = await res.text(); const raw = await timer.measure("backend_read", () => res.text());
const response = buildUpstreamErrorResponse(res.status, raw, { const response = buildUpstreamErrorResponse(res.status, raw, {
detailLimit: 260, detailLimit: 260,
}); });
return applyAuthResponseCookies(response, auth.response); return finishProxyTimedResponse(
applyAuthResponseCookies(response, auth.response),
timer,
`upstream_${res.status}`,
{ backendServerTiming },
);
} }
const data = await res.json(); const data = await timer.measure("backend_read", () => res.json());
const response = NextResponse.json(data); const response = NextResponse.json(data);
return applyAuthResponseCookies(response, auth.response); return finishProxyTimedResponse(
applyAuthResponseCookies(response, auth.response),
timer,
"ok",
{ backendServerTiming },
);
} catch (error) { } catch (error) {
return buildProxyExceptionResponse(error, { const timedOut = controller.signal.aborted;
publicMessage: "Failed to track analytics event", const response = timedOut
}); ? NextResponse.json(
{
ok: false,
accepted: true,
dropped: true,
reason: "timeout",
},
{ status: 202 },
)
: buildProxyExceptionResponse(error, {
publicMessage: "Failed to track analytics event",
});
const withCookies = auth ? applyAuthResponseCookies(response, auth.response) : response;
return finishProxyTimedResponse(
withCookies,
timer,
timedOut ? "timeout_accepted" : "exception",
);
} finally {
clearTimeout(timeoutId);
} }
} }
+640 -81
View File
@@ -4,121 +4,680 @@ import {
buildBackendRequestHeaders, buildBackendRequestHeaders,
} from "@/lib/backend-auth"; } from "@/lib/backend-auth";
import { import {
buildProxyExceptionResponse,
buildUpstreamErrorResponse, buildUpstreamErrorResponse,
} from "@/lib/api-proxy"; } from "@/lib/api-proxy";
import { import {
getLocalDevAuthPayload, getLocalDevAuthPayload,
isLocalFullAccessHost, isLocalFullAccessHost,
} from "@/lib/local-dev-access"; } from "@/lib/local-dev-access";
import {
applyEntitlementSnapshotCookie,
clearEntitlementSnapshotCookie,
entitlementSnapshotToAuthPayload,
readEntitlementSnapshot,
} from "@/lib/entitlement-snapshot";
import {
buildSubscriptionRequiredAuthProfile,
isSubscriptionRequiredBackendResponse,
} from "@/lib/auth-profile-proxy";
import {
hasSupabaseServerEnv,
hasSupabaseSessionCookieValues,
} from "@/lib/supabase/server";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
type AuthMeTimingStage = {
durationMs: number;
name: string;
};
type AuthMeTimer = {
hasAuthorization: boolean;
hasSupabaseCookie: boolean;
measure<T>(name: string, action: () => Promise<T>): Promise<T>;
measureSync<T>(name: string, action: () => T): T;
preferSnapshot: boolean;
stages: AuthMeTimingStage[];
totalMs(): number;
};
function authMeNowMs() {
return typeof performance !== "undefined" ? performance.now() : Date.now();
}
function createAuthMeTimer(req: NextRequest): AuthMeTimer {
const startedAt = authMeNowMs();
const stages: AuthMeTimingStage[] = [];
const recordStage = (name: string, stageStartedAt: number) => {
stages.push({
durationMs: Math.round((authMeNowMs() - stageStartedAt) * 10) / 10,
name,
});
};
return {
hasAuthorization: Boolean(req.headers.get("authorization")),
hasSupabaseCookie: hasRequestSupabaseSessionCookie(req),
async measure<T>(name: string, action: () => Promise<T>) {
const stageStartedAt = authMeNowMs();
try {
return await action();
} finally {
recordStage(name, stageStartedAt);
}
},
measureSync<T>(name: string, action: () => T) {
const stageStartedAt = authMeNowMs();
try {
return action();
} finally {
recordStage(name, stageStartedAt);
}
},
preferSnapshot: req.nextUrl.searchParams.get("prefer_snapshot") === "1",
stages,
totalMs() {
return Math.round((authMeNowMs() - startedAt) * 10) / 10;
},
};
}
function formatServerTiming(stages: AuthMeTimingStage[], totalMs: number) {
return [...stages, { durationMs: totalMs, name: "total" }]
.map(({ durationMs, name }) => {
const safeName = name.replace(/[^A-Za-z0-9_-]/g, "_");
return `${safeName};dur=${Math.max(0, durationMs).toFixed(1)}`;
})
.join(", ");
}
function finishAuthMeResponse(
response: NextResponse,
timer: AuthMeTimer,
outcome: string,
extra?: { backendServerTiming?: string },
) {
const total = timer.totalMs();
const ownServerTiming = formatServerTiming(timer.stages, total);
const backendServerTiming = String(extra?.backendServerTiming || "").trim();
response.headers.set(
"Server-Timing",
backendServerTiming
? `${ownServerTiming}, ${backendServerTiming}`
: ownServerTiming,
);
console.info(
"[auth-me-timing]",
JSON.stringify({
backendServerTiming: backendServerTiming || undefined,
hasAuthorization: timer.hasAuthorization,
hasSupabaseCookie: timer.hasSupabaseCookie,
outcome,
preferSnapshot: timer.preferSnapshot,
stagesMs: Object.fromEntries(
timer.stages.map((stage) => [stage.name, stage.durationMs]),
),
status: response.status,
totalMs: total,
}),
);
return response;
}
async function trackAuthDiagnosticEvent(
req: NextRequest,
{
email,
reason,
responseMode,
userId,
}: {
email: string | null;
reason: string;
responseMode: "snapshot" | "degraded" | "anonymous";
userId?: string | null;
},
) {
if (!API_BASE) return;
const normalizedUserId = String(userId || "").trim();
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 1200);
try {
await fetch(`${API_BASE}/api/analytics/events`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
event_type: "degraded_auth_profile",
client_id: normalizedUserId ? `auth:${normalizedUserId}` : undefined,
payload: {
route: "/api/auth/me",
reason: String(reason || "unknown").slice(0, 240),
response_mode: responseMode,
user_id: normalizedUserId || undefined,
email_domain: email?.includes("@") ? email.split("@").pop() : undefined,
cf_country:
req.headers.get("cf-ipcountry") ||
req.headers.get("x-vercel-ip-country") ||
"",
user_agent: req.headers.get("user-agent") || "",
referer_header: req.headers.get("referer") || "",
captured_at: new Date().toISOString(),
},
}),
cache: "no-store",
signal: controller.signal,
});
} catch {
// Diagnostics must never block auth/profile fallback responses.
} finally {
clearTimeout(timeoutId);
}
}
type VerifiedBearerIdentity = {
email: string | null;
userId: string;
};
function extractBearerToken(headerValue: string | null) {
if (!headerValue) return "";
const parts = headerValue.trim().split(/\s+/);
if (parts.length === 2 && parts[0].toLowerCase() === "bearer") {
return parts[1];
}
return "";
}
async function getVerifiedBearerIdentity(
req: NextRequest,
): Promise<VerifiedBearerIdentity | null> {
const token = extractBearerToken(req.headers.get("authorization"));
if (!token) return null;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL?.trim();
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY?.trim();
if (!supabaseUrl || !anonKey) return null;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 4000);
try {
const res = await fetch(`${supabaseUrl.replace(/\/+$/, "")}/auth/v1/user`, {
cache: "no-store",
headers: {
apikey: anonKey,
Authorization: `Bearer ${token}`,
},
signal: controller.signal,
});
if (!res.ok) return null;
const user = await res.json();
const userId = String(user?.id || "").trim();
if (!userId) return null;
return {
email: String(user?.email || "").trim() || null,
userId,
};
} catch {
return null;
} finally {
clearTimeout(timeoutId);
}
}
async function degradedAuthProfileResponse({
email,
reason,
req,
response,
userId,
}: {
email: string | null;
reason: string;
req: NextRequest;
response: NextResponse | null;
userId: string;
}) {
const snapshotPayload = entitlementSnapshotToAuthPayload(
readEntitlementSnapshot(req, userId),
);
if (snapshotPayload) {
await trackAuthDiagnosticEvent(req, {
email: snapshotPayload.email || email,
reason,
responseMode: "snapshot",
userId,
});
const snapshotResponse = NextResponse.json({
...snapshotPayload,
email: snapshotPayload.email || email,
entitlement_snapshot_reason: reason,
});
return applyAuthResponseCookies(snapshotResponse, response);
}
await trackAuthDiagnosticEvent(req, {
email,
reason,
responseMode: "degraded",
userId,
});
const degraded = NextResponse.json({
authenticated: true,
user_id: userId,
email,
subscription_active: null,
subscription_plan_code: null,
subscription_expires_at: null,
subscription_total_expires_at: null,
subscription_queued_days: 0,
subscription_queued_count: 0,
points: 0,
degraded_auth_profile: true,
degraded_reason: reason,
});
return applyAuthResponseCookies(degraded, response);
}
function subscriptionRequiredAuthProfileResponse({
email,
response,
userId,
}: {
email: string | null;
response: NextResponse | null;
userId: string;
}) {
const inactive = NextResponse.json(
buildSubscriptionRequiredAuthProfile({ email, userId }),
);
clearEntitlementSnapshotCookie(inactive);
return applyAuthResponseCookies(inactive, response);
}
async function unauthenticatedAuthProfileResponse({
reason,
req,
response,
}: {
reason: string;
req: NextRequest;
response: NextResponse | null;
}) {
await trackAuthDiagnosticEvent(req, {
email: null,
reason,
responseMode: "anonymous",
});
const anonymous = NextResponse.json(
{
authenticated: false,
subscription_active: false,
points: 0,
degraded_auth_profile: true,
degraded_reason: reason,
},
{ headers: { "Cache-Control": "no-store" } },
);
clearEntitlementSnapshotCookie(anonymous);
return applyAuthResponseCookies(anonymous, response);
}
function snapshotAuthProfileResponse({
email,
reason,
req,
response,
userId,
}: {
email: string | null;
reason: string;
req: NextRequest;
response: NextResponse | null;
userId: string;
}) {
const snapshotPayload = entitlementSnapshotToAuthPayload(
readEntitlementSnapshot(req, userId),
);
if (!snapshotPayload) return null;
const snapshotResponse = NextResponse.json({
...snapshotPayload,
email: snapshotPayload.email || email,
entitlement_snapshot_reason: reason,
});
return applyAuthResponseCookies(snapshotResponse, response);
}
function applyEntitlementSnapshotFromAuthPayload(
response: NextResponse,
data: Record<string, unknown>,
) {
if (data.authenticated === true && data.subscription_active === true) {
return applyEntitlementSnapshotCookie(response, data);
}
if (data.authenticated === false || data.subscription_active === false) {
return clearEntitlementSnapshotCookie(response);
}
return response;
}
function hasRequestSupabaseSessionCookie(req: NextRequest) {
return hasSupabaseSessionCookieValues(
req.cookies.getAll().map((item) => ({
name: item.name,
value: item.value,
})),
);
}
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const timer = createAuthMeTimer(req);
const requestHost = const requestHost =
req.headers.get("x-forwarded-host") || req.headers.get("host") || req.nextUrl.host; req.headers.get("x-forwarded-host") || req.headers.get("host") || req.nextUrl.host;
if ( if (
isLocalFullAccessHost(requestHost) || isLocalFullAccessHost(requestHost) ||
isLocalFullAccessHost(req.nextUrl.hostname) isLocalFullAccessHost(req.nextUrl.hostname)
) { ) {
return NextResponse.json(getLocalDevAuthPayload(), { return finishAuthMeResponse(
headers: { "Cache-Control": "no-store" }, NextResponse.json(getLocalDevAuthPayload(), {
}); headers: { "Cache-Control": "no-store" },
} }),
timer,
if (!API_BASE) { "local_full_access",
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
); );
} }
if (!API_BASE) {
return finishAuthMeResponse(
NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
),
timer,
"missing_api_base",
);
}
const preferSnapshot = req.nextUrl.searchParams.get("prefer_snapshot") === "1";
if (
preferSnapshot &&
!req.headers.get("authorization") &&
hasRequestSupabaseSessionCookie(req)
) {
const snapshotPayload = timer.measureSync(
"snapshot_cookie",
() => entitlementSnapshotToAuthPayload(readEntitlementSnapshot(req)),
);
if (snapshotPayload) {
return finishAuthMeResponse(
NextResponse.json(
{
...snapshotPayload,
entitlement_snapshot_reason: "prefer_snapshot_fast_path",
},
{ headers: { "Cache-Control": "no-store" } },
),
timer,
"prefer_snapshot_fast_path",
);
}
}
let auth: Awaited<ReturnType<typeof buildBackendRequestHeaders>> | null = null;
let bearerIdentity: VerifiedBearerIdentity | null | undefined;
const getBearerIdentityOnce = async () => {
if (bearerIdentity !== undefined) return bearerIdentity;
bearerIdentity = await timer.measure(
"bearer_identity",
() => getVerifiedBearerIdentity(req),
);
return bearerIdentity;
};
try { try {
const auth = await buildBackendRequestHeaders(req); auth = await timer.measure(
const controller = new AbortController(); "auth_headers",
const timeoutId = setTimeout(() => controller.abort(), 6000); () => buildBackendRequestHeaders(req),
let res: Response; );
try { if (
res = await fetch(`${API_BASE}/api/auth/me`, { hasSupabaseServerEnv() &&
headers: auth.headers, !auth.authUserId &&
cache: "no-store", !req.headers.get("authorization")
signal: controller.signal, ) {
});
} finally {
clearTimeout(timeoutId);
}
if ((res.status === 401 || res.status === 403) && auth.authUserId) {
const response = NextResponse.json({
authenticated: true,
user_id: auth.authUserId,
email: auth.authEmail || null,
subscription_active: null,
subscription_plan_code: null,
subscription_expires_at: null,
subscription_total_expires_at: null,
subscription_queued_days: 0,
subscription_queued_count: 0,
points: 0,
degraded_auth_profile: true,
degraded_reason: `backend_${res.status}`,
});
return applyAuthResponseCookies(response, auth.response);
}
if (res.status === 401 || res.status === 403) {
const response = NextResponse.json({ const response = NextResponse.json({
authenticated: false, authenticated: false,
subscription_active: false, subscription_active: false,
points: 0, points: 0,
}); });
return applyAuthResponseCookies(response, auth.response); if (!preferSnapshot) clearEntitlementSnapshotCookie(response);
return finishAuthMeResponse(
applyAuthResponseCookies(response, auth.response),
timer,
"no_session",
);
}
if (preferSnapshot) {
const identity =
auth.authUserId
? { email: auth.authEmail || null, userId: auth.authUserId }
: await getBearerIdentityOnce();
if (identity?.userId) {
const snapshotResponse = snapshotAuthProfileResponse({
email: identity.email,
reason: "prefer_snapshot",
req,
response: auth.response,
userId: identity.userId,
});
if (snapshotResponse) {
return finishAuthMeResponse(
snapshotResponse,
timer,
"prefer_snapshot",
);
}
}
}
if (!auth) throw new Error("auth headers unavailable");
const backendAuth = auth;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 6000);
let res: Response;
try {
res = await timer.measure("backend_fetch", async () =>
await fetch(`${API_BASE}/api/auth/me`, {
headers: backendAuth.headers,
cache: "no-store",
signal: controller.signal,
}),
);
} finally {
clearTimeout(timeoutId);
}
const backendServerTiming = res.headers.get("server-timing") || "";
if (res.status === 401 || res.status === 403) {
const raw = await timer.measure("backend_read", () => res.text());
const authIdentity = auth.authUserId
? { email: auth.authEmail || null, userId: auth.authUserId }
: await getBearerIdentityOnce();
if (
authIdentity?.userId &&
isSubscriptionRequiredBackendResponse(res.status, raw)
) {
return finishAuthMeResponse(
subscriptionRequiredAuthProfileResponse({
email: authIdentity.email,
response: auth.response,
userId: authIdentity.userId,
}),
timer,
"subscription_required",
{ backendServerTiming },
);
}
if (auth.authUserId) {
return finishAuthMeResponse(
await degradedAuthProfileResponse({
email: auth.authEmail || null,
reason: `backend_${res.status}`,
req,
response: auth.response,
userId: auth.authUserId,
}),
timer,
`degraded_backend_${res.status}`,
{ backendServerTiming },
);
}
const identity = await getBearerIdentityOnce();
if (identity) {
return finishAuthMeResponse(
await degradedAuthProfileResponse({
email: identity.email,
reason: `backend_${res.status}`,
req,
response: auth.response,
userId: identity.userId,
}),
timer,
`degraded_backend_${res.status}`,
{ backendServerTiming },
);
}
const response = NextResponse.json({
authenticated: false,
subscription_active: false,
points: 0,
});
clearEntitlementSnapshotCookie(response);
return finishAuthMeResponse(
applyAuthResponseCookies(response, auth.response),
timer,
`anonymous_backend_${res.status}`,
{ backendServerTiming },
);
} }
if (!res.ok) { if (!res.ok) {
const raw = await res.text(); const raw = await timer.measure("backend_read", () => res.text());
if (auth.authUserId) { if (auth.authUserId) {
const response = NextResponse.json({ return finishAuthMeResponse(
authenticated: true, await degradedAuthProfileResponse({
user_id: auth.authUserId, email: auth.authEmail || null,
email: auth.authEmail || null, reason: `backend_${res.status}`,
subscription_active: null, req,
subscription_plan_code: null, response: auth.response,
subscription_expires_at: null, userId: auth.authUserId,
subscription_total_expires_at: null, }),
subscription_queued_days: 0, timer,
subscription_queued_count: 0, `degraded_backend_${res.status}`,
points: 0, { backendServerTiming },
degraded_auth_profile: true, );
degraded_reason: `backend_${res.status}`, }
}); const identity = await getBearerIdentityOnce();
return applyAuthResponseCookies(response, auth.response); if (identity) {
return finishAuthMeResponse(
await degradedAuthProfileResponse({
email: identity.email,
reason: `backend_${res.status}`,
req,
response: auth.response,
userId: identity.userId,
}),
timer,
`degraded_backend_${res.status}`,
{ backendServerTiming },
);
} }
const response = buildUpstreamErrorResponse(res.status, raw); const response = buildUpstreamErrorResponse(res.status, raw);
return applyAuthResponseCookies(response, auth.response); return finishAuthMeResponse(
applyAuthResponseCookies(response, auth.response),
timer,
`upstream_${res.status}`,
{ backendServerTiming },
);
}
const data = await timer.measure("backend_read", () => res.json());
if (data?.authenticated === true && data?.subscription_active == null) {
const userId = String(data.user_id || auth.authUserId || "").trim();
if (userId) {
const snapshotResponse = snapshotAuthProfileResponse({
email: String(data.email || auth.authEmail || "").trim() || null,
reason: "subscription_unknown",
req,
response: auth.response,
userId,
});
if (snapshotResponse) {
return finishAuthMeResponse(
snapshotResponse,
timer,
"subscription_unknown_snapshot",
{ backendServerTiming },
);
}
}
} }
const data = await res.json();
const response = NextResponse.json(data); const response = NextResponse.json(data);
return applyAuthResponseCookies(response, auth.response); applyEntitlementSnapshotFromAuthPayload(response, data);
return finishAuthMeResponse(
applyAuthResponseCookies(response, auth.response),
timer,
"ok",
{ backendServerTiming },
);
} catch (error) { } catch (error) {
const auth = await buildBackendRequestHeaders(req); if (auth?.authUserId) {
if (auth.authUserId) { return finishAuthMeResponse(
const response = NextResponse.json({ await degradedAuthProfileResponse({
authenticated: true, email: auth.authEmail || null,
user_id: auth.authUserId, reason: String(error),
email: auth.authEmail || null, req,
subscription_active: null, response: auth.response,
subscription_plan_code: null, userId: auth.authUserId,
subscription_expires_at: null, }),
subscription_total_expires_at: null, timer,
subscription_queued_days: 0, "exception_degraded",
subscription_queued_count: 0, );
points: 0,
degraded_auth_profile: true,
degraded_reason: String(error),
});
return applyAuthResponseCookies(response, auth.response);
} }
return buildProxyExceptionResponse(error, { const identity = await getBearerIdentityOnce();
publicMessage: "Failed to fetch auth profile", if (identity) {
}); return finishAuthMeResponse(
await degradedAuthProfileResponse({
email: identity.email,
reason: String(error),
req,
response: auth?.response || null,
userId: identity.userId,
}),
timer,
"exception_degraded",
);
}
const snapshotPayload = timer.measureSync(
"snapshot_cookie",
() => entitlementSnapshotToAuthPayload(readEntitlementSnapshot(req)),
);
if (snapshotPayload) {
const snapshotResponse = NextResponse.json({
...snapshotPayload,
entitlement_snapshot_reason: "exception_snapshot",
});
return finishAuthMeResponse(
applyAuthResponseCookies(snapshotResponse, auth?.response || null),
timer,
"exception_snapshot",
);
}
return finishAuthMeResponse(
await unauthenticatedAuthProfileResponse({
reason: String(error),
req,
response: auth?.response || null,
}),
timer,
"exception_anonymous",
);
} }
} }
@@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
buildUpstreamErrorResponse,
} from "@/lib/api-proxy";
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 body = await req.text();
const res = await fetch(`${API_BASE}/api/auth/referral/apply`, {
method: "POST",
headers: {
...auth.headers,
"Content-Type": "application/json",
},
body,
cache: "no-store",
});
if (!res.ok) {
const raw = await res.text();
const response = buildUpstreamErrorResponse(res.status, raw);
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
const response = NextResponse.json(data);
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to apply referral code",
});
}
}
@@ -0,0 +1,62 @@
import { NextRequest, NextResponse } from "next/server";
import { proxyBackendJsonGet } from "@/lib/api-proxy";
import { buildCityDetailProxyCachePolicy } from "@/lib/proxy-cache-policy";
import {
createProxyTimer,
finishProxyTimedResponse,
} from "@/lib/proxy-timing";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
const DETAIL_BATCH_PROXY_TIMEOUT_MS = Number(
process.env.POLYWEATHER_CITY_DETAIL_BATCH_PROXY_TIMEOUT_MS || "12000",
);
export async function GET(req: NextRequest) {
const timer = createProxyTimer(req, "city_detail_batch");
if (!API_BASE) {
return finishProxyTimedResponse(
NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
),
timer,
"missing_api_base",
);
}
const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false";
const cachePolicy = buildCityDetailProxyCachePolicy(forceRefresh, 15);
const searchParams = new URLSearchParams({
cities: req.nextUrl.searchParams.get("cities") || "",
force_refresh: forceRefresh,
limit: req.nextUrl.searchParams.get("limit") || "12",
});
for (const key of ["market_slug", "target_date", "resolution"]) {
const value = req.nextUrl.searchParams.get(key);
if (value) searchParams.set(key, value);
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), DETAIL_BATCH_PROXY_TIMEOUT_MS);
try {
return await proxyBackendJsonGet(req, {
cacheControl: cachePolicy.responseCacheControl,
cacheControlForData: (data) =>
data &&
typeof data === "object" &&
(data as { partial?: unknown }).partial === true
? "no-store, max-age=0"
: cachePolicy.responseCacheControl,
fetchCache: "no-store",
publicMessage: "Failed to fetch city detail batch",
revalidateSeconds: cachePolicy.revalidateSeconds,
signal: controller.signal,
timeoutPublicMessage: "City detail batch request timed out",
timing: timer,
url: `${API_BASE}/api/cities/detail-batch?${searchParams.toString()}`,
});
} finally {
clearTimeout(timeoutId);
}
}
+50 -12
View File
@@ -1,21 +1,59 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest } from "next/server";
import { proxyBackendJsonGet } from "@/lib/api-proxy"; import { proxyBackendJsonGet } from "@/lib/api-proxy";
import { buildCachedJsonResponse } from "@/lib/http-cache";
import { STATIC_CITY_LIST } from "@/lib/static-cities";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
const CITIES_CACHE_CONTROL = "public, max-age=0, s-maxage=60, stale-while-revalidate=300";
const STATIC_CITIES_CACHE_CONTROL =
"public, max-age=0, s-maxage=300, stale-while-revalidate=3600";
const CITIES_BACKEND_TIMEOUT_MS = Number(
process.env.POLYWEATHER_CITIES_BACKEND_TIMEOUT_MS || 1000,
);
function staticCitiesFallback(req: NextRequest, reason: string) {
const response = buildCachedJsonResponse(
req,
{
cities: STATIC_CITY_LIST,
source: "static_fallback",
stale: true,
},
STATIC_CITIES_CACHE_CONTROL,
);
response.headers.set("x-polyweather-cities-source", "static-fallback");
response.headers.set("x-polyweather-cities-fallback-reason", reason);
return response;
}
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
if (!API_BASE) { if (!API_BASE) {
const response = NextResponse.json( return staticCitiesFallback(req, "missing-api-base");
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
return response;
} }
return proxyBackendJsonGet(req, { const abortController = new AbortController();
cacheControl: "public, max-age=0, s-maxage=60, stale-while-revalidate=300", const timeout = setTimeout(() => abortController.abort(), CITIES_BACKEND_TIMEOUT_MS);
publicMessage: "Failed to fetch cities",
revalidateSeconds: 60, try {
url: `${API_BASE}/api/cities`, const response = await proxyBackendJsonGet(req, {
}); cacheControl: CITIES_CACHE_CONTROL,
publicMessage: "Failed to fetch cities",
revalidateSeconds: 60,
signal: abortController.signal,
statusOnException: 504,
timeoutPublicMessage: "Cities backend timed out",
url: `${API_BASE}/api/cities`,
});
if (response.status >= 500) {
return staticCitiesFallback(
req,
response.status === 504 ? "backend-timeout" : "backend-error",
);
}
return response;
} finally {
clearTimeout(timeout);
}
} }
+38 -16
View File
@@ -9,6 +9,10 @@ import {
} from "@/lib/api-proxy"; } from "@/lib/api-proxy";
import { buildCachedJsonResponse } from "@/lib/http-cache"; import { buildCachedJsonResponse } from "@/lib/http-cache";
import { buildCityDetailProxyCachePolicy } from "@/lib/proxy-cache-policy"; import { buildCityDetailProxyCachePolicy } from "@/lib/proxy-cache-policy";
import {
createProxyTimer,
finishProxyTimedResponse,
} from "@/lib/proxy-timing";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -34,15 +38,16 @@ export async function GET(
req: NextRequest, req: NextRequest,
context: { params: Promise<{ name: string }> }, context: { params: Promise<{ name: string }> },
) { ) {
const timer = createProxyTimer(req, "city_detail");
if (!API_BASE) { if (!API_BASE) {
const response = NextResponse.json( const response = NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" }, { error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 }, { status: 500 },
); );
return response; return finishProxyTimedResponse(response, timer, "missing_api_base");
} }
const { name } = await context.params; const { name } = await timer.measure("route_params", () => context.params);
const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false"; const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false";
const cachePolicy = buildCityDetailProxyCachePolicy(forceRefresh, 15); const cachePolicy = buildCityDetailProxyCachePolicy(forceRefresh, 15);
const depth = req.nextUrl.searchParams.get("depth"); const depth = req.nextUrl.searchParams.get("depth");
@@ -67,31 +72,48 @@ export async function GET(
const url = `${API_BASE}/api/city/${encodeURIComponent(name)}/detail?${searchParams.toString()}`; const url = `${API_BASE}/api/city/${encodeURIComponent(name)}/detail?${searchParams.toString()}`;
try { try {
const auth = await buildBackendRequestHeaders(req, { const auth = await timer.measure("auth_headers", () =>
includeSupabaseIdentity: false, buildBackendRequestHeaders(req, {
}); includeSupabaseIdentity: false,
const res = await fetch(url, { }),
headers: auth.headers, );
...(cachePolicy.fetchMode === "no-store" const res = await timer.measure("backend_fetch", () =>
? { cache: "no-store" as const } fetch(url, {
: { next: { revalidate: cachePolicy.revalidateSeconds ?? 15 } }), headers: auth.headers,
}); ...(cachePolicy.fetchMode === "no-store"
? { cache: "no-store" as const }
: { next: { revalidate: cachePolicy.revalidateSeconds ?? 15 } }),
}),
);
const backendServerTiming = res.headers.get("server-timing") || "";
if (!res.ok) { if (!res.ok) {
const raw = await res.text(); const raw = await timer.measure("backend_read", () => res.text());
const response = buildUpstreamErrorResponse(res.status, raw); const response = buildUpstreamErrorResponse(res.status, raw);
return applyAuthResponseCookies(response, auth.response); return finishProxyTimedResponse(
applyAuthResponseCookies(response, auth.response),
timer,
`upstream_${res.status}`,
{ backendServerTiming },
);
} }
const data = normalizeCityDetailPayload(await res.json()); const data = normalizeCityDetailPayload(
await timer.measure("backend_read", () => res.json()),
);
const response = buildCachedJsonResponse( const response = buildCachedJsonResponse(
req, req,
data, data,
cachePolicy.responseCacheControl, cachePolicy.responseCacheControl,
); );
return applyAuthResponseCookies(response, auth.response); return finishProxyTimedResponse(
applyAuthResponseCookies(response, auth.response),
timer,
"ok",
{ backendServerTiming },
);
} catch (error) { } catch (error) {
const response = buildProxyExceptionResponse(error, { const response = buildProxyExceptionResponse(error, {
publicMessage: "Failed to fetch city detail aggregate", publicMessage: "Failed to fetch city detail aggregate",
}); });
return response; return finishProxyTimedResponse(response, timer, "exception");
} }
} }
@@ -4,6 +4,7 @@ import {
buildBackendRequestHeaders, buildBackendRequestHeaders,
} from "@/lib/backend-auth"; } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy"; import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -17,6 +18,9 @@ export async function GET(req: NextRequest) {
try { try {
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const url = new URL(`${API_BASE}/api/ops/analytics/funnel`); const url = new URL(`${API_BASE}/api/ops/analytics/funnel`);
const days = req.nextUrl.searchParams.get("days"); const days = req.nextUrl.searchParams.get("days");
if (days) { if (days) {
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(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 authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const upstream = new URL(`${API_BASE}/api/ops/billing-risk`);
req.nextUrl.searchParams.forEach((value, key) => {
upstream.searchParams.set(key, value);
});
const res = await fetch(upstream.toString(), {
cache: "no-store",
headers: auth.headers,
});
const raw = await res.text();
const response = new NextResponse(raw, {
headers: {
"Cache-Control": "no-store",
"Content-Type": res.headers.get("content-type") || "application/json",
},
status: res.status,
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return buildProxyExceptionResponse(error, {
publicMessage: "Billing risk check failed",
});
}
}
+7
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth"; import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy"; import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
const BACKEND = API_BASE ? `${API_BASE}/api/ops/config` : ""; const BACKEND = API_BASE ? `${API_BASE}/api/ops/config` : "";
@@ -9,6 +10,9 @@ export async function GET(req: NextRequest) {
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 }); if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
try { try {
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const res = await fetch(BACKEND, { headers: auth.headers, cache: "no-store" }); const res = await fetch(BACKEND, { headers: auth.headers, cache: "no-store" });
const raw = await res.text(); const raw = await res.text();
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } }); const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
@@ -20,6 +24,9 @@ export async function PUT(req: NextRequest) {
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 }); if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
try { try {
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const body = await req.text(); const body = await req.text();
const res = await fetch(BACKEND, { method: "PUT", headers: { ...auth.headers, "Content-Type": "application/json" }, body, cache: "no-store" }); const res = await fetch(BACKEND, { method: "PUT", headers: { ...auth.headers, "Content-Type": "application/json" }, body, cache: "no-store" });
const raw = await res.text(); const raw = await res.text();
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth"; import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy"; import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -8,6 +9,9 @@ export async function GET(req: NextRequest) {
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 }); if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
try { try {
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const res = await fetch(`${API_BASE}/api/ops/health-check`, { headers: auth.headers, cache: "no-store" }); const res = await fetch(`${API_BASE}/api/ops/health-check`, { headers: auth.headers, cache: "no-store" });
const raw = await res.text(); const raw = await res.text();
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } }); const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
@@ -4,6 +4,7 @@ import {
buildBackendRequestHeaders, buildBackendRequestHeaders,
} from "@/lib/backend-auth"; } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy"; import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -17,6 +18,9 @@ export async function GET(req: NextRequest) {
try { try {
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const url = new URL(`${API_BASE}/api/ops/leaderboard/weekly`); const url = new URL(`${API_BASE}/api/ops/leaderboard/weekly`);
const limit = req.nextUrl.searchParams.get("limit"); const limit = req.nextUrl.searchParams.get("limit");
if (limit) url.searchParams.set("limit", limit); if (limit) url.searchParams.set("limit", limit);
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth"; import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy"; import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -8,6 +9,8 @@ export async function GET(req: NextRequest) {
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 }); if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
try { try {
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const url = new URL(`${API_BASE}/api/ops/memberships/growth`); const url = new URL(`${API_BASE}/api/ops/memberships/growth`);
const days = req.nextUrl.searchParams.get("days"); const days = req.nextUrl.searchParams.get("days");
if (days) url.searchParams.set("days", days); if (days) url.searchParams.set("days", days);
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(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 authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const url = new URL(`${API_BASE}/api/ops/memberships/overview`);
const limit = req.nextUrl.searchParams.get("limit");
const days = req.nextUrl.searchParams.get("days");
if (limit) url.searchParams.set("limit", limit);
if (days) url.searchParams.set("days", days);
const res = await fetch(url.toString(), {
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",
"Cache-Control": "no-store",
},
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to fetch memberships overview",
});
}
}
@@ -4,6 +4,7 @@ import {
buildBackendRequestHeaders, buildBackendRequestHeaders,
} from "@/lib/backend-auth"; } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy"; import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -17,6 +18,8 @@ export async function GET(req: NextRequest) {
try { try {
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const url = new URL(`${API_BASE}/api/ops/memberships`); const url = new URL(`${API_BASE}/api/ops/memberships`);
const limit = req.nextUrl.searchParams.get("limit"); const limit = req.nextUrl.searchParams.get("limit");
if (limit) url.searchParams.set("limit", limit); if (limit) url.searchParams.set("limit", limit);
+44 -13
View File
@@ -4,24 +4,46 @@ import {
buildBackendRequestHeaders, buildBackendRequestHeaders,
} from "@/lib/backend-auth"; } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy"; import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
import {
createProxyTimer,
finishProxyTimedResponse,
} from "@/lib/proxy-timing";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const timer = createProxyTimer(req, "ops_online_users");
if (!API_BASE) { if (!API_BASE) {
return NextResponse.json( return finishProxyTimedResponse(
{ error: "POLYWEATHER_API_BASE_URL is not configured" }, NextResponse.json(
{ status: 500 }, { error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
),
timer,
"missing_api_base",
); );
} }
try { try {
const auth = await buildBackendRequestHeaders(req); const auth = await timer.measure("auth_headers", () =>
const res = await fetch(`${API_BASE}/api/ops/online-users`, { buildBackendRequestHeaders(req),
headers: auth.headers, );
cache: "no-store", const authError = timer.measureSync("ops_auth", () =>
}); requireOpsProxyAuth(req, auth),
const raw = await res.text(); );
if (authError) {
return finishProxyTimedResponse(authError, timer, "ops_auth_error");
}
const res = await timer.measure("backend_fetch", () =>
fetch(`${API_BASE}/api/ops/online-users`, {
headers: auth.headers,
cache: "no-store",
}),
);
const backendServerTiming = res.headers.get("server-timing") || "";
const raw = await timer.measure("backend_read", () => res.text());
const response = new NextResponse(raw, { const response = new NextResponse(raw, {
status: res.status, status: res.status,
headers: { headers: {
@@ -29,10 +51,19 @@ export async function GET(req: NextRequest) {
"Cache-Control": "no-store", "Cache-Control": "no-store",
}, },
}); });
return applyAuthResponseCookies(response, auth.response); return finishProxyTimedResponse(
applyAuthResponseCookies(response, auth.response),
timer,
res.ok ? "ok" : `upstream_${res.status}`,
{ backendServerTiming },
);
} catch (error) { } catch (error) {
return buildProxyExceptionResponse(error, { return finishProxyTimedResponse(
publicMessage: "Failed to fetch online users", buildProxyExceptionResponse(error, {
}); publicMessage: "Failed to fetch online users",
}),
timer,
"exception",
);
} }
} }
@@ -4,6 +4,7 @@ import {
buildBackendRequestHeaders, buildBackendRequestHeaders,
} from "@/lib/backend-auth"; } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy"; import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -21,6 +22,9 @@ export async function POST(req: NextRequest, context: RouteContext) {
try { try {
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const { eventId } = await context.params; const { eventId } = await context.params;
const res = await fetch(`${API_BASE}/api/ops/payments/incidents/${eventId}/resolve`, { const res = await fetch(`${API_BASE}/api/ops/payments/incidents/${eventId}/resolve`, {
method: "POST", method: "POST",
@@ -4,6 +4,7 @@ import {
buildBackendRequestHeaders, buildBackendRequestHeaders,
} from "@/lib/backend-auth"; } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy"; import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -17,6 +18,9 @@ export async function GET(req: NextRequest) {
try { try {
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const url = new URL(`${API_BASE}/api/ops/payments/incidents`); const url = new URL(`${API_BASE}/api/ops/payments/incidents`);
const limit = req.nextUrl.searchParams.get("limit"); const limit = req.nextUrl.searchParams.get("limit");
if (limit) url.searchParams.set("limit", limit); if (limit) url.searchParams.set("limit", limit);
+47
View File
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(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 authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const upstream = new URL(`${API_BASE}/api/ops/payments`);
req.nextUrl.searchParams.forEach((value, key) => {
upstream.searchParams.set(key, value);
});
const res = await fetch(upstream.toString(), {
cache: "no-store",
headers: auth.headers,
});
const raw = await res.text();
const response = new NextResponse(raw, {
headers: {
"Cache-Control": "no-store",
"Content-Type": res.headers.get("content-type") || "application/json",
},
status: res.status,
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to fetch ops payments",
});
}
}
@@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from "next/server";
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
const BACKEND = API_BASE ? `${API_BASE}/api/ops/sensitive-config` : "";
export async function GET(req: NextRequest) {
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const res = await fetch(BACKEND, { headers: auth.headers, cache: "no-store" });
const raw = await res.text();
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
return applyAuthResponseCookies(response, auth.response);
} catch (e) { return buildProxyExceptionResponse(e, { publicMessage: "Sensitive config fetch failed" }); }
}
export async function PUT(req: NextRequest) {
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const body = await req.text();
const res = await fetch(BACKEND, { method: "PUT", headers: { ...auth.headers, "Content-Type": "application/json" }, body, cache: "no-store" });
const raw = await res.text();
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
return applyAuthResponseCookies(response, auth.response);
} catch (e) { return buildProxyExceptionResponse(e, { publicMessage: "Sensitive config update failed" }); }
}
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(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 authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const upstream = new URL(`${API_BASE}/api/ops/source-health`);
req.nextUrl.searchParams.forEach((value, key) => {
upstream.searchParams.set(key, value);
});
const res = await fetch(upstream.toString(), {
cache: "no-store",
headers: auth.headers,
});
const raw = await res.text();
const response = new NextResponse(raw, {
headers: {
"Cache-Control": "no-store",
"Content-Type": "application/json",
},
status: res.status,
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return buildProxyExceptionResponse(error, {
publicMessage: "Source health check failed",
});
}
}
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth"; import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy"; import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
const ENTITLEMENT_TOKEN = process.env.POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN?.trim() || ""; const ENTITLEMENT_TOKEN = process.env.POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN?.trim() || "";
@@ -9,6 +10,9 @@ export async function POST(req: NextRequest) {
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 }); if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
try { try {
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const body = await req.text(); const body = await req.text();
const headers: Record<string, string> = { ...auth.headers as Record<string, string>, "Content-Type": "application/json" }; const headers: Record<string, string> = { ...auth.headers as Record<string, string>, "Content-Type": "application/json" };
if (ENTITLEMENT_TOKEN) { if (ENTITLEMENT_TOKEN) {
@@ -4,6 +4,7 @@ import {
buildBackendRequestHeaders, buildBackendRequestHeaders,
} from "@/lib/backend-auth"; } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy"; import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -41,6 +42,23 @@ async function findSupabaseUserIdByEmail(email: string) {
if (!supabaseUrl || !serviceRoleKey) { if (!supabaseUrl || !serviceRoleKey) {
throw new Error("Supabase service role is not configured on Vercel"); throw new Error("Supabase service role is not configured on Vercel");
} }
const profileRes = await fetch(
`${supabaseUrl}/rest/v1/profiles?select=id&email=eq.${encodeURIComponent(email)}&limit=1`,
{
headers: {
apikey: serviceRoleKey,
Authorization: `Bearer ${serviceRoleKey}`,
Accept: "application/json",
},
cache: "no-store",
},
);
const profileData = (await profileRes.json().catch(() => [])) as Array<{ id?: string }>;
if (profileRes.ok) {
const profileUserId = String(profileData?.[0]?.id || "").trim();
if (profileUserId) return { supabaseUrl, serviceRoleKey, userId: profileUserId };
}
const res = await fetch( const res = await fetch(
`${supabaseUrl}/auth/v1/admin/users?filter=${encodeURIComponent(`email.eq.${email}`)}`, `${supabaseUrl}/auth/v1/admin/users?filter=${encodeURIComponent(`email.eq.${email}`)}`,
{ {
@@ -109,7 +127,7 @@ async function grantSubscriptionDirectly(req: NextRequest, bodyText: string, aut
apikey: serviceRoleKey, apikey: serviceRoleKey,
Authorization: `Bearer ${serviceRoleKey}`, Authorization: `Bearer ${serviceRoleKey}`,
"Content-Type": "application/json", "Content-Type": "application/json",
Prefer: "return=representation", Prefer: "return=minimal",
}, },
body: JSON.stringify(payload), body: JSON.stringify(payload),
cache: "no-store", cache: "no-store",
@@ -138,6 +156,9 @@ async function grantSubscriptionDirectly(req: NextRequest, bodyText: string, aut
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const body = await req.text(); const body = await req.text();
if (!API_BASE) { if (!API_BASE) {
return grantSubscriptionDirectly(req, body, auth.authEmail); return grantSubscriptionDirectly(req, body, auth.authEmail);
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth"; import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy"; import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -8,6 +9,9 @@ export async function GET(req: NextRequest) {
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 }); if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
try { try {
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const res = await fetch(`${API_BASE}/api/ops/telegram/members-audit`, { headers: auth.headers, cache: "no-store" }); const res = await fetch(`${API_BASE}/api/ops/telegram/members-audit`, { headers: auth.headers, cache: "no-store" });
const raw = await res.text(); const raw = await res.text();
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } }); const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth"; import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy"; import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -8,6 +9,9 @@ export async function GET(req: NextRequest) {
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 }); if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
try { try {
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const res = await fetch(`${API_BASE}/api/ops/training/accuracy`, { headers: auth.headers, cache: "no-store" }); const res = await fetch(`${API_BASE}/api/ops/training/accuracy`, { headers: auth.headers, cache: "no-store" });
const raw = await res.text(); const raw = await res.text();
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } }); const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
@@ -4,6 +4,7 @@ import {
buildBackendRequestHeaders, buildBackendRequestHeaders,
} from "@/lib/backend-auth"; } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy"; import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -17,6 +18,9 @@ export async function GET(req: NextRequest) {
try { try {
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const url = new URL(`${API_BASE}/api/ops/truth-history`); const url = new URL(`${API_BASE}/api/ops/truth-history`);
for (const key of ["city", "date_from", "date_to", "limit"]) { for (const key of ["city", "date_from", "date_to", "limit"]) {
const value = req.nextUrl.searchParams.get(key); const value = req.nextUrl.searchParams.get(key);
@@ -4,6 +4,7 @@ import {
buildBackendRequestHeaders, buildBackendRequestHeaders,
} from "@/lib/backend-auth"; } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy"; import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -17,6 +18,9 @@ export async function POST(req: NextRequest) {
try { try {
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const body = await req.text(); const body = await req.text();
const res = await fetch(`${API_BASE}/api/ops/users/grant-points`, { const res = await fetch(`${API_BASE}/api/ops/users/grant-points`, {
method: "POST", method: "POST",
+4
View File
@@ -4,6 +4,7 @@ import {
buildBackendRequestHeaders, buildBackendRequestHeaders,
} from "@/lib/backend-auth"; } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy"; import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -17,6 +18,9 @@ export async function GET(req: NextRequest) {
try { try {
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const url = new URL(`${API_BASE}/api/ops/users`); const url = new URL(`${API_BASE}/api/ops/users`);
const q = req.nextUrl.searchParams.get("q"); const q = req.nextUrl.searchParams.get("q");
const limit = req.nextUrl.searchParams.get("limit"); const limit = req.nextUrl.searchParams.get("limit");
+4
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth"; import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy"; import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -8,6 +9,9 @@ export async function GET(req: NextRequest) {
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 }); if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
try { try {
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const url = new URL(`${API_BASE}/api/ops/logs`); const url = new URL(`${API_BASE}/api/ops/logs`);
const level = req.nextUrl.searchParams.get("level"); const level = req.nextUrl.searchParams.get("level");
const lines = req.nextUrl.searchParams.get("lines"); const lines = req.nextUrl.searchParams.get("lines");
+1 -1
View File
@@ -13,7 +13,7 @@ export async function GET(req: NextRequest) {
return proxyBackendJsonGet(req, { return proxyBackendJsonGet(req, {
cacheControl: "public, max-age=0, s-maxage=300, stale-while-revalidate=900", cacheControl: "public, max-age=0, s-maxage=300, stale-while-revalidate=900",
detailLimit: 350, detailLimit: 350,
includeSupabaseIdentity: true, includeSupabaseIdentity: false,
publicMessage: "Failed to fetch payment config", publicMessage: "Failed to fetch payment config",
revalidateSeconds: 300, revalidateSeconds: 300,
url: `${API_BASE}/api/payments/config`, url: `${API_BASE}/api/payments/config`,
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { import {
applyAuthResponseCookies, applyAuthResponseCookies,
buildBackendRequestHeaders, buildBackendRequestHeaders,
requireBackendAuthUser, requireBackendPaymentAuth,
} from "@/lib/backend-auth"; } from "@/lib/backend-auth";
import { import {
buildProxyExceptionResponse, buildProxyExceptionResponse,
@@ -25,7 +25,7 @@ export async function POST(
try { try {
const body = await req.json(); const body = await req.json();
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireBackendAuthUser(auth); const authError = requireBackendPaymentAuth(auth);
if (authError) return authError; if (authError) return authError;
const proxiedHeaders = new Headers(auth.headers); const proxiedHeaders = new Headers(auth.headers);
proxiedHeaders.set("Content-Type", "application/json"); proxiedHeaders.set("Content-Type", "application/json");
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { import {
applyAuthResponseCookies, applyAuthResponseCookies,
buildBackendRequestHeaders, buildBackendRequestHeaders,
requireBackendAuthUser, requireBackendPaymentAuth,
} from "@/lib/backend-auth"; } from "@/lib/backend-auth";
import { import {
buildProxyExceptionResponse, buildProxyExceptionResponse,
@@ -25,7 +25,7 @@ export async function POST(
try { try {
const body = await req.json(); const body = await req.json();
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireBackendAuthUser(auth); const authError = requireBackendPaymentAuth(auth);
if (authError) return authError; if (authError) return authError;
const proxiedHeaders = new Headers(auth.headers); const proxiedHeaders = new Headers(auth.headers);
proxiedHeaders.set("Content-Type", "application/json"); proxiedHeaders.set("Content-Type", "application/json");
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { import {
applyAuthResponseCookies, applyAuthResponseCookies,
buildBackendRequestHeaders, buildBackendRequestHeaders,
requireBackendAuthUser, requireBackendPaymentAuth,
} from "@/lib/backend-auth"; } from "@/lib/backend-auth";
import { import {
buildProxyExceptionResponse, buildProxyExceptionResponse,
@@ -25,7 +25,7 @@ export async function POST(
try { try {
const body = await req.json(); const body = await req.json();
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireBackendAuthUser(auth); const authError = requireBackendPaymentAuth(auth);
if (authError) return authError; if (authError) return authError;
const proxiedHeaders = new Headers(auth.headers); const proxiedHeaders = new Headers(auth.headers);
proxiedHeaders.set("Content-Type", "application/json"); proxiedHeaders.set("Content-Type", "application/json");
+2 -2
View File
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { import {
applyAuthResponseCookies, applyAuthResponseCookies,
buildBackendRequestHeaders, buildBackendRequestHeaders,
requireBackendAuthUser, requireBackendPaymentAuth,
} from "@/lib/backend-auth"; } from "@/lib/backend-auth";
import { import {
buildProxyExceptionResponse, buildProxyExceptionResponse,
@@ -36,7 +36,7 @@ export async function POST(req: NextRequest) {
try { try {
const body = await req.json(); const body = await req.json();
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireBackendAuthUser(auth); const authError = requireBackendPaymentAuth(auth);
if (authError) return authError; if (authError) return authError;
const proxiedHeaders = new Headers(auth.headers); const proxiedHeaders = new Headers(auth.headers);
proxiedHeaders.set("Content-Type", "application/json"); proxiedHeaders.set("Content-Type", "application/json");
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { import {
applyAuthResponseCookies, applyAuthResponseCookies,
buildBackendRequestHeaders, buildBackendRequestHeaders,
requireBackendAuthUser, requireBackendPaymentAuth,
} from "@/lib/backend-auth"; } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy"; import { buildProxyExceptionResponse } from "@/lib/api-proxy";
@@ -18,7 +18,7 @@ export async function POST(req: NextRequest) {
try { try {
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireBackendAuthUser(auth); const authError = requireBackendPaymentAuth(auth);
if (authError) return authError; if (authError) return authError;
const res = await fetch(`${API_BASE}/api/payments/reconcile-latest`, { const res = await fetch(`${API_BASE}/api/payments/reconcile-latest`, {
method: "POST", method: "POST",
+1 -1
View File
@@ -16,7 +16,7 @@ export async function GET(req: NextRequest) {
conditionalResponse: false, conditionalResponse: false,
detailLimit: 500, detailLimit: 500,
fetchCache: "no-store", fetchCache: "no-store",
includeSupabaseIdentity: true, includeSupabaseIdentity: false,
publicMessage: "Failed to fetch payment runtime", publicMessage: "Failed to fetch payment runtime",
url: `${API_BASE}/api/payments/runtime`, url: `${API_BASE}/api/payments/runtime`,
}); });
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { import {
applyAuthResponseCookies, applyAuthResponseCookies,
buildBackendRequestHeaders, buildBackendRequestHeaders,
requireBackendAuthUser, requireBackendPaymentAuth,
} from "@/lib/backend-auth"; } from "@/lib/backend-auth";
import { import {
buildProxyExceptionResponse, buildProxyExceptionResponse,
@@ -21,7 +21,7 @@ export async function POST(req: NextRequest) {
try { try {
const body = await req.json(); const body = await req.json();
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireBackendAuthUser(auth); const authError = requireBackendPaymentAuth(auth);
if (authError) return authError; if (authError) return authError;
const proxiedHeaders = new Headers(auth.headers); const proxiedHeaders = new Headers(auth.headers);
proxiedHeaders.set("Content-Type", "application/json"); proxiedHeaders.set("Content-Type", "application/json");
+2 -2
View File
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { import {
applyAuthResponseCookies, applyAuthResponseCookies,
buildBackendRequestHeaders, buildBackendRequestHeaders,
requireBackendAuthUser, requireBackendPaymentAuth,
} from "@/lib/backend-auth"; } from "@/lib/backend-auth";
import { import {
buildProxyExceptionResponse, buildProxyExceptionResponse,
@@ -45,7 +45,7 @@ export async function DELETE(req: NextRequest) {
} }
try { try {
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireBackendAuthUser(auth); const authError = requireBackendPaymentAuth(auth);
if (authError) return authError; if (authError) return authError;
const proxiedHeaders = new Headers(auth.headers); const proxiedHeaders = new Headers(auth.headers);
proxiedHeaders.set("Content-Type", "application/json"); proxiedHeaders.set("Content-Type", "application/json");
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { import {
applyAuthResponseCookies, applyAuthResponseCookies,
buildBackendRequestHeaders, buildBackendRequestHeaders,
requireBackendAuthUser, requireBackendPaymentAuth,
} from "@/lib/backend-auth"; } from "@/lib/backend-auth";
import { import {
buildProxyExceptionResponse, buildProxyExceptionResponse,
@@ -21,7 +21,7 @@ export async function POST(req: NextRequest) {
try { try {
const body = await req.json(); const body = await req.json();
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireBackendAuthUser(auth); const authError = requireBackendPaymentAuth(auth);
if (authError) return authError; if (authError) return authError;
const proxiedHeaders = new Headers(auth.headers); const proxiedHeaders = new Headers(auth.headers);
proxiedHeaders.set("Content-Type", "application/json"); proxiedHeaders.set("Content-Type", "application/json");
@@ -37,7 +37,9 @@ export async function POST(req: NextRequest) {
const timeoutId = setTimeout(() => controller.abort(), OVERVIEW_PROXY_TIMEOUT_MS); const timeoutId = setTimeout(() => controller.abort(), OVERVIEW_PROXY_TIMEOUT_MS);
try { try {
auth = await buildBackendRequestHeaders(req); auth = await buildBackendRequestHeaders(req, {
includeSupabaseIdentity: false,
});
const headers = new Headers(auth.headers); const headers = new Headers(auth.headers);
headers.set("Content-Type", "application/json"); headers.set("Content-Type", "application/json");
headers.set("Accept", "application/json"); headers.set("Accept", "application/json");
+18 -4
View File
@@ -1,6 +1,11 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { proxyBackendJsonGet } from "@/lib/api-proxy"; import { proxyBackendJsonGet } from "@/lib/api-proxy";
import { buildForceRefreshProxyCachePolicy } from "@/lib/proxy-cache-policy"; import { buildForceRefreshProxyCachePolicy } from "@/lib/proxy-cache-policy";
import { DASHBOARD_REFRESH_POLICY_SEC } from "@/lib/refresh-policy";
import {
createProxyTimer,
finishProxyTimedResponse,
} from "@/lib/proxy-timing";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
const SCAN_TERMINAL_PROXY_TIMEOUT_MS = Number( const SCAN_TERMINAL_PROXY_TIMEOUT_MS = Number(
@@ -10,10 +15,15 @@ const SCAN_TERMINAL_PROXY_TIMEOUT_MS = Number(
export const maxDuration = 45; export const maxDuration = 45;
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const timer = createProxyTimer(req, "scan_terminal");
if (!API_BASE) { if (!API_BASE) {
return NextResponse.json( return finishProxyTimedResponse(
{ error: "POLYWEATHER_API_BASE_URL is not configured" }, NextResponse.json(
{ status: 500 }, { error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
),
timer,
"missing_api_base",
); );
} }
@@ -41,7 +51,10 @@ export async function GET(req: NextRequest) {
if (tradingRegion != null && tradingRegion !== "") { if (tradingRegion != null && tradingRegion !== "") {
params.set("region", tradingRegion); params.set("region", tradingRegion);
} }
const cachePolicy = buildForceRefreshProxyCachePolicy(forceRefresh, 10); const cachePolicy = buildForceRefreshProxyCachePolicy(
forceRefresh,
DASHBOARD_REFRESH_POLICY_SEC.scanRows,
);
const url = `${API_BASE}/api/scan/terminal?${params.toString()}`; const url = `${API_BASE}/api/scan/terminal?${params.toString()}`;
@@ -57,6 +70,7 @@ export async function GET(req: NextRequest) {
revalidateSeconds: cachePolicy.revalidateSeconds, revalidateSeconds: cachePolicy.revalidateSeconds,
signal: controller.signal, signal: controller.signal,
timeoutPublicMessage: "Scan terminal request timed out", timeoutPublicMessage: "Scan terminal request timed out",
timing: timer,
url, url,
}); });
} finally { } finally {
+35 -1
View File
@@ -1,7 +1,10 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { BACKEND_ENTITLEMENT_HEADER } from "@/lib/backend-auth";
import { createSupabaseRouteClient, hasSupabaseServerEnv } from "@/lib/supabase/server"; import { createSupabaseRouteClient, hasSupabaseServerEnv } from "@/lib/supabase/server";
import { getConfiguredSiteUrl } from "@/lib/site-url"; import { getConfiguredSiteUrl } from "@/lib/site-url";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
function normalizeNextPath(input: string | null) { function normalizeNextPath(input: string | null) {
const fallback = "/"; const fallback = "/";
const raw = String(input || "").trim(); const raw = String(input || "").trim();
@@ -11,6 +14,34 @@ function normalizeNextPath(input: string | null) {
return raw; return raw;
} }
async function warmSignupTrial(accessToken: string) {
const token = String(accessToken || "").trim();
if (!API_BASE || !token) return;
const headers = new Headers({
Accept: "application/json",
Authorization: `Bearer ${token}`,
});
const backendToken = process.env.POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN?.trim();
if (backendToken) {
headers.set(BACKEND_ENTITLEMENT_HEADER, backendToken);
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 2500);
try {
await fetch(`${API_BASE}/api/auth/me`, {
cache: "no-store",
headers,
signal: controller.signal,
});
} catch {
// The account/terminal bootstrap will retry. Callback must not strand login.
} finally {
clearTimeout(timeoutId);
}
}
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const siteUrl = getConfiguredSiteUrl(); const siteUrl = getConfiguredSiteUrl();
if (siteUrl) { if (siteUrl) {
@@ -38,7 +69,10 @@ export async function GET(request: NextRequest) {
const supabase = createSupabaseRouteClient(request, response); const supabase = createSupabaseRouteClient(request, response);
const code = request.nextUrl.searchParams.get("code"); const code = request.nextUrl.searchParams.get("code");
if (code) { if (code) {
await supabase.auth.exchangeCodeForSession(code); const {
data: { session },
} = await supabase.auth.exchangeCodeForSession(code);
await warmSignupTrial(session?.access_token || "");
} }
return response; return response;
+25
View File
@@ -0,0 +1,25 @@
import { ResetPasswordClient } from "@/components/auth/ResetPasswordClient";
import { I18nProvider } from "@/hooks/useI18n";
type PageProps = {
searchParams?: Promise<{ next?: string }>;
};
function normalizeNextPath(input: string | undefined) {
const fallback = "/account";
const raw = String(input || "").trim();
if (!raw) return fallback;
if (!raw.startsWith("/")) return fallback;
if (raw.startsWith("//")) return fallback;
return raw;
}
export default async function ResetPasswordPage({ searchParams }: PageProps) {
const params = (await searchParams) || {};
const nextPath = normalizeNextPath(params.next);
return (
<I18nProvider>
<ResetPasswordClient nextPath={nextPath} />
</I18nProvider>
);
}
+2 -2
View File
@@ -23,7 +23,7 @@ export const metadata: Metadata = {
template: "%s | PolyWeather", template: "%s | PolyWeather",
}, },
description: description:
"PolyWeather is a paid professional weather-signal intelligence terminal with METAR evidence, DEB forecast blending, and AI decision cards. Real-time observations for 51 global cities.", "PolyWeather is a paid professional weather-signal intelligence terminal with METAR evidence, DEB forecast blending, and structured decision context. Real-time observations for 51 global cities.",
manifest: "/site.webmanifest", manifest: "/site.webmanifest",
metadataBase: new URL("https://polyweather.top"), metadataBase: new URL("https://polyweather.top"),
alternates: { alternates: {
@@ -34,7 +34,7 @@ export const metadata: Metadata = {
siteName: "PolyWeather", siteName: "PolyWeather",
title: "PolyWeather | Institutional Weather Signal Intelligence", title: "PolyWeather | Institutional Weather Signal Intelligence",
description: description:
"Paid professional weather-signal intelligence terminal. METAR evidence, DEB forecast blending, AI decision cards. 51 cities, real-time.", "Paid professional weather-signal intelligence terminal. METAR evidence, DEB forecast blending, structured decision context. 51 cities, real-time.",
url: "https://polyweather.top", url: "https://polyweather.top",
images: [ images: [
{ {
+21 -10
View File
@@ -1,12 +1,11 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { PreloadTerminalData } from "@/components/landing/PreloadTerminalData";
import { InstitutionalLandingPage } from "@/components/landing/InstitutionalLandingPage"; import { InstitutionalLandingPage } from "@/components/landing/InstitutionalLandingPage";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "PolyWeather | Institutional Weather Signal Intelligence", title: "PolyWeather | Institutional Weather Signal Intelligence",
description: description:
"PolyWeather is a paid professional weather-signal intelligence terminal with METAR evidence, DEB forecast blending, and AI decision cards.", "PolyWeather is a paid professional weather-signal intelligence terminal with METAR evidence, DEB forecast blending, and structured decision context.",
other: { other: {
preconnect: "https://api.polyweather.top", preconnect: "https://api.polyweather.top",
}, },
@@ -38,16 +37,29 @@ export default async function HomePage({
"@type": "WebApplication", "@type": "WebApplication",
name: "PolyWeather", name: "PolyWeather",
description: description:
"Paid professional weather-signal intelligence terminal with METAR evidence, DEB forecast blending, and AI decision cards.", "Paid professional weather-signal intelligence terminal with METAR evidence, DEB forecast blending, and structured decision context.",
url: "https://polyweather.top", url: "https://polyweather.top",
applicationCategory: "BusinessApplication", applicationCategory: "BusinessApplication",
operatingSystem: "Web", operatingSystem: "Web",
offers: { offers: [
"@type": "Offer", {
price: "10.00", "@type": "Offer",
priceCurrency: "USD", name: "Pro monthly",
description: "Monthly subscription", price: "29.90",
}, priceCurrency: "USD",
description:
"Pro subscription for 30 days. Referral users pay 20.00 USD-equivalent USDC for the first month.",
availability: "https://schema.org/InStock",
},
{
"@type": "Offer",
name: "Pro quarterly",
price: "79.90",
priceCurrency: "USD",
description: "Pro subscription for 90 days.",
availability: "https://schema.org/InStock",
},
],
}; };
return ( return (
@@ -56,7 +68,6 @@ export default async function HomePage({
type="application/ld+json" type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/> />
<PreloadTerminalData />
<InstitutionalLandingPage /> <InstitutionalLandingPage />
</> </>
); );
@@ -12,11 +12,6 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useI18n } from "@/hooks/useI18n"; import { useI18n } from "@/hooks/useI18n";
const TELEGRAM_GROUP_URL = String(
process.env.NEXT_PUBLIC_TELEGRAM_GROUP_URL ||
"https://t.me/+Io5H9oVHFmVjOTQ5",
).trim();
const FAQ_ITEMS = [ const FAQ_ITEMS = [
{ {
q_zh: "Pro 包含哪些功能?", q_zh: "Pro 包含哪些功能?",
@@ -27,14 +22,14 @@ const FAQ_ITEMS = [
{ {
q_zh: "当前订阅价格是多少?", q_zh: "当前订阅价格是多少?",
q_en: "What is the current subscription price?", q_en: "What is the current subscription price?",
a_zh: "目前仅提供月付:10 USDC / 30 天。", a_zh: "Pro 月付 29.9 USDC / 30 天,Pro 季度 79.9 USDC / 90 天。",
a_en: "Monthly plan only: 10 USDC / 30 days.", a_en: "Pro monthly is 29.9 USDC / 30 days. Pro quarterly is 79.9 USDC / 90 days.",
}, },
{ {
q_zh: "积分如何抵扣?", q_zh: "积分如何抵扣?",
q_en: "How do points work for discounts?", q_en: "How do points work for discounts?",
a_zh: "满 500 积分起兑,每 500 积分抵 1U,单次最多抵 3U。", a_zh: "满 500 积分起兑,每 500 积分抵 1U。月付最多抵 3U,季度最多抵 8U。",
a_en: "500 points minimum every 500 points = 1 USDC off, up to 3 USDC discount per payment.", a_en: "500 points minimum: every 500 points = 1 USDC off. Monthly orders can use up to 3 USDC off; quarterly orders can use up to 8 USDC off.",
}, },
{ {
q_zh: "支持哪些钱包和支付方式?", q_zh: "支持哪些钱包和支付方式?",
@@ -55,11 +50,11 @@ export function SubscriptionHelpClient() {
? "Complete subscription rules and payment guide." ? "Complete subscription rules and payment guide."
: "这里是完整的订阅规则和支付说明。你可以先在页面内绑定钱包,再直接开通 Pro。", : "这里是完整的订阅规则和支付说明。你可以先在页面内绑定钱包,再直接开通 Pro。",
priceLabel: isEn ? "Price" : "订阅价格", priceLabel: isEn ? "Price" : "订阅价格",
priceText: "10 USDC / 30 " + (isEn ? "Days" : "天"), priceText: isEn ? "29.9 / 30d · 79.9 / 90d" : "29.9 / 30天 · 79.9 / 90天",
discountLabel: isEn ? "Points Discount" : "积分抵扣", discountLabel: isEn ? "Points Discount" : "积分抵扣",
discountText: isEn ? "Up to 3 USDC off" : "最多抵 3U", discountText: isEn ? "Monthly 3U · Quarterly 8U" : "月付 3U · 季度 8U",
communityLabel: isEn ? "Community Points" : "社群积分", communityLabel: isEn ? "Telegram Group" : "Telegram 群",
communityLink: isEn ? "Join community to earn" : "加入社群即可赚取积分", communityLink: isEn ? "Open Account Center" : "前往账户中心",
faqTitle: isEn ? "FAQ" : "常见问题", faqTitle: isEn ? "FAQ" : "常见问题",
}), [isEn]); }), [isEn]);
@@ -102,8 +97,7 @@ export function SubscriptionHelpClient() {
<span className="text-sm font-semibold">{copy.communityLabel}</span> <span className="text-sm font-semibold">{copy.communityLabel}</span>
</div> </div>
<Link <Link
href={TELEGRAM_GROUP_URL} href="/account"
target="_blank"
className="inline-flex min-h-9 items-center text-sm font-semibold text-blue-700 underline decoration-blue-500/50 underline-offset-4" className="inline-flex min-h-9 items-center text-sm font-semibold text-blue-700 underline decoration-blue-500/50 underline-offset-4"
> >
{copy.communityLink} {copy.communityLink}
+12 -1
View File
@@ -1,5 +1,16 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { ScanTerminalDashboard } from "@/components/dashboard/ScanTerminalDashboard"; import dynamic from "next/dynamic";
import { DashboardShellSkeleton } from "@/components/dashboard/DashboardShellSkeleton";
const ScanTerminalDashboard = dynamic(
() =>
import("@/components/dashboard/ScanTerminalDashboard").then(
(mod) => mod.ScanTerminalDashboard,
),
{
loading: () => <DashboardShellSkeleton />,
},
);
export const metadata: Metadata = { export const metadata: Metadata = {
title: "PolyWeather Terminal | Paid Product", title: "PolyWeather Terminal | Paid Product",
+464 -153
View File
@@ -84,6 +84,8 @@ export function AccountCenter() {
const [updatedAt, setUpdatedAt] = useState<string>(""); const [updatedAt, setUpdatedAt] = useState<string>("");
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [backend, setBackend] = useState<AuthMeResponse | null>(null); const [backend, setBackend] = useState<AuthMeResponse | null>(null);
const [referralCodeInput, setReferralCodeInput] = useState("");
const [referralApplying, setReferralApplying] = useState(false);
const supabaseReady = hasSupabasePublicEnv(); const supabaseReady = hasSupabasePublicEnv();
const walletConnectEnabled = Boolean(WALLETCONNECT_PROJECT_ID); const walletConnectEnabled = Boolean(WALLETCONNECT_PROJECT_ID);
@@ -124,6 +126,7 @@ export function AccountCenter() {
boundWallets, boundWallets,
walletAddress, walletAddress,
selectedPlanCode, selectedPlanCode,
selectedPaymentChainId,
selectedTokenAddress, selectedTokenAddress,
selectedWallet, selectedWallet,
providerMode, providerMode,
@@ -133,6 +136,8 @@ export function AccountCenter() {
// Shared setters // Shared setters
setSelectedTokenAddress, setSelectedTokenAddress,
setSelectedPaymentChainId,
setSelectedPlanCode,
setSelectedWallet, setSelectedWallet,
setSelectedInjectedProviderKey, setSelectedInjectedProviderKey,
setProviderMode, setProviderMode,
@@ -149,6 +154,8 @@ export function AccountCenter() {
selectedPaymentToken, selectedPaymentToken,
selectedTokenLabel, selectedTokenLabel,
availableTokenList, availableTokenList,
availableChainList,
selectedPaymentChain,
effectivePlanList, effectivePlanList,
resolvedSelectedTokenAddress, resolvedSelectedTokenAddress,
paymentReceiverAddress, paymentReceiverAddress,
@@ -189,19 +196,49 @@ export function AccountCenter() {
useEffect(() => { useEffect(() => {
if (!authIsAuthenticated || !authUserId) return; if (!authIsAuthenticated || !authUserId) return;
const actorKey = authUserId.toLowerCase(); const actorKey = authUserId.toLowerCase();
const subscriptionPlanCode = String(backend?.subscription_plan_code || "").trim();
const subscriptionSource = String(backend?.subscription_source || "").trim();
const trialSubscription = Boolean(
backend?.subscription_is_trial === true ||
subscriptionPlanCode.toLowerCase().includes("trial") ||
subscriptionSource.toLowerCase().includes("trial"),
);
if (markAnalyticsOnce(`signup_completed:${actorKey}`, "local")) { if (markAnalyticsOnce(`signup_completed:${actorKey}`, "local")) {
trackAppEvent("signup_completed", { trackAppEvent("signup_completed", {
entry: "account_center", entry: "account_center",
user_id: authUserId, user_id: authUserId,
}); });
} }
if (markAnalyticsOnce(`signup_success:${actorKey}`, "local")) {
trackAppEvent("signup_success", {
entry: "account_center",
user_id: authUserId,
});
}
if (markAnalyticsOnce(`dashboard_active:${actorKey}`, "session")) { if (markAnalyticsOnce(`dashboard_active:${actorKey}`, "session")) {
trackAppEvent("dashboard_active", { trackAppEvent("dashboard_active", {
entry: "account_center", entry: "account_center",
user_id: authUserId, user_id: authUserId,
}); });
} }
}, [authIsAuthenticated, authUserId]); if (trialSubscription && markAnalyticsOnce(`trial_created:${actorKey}`, "local")) {
trackAppEvent("trial_created", {
entry: "account_center",
user_id: authUserId,
subscription_plan_code: subscriptionPlanCode || null,
subscription_source: subscriptionSource || null,
subscription_expires_at: backend?.subscription_expires_at || null,
subscription_is_trial: backend?.subscription_is_trial === true,
});
}
}, [
authIsAuthenticated,
authUserId,
backend?.subscription_expires_at,
backend?.subscription_is_trial,
backend?.subscription_plan_code,
backend?.subscription_source,
]);
// ── Idle callback effect ────────────────────────────── // ── Idle callback effect ──────────────────────────────
useEffect(() => { useEffect(() => {
@@ -295,8 +332,16 @@ export function AccountCenter() {
backend.authenticated === false), backend.authenticated === false),
); );
const isSubscribed = backend?.subscription_active === true; const isSubscribed = backend?.subscription_active === true;
const subscriptionSource = String(backend?.subscription_source || "").trim();
const isTrialSubscription = Boolean(
backend?.subscription_is_trial === true ||
String(backend?.subscription_plan_code || "").toLowerCase().includes("trial") ||
subscriptionSource.toLowerCase().includes("trial"),
);
const subscriptionStatusLabel = isSubscribed const subscriptionStatusLabel = isSubscribed
? copy.proMember ? isTrialSubscription
? copy.trialBadge
: copy.proMember
: isSubscriptionUnknown : isSubscriptionUnknown
? copy.subscriptionChecking ? copy.subscriptionChecking
: isEn : isEn
@@ -321,7 +366,8 @@ export function AccountCenter() {
const hasQueuedExtension = Boolean( const hasQueuedExtension = Boolean(
isSubscribed && queuedExtensionDays > 0, isSubscribed && queuedExtensionDays > 0,
); );
const canAccessPaidTelegramGroup = Boolean(isSubscribed); const canAccessPaidTelegramGroup = Boolean(isSubscribed && !isTrialSubscription);
const hasTelegramPanel = Boolean(isTrialSubscription || canAccessPaidTelegramGroup);
const telegramBound = const telegramBound =
Number(backend?.telegram_pricing?.telegram_id || 0) > 0; Number(backend?.telegram_pricing?.telegram_id || 0) > 0;
const displayExpiryRaw = isSubscribed const displayExpiryRaw = isSubscribed
@@ -380,6 +426,21 @@ export function AccountCenter() {
const expiryLabel = hasQueuedExtension const expiryLabel = hasQueuedExtension
? copy.accessUntil ? copy.accessUntil
: copy.renewalDate; : copy.renewalDate;
const displayPlanList = effectivePlanList.length
? effectivePlanList
: [
{ plan_code: "pro_monthly", plan_id: 101, amount_usdc: "29.9", duration_days: 30 },
{ plan_code: "pro_quarterly", plan_id: 102, amount_usdc: "79.9", duration_days: 90 },
];
const referral = backend?.referral;
const referralCode = String(referral?.code || "").trim();
const appliedReferralCode = String(referral?.applied_code || "").trim();
const canApplyReferralCode = Boolean(
isAuthenticated &&
!isSubscribed &&
!appliedReferralCode &&
referralCodeInput.trim(),
);
// ── Payment overlay tracking effect ────────────────────── // ── Payment overlay tracking effect ──────────────────────
useEffect(() => { useEffect(() => {
@@ -400,20 +461,31 @@ export function AccountCenter() {
showOverlay, showOverlay,
]); ]);
// ── Weekly points display (component-only derived) ────── // ── Referral points display ────────────────────────────
const backendWeeklyPointsRaw = Number(backend?.weekly_points); const referralRewardPointsRaw = Number(referral?.reward_points ?? 3500);
const metadataWeeklyPointsRaw = Number( const referralRewardPoints = Number.isFinite(referralRewardPointsRaw)
user?.user_metadata?.weekly_points ?? 0, ? Math.max(0, referralRewardPointsRaw)
); : 3500;
const weeklyPointsRaw = Number.isFinite(backendWeeklyPointsRaw) const monthlyReferralCountRaw = Number(referral?.monthly_reward_count ?? 0);
? backendWeeklyPointsRaw const monthlyReferralCount = Number.isFinite(monthlyReferralCountRaw)
: metadataWeeklyPointsRaw; ? Math.max(0, monthlyReferralCountRaw)
const weeklyRankRaw =
backend?.weekly_rank ?? user?.user_metadata?.weekly_rank;
const weeklyPoints = Number.isFinite(weeklyPointsRaw)
? Math.max(0, weeklyPointsRaw)
: 0; : 0;
const weeklyRank = weeklyRankRaw == null ? "--" : String(weeklyRankRaw); const monthlyReferralLimitRaw = Number(referral?.monthly_reward_limit ?? 10);
const monthlyReferralLimit = Number.isFinite(monthlyReferralLimitRaw)
? Math.max(0, monthlyReferralLimitRaw)
: 10;
const monthlyReferralPointsRaw = Number(
referral?.monthly_reward_points ?? monthlyReferralCount * referralRewardPoints,
);
const monthlyReferralPoints = Number.isFinite(monthlyReferralPointsRaw)
? Math.max(0, monthlyReferralPointsRaw)
: 0;
const monthlyReferralPointsLimitRaw = Number(
referral?.monthly_reward_points_limit ?? monthlyReferralLimit * referralRewardPoints,
);
const monthlyReferralPointsLimit = Number.isFinite(monthlyReferralPointsLimitRaw)
? Math.max(0, monthlyReferralPointsLimitRaw)
: monthlyReferralLimit * referralRewardPoints;
// ── Telegram bind command ────────────────────────────── // ── Telegram bind command ──────────────────────────────
const bindCommand = userId const bindCommand = userId
@@ -428,6 +500,55 @@ export function AccountCenter() {
}); });
}; };
const applyReferralCode = useCallback(async () => {
const code = referralCodeInput.trim();
if (!code || referralApplying) return;
setReferralApplying(true);
setPaymentError("");
setPaymentInfo("");
try {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (supabaseReady) {
const {
data: { session },
} = await getSupabaseBrowserClient().auth.getSession();
const token = String(session?.access_token || "").trim();
if (token) headers.Authorization = `Bearer ${token}`;
}
const res = await fetch("/api/auth/referral/apply", {
method: "POST",
headers,
body: JSON.stringify({ code }),
});
if (!res.ok) {
const raw = (await res.text()).slice(0, 240);
throw new Error(raw || copy.referralApplyFailed);
}
setPaymentInfo(copy.referralApplied);
setReferralCodeInput("");
await loadSnapshot();
await loadPaymentSnapshot();
} catch (error) {
setPaymentError(
error instanceof Error ? error.message : copy.referralApplyFailed,
);
} finally {
setReferralApplying(false);
}
}, [
copy.referralApplied,
copy.referralApplyFailed,
loadPaymentSnapshot,
loadSnapshot,
referralApplying,
referralCodeInput,
setPaymentError,
setPaymentInfo,
supabaseReady,
]);
// ── Render ──────────────────────────────────────────── // ── Render ────────────────────────────────────────────
if (loading && !refreshing) { if (loading && !refreshing) {
@@ -611,7 +732,7 @@ export function AccountCenter() {
</p> </p>
<p className="flex items-center justify-center gap-2 text-xl font-bold text-slate-950"> <p className="flex items-center justify-center gap-2 text-xl font-bold text-slate-950">
<TrendingUp size={16} className="text-emerald-400" />{" "} <TrendingUp size={16} className="text-emerald-400" />{" "}
{weeklyPoints.toLocaleString()} {monthlyReferralCount.toLocaleString()}
</p> </p>
</div> </div>
<div className="min-w-[140px] rounded-xl border border-blue-200 bg-blue-50 px-6 py-4 text-center"> <div className="min-w-[140px] rounded-xl border border-blue-200 bg-blue-50 px-6 py-4 text-center">
@@ -620,13 +741,13 @@ export function AccountCenter() {
</p> </p>
<p className="flex items-center justify-center gap-2 text-xl font-bold text-slate-950"> <p className="flex items-center justify-center gap-2 text-xl font-bold text-slate-950">
<Trophy size={16} className="text-amber-400" />{" "} <Trophy size={16} className="text-amber-400" />{" "}
{weeklyRank === "--" ? weeklyRank : `#${weeklyRank}`} {monthlyReferralCount}/{monthlyReferralLimit}
</p> </p>
</div> </div>
</div> </div>
</div> </div>
{/* Weekly Ranking Motivation */} {/* Referral rewards */}
{showSecondarySections ? ( {showSecondarySections ? (
<div className="flex flex-col justify-between rounded-2xl border border-slate-200 bg-white p-6 shadow-sm lg:col-span-4"> <div className="flex flex-col justify-between rounded-2xl border border-slate-200 bg-white p-6 shadow-sm lg:col-span-4">
<div> <div>
@@ -637,35 +758,29 @@ export function AccountCenter() {
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between rounded-xl border border-slate-200 bg-slate-50 p-3"> <div className="flex items-center justify-between rounded-xl border border-slate-200 bg-slate-50 p-3">
<span className="text-sm flex items-center gap-2"> <span className="text-sm flex items-center gap-2">
<div className="w-5 h-5 bg-yellow-500 rounded text-black font-bold text-[10px] flex items-center justify-center"> <Coins size={16} className="text-yellow-500" />{" "}
1 {copy.referralRewardHint}
</div>{" "}
Top 1
</span> </span>
<span className="text-xs font-bold text-amber-600"> <span className="text-xs font-bold text-amber-600">
+200 & 7Pro +{referralRewardPoints.toLocaleString()}
</span> </span>
</div> </div>
<div className="flex items-center justify-between rounded-xl border border-slate-200 bg-slate-50 p-3"> <div className="flex items-center justify-between rounded-xl border border-slate-200 bg-slate-50 p-3">
<span className="text-sm flex items-center gap-2"> <span className="text-sm flex items-center gap-2">
<div className="w-5 h-5 bg-slate-300 rounded text-black font-bold text-[10px] flex items-center justify-center"> <TrendingUp size={16} className="text-emerald-500" />{" "}
2 {copy.weeklyPoints}
</div>{" "}
Top 2-3
</span> </span>
<span className="text-xs font-bold text-slate-600"> <span className="text-xs font-bold text-slate-600">
+100 {monthlyReferralCount}/{monthlyReferralLimit}
</span> </span>
</div> </div>
<div className="flex items-center justify-between rounded-xl border border-slate-200 bg-slate-50 p-3"> <div className="flex items-center justify-between rounded-xl border border-slate-200 bg-slate-50 p-3">
<span className="text-sm flex items-center gap-2"> <span className="text-sm flex items-center gap-2">
<div className="w-5 h-5 bg-orange-800 rounded text-white font-bold text-[10px] flex items-center justify-center"> <Trophy size={16} className="text-blue-500" />{" "}
4 {copy.totalPoints}
</div>{" "}
Top 4-10
</span> </span>
<span className="text-xs font-bold text-orange-400"> <span className="text-xs font-bold text-orange-400">
+50 {monthlyReferralPoints.toLocaleString()}/{monthlyReferralPointsLimit.toLocaleString()}
</span> </span>
</div> </div>
</div> </div>
@@ -673,8 +788,7 @@ export function AccountCenter() {
<div className="mt-6 flex items-start gap-2 rounded-xl border border-slate-200 bg-slate-50 p-3"> <div className="mt-6 flex items-start gap-2 rounded-xl border border-slate-200 bg-slate-50 p-3">
<Info size={14} className="text-slate-500 mt-0.5 shrink-0" /> <Info size={14} className="text-slate-500 mt-0.5 shrink-0" />
<p className="text-[10px] text-slate-500 leading-normal italic"> <p className="text-[10px] text-slate-500 leading-normal italic">
+ {copy.pointsRule}
</p> </p>
</div> </div>
</div> </div>
@@ -810,7 +924,7 @@ export function AccountCenter() {
errorText={paymentError || undefined} errorText={paymentError || undefined}
infoText={paymentInfo || undefined} infoText={paymentInfo || undefined}
txHash={lastTxHash || undefined} txHash={lastTxHash || undefined}
chainId={paymentConfig?.chain_id || 137} chainId={selectedPaymentChainId || paymentConfig?.chain_id || 137}
paymentTokenLabel={selectedTokenLabel} paymentTokenLabel={selectedTokenLabel}
faqHref={SUBSCRIPTION_HELP_HREF} faqHref={SUBSCRIPTION_HELP_HREF}
telegramGroupUrl="" telegramGroupUrl=""
@@ -821,9 +935,38 @@ export function AccountCenter() {
{/* Telegram Bot Section & Payment Details */} {/* Telegram Bot Section & Payment Details */}
{showSecondarySections ? ( {showSecondarySections ? (
<div className="lg:col-span-12 grid grid-cols-1 md:flex gap-6"> <div
className={`lg:col-span-12 grid grid-cols-1 items-start gap-6 ${
hasTelegramPanel ? "xl:grid-cols-[minmax(0,0.9fr)_minmax(620px,1.1fr)]" : ""
}`}
>
{isTrialSubscription && (
<section className="group relative min-w-0 overflow-hidden rounded-2xl border border-amber-200 bg-amber-50 p-8 shadow-sm">
<Bot
size={140}
className="absolute -right-8 -bottom-8 -rotate-12 text-amber-100"
/>
<div className="relative z-10">
<h3 className="mb-2 flex items-center gap-2 text-lg font-bold text-amber-800">
<Bot size={22} /> {copy.telegramBind}
</h3>
<p className="text-sm leading-6 text-amber-900">
{copy.trialPaidGroupLocked}
</p>
<button
type="button"
onClick={() => setShowOverlay(true)}
disabled={!canOpenCheckoutOverlay}
className="mt-5 inline-flex items-center gap-2 rounded-xl border border-amber-700 bg-amber-600 px-4 py-3 text-xs font-bold text-white hover:bg-amber-700 disabled:cursor-not-allowed disabled:opacity-50"
>
<Crown size={14} />
{copy.upgradePro}
</button>
</div>
</section>
)}
{canAccessPaidTelegramGroup && ( {canAccessPaidTelegramGroup && (
<section className="group relative flex-1 overflow-hidden rounded-2xl border border-slate-200 bg-white p-8 shadow-sm"> <section className="group relative min-w-0 overflow-hidden rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
<Bot <Bot
size={140} size={140}
className="absolute -right-8 -bottom-8 -rotate-12 text-slate-100 transition-transform duration-1000 group-hover:rotate-0" className="absolute -right-8 -bottom-8 -rotate-12 text-slate-100 transition-transform duration-1000 group-hover:rotate-0"
@@ -901,11 +1044,7 @@ export function AccountCenter() {
)} )}
{/* Payment Details / Wallet Management */} {/* Payment Details / Wallet Management */}
<section <section className="w-full rounded-2xl border border-slate-200 bg-white p-6 shadow-sm lg:p-7">
className={`flex flex-col justify-between rounded-2xl border border-slate-200 bg-white p-8 shadow-sm ${
canAccessPaidTelegramGroup ? "w-full md:w-96" : "w-full"
}`}
>
<div> <div>
<h3 className="mb-6 flex items-center gap-2 text-sm font-bold uppercase text-blue-700"> <h3 className="mb-6 flex items-center gap-2 text-sm font-bold uppercase text-blue-700">
<Wallet size={18} /> {copy.paymentMgmt} <Wallet size={18} /> {copy.paymentMgmt}
@@ -939,117 +1078,287 @@ export function AccountCenter() {
)} )}
</div> </div>
) : null} ) : null}
<div className="mb-5 space-y-3"> <div
<InfoRow data-testid="payment-management-grid"
icon={Mail} className={`grid gap-6 lg:items-start ${
label={copy.paymentAccount} hasTelegramPanel ? "" : "lg:grid-cols-[minmax(0,1fr)_minmax(320px,380px)]"
value={email || "--"} }`}
isPrimary >
/> <div className="space-y-5">
<InfoRow <div>
icon={Wallet} <p className="mb-2 text-[11px] uppercase text-slate-500">
label={copy.paymentWallet} {copy.proPlan}
value={shortAddress(paymentWalletLabel) || "--"} </p>
/> <div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<InfoRow {displayPlanList.map((plan) => {
icon={ShieldCheck} const code = String(plan.plan_code || "");
label={copy.paymentReceiver} const active = code === selectedPlanCode;
value={shortAddress(paymentReceiverAddress) || "--"} const isQuarterly = code === "pro_quarterly";
/> return (
<InfoRow <button
icon={ExternalLink} type="button"
label={copy.paymentNetwork} key={code}
value={chainIdToDisplayName(paymentConfig?.chain_id)} onClick={() => setSelectedPlanCode(code)}
/> disabled={paymentBusy}
<InfoRow className={`rounded-xl border px-3 py-3 text-left transition-all ${
icon={ExternalLink} active
label={copy.paymentHost} ? "border-blue-300 bg-blue-50 text-blue-900"
value={currentPaymentHost || "--"} : "border-slate-200 bg-white text-slate-600 hover:bg-slate-50"
/> }`}
<p className="text-[11px] text-slate-500"> >
{copy.paymentGuardHint} <div className="flex items-center justify-between gap-2 text-xs font-bold">
</p> <span>
</div> {isQuarterly ? copy.quarterlyPlan : copy.monthlyPlan}
{availableTokenList.length > 0 && ( </span>
<div className="mb-5"> <span>{plan.amount_usdc} USDC</span>
<p className="mb-2 text-[11px] uppercase text-slate-500"> </div>
{copy.paymentToken} <div className="mt-1 text-[10px] opacity-80">
</p> {code} · {plan.duration_days}
<div className="grid grid-cols-2 gap-2"> </div>
{availableTokenList.map((token) => { </button>
const active = );
token.address === })}
(resolvedSelectedTokenAddress || </div>
token.address); {appliedReferralCode && selectedPlanCode === "pro_monthly" ? (
return ( <p className="mt-2 rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2 text-[11px] text-emerald-800">
<button {copy.referralDiscountHint}
type="button" </p>
key={token.address} ) : null}
onClick={() => </div>
setSelectedTokenAddress( <div className="rounded-2xl border border-slate-200 bg-slate-50 p-4">
token.address, <div className="mb-3 flex items-center justify-between gap-3">
) <p className="text-xs font-bold uppercase text-slate-700">
} {copy.referralTitle}
disabled={paymentBusy} </p>
className={`rounded-xl border px-3 py-2 text-left transition-all ${ <span className="text-[10px] text-slate-500">
active {copy.referralInviteLimit}
? "border-blue-300 bg-blue-50 text-blue-900" </span>
: "border-slate-200 bg-white text-slate-600 hover:bg-slate-50" </div>
}`} <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2">
> <div>
<div className="text-xs font-bold"> <p className="mb-1 text-[10px] uppercase text-slate-500">
{token.symbol} {copy.referralMyCode}
</p>
<div className="flex gap-2">
<code className="min-w-0 flex-1 truncate rounded-xl border border-slate-200 bg-white px-3 py-2 font-mono text-xs text-blue-700">
{referralCode || "--"}
</code>
{referralCode ? (
<button
type="button"
onClick={() => handleCopy(referralCode)}
className="rounded-xl border border-blue-700 bg-blue-600 px-3 text-xs font-bold text-white hover:bg-blue-700"
>
{copied ? <CheckCircle2 size={15} /> : <Copy size={15} />}
</button>
) : null}
</div>
<p className="mt-2 text-[11px] leading-5 text-slate-500">
{copy.referralRewardHint}
</p>
</div>
<div>
<p className="mb-1 text-[10px] uppercase text-slate-500">
{copy.referralApplyLabel}
</p>
{appliedReferralCode ? (
<div className="rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs font-semibold text-emerald-800">
{appliedReferralCode}
</div> </div>
<div className="text-[10px] opacity-80 truncate"> ) : (
{token.name} <div className="flex gap-2">
<input
value={referralCodeInput}
onChange={(event) => setReferralCodeInput(event.target.value)}
placeholder={copy.referralApplyPlaceholder}
className="min-w-0 flex-1 rounded-xl border border-slate-300 bg-white px-3 py-2 text-xs text-slate-950 outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20"
/>
<button
type="button"
onClick={() => void applyReferralCode()}
disabled={!canApplyReferralCode || referralApplying}
className="rounded-xl border border-slate-900 bg-slate-900 px-3 text-xs font-bold text-white hover:bg-slate-800 disabled:cursor-not-allowed disabled:opacity-50"
>
{referralApplying ? "..." : copy.referralApplyButton}
</button>
</div> </div>
</button> )}
); <p className="mt-2 text-[11px] leading-5 text-slate-500">
})} {copy.referralDiscountHint}
</p>
</div>
</div>
</div>
<div
data-testid="payment-guard-grid"
className={`grid gap-3 ${hasTelegramPanel ? "" : "sm:grid-cols-2"}`}
>
<InfoRow
icon={Mail}
label={copy.paymentAccount}
value={email || "--"}
isPrimary
/>
<InfoRow
icon={Wallet}
label={copy.paymentWallet}
value={shortAddress(paymentWalletLabel) || "--"}
/>
<InfoRow
icon={ShieldCheck}
label={copy.paymentReceiver}
value={shortAddress(paymentReceiverAddress) || "--"}
/>
<InfoRow
icon={ExternalLink}
label={copy.paymentNetwork}
value={
selectedPaymentChain?.name ||
chainIdToDisplayName(selectedPaymentChainId)
}
/>
<InfoRow
icon={ExternalLink}
label={copy.paymentHost}
value={currentPaymentHost || "--"}
/>
<p className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-[11px] leading-5 text-slate-500 sm:col-span-2">
{copy.paymentGuardHint}
</p>
</div> </div>
</div> </div>
)} <div className="space-y-5">
{availableChainList.length > 1 && (
{/* Payment Method Tabs */} <div>
<div className="mt-6 border-t border-slate-200 pt-6"> <p className="mb-2 text-[11px] uppercase text-slate-500">
<p className="mb-3 text-[10px] font-semibold uppercase text-slate-500"> {copy.paymentNetwork}
{copy.paymentMethodLabel} </p>
</p> <div className="grid grid-cols-2 gap-2">
<div className="mb-5 grid grid-cols-2 gap-2 rounded-xl border border-slate-200 bg-slate-100 p-1"> {availableChainList.map((chain) => {
<button const active = chain.chain_id === selectedPaymentChainId;
type="button" return (
onClick={() => setPaymentMethodTab("wallet")} <button
className={`py-2 rounded-lg text-xs font-bold transition-all flex items-center justify-center gap-1.5 ${ type="button"
paymentMethodTab === "wallet" key={chain.chain_id}
? "bg-white text-blue-700 shadow-sm" onClick={() => {
: "text-slate-500 hover:bg-white/70 hover:text-slate-900" setSelectedPaymentChainId(chain.chain_id);
}`} const nextToken =
> paymentConfig?.tokens?.find(
<Wallet size={12} /> (token) =>
{copy.paymentMethodWallet} Number(token.chain_id || chain.chain_id) ===
</button> chain.chain_id &&
<button token.is_default,
type="button" ) ||
onClick={() => setPaymentMethodTab("manual")} paymentConfig?.tokens?.find(
className={`py-2 rounded-lg text-xs font-bold transition-all flex items-center justify-center gap-1.5 ${ (token) =>
paymentMethodTab === "manual" Number(token.chain_id || chain.chain_id) ===
? "bg-white text-emerald-700 shadow-sm" chain.chain_id,
: "text-slate-500 hover:bg-white/70 hover:text-slate-900" );
}`} if (nextToken?.address) {
> setSelectedTokenAddress(
<ExternalLink size={12} /> String(nextToken.address).toLowerCase(),
{copy.paymentMethodManual} );
</button> }
</div> }}
disabled={paymentBusy}
{paymentMethodTab === "wallet" ? ( className={`rounded-xl border px-3 py-2 text-left transition-all ${
<div className="space-y-4"> active
<p className="text-[11px] leading-relaxed text-slate-400"> ? "border-blue-300 bg-blue-50 text-blue-900"
{copy.paymentWalletDesc} : "border-slate-200 bg-white text-slate-600 hover:bg-slate-50"
</p> }`}
<div className="rounded-xl border border-amber-200 bg-amber-50 p-3 text-[11px] leading-relaxed text-amber-800"> >
{copy.paymentGasWarning} <div className="text-xs font-bold">
{chain.name || chainIdToDisplayName(chain.chain_id)}
</div>
<div className="text-[10px] opacity-80">
{chain.native_currency_symbol || "ETH"} gas
</div>
</button>
);
})}
</div>
</div> </div>
)}
{availableTokenList.length > 0 && (
<div>
<p className="mb-2 text-[11px] uppercase text-slate-500">
{copy.paymentToken}
</p>
<div className="grid grid-cols-2 gap-2">
{availableTokenList.map((token) => {
const active =
token.address ===
(resolvedSelectedTokenAddress ||
token.address);
return (
<button
type="button"
key={`${token.chain_id || selectedPaymentChainId}:${token.address}`}
onClick={() =>
setSelectedTokenAddress(
token.address,
)
}
disabled={paymentBusy}
className={`rounded-xl border px-3 py-2 text-left transition-all ${
active
? "border-blue-300 bg-blue-50 text-blue-900"
: "border-slate-200 bg-white text-slate-600 hover:bg-slate-50"
}`}
>
<div className="text-xs font-bold">
{token.symbol}
</div>
<div className="truncate text-[10px] opacity-80">
{token.name}
</div>
</button>
);
})}
</div>
</div>
)}
{/* Payment Method Tabs */}
<div className="border-t border-slate-200 pt-5">
<p className="mb-3 text-[10px] font-semibold uppercase text-slate-500">
{copy.paymentMethodLabel}
</p>
<div className="mb-5 grid grid-cols-2 gap-2 rounded-xl border border-slate-200 bg-slate-100 p-1">
<button
type="button"
onClick={() => setPaymentMethodTab("wallet")}
className={`py-2 rounded-lg text-xs font-bold transition-all flex items-center justify-center gap-1.5 ${
paymentMethodTab === "wallet"
? "bg-white text-blue-700 shadow-sm"
: "text-slate-500 hover:bg-white/70 hover:text-slate-900"
}`}
>
<Wallet size={12} />
{copy.paymentMethodWallet}
</button>
<button
type="button"
onClick={() => setPaymentMethodTab("manual")}
className={`py-2 rounded-lg text-xs font-bold transition-all flex items-center justify-center gap-1.5 ${
paymentMethodTab === "manual"
? "bg-white text-emerald-700 shadow-sm"
: "text-slate-500 hover:bg-white/70 hover:text-slate-900"
}`}
>
<ExternalLink size={12} />
{copy.paymentMethodManual}
</button>
</div>
{paymentMethodTab === "wallet" ? (
<div className="space-y-4">
<p className="text-[11px] leading-relaxed text-slate-400">
{copy.paymentWalletDesc}
</p>
<div className="rounded-xl border border-amber-200 bg-amber-50 p-3 text-[11px] leading-relaxed text-amber-800">
{copy.paymentGasWarning}
</div>
{boundWallets.length ? ( {boundWallets.length ? (
<div className="space-y-3"> <div className="space-y-3">
@@ -1073,7 +1382,7 @@ export function AccountCenter() {
)} )}
</div> </div>
<div className="text-[10px]"> <div className="text-[10px]">
{copy.polygonChain} {chainIdToDisplayName(w.chain_id)}
</div> </div>
<div className="mt-2 flex justify-end"> <div className="mt-2 flex justify-end">
<button <button
@@ -1298,8 +1607,8 @@ export function AccountCenter() {
} }
disabled={ disabled={
paymentBusy || paymentBusy ||
(txValidation.checked && !(txValidation.checked &&
!txValidation.valid) txValidation.valid === true)
} }
className="w-full rounded-xl border border-emerald-700 bg-emerald-600 px-3 py-2 text-xs font-bold text-white transition-all hover:bg-emerald-700 disabled:opacity-50" className="w-full rounded-xl border border-emerald-700 bg-emerald-600 px-3 py-2 text-xs font-bold text-white transition-all hover:bg-emerald-700 disabled:opacity-50"
> >
@@ -1312,6 +1621,8 @@ export function AccountCenter() {
)} )}
</div> </div>
</div> </div>
</div>
</div>
</section> </section>
</div> </div>
) : ( ) : (
@@ -0,0 +1,63 @@
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 accountDir = path.join(projectRoot, "components", "account");
const useAccountPaymentSource = fs.readFileSync(
path.join(accountDir, "useAccountPayment.ts"),
"utf8",
);
const usePaymentFlowSource = fs.readFileSync(
path.join(accountDir, "usePaymentFlow.ts"),
"utf8",
);
const useWalletBindSource = fs.readFileSync(
path.join(accountDir, "useWalletBind.ts"),
"utf8",
);
const accountCenterSource = fs.readFileSync(
path.join(accountDir, "AccountCenter.tsx"),
"utf8",
);
const opsPaymentsSource = fs.readFileSync(
path.join(projectRoot, "components", "ops", "payments", "PaymentsPageClient.tsx"),
"utf8",
);
assert(
useAccountPaymentSource.includes("selectedPaymentChainId") &&
useAccountPaymentSource.includes("setSelectedPaymentChainId"),
"account payment state must track the selected payment chain separately from the legacy default chain",
);
assert(
usePaymentFlowSource.includes("chain_id: selectedPaymentChainId") ||
usePaymentFlowSource.includes("chain_id: targetChainId"),
"payment intent creation must send the selected chain_id to the backend",
);
assert(
!usePaymentFlowSource.includes("请在 Polygon 网络转"),
"manual transfer instructions must not hard-code Polygon after Ethereum USDC is supported",
);
assert(
useWalletBindSource.includes("chainName") &&
useWalletBindSource.includes("wallet_addEthereumChain") &&
useWalletBindSource.includes("Ethereum Mainnet"),
"wallet network switching must use chain metadata instead of hard-coded Polygon-only add-network params",
);
assert(
accountCenterSource.includes("availableChainList") &&
accountCenterSource.includes("setSelectedPaymentChainId") &&
accountCenterSource.includes("paymentNetwork"),
"account center must expose a payment network selector when multiple chains are configured",
);
assert(
opsPaymentsSource.includes("etherscan.io") &&
opsPaymentsSource.includes("polygonscan.com"),
"ops payment tx links must route Ethereum payments to Etherscan and Polygon payments to Polygonscan",
);
}
@@ -0,0 +1,69 @@
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 accountCenter = fs.readFileSync(
path.join(projectRoot, "components", "account", "AccountCenter.tsx"),
"utf8",
);
const accountCopy = fs.readFileSync(
path.join(projectRoot, "components", "account", "account-copy.ts"),
"utf8",
);
const useAccountPayment = fs.readFileSync(
path.join(projectRoot, "components", "account", "useAccountPayment.ts"),
"utf8",
);
const usePaymentFlow = fs.readFileSync(
path.join(projectRoot, "components", "account", "usePaymentFlow.ts"),
"utf8",
);
const types = fs.readFileSync(
path.join(projectRoot, "components", "account", "types.ts"),
"utf8",
);
assert(
accountCopy.includes("3天试用") &&
accountCopy.includes("付费 Telegram 群") &&
accountCopy.includes("邀请码"),
"account copy must describe trial limits and referral code UI",
);
assert(
accountCenter.includes("copy.trialPaidGroupLocked") &&
accountCenter.includes("copy.referralInviteLimit") &&
accountCenter.includes("applyReferralCode"),
"account center must expose trial paid-group gating and referral controls",
);
assert(
accountCenter.includes("pro_quarterly") &&
accountCenter.includes("79.9") &&
accountCenter.includes("29.9"),
"account center must show monthly and quarterly Pro prices",
);
assert(
accountCopy.includes("20 USDC") &&
accountCopy.includes("+3500 积分") &&
accountCopy.includes("月付订单最多抵扣 3 USDC") &&
accountCopy.includes("季度订单最多抵扣 8 USDC") &&
!accountCopy.includes("群内有效发言"),
"account copy must describe balanced referral points and remove group-message points",
);
assert(
!useAccountPayment.includes("monthlyPlanList") &&
!usePaymentFlow.includes("monthlyPlanList"),
"payment hooks must not filter checkout plans down to monthly only",
);
assert(
types.includes("ReferralSummary") &&
types.includes("referral?: ReferralSummary | null") &&
types.includes("duration_days: number") &&
types.includes("max_discount_usdc_by_plan"),
"account auth and payment types must include referral summary and plan durations",
);
}
@@ -71,6 +71,23 @@ export function runTests() {
paymentFlowSource.includes("assertExpectedPaymentReceiver"), paymentFlowSource.includes("assertExpectedPaymentReceiver"),
"AccountCenter must validate backend-returned manual payment receiver before displaying it", "AccountCenter must validate backend-returned manual payment receiver before displaying it",
); );
assert(
/!\(\s*txValidation\.checked\s*&&\s*txValidation\.valid === true\s*\)/.test(
accountCenterSource,
),
"manual payment submit button must require checked && valid === true",
);
assert(
paymentFlowSource.includes("validateTxHash") &&
paymentFlowSource.includes("/validate"),
"manual payment flow must validate tx hashes with the backend before submission",
);
assert(
paymentFlowSource.includes("await waitForReceipt(txHashNorm, eth)") &&
paymentFlowSource.indexOf("await waitForReceipt(txHashNorm, eth)") <
paymentFlowSource.indexOf("const submitRes = await fetch(`/api/payments/intents/${intentId}/submit`"),
"wallet payment flow must wait for the payment tx receipt before submitting tx hash to the backend",
);
assert( assert(
accountCenterSource.includes("EXPECTED_PAYMENT_RECEIVER_ADDRESS") || accountCenterSource.includes("EXPECTED_PAYMENT_RECEIVER_ADDRESS") ||
hookSource.includes("EXPECTED_PAYMENT_RECEIVER_ADDRESS") || hookSource.includes("EXPECTED_PAYMENT_RECEIVER_ADDRESS") ||
@@ -83,6 +100,11 @@ export function runTests() {
backendAuthSource.includes("requireBackendAuthUser"), backendAuthSource.includes("requireBackendAuthUser"),
"backend auth helper must expose a real-user requirement for payment mutations", "backend auth helper must expose a real-user requirement for payment mutations",
); );
assert(
backendAuthSource.includes("requireBackendPaymentAuth") &&
backendAuthSource.includes("hasBearerAuth"),
"backend auth helper must allow bearer-backed payment mutations to reach the backend verifier",
);
const middlewareSource = fs.readFileSync(middlewarePath, "utf8"); const middlewareSource = fs.readFileSync(middlewarePath, "utf8");
assert( assert(
@@ -90,12 +112,113 @@ export function runTests() {
!middlewareSource.includes("return NextResponse.next();\n }\n }\n\n const response = NextResponse.next"), !middlewareSource.includes("return NextResponse.next();\n }\n }\n\n const response = NextResponse.next"),
"middleware must not treat the mere presence of a bearer token as authenticated", "middleware must not treat the mere presence of a bearer token as authenticated",
); );
assert(
middlewareSource.includes("function hasSupabaseSessionCookie") &&
middlewareSource.includes("request.cookies.getAll()") &&
middlewareSource.includes("hasSupabaseSessionCookieValues"),
"middleware must detect non-empty Supabase session cookies locally via the shared helper before refreshing auth",
);
assert(
middlewareSource.includes("redirectToLogin(request, pathname)") &&
middlewareSource.indexOf("if (!hasSupabaseSessionCookie(request))") <
middlewareSource.indexOf("await refreshMiddlewareSession(request)"),
"middleware must redirect no-cookie page requests without calling Supabase auth",
);
const terminalGateSource = middlewareSource.slice(
middlewareSource.indexOf("async function handleTerminalGate"),
middlewareSource.indexOf("async function handleSupabaseAuthGate"),
);
assert(
terminalGateSource.includes("return response;") &&
!terminalGateSource.includes("return redirectToLogin(request, pathname);\n}"),
"terminal middleware must not navigate long-lived dashboards away when a session cookie exists but claims refresh is transiently unavailable",
);
assert(
middlewareSource.includes("unauthorizedSupabaseSessionResponse()"),
"middleware must reject no-cookie protected API requests without calling Supabase auth",
);
const authMeRouteSource = fs.readFileSync(
path.join(projectRoot, "app", "api", "auth", "me", "route.ts"),
"utf8",
);
assert(
authMeRouteSource.includes("/auth/v1/user") &&
authMeRouteSource.includes("getVerifiedBearerIdentity") &&
authMeRouteSource.includes("degraded_auth_profile: true"),
"/api/auth/me must verify bearer tokens directly and return a degraded authenticated profile when the backend auth profile is transiently unavailable",
);
assert(
authMeRouteSource.includes("const identity = await getBearerIdentityOnce();") &&
!authMeRouteSource.includes("return buildProxyExceptionResponse(error"),
"/api/auth/me must try bearer identity fallback before returning a proxy exception",
);
assert(
authMeRouteSource.includes("exception_snapshot") &&
authMeRouteSource.includes("unauthenticatedAuthProfileResponse") &&
!authMeRouteSource.includes("return buildProxyExceptionResponse(error"),
"/api/auth/me must serve a snapshot or anonymous auth profile before surfacing proxy exceptions to long-lived clients",
);
for (const route of [
"app/api/ops/analytics/funnel/route.ts",
"app/api/ops/config/route.ts",
"app/api/ops/health-check/route.ts",
"app/api/ops/leaderboard/weekly/route.ts",
"app/api/ops/memberships/route.ts",
"app/api/ops/memberships/growth/route.ts",
"app/api/ops/memberships/overview/route.ts",
"app/api/ops/online-users/route.ts",
"app/api/ops/payments/incidents/route.ts",
"app/api/ops/payments/incidents/[eventId]/resolve/route.ts",
"app/api/ops/subscriptions/extend/route.ts",
"app/api/ops/subscriptions/grant/route.ts",
"app/api/ops/telegram/members-audit/route.ts",
"app/api/ops/training/accuracy/route.ts",
"app/api/ops/truth-history/route.ts",
"app/api/ops/users/route.ts",
"app/api/ops/users/grant-points/route.ts",
"app/api/ops/view-logs/route.ts",
]) {
const routeSource = fs.readFileSync(path.join(projectRoot, route), "utf8");
assert(
routeSource.includes("requireOpsProxyAuth(req, auth)") &&
routeSource.indexOf("requireOpsProxyAuth(req, auth)") >
routeSource.indexOf("buildBackendRequestHeaders(req"),
`${route} must reject requests without Supabase identity before forwarding the backend entitlement token`,
);
}
assert(
middlewareSource.includes('pathname === "/api/payments/config"'),
"middleware must treat public payment config as public API so cached config requests do not refresh Supabase sessions",
);
const optionalRefreshFunction = middlewareSource.slice(
middlewareSource.indexOf("function shouldRefreshOptionalSupabaseSession"),
middlewareSource.indexOf("function hasSupabaseSessionCookie"),
);
assert(
!optionalRefreshFunction.includes('pathname.startsWith("/api/ops/")') &&
!optionalRefreshFunction.includes('pathname.startsWith("/api/payments/")'),
"optional Supabase middleware refresh must not pre-read sessions for API routes that already build backend auth headers",
);
const optionalRefreshIndex = middlewareSource.indexOf(
"function shouldRefreshOptionalSupabaseSession",
);
const systemStatusPublicIndex = middlewareSource.indexOf(
'pathname === "/api/system/status"',
);
assert(
systemStatusPublicIndex >= 0 &&
optionalRefreshIndex >= 0 &&
systemStatusPublicIndex < optionalRefreshIndex,
"middleware must treat public system status as public API instead of optional Supabase session refresh",
);
for (const route of paymentRoutes) { for (const route of paymentRoutes) {
const routeSource = fs.readFileSync(path.join(projectRoot, route), "utf8"); const routeSource = fs.readFileSync(path.join(projectRoot, route), "utf8");
assert( assert(
routeSource.includes("requireBackendAuthUser"), routeSource.includes("requireBackendPaymentAuth") &&
`${route} must reject payment mutations without a real Supabase user`, !routeSource.includes("requireBackendAuthUser(auth)"),
`${route} must allow bearer-backed payment mutations while still rejecting requests with no auth context`,
); );
} }
} }
@@ -58,6 +58,10 @@ export function runTests() {
const hookSource = fs.existsSync(hookPath) const hookSource = fs.existsSync(hookPath)
? fs.readFileSync(hookPath, "utf8") ? fs.readFileSync(hookPath, "utf8")
: ""; : "";
const paymentFlowSource = fs.readFileSync(
path.join(accountDir, "usePaymentFlow.ts"),
"utf8",
);
assert( assert(
(accountCenterSource.includes('import { usePaymentState } from "./usePaymentState";') || (accountCenterSource.includes('import { usePaymentState } from "./usePaymentState";') ||
hookSource.includes('import { usePaymentState } from "./usePaymentState";')) && hookSource.includes('import { usePaymentState } from "./usePaymentState";')) &&
@@ -92,6 +96,26 @@ export function runTests() {
path.join(projectRoot, "app", "api", "auth", "me", "route.ts"), path.join(projectRoot, "app", "api", "auth", "me", "route.ts"),
"utf8", "utf8",
); );
const paymentConfigRouteSource = fs.readFileSync(
path.join(projectRoot, "app", "api", "payments", "config", "route.ts"),
"utf8",
);
const paymentRuntimeRouteSource = fs.readFileSync(
path.join(projectRoot, "app", "api", "payments", "runtime", "route.ts"),
"utf8",
);
const backendAuthSource = fs.readFileSync(
path.join(projectRoot, "lib", "backend-auth.ts"),
"utf8",
);
const opsAdminSource = fs.readFileSync(
path.join(projectRoot, "lib", "ops-admin.ts"),
"utf8",
);
const supabaseServerSource = fs.readFileSync(
path.join(projectRoot, "lib", "supabase", "server.ts"),
"utf8",
);
const subscriptionsPageSource = fs.readFileSync( const subscriptionsPageSource = fs.readFileSync(
path.join( path.join(
projectRoot, projectRoot,
@@ -102,6 +126,26 @@ export function runTests() {
), ),
"utf8", "utf8",
); );
const membershipsPageSource = fs.readFileSync(
path.join(
projectRoot,
"components",
"ops",
"memberships",
"MembershipsPageClient.tsx",
),
"utf8",
);
const opsOverviewSource = fs.readFileSync(
path.join(
projectRoot,
"components",
"ops",
"overview",
"OverviewPageClient.tsx",
),
"utf8",
);
assert( assert(
accountCenterSource.includes( accountCenterSource.includes(
@@ -133,6 +177,22 @@ export function runTests() {
accountFeatureSource.includes("只有 USDC 可能无法完成授权或支付"), accountFeatureSource.includes("只有 USDC 可能无法完成授权或支付"),
"payment wallet tab must warn users that Polygon POL gas is required in addition to USDC", "payment wallet tab must warn users that Polygon POL gas is required in addition to USDC",
); );
assert(
!accountCenterSource.includes("md:w-96"),
"account payment management must not use a narrow fixed sidebar that creates an overlong column",
);
assert(
accountCenterSource.includes("items-start") &&
accountCenterSource.includes("xl:grid-cols-[minmax(0,0.9fr)_minmax(620px,1.1fr)]"),
"account secondary sections must align cards to the top and give payment management a wider responsive column",
);
assert(
accountCenterSource.includes("data-testid=\"payment-management-grid\"") &&
accountCenterSource.includes('hasTelegramPanel ? "" : "lg:grid-cols-[minmax(0,1fr)_minmax(320px,380px)]"') &&
accountCenterSource.includes("data-testid=\"payment-guard-grid\"") &&
accountCenterSource.includes('hasTelegramPanel ? "" : "sm:grid-cols-2"'),
"payment management must only split into internal columns when it is not already sharing the row with a Telegram panel",
);
assert( assert(
!appAnalyticsSource.includes('NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS === "true"') && !appAnalyticsSource.includes('NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS === "true"') &&
!analyticsRouteSource.includes('NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS === "true"'), !analyticsRouteSource.includes('NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS === "true"'),
@@ -143,6 +203,27 @@ export function runTests() {
accountCenterSource.includes('trackAppEvent("dashboard_active"'), accountCenterSource.includes('trackAppEvent("dashboard_active"'),
"account center must emit signup_completed and dashboard_active so the ops funnel has top-of-funnel data", "account center must emit signup_completed and dashboard_active so the ops funnel has top-of-funnel data",
); );
assert(
appAnalyticsSource.includes('| "landing_view"') &&
appAnalyticsSource.includes('| "enter_terminal"') &&
appAnalyticsSource.includes('| "login_start"') &&
appAnalyticsSource.includes('| "signup_success"') &&
appAnalyticsSource.includes('| "trial_created"') &&
appAnalyticsSource.includes('| "payment_start"') &&
appAnalyticsSource.includes('| "payment_success"'),
"app analytics must expose the standard growth funnel event names",
);
assert(
accountCenterSource.includes('trackAppEvent("signup_success"') &&
accountCenterSource.includes('trackAppEvent("trial_created"') &&
accountCenterSource.includes("subscription_is_trial"),
"account center must emit signup_success and trial_created for the standard funnel",
);
assert(
paymentFlowSource.includes('trackAppEvent("payment_start"') &&
paymentFlowSource.includes('trackAppEvent("payment_success"'),
"payment flow must emit payment_start and payment_success alongside legacy checkout events",
);
assert( assert(
accountCenterSource.includes("isSubscriptionUnknown") && accountCenterSource.includes("isSubscriptionUnknown") &&
accountCenterSource.includes("subscriptionStatusLabel") && accountCenterSource.includes("subscriptionStatusLabel") &&
@@ -157,6 +238,18 @@ export function runTests() {
hookSource.includes("retriedBackendJson"), hookSource.includes("retriedBackendJson"),
"account snapshot loader must retry with a refreshed Supabase token when local user exists but /api/auth/me reports unauthenticated", "account snapshot loader must retry with a refreshed Supabase token when local user exists but /api/auth/me reports unauthenticated",
); );
assert(
hookSource.includes("refreshEntitlementAfterPayment") &&
paymentFlowSource.includes("refreshEntitlementAfterPayment") &&
paymentFlowSource.includes("await refreshEntitlementAfterPayment();") &&
hookSource.includes("subscription_active === true"),
"successful payment flows must automatically poll /api/auth/me until the paid subscription is visible instead of requiring logout or manual refresh",
);
assert(
!hookSource.includes(".auth.getUser()") &&
hookSource.includes(".auth.getSession()"),
"account snapshot loader must use the local Supabase session instead of calling getUser before /api/auth/me validates the bearer",
);
assert( assert(
subscriptionsPageSource.includes("getSupabaseBrowserClient") && subscriptionsPageSource.includes("getSupabaseBrowserClient") &&
subscriptionsPageSource.includes("refreshSession") && subscriptionsPageSource.includes("refreshSession") &&
@@ -166,15 +259,124 @@ export function runTests() {
"ops manual subscription grant/extend must send the Supabase bearer token to avoid 401 when route cookies are stale", "ops manual subscription grant/extend must send the Supabase bearer token to avoid 401 when route cookies are stale",
); );
assert( assert(
grantRouteSource.includes("grantSubscriptionDirectly") && membershipsPageSource.includes("opsApi.membershipsOverview") &&
grantRouteSource.includes("res.status === 404") && !membershipsPageSource.includes('fetch("/api/ops/memberships/growth?days=90"') &&
grantRouteSource.includes('"status": "active"'), !membershipsPageSource.includes("Promise.all"),
"ops subscription grant route must fall back to direct Supabase grant when the VPS backend route is missing", "ops memberships page must load memberships and growth through one overview proxy request to avoid duplicate Supabase session/subscription reads",
); );
assert( assert(
authMeRouteSource.includes("if ((res.status === 401 || res.status === 403) && auth.authUserId)") && opsOverviewSource.includes("opsApi.membershipsOverview(200, 30)") &&
authMeRouteSource.includes("degraded_reason: `backend_${res.status}`") && !opsOverviewSource.includes("opsApi.memberships()") &&
!opsOverviewSource.includes("opsApi.membershipsGrowth(30)"),
"ops overview page must reuse membershipsOverview for table and growth data instead of issuing separate membership/growth proxy requests",
);
assert(
grantRouteSource.includes("grantSubscriptionDirectly") &&
grantRouteSource.includes("res.status === 404") &&
grantRouteSource.includes('"status": "active"') &&
grantRouteSource.includes("/rest/v1/profiles") &&
grantRouteSource.includes("select=id&email=eq.") &&
grantRouteSource.indexOf("/rest/v1/profiles") <
grantRouteSource.indexOf("/auth/v1/admin/users") &&
grantRouteSource.includes('Prefer: "return=minimal"'),
"ops subscription grant route must fall back to direct Supabase grant and resolve users via indexed profiles before Auth Admin",
);
assert(
authMeRouteSource.includes("isSubscriptionRequiredBackendResponse(res.status, raw)") &&
authMeRouteSource.includes("subscriptionRequiredAuthProfileResponse") &&
authMeRouteSource.includes("degradedAuthProfileResponse") &&
authMeRouteSource.includes("reason: `backend_${res.status}`") &&
authMeRouteSource.includes("subscription_active: null"), authMeRouteSource.includes("subscription_active: null"),
"auth profile proxy must preserve authenticated identity with unknown subscription on backend 401/403 instead of forcing a false paywall", "auth profile proxy must only convert explicit subscription-required 403 responses to inactive access while preserving unrelated backend 401/403 responses as unknown subscription",
);
assert(
(authMeRouteSource.match(/buildBackendRequestHeaders\(req\)/g) || []).length === 1 &&
authMeRouteSource.includes("let auth: Awaited<ReturnType<typeof buildBackendRequestHeaders>> | null = null"),
"auth profile proxy must build backend auth headers once and reuse them on timeout/error fallback",
);
assert(
authMeRouteSource.includes("hasSupabaseServerEnv()") &&
authMeRouteSource.includes("!auth.authUserId") &&
authMeRouteSource.includes('req.headers.get("authorization")') &&
authMeRouteSource.indexOf("authenticated: false") <
authMeRouteSource.indexOf("await fetch(`${API_BASE}/api/auth/me`"),
"auth profile proxy must return unauthenticated locally for no-session Supabase requests instead of forwarding the backend entitlement token",
);
assert(
authMeRouteSource.includes('reason: "prefer_snapshot_fast_path"') &&
authMeRouteSource.indexOf('reason: "prefer_snapshot_fast_path"') <
authMeRouteSource.indexOf("buildBackendRequestHeaders(req)"),
"auth profile proxy must return a valid entitlement snapshot before reading Supabase session cookies on terminal cold start",
);
assert(
backendAuthSource.includes("if (incomingAuth) {") &&
backendAuthSource.indexOf("if (incomingAuth) {") <
backendAuthSource.indexOf("const supabase = createSupabaseRouteClient"),
"backend proxy must forward caller bearer tokens before creating a Supabase route client to avoid duplicate getUser calls",
);
assert(
backendAuthSource.includes("headers.set(FORWARDED_SUPABASE_USER_ID_HEADER") &&
backendAuthSource.includes("headers.set(FORWARDED_SUPABASE_EMAIL_HEADER") &&
backendAuthSource.indexOf("headers.set(FORWARDED_SUPABASE_USER_ID_HEADER") >
backendAuthSource.indexOf("const sessionUser = session?.user"),
"backend proxy must forward Supabase session user id/email with the backend token so Python can skip duplicate /auth/v1/user validation",
);
assert(
backendAuthSource.includes("function hasSupabaseSessionCookie") &&
backendAuthSource.includes("String(cookie.value || \"\").trim()") &&
backendAuthSource.includes("if (!hasSupabaseSessionCookie(request))") &&
backendAuthSource.indexOf("if (!hasSupabaseSessionCookie(request))") <
backendAuthSource.indexOf("const supabase = createSupabaseRouteClient"),
"backend proxy must skip Supabase route client/session reads when no auth cookie is present",
);
assert(
!backendAuthSource.includes(".auth.getUser()") &&
backendAuthSource.includes(".auth.getSession()"),
"backend proxy must not call Supabase getUser before forwarding a bearer token that the backend validates again",
);
assert(
supabaseServerSource.includes("export function hasSupabaseSessionCookieValues") &&
supabaseServerSource.includes('name === "supabase-auth-token"') &&
supabaseServerSource.includes('name.startsWith("sb-")') &&
supabaseServerSource.indexOf("if (!hasSupabaseSessionCookieValues") <
supabaseServerSource.indexOf("const supabase = createSupabaseServerClient"),
"Supabase server helpers must expose one cookie detector and skip refresh getUser calls when no session cookie exists",
);
assert(
supabaseServerSource.includes(".auth.getClaims()") &&
!supabaseServerSource.includes(".auth.getUser()"),
"Supabase middleware refresh must validate JWTs with getClaims so asymmetric JWT projects avoid per-request /auth/v1/user reads",
);
assert(
opsAdminSource.includes("hasSupabaseSessionCookieValues") &&
opsAdminSource.indexOf("if (!hasSupabaseSessionCookieValues") <
opsAdminSource.indexOf("const supabase = createSupabaseServerClient"),
"ops admin page gate must redirect before creating a Supabase client/getUser call when no session cookie exists",
);
assert(
opsAdminSource.includes(".auth.getClaims()") &&
!opsAdminSource.includes(".auth.getUser()") &&
opsAdminSource.includes("claims?.email"),
"ops admin page gate must use verified JWT claims instead of a per-page getUser auth lookup",
);
assert(
paymentConfigRouteSource.includes("includeSupabaseIdentity: false"),
"payment config proxy must not read Supabase session cookies for public cached config",
);
assert(
paymentRuntimeRouteSource.includes("includeSupabaseIdentity: false"),
"payment runtime proxy must not read Supabase session cookies because backend entitlement token already protects the runtime status payload",
);
assert(
(paymentFlowSource.match(/await buildAuthedHeaders\(true, true\);/g) || [])
.length >= 3,
"manual payment mutations must require a valid Supabase bearer token instead of forwarding unauthenticated requests that surface raw backend JSON",
);
assert(
paymentFlowSource.includes("verifyPaymentAuthReady") &&
paymentFlowSource.indexOf("await verifyPaymentAuthReady()") <
paymentFlowSource.indexOf("resolvePaymentProvider(") &&
paymentFlowSource.includes("auth_confirmed_at"),
"wallet checkout must confirm a fresh Supabase bearer before opening wallet prompts or creating payment intents",
); );
} }
+34 -9
View File
@@ -10,9 +10,9 @@ export function createAccountCopy(isEn: boolean): Record<string, string> {
guestUser: isEn ? "Signed-out account" : "未登录账户", 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 ? "This Month Invites" : "本月有效邀请",
weeklyRank: isEn ? "Weekly Rank" : "周排行 (竞技)", weeklyRank: isEn ? "Monthly Cap" : "月度上限",
weeklyRewards: isEn ? "Weekly Rewards" : "周榜奖励", weeklyRewards: isEn ? "Referral Rewards" : "邀请奖励",
membershipDetails: isEn ? "Membership Details" : "会员权限详情", membershipDetails: isEn ? "Membership Details" : "会员权限详情",
identityStatus: isEn ? "Identity Status" : "身份状态", identityStatus: isEn ? "Identity Status" : "身份状态",
authMode: isEn ? "Auth Mode" : "鉴权模式", authMode: isEn ? "Auth Mode" : "鉴权模式",
@@ -57,6 +57,31 @@ export function createAccountCopy(isEn: boolean): Record<string, string> {
: "城市实测温度群", : "城市实测温度群",
copyCommand: isEn ? "Copy fallback command" : "复制兜底命令", copyCommand: isEn ? "Copy fallback command" : "复制兜底命令",
paymentMgmt: isEn ? "Payment Management" : "支付管理", paymentMgmt: isEn ? "Payment Management" : "支付管理",
proPlan: isEn ? "Pro Plan" : "Pro 套餐",
monthlyPlan: isEn ? "Monthly" : "月付",
quarterlyPlan: isEn ? "Quarterly" : "季度",
trialBadge: isEn ? "3-day trial" : "3天试用",
trialPaidGroupLocked: isEn
? "Trial users can use the core product experience, but the paid Telegram group is available to full Pro subscriptions only."
: "3天试用用户可体验核心产品,但无法进入付费 Telegram 群;付费群仅对正式 Pro 开放。",
referralTitle: isEn ? "Referral Code" : "邀请码",
referralMyCode: isEn ? "My invite code" : "我的邀请码",
referralApplyLabel: isEn ? "Use invite code" : "使用邀请码",
referralApplyPlaceholder: isEn ? "Enter invite code" : "输入邀请码",
referralApplyButton: isEn ? "Apply" : "应用",
referralApplied: isEn
? "Referral code applied. Your first monthly payment is now discounted."
: "邀请码已应用,首月 Pro 将按邀请价结算。",
referralApplyFailed: isEn ? "Failed to apply referral code" : "邀请码应用失败",
referralDiscountHint: isEn
? "Invite discount: first monthly Pro is 20 USDC."
: "邀请首月价:Pro 月付 20 USDC。",
referralRewardHint: isEn
? "When an invited user pays for Pro, you receive +3500 points."
: "被邀请人成功付费后,邀请人获得 +3500 积分。",
referralInviteLimit: isEn
? "Monthly referral reward cap: 10 paid invites, up to +35000 points."
: "每月最多 10 个有效付费邀请奖励,最高 +35000 积分。",
paymentToken: isEn ? "Payment Token" : "支付币种", paymentToken: isEn ? "Payment Token" : "支付币种",
paymentAccount: isEn ? "Subscription Account" : "订阅归属账号", paymentAccount: isEn ? "Subscription Account" : "订阅归属账号",
paymentWallet: isEn ? "Paying Wallet" : "付款钱包", paymentWallet: isEn ? "Paying Wallet" : "付款钱包",
@@ -218,8 +243,8 @@ export function createAccountCopy(isEn: boolean): Record<string, string> {
? "Current receiver contract: {address}" ? "Current receiver contract: {address}"
: "当前收款合约: {address}", : "当前收款合约: {address}",
manualOrderCreated: isEn manualOrderCreated: isEn
? "Manual transfer order created. Please send {amount} USDC to the receiver on Polygon network. Then submit your tx hash below." ? "Manual transfer order created. Send {amount} {symbol} on {chain} to {receiver}, then submit your tx hash below."
: "手动转账订单已创建:请在 Polygon 网络转 {amount} USDC 到收款地址。完成后在下方提交 tx hash。", : "手动转账订单已创建:请在 {chain} 转 {amount} {symbol} 到 {receiver}完成后在下方提交 tx hash。",
manualOrderRequired: isEn manualOrderRequired: isEn
? "Please create a manual transfer order first." ? "Please create a manual transfer order first."
: "请先创建手动转账订单。", : "请先创建手动转账订单。",
@@ -246,8 +271,8 @@ export function createAccountCopy(isEn: boolean): Record<string, string> {
verifyUnknown: isEn ? "Unknown error" : "未知错误", verifyUnknown: isEn ? "Unknown error" : "未知错误",
// ── Telegram bind messages ──────────────────────────────────────── // ── Telegram bind messages ────────────────────────────────────────
telegramVerifySuccess: isEn telegramVerifySuccess: isEn
? "Telegram group membership verified. Current membership price: {amount} U." ? "Telegram group membership verified. Checkout follows the selected Pro plan."
: "Telegram 群成员验证成功,当前会员价 {amount}U。", : "Telegram 群成员验证成功,结算金额以当前选择的 Pro 套餐为准。",
telegramBindClickHint: isEn telegramBindClickHint: isEn
? "Open the Telegram Bot, click Start, and confirm binding. Then refresh this page to request group entry." ? "Open the Telegram Bot, click Start, and confirm binding. Then refresh this page to request group entry."
: "已打开 Telegram Bot,请在 Bot 内点击 Start 并确认绑定;完成后刷新本页再申请入群。", : "已打开 Telegram Bot,请在 Bot 内点击 Start 并确认绑定;完成后刷新本页再申请入群。",
@@ -309,8 +334,8 @@ export function createAccountCopy(isEn: boolean): Record<string, string> {
: "加载支付配置失败: {raw}", : "加载支付配置失败: {raw}",
// ── Points / Footer ──────────────────────────────────────────────── // ── Points / Footer ────────────────────────────────────────────────
pointsRule: isEn pointsRule: isEn
? "Points rule: active messages in group (anti-spam) + daily first message bonus. Weekly leaderboard settles every Monday midnight. All active members receive participation awards." ? "Points rule: points are earned through successful paid referrals only. 500 points = 1 USDC. Monthly orders can use up to 3 USDC off; quarterly orders can use up to 8 USDC off."
: "积分规则:群内有效发言(自动防刷检测)+ 每日首条发言额外奖励。每周一零点结算周榜,所有活跃用户均享参与奖。", : "积分规则:积分仅通过有效付费邀请获得。500 积分 = 1 USDC。月付订单最多抵扣 3 USDC,季度订单最多抵扣 8 USDC。",
pointsDiscount: isEn pointsDiscount: isEn
? "Available: {points} points discounting ${amount}. Applied automatically on renewal." ? "Available: {points} points discounting ${amount}. Applied automatically on renewal."
: "当前可用 {points} 积分抵扣 ${amount},续费时会自动生效。", : "当前可用 {points} 积分抵扣 ${amount},续费时会自动生效。",
@@ -7,6 +7,12 @@ export function chainIdToDisplayName(chainId: number | undefined | null): string
return "Polygon"; return "Polygon";
} }
export function chainIdToExplorerBase(chainId: number | undefined | null): string {
if (chainId === 1) return "https://etherscan.io";
if (chainId === 137) return "https://polygonscan.com";
return "";
}
export function formatTime(value: string | undefined | null, locale: string) { export function formatTime(value: string | undefined | null, locale: string) {
if (!value) return "--"; if (!value) return "--";
try { try {
+41
View File
@@ -10,12 +10,31 @@ export type AuthMeResponse = {
subscription_required?: boolean; subscription_required?: boolean;
subscription_active?: boolean | null; subscription_active?: boolean | null;
subscription_plan_code?: string | null; subscription_plan_code?: string | null;
subscription_source?: string | null;
subscription_is_trial?: boolean | null;
subscription_starts_at?: string | null; subscription_starts_at?: string | null;
subscription_expires_at?: string | null; subscription_expires_at?: string | null;
subscription_total_expires_at?: string | null; subscription_total_expires_at?: string | null;
subscription_queued_days?: number | null; subscription_queued_days?: number | null;
subscription_queued_count?: number | null; subscription_queued_count?: number | null;
telegram_pricing?: TelegramPricing | null; telegram_pricing?: TelegramPricing | null;
referral?: ReferralSummary | null;
};
export type ReferralSummary = {
code?: string;
discount_usdc?: string;
discounted_monthly_amount_usdc?: string;
reward_days?: number;
reward_points?: number;
monthly_reward_limit?: number;
monthly_reward_days_limit?: number;
monthly_reward_points_limit?: number;
monthly_reward_count?: number;
monthly_reward_days?: number;
monthly_reward_points?: number;
applied_code?: string;
attribution_status?: string;
}; };
export type TelegramPricing = { export type TelegramPricing = {
@@ -40,7 +59,24 @@ export type PaymentTokenOption = {
name: string; name: string;
address: string; address: string;
decimals: number; decimals: number;
chain_id?: number;
chain_code?: string;
chain_name?: string;
receiver_contract?: string; receiver_contract?: string;
direct_receiver_address?: string;
explorer_tx_url?: string;
supports_contract_checkout?: boolean;
supports_direct_transfer?: boolean;
is_default?: boolean;
};
export type PaymentChainOption = {
chain_id: number;
code?: string;
name?: string;
native_currency_symbol?: string;
block_explorer_url?: string;
explorer_tx_url?: string;
is_default?: boolean; is_default?: boolean;
}; };
@@ -48,15 +84,18 @@ export type PointsRedemptionConfig = {
enabled?: boolean; enabled?: boolean;
points_per_usdc?: number; points_per_usdc?: number;
max_discount_usdc?: number; max_discount_usdc?: number;
max_discount_usdc_by_plan?: Record<string, number>;
}; };
export type PaymentConfig = { export type PaymentConfig = {
enabled?: boolean; enabled?: boolean;
configured?: boolean; configured?: boolean;
chain_id?: number; chain_id?: number;
default_chain_id?: number;
token_address?: string; token_address?: string;
token_decimals?: number; token_decimals?: number;
default_token_address?: string; default_token_address?: string;
chains?: PaymentChainOption[];
tokens?: PaymentTokenOption[]; tokens?: PaymentTokenOption[];
receiver_contract?: string; receiver_contract?: string;
confirmations?: number; confirmations?: number;
@@ -93,6 +132,7 @@ export type CreatedIntent = {
direct_payment?: { direct_payment?: {
chain_id: number; chain_id: number;
chain?: string; chain?: string;
chain_name?: string;
token_symbol?: string; token_symbol?: string;
token_address: string; token_address: string;
token_decimals?: number; token_decimals?: number;
@@ -101,6 +141,7 @@ export type CreatedIntent = {
amount_usdc: string; amount_usdc: string;
intent_id: string; intent_id: string;
expires_at: string; expires_at: string;
explorer_tx_url?: string;
}; };
}; };
@@ -146,23 +146,25 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
[supabaseReady, getValidAccessToken], [supabaseReady, getValidAccessToken],
); );
// ── loadSnapshot ────────────────────────────────────────── // ── Auth snapshot refresh ────────────────────────────────
const loadSnapshot = useCallback(async () => { const fetchAuthSnapshot = useCallback(
setErrorText(""); async (retry: boolean): Promise<AuthMeResponse> => {
const attempt = async (retry: boolean): Promise<void> => {
const userPromise = supabaseReady const userPromise = supabaseReady
? getSupabaseBrowserClient().auth.getUser() ? getSupabaseBrowserClient()
: Promise.resolve({ data: { user: null as User | null } }); .auth.getSession()
.then(({ data: { session } }) => session?.user ?? null)
.catch(() => null as User | null)
: Promise.resolve(null as User | null);
const authHeadersPromise = buildAuthedHeaders(false); const authHeadersPromise = buildAuthedHeaders(false);
const backendPromise = authHeadersPromise.then((headers) => const backendPromise = authHeadersPromise.then((headers) =>
fetch("/api/auth/me", { cache: "no-store", headers }), fetch("/api/auth/me", { cache: "no-store", headers }),
); );
const [userResult, backendResult] = await Promise.all([userPromise, backendPromise]); const [localUser, backendResult] = await Promise.all([userPromise, backendPromise]);
setUser(userResult.data?.user ?? null); setUser(localUser);
if (!backendResult.ok) { if (!backendResult.ok) {
if (retry && backendResult.status === 401) { if (retry && backendResult.status === 401) {
await new Promise((r) => setTimeout(r, 1200)); await new Promise((r) => setTimeout(r, 1200));
return attempt(false); return fetchAuthSnapshot(false);
} }
const raw = (await backendResult.text()).slice(0, 260); const raw = (await backendResult.text()).slice(0, 260);
throw new Error(copy.httpError.replace("{status}", String(backendResult.status)).replace("{raw}", raw)); throw new Error(copy.httpError.replace("{status}", String(backendResult.status)).replace("{raw}", raw));
@@ -171,7 +173,7 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
if ( if (
retry && retry &&
supabaseReady && supabaseReady &&
userResult.data?.user && localUser &&
backendJson.authenticated === false backendJson.authenticated === false
) { ) {
try { try {
@@ -199,10 +201,41 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
} }
setBackend(backendJson); setBackend(backendJson);
setUpdatedAt(new Date().toISOString()); setUpdatedAt(new Date().toISOString());
}; return backendJson;
try { await attempt(true); } },
[
buildAuthedHeaders,
copy.httpError,
setBackend,
setUpdatedAt,
setUser,
supabaseReady,
],
);
// ── loadSnapshot ──────────────────────────────────────────
const loadSnapshot = useCallback(async () => {
setErrorText("");
try { await fetchAuthSnapshot(true); }
catch (error) { setErrorText(String(error)); } catch (error) { setErrorText(String(error)); }
}, [buildAuthedHeaders, supabaseReady, copy.httpError]); }, [fetchAuthSnapshot, setErrorText]);
const refreshEntitlementAfterPayment = useCallback(async () => {
setErrorText("");
const maxAttempts = 5;
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
try {
const backendJson = await fetchAuthSnapshot(true);
if (backendJson.subscription_active === true) return;
} catch (error) {
if (attempt === maxAttempts - 1) {
setErrorText(String(error));
return;
}
}
await new Promise((resolve) => setTimeout(resolve, 1200));
}
}, [fetchAuthSnapshot, setErrorText]);
// ── Shared state for sub-hooks ─────────────────────────── // ── Shared state for sub-hooks ───────────────────────────
// These state variables are managed in the master hook and passed // These state variables are managed in the master hook and passed
@@ -211,6 +244,7 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
const [boundWallets, setBoundWallets] = useState<BoundWallet[]>([]); const [boundWallets, setBoundWallets] = useState<BoundWallet[]>([]);
const [walletAddress, setWalletAddress] = useState(""); const [walletAddress, setWalletAddress] = useState("");
const [selectedPlanCode, setSelectedPlanCode] = useState("pro_monthly"); const [selectedPlanCode, setSelectedPlanCode] = useState("pro_monthly");
const [selectedPaymentChainId, setSelectedPaymentChainId] = useState<number | null>(null);
const [selectedTokenAddress, setSelectedTokenAddress] = useState(""); const [selectedTokenAddress, setSelectedTokenAddress] = useState("");
const [selectedWallet, setSelectedWallet] = useState(""); const [selectedWallet, setSelectedWallet] = useState("");
const [providerMode, setProviderMode] = useState<ProviderMode>("auto"); const [providerMode, setProviderMode] = useState<ProviderMode>("auto");
@@ -218,7 +252,7 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
const [selectedInjectedProviderKey, setSelectedInjectedProviderKey] = useState(""); const [selectedInjectedProviderKey, setSelectedInjectedProviderKey] = useState("");
// ── Chain ID derived from payment config ──────────────── // ── Chain ID derived from payment config ────────────────
const chainId = paymentConfig?.chain_id ?? 137; const chainId = selectedPaymentChainId || paymentConfig?.default_chain_id || paymentConfig?.chain_id || 137;
// ── loadPaymentSnapshot ────────────────────────────────── // ── loadPaymentSnapshot ──────────────────────────────────
// Defined in master because it sets state across multiple sub-hook domains. // Defined in master because it sets state across multiple sub-hook domains.
@@ -243,8 +277,35 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
const tokenOptions = Array.isArray(configJson.tokens) const tokenOptions = Array.isArray(configJson.tokens)
? configJson.tokens.filter((row) => typeof row?.address === "string" && String(row.address).startsWith("0x")) ? configJson.tokens.filter((row) => typeof row?.address === "string" && String(row.address).startsWith("0x"))
: []; : [];
const chainOptions = Array.isArray(configJson.chains)
? configJson.chains.filter((row) => Number(row?.chain_id) > 0)
: [];
const defaultChainId = Number(
configJson.default_chain_id ||
chainOptions.find((row) => row.is_default)?.chain_id ||
configJson.chain_id ||
tokenOptions.find((row) => row.is_default)?.chain_id ||
137,
);
const supportedChainIds = new Set(
(chainOptions.length ? chainOptions : [{ chain_id: defaultChainId }])
.map((row) => Number(row.chain_id))
.filter((value) => Number.isFinite(value) && value > 0),
);
setSelectedPaymentChainId((prev) =>
prev && supportedChainIds.has(prev) ? prev : defaultChainId,
);
const activeChainId =
selectedPaymentChainId && supportedChainIds.has(selectedPaymentChainId)
? selectedPaymentChainId
: defaultChainId;
const tokenOptionsForChain = tokenOptions.filter(
(row) => Number(row.chain_id || activeChainId) === activeChainId,
);
const defaultTokenAddress = String( const defaultTokenAddress = String(
configJson.default_token_address || configJson.default_token_address ||
tokenOptionsForChain.find((row) => row.is_default)?.address ||
tokenOptionsForChain[0]?.address ||
tokenOptions.find((row) => row.is_default)?.address || tokenOptions.find((row) => row.is_default)?.address ||
tokenOptions[0]?.address || tokenOptions[0]?.address ||
configJson.token_address || "", configJson.token_address || "",
@@ -283,15 +344,13 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
backend?.authenticated, backend?.authenticated,
buildAuthedHeaders, buildAuthedHeaders,
selectedPlanCode, selectedPlanCode,
selectedPaymentChainId,
selectedWallet, selectedWallet,
walletAddress, walletAddress,
]); ]);
// ── Selected plan (derived, shared across sub-hooks) ─── // ── Selected plan (derived, shared across sub-hooks) ───
const monthlyPlanList = (paymentConfig?.plans || []).filter( const effectivePlanList = paymentConfig?.plans || [];
(p) => String(p.plan_code || "").trim().toLowerCase() === "pro_monthly",
);
const effectivePlanList = monthlyPlanList.length ? monthlyPlanList : (paymentConfig?.plans || []);
const selectedPlan = effectivePlanList.find((p) => p.plan_code === selectedPlanCode) || effectivePlanList[0]; const selectedPlan = effectivePlanList.find((p) => p.plan_code === selectedPlanCode) || effectivePlanList[0];
// ── useWalletBind ────────────────────────────────────── // ── useWalletBind ──────────────────────────────────────
@@ -354,6 +413,7 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
getValidAccessToken, getValidAccessToken,
buildAuthedHeaders, buildAuthedHeaders,
loadSnapshot, loadSnapshot,
refreshEntitlementAfterPayment,
loadPaymentSnapshot: loadPaymentSnapshotImpl, loadPaymentSnapshot: loadPaymentSnapshotImpl,
user, user,
}); });
@@ -371,6 +431,8 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
setPaymentConfig, setPaymentConfig,
selectedPlanCode, selectedPlanCode,
setSelectedPlanCode, setSelectedPlanCode,
selectedPaymentChainId: chainId,
setSelectedPaymentChainId,
selectedTokenAddress, selectedTokenAddress,
setSelectedTokenAddress, setSelectedTokenAddress,
boundWallets, boundWallets,
@@ -407,6 +469,7 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
getValidAccessToken, getValidAccessToken,
buildAuthedHeaders, buildAuthedHeaders,
loadSnapshot, loadSnapshot,
refreshEntitlementAfterPayment,
loadPaymentSnapshot: loadPaymentSnapshotImpl, loadPaymentSnapshot: loadPaymentSnapshotImpl,
waitForReceipt: walletBind.waitForReceipt, waitForReceipt: walletBind.waitForReceipt,
ensureTargetChain: walletBind.ensureTargetChain, ensureTargetChain: walletBind.ensureTargetChain,
@@ -451,6 +514,7 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
boundWallets, boundWallets,
walletAddress, walletAddress,
selectedPlanCode, selectedPlanCode,
selectedPaymentChainId: chainId,
selectedTokenAddress, selectedTokenAddress,
selectedWallet, selectedWallet,
providerMode, providerMode,
@@ -460,6 +524,8 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
// Setters for shared state // Setters for shared state
setSelectedTokenAddress, setSelectedTokenAddress,
setSelectedPaymentChainId,
setSelectedPlanCode,
setSelectedWallet, setSelectedWallet,
setSelectedInjectedProviderKey, setSelectedInjectedProviderKey,
setProviderMode, setProviderMode,
@@ -476,6 +542,8 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
selectedPaymentToken: paymentFlow.selectedPaymentToken, selectedPaymentToken: paymentFlow.selectedPaymentToken,
selectedTokenLabel: paymentFlow.selectedTokenLabel, selectedTokenLabel: paymentFlow.selectedTokenLabel,
availableTokenList: paymentFlow.availableTokenList, availableTokenList: paymentFlow.availableTokenList,
availableChainList: paymentFlow.availableChainList,
selectedPaymentChain: paymentFlow.selectedPaymentChain,
effectivePlanList, effectivePlanList,
resolvedSelectedTokenAddress: paymentFlow.resolvedSelectedTokenAddress, resolvedSelectedTokenAddress: paymentFlow.resolvedSelectedTokenAddress,
paymentReceiverAddress: paymentFlow.paymentReceiverAddress, paymentReceiverAddress: paymentFlow.paymentReceiverAddress,
+44 -12
View File
@@ -53,6 +53,7 @@ export interface UseBillingParams {
getValidAccessToken: () => Promise<string>; getValidAccessToken: () => Promise<string>;
buildAuthedHeaders: (withJson?: boolean, requireAuth?: boolean) => Promise<Record<string, string>>; buildAuthedHeaders: (withJson?: boolean, requireAuth?: boolean) => Promise<Record<string, string>>;
loadSnapshot: () => Promise<void>; loadSnapshot: () => Promise<void>;
refreshEntitlementAfterPayment: () => Promise<void>;
loadPaymentSnapshot: () => Promise<void>; loadPaymentSnapshot: () => Promise<void>;
// User for metadata points // User for metadata points
@@ -86,6 +87,7 @@ export function useBilling(params: UseBillingParams) {
getValidAccessToken, getValidAccessToken,
buildAuthedHeaders, buildAuthedHeaders,
loadSnapshot, loadSnapshot,
refreshEntitlementAfterPayment,
loadPaymentSnapshot, loadPaymentSnapshot,
user, user,
} = params; } = params;
@@ -116,10 +118,31 @@ export function useBilling(params: UseBillingParams) {
// ── Billing ────────────────────────────────────────────── // ── Billing ──────────────────────────────────────────────
const billing = useMemo(() => { const billing = useMemo(() => {
const parsedPlanAmount = Number( const listAmountRaw = Number(selectedPlan?.amount_usdc ?? 29.9);
backend?.telegram_pricing?.amount_usdc ?? selectedPlan?.amount_usdc ?? 10, const listAmount =
Number.isFinite(listAmountRaw) && listAmountRaw > 0 ? listAmountRaw : 29.9;
const selectedPlanCode = String(selectedPlan?.plan_code || "").toLowerCase();
const referral = backend?.referral;
const referralPending = Boolean(
referral?.applied_code ||
String(referral?.attribution_status || "").toLowerCase() === "pending",
); );
const planAmount = Number.isFinite(parsedPlanAmount) && parsedPlanAmount > 0 ? parsedPlanAmount : 10; const referralDiscountRaw = Number(referral?.discount_usdc ?? 0);
const referralDiscount = Number.isFinite(referralDiscountRaw)
? Math.max(0, referralDiscountRaw)
: 0;
const discountedMonthlyRaw = Number(
referral?.discounted_monthly_amount_usdc ?? 0,
);
const referralApplies =
selectedPlanCode === "pro_monthly" &&
referralPending &&
backend?.subscription_active !== true;
const planAmount = referralApplies
? Number.isFinite(discountedMonthlyRaw) && discountedMonthlyRaw > 0
? discountedMonthlyRaw
: Math.max(0, listAmount - referralDiscount)
: listAmount;
const pointsCfg = paymentConfig?.points_redemption || {}; const pointsCfg = paymentConfig?.points_redemption || {};
const pointsEnabled = pointsCfg.enabled !== false; const pointsEnabled = pointsCfg.enabled !== false;
@@ -127,7 +150,10 @@ export function useBilling(params: UseBillingParams) {
const pointsPerUsdc = const pointsPerUsdc =
Number.isFinite(pointsPerUsdcRaw) && pointsPerUsdcRaw > 0 ? Math.floor(pointsPerUsdcRaw) : 500; Number.isFinite(pointsPerUsdcRaw) && pointsPerUsdcRaw > 0 ? Math.floor(pointsPerUsdcRaw) : 500;
const maxDiscountRaw = Number(pointsCfg.max_discount_usdc ?? 3); const maxDiscountByPlan = pointsCfg.max_discount_usdc_by_plan || {};
const planMaxDiscountRaw =
maxDiscountByPlan[selectedPlanCode] ?? pointsCfg.max_discount_usdc ?? 3;
const maxDiscountRaw = Number(planMaxDiscountRaw);
const maxDiscountUsdc = Math.max( const maxDiscountUsdc = Math.max(
0, 0,
Math.min( Math.min(
@@ -137,14 +163,18 @@ export function useBilling(params: UseBillingParams) {
); );
const maxRedeemablePoints = pointsPerUsdc * maxDiscountUsdc; const maxRedeemablePoints = pointsPerUsdc * maxDiscountUsdc;
const actualRedeem = pointsEnabled ? Math.min(totalPoints, maxRedeemablePoints) : 0; const pointsCanApply = pointsEnabled && !referralApplies;
const actualRedeem = pointsCanApply ? Math.min(totalPoints, maxRedeemablePoints) : 0;
const discountUnits = Math.floor(actualRedeem / pointsPerUsdc); const discountUnits = Math.floor(actualRedeem / pointsPerUsdc);
const pointsUsed = discountUnits * pointsPerUsdc; const pointsUsed = discountUnits * pointsPerUsdc;
const canRedeem = pointsEnabled && maxDiscountUsdc > 0 && totalPoints >= pointsPerUsdc; const canRedeem = pointsCanApply && maxDiscountUsdc > 0 && totalPoints >= pointsPerUsdc;
const applyDiscount = usePoints && canRedeem && pointsUsed > 0; const applyDiscount = usePoints && canRedeem && pointsUsed > 0;
return { return {
planAmount, planAmount,
listAmount,
referralApplied: referralApplies,
referralDiscountAmount: referralApplies ? listAmount - planAmount : 0,
pointsEnabled, pointsEnabled,
pointsPerUsdc, pointsPerUsdc,
maxDiscountUsdc, maxDiscountUsdc,
@@ -155,7 +185,9 @@ export function useBilling(params: UseBillingParams) {
}; };
}, [ }, [
paymentConfig?.points_redemption, paymentConfig?.points_redemption,
backend?.telegram_pricing?.amount_usdc, backend?.referral,
backend?.subscription_active,
selectedPlan?.plan_code,
selectedPlan?.amount_usdc, selectedPlan?.amount_usdc,
totalPoints, totalPoints,
usePoints, usePoints,
@@ -177,7 +209,7 @@ export function useBilling(params: UseBillingParams) {
if (json.ok) { if (json.ok) {
setPaymentInfo(copy.walletRecoveryDone); setPaymentInfo(copy.walletRecoveryDone);
setPaymentError(""); setPaymentError("");
await loadSnapshot(); await refreshEntitlementAfterPayment();
await loadPaymentSnapshot(); await loadPaymentSnapshot();
return true; return true;
} }
@@ -189,7 +221,7 @@ export function useBilling(params: UseBillingParams) {
} }
}, [ }, [
authIsAuthenticated, buildAuthedHeaders, copy.walletRecoveryDone, authIsAuthenticated, buildAuthedHeaders, copy.walletRecoveryDone,
loadPaymentSnapshot, loadSnapshot, reconcileBusy, loadPaymentSnapshot, refreshEntitlementAfterPayment, reconcileBusy,
]); ]);
// ── handleSubmit409 ──────────────────────────────────── // ── handleSubmit409 ────────────────────────────────────
@@ -200,7 +232,7 @@ export function useBilling(params: UseBillingParams) {
const ok = await reconcileLatestPayment(); const ok = await reconcileLatestPayment();
if (ok) return; if (ok) return;
setPaymentInfo(copy.orderAlreadyPaid); setPaymentInfo(copy.orderAlreadyPaid);
await loadSnapshot(); await refreshEntitlementAfterPayment();
await loadPaymentSnapshot(); await loadPaymentSnapshot();
return; return;
} }
@@ -220,7 +252,7 @@ export function useBilling(params: UseBillingParams) {
const txHash = intentJson.intent?.tx_hash || txHashNorm; const txHash = intentJson.intent?.tx_hash || txHashNorm;
setPaymentInfo(`支付已确认,交易: ${shortAddress(txHash)}`); setPaymentInfo(`支付已确认,交易: ${shortAddress(txHash)}`);
setPaymentError(""); setPaymentError("");
await loadSnapshot(); await refreshEntitlementAfterPayment();
await loadPaymentSnapshot(); await loadPaymentSnapshot();
return; return;
} }
@@ -231,7 +263,7 @@ export function useBilling(params: UseBillingParams) {
} }
throw new Error(copy.submitTxFailed.replace("{raw}", raw)); throw new Error(copy.submitTxFailed.replace("{raw}", raw));
}, },
[buildAuthedHeaders, copy, loadPaymentSnapshot, loadSnapshot, reconcileLatestPayment], [buildAuthedHeaders, copy, loadPaymentSnapshot, refreshEntitlementAfterPayment, reconcileLatestPayment],
); );
// ── openTelegramBotBindLink ────────────────────────────── // ── openTelegramBotBindLink ──────────────────────────────
+182 -40
View File
@@ -8,6 +8,7 @@ import type {
CreatedIntent, CreatedIntent,
EvmProvider, EvmProvider,
IntentStatusResponse, IntentStatusResponse,
PaymentChainOption,
PaymentConfig, PaymentConfig,
PaymentTokenOption, PaymentTokenOption,
ProviderMode, ProviderMode,
@@ -16,7 +17,7 @@ import type {
import { import {
WALLET_TRANSACTION_REQUEST_TIMEOUT_MS, WALLET_TRANSACTION_REQUEST_TIMEOUT_MS,
} from "./constants"; } from "./constants";
import { clearStoredPaymentRecovery, shortAddress } from "./formatters"; import { chainIdToDisplayName, clearStoredPaymentRecovery, shortAddress } from "./formatters";
import { import {
buildAllowanceCalldata, buildAllowanceCalldata,
buildApproveCalldata, buildApproveCalldata,
@@ -43,6 +44,8 @@ export interface UsePaymentFlowParams {
setPaymentConfig: React.Dispatch<React.SetStateAction<PaymentConfig | null>>; setPaymentConfig: React.Dispatch<React.SetStateAction<PaymentConfig | null>>;
selectedPlanCode: string; selectedPlanCode: string;
setSelectedPlanCode: React.Dispatch<React.SetStateAction<string>>; setSelectedPlanCode: React.Dispatch<React.SetStateAction<string>>;
selectedPaymentChainId: number;
setSelectedPaymentChainId: React.Dispatch<React.SetStateAction<number | null>>;
selectedTokenAddress: string; selectedTokenAddress: string;
setSelectedTokenAddress: React.Dispatch<React.SetStateAction<string>>; setSelectedTokenAddress: React.Dispatch<React.SetStateAction<string>>;
@@ -98,9 +101,10 @@ export interface UsePaymentFlowParams {
getValidAccessToken: () => Promise<string>; getValidAccessToken: () => Promise<string>;
buildAuthedHeaders: (withJson?: boolean, requireAuth?: boolean) => Promise<Record<string, string>>; buildAuthedHeaders: (withJson?: boolean, requireAuth?: boolean) => Promise<Record<string, string>>;
loadSnapshot: () => Promise<void>; loadSnapshot: () => Promise<void>;
refreshEntitlementAfterPayment: () => Promise<void>;
loadPaymentSnapshot: () => Promise<void>; loadPaymentSnapshot: () => Promise<void>;
waitForReceipt: (txHash: string, provider?: EvmProvider, timeoutMs?: number, pollMs?: number) => Promise<any>; waitForReceipt: (txHash: string, provider?: EvmProvider, timeoutMs?: number, pollMs?: number) => Promise<any>;
ensureTargetChain: (eth: EvmProvider, targetChainId: number) => Promise<void>; ensureTargetChain: (eth: EvmProvider, targetChainId: number, chain?: PaymentChainOption) => Promise<void>;
// Ref-wrapped cross-hook callbacks // Ref-wrapped cross-hook callbacks
connectAndBindWalletRef: React.MutableRefObject<((mode?: ProviderMode, options?: ConnectBindOptions) => Promise<boolean>) | null>; connectAndBindWalletRef: React.MutableRefObject<((mode?: ProviderMode, options?: ConnectBindOptions) => Promise<boolean>) | null>;
@@ -120,6 +124,8 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
setPaymentConfig, setPaymentConfig,
selectedPlanCode, selectedPlanCode,
setSelectedPlanCode, setSelectedPlanCode,
selectedPaymentChainId,
setSelectedPaymentChainId,
selectedTokenAddress, selectedTokenAddress,
setSelectedTokenAddress, setSelectedTokenAddress,
boundWallets, boundWallets,
@@ -155,6 +161,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
getValidAccessToken, getValidAccessToken,
buildAuthedHeaders, buildAuthedHeaders,
loadSnapshot, loadSnapshot,
refreshEntitlementAfterPayment,
loadPaymentSnapshot, loadPaymentSnapshot,
waitForReceipt, waitForReceipt,
ensureTargetChain, ensureTargetChain,
@@ -165,11 +172,81 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
// ── Derived payment values ────────────────────────────── // ── Derived payment values ──────────────────────────────
const planList = paymentConfig?.plans || []; const planList = paymentConfig?.plans || [];
const monthlyPlanList = planList.filter( const effectivePlanList = planList;
(plan) => String(plan.plan_code || "").trim().toLowerCase() === "pro_monthly",
);
const effectivePlanList = monthlyPlanList.length ? monthlyPlanList : planList;
const selectedPlan = effectivePlanList.find((plan) => plan.plan_code === selectedPlanCode) || effectivePlanList[0]; const selectedPlan = effectivePlanList.find((plan) => plan.plan_code === selectedPlanCode) || effectivePlanList[0];
const trackPaymentStart = useCallback((payload: Record<string, unknown>) => {
trackAppEvent("checkout_started", payload);
trackAppEvent("payment_start", payload);
}, []);
const trackPaymentSuccess = useCallback((payload: Record<string, unknown>) => {
trackAppEvent("checkout_succeeded", payload);
trackAppEvent("payment_success", payload);
}, []);
const verifyPaymentAuthReady = useCallback(async () => {
const accessToken = await getValidAccessToken();
const authHeaders: Record<string, string> = {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
};
const authRes = await fetch("/api/auth/me", {
cache: "no-store",
headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/json" },
});
if (!authRes.ok) {
const raw = (await authRes.text().catch(() => "")).slice(0, 240);
throw new Error(
isEn
? `Authentication check failed before payment (${authRes.status}). ${raw}`
: `支付前登录态校验失败 (${authRes.status})。${raw}`,
);
}
const profile = (await authRes.json()) as AuthMeResponse;
if (profile.authenticated !== true) {
throw new Error(copy.loginBeforePay);
}
return {
authHeaders,
auth_confirmed_at: new Date().toISOString(),
profile,
};
}, [copy.loginBeforePay, getValidAccessToken, isEn]);
const availableChainList: PaymentChainOption[] = useMemo(() => {
const configured = Array.isArray(paymentConfig?.chains) ? paymentConfig?.chains || [] : [];
const clean = configured
.map((row) => ({
...row,
chain_id: Number(row.chain_id),
name: String(row.name || chainIdToDisplayName(Number(row.chain_id))),
}))
.filter((row) => Number.isFinite(row.chain_id) && row.chain_id > 0);
if (clean.length) return clean;
const chainIds = new Set<number>();
const defaultChainId = Number(paymentConfig?.default_chain_id || paymentConfig?.chain_id || 137);
if (Number.isFinite(defaultChainId) && defaultChainId > 0) chainIds.add(defaultChainId);
(paymentConfig?.tokens || []).forEach((token) => {
const chainId = Number(token.chain_id || defaultChainId);
if (Number.isFinite(chainId) && chainId > 0) chainIds.add(chainId);
});
return Array.from(chainIds).sort((a, b) => a - b).map((chainId) => ({
chain_id: chainId,
name: chainIdToDisplayName(chainId),
is_default: chainId === defaultChainId,
}));
}, [paymentConfig]);
const selectedPaymentChain =
availableChainList.find((chain) => chain.chain_id === selectedPaymentChainId) ||
availableChainList.find((chain) => chain.is_default) ||
availableChainList[0];
const effectivePaymentChainId = Number(
selectedPaymentChain?.chain_id ||
selectedPaymentChainId ||
paymentConfig?.default_chain_id ||
paymentConfig?.chain_id ||
137,
);
const availableTokenList: PaymentTokenOption[] = useMemo(() => { const availableTokenList: PaymentTokenOption[] = useMemo(() => {
const configured = Array.isArray(paymentConfig?.tokens) ? paymentConfig?.tokens || [] : []; const configured = Array.isArray(paymentConfig?.tokens) ? paymentConfig?.tokens || [] : [];
@@ -181,22 +258,32 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
symbol: String(row.symbol || "USDC"), symbol: String(row.symbol || "USDC"),
name: String(row.name || row.symbol || "USDC"), name: String(row.name || row.symbol || "USDC"),
code: String(row.code || "usdc"), code: String(row.code || "usdc"),
chain_id: Number(row.chain_id || effectivePaymentChainId),
decimals: Number.isFinite(Number(row.decimals)) decimals: Number.isFinite(Number(row.decimals))
? Number(row.decimals) ? Number(row.decimals)
: Number(paymentConfig?.token_decimals ?? 6), : Number(paymentConfig?.token_decimals ?? 6),
})); }))
.filter((row) => Number(row.chain_id) === effectivePaymentChainId);
if (clean.length) return clean; if (clean.length) return clean;
const fallbackAddress = String(paymentConfig?.token_address || "").toLowerCase(); const fallbackAddress = String(paymentConfig?.token_address || "").toLowerCase();
if (!fallbackAddress.startsWith("0x")) return []; if (!fallbackAddress.startsWith("0x")) return [];
return [{ return [{
code: "usdc", symbol: "USDC", name: "USDC", address: fallbackAddress, code: "usdc", symbol: "USDC", name: "USDC", address: fallbackAddress,
chain_id: effectivePaymentChainId,
decimals: Number(paymentConfig?.token_decimals ?? 6), decimals: Number(paymentConfig?.token_decimals ?? 6),
receiver_contract: paymentConfig?.receiver_contract, is_default: true, receiver_contract: paymentConfig?.receiver_contract, is_default: true,
}]; }];
}, [paymentConfig]); }, [effectivePaymentChainId, paymentConfig]);
const resolvedSelectedTokenAddress = String( const resolvedSelectedTokenAddress = String(
selectedTokenAddress || paymentConfig?.default_token_address || (
selectedTokenAddress &&
availableTokenList.some((row) => row.address === String(selectedTokenAddress).toLowerCase())
? selectedTokenAddress
: ""
) ||
availableTokenList.find((row) => row.is_default)?.address ||
paymentConfig?.default_token_address ||
availableTokenList.find((row) => row.is_default)?.address || availableTokenList.find((row) => row.is_default)?.address ||
availableTokenList[0]?.address || paymentConfig?.token_address || "", availableTokenList[0]?.address || paymentConfig?.token_address || "",
).toLowerCase(); ).toLowerCase();
@@ -225,21 +312,50 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
if (!selectedPlanCode && configJson.plans?.length) { if (!selectedPlanCode && configJson.plans?.length) {
setSelectedPlanCode(configJson.plans[0].plan_code); setSelectedPlanCode(configJson.plans[0].plan_code);
} }
const chainOptions = Array.isArray(configJson.chains)
? configJson.chains.filter((row) => Number(row?.chain_id) > 0)
: [];
const defaultChainId = Number(
configJson.default_chain_id ||
chainOptions.find((row) => row.is_default)?.chain_id ||
configJson.chain_id ||
137,
);
const supportedChainIds = new Set(
(chainOptions.length ? chainOptions : [{ chain_id: defaultChainId }])
.map((row) => Number(row.chain_id))
.filter((value) => Number.isFinite(value) && value > 0),
);
setSelectedPaymentChainId((prev) =>
prev && supportedChainIds.has(prev) ? prev : defaultChainId,
);
const activeChainId =
selectedPaymentChainId && supportedChainIds.has(selectedPaymentChainId)
? selectedPaymentChainId
: defaultChainId;
const tokenOptions = Array.isArray(configJson.tokens) const tokenOptions = Array.isArray(configJson.tokens)
? configJson.tokens.filter((row) => typeof row?.address === "string" && String(row.address).startsWith("0x")) ? configJson.tokens.filter((row) => typeof row?.address === "string" && String(row.address).startsWith("0x"))
: []; : [];
const tokenOptionsForChain = tokenOptions.filter(
(row) => Number(row.chain_id || activeChainId) === activeChainId,
);
const defaultTokenAddress = String( const defaultTokenAddress = String(
configJson.default_token_address || configJson.default_token_address ||
tokenOptionsForChain.find((row) => row.is_default)?.address ||
tokenOptionsForChain[0]?.address ||
tokenOptions.find((row) => row.is_default)?.address || tokenOptions.find((row) => row.is_default)?.address ||
tokenOptions[0]?.address || configJson.token_address || "", tokenOptions[0]?.address || configJson.token_address || "",
).toLowerCase(); ).toLowerCase();
if (defaultTokenAddress) { if (defaultTokenAddress) {
setSelectedTokenAddress((prev: string) => prev || defaultTokenAddress); const tokenSet = new Set(tokenOptionsForChain.map((row) => String(row.address).toLowerCase()));
setSelectedTokenAddress((prev: string) =>
prev && tokenSet.has(String(prev).toLowerCase()) ? prev : defaultTokenAddress,
);
} }
} }
return configJson; return configJson;
}, },
[buildAuthedHeaders, selectedPlanCode], [buildAuthedHeaders, selectedPaymentChainId, selectedPlanCode],
); );
// ── pollIntentUntilConfirmed ──────────────────────────── // ── pollIntentUntilConfirmed ────────────────────────────
@@ -266,13 +382,13 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
if (status === "confirmed") { if (status === "confirmed") {
setPaymentError(""); setPaymentError("");
setPaymentInfo(copy.paymentConfirmed.replace("{txHash}", shortAddress(txHash))); setPaymentInfo(copy.paymentConfirmed.replace("{txHash}", shortAddress(txHash)));
trackAppEvent("checkout_succeeded", { trackPaymentSuccess({
entry: "account_center", entry: "account_center",
plan_code: selectedPlan?.plan_code || "pro_monthly", plan_code: selectedPlan?.plan_code || "pro_monthly",
intent_id: intentId, intent_id: intentId,
tx_hash: txHash || null, tx_hash: txHash || null,
}); });
await loadSnapshot(); await refreshEntitlementAfterPayment();
await loadPaymentSnapshot(); await loadPaymentSnapshot();
return; return;
} }
@@ -284,7 +400,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
} }
throw new Error(copy.paymentPendingTimeout); throw new Error(copy.paymentPendingTimeout);
}, },
[loadPaymentSnapshot, loadSnapshot, selectedPlan?.plan_code], [loadPaymentSnapshot, refreshEntitlementAfterPayment, selectedPlan?.plan_code, trackPaymentSuccess],
); );
// ── createIntentAndPay ────────────────────────────────── // ── createIntentAndPay ──────────────────────────────────
@@ -314,6 +430,8 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
setPaymentBusy(true); setPaymentBusy(true);
let approvedInThisRun = false; let approvedInThisRun = false;
try { try {
const authReady = await verifyPaymentAuthReady();
const authHeaders = authReady.authHeaders;
const providerSelection = await resolvePaymentProvider(providerMode, selectedInjectedProviderKey); const providerSelection = await resolvePaymentProvider(providerMode, selectedInjectedProviderKey);
const eth = providerSelection.provider; const eth = providerSelection.provider;
const activeAccounts = await requestWalletWithTimeout<string[]>( const activeAccounts = await requestWalletWithTimeout<string[]>(
@@ -331,21 +449,32 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
setSelectedWallet(payingWallet); setSelectedWallet(payingWallet);
setProviderMode(providerSelection.mode); setProviderMode(providerSelection.mode);
let accessToken: string;
try { accessToken = await getValidAccessToken(); }
catch (tokenErr) {
setPaymentError(normalizePaymentError(tokenErr).message);
setPaymentBusy(false);
return;
}
const authHeaders: Record<string, string> = {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
};
const latestConfig = await fetchLatestPaymentConfig(authHeaders, true); const latestConfig = await fetchLatestPaymentConfig(authHeaders, true);
if (!latestConfig?.enabled || !latestConfig?.configured) throw new Error(copy.payNotReady); if (!latestConfig?.enabled || !latestConfig?.configured) throw new Error(copy.payNotReady);
const expectedReceiver = String(latestConfig.receiver_contract || "").toLowerCase(); const targetChainId = Number(selectedPaymentChainId || latestConfig.default_chain_id || latestConfig.chain_id || 137);
const latestChains = Array.isArray(latestConfig.chains) ? latestConfig.chains : [];
const targetChain =
latestChains.find((chain) => Number(chain.chain_id) === targetChainId) ||
selectedPaymentChain;
const latestTokens = Array.isArray(latestConfig.tokens) ? latestConfig.tokens : [];
const selectedLatestToken =
latestTokens.find(
(token) =>
Number(token.chain_id || targetChainId) === targetChainId &&
String(token.address || "").toLowerCase() === resolvedSelectedTokenAddress,
) ||
latestTokens.find(
(token) => Number(token.chain_id || targetChainId) === targetChainId && token.is_default,
) ||
latestTokens.find((token) => Number(token.chain_id || targetChainId) === targetChainId);
if (selectedLatestToken?.supports_contract_checkout === false) {
throw new Error(
isEn
? `${selectedLatestToken.chain_name || chainIdToDisplayName(targetChainId)} ${selectedLatestToken.symbol || "USDC"} supports manual transfer only.`
: `${selectedLatestToken.chain_name || chainIdToDisplayName(targetChainId)} ${selectedLatestToken.symbol || "USDC"} 仅支持手动转账。`,
);
}
const expectedReceiver = String(selectedLatestToken?.receiver_contract || latestConfig.receiver_contract || "").toLowerCase();
assertExpectedPaymentReceiver(expectedReceiver, "payment receiver contract"); assertExpectedPaymentReceiver(expectedReceiver, "payment receiver contract");
if (paymentConfig?.receiver_contract && String(paymentConfig.receiver_contract).toLowerCase() !== expectedReceiver) { if (paymentConfig?.receiver_contract && String(paymentConfig.receiver_contract).toLowerCase() !== expectedReceiver) {
setPaymentInfo(copy.paymentConfigUpdated.replace("{address}", shortAddress(expectedReceiver))); setPaymentInfo(copy.paymentConfigUpdated.replace("{address}", shortAddress(expectedReceiver)));
@@ -353,8 +482,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
setPaymentInfo(copy.currentReceiver.replace("{address}", shortAddress(expectedReceiver))); setPaymentInfo(copy.currentReceiver.replace("{address}", shortAddress(expectedReceiver)));
} }
const targetChainId = Number(latestConfig.chain_id || 137); await ensureTargetChain(eth, targetChainId, targetChain);
await ensureTargetChain(eth, targetChainId);
const createRes = await fetch("/api/payments/intents", { const createRes = await fetch("/api/payments/intents", {
method: "POST", method: "POST",
@@ -363,13 +491,15 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
plan_code: selectedPlan?.plan_code || "pro_monthly", plan_code: selectedPlan?.plan_code || "pro_monthly",
payment_mode: "strict", payment_mode: "strict",
allowed_wallet: payingWallet, allowed_wallet: payingWallet,
token_address: resolvedSelectedTokenAddress || undefined, chain_id: targetChainId,
token_address: String(selectedLatestToken?.address || resolvedSelectedTokenAddress || "").toLowerCase() || undefined,
use_points: billing.canRedeem && usePoints, use_points: billing.canRedeem && usePoints,
points_to_consume: billing.canRedeem && usePoints ? billing.pointsUsed : 0, points_to_consume: billing.canRedeem && usePoints ? billing.pointsUsed : 0,
metadata: { metadata: {
source: "account_center", source: "account_center",
frontend_host: currentPaymentHost || null, frontend_host: currentPaymentHost || null,
account_email: backend?.email || null, account_email: backend?.email || null,
auth_confirmed_at: authReady.auth_confirmed_at,
}, },
}), }),
}); });
@@ -382,7 +512,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
const intentId = String(created.intent?.intent_id || ""); const intentId = String(created.intent?.intent_id || "");
const txPayload = created.tx_payload; const txPayload = created.tx_payload;
if (!intentId || !txPayload?.to || !txPayload?.data) throw new Error(copy.intentPayloadInvalid); if (!intentId || !txPayload?.to || !txPayload?.data) throw new Error(copy.intentPayloadInvalid);
trackAppEvent("checkout_started", { trackPaymentStart({
entry: "account_center", entry: "account_center",
plan_code: selectedPlan?.plan_code || "pro_monthly", plan_code: selectedPlan?.plan_code || "pro_monthly",
intent_id: intentId, intent_id: intentId,
@@ -446,6 +576,8 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
const txHashNorm = String(txHash || "").toLowerCase(); const txHashNorm = String(txHash || "").toLowerCase();
setLastTxHash(txHashNorm); setLastTxHash(txHashNorm);
setLastPaymentStartedAt(Date.now()); setLastPaymentStartedAt(Date.now());
setPaymentInfo(`交易已提交: ${shortAddress(txHashNorm)},等待链上回执...`);
await waitForReceipt(txHashNorm, eth);
const submitRes = await fetch(`/api/payments/intents/${intentId}/submit`, { const submitRes = await fetch(`/api/payments/intents/${intentId}/submit`, {
method: "POST", headers: authHeaders, body: JSON.stringify({ tx_hash: txHashNorm, from_address: payingWallet }), method: "POST", headers: authHeaders, body: JSON.stringify({ tx_hash: txHashNorm, from_address: payingWallet }),
@@ -478,13 +610,13 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
} }
setPaymentInfo(copy.paymentConfirmed.replace("{txHash}", shortAddress(txHashNorm))); setPaymentInfo(copy.paymentConfirmed.replace("{txHash}", shortAddress(txHashNorm)));
trackAppEvent("checkout_succeeded", { trackPaymentSuccess({
entry: "account_center", entry: "account_center",
plan_code: selectedPlan?.plan_code || "pro_monthly", plan_code: selectedPlan?.plan_code || "pro_monthly",
intent_id: intentId, intent_id: intentId,
tx_hash: txHashNorm, tx_hash: txHashNorm,
}); });
await loadSnapshot(); await refreshEntitlementAfterPayment();
await loadPaymentSnapshot(); await loadPaymentSnapshot();
} catch (error) { } catch (error) {
const normalized = normalizePaymentError(error); const normalized = normalizePaymentError(error);
@@ -530,13 +662,14 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
setPaymentBusy(true); setPaymentBusy(true);
try { try {
const authHeaders = await buildAuthedHeaders(true, false); const authHeaders = await buildAuthedHeaders(true, true);
const createRes = await fetch("/api/payments/intents", { const createRes = await fetch("/api/payments/intents", {
method: "POST", method: "POST",
headers: authHeaders, headers: authHeaders,
body: JSON.stringify({ body: JSON.stringify({
plan_code: selectedPlan?.plan_code || "pro_monthly", plan_code: selectedPlan?.plan_code || "pro_monthly",
payment_mode: "direct", payment_mode: "direct",
chain_id: effectivePaymentChainId,
token_address: resolvedSelectedTokenAddress || undefined, token_address: resolvedSelectedTokenAddress || undefined,
use_points: billing.canRedeem && usePoints, use_points: billing.canRedeem && usePoints,
points_to_consume: billing.canRedeem && usePoints ? billing.pointsUsed : 0, points_to_consume: billing.canRedeem && usePoints ? billing.pointsUsed : 0,
@@ -563,8 +696,15 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
setManualPayment(direct); setManualPayment(direct);
setPaymentMethodTab("manual"); setPaymentMethodTab("manual");
setShowOverlay(false); setShowOverlay(false);
setPaymentInfo(`手动转账订单已创建:请在 Polygon 网络转 ${direct.amount_usdc} ${direct.token_symbol || selectedTokenLabel}${direct.receiver_address},请在下方【手动转账】面板中查看详情并复制地址,完成后提交 tx hash。`); const chainName = direct.chain_name || chainIdToDisplayName(direct.chain_id);
trackAppEvent("checkout_started", { setPaymentInfo(
copy.manualOrderCreated
.replace("{amount}", direct.amount_usdc)
.replace("{symbol}", direct.token_symbol || selectedTokenLabel)
.replace("{chain}", chainName)
.replace("{receiver}", shortAddress(direct.receiver_address)),
);
trackPaymentStart({
entry: "account_center_manual_transfer", entry: "account_center_manual_transfer",
plan_code: selectedPlan?.plan_code || "pro_monthly", plan_code: selectedPlan?.plan_code || "pro_monthly",
intent_id: intentId, intent_id: intentId,
@@ -594,7 +734,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
setPaymentBusy(true); setPaymentBusy(true);
setPaymentError(""); setPaymentError("");
try { try {
const authHeaders = await buildAuthedHeaders(true, false); const authHeaders = await buildAuthedHeaders(true, true);
const submitRes = await fetch(`/api/payments/intents/${intentIdVal}/submit`, { const submitRes = await fetch(`/api/payments/intents/${intentIdVal}/submit`, {
method: "POST", headers: authHeaders, body: JSON.stringify({ tx_hash: txHashNorm }), method: "POST", headers: authHeaders, body: JSON.stringify({ tx_hash: txHashNorm }),
}); });
@@ -627,13 +767,13 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
setManualPayment(null); setManualPayment(null);
setManualTxHash(""); setManualTxHash("");
setTxValidation({ loading: false, checked: false }); setTxValidation({ loading: false, checked: false });
trackAppEvent("checkout_succeeded", { trackPaymentSuccess({
entry: "account_center_manual_transfer", entry: "account_center_manual_transfer",
plan_code: selectedPlan?.plan_code || "pro_monthly", plan_code: selectedPlan?.plan_code || "pro_monthly",
intent_id: intentIdVal, intent_id: intentIdVal,
tx_hash: txHashNorm, tx_hash: txHashNorm,
}); });
await loadSnapshot(); await refreshEntitlementAfterPayment();
await loadPaymentSnapshot(); await loadPaymentSnapshot();
} catch (error) { } catch (error) {
setPaymentError(normalizePaymentError(error).message); setPaymentError(normalizePaymentError(error).message);
@@ -652,7 +792,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
} }
setTxValidation({ loading: true, checked: false }); setTxValidation({ loading: true, checked: false });
try { try {
const headers = await buildAuthedHeaders(true, false); const headers = await buildAuthedHeaders(true, true);
const res = await fetch(`/api/payments/intents/${intentId}/validate`, { const res = await fetch(`/api/payments/intents/${intentId}/validate`, {
method: "POST", headers, body: JSON.stringify({ tx_hash: hashNorm }), method: "POST", headers, body: JSON.stringify({ tx_hash: hashNorm }),
}); });
@@ -700,6 +840,8 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
validateTxHash, validateTxHash,
handleOverlayCheckout, handleOverlayCheckout,
availableTokenList, availableTokenList,
availableChainList,
selectedPaymentChain,
resolvedSelectedTokenAddress, resolvedSelectedTokenAddress,
selectedPaymentToken, selectedPaymentToken,
selectedTokenLabel, selectedTokenLabel,
+36 -10
View File
@@ -7,6 +7,7 @@ import type {
Eip6963ProviderDetail, Eip6963ProviderDetail,
EvmProvider, EvmProvider,
InjectedProviderOption, InjectedProviderOption,
PaymentChainOption,
ProviderMode, ProviderMode,
ProviderSelection, ProviderSelection,
} from "./types"; } from "./types";
@@ -190,12 +191,30 @@ export function useWalletBind(params: UseWalletBindParams) {
} }
}; };
const ensureTargetChain = async (eth: EvmProvider, targetChainId: number): Promise<void> => { const ensureTargetChain = async (
eth: EvmProvider,
targetChainId: number,
chain?: PaymentChainOption,
): Promise<void> => {
const currentChainIdHex = String( const currentChainIdHex = String(
(await requestWalletWithTimeout<string>(eth, { method: "eth_chainId" }, copy.chainReadError)) || "", (await requestWalletWithTimeout<string>(eth, { method: "eth_chainId" }, copy.chainReadError)) || "",
); );
const targetChainHex = `0x${targetChainId.toString(16)}`; const targetChainHex = `0x${targetChainId.toString(16)}`;
if (currentChainIdHex.toLowerCase() === targetChainHex.toLowerCase()) return; if (currentChainIdHex.toLowerCase() === targetChainHex.toLowerCase()) return;
const chainName =
String(chain?.name || "").trim() ||
(targetChainId === 1 ? "Ethereum Mainnet" : targetChainId === 137 ? "Polygon Mainnet" : `Chain ${targetChainId}`);
const nativeSymbol =
String(chain?.native_currency_symbol || "").trim() ||
(targetChainId === 137 ? "POL" : "ETH");
const explorerBase = String(chain?.block_explorer_url || "").trim() ||
(targetChainId === 1 ? "https://etherscan.io" : targetChainId === 137 ? "https://polygonscan.com" : "");
const defaultRpc =
targetChainId === 137
? "https://polygon-rpc.com"
: targetChainId === 1
? "https://ethereum-rpc.publicnode.com"
: "";
try { try {
await requestWalletWithTimeout( await requestWalletWithTimeout(
eth, eth,
@@ -206,24 +225,31 @@ export function useWalletBind(params: UseWalletBindParams) {
const code = Number(err?.code); const code = Number(err?.code);
if (code === 4902 || targetChainId === 137) { if (code === 4902 || targetChainId === 137) {
try { try {
const addParams: Record<string, any> = {
chainId: targetChainHex,
chainName,
nativeCurrency: { name: nativeSymbol, symbol: nativeSymbol, decimals: 18 },
};
if (defaultRpc) addParams.rpcUrls = [defaultRpc];
if (explorerBase) addParams.blockExplorerUrls = [explorerBase];
await requestWalletWithTimeout( await requestWalletWithTimeout(
eth, eth,
{ {
method: "wallet_addEthereumChain", method: "wallet_addEthereumChain",
params: [{ params: [addParams],
chainId: "0x89",
chainName: "Polygon Mainnet",
nativeCurrency: { name: "POL", symbol: "POL", decimals: 18 },
rpcUrls: ["https://polygon-rpc.com"],
blockExplorerUrls: ["https://polygonscan.com"],
}],
}, },
copy.chainAddPolygon, isEn ? `Add ${chainName}` : `添加 ${chainName}`,
); );
return; return;
} catch (addErr: any) { err = addErr; } } catch (addErr: any) { err = addErr; }
} }
throw new Error(`${copy.chainSwitchPrompt} (${err?.message || (isEn ? "Network switch failed" : "网络切换失败")})`); throw new Error(
`${
isEn
? `Please manually switch to ${chainName} in your wallet and try again.`
: `请在钱包中手动切换到 ${chainName} 后再试。`
} (${err?.message || (isEn ? "Network switch failed" : "网络切换失败")})`,
);
} }
}; };
+21 -7
View File
@@ -64,7 +64,10 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) {
? "Set at least 6 characters" ? "Set at least 6 characters"
: "设置至少 6 位密码", : "设置至少 6 位密码",
loginSubmit: isEn ? "Start your weather decision journey" : "开启气象决策之旅", loginSubmit: isEn ? "Start your weather decision journey" : "开启气象决策之旅",
loginSubmitting: isEn ? "Signing in..." : "正在登录...",
signupSubmit: isEn ? "Create account now" : "立即创建账号", signupSubmit: isEn ? "Create account now" : "立即创建账号",
signupSubmitting: isEn ? "Creating account..." : "正在创建账号...",
googleSubmitting: isEn ? "Connecting Google..." : "正在连接 Google...",
loginHint: isEn loginHint: isEn
? "After signing in, your homepage will be personalized." ? "After signing in, your homepage will be personalized."
: "登录后将为您个性化定制首页数据", : "登录后将为您个性化定制首页数据",
@@ -105,10 +108,18 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) {
? "By proceeding, you agree to the Privacy Policy and Terms & Conditions." ? "By proceeding, you agree to the Privacy Policy and Terms & Conditions."
: "继续操作即代表您同意隐私政策与服务条款。", : "继续操作即代表您同意隐私政策与服务条款。",
desc: isEn desc: isEn
? "Access robust METAR observations, advanced DEB forecast blends, and real-time AI decision cards that bring clarity to your weather risk analyses." ? "Access robust METAR observations, advanced DEB forecast blends, and structured decision context for weather risk analysis."
: "提供精准的机场 METAR 实况、先进的 DEB 智能融合预测和实时 AI 决策卡片,助您理清气象风险脉络。", : "提供精准的机场 METAR 实况、先进的 DEB 智能融合预测和结构化决策背景,助您理清气象风险脉络。",
trusted: isEn ? "Trusted by industry professionals" : "深受行业决策人员信赖", trusted: isEn ? "Trusted by industry professionals" : "深受行业决策人员信赖",
} as const; } as const;
const submittingLabel = isLogin ? copy.loginSubmitting : copy.signupSubmitting;
const googleSubmittingLabel = copy.googleSubmitting;
const loadingSpinner = (
<span
aria-hidden="true"
className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"
/>
);
const onResetPassword = async () => { const onResetPassword = async () => {
setErrorText(""); setErrorText("");
@@ -126,7 +137,7 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) {
const supabase = getSupabaseBrowserClient(); const supabase = getSupabaseBrowserClient();
const { error } = await supabase.auth.resetPasswordForEmail(email.trim(), { const { error } = await supabase.auth.resetPasswordForEmail(email.trim(), {
redirectTo: `${siteOrigin}/auth/callback?next=${encodeURIComponent( redirectTo: `${siteOrigin}/auth/callback?next=${encodeURIComponent(
"/account", `/auth/reset-password?next=${encodeURIComponent(nextPath || "/account")}`,
)}`, )}`,
}); });
if (error) { if (error) {
@@ -489,10 +500,12 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) {
<button <button
type="submit" type="submit"
disabled={loading} disabled={loading}
aria-busy={loading}
className="w-full py-3.5 px-4 rounded-xl bg-gradient-to-r from-slate-900 to-slate-800 hover:from-blue-600 hover:to-indigo-600 text-sm font-bold text-white shadow-lg shadow-slate-950/10 hover:shadow-blue-600/25 active:scale-[0.98] transition-all duration-300 disabled:opacity-50 mt-8 flex items-center justify-center gap-2 group animate-fade-up [animation-delay:550ms] opacity-0" className="w-full py-3.5 px-4 rounded-xl bg-gradient-to-r from-slate-900 to-slate-800 hover:from-blue-600 hover:to-indigo-600 text-sm font-bold text-white shadow-lg shadow-slate-950/10 hover:shadow-blue-600/25 active:scale-[0.98] transition-all duration-300 disabled:opacity-50 mt-8 flex items-center justify-center gap-2 group animate-fade-up [animation-delay:550ms] opacity-0"
> >
<span>{isLogin ? copy.loginSubmit : copy.signupSubmit}</span> {loading ? loadingSpinner : null}
<ArrowRight size={16} className="transition-transform group-hover:translate-x-1" /> <span>{loading ? submittingLabel : (isLogin ? copy.loginSubmit : copy.signupSubmit)}</span>
{!loading && <ArrowRight size={16} className="transition-transform group-hover:translate-x-1" />}
</button> </button>
</form> </form>
@@ -508,10 +521,11 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) {
type="button" type="button"
onClick={() => void onGoogleSignIn()} onClick={() => void onGoogleSignIn()}
disabled={loading} disabled={loading}
aria-busy={loading}
className="w-full py-3 px-4 rounded-xl border border-slate-200 bg-white text-sm font-semibold text-slate-700 shadow-sm hover:bg-slate-50 active:scale-[0.99] transition-all duration-150 flex items-center justify-center gap-2 disabled:opacity-50" className="w-full py-3 px-4 rounded-xl border border-slate-200 bg-white text-sm font-semibold text-slate-700 shadow-sm hover:bg-slate-50 active:scale-[0.99] transition-all duration-150 flex items-center justify-center gap-2 disabled:opacity-50"
> >
<Chrome className="h-4 w-4 text-blue-600" /> {loading ? loadingSpinner : <Chrome className="h-4 w-4 text-blue-600" />}
<span>{copy.googleOneClick}</span> <span>{loading ? googleSubmittingLabel : copy.googleOneClick}</span>
</button> </button>
{errorText ? <p className="mt-4 rounded-xl border border-rose-200 bg-rose-50 px-3 py-2.5 text-xs text-rose-700 leading-normal">{errorText}</p> : null} {errorText ? <p className="mt-4 rounded-xl border border-rose-200 bg-rose-50 px-3 py-2.5 text-xs text-rose-700 leading-normal">{errorText}</p> : null}
@@ -0,0 +1,227 @@
"use client";
import { FormEvent, useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { AlertCircle, ArrowRight, CheckCircle2, Eye, EyeOff, Lock } from "lucide-react";
import {
getSupabaseBrowserClient,
hasSupabasePublicEnv,
} from "@/lib/supabase/client";
import { useI18n } from "@/hooks/useI18n";
type ResetPasswordClientProps = {
nextPath: string;
};
export function ResetPasswordClient({ nextPath }: ResetPasswordClientProps) {
const router = useRouter();
const { locale } = useI18n();
const isEn = locale === "en-US";
const supabaseReady = hasSupabasePublicEnv();
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [checkingSession, setCheckingSession] = useState(true);
const [hasSession, setHasSession] = useState(false);
const [loading, setLoading] = useState(false);
const [errorText, setErrorText] = useState("");
const [infoText, setInfoText] = useState("");
const copy = {
title: isEn ? "Set a new password" : "设置新密码",
subtitle: isEn
? "Create a password for your PolyWeather account."
: "为您的 PolyWeather 账户创建一个新密码。",
password: isEn ? "New password" : "新密码",
confirmPassword: isEn ? "Confirm password" : "确认密码",
passwordPlaceholder: isEn ? "At least 6 characters" : "至少 6 位字符",
confirmPlaceholder: isEn ? "Enter it again" : "再次输入新密码",
submit: isEn ? "Update password" : "更新密码",
checking: isEn ? "Checking reset link..." : "正在检查重置链接...",
expired: isEn
? "This reset link is expired or invalid. Please request a new password reset email."
: "重置链接已过期或无效,请重新发送密码重置邮件。",
supabaseMissing: isEn
? "Supabase is not configured. Password reset is unavailable."
: "Supabase 未配置,无法重置密码。",
passwordTooShort: isEn
? "Password must be at least 6 characters."
: "密码至少需要 6 位字符。",
passwordMismatch: isEn
? "The two passwords do not match."
: "两次输入的密码不一致。",
success: isEn
? "Password updated. Redirecting..."
: "密码已更新,正在跳转...",
backLogin: isEn ? "Back to sign in" : "返回登录",
} as const;
useEffect(() => {
if (!supabaseReady) {
setErrorText(copy.supabaseMissing);
setCheckingSession(false);
return;
}
const run = async () => {
const supabase = getSupabaseBrowserClient();
const {
data: { session },
} = await supabase.auth.getSession();
const hasRecoverySession = Boolean(session?.user);
setHasSession(hasRecoverySession);
if (!hasRecoverySession) {
setErrorText(copy.expired);
}
setCheckingSession(false);
};
void run();
}, [copy.expired, copy.supabaseMissing, supabaseReady]);
const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setErrorText("");
setInfoText("");
if (!hasSession) {
setErrorText(copy.expired);
return;
}
if (password.length < 6) {
setErrorText(copy.passwordTooShort);
return;
}
if (password !== confirmPassword) {
setErrorText(copy.passwordMismatch);
return;
}
setLoading(true);
try {
const supabase = getSupabaseBrowserClient();
const { error } = await supabase.auth.updateUser({ password });
if (error) {
setErrorText(error.message);
return;
}
setInfoText(copy.success);
window.setTimeout(() => router.replace(nextPath), 700);
} finally {
setLoading(false);
}
};
return (
<main className="flex min-h-screen items-center justify-center bg-slate-50 px-5 py-10 text-slate-900">
<div className="w-full max-w-[420px] rounded-2xl border border-slate-200 bg-white p-6 shadow-[0_24px_60px_rgba(8,16,36,0.08)] sm:p-8">
<Link href="/" className="mb-8 inline-flex items-center">
<img src="/logo.png" alt="PolyWeather" className="h-8 w-auto object-contain" />
</Link>
<div className="mb-6 space-y-2">
<h1 className="text-2xl font-black tracking-tight">{copy.title}</h1>
<p className="text-sm leading-6 text-slate-500">{copy.subtitle}</p>
</div>
{checkingSession ? (
<div className="rounded-xl border border-slate-200 bg-slate-50 px-3 py-3 text-sm text-slate-600">
{copy.checking}
</div>
) : (
<form onSubmit={(event) => void onSubmit(event)} className="space-y-5">
<div className="space-y-2">
<label className="block text-[10px] font-bold uppercase tracking-wider text-slate-400">
{copy.password}
</label>
<div className="relative">
<Lock className="absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input
type={showPassword ? "text" : "password"}
minLength={6}
required
disabled={!hasSession || loading}
autoComplete="new-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder={copy.passwordPlaceholder}
className="w-full rounded-xl border border-slate-200 bg-slate-50/70 py-3 pl-11 pr-11 text-sm text-slate-900 placeholder:text-slate-400 transition-all focus:border-blue-500 focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 disabled:cursor-not-allowed disabled:opacity-60"
/>
<button
type="button"
onClick={() => setShowPassword((value) => !value)}
disabled={!hasSession || loading}
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-slate-400 transition-colors hover:text-slate-600 disabled:cursor-not-allowed disabled:opacity-60"
aria-label={showPassword ? "Hide password" : "Show password"}
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
<div className="space-y-2">
<label className="block text-[10px] font-bold uppercase tracking-wider text-slate-400">
{copy.confirmPassword}
</label>
<div className="relative">
<Lock className="absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input
type={showConfirmPassword ? "text" : "password"}
minLength={6}
required
disabled={!hasSession || loading}
autoComplete="new-password"
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)}
placeholder={copy.confirmPlaceholder}
className="w-full rounded-xl border border-slate-200 bg-slate-50/70 py-3 pl-11 pr-11 text-sm text-slate-900 placeholder:text-slate-400 transition-all focus:border-blue-500 focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 disabled:cursor-not-allowed disabled:opacity-60"
/>
<button
type="button"
onClick={() => setShowConfirmPassword((value) => !value)}
disabled={!hasSession || loading}
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-slate-400 transition-colors hover:text-slate-600 disabled:cursor-not-allowed disabled:opacity-60"
aria-label={showConfirmPassword ? "Hide password" : "Show password"}
>
{showConfirmPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
<button
type="submit"
disabled={!hasSession || loading}
className="flex w-full items-center justify-center gap-2 rounded-xl bg-slate-900 px-4 py-3.5 text-sm font-bold text-white shadow-lg shadow-slate-950/10 transition-all hover:bg-blue-600 disabled:cursor-not-allowed disabled:opacity-50"
>
<span>{copy.submit}</span>
<ArrowRight size={16} />
</button>
</form>
)}
{errorText ? (
<div className="mt-4 flex gap-2 rounded-xl border border-rose-200 bg-rose-50 px-3 py-2.5 text-xs leading-5 text-rose-700">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{errorText}</span>
</div>
) : null}
{infoText ? (
<div className="mt-4 flex gap-2 rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2.5 text-xs leading-5 text-emerald-700">
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" />
<span>{infoText}</span>
</div>
) : null}
<Link
href="/auth/login"
className="mt-5 inline-flex text-xs font-semibold text-blue-600 transition-colors hover:text-blue-700"
>
{copy.backLogin}
</Link>
</div>
</main>
);
}
@@ -0,0 +1,83 @@
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 loginClientPath = path.join(
projectRoot,
"components",
"auth",
"LoginClient.tsx",
);
const resetPagePath = path.join(
projectRoot,
"app",
"auth",
"reset-password",
"page.tsx",
);
const resetClientPath = path.join(
projectRoot,
"components",
"auth",
"ResetPasswordClient.tsx",
);
const authCallbackPath = path.join(
projectRoot,
"app",
"auth",
"callback",
"route.ts",
);
const loginClientSource = fs.readFileSync(loginClientPath, "utf8");
assert(
loginClientSource.includes("/auth/reset-password") &&
loginClientSource.indexOf("resetPasswordForEmail") <
loginClientSource.indexOf("/auth/reset-password"),
"forgot-password emails must land on a password reset page after auth callback",
);
assert(
fs.existsSync(resetPagePath),
"password reset page must exist at app/auth/reset-password/page.tsx",
);
assert(
fs.existsSync(resetClientPath),
"password reset page must use a dedicated client component",
);
assert(
loginClientSource.includes("loadingSpinner") &&
loginClientSource.includes("submittingLabel") &&
loginClientSource.includes("googleSubmittingLabel") &&
loginClientSource.includes('aria-busy={loading}') &&
loginClientSource.includes("animate-spin"),
"login submit and Google sign-in buttons must show a visible loading spinner and pending label",
);
const resetClientSource = fs.readFileSync(resetClientPath, "utf8");
assert(
resetClientSource.includes("supabase.auth.updateUser") &&
resetClientSource.includes("password"),
"password reset client must update the authenticated user's password",
);
assert(
resetClientSource.includes("getSession") &&
resetClientSource.includes("expired"),
"password reset client must detect an expired or invalid recovery session",
);
const authCallbackSource = fs.readFileSync(authCallbackPath, "utf8");
assert(
authCallbackSource.includes("warmSignupTrial") &&
authCallbackSource.includes("exchangeCodeForSession") &&
authCallbackSource.includes("/api/auth/me") &&
authCallbackSource.includes("Authorization") &&
authCallbackSource.includes("Bearer"),
"auth callback must warm /api/auth/me with the exchanged Supabase session so new users receive the signup trial immediately",
);
}
@@ -1,20 +1,21 @@
"use client"; "use client";
import clsx from "clsx"; import clsx from "clsx";
import dynamic from "next/dynamic";
import Link from "next/link"; import Link from "next/link";
import { import {
Activity, Activity,
BookOpenCheck,
ChevronDown, ChevronDown,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
GraduationCap, GraduationCap,
Menu, Menu,
Search, Search,
Table2,
UserRound, UserRound,
Users, Users,
} from "lucide-react"; } from "lucide-react";
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Fragment, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
import type { CityListItem, ProAccessState, ScanOpportunityRow } from "@/lib/dashboard-types"; import type { CityListItem, ProAccessState, ScanOpportunityRow } from "@/lib/dashboard-types";
import { getInitialLocaleFromNavigator } from "@/lib/i18n"; import { getInitialLocaleFromNavigator } from "@/lib/i18n";
import { isBrowserLocalFullAccess } from "@/lib/local-dev-access"; import { isBrowserLocalFullAccess } from "@/lib/local-dev-access";
@@ -44,7 +45,7 @@ import { ScanTerminalLoadingScreen } from "@/components/dashboard/scan-terminal/
import { scanRootClass } from "@/components/dashboard/scan-root-styles"; import { scanRootClass } from "@/components/dashboard/scan-root-styles";
import { useRelativeTime } from "@/hooks/useRelativeTime"; import { useRelativeTime } from "@/hooks/useRelativeTime";
import { Panel } from "@/components/dashboard/scan-terminal/Panel"; import { Panel } from "@/components/dashboard/scan-terminal/Panel";
import { TrainingDashboard } from "@/components/dashboard/scan-terminal/TrainingDashboard"; import { UsageGuideDashboard } from "@/components/dashboard/scan-terminal/UsageGuideDashboard";
import { LiveTemperatureThresholdChart, clearCityDetailCache } from "@/components/dashboard/scan-terminal/LiveTemperatureThresholdChart"; import { LiveTemperatureThresholdChart, clearCityDetailCache } from "@/components/dashboard/scan-terminal/LiveTemperatureThresholdChart";
import { KoyfinRowsTable } from "@/components/dashboard/scan-terminal/KoyfinRowsTable"; import { KoyfinRowsTable } from "@/components/dashboard/scan-terminal/KoyfinRowsTable";
import { rowName, pct, money, temp, edgeClass } from "@/components/dashboard/scan-terminal/utils"; import { rowName, pct, money, temp, edgeClass } from "@/components/dashboard/scan-terminal/utils";
@@ -54,10 +55,32 @@ import {
mergeAccessStateWithAuthPayload, mergeAccessStateWithAuthPayload,
type AuthProfilePayload, type AuthProfilePayload,
} from "@/components/dashboard/scan-terminal/terminal-access-state"; } from "@/components/dashboard/scan-terminal/terminal-access-state";
import {
createAuthProfileRequestCache,
loadTerminalAuthProfile,
} from "@/components/dashboard/scan-terminal/terminal-auth-bootstrap";
import { import {
cityListItemsToScanRows, cityListItemsToScanRows,
mergeScanRowsWithCityFallbackRows, mergeScanRowsWithCityFallbackRows,
} from "@/components/dashboard/scan-terminal/city-fallback-rows"; } from "@/components/dashboard/scan-terminal/city-fallback-rows";
import { markAnalyticsOnce, trackAppEvent } from "@/lib/app-analytics";
import { STATIC_CITY_LIST } from "@/lib/static-cities";
const TrainingDashboard = dynamic(
() =>
import("@/components/dashboard/scan-terminal/TrainingDashboard").then(
(mod) => mod.TrainingDashboard,
),
{
loading: () => (
<div className="flex min-h-0 flex-1 items-center justify-center text-xs font-semibold text-slate-400">
Loading analytics...
</div>
),
},
);
const ONLINE_USERS_REFRESH_MS = 5 * 60_000;
function createEmptyAccess(loading = true): ProAccessState { function createEmptyAccess(loading = true): ProAccessState {
return { return {
@@ -89,7 +112,13 @@ function createLocalAccess(): ProAccessState {
}; };
} }
function createTransientAccess(error: unknown): ProAccessState {
return {
...createEmptyAccess(true),
authenticated: true,
error: String(error),
};
}
const TERM = { const TERM = {
cityThreshold: { en: "City / Threshold", zh: "城市 / 阈值" }, cityThreshold: { en: "City / Threshold", zh: "城市 / 阈值" },
@@ -100,7 +129,7 @@ const TERM = {
liq: { en: "Liq", zh: "流动性" }, liq: { en: "Liq", zh: "流动性" },
signal: { en: "Signal", zh: "信号" }, signal: { en: "Signal", zh: "信号" },
searchPlaceholder: { en: "Search city, threshold, station, or signal", zh: "搜索城市、阈值、站点或信号" }, searchPlaceholder: { en: "Search city, threshold, station, or signal", zh: "搜索城市、阈值、站点或信号" },
weatherThresholds: { en: "Weather Thresholds", zh: "天气阈值" }, weatherThresholds: { en: "Weather Decisions", zh: "天气决策" },
selectedThresholdMonitor: { en: "Selected Threshold Monitor", zh: "选中阈值监控" }, selectedThresholdMonitor: { en: "Selected Threshold Monitor", zh: "选中阈值监控" },
probabilityDistribution: { en: "Probability Distribution", zh: "概率分布" }, probabilityDistribution: { en: "Probability Distribution", zh: "概率分布" },
signalList: { en: "Signal List", zh: "信号列表" }, signalList: { en: "Signal List", zh: "信号列表" },
@@ -125,7 +154,7 @@ const TERM = {
logIn: { en: "Log in", zh: "登录" }, logIn: { en: "Log in", zh: "登录" },
createAccount: { en: "Create an account", zh: "注册账号" }, createAccount: { en: "Create an account", zh: "注册账号" },
learnAbout: { en: "Learn about PolyWeather", zh: "了解 PolyWeather" }, learnAbout: { en: "Learn about PolyWeather", zh: "了解 PolyWeather" },
proAccessRequired: { en: "Pro Access Required", zh: "需要付费订阅" }, proAccessRequired: { en: "Pro subscription required", zh: "需要开通 Pro" },
proDesc: { proDesc: {
en: "The PolyWeather terminal is a paid product. Subscribe to unlock real-time weather-signal intelligence.", en: "The PolyWeather terminal is a paid product. Subscribe to unlock real-time weather-signal intelligence.",
zh: "PolyWeather 决策台为付费产品。订阅以解锁实时天气信号情报。", zh: "PolyWeather 决策台为付费产品。订阅以解锁实时天气信号情报。",
@@ -135,7 +164,7 @@ const TERM = {
zh: "按月计费,随时可取消。通过 Polygon 链 USDC 支付。", zh: "按月计费,随时可取消。通过 Polygon 链 USDC 支付。",
}, },
month: { en: "/ month", zh: "/ 月" }, month: { en: "/ month", zh: "/ 月" },
subscribeNow: { en: "Subscribe Now — $10/mo", zh: "立即订阅 — $10/月" }, subscribeNow: { en: "View Pro plans", zh: "查看订阅方案" },
subscribePrompt: { subscribePrompt: {
en: "You need an active subscription to access the terminal.", en: "You need an active subscription to access the terminal.",
zh: "你需要开通有效订阅才能访问决策台。", zh: "你需要开通有效订阅才能访问决策台。",
@@ -401,14 +430,22 @@ function PolyWeatherTerminal({
useEffect(() => { useEffect(() => {
const fetchOnline = () => { const fetchOnline = () => {
if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
fetch("/api/ops/online-users", { headers: { Accept: "application/json" } }) fetch("/api/ops/online-users", { headers: { Accept: "application/json" } })
.then((r) => (r.ok ? r.json() : null)) .then((r) => (r.ok ? r.json() : null))
.then((d) => { if (d?.online != null) setOnlineCount(d.online); }) .then((d) => { if (d?.online != null) setOnlineCount(d.online); })
.catch(() => {}); .catch(() => {});
}; };
fetchOnline(); fetchOnline();
const id = setInterval(fetchOnline, 60_000); const handleVisibilityChange = () => {
return () => clearInterval(id); if (document.visibilityState === "visible") fetchOnline();
};
document.addEventListener("visibilitychange", handleVisibilityChange);
const id = setInterval(fetchOnline, ONLINE_USERS_REFRESH_MS);
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
clearInterval(id);
};
}, []); }, []);
const [gridCols, setGridCols] = useState<number>(() => { const [gridCols, setGridCols] = useState<number>(() => {
@@ -465,8 +502,9 @@ function PolyWeatherTerminal({
}; };
const NAV_ITEMS = [ const NAV_ITEMS = [
{ key: "thresholds", Icon: Table2, labelEn: "Thresholds", labelZh: "天气阈值" }, { key: "thresholds", Icon: Activity, labelEn: "Decision", labelZh: "天气决策" },
{ key: "training", Icon: GraduationCap, labelEn: "Training", labelZh: "训练数据" }, { key: "training", Icon: GraduationCap, labelEn: "Training", labelZh: "训练数据" },
{ key: "guide", Icon: BookOpenCheck, labelEn: "Guide", labelZh: "使用指南" },
]; ];
useEffect(() => { useEffect(() => {
@@ -719,6 +757,8 @@ function PolyWeatherTerminal({
<main className="min-h-0 flex-1 overflow-hidden flex flex-col p-2 bg-[#eef2f6]"> <main className="min-h-0 flex-1 overflow-hidden flex flex-col p-2 bg-[#eef2f6]">
{activeNavKey === "training" ? ( {activeNavKey === "training" ? (
<TrainingDashboard isEn={isEn} /> <TrainingDashboard isEn={isEn} />
) : activeNavKey === "guide" ? (
<UsageGuideDashboard isEn={isEn} />
) : ( ) : (
<> <>
{/* Mobile layout */} {/* Mobile layout */}
@@ -855,7 +895,7 @@ function PolyWeatherTerminal({
setActiveSearchSlotIndex(null); setActiveSearchSlotIndex(null);
}} }}
onClose={() => setActiveSearchSlotIndex(null)} onClose={() => setActiveSearchSlotIndex(null)}
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-50 w-[380px] bg-white border border-slate-200 rounded shadow-lg p-2" className="absolute left-1/2 top-12 z-50 w-[380px] -translate-x-1/2 bg-white border border-slate-200 rounded shadow-lg p-2"
/> />
)} )}
</div> </div>
@@ -925,20 +965,64 @@ function ScanTerminalScreen() {
createEmptyAccess(true), createEmptyAccess(true),
); );
const loadAuthProfile = useCallback( const rawLoadAuthProfile = useCallback(
async (accessToken?: string | null): Promise<AuthProfilePayload> => { async (
accessToken?: string | null,
options?: { preferSnapshot?: boolean },
): Promise<AuthProfilePayload> => {
const headers: Record<string, string> = { Accept: "application/json" }; const headers: Record<string, string> = { Accept: "application/json" };
const token = String(accessToken || "").trim(); const token = String(accessToken || "").trim();
if (token) headers.Authorization = `Bearer ${token}`; if (token) headers.Authorization = `Bearer ${token}`;
const response = await fetch("/api/auth/me", { const response = await fetch(
cache: "no-store", options?.preferSnapshot
headers, ? "/api/auth/me?prefer_snapshot=1"
}); : "/api/auth/me",
{
cache: "no-store",
headers,
},
);
if (!response.ok) throw new Error(`HTTP ${response.status}`); if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<AuthProfilePayload>; return response.json() as Promise<AuthProfilePayload>;
}, },
[], [],
); );
const authProfileRequestCacheRef = useRef<{
load: typeof rawLoadAuthProfile;
cached: ReturnType<typeof createAuthProfileRequestCache>;
} | null>(null);
const loadAuthProfile = useCallback(
(
accessToken?: string | null,
options?: { preferSnapshot?: boolean },
): Promise<AuthProfilePayload> => {
const current = authProfileRequestCacheRef.current;
if (current?.load === rawLoadAuthProfile) {
return current.cached(accessToken, options);
}
const next = {
load: rawLoadAuthProfile,
cached: createAuthProfileRequestCache(rawLoadAuthProfile),
};
authProfileRequestCacheRef.current = next;
return next.cached(accessToken, options);
},
[rawLoadAuthProfile],
);
const refreshLiveAuthProfile = useCallback(async () => {
const supabaseEnabled = hasSupabasePublicEnv();
const payload = await loadTerminalAuthProfile({
getSession: () =>
supabaseEnabled
? getSupabaseBrowserClient().auth.getSession()
: Promise.resolve({ data: { session: null } }),
hasSupabasePublicEnv: supabaseEnabled,
loadAuthProfile: (accessToken) =>
loadAuthProfile(accessToken, { preferSnapshot: false }),
});
setProAccess((prev) => mergeAccessStateWithAuthPayload(prev, payload));
}, [loadAuthProfile]);
// Listen to Supabase auth events (e.g. token refreshed, signed out) // Listen to Supabase auth events (e.g. token refreshed, signed out)
useEffect(() => { useEffect(() => {
@@ -949,9 +1033,26 @@ function ScanTerminalScreen() {
data: { subscription }, data: { subscription },
} = supabase.auth.onAuthStateChange(async (event, session) => { } = supabase.auth.onAuthStateChange(async (event, session) => {
if (event === "SIGNED_OUT") { if (event === "SIGNED_OUT") {
setProAccess(createEmptyAccess(false));
} else if (event === "TOKEN_REFRESHED" || event === "SIGNED_IN") {
try { try {
const {
data: { session: currentSession },
} = await supabase.auth.getSession();
const accessToken =
currentSession?.access_token || session?.access_token || null;
if (accessToken) {
const payload = await loadAuthProfile(accessToken);
setProAccess((prev) => mergeAccessStateWithAuthPayload(prev, payload));
return;
}
} catch {}
setProAccess(createEmptyAccess(false));
} else if (
event === "INITIAL_SESSION" ||
event === "TOKEN_REFRESHED" ||
event === "SIGNED_IN"
) {
try {
if (!session?.access_token) return;
const payload = await loadAuthProfile(session?.access_token); const payload = await loadAuthProfile(session?.access_token);
setProAccess((prev) => mergeAccessStateWithAuthPayload(prev, payload)); setProAccess((prev) => mergeAccessStateWithAuthPayload(prev, payload));
} catch {} } catch {}
@@ -986,6 +1087,9 @@ function ScanTerminalScreen() {
hydrated && (proAccess.authenticated || canUseLocalFullAccess); hydrated && (proAccess.authenticated || canUseLocalFullAccess);
const isPro = const isPro =
hydrated && (proAccess.subscriptionActive || canUseLocalFullAccess); hydrated && (proAccess.subscriptionActive || canUseLocalFullAccess);
const accessDecisionPending =
!hydrated || (proAccess.loading && !canUseLocalFullAccess);
const shouldShowPaywall = !accessDecisionPending && (!isAuthenticated || !isPro);
const userLocalTime = useUserLocalClock(); const userLocalTime = useUserLocalClock();
const { themeMode } = useScanTerminalTheme(); const { themeMode } = useScanTerminalTheme();
const [selectedRegionKey, setSelectedRegionKey] = useState<string>("all"); const [selectedRegionKey, setSelectedRegionKey] = useState<string>("all");
@@ -1030,28 +1134,92 @@ function ScanTerminalScreen() {
cancelled = true; cancelled = true;
}; };
} }
const sessionPromise = hasSupabasePublicEnv() const supabaseEnabled = hasSupabasePublicEnv();
? getSupabaseBrowserClient().auth.getSession() loadTerminalAuthProfile({
: Promise.resolve({ data: { session: null } }); getSession: () =>
supabaseEnabled
sessionPromise ? getSupabaseBrowserClient().auth.getSession()
.then(({ data: { session } }) => loadAuthProfile(session?.access_token)) : Promise.resolve({ data: { session: null } }),
hasSupabasePublicEnv: supabaseEnabled,
loadAuthProfile,
})
.then((payload) => { .then((payload) => {
if (cancelled) return; if (cancelled) return;
setProAccess((prev) => mergeAccessStateWithAuthPayload(prev, payload)); setProAccess((prev) => mergeAccessStateWithAuthPayload(prev, payload));
if (payload.entitlement_snapshot === true) {
window.setTimeout(() => {
if (!cancelled) void refreshLiveAuthProfile();
}, 0);
}
}) })
.catch((error) => { .catch((error) => {
if (cancelled) return; if (cancelled) return;
setProAccess((prev) => ( setProAccess((prev) => (
prev.subscriptionActive prev.subscriptionActive
? { ...prev, loading: false, error: String(error) } ? { ...prev, loading: false, error: String(error) }
: { ...createEmptyAccess(false), error: String(error) } : createTransientAccess(error)
)); ));
}); });
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [loadAuthProfile]); }, [loadAuthProfile, refreshLiveAuthProfile]);
useEffect(() => {
if (
!hydrated ||
canUseLocalFullAccess ||
!proAccess.authenticated ||
!proAccess.loading ||
proAccess.subscriptionActive ||
typeof fetch !== "function"
) {
return;
}
let cancelled = false;
const supabaseEnabled = hasSupabasePublicEnv();
const retryAuthProfile = async () => {
try {
const payload = await loadTerminalAuthProfile({
getSession: () =>
supabaseEnabled
? getSupabaseBrowserClient().auth.getSession()
: Promise.resolve({ data: { session: null } }),
hasSupabasePublicEnv: supabaseEnabled,
loadAuthProfile,
});
if (cancelled) return;
setProAccess((prev) => mergeAccessStateWithAuthPayload(prev, payload));
} catch (error) {
if (cancelled) return;
setProAccess((prev) =>
prev.loading && prev.authenticated && !prev.subscriptionActive
? { ...prev, error: String(error) }
: prev,
);
}
};
const firstRetry = window.setTimeout(() => {
void retryAuthProfile();
}, 1500);
const interval = window.setInterval(() => {
void retryAuthProfile();
}, 5000);
return () => {
cancelled = true;
window.clearTimeout(firstRetry);
window.clearInterval(interval);
};
}, [
canUseLocalFullAccess,
hydrated,
loadAuthProfile,
proAccess.authenticated,
proAccess.loading,
proAccess.subscriptionActive,
]);
useEffect(() => { useEffect(() => {
setSelectedRegionKey("all"); setSelectedRegionKey("all");
@@ -1070,12 +1238,25 @@ function ScanTerminalScreen() {
timezoneOffsetSeconds: useLocalTimezoneDefault ? localTimezoneOffsetSeconds : null, timezoneOffsetSeconds: useLocalTimezoneDefault ? localTimezoneOffsetSeconds : null,
tradingRegion: selectedRegionKey, tradingRegion: selectedRegionKey,
}); });
useEffect(() => {
if (!hydrated || !isAuthenticated || !isPro) return;
const actorKey = String(proAccess.userId || "local").toLowerCase();
if (markAnalyticsOnce(`enter_terminal:${actorKey}`, "session")) {
trackAppEvent("enter_terminal", {
entry: "terminal",
user_id: proAccess.userId || null,
});
}
}, [hydrated, isAuthenticated, isPro, proAccess.userId]);
const handleRefresh = useCallback(() => { const handleRefresh = useCallback(() => {
clearCityDetailCache(); clearCityDetailCache();
refreshScanTerminalManually(); refreshScanTerminalManually();
}, [refreshScanTerminalManually]); }, [refreshScanTerminalManually]);
const [cityFallbackRows, setCityFallbackRows] = useState<ScanOpportunityRow[]>([]); const [cityFallbackRows, setCityFallbackRows] = useState<ScanOpportunityRow[]>(() =>
cityListItemsToScanRows(STATIC_CITY_LIST),
);
const rows = useMemo( const rows = useMemo(
() => { () => {
const scanRows = terminalData?.rows || []; const scanRows = terminalData?.rows || [];
@@ -1109,11 +1290,12 @@ function ScanTerminalScreen() {
return () => controller.abort(); return () => controller.abort();
}, [isPro]); }, [isPro]);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const deferredSearchQuery = useDeferredValue(searchQuery);
const searchInputRef = useRef<HTMLInputElement>(null); const searchInputRef = useRef<HTMLInputElement>(null);
const filteredRows = useMemo(() => { const filteredRows = useMemo(() => {
if (!searchQuery.trim()) return rows; if (!deferredSearchQuery.trim()) return rows;
const q = searchQuery.toLowerCase().trim(); const q = deferredSearchQuery.toLowerCase().trim();
return rows.filter((row) => { return rows.filter((row) => {
const haystack = [ const haystack = [
row.city, row.city,
@@ -1132,7 +1314,7 @@ function ScanTerminalScreen() {
.map((v) => String(v).toLowerCase()); .map((v) => String(v).toLowerCase());
return haystack.some((s) => s.includes(q)); return haystack.some((s) => s.includes(q));
}); });
}, [rows, searchQuery]); }, [rows, deferredSearchQuery]);
const [selectedId, setSelectedId] = useState<string | null>(null); const [selectedId, setSelectedId] = useState<string | null>(null);
const [selectedCity, setSelectedCity] = useState<string | null>(null); const [selectedCity, setSelectedCity] = useState<string | null>(null);
@@ -1145,7 +1327,7 @@ function ScanTerminalScreen() {
}, []); }, []);
const generatedText = useRelativeTime(terminalData?.generated_at ?? null); const generatedText = useRelativeTime(terminalData?.generated_at ?? null);
if (!hydrated || (proAccess.loading && !canUseLocalFullAccess)) { if (accessDecisionPending) {
return ( return (
<ScanTerminalLoadingScreen <ScanTerminalLoadingScreen
isEn={isEn} isEn={isEn}
@@ -1156,7 +1338,7 @@ function ScanTerminalScreen() {
); );
} }
if (!isAuthenticated || !isPro) { if (shouldShowPaywall) {
return ( return (
<ProductAccessRequired <ProductAccessRequired
isAuthenticated={isAuthenticated} isAuthenticated={isAuthenticated}
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useMemo, useRef, useState } from "react"; import { useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
import clsx from "clsx"; import clsx from "clsx";
import type { ScanOpportunityRow } from "@/lib/dashboard-types"; import type { ScanOpportunityRow } from "@/lib/dashboard-types";
import { REGIONS, getCityRegion } from "./continent-grouping"; import { REGIONS, getCityRegion } from "./continent-grouping";
@@ -113,6 +113,9 @@ const getCityCode = (city: string): string => {
return CITY_IATA_MAP[normalized] || normalized.substring(0, 3).toUpperCase(); return CITY_IATA_MAP[normalized] || normalized.substring(0, 3).toUpperCase();
}; };
const MIN_DROPDOWN_TOP_PX = 56;
const DROPDOWN_VIEWPORT_PADDING_PX = 12;
export function CitySelectorDropdown({ export function CitySelectorDropdown({
isEn, isEn,
rows, rows,
@@ -123,7 +126,9 @@ export function CitySelectorDropdown({
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const deferredSearchQuery = useDeferredValue(searchQuery);
const [activeTab, setActiveTab] = useState<string>("all"); const [activeTab, setActiveTab] = useState<string>("all");
const [viewportNudgeY, setViewportNudgeY] = useState(0);
// Auto-focus input on mount // Auto-focus input on mount
useEffect(() => { useEffect(() => {
@@ -166,7 +171,7 @@ export function CitySelectorDropdown({
// Filter rows // Filter rows
const filteredRows = useMemo(() => { const filteredRows = useMemo(() => {
const q = searchQuery.toLowerCase().trim(); const q = deferredSearchQuery.toLowerCase().trim();
return rows.filter((row) => { return rows.filter((row) => {
// 1. Region filter // 1. Region filter
if (activeTab !== "all") { if (activeTab !== "all") {
@@ -191,7 +196,36 @@ export function CitySelectorDropdown({
.map((s) => s!.toLowerCase()); .map((s) => s!.toLowerCase());
return haystack.some((s) => s.includes(q)); return haystack.some((s) => s.includes(q));
}); });
}, [rows, searchQuery, activeTab]); }, [rows, deferredSearchQuery, activeTab]);
useEffect(() => {
let frame = 0;
const updatePosition = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
const el = containerRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
let next = 0;
if (rect.top < MIN_DROPDOWN_TOP_PX) {
next = MIN_DROPDOWN_TOP_PX - rect.top;
} else if (rect.bottom > window.innerHeight - DROPDOWN_VIEWPORT_PADDING_PX) {
next = Math.max(
MIN_DROPDOWN_TOP_PX - rect.top,
window.innerHeight - DROPDOWN_VIEWPORT_PADDING_PX - rect.bottom,
);
}
setViewportNudgeY((prev) => (Math.abs(prev - next) < 1 ? prev : next));
});
};
updatePosition();
window.addEventListener("resize", updatePosition);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("resize", updatePosition);
};
}, [filteredRows.length]);
const getRegionLabel = (regionKey: string): string => { const getRegionLabel = (regionKey: string): string => {
const match = REGIONS.find((r) => r.key === regionKey); const match = REGIONS.find((r) => r.key === regionKey);
@@ -203,9 +237,10 @@ export function CitySelectorDropdown({
<div <div
ref={containerRef} ref={containerRef}
className={clsx( className={clsx(
"flex flex-col bg-white border border-slate-300 rounded shadow-2xl overflow-hidden text-xs text-[#202833] animate-in fade-in-50 zoom-in-95 duration-100", "flex max-h-[calc(100vh-72px)] min-h-0 flex-col bg-white border border-slate-300 rounded shadow-2xl overflow-hidden text-xs text-[#202833] animate-in fade-in-50 zoom-in-95 duration-100",
className className
)} )}
style={viewportNudgeY ? { marginTop: viewportNudgeY } : undefined}
onClick={(e) => e.stopPropagation()} // Prevent triggering slot clicks onClick={(e) => e.stopPropagation()} // Prevent triggering slot clicks
> >
{/* Search Input Area */} {/* Search Input Area */}
@@ -243,7 +278,7 @@ export function CitySelectorDropdown({
</div> </div>
{/* Results List */} {/* Results List */}
<div className="flex-1 overflow-y-auto max-h-[380px] divide-y divide-slate-100"> <div className="min-h-0 flex-1 overflow-y-auto max-h-[380px] divide-y divide-slate-100">
{filteredRows.length === 0 ? ( {filteredRows.length === 0 ? (
<div className="p-4 text-center text-slate-400 font-medium"> <div className="p-4 text-center text-slate-400 font-medium">
{isEn ? "No matching cities" : "无匹配城市"} {isEn ? "No matching cities" : "无匹配城市"}
@@ -1,12 +1,13 @@
"use client"; "use client";
import clsx from "clsx"; import clsx from "clsx";
import { useEffect, useMemo, useRef, useState } from "react"; import dynamic from "next/dynamic";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ScanOpportunityRow } from "@/lib/dashboard-types"; import type { ScanOpportunityRow } from "@/lib/dashboard-types";
import { useLatestPatch, useSseResyncVersion } from "@/hooks/use-sse-patches"; import { useLatestPatch, useSseResyncVersion } from "@/hooks/use-sse-patches";
import { Panel } from "@/components/dashboard/scan-terminal/Panel"; import { Panel } from "@/components/dashboard/scan-terminal/Panel";
import { ModelCurvesSummary } from "@/components/dashboard/scan-terminal/ModelCurvesSummary"; import { ModelCurvesSummary } from "@/components/dashboard/scan-terminal/ModelCurvesSummary";
import { TemperatureChartCanvas } from "@/components/dashboard/scan-terminal/TemperatureChartCanvas"; import { TemperatureChartCanvasFallback } from "@/components/dashboard/scan-terminal/TemperatureChartCanvasFallback";
import { TemperatureRunwayDetails } from "@/components/dashboard/scan-terminal/TemperatureRunwayDetails"; import { TemperatureRunwayDetails } from "@/components/dashboard/scan-terminal/TemperatureRunwayDetails";
import { TemperatureStatsBars } from "@/components/dashboard/scan-terminal/TemperatureStatsBars"; import { TemperatureStatsBars } from "@/components/dashboard/scan-terminal/TemperatureStatsBars";
import { rowName } from "@/components/dashboard/scan-terminal/utils"; import { rowName } from "@/components/dashboard/scan-terminal/utils";
@@ -56,6 +57,17 @@ const PEAK_GLOW_BADGE_CLASS = {
const PROBABILITY_REFRESH_AFTER_PATCH_MS = 60_000; const PROBABILITY_REFRESH_AFTER_PATCH_MS = 60_000;
const TemperatureChartCanvas = dynamic(
() =>
import("@/components/dashboard/scan-terminal/TemperatureChartCanvas").then(
(mod) => mod.TemperatureChartCanvas,
),
{
ssr: false,
loading: () => <TemperatureChartCanvasFallback />,
},
);
function peakGlowLabel(state: keyof typeof PEAK_GLOW_PANEL_CLASS, isEn: boolean) { function peakGlowLabel(state: keyof typeof PEAK_GLOW_PANEL_CLASS, isEn: boolean) {
if (state === "watch") return isEn ? "Watch" : "关注"; if (state === "watch") return isEn ? "Watch" : "关注";
if (state === "near_peak") return isEn ? "Near peak" : "接近峰值"; if (state === "near_peak") return isEn ? "Near peak" : "接近峰值";
@@ -89,6 +101,26 @@ function formatCityLocalDate(tzOffsetSeconds: number | null | undefined) {
return `${y}-${m}-${d}`; return `${y}-${m}-${d}`;
} }
function getLiveTempFromHourly(data: HourlyForecast) {
return validNumber(data?.airportCurrent?.temp) ?? validNumber(data?.airportPrimary?.temp) ?? null;
}
function getWundergroundDailyHigh(hourly: HourlyForecast) {
return validNumber(hourly?.wundergroundCurrent?.max_so_far) ?? null;
}
function shouldFetchCityDetailForChart({
city,
documentHidden,
isChartVisible,
}: {
city: string;
documentHidden: boolean;
isChartVisible: boolean;
}) {
return Boolean(city) && isChartVisible && !documentHidden;
}
// ── Main component ───────────────────────────────────────────────────── // ── Main component ─────────────────────────────────────────────────────
export function LiveTemperatureThresholdChart({ export function LiveTemperatureThresholdChart({
@@ -125,11 +157,18 @@ export function LiveTemperatureThresholdChart({
const [userToggledKeys, setUserToggledKeys] = useState<Record<string, boolean>>({}); const [userToggledKeys, setUserToggledKeys] = useState<Record<string, boolean>>({});
const [liveTemp, setLiveTemp] = useState<number | null>(null); const [liveTemp, setLiveTemp] = useState<number | null>(null);
const [isHourlyLoading, setIsHourlyLoading] = useState(false); const [isHourlyLoading, setIsHourlyLoading] = useState(false);
const [detailError, setDetailError] = useState<string | null>(null);
const [detailRetryNonce, setDetailRetryNonce] = useState(0);
const [showingStaleDetail, setShowingStaleDetail] = useState(false);
const hasLoadedHourlyDetailRef = useRef(false); const hasLoadedHourlyDetailRef = useRef(false);
const chartVisibilityRef = useRef<HTMLDivElement | null>(null);
const lastPatchAtRef = useRef<number>(Date.now()); const lastPatchAtRef = useRef<number>(Date.now());
const lastAppliedPatchRevisionRef = useRef<number>(0); const lastAppliedPatchRevisionRef = useRef<number>(0);
const lastProbabilityRefreshAtRef = useRef<number>(0); const lastProbabilityRefreshAtRef = useRef<number>(0);
const localDayRolloverFetchDateRef = useRef<string>(""); const localDayRolloverFetchDateRef = useRef<string>("");
const [isChartVisible, setIsChartVisible] = useState(
() => typeof IntersectionObserver === "undefined",
);
const [showRunwayDetails, setShowRunwayDetails] = useState<boolean>(true); const [showRunwayDetails, setShowRunwayDetails] = useState<boolean>(true);
const [refAreaLeft, setRefAreaLeft] = useState<number | null>(null); const [refAreaLeft, setRefAreaLeft] = useState<number | null>(null);
@@ -151,6 +190,9 @@ export function LiveTemperatureThresholdChart({
setHourly(seedHourlyForecastFromRow(row)); setHourly(seedHourlyForecastFromRow(row));
setLiveTemp(null); setLiveTemp(null);
setIsHourlyLoading(Boolean(city)); setIsHourlyLoading(Boolean(city));
setDetailError(null);
setDetailRetryNonce(0);
setShowingStaleDetail(false);
hasLoadedHourlyDetailRef.current = false; hasLoadedHourlyDetailRef.current = false;
lastPatchAtRef.current = Date.now(); lastPatchAtRef.current = Date.now();
lastAppliedPatchRevisionRef.current = 0; lastAppliedPatchRevisionRef.current = 0;
@@ -159,6 +201,23 @@ export function LiveTemperatureThresholdChart({
setCurrentCityLocalDate(formatCityLocalDate(row?.tz_offset_seconds)); setCurrentCityLocalDate(formatCityLocalDate(row?.tz_offset_seconds));
}, [city]); }, [city]);
useEffect(() => {
const node = chartVisibilityRef.current;
if (!node || typeof IntersectionObserver === "undefined") {
setIsChartVisible(true);
return;
}
const observer = new IntersectionObserver(
([entry]) => {
setIsChartVisible(entry.isIntersecting || entry.intersectionRatio > 0);
},
{ root: null, rootMargin: "160px 0px", threshold: 0.01 },
);
observer.observe(node);
return () => observer.disconnect();
}, []);
useEffect(() => { useEffect(() => {
setCurrentCityLocalDate(formatCityLocalDate(row?.tz_offset_seconds)); setCurrentCityLocalDate(formatCityLocalDate(row?.tz_offset_seconds));
const id = setInterval(() => { const id = setInterval(() => {
@@ -173,29 +232,46 @@ export function LiveTemperatureThresholdChart({
return; return;
} }
if (
!shouldFetchCityDetailForChart({
city,
documentHidden:
typeof document !== "undefined" && document.visibilityState === "hidden",
isChartVisible,
})
) {
return;
}
const cacheKey = `${city}:${targetResolution}`; const cacheKey = `${city}:${targetResolution}`;
// Check in-memory cache first
let cached = _hourlyCache.get(cacheKey); let cached = _hourlyCache.get(cacheKey);
if (!cached) { if (!cached || Date.now() - Number(cached.ts || 0) >= HOURLY_CACHE_TTL_MS) {
// Fallback to session cache const sessionEntry = readSessionCache(cacheKey, { allowStale: true });
const sessionEntry = readSessionCache(cacheKey);
if (sessionEntry) { if (sessionEntry) {
cached = sessionEntry; cached = sessionEntry;
_hourlyCache.set(cacheKey, sessionEntry); _hourlyCache.set(cacheKey, sessionEntry);
} }
} }
const cacheAge = cached ? Date.now() - Number(cached.ts || 0) : Number.POSITIVE_INFINITY;
const hasFreshCache = cached && cacheAge >= 0 && cacheAge < HOURLY_CACHE_TTL_MS;
if (cached && Date.now() - cached.ts < HOURLY_CACHE_TTL_MS) { if (cached) {
hasLoadedHourlyDetailRef.current = true; hasLoadedHourlyDetailRef.current = true;
setHourly(cached.data); setHourly(cached.data);
setShowingStaleDetail(!hasFreshCache);
}
if (hasFreshCache) {
setIsHourlyLoading(false); setIsHourlyLoading(false);
setDetailError(null);
return; return;
} }
if (!hasLoadedHourlyDetailRef.current) { if (!cached && !hasLoadedHourlyDetailRef.current) {
setHourly(seedHourlyForecastFromRow(row)); setHourly(seedHourlyForecastFromRow(row));
setShowingStaleDetail(false);
} }
setIsHourlyLoading(!hasLoadedHourlyDetailRef.current); setIsHourlyLoading(true);
let cancelled = false; let cancelled = false;
// Prioritize active slots, stagger/delay background slots to optimize load performance // Prioritize active slots, stagger/delay background slots to optimize load performance
@@ -204,11 +280,19 @@ export function LiveTemperatureThresholdChart({
const timer = setTimeout(() => { const timer = setTimeout(() => {
fetchHourlyForecastForCity(city, { resolution: targetResolution }) fetchHourlyForecastForCity(city, { resolution: targetResolution })
.then((data) => { .then((data) => {
if (cancelled || !data) return; if (cancelled) return;
if (!data) {
setDetailError(isEn ? "Data temporarily unavailable." : "数据暂不可用");
return;
}
hasLoadedHourlyDetailRef.current = true; hasLoadedHourlyDetailRef.current = true;
setHourly(data); setHourly(data);
setDetailError(null);
setShowingStaleDetail(false);
})
.catch(() => {
if (!cancelled) setDetailError(isEn ? "Data temporarily unavailable." : "数据暂不可用");
}) })
.catch(() => {})
.finally(() => { .finally(() => {
if (!cancelled) setIsHourlyLoading(false); if (!cancelled) setIsHourlyLoading(false);
}); });
@@ -218,7 +302,7 @@ export function LiveTemperatureThresholdChart({
cancelled = true; cancelled = true;
clearTimeout(timer); clearTimeout(timer);
}; };
}, [city, row, isActive, slotIndex, targetResolution]); }, [city, row, isActive, slotIndex, targetResolution, isChartVisible, detailRetryNonce, isEn]);
useEffect(() => { useEffect(() => {
if (!latestPatch || latestPatch.revision <= lastAppliedPatchRevisionRef.current) return; if (!latestPatch || latestPatch.revision <= lastAppliedPatchRevisionRef.current) return;
@@ -285,6 +369,8 @@ export function LiveTemperatureThresholdChart({
.then((data) => { .then((data) => {
if (cancelled || !data) return; if (cancelled || !data) return;
hasLoadedHourlyDetailRef.current = true; hasLoadedHourlyDetailRef.current = true;
const temp = getLiveTempFromHourly(data);
if (temp !== null) setLiveTemp(temp);
setHourly(data); setHourly(data);
}) })
.catch(() => {}) .catch(() => {})
@@ -297,15 +383,6 @@ export function LiveTemperatureThresholdChart({
if (typeof document !== "undefined" && document.visibilityState === "hidden") return; if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
if (Date.now() - lastPatchAtRef.current < 2 * 60_000) return; if (Date.now() - lastPatchAtRef.current < 2 * 60_000) return;
fetch(`/api/city/${encodeURIComponent(city)}/summary`)
.then((res) => (res.ok ? res.json() : null))
.then((payload) => {
if (cancelled || !payload) return;
const temp = validNumber(payload?.current?.temp);
if (temp !== null) setLiveTemp(temp);
})
.catch(() => {});
refreshFullDetail(); refreshFullDetail();
}; };
@@ -323,19 +400,12 @@ export function LiveTemperatureThresholdChart({
const refreshForegroundFullDetail = () => { const refreshForegroundFullDetail = () => {
lastPatchAtRef.current = Date.now(); lastPatchAtRef.current = Date.now();
fetch(`/api/city/${encodeURIComponent(city)}/summary`)
.then((res) => (res.ok ? res.json() : null))
.then((payload) => {
if (cancelled || !payload) return;
const temp = validNumber(payload?.current?.temp);
if (temp !== null) setLiveTemp(temp);
})
.catch(() => {});
fetchHourlyForecastForCity(city, { ignoreCache: true, resolution: targetResolution }) fetchHourlyForecastForCity(city, { ignoreCache: true, resolution: targetResolution })
.then((data) => { .then((data) => {
if (cancelled || !data) return; if (cancelled || !data) return;
hasLoadedHourlyDetailRef.current = true; hasLoadedHourlyDetailRef.current = true;
const temp = getLiveTempFromHourly(data);
if (temp !== null) setLiveTemp(temp);
setHourly(data); setHourly(data);
}) })
.catch(() => {}); .catch(() => {});
@@ -444,19 +514,29 @@ export function LiveTemperatureThresholdChart({
}, [row, chartHourly, tzOffset, chartLocalDate]); }, [row, chartHourly, tzOffset, chartLocalDate]);
const runwayPlates = useMemo(() => buildRunwayPlates(chartHourly?.amos, row, settlementObs), [chartHourly?.amos, row, settlementObs]); const runwayPlates = useMemo(() => buildRunwayPlates(chartHourly?.amos, row, settlementObs), [chartHourly?.amos, row, settlementObs]);
const hasRunwayData = runwayPlates.length > 0; const hasRunwaySeries = useMemo(
() =>
series.some(
(item) =>
item.key.startsWith("runway_") &&
item.key !== "runway_max" &&
item.values.some((value) => validNumber(value) !== null),
),
[series],
);
const hasRunwayData = runwayPlates.length > 0 || hasRunwaySeries;
const settlementPlate = useMemo(() => runwayPlates.find((p) => p.isSettlement), [runwayPlates]); const settlementPlate = useMemo(() => runwayPlates.find((p) => p.isSettlement), [runwayPlates]);
const chartSeries = useMemo(() => { const chartSeries = useMemo(() => {
return series; return series;
}, [series]); }, [series]);
const isSeriesVisible = (sKey: string) => { const isSeriesVisible = useCallback((sKey: string) => {
if (userToggledKeys[sKey] !== undefined) { if (userToggledKeys[sKey] !== undefined) {
return userToggledKeys[sKey]; return userToggledKeys[sKey];
} }
return isTemperatureSeriesVisibleByDefault(city, sKey); return isTemperatureSeriesVisibleByDefault(city, sKey);
}; }, [city, userToggledKeys]);
const activeSeries = useMemo(() => { const activeSeries = useMemo(() => {
return getActiveTemperatureSeries( return getActiveTemperatureSeries(
@@ -480,7 +560,7 @@ export function LiveTemperatureThresholdChart({
[row, chartHourly, settlementPlate], [row, chartHourly, settlementPlate],
); );
const displayRunwayTemp = selectDisplayRunwayTemp(liveTemp, currentRunwayTemp, hasRunwayData); const displayRunwayTemp = selectDisplayRunwayTemp(liveTemp, currentRunwayTemp, hasRunwayData);
const wundergroundDailyHigh = validNumber(chartHourly?.airportCurrent?.max_so_far ?? chartHourly?.airportPrimary?.max_so_far) ?? null; const wundergroundDailyHigh = getWundergroundDailyHigh(chartHourly);
const localDateStr = chartLocalDate || new Date().toISOString().slice(0, 10); const localDateStr = chartLocalDate || new Date().toISOString().slice(0, 10);
const modelSources = (row?.model_cluster_sources && Object.keys(row.model_cluster_sources).length > 0) const modelSources = (row?.model_cluster_sources && Object.keys(row.model_cluster_sources).length > 0)
@@ -554,6 +634,68 @@ export function LiveTemperatureThresholdChart({
const subtitle = row ? (isEn ? "Live & Forecast" : "实测与预测") : ""; const subtitle = row ? (isEn ? "Live & Forecast" : "实测与预测") : "";
const handleZoomReset = useCallback(() => {
setZoomRange(null);
}, []);
const handleViewModeChange = useCallback((mode: "auto" | "full") => {
setViewMode(mode);
setZoomRange(null);
}, []);
const handleMouseDown = useCallback((e: any) => {
if (compact || !e) return;
if (typeof e.activeTooltipIndex === "number") {
setRefAreaLeft(e.activeTooltipIndex);
setRefAreaRight(e.activeTooltipIndex);
}
}, [compact]);
const handleMouseMove = useCallback((e: any) => {
if (compact || !e || refAreaLeft === null) return;
if (typeof e.activeTooltipIndex === "number") {
setRefAreaRight(e.activeTooltipIndex);
}
}, [compact, refAreaLeft]);
const handleMouseUp = useCallback(() => {
if (refAreaLeft === null || refAreaRight === null) {
setRefAreaLeft(null);
setRefAreaRight(null);
return;
}
let leftIdx = refAreaLeft;
let rightIdx = refAreaRight;
if (leftIdx > rightIdx) {
[leftIdx, rightIdx] = [rightIdx, leftIdx];
}
if (rightIdx - leftIdx >= 1) {
const originalStartIndex = visibleRange ? visibleRange[0] : 0;
const newStart = originalStartIndex + leftIdx;
const newEnd = originalStartIndex + rightIdx;
setZoomRange([newStart, newEnd]);
}
setRefAreaLeft(null);
setRefAreaRight(null);
}, [refAreaLeft, refAreaRight, visibleRange]);
const handleSeriesToggle = useCallback((seriesKey: string) => {
setUserToggledKeys((prev) => ({
...prev,
[seriesKey]: !isSeriesVisible(seriesKey),
}));
}, [isSeriesVisible]);
const handleRetryDetail = useCallback(() => {
setDetailError(null);
setIsHourlyLoading(true);
setDetailRetryNonce((value) => value + 1);
}, []);
const panelTitle = row ? ( const panelTitle = row ? (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<button <button
@@ -592,7 +734,7 @@ export function LiveTemperatureThresholdChart({
{zoomRange && ( {zoomRange && (
<button <button
type="button" type="button"
onClick={() => setZoomRange(null)} onClick={handleZoomReset}
className="px-2 py-0.5 text-[9px] font-bold rounded bg-slate-100 hover:bg-slate-200 text-slate-700 border border-slate-300 shadow-sm transition-all cursor-pointer" className="px-2 py-0.5 text-[9px] font-bold rounded bg-slate-100 hover:bg-slate-200 text-slate-700 border border-slate-300 shadow-sm transition-all cursor-pointer"
> >
{isEn ? "Reset Zoom" : "重置缩放"} {isEn ? "Reset Zoom" : "重置缩放"}
@@ -603,10 +745,7 @@ export function LiveTemperatureThresholdChart({
<button <button
key={mode} key={mode}
type="button" type="button"
onClick={() => { onClick={() => handleViewModeChange(mode)}
setViewMode(mode);
setZoomRange(null);
}}
className={clsx( className={clsx(
"px-2 py-0.5 text-[9px] font-bold rounded transition-all cursor-pointer", "px-2 py-0.5 text-[9px] font-bold rounded transition-all cursor-pointer",
viewMode === mode viewMode === mode
@@ -656,45 +795,7 @@ export function LiveTemperatureThresholdChart({
</div> </div>
)} )}
</div> </div>
); const handleMouseDown = (e: any) => { );
if (compact || !e) return;
if (typeof e.activeTooltipIndex === "number") {
setRefAreaLeft(e.activeTooltipIndex);
setRefAreaRight(e.activeTooltipIndex);
}
};
const handleMouseMove = (e: any) => {
if (compact || !e || refAreaLeft === null) return;
if (typeof e.activeTooltipIndex === "number") {
setRefAreaRight(e.activeTooltipIndex);
}
};
const handleMouseUp = () => {
if (refAreaLeft === null || refAreaRight === null) {
setRefAreaLeft(null);
setRefAreaRight(null);
return;
}
let leftIdx = refAreaLeft;
let rightIdx = refAreaRight;
if (leftIdx > rightIdx) {
[leftIdx, rightIdx] = [rightIdx, leftIdx];
}
if (rightIdx - leftIdx >= 1) {
const originalStartIndex = visibleRange ? visibleRange[0] : 0;
const newStart = originalStartIndex + leftIdx;
const newEnd = originalStartIndex + rightIdx;
setZoomRange([newStart, newEnd]);
}
setRefAreaLeft(null);
setRefAreaRight(null);
};
return ( return (
<Panel <Panel
@@ -702,7 +803,7 @@ export function LiveTemperatureThresholdChart({
actions={timeframeActions} actions={timeframeActions}
className={PEAK_GLOW_PANEL_CLASS[peakGlow.state]} className={PEAK_GLOW_PANEL_CLASS[peakGlow.state]}
> >
<div className={clsx("flex h-full flex-col", compact ? "min-h-0" : "min-h-[300px]")}> <div ref={chartVisibilityRef} className={clsx("flex h-full flex-col", compact ? "min-h-0" : "min-h-[300px]")}>
<TemperatureStatsBars <TemperatureStatsBars
isEn={isEn} isEn={isEn}
compact={compact} compact={compact}
@@ -749,20 +850,18 @@ export function LiveTemperatureThresholdChart({
hasRunwayData={hasRunwayData} hasRunwayData={hasRunwayData}
showRunwayDetails={showRunwayDetails} showRunwayDetails={showRunwayDetails}
isHourlyLoading={isHourlyLoading} isHourlyLoading={isHourlyLoading}
detailError={detailError}
showingStaleDetail={showingStaleDetail}
refAreaLeft={refAreaLeft} refAreaLeft={refAreaLeft}
refAreaRight={refAreaRight} refAreaRight={refAreaRight}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove} onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp} onMouseUp={handleMouseUp}
onZoomReset={() => setZoomRange(null)} onZoomReset={handleZoomReset}
isSeriesVisible={isSeriesVisible} isSeriesVisible={isSeriesVisible}
onSeriesToggle={(seriesKey) => { onSeriesToggle={handleSeriesToggle}
setUserToggledKeys((prev) => ({
...prev,
[seriesKey]: !isSeriesVisible(seriesKey),
}));
}}
onShowRunwayDetailsChange={setShowRunwayDetails} onShowRunwayDetailsChange={setShowRunwayDetails}
onRetryDetail={handleRetryDetail}
/> />
</div> </div>
</Panel> </Panel>
@@ -785,6 +884,8 @@ export const __getDebPeakWindowRangeForTest = getDebPeakWindowRange;
export const __getLiveObservationLabelsForTest = getLiveObservationLabels; export const __getLiveObservationLabelsForTest = getLiveObservationLabels;
export const __getObservationDisplayMetricsForTest = getObservationDisplayMetrics; export const __getObservationDisplayMetricsForTest = getObservationDisplayMetrics;
export const __getPeakGlowStateForTest = getPeakGlowState; export const __getPeakGlowStateForTest = getPeakGlowState;
export const __getWundergroundDailyHighForTest = getWundergroundDailyHigh;
export const __shouldFetchCityDetailForChartForTest = shouldFetchCityDetailForChart;
export const __shouldPollLiveChartForTest = shouldPollLiveChart; export const __shouldPollLiveChartForTest = shouldPollLiveChart;
export const __mergePatchIntoHourlyForTest = mergePatchIntoHourly; export const __mergePatchIntoHourlyForTest = mergePatchIntoHourly;
export const __selectDisplayRunwayTempForTest = selectDisplayRunwayTemp; export const __selectDisplayRunwayTempForTest = selectDisplayRunwayTemp;
@@ -5,9 +5,9 @@ import { LockKeyhole, CreditCard, LogIn } from "lucide-react";
const ACCESS_TERM = { const ACCESS_TERM = {
signInToContinue: { en: "Sign in to continue", zh: "请先登录" }, signInToContinue: { en: "Sign in to continue", zh: "请先登录" },
proAccessRequired: { en: "Pro Access Required", zh: "需要付费订阅" }, proAccessRequired: { en: "Pro subscription required", zh: "需要开通 Pro" },
month: { en: "/ month", zh: "/ " }, month: { en: "/ 30 days", zh: "/ 30 天" },
subscribeNow: { en: "Subscribe Now — $10/mo", zh: "立即订阅 — $10/月" }, subscribeNow: { en: "View Pro plans", zh: "查看订阅方案" },
backToProduct: { en: "Back to product overview", zh: "返回产品介绍页" }, backToProduct: { en: "Back to product overview", zh: "返回产品介绍页" },
} as const; } as const;
@@ -19,16 +19,16 @@ function t(key: keyof typeof ACCESS_TERM, isEn: boolean) {
function SubscriptionGate({ isEn }: { isEn: boolean }) { function SubscriptionGate({ isEn }: { isEn: boolean }) {
const features = isEn const features = isEn
? [ ? [
"Real-time METAR observations across 500+ stations", "Real-time station, METAR, and runway signals",
"DEB forecast blends with 0240h horizon", "DEB forecast curves and model comparison",
"AI decision cards with Poly-score ranking", "Full terminal grid with high-frequency refresh",
"Historical backtesting & weather signals", "API and paid Telegram group access on paid Pro",
] ]
: [ : [
"500+ 气象站实时 METAR 实况", "实时气象站、METAR 与跑道信号",
"DEB 智能融合预测(0240 小时)", "DEB 预测曲线与模型对比",
"AI 决策卡片 + Poly-score 排名", "完整终端网格与高频刷新",
"历史回测与天气信号", "付费 Pro 可进入 API 与付费 Telegram 群",
]; ];
return ( return (
@@ -57,7 +57,7 @@ function SubscriptionGate({ isEn }: { isEn: boolean }) {
<div className="p-8"> <div className="p-8">
<div className="mb-6 flex items-baseline gap-1"> <div className="mb-6 flex items-baseline gap-1">
<span className="text-4xl font-black text-slate-900">$10</span> <span className="text-4xl font-black text-slate-900">29.9 USDC</span>
<span className="text-base text-slate-500"> <span className="text-base text-slate-500">
{t("month", isEn)} {t("month", isEn)}
</span> </span>
@@ -1,7 +1,7 @@
"use client"; "use client";
import clsx from "clsx"; import clsx from "clsx";
import { useEffect, useRef, useState } from "react"; import { memo, useEffect, useRef, useState } from "react";
import { import {
Area, Area,
CartesianGrid, CartesianGrid,
@@ -15,7 +15,11 @@ import {
} from "recharts"; } from "recharts";
import type { ScanOpportunityRow } from "@/lib/dashboard-types"; import type { ScanOpportunityRow } from "@/lib/dashboard-types";
import { TemperatureTooltipContent } from "@/components/dashboard/scan-terminal/TemperatureTooltipContent"; import { TemperatureTooltipContent } from "@/components/dashboard/scan-terminal/TemperatureTooltipContent";
import type { EvidenceSeries, ProbabilityOverlay } from "@/components/dashboard/scan-terminal/temperature-chart-logic"; import {
getTemperatureSeriesForRunwayDetailsMode,
type EvidenceSeries,
type ProbabilityOverlay,
} from "@/components/dashboard/scan-terminal/temperature-chart-logic";
type CityThreshold = { type CityThreshold = {
threshold: number; threshold: number;
@@ -24,7 +28,74 @@ type CityThreshold = {
kind: "gte" | "lte"; kind: "gte" | "lte";
}; };
export function TemperatureChartCanvas({ function isFiniteChartValue(value: unknown) {
return typeof value === "number" && Number.isFinite(value);
}
function hasDrawableTemperatureChartContent({
activeSeries,
probabilityOverlay,
zoomedData,
}: {
activeSeries: EvidenceSeries[];
probabilityOverlay: ProbabilityOverlay | null;
zoomedData: Array<Record<string, any>>;
}) {
if (probabilityOverlay?.muLine || probabilityOverlay?.bands.length) return true;
return activeSeries.some((series) =>
zoomedData.some((point, index) => {
const value = point?.[series.key] ?? series.values[index];
return isFiniteChartValue(value);
}),
);
}
function shouldKeepTemperatureChartLoading({
row,
isHourlyLoading,
activeSeries,
probabilityOverlay,
zoomedData,
}: {
row: ScanOpportunityRow | null;
isHourlyLoading: boolean;
activeSeries: EvidenceSeries[];
probabilityOverlay: ProbabilityOverlay | null;
zoomedData: Array<Record<string, any>>;
}) {
if (!row?.city) return false;
if (!isHourlyLoading) return false;
return !hasDrawableTemperatureChartContent({ activeSeries, probabilityOverlay, zoomedData });
}
function TemperatureChartSkeleton({ compact }: { compact: boolean }) {
const horizontalLines = compact ? 5 : 7;
const verticalLines = compact ? 5 : 8;
return (
<div className="absolute inset-0 overflow-hidden bg-white">
<div className="absolute inset-x-3 bottom-7 top-4 rounded-sm border border-slate-100">
{Array.from({ length: horizontalLines }).map((_, index) => (
<span
key={`h-${index}`}
className="absolute left-0 right-0 border-t border-dashed border-sky-100"
style={{ top: `${(index / Math.max(1, horizontalLines - 1)) * 100}%` }}
/>
))}
{Array.from({ length: verticalLines }).map((_, index) => (
<span
key={`v-${index}`}
className="absolute bottom-0 top-0 border-l border-dashed border-sky-100"
style={{ left: `${(index / Math.max(1, verticalLines - 1)) * 100}%` }}
/>
))}
<div className="absolute inset-x-10 top-1/3 h-10 rounded bg-slate-100/60" />
</div>
</div>
);
}
function TemperatureChartCanvasComponent({
isEn, isEn,
compact, compact,
timeframe, timeframe,
@@ -39,6 +110,8 @@ export function TemperatureChartCanvas({
hasRunwayData, hasRunwayData,
showRunwayDetails, showRunwayDetails,
isHourlyLoading, isHourlyLoading,
detailError,
showingStaleDetail,
refAreaLeft, refAreaLeft,
refAreaRight, refAreaRight,
onMouseDown, onMouseDown,
@@ -48,6 +121,7 @@ export function TemperatureChartCanvas({
isSeriesVisible, isSeriesVisible,
onSeriesToggle, onSeriesToggle,
onShowRunwayDetailsChange, onShowRunwayDetailsChange,
onRetryDetail,
}: { }: {
isEn: boolean; isEn: boolean;
compact: boolean; compact: boolean;
@@ -63,6 +137,8 @@ export function TemperatureChartCanvas({
hasRunwayData: boolean; hasRunwayData: boolean;
showRunwayDetails: boolean; showRunwayDetails: boolean;
isHourlyLoading: boolean; isHourlyLoading: boolean;
detailError?: string | null;
showingStaleDetail?: boolean;
refAreaLeft: number | null; refAreaLeft: number | null;
refAreaRight: number | null; refAreaRight: number | null;
onMouseDown: (event: any) => void; onMouseDown: (event: any) => void;
@@ -72,6 +148,7 @@ export function TemperatureChartCanvas({
isSeriesVisible: (seriesKey: string) => boolean; isSeriesVisible: (seriesKey: string) => boolean;
onSeriesToggle: (seriesKey: string) => void; onSeriesToggle: (seriesKey: string) => void;
onShowRunwayDetailsChange: (value: boolean) => void; onShowRunwayDetailsChange: (value: boolean) => void;
onRetryDetail?: () => void;
}) { }) {
const chartHostRef = useRef<HTMLDivElement | null>(null); const chartHostRef = useRef<HTMLDivElement | null>(null);
const [chartSize, setChartSize] = useState({ width: 0, height: 0 }); const [chartSize, setChartSize] = useState({ width: 0, height: 0 });
@@ -120,23 +197,43 @@ export function TemperatureChartCanvas({
const individualRunwaySeriesCount = chartSeries.filter( const individualRunwaySeriesCount = chartSeries.filter(
(series) => series.key.startsWith("runway_") && series.key !== "runway_max", (series) => series.key.startsWith("runway_") && series.key !== "runway_max",
).length; ).length;
const collapsedRunwaySeries = getTemperatureSeriesForRunwayDetailsMode(
row?.city || "",
chartSeries,
false,
);
const canToggleRunwayDetails = const canToggleRunwayDetails =
hasRunwayData && hasRunwayData &&
individualRunwaySeriesCount > 1 && individualRunwaySeriesCount > 1 &&
chartSeries.some((series) => series.key === "runway_max"); collapsedRunwaySeries.length < chartSeries.length;
const hasDrawableChartContent = hasDrawableTemperatureChartContent({
activeSeries,
probabilityOverlay,
zoomedData,
});
const shouldShowChartLoading = shouldKeepTemperatureChartLoading({
row,
isHourlyLoading,
activeSeries,
probabilityOverlay,
zoomedData,
});
const shouldRenderChart = canRenderChart && hasDrawableChartContent;
const shouldShowEmptyState = Boolean(row?.city) && !isHourlyLoading && !hasDrawableChartContent;
const shouldShowBackgroundRefresh = isHourlyLoading && hasDrawableChartContent;
const shouldShowUnavailableState = Boolean(row?.city) && Boolean(detailError) && !isHourlyLoading && !hasDrawableChartContent;
const shouldShowBackgroundError =
Boolean(row?.city) && Boolean(detailError) && !isHourlyLoading && hasDrawableChartContent;
return ( return (
<div className={clsx("relative flex flex-1 flex-col p-2", compact ? "min-h-[120px]" : "min-h-[240px]")}> <div className={clsx("relative flex flex-1 flex-col p-2", compact ? "min-h-[120px]" : "min-h-[240px]")}>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 px-3 py-1.5 text-[11px] border-b border-[#e2e8f0] bg-white"> <div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 px-3 py-1.5 text-[11px] border-b border-[#e2e8f0] bg-white">
{chartSeries.length > 1 && {chartSeries.length > 1 &&
chartSeries getTemperatureSeriesForRunwayDetailsMode(
.filter((s) => { row?.city || "",
const isIndividualRunway = s.key.startsWith("runway_") && s.key !== "runway_max"; chartSeries,
if (showRunwayDetails) { showRunwayDetails,
return s.key !== "runway_max"; )
}
return !isIndividualRunway;
})
.map((s) => ( .map((s) => (
<button <button
key={s.key} key={s.key}
@@ -189,7 +286,8 @@ export function TemperatureChartCanvas({
)} )}
</div> </div>
<div ref={chartHostRef} className={clsx("relative flex-1", compact ? "min-h-[120px]" : "min-h-[220px]")}> <div ref={chartHostRef} className={clsx("relative flex-1", compact ? "min-h-[120px]" : "min-h-[220px]")}>
{canRenderChart && ( {!shouldRenderChart && <TemperatureChartSkeleton compact={compact} />}
{shouldRenderChart && (
<ReComposedChart <ReComposedChart
width={chartWidth} width={chartWidth}
height={chartHeight} height={chartHeight}
@@ -331,8 +429,47 @@ export function TemperatureChartCanvas({
))} ))}
</ReComposedChart> </ReComposedChart>
)} )}
{shouldShowUnavailableState && (
<div className="absolute inset-0 z-10 grid place-items-center px-4 text-center">
<div className="max-w-[260px] rounded border border-amber-200 bg-amber-50/95 px-3 py-2 text-[11px] font-semibold text-amber-700 shadow-sm">
<div>{isEn ? "Data temporarily unavailable" : "数据暂不可用"}</div>
<button
type="button"
onClick={onRetryDetail}
className="mt-2 rounded border border-amber-300 bg-white px-2 py-1 text-[10px] font-bold text-amber-700 shadow-sm transition-colors hover:bg-amber-100"
>
{isEn ? "Retry" : "重试"}
</button>
</div>
</div>
)}
{shouldShowEmptyState && !shouldShowUnavailableState && (
<div className="pointer-events-none absolute inset-0 grid place-items-center px-4 text-center">
<div className="rounded border border-slate-200 bg-white/90 px-3 py-2 text-[11px] font-semibold text-slate-500 shadow-sm">
{isEn ? "No drawable chart data yet" : "暂无可绘制图表数据"}
</div>
</div>
)}
</div> </div>
{isHourlyLoading && ( {shouldShowBackgroundError && (
<div className="absolute right-3 top-12 z-10 inline-flex items-center gap-1.5 rounded border border-amber-200 bg-amber-50/95 px-2 py-1 text-[10px] font-semibold text-amber-700 shadow-sm">
<span>{showingStaleDetail ? (isEn ? "Showing cache" : "显示缓存") : (isEn ? "Update failed" : "更新失败")}</span>
<button
type="button"
onClick={onRetryDetail}
className="rounded border border-amber-300 bg-white px-1.5 py-0.5 font-bold transition-colors hover:bg-amber-100"
>
{isEn ? "Retry" : "重试"}
</button>
</div>
)}
{shouldShowBackgroundRefresh && (
<div className="pointer-events-none absolute right-3 top-12 z-10 inline-flex items-center gap-1.5 rounded border border-slate-200 bg-white/80 px-2 py-1 text-[10px] font-semibold text-slate-500 shadow-sm backdrop-blur-[1px]">
<span className="h-2.5 w-2.5 animate-spin rounded-full border-2 border-slate-200 border-t-blue-500" />
<span>{isEn ? "Updating" : "更新中"}</span>
</div>
)}
{shouldShowChartLoading && (
<div className="pointer-events-none absolute inset-2 z-10 grid place-items-center bg-white/65 backdrop-blur-[1px]"> <div className="pointer-events-none absolute inset-2 z-10 grid place-items-center bg-white/65 backdrop-blur-[1px]">
<div className="flex items-center gap-2 rounded border border-slate-200 bg-white px-3 py-2 text-[11px] font-semibold text-slate-600 shadow-sm"> <div className="flex items-center gap-2 rounded border border-slate-200 bg-white px-3 py-2 text-[11px] font-semibold text-slate-600 shadow-sm">
<span className="h-3 w-3 animate-spin rounded-full border-2 border-slate-300 border-t-blue-500" /> <span className="h-3 w-3 animate-spin rounded-full border-2 border-slate-300 border-t-blue-500" />
@@ -343,3 +480,6 @@ export function TemperatureChartCanvas({
</div> </div>
); );
} }
export const TemperatureChartCanvas = memo(TemperatureChartCanvasComponent);
export const __shouldKeepTemperatureChartLoadingForTest = shouldKeepTemperatureChartLoading;
@@ -0,0 +1,35 @@
export function TemperatureChartCanvasFallback({ compact }: { compact?: boolean }) {
const horizontalLines = compact ? 5 : 7;
const verticalLines = compact ? 5 : 8;
const minChartHeight = compact === false ? 220 : 120;
return (
<div
className="relative flex-1 overflow-hidden rounded-sm border border-slate-100 bg-white"
style={{ minHeight: minChartHeight }}
>
<div className="absolute inset-x-3 bottom-7 top-4 rounded-sm border border-slate-100">
{Array.from({ length: horizontalLines }).map((_, index) => (
<span
key={`h-${index}`}
className="absolute left-0 right-0 border-t border-dashed border-sky-100"
style={{ top: `${(index / Math.max(1, horizontalLines - 1)) * 100}%` }}
/>
))}
{Array.from({ length: verticalLines }).map((_, index) => (
<span
key={`v-${index}`}
className="absolute bottom-0 top-0 border-l border-dashed border-sky-100"
style={{ left: `${(index / Math.max(1, verticalLines - 1)) * 100}%` }}
/>
))}
</div>
<div className="absolute inset-0 grid place-items-center">
<div className="flex items-center gap-2 rounded border border-slate-200 bg-white px-3 py-2 text-xs font-semibold text-slate-500 shadow-sm">
<span className="h-3 w-3 animate-spin rounded-full border-2 border-blue-200 border-t-blue-500" />
</div>
</div>
</div>
);
}
@@ -3,6 +3,68 @@
import clsx from "clsx"; import clsx from "clsx";
import { temp } from "@/components/dashboard/scan-terminal/utils"; import { temp } from "@/components/dashboard/scan-terminal/utils";
const OBSERVATION_LABEL_EN: Record<string, string> = {
"参考站点 (1分钟)": "Reference Station (1m)",
"天文台实测 (10分钟)": "HKO Live (10m)",
"机场气象站 (10分钟)": "Airport Weather Station (10m)",
"航站楼温度": "Terminal Temperature",
"官方机场观测 (15分钟)": "Official Airport Obs (15m)",
"CWA (10分钟)": "CWA (10m)",
"气象站实测": "Weather Station Live",
"跑道实测 (1分钟)": "Runway Live (1m)",
"机场报文": "Airport METAR",
"METAR 结算 (30分钟)": "METAR Settlement (30m)",
};
const HIGH_LABEL_EN: Record<string, string> = {
"参考站点": "Reference Station",
"天文台实测": "HKO Live",
"天文台": "HKO",
"机场气象站": "Airport Weather Station",
"航站楼": "Terminal",
"官方机场观测": "Official Airport Obs",
"气象站": "Weather Station",
"跑道实测": "Runway",
"机场报文": "Airport METAR",
"METAR 官方": "Official METAR",
};
function observationLabel(label: string, isEn: boolean) {
return isEn ? (OBSERVATION_LABEL_EN[label] || label) : label;
}
function highLabel(label: string, isEn: boolean) {
return isEn ? (HIGH_LABEL_EN[label] || label) : label;
}
function buildStatsLabels({
isEn,
isShenzhen,
runwayHeaderLabel,
metarHeaderLabel,
runwayHighLabel,
metarHighLabel,
}: {
isEn: boolean;
isShenzhen: boolean;
runwayHeaderLabel: string;
metarHeaderLabel: string;
runwayHighLabel: string;
metarHighLabel: string;
}) {
const primary = observationLabel(runwayHeaderLabel, isEn);
const secondaryObservation = observationLabel(metarHeaderLabel, isEn);
const dailyHigh = isEn ? "Daily High" : "当日最高";
return {
primary,
compactSecondary: isShenzhen ? dailyHigh : secondaryObservation,
expandedSecondary: `${secondaryObservation} · ${dailyHigh}`,
dailyPeakTitle: isEn ? "Daily Peak" : "当日最高气温",
runwayHigh: highLabel(runwayHighLabel, isEn),
metarHigh: highLabel(metarHighLabel, isEn),
};
}
export function TemperatureStatsBars({ export function TemperatureStatsBars({
isEn, isEn,
compact, compact,
@@ -46,18 +108,27 @@ export function TemperatureStatsBars({
spreadLabelEn: string; spreadLabelEn: string;
formattedUpdateTime: string; formattedUpdateTime: string;
}) { }) {
const labels = buildStatsLabels({
isEn,
isShenzhen,
runwayHeaderLabel,
metarHeaderLabel,
runwayHighLabel,
metarHighLabel,
});
if (compact) { if (compact) {
return ( return (
<div className="shrink-0 border-b border-slate-200 bg-white px-3 py-1.5 flex items-center justify-between"> <div className="shrink-0 border-b border-slate-200 bg-white px-3 py-1.5 flex items-center justify-between">
{timeframe === "1D" ? ( {timeframe === "1D" ? (
<div className="flex items-center gap-4 text-[11px]"> <div className="flex items-center gap-4 text-[11px]">
<span className="font-semibold text-slate-500"> <span className="font-semibold text-slate-500">
{isEn ? "Runway" : runwayHeaderLabel}:{" "} {labels.primary}:{" "}
<strong className="text-[#009688] font-mono">{temp(displayRunwayTemp, tempSymbol)}</strong> <strong className="text-[#009688] font-mono">{temp(displayRunwayTemp, tempSymbol)}</strong>
</span> </span>
<span className="text-slate-300">|</span> <span className="text-slate-300">|</span>
<span className="font-semibold text-slate-500"> <span className="font-semibold text-slate-500">
{isEn ? "METAR" : (isShenzhen ? "当日最高" : metarHeaderLabel)}:{" "} {labels.compactSecondary}:{" "}
<strong className="text-blue-600 font-mono">{temp(observedHighMetar, tempSymbol)}</strong> <strong className="text-blue-600 font-mono">{temp(observedHighMetar, tempSymbol)}</strong>
</span> </span>
</div> </div>
@@ -93,7 +164,7 @@ export function TemperatureStatsBars({
<div className="flex items-center gap-12"> <div className="flex items-center gap-12">
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-[11px] font-semibold text-slate-500 uppercase tracking-wider"> <span className="text-[11px] font-semibold text-slate-500 uppercase tracking-wider">
{isEn ? "Runway Live (1m)" : `${runwayHeaderLabel}`} {labels.primary}
</span> </span>
<span className="text-2xl font-bold font-mono text-[#009688] mt-1"> <span className="text-2xl font-bold font-mono text-[#009688] mt-1">
{temp(displayRunwayTemp, tempSymbol)} {temp(displayRunwayTemp, tempSymbol)}
@@ -101,7 +172,7 @@ export function TemperatureStatsBars({
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-[11px] font-semibold text-slate-500 uppercase tracking-wider"> <span className="text-[11px] font-semibold text-slate-500 uppercase tracking-wider">
{isEn ? "METAR Settlement · Daily High" : `${metarHeaderLabel} · 当日最高`} {labels.expandedSecondary}
</span> </span>
<span className="text-2xl font-bold font-mono text-blue-600 mt-1"> <span className="text-2xl font-bold font-mono text-blue-600 mt-1">
{temp(observedHighMetar, tempSymbol)} {temp(observedHighMetar, tempSymbol)}
@@ -131,12 +202,12 @@ export function TemperatureStatsBars({
<div className="hidden sm:flex flex-col items-end text-right"> <div className="hidden sm:flex flex-col items-end text-right">
<span className="text-[10px] text-slate-400 uppercase font-semibold"> <span className="text-[10px] text-slate-400 uppercase font-semibold">
{isEn ? "Daily Peak" : "当日最高气温"} {labels.dailyPeakTitle}
</span> </span>
<div className="mt-1 flex items-center gap-2 text-xs font-mono text-slate-600"> <div className="mt-1 flex items-center gap-2 text-xs font-mono text-slate-600">
<span>{isEn ? "Runway" : runwayHighLabel}: <strong className="text-[#009688]">{temp(observedHighRunway, tempSymbol)}</strong></span> <span>{labels.runwayHigh}: <strong className="text-[#009688]">{temp(observedHighRunway, tempSymbol)}</strong></span>
<span>|</span> <span>|</span>
<span>{isEn ? "METAR" : metarHighLabel}: <strong className="text-blue-600">{temp(observedHighMetar, tempSymbol)}</strong></span> <span>{labels.metarHigh}: <strong className="text-blue-600">{temp(observedHighMetar, tempSymbol)}</strong></span>
{wundergroundDailyHigh !== null && ( {wundergroundDailyHigh !== null && (
<> <>
<span>|</span> <span>|</span>
@@ -187,3 +258,5 @@ export function TemperatureStatsBars({
</div> </div>
); );
} }
export const __buildTemperatureStatsLabelsForTest = buildStatsLabels;
@@ -0,0 +1,277 @@
"use client";
import {
BookOpenCheck,
ChartSpline,
CheckCircle2,
CircleHelp,
Eye,
Gauge,
Layers3,
MapPinned,
Monitor,
MousePointer2,
Plane,
RadioTower,
SlidersHorizontal,
Sparkles,
Users,
} from "lucide-react";
type GuideCopy = {
title: string;
body: string;
};
const quickStart: Record<"zh" | "en", GuideCopy[]> = {
zh: [
{
title: "先选城市",
body: "左上角城市名用于切换当前图表,网格布局可同时观察多个城市。",
},
{
title: "先看实测",
body: "青绿色粗线是当前更重要的实况锚点,结算跑道或官方站优先于普通机场报文。",
},
{
title: "再看 DEB",
body: "橙色 DEB Forecast 是融合模型和日内修正后的路径,用来判断后续升温或降温空间。",
},
{
title: "最后看概率",
body: "紫色区域和虚线表示高概率温度带,适合判断当前实测是否偏离主预期。",
},
],
en: [
{
title: "Pick cities first",
body: "Use the city name in each chart header to switch slots and monitor multiple cities in the grid.",
},
{
title: "Read live evidence",
body: "The teal anchor is the key live observation layer; settlement runway or official station data takes priority.",
},
{
title: "Compare DEB",
body: "The orange DEB Forecast blends model context with intraday correction to frame the remaining move.",
},
{
title: "Check probability",
body: "The purple band and dotted line show the high-probability temperature zone for fast deviation checks.",
},
],
};
const legendItems: Record<"zh" | "en", GuideCopy[]> = {
zh: [
{ title: "实测 / 结算线", body: "优先展示结算跑道、官方站或城市核心实况,用于判断已兑现温度。" },
{ title: "DEB Forecast", body: "橙色预测路径,重点看它和实测线在峰值窗口前后的分歧。" },
{ title: "高概率带", body: "紫色带表示当前概率分布的主要落点,虚线是概率均值附近。" },
{ title: "机场报文", body: "METAR / MGM 作为机场站参考,默认只在适合的城市自动显示。" },
{ title: "模型线", body: "ECMWF、GFS、ICON、GEM 等提供背景,默认弱化为辅助判断。" },
{ title: "跑道明细", body: "打开后可查看各跑道传感器,关闭后仍保留结算跑道温度。" },
],
en: [
{ title: "Live / settlement", body: "Settlement runway, official station, or core live observation used as the realized anchor." },
{ title: "DEB Forecast", body: "Orange forecast path; focus on its gap versus live observations near the peak window." },
{ title: "Probability band", body: "Purple band marks the main probability zone, with the dotted line near the probability mean." },
{ title: "Airport reports", body: "METAR / MGM are airport references and are auto-shown only where they are useful by default." },
{ title: "Model lines", body: "ECMWF, GFS, ICON, GEM, and related model layers provide background context." },
{ title: "Runway details", body: "When disabled, the chart still keeps the settlement runway temperature visible." },
],
};
const operations: Record<"zh" | "en", GuideCopy[]> = {
zh: [
{ title: "布局", body: "右上角可切换 1x1 到 3x3,适合从单城复盘切到多城巡检。" },
{ title: "换城市", body: "点击图表标题栏城市名,在当前卡片内搜索并替换城市。" },
{ title: "高温模式", body: "卡片右上角高温按钮用于聚焦最高温兑现窗口。" },
{ title: "曲线显隐", body: "图例可自定义显示机场报文、模型线和跑道明细。" },
],
en: [
{ title: "Layout", body: "Switch from 1x1 to 3x3 in the top-right control for review or multi-city scanning." },
{ title: "Change city", body: "Click the city name in a chart header to search and replace that slot." },
{ title: "High mode", body: "Use the High button to focus the chart on the high-temperature payoff window." },
{ title: "Layer toggles", body: "Use the legend to customize airport reports, model lines, and runway details." },
],
};
function GuideCard({
icon: Icon,
title,
body,
}: {
icon: typeof BookOpenCheck;
title: string;
body: string;
}) {
return (
<div className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
<div className="mb-3 flex h-9 w-9 items-center justify-center rounded-md border border-blue-100 bg-blue-50 text-blue-600">
<Icon size={18} />
</div>
<h3 className="text-sm font-black text-slate-900">{title}</h3>
<p className="mt-2 text-xs leading-5 text-slate-500">{body}</p>
</div>
);
}
function SectionTitle({
icon: Icon,
title,
eyebrow,
}: {
icon: typeof BookOpenCheck;
title: string;
eyebrow: string;
}) {
return (
<div className="mb-3 flex items-center gap-2">
<div className="grid h-7 w-7 place-items-center rounded-md border border-slate-200 bg-white text-blue-600">
<Icon size={15} />
</div>
<div>
<div className="text-[10px] font-black uppercase tracking-wide text-slate-400">
{eyebrow}
</div>
<h2 className="text-sm font-black text-slate-900">{title}</h2>
</div>
</div>
);
}
export function UsageGuideDashboard({ isEn }: { isEn: boolean }) {
const locale = isEn ? "en" : "zh";
const quickIcons = [MapPinned, RadioTower, ChartSpline, Gauge];
const legendIcons = [RadioTower, ChartSpline, Sparkles, Plane, Layers3, SlidersHorizontal];
return (
<div className="h-full overflow-auto bg-[#f5f7fa]">
<div className="mx-auto max-w-6xl p-4">
<div className="mb-4 rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
<div className="mb-3 inline-flex items-center gap-2 rounded-md border border-blue-100 bg-blue-50 px-2.5 py-1 text-[11px] font-black text-blue-700">
<BookOpenCheck size={13} />
{isEn ? "Terminal Guide" : "决策台使用指南"}
</div>
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div>
<h1 className="text-xl font-black tracking-tight text-slate-950">
{isEn ? "Read the terminal in four passes" : "按四步阅读天气决策台"}
</h1>
<p className="mt-2 max-w-3xl text-sm leading-6 text-slate-500">
{isEn
? "Start from live evidence, compare DEB, then use probability and layer toggles to confirm whether the city is moving away from the main path."
: "先看实况锚点,再对照 DEB 路径,最后用概率带和图层显隐确认城市是否偏离主预期。"}
</p>
</div>
<div className="grid grid-cols-3 gap-2 text-center text-[11px] font-bold text-slate-600">
<div className="rounded-md border border-slate-200 bg-slate-50 px-3 py-2">
<div className="font-mono text-sm font-black text-slate-950">1-9</div>
{isEn ? "Charts" : "图表位"}
</div>
<div className="rounded-md border border-slate-200 bg-slate-50 px-3 py-2">
<div className="font-mono text-sm font-black text-slate-950">Live</div>
{isEn ? "Anchor" : "锚点"}
</div>
<div className="rounded-md border border-slate-200 bg-slate-50 px-3 py-2">
<div className="font-mono text-sm font-black text-slate-950">DEB</div>
{isEn ? "Path" : "路径"}
</div>
</div>
</div>
</div>
<SectionTitle
icon={Monitor}
eyebrow={isEn ? "Quick start" : "快速开始"}
title={isEn ? "The default reading order" : "默认阅读顺序"}
/>
<div className="mb-5 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
{quickStart[locale].map((item, index) => (
<GuideCard
key={item.title}
icon={quickIcons[index]}
title={`${index + 1}. ${item.title}`}
body={item.body}
/>
))}
</div>
<SectionTitle
icon={Eye}
eyebrow={isEn ? "Legend" : "图表图例"}
title={isEn ? "What each layer means" : "每条曲线代表什么"}
/>
<div className="mb-5 grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{legendItems[locale].map((item, index) => (
<GuideCard
key={item.title}
icon={legendIcons[index]}
title={item.title}
body={item.body}
/>
))}
</div>
<div className="mb-5 grid gap-4 lg:grid-cols-[1.1fr_0.9fr]">
<div>
<SectionTitle
icon={MousePointer2}
eyebrow={isEn ? "Controls" : "常用操作"}
title={isEn ? "Daily workflow controls" : "日常巡检操作"}
/>
<div className="grid gap-3 md:grid-cols-2">
{operations[locale].map((item) => (
<div key={item.title} className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
<div className="mb-2 flex items-center gap-2">
<CheckCircle2 size={15} className="text-emerald-600" />
<h3 className="text-sm font-black text-slate-900">{item.title}</h3>
</div>
<p className="text-xs leading-5 text-slate-500">{item.body}</p>
</div>
))}
</div>
</div>
<div>
<SectionTitle
icon={CircleHelp}
eyebrow={isEn ? "Rules" : "默认规则"}
title={isEn ? "Visibility and access rules" : "图层和权益规则"}
/>
<div className="space-y-3">
{[
isEn
? "For cities other than Hong Kong and Shenzhen, airport METAR temperature is hidden by default. Users can still enable it manually."
: "除香港和深圳外,机场 METAR 温度默认不参与图表展示;用户仍可手动打开。",
isEn
? "Turkey airport-station curves use MGM data when available, so Ankara and Istanbul should be read from the MGM airport anchor."
: "土耳其机场站优先使用 MGM 数据,安卡拉和伊斯坦布尔应以 MGM 机场锚点阅读。",
isEn
? "The 3-day trial has the same core terminal access as Pro, except the paid Telegram group link is hidden."
: "3 天试用拥有和 Pro 一致的核心决策台权益,仅不显示付费 Telegram 群链接。",
].map((text) => (
<div key={text} className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs font-semibold leading-5 text-amber-900">
{text}
</div>
))}
<div className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
<div className="mb-2 flex items-center gap-2">
<Users size={15} className="text-blue-600" />
<h3 className="text-sm font-black text-slate-900">
{isEn ? "Pro membership" : "Pro 会员"}
</h3>
</div>
<p className="text-xs leading-5 text-slate-500">
{isEn
? "Monthly and quarterly Pro unlock the full paid workflow, including the Telegram group entry after subscription activation."
: "月付和季度 Pro 开通后解锁完整付费工作流,并在账户页显示 Telegram 群入口。"}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -15,6 +15,11 @@ export function runTests() {
path.join(process.cwd(), "components", "dashboard", "ScanTerminalDashboard.tsx"), path.join(process.cwd(), "components", "dashboard", "ScanTerminalDashboard.tsx"),
"utf8", "utf8",
); );
const citiesRouteSource = fs.readFileSync(
path.join(process.cwd(), "app", "api", "cities", "route.ts"),
"utf8",
);
const staticCitiesPath = path.join(process.cwd(), "lib", "static-cities.ts");
const cities: CityListItem[] = [ const cities: CityListItem[] = [
{ {
@@ -116,8 +121,18 @@ export function runTests() {
assert( assert(
dashboardSource.includes("cityListItemsToScanRows") && dashboardSource.includes("cityListItemsToScanRows") &&
dashboardSource.includes("STATIC_CITY_LIST") &&
dashboardSource.includes("useState<ScanOpportunityRow[]>(() =>") &&
dashboardSource.includes("/api/cities") && dashboardSource.includes("/api/cities") &&
dashboardSource.includes("cityFallbackRows"), dashboardSource.includes("cityFallbackRows"),
"terminal dashboard should use /api/cities fallback rows when scan terminal rows are not ready", "terminal dashboard should seed fallback rows from the static city snapshot before refreshing /api/cities",
);
assert(fs.existsSync(staticCitiesPath), "/api/cities route should have a static city snapshot fallback");
assert(
citiesRouteSource.includes("STATIC_CITY_LIST") &&
citiesRouteSource.includes("AbortController") &&
citiesRouteSource.includes("x-polyweather-cities-source") &&
citiesRouteSource.includes("static-fallback"),
"/api/cities should return a quick static fallback when the backend city registry is cold or unavailable",
); );
} }
@@ -0,0 +1,37 @@
import fs from "node:fs";
import path from "node:path";
function assert(condition: unknown, message: string) {
if (!condition) throw new Error(message);
}
function readRepoFile(...parts: string[]) {
return fs.readFileSync(path.resolve(process.cwd(), "..", ...parts), "utf8");
}
export function runTests() {
const workflow = readRepoFile(".github", "workflows", "ci.yml");
const deployScript = readRepoFile("deploy.sh");
assert(
workflow.includes("group: polyweather-production-deploy") &&
workflow.includes("cancel-in-progress: false"),
"production deploy job must be serialized so overlapping pushes cannot restart the VPS concurrently",
);
assert(
deployScript.includes("POLYWEATHER_DEPLOY_LOCK_FILE") &&
deployScript.includes("flock -n"),
"VPS deploy script must take a host-level deploy lock for manual or retried deploys",
);
assert(
deployScript.includes("wait_for_local_service") &&
deployScript.includes("http://127.0.0.1:3001/terminal"),
"deploy script must wait for the local frontend before relying on Cloudflare/public smoke checks",
);
assert(
deployScript.includes("warm_public_route") &&
deployScript.includes("https://polyweather.top/terminal") &&
deployScript.includes("https://polyweather.top/api/auth/me?prefer_snapshot=1"),
"deploy script must warm terminal and auth snapshot routes after container replacement",
);
}
@@ -0,0 +1,104 @@
import {
authPayloadToEntitlementSnapshot,
decodeEntitlementSnapshot,
encodeEntitlementSnapshot,
entitlementSnapshotToAuthPayload,
type EntitlementSnapshotPayload,
} from "@/lib/entitlement-snapshot";
function assert(condition: unknown, message: string) {
if (!condition) throw new Error(message);
}
export function runTests() {
const now = Date.parse("2026-05-30T10:00:00.000Z");
const payload: EntitlementSnapshotPayload = {
v: 1,
user_id: "user-1",
email: "user@example.com",
status: "active",
subscription_plan_code: "pro_monthly",
subscription_expires_at: "2026-06-30T00:00:00.000Z",
subscription_total_expires_at: "2026-06-30T00:00:00.000Z",
subscription_queued_days: 0,
points: 3500,
issued_at: "2026-05-30T10:00:00.000Z",
};
const token = encodeEntitlementSnapshot(payload, "snapshot-secret");
const decoded = decodeEntitlementSnapshot(token, "snapshot-secret", {
maxAgeSeconds: 15 * 60,
nowMs: now + 60_000,
expectedUserId: "user-1",
});
assert(
decoded?.user_id === "user-1",
"signed entitlement snapshot should decode for the matching user",
);
assert(
decoded?.subscription_plan_code === "pro_monthly",
"snapshot should preserve subscription metadata",
);
const authPayload = entitlementSnapshotToAuthPayload(decoded);
if (!authPayload) {
throw new Error("valid snapshot should convert to auth payload");
}
assert(
authPayload.subscription_active === true &&
authPayload.entitlement_snapshot === true &&
!("degraded_auth_profile" in authPayload),
"snapshot auth payload should grant only a snapshot-backed active terminal state",
);
const [body, signature] = token.split(".");
const tamperedBody =
`${body.slice(0, -1)}${body.endsWith("A") ? "B" : "A"}`;
const tampered = `${tamperedBody}.${signature}`;
assert(
decodeEntitlementSnapshot(tampered, "snapshot-secret", {
maxAgeSeconds: 15 * 60,
nowMs: now + 60_000,
expectedUserId: "user-1",
}) === null,
"tampered entitlement snapshots must be rejected",
);
assert(
decodeEntitlementSnapshot(token, "wrong-secret", {
maxAgeSeconds: 15 * 60,
nowMs: now + 60_000,
expectedUserId: "user-1",
}) === null,
"snapshots signed with another secret must be rejected",
);
assert(
decodeEntitlementSnapshot(token, "snapshot-secret", {
maxAgeSeconds: 15 * 60,
nowMs: now + 20 * 60_000,
expectedUserId: "user-1",
}) === null,
"old entitlement snapshots must expire quickly",
);
assert(
decodeEntitlementSnapshot(token, "snapshot-secret", {
maxAgeSeconds: 15 * 60,
nowMs: now + 60_000,
expectedUserId: "other-user",
}) === null,
"snapshots must be bound to the current Supabase user id",
);
assert(
authPayloadToEntitlementSnapshot({
authenticated: true,
user_id: "expired-user",
subscription_active: true,
subscription_total_expires_at: "2020-01-01T00:00:00.000Z",
}) === null,
"expired subscription payloads must not be cached as entitlement snapshots",
);
}
@@ -1,4 +1,6 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { import {
buildCityDetailProxyCachePolicy, buildCityDetailProxyCachePolicy,
buildForceRefreshProxyCachePolicy, buildForceRefreshProxyCachePolicy,
@@ -22,4 +24,63 @@ export function runTests() {
const scanForced = buildForceRefreshProxyCachePolicy("true", 10); const scanForced = buildForceRefreshProxyCachePolicy("true", 10);
assert.equal(scanForced.fetchMode, "no-store"); assert.equal(scanForced.fetchMode, "no-store");
const scanTerminalProxySource = fs.readFileSync(
path.join(process.cwd(), "app", "api", "scan", "terminal", "route.ts"),
"utf8",
);
assert.match(
scanTerminalProxySource,
/DASHBOARD_REFRESH_POLICY_SEC\.scanRows/,
"scan terminal proxy cache TTL should match the dashboard scan refresh cadence instead of a short literal TTL",
);
assert.doesNotMatch(
scanTerminalProxySource,
/buildForceRefreshProxyCachePolicy\(forceRefresh,\s*10\)/,
"scan terminal proxy must not use the old 10 second edge cache because it over-drives the slow scan endpoint",
);
const scanTerminalClientSource = fs.readFileSync(
path.join(
process.cwd(),
"components",
"dashboard",
"scan-terminal",
"scan-terminal-client.ts",
),
"utf8",
);
assert.match(
scanTerminalClientSource,
/hasDirectBackendApiBaseUrl/,
"public scan terminal requests should only attach user auth in direct-backend mode so CDN caches can be shared through the Next proxy",
);
const overviewProxySource = fs.readFileSync(
path.join(
process.cwd(),
"app",
"api",
"scan",
"terminal",
"overview",
"route.ts",
),
"utf8",
);
assert.match(
overviewProxySource,
/buildBackendRequestHeaders\(req,\s*\{\s*includeSupabaseIdentity:\s*false,\s*\}\)/s,
"scan terminal overview proxy must not read Supabase sessions for public overview payloads",
);
const priorityWarmProxySource = fs.readFileSync(
path.join(process.cwd(), "lib", "system-priority-proxy.ts"),
"utf8",
);
assert.match(
priorityWarmProxySource,
/buildBackendRequestHeaders\(req,\s*\{\s*includeSupabaseIdentity:\s*false,\s*\}\)/s,
"priority warm proxy must not read Supabase sessions for backend-token warm hints",
);
} }
@@ -5,11 +5,18 @@ import {
DASHBOARD_REFRESH_POLICY_SEC, DASHBOARD_REFRESH_POLICY_SEC,
} from "@/lib/refresh-policy"; } from "@/lib/refresh-policy";
import { scanTerminalQueryPolicy } from "@/components/dashboard/scan-terminal/scan-terminal-client"; import { scanTerminalQueryPolicy } from "@/components/dashboard/scan-terminal/scan-terminal-client";
import { __shouldPollLiveChartForTest } from "@/components/dashboard/scan-terminal/LiveTemperatureThresholdChart"; import {
__shouldFetchCityDetailForChartForTest,
__shouldPollLiveChartForTest,
} from "@/components/dashboard/scan-terminal/LiveTemperatureThresholdChart";
import { import {
MAX_HOURLY_DETAIL_CONCURRENT_REQUESTS, MAX_HOURLY_DETAIL_CONCURRENT_REQUESTS,
HOURLY_CACHE_TTL_MS,
__resolveCityDetailFromBatchForTest,
__readHourlyCacheEntryForTest,
__resetHourlyDetailRequestQueueForTest, __resetHourlyDetailRequestQueueForTest,
__runQueuedHourlyDetailRequestForTest, __runQueuedHourlyDetailRequestForTest,
clearCityDetailCache,
} from "@/components/dashboard/scan-terminal/temperature-chart-logic"; } from "@/components/dashboard/scan-terminal/temperature-chart-logic";
function assert(condition: unknown, message: string) { function assert(condition: unknown, message: string) {
@@ -43,6 +50,10 @@ export async function runTests() {
path.join(projectRoot, "components", "dashboard", "scan-terminal", "temperature-chart-logic.ts"), path.join(projectRoot, "components", "dashboard", "scan-terminal", "temperature-chart-logic.ts"),
"utf8", "utf8",
); );
const dashboardSource = fs.readFileSync(
path.join(projectRoot, "components", "dashboard", "ScanTerminalDashboard.tsx"),
"utf8",
);
assert( assert(
querySource.includes("useSsePatchVersion") && querySource.includes("useSsePatchVersion") &&
@@ -79,6 +90,72 @@ export async function runTests() {
chartSource.includes("setHourly(seedHourlyForecastFromRow(row))"), chartSource.includes("setHourly(seedHourlyForecastFromRow(row))"),
"terminal charts should render from row data immediately and dedupe concurrent city detail requests", "terminal charts should render from row data immediately and dedupe concurrent city detail requests",
); );
assert(
chartLogicSource.includes("/api/cities/detail-batch") &&
chartLogicSource.includes("flushCityDetailBatch") &&
chartLogicSource.includes("primeCityDetailCache"),
"visible terminal chart detail fetches should be coalesced into one batch request and prime the shared chart cache",
);
assert(
chartLogicSource.includes("partial?: boolean") &&
chartLogicSource.includes("missing?: string[]"),
"frontend city detail batch payload should understand partial responses and missing city markers",
);
const flushCityDetailBatchBlock = chartLogicSource.match(/async function flushCityDetailBatch[\s\S]*?\r?\n}\r?\n\r?\nfunction fetchCityDetailBatchWithTimeout/)?.[0] || "";
assert(
flushCityDetailBatchBlock.includes("partialMissingCities") &&
flushCityDetailBatchBlock.includes("resolveBatchWaiters(waiters, null)") &&
flushCityDetailBatchBlock.includes("payload?.partial === true"),
"partial detail-batch misses should resolve without immediately issuing single-city fallback requests",
);
const fetchHourlyBlock = chartLogicSource.match(/async function fetchHourlyForecastForCity[\s\S]*?\r?\n}\r?\n\r?\nfunction fetchCityDetailWithTimeout/)?.[0] || "";
assert(
fetchHourlyBlock.includes("queueCityDetailBatch(city, resParam)") &&
!fetchHourlyBlock.includes("runQueuedHourlyDetailRequest"),
"first-paint and background city detail refreshes should both enter the batch queue before falling back to single requests",
);
assert(
dashboardSource.includes("ONLINE_USERS_REFRESH_MS = 5 * 60_000") &&
dashboardSource.includes('document.visibilityState === "hidden"'),
"terminal online-user presence should refresh slowly and pause while the browser tab is hidden",
);
assert(
chartSource.includes("IntersectionObserver") &&
chartSource.includes("shouldFetchCityDetailForChart") &&
chartSource.includes("isChartVisible"),
"city detail prefetch should be gated to visible chart cards instead of every mounted slot",
);
assert(
chartSource.includes("allowStale: true") &&
chartCanvasSourceIncludes(chartSource, "数据暂不可用") &&
chartCanvasSourceIncludes(chartSource, "handleRetryDetail"),
"city detail charts should show stale cache first and expose a retryable unavailable state",
);
assert(
__shouldFetchCityDetailForChartForTest({ city: "paris", documentHidden: false, isChartVisible: true }) === true,
"visible chart cards should fetch city detail",
);
assert(
__shouldFetchCityDetailForChartForTest({ city: "paris", documentHidden: false, isChartVisible: false }) === false,
"offscreen chart cards should not prefetch city detail",
);
assert(
__shouldFetchCityDetailForChartForTest({ city: "paris", documentHidden: true, isChartVisible: true }) === false,
"hidden browser tabs should not prefetch city detail",
);
const normalizedBatchDetail = __resolveCityDetailFromBatchForTest(
{
"hong kong": {
city: "hong kong",
timeseries: { hourly: { times: ["00:00"], temps: [32] } },
},
} as any,
"Hong Kong",
) as any;
assert(
normalizedBatchDetail?.city === "hong kong",
"frontend detail batch lookup should accept backend-normalized city keys before falling back to single-city requests",
);
__resetHourlyDetailRequestQueueForTest(); __resetHourlyDetailRequestQueueForTest();
let activeRequests = 0; let activeRequests = 0;
@@ -130,4 +207,63 @@ export async function runTests() {
"city detail queue should resolve every queued request in order", "city detail queue should resolve every queued request in order",
); );
__resetHourlyDetailRequestQueueForTest(); __resetHourlyDetailRequestQueueForTest();
const originalWindow = (globalThis as any).window;
const originalSessionStorage = (globalThis as any).sessionStorage;
const store = new Map<string, string>();
(globalThis as any).window = {};
(globalThis as any).sessionStorage = {
get length() {
return store.size;
},
getItem(key: string) {
return store.get(key) ?? null;
},
key(index: number) {
return Array.from(store.keys())[index] ?? null;
},
removeItem(key: string) {
store.delete(key);
},
setItem(key: string, value: string) {
store.set(key, value);
},
};
try {
clearCityDetailCache();
const cacheKey = "paris:10m";
store.set(
`polyweather_city_detail_v1:${cacheKey}`,
JSON.stringify({
ts: Date.now() - HOURLY_CACHE_TTL_MS - 1000,
data: {
forecastDaily: [],
localDate: "2026-05-31",
multiModelDaily: {},
probabilities: null,
temps: [21],
times: ["00:00"],
},
}),
);
assert(
__readHourlyCacheEntryForTest(cacheKey) === null,
"stale city detail cache should not suppress a fresh revalidation",
);
assert(
__readHourlyCacheEntryForTest(cacheKey, { allowStale: true })?.data?.temps[0] === 21,
"stale city detail cache should still be available for immediate chart rendering",
);
} finally {
clearCityDetailCache();
(globalThis as any).window = originalWindow;
(globalThis as any).sessionStorage = originalSessionStorage;
}
}
function chartCanvasSourceIncludes(source: string, pattern: string) {
return source.includes(pattern) || fs.readFileSync(
path.join(process.cwd(), "components", "dashboard", "scan-terminal", "TemperatureChartCanvas.tsx"),
"utf8",
).includes(pattern);
} }
@@ -182,6 +182,10 @@ export function runTests() {
!foregroundRefreshBlock.includes("setIsHourlyLoading(true)"), !foregroundRefreshBlock.includes("setIsHourlyLoading(true)"),
"foreground resume refresh should update full detail immediately in the background without showing the loading overlay", "foreground resume refresh should update full detail immediately in the background without showing the loading overlay",
); );
assert(
!chart.includes("/api/city/${encodeURIComponent(city)}/summary"),
"visible chart fallback and foreground refresh should not issue a separate summary request after requesting full detail",
);
assert(chart.includes("viewMode"), "temperature chart must expose a view mode for DEB-peak auto view versus full-day view"); assert(chart.includes("viewMode"), "temperature chart must expose a view mode for DEB-peak auto view versus full-day view");
assert(chart.includes('useState<"auto" | "full">("full")'), "temperature chart must default every city panel to the all-day view"); assert(chart.includes('useState<"auto" | "full">("full")'), "temperature chart must default every city panel to the all-day view");
assert( assert(
@@ -233,6 +237,13 @@ export function runTests() {
chartCanvas.includes("canToggleRunwayDetails") && chartCanvas.includes("individualRunwaySeriesCount > 1"), chartCanvas.includes("canToggleRunwayDetails") && chartCanvas.includes("individualRunwaySeriesCount > 1"),
"single-runway charts must not show the runway-detail toggle because aggregate and individual views are visually redundant", "single-runway charts must not show the runway-detail toggle because aggregate and individual views are visually redundant",
); );
assert(
chartLogic.includes("HOURLY_DETAIL_REQUEST_TIMEOUT_MS = 12_000") &&
chartLogic.includes("fetchCityDetailWithTimeout") &&
chartLogic.includes("signal: controller.signal") &&
chartLogic.includes("controller.abort()"),
"city detail chart fetches must have a frontend timeout so panels cannot stay on 加载图表 forever",
);
assert(!chart.includes("3D"), "temperature chart UI must not expose a 3D/future-forecast mode"); assert(!chart.includes("3D"), "temperature chart UI must not expose a 3D/future-forecast mode");
assert(!chart.includes("build3DayChartData"), "temperature chart component must not render future prediction curves"); assert(!chart.includes("build3DayChartData"), "temperature chart component must not render future prediction curves");
assert( assert(
@@ -5,11 +5,13 @@ import {
__getLiveObservationLabelsForTest, __getLiveObservationLabelsForTest,
__getObservationDisplayMetricsForTest, __getObservationDisplayMetricsForTest,
__getPeakGlowStateForTest, __getPeakGlowStateForTest,
__getWundergroundDailyHighForTest,
__getVisibleTemperatureSeriesForTest, __getVisibleTemperatureSeriesForTest,
__isTemperatureSeriesVisibleByDefaultForTest, __isTemperatureSeriesVisibleByDefaultForTest,
__mergePatchIntoHourlyForTest, __mergePatchIntoHourlyForTest,
__selectDisplayRunwayTempForTest, __selectDisplayRunwayTempForTest,
} from "@/components/dashboard/scan-terminal/LiveTemperatureThresholdChart"; } from "@/components/dashboard/scan-terminal/LiveTemperatureThresholdChart";
import { __buildTemperatureTooltipRowsForTest } from "@/components/dashboard/scan-terminal/TemperatureTooltipContent";
function assert(condition: unknown, message: string): asserts condition { function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message); if (!condition) throw new Error(message);
@@ -101,6 +103,22 @@ export function runTests() {
"morning observations near the intraday observed high should not trigger peak glow before the forecast hot window", "morning observations near the intraday observed high should not trigger peak glow before the forecast hot window",
); );
assert(
__getWundergroundDailyHighForTest({
wundergroundCurrent: { max_so_far: 26 },
airportCurrent: { max_so_far: 27 },
airportPrimary: { max_so_far: 27 },
} as any) === 26,
"WU display should use Wunderground historical.json high before airport/METAR highs",
);
assert(
__getWundergroundDailyHighForTest({
airportCurrent: { max_so_far: 27 },
airportPrimary: { max_so_far: 27 },
} as any) === null,
"WU display should not fall back to airport/METAR highs when historical.json is missing",
);
const guangzhou = { const guangzhou = {
city: "guangzhou", city: "guangzhou",
local_date: "2026-05-25", local_date: "2026-05-25",
@@ -175,7 +193,133 @@ export function runTests() {
); );
assert( assert(
__isTemperatureSeriesVisibleByDefaultForTest("guangzhou", "metar"), __isTemperatureSeriesVisibleByDefaultForTest("guangzhou", "metar"),
"METAR observations should be visible by default", "METAR observations should be visible by default outside Hong Kong and Shenzhen",
);
assert(
activeDefaultSeries.some((item) => item.key === "metar"),
"non-Hong Kong/Shenzhen airport METAR observations should be part of the active chart series by default",
);
assert(
!__getVisibleTemperatureSeriesForTest("guangzhou", series, { metar: false }).some(
(item) => item.key === "metar",
),
"users should still be able to hide the airport METAR series from the legend",
);
assert(
!__isTemperatureSeriesVisibleByDefaultForTest("hong kong", "metar"),
"Hong Kong airport METAR observations should stay hidden by default because HKO is the primary local station",
);
assert(
!__isTemperatureSeriesVisibleByDefaultForTest("Lau Fau Shan", "metar"),
"Lau Fau Shan airport METAR observations should stay hidden by default because HKO is the primary local station",
);
assert(
!__isTemperatureSeriesVisibleByDefaultForTest("shenzhen", "metar"),
"Shenzhen airport METAR observations should stay hidden by default because HKO is the primary local station",
);
assert(
__isTemperatureSeriesVisibleByDefaultForTest("Lau Fau Shan", "madis"),
"Lau Fau Shan HKO observations should remain visible by default",
);
assert(
__isTemperatureSeriesVisibleByDefaultForTest("shenzhen", "madis"),
"Shenzhen HKO observations should remain visible by default",
);
assert(
__isTemperatureSeriesVisibleByDefaultForTest("new york", "madis"),
"airport-primary weather-station observations should be visible by default",
);
[
{ city: "amsterdam", airport: "EHAM", sourceCode: "knmi", sourceLabel: "KNMI" },
{ city: "tel aviv", airport: "LLBG", sourceCode: "ims", sourceLabel: "IMS Lod Airport" },
{ city: "helsinki", airport: "EFHK", sourceCode: "fmi", sourceLabel: "FMI" },
{ city: "tokyo", airport: "RJTT", sourceCode: "jma_amedas", sourceLabel: "JMA" },
{ city: "singapore", airport: "WSSS", sourceCode: "singapore_mss", sourceLabel: "MSS" },
{ city: "panama city", airport: "MPMG", sourceCode: "ncm", sourceLabel: "NCM" },
{ city: "brussels", airport: "EBBR", sourceCode: "aeroweb", sourceLabel: "AeroWeb" },
].forEach(({ city: stationCity, airport, sourceCode, sourceLabel }) => {
const stationChart = __buildTemperatureChartDataForTest(
{
city: stationCity,
local_date: "2026-05-29",
local_time: "17:03",
tz_offset_seconds: 2 * 60 * 60,
airport,
} as any,
{
localTime: "17:03",
times: ["00:00", "06:00", "12:00", "18:00"],
temps: [19, 18, 27, 20],
airportPrimary: {
source_code: sourceCode,
source_label: sourceLabel,
temp: 19.0,
obs_time: "2026-05-29T15:03:00Z",
},
airportPrimaryTodayObs: [
{ time: "2026-05-29T13:00:00Z", temp: 26.2 },
{ time: "2026-05-29T14:00:00Z", temp: 24.5 },
{ time: "2026-05-29T15:00:00Z", temp: 19.9 },
],
} as any,
"1D",
);
const stationDefaultSeries = __getActiveTemperatureSeriesForTest(
stationCity,
stationChart.series as any,
{},
true,
);
assert(
stationDefaultSeries.some((item: any) => item.key === "madis" && item.label === sourceLabel),
`${stationCity} ${sourceLabel} weather-station curve should be visible by default`,
);
});
const ankaraMgmWithMetarBackup = __buildTemperatureChartDataForTest(
{
city: "ankara",
local_date: "2026-05-29",
local_time: "17:28",
tz_offset_seconds: 3 * 60 * 60,
airport: "LTAC",
metar_today_obs: [
{ time: "2026-05-29T12:00:00Z", temp: 15.0 },
{ time: "2026-05-29T13:00:00Z", temp: 16.0 },
{ time: "2026-05-29T14:00:00Z", temp: 17.0 },
],
} as any,
{
localTime: "17:28",
times: ["00:00", "06:00", "12:00", "18:00"],
temps: [15, 14, 16, 15],
airportPrimary: {
source_code: "mgm",
source_label: "MGM",
temp: 14.0,
obs_time: "2026-05-29T14:28:00Z",
},
airportPrimaryTodayObs: [
{ time: "2026-05-29T13:28:00Z", temp: 17.0 },
{ time: "2026-05-29T14:28:00Z", temp: 14.0 },
],
} as any,
"1D",
);
const ankaraDefaultSeries = __getActiveTemperatureSeriesForTest(
"ankara",
ankaraMgmWithMetarBackup.series as any,
{},
true,
);
assert(
ankaraDefaultSeries.some((item: any) => item.key === "madis" && item.label === "MGM"),
"Ankara MGM airport-primary weather-station curve should be visible by default",
);
assert(
ankaraDefaultSeries.some((item: any) => item.key === "metar"),
"Ankara local METAR backup curve should remain visible by default because MGM history can be incomplete",
); );
assert( assert(
!__isTemperatureSeriesVisibleByDefaultForTest("guangzhou", "model_curve_ECMWF"), !__isTemperatureSeriesVisibleByDefaultForTest("guangzhou", "model_curve_ECMWF"),
@@ -342,6 +486,50 @@ export function runTests() {
assert(!highlighted.dashed, `${city} settlement runway should be solid`); assert(!highlighted.dashed, `${city} settlement runway should be solid`);
const auxiliary = seriesByKey(chart.series, "runway_99_00") as any; const auxiliary = seriesByKey(chart.series, "runway_99_00") as any;
assert(auxiliary?.dashed === true, `${city} auxiliary runway should be dashed`); assert(auxiliary?.dashed === true, `${city} auxiliary runway should be dashed`);
const englishChart = __buildTemperatureChartDataForTest(
{
city,
local_date: "2026-05-25",
local_time: "10:00",
tz_offset_seconds: 8 * 60 * 60,
runway_plate_history: {
[settlementRwy]: [
{ time: "00:05", temp: 25.1 },
{ time: "00:35", temp: 25.3 },
],
},
} as any,
{ localTime: "10:00", times: ["00:00", "00:30"], temps: [25, 26] } as any,
"1D",
true,
);
const englishSettlement = seriesByKey(englishChart.series, runwayKey(settlementRwy)) as any;
assert(
englishSettlement?.label.includes("Settlement Runway"),
`${city} English settlement runway label should be translated`,
);
assert(
!englishSettlement?.label.includes("结算跑道"),
`${city} English settlement runway label should not leak Chinese`,
);
const englishRunwayPoint = englishChart.data.find(
(point: any) => point[runwayKey(settlementRwy)] !== null && point[runwayKey(settlementRwy)] !== undefined,
);
const tooltipRows = __buildTemperatureTooltipRowsForTest(
englishRunwayPoint as any,
englishChart.data,
englishChart.series,
);
const tooltipRunway = tooltipRows.find((item) => item.key === runwayKey(settlementRwy));
assert(
tooltipRunway?.label.includes("Settlement Runway"),
`${city} English tooltip runway label should be translated`,
);
assert(
!tooltipRunway?.label.includes("结算跑道"),
`${city} English tooltip runway label should not leak Chinese`,
);
}); });
const shenzhen = __buildTemperatureChartDataForTest( const shenzhen = __buildTemperatureChartDataForTest(
@@ -758,6 +946,94 @@ export function runTests() {
"live aggregate temp should not override runway-history current temp when runway data is rendered", "live aggregate temp should not override runway-history current temp when runway data is rendered",
); );
const wuhanRunwayMetrics = __getObservationDisplayMetricsForTest(
{
city: "wuhan",
local_date: "2026-05-27",
local_time: "13:54",
tz_offset_seconds: 8 * 60 * 60,
current_temp: 25.0,
temp_symbol: "°C",
} as any,
{
localTime: "13:54",
times: ["00:00", "12:00", "18:00", "23:00"],
temps: [22.0, 30.0, 29.0, 25.0],
runwayPlateHistory: {
"04/22": [
{ time: "13:52", temp: 28.6 },
{ time: "13:54", temp: 28.8 },
],
},
} as any,
);
assert(
wuhanRunwayMetrics.currentRunwayTemp === 28.8,
"runway header metrics should use the latest settlement runway history point",
);
assert(
__selectDisplayRunwayTempForTest(25.0, wuhanRunwayMetrics.currentRunwayTemp, false) === 28.8,
"runway header should prefer runway-history current temp even when the latest detail payload lacks runway_obs snapshot",
);
const wuhanRunwayChart = __buildTemperatureChartDataForTest(
{
city: "wuhan",
local_date: "2026-05-27",
local_time: "13:54",
tz_offset_seconds: 8 * 60 * 60,
temp_symbol: "°C",
} as any,
{
localTime: "13:54",
times: ["00:00", "12:00", "18:00", "23:00"],
temps: [22.0, 30.0, 29.0, 25.0],
runwayPlateHistory: {
"04/22": [
{ time: "13:52", temp: 24.0 },
{ time: "13:54", temp: 24.2 },
],
"05L/23R": [
{ time: "13:52", temp: 25.0 },
{ time: "13:54", temp: 25.5 },
],
},
runwayBandHistory: [
{ time: "2026-05-27T13:52:00+08:00", low_temp: 24.0, high_temp: 25.0, avg_temp: 24.5 },
{ time: "2026-05-27T13:54:00+08:00", low_temp: 24.2, high_temp: 25.5, avg_temp: 24.9 },
],
} as any,
"1D",
);
const wuhanCollapsedRunwaySeries = __getActiveTemperatureSeriesForTest(
"wuhan",
wuhanRunwayChart.series,
{},
false,
);
assert(
wuhanCollapsedRunwaySeries.some((item: any) => item.key === runwayKey("04/22")),
"collapsed runway view should keep the settlement runway series",
);
assert(
!wuhanCollapsedRunwaySeries.some((item: any) => item.key === runwayKey("05L/23R")),
"collapsed runway view should hide auxiliary runway detail series",
);
assert(
!wuhanCollapsedRunwaySeries.some((item: any) => item.key === "runway_max"),
"collapsed runway view should not replace the settlement runway with runway max",
);
const wuhanCollapsedWithSettlementHidden = __getActiveTemperatureSeriesForTest(
"wuhan",
wuhanRunwayChart.series,
{ [runwayKey("04/22")]: false },
false,
);
assert(
!wuhanCollapsedWithSettlementHidden.some((item: any) => item.key === "runway_max"),
"hiding the settlement runway should not reveal runway max in collapsed runway view",
);
const newYorkMetrics = __getObservationDisplayMetricsForTest( const newYorkMetrics = __getObservationDisplayMetricsForTest(
{ {
city: "new york", city: "new york",
@@ -1012,6 +1288,90 @@ export function runTests() {
"long-lived chart with only one fresh observation should keep a renderable current reference line instead of an invisible single-point series", "long-lived chart with only one fresh observation should keep a renderable current reference line instead of an invisible single-point series",
); );
const istanbulMgmOnlySeries = __buildTemperatureChartDataForTest(
{
city: "istanbul",
local_date: "2026-05-29",
local_time: "15:10",
tz_offset_seconds: 3 * 60 * 60,
current_temp: 18,
current_max_so_far: 18,
airport: "LTFM",
} as any,
{
localTime: "15:10",
times: [],
temps: [],
airportPrimary: {
source_code: "mgm",
source_label: "MGM",
temp: 18.2,
obs_time: "2026-05-29T12:10:00Z",
},
airportCurrent: {
source_code: "metar",
source_label: "METAR",
temp: 17,
obs_time: "14:50",
},
airportPrimaryTodayObs: [
{ time: "2026-05-29T12:00:00Z", temp: 18 },
{ time: "2026-05-29T12:05:00Z", temp: 18.1 },
],
} as any,
"1D",
);
const istanbulMgmSeries = seriesByKey(istanbulMgmOnlySeries.series as any, "madis") as any;
assert(istanbulMgmSeries?.label === "MGM", "Istanbul airport-primary series should be labeled MGM");
assert(
istanbulMgmSeries.values.some((value: number | null) => value === 18.2),
"MGM series should append the latest MGM airport-primary observation",
);
assert(
!istanbulMgmSeries.values.some((value: number | null) => value === 17),
"MGM series should not mix METAR airportCurrent points into the MGM airport-station line",
);
const ankaraMgmWithMetarLabel = __buildTemperatureChartDataForTest(
{
city: "ankara",
local_date: "2026-05-29",
local_time: "18:48",
tz_offset_seconds: 3 * 60 * 60,
current_temp: 14,
airport: "LTAC",
} as any,
{
localTime: "18:48",
times: [],
temps: [],
airportPrimary: {
source_code: "mgm",
source_label: "METAR",
temp: 14,
obs_time: "2026-05-29T15:48:00Z",
},
airportCurrent: {
source_code: "metar",
source_label: "METAR",
temp: 17,
obs_time: "18:20",
},
airportPrimaryTodayObs: [
{ time: "2026-05-29T15:00:00Z", temp: 14 },
{ time: "2026-05-29T15:30:00Z", temp: 15 },
],
metarTodayObs: [
{ time: "2026-05-29T15:20:00Z", temp: 17 },
],
} as any,
"1D",
);
const ankaraMgmSeries = seriesByKey(ankaraMgmWithMetarLabel.series as any, "madis") as any;
const ankaraMetarSeries = seriesByKey(ankaraMgmWithMetarLabel.series as any, "metar") as any;
assert(ankaraMgmSeries?.label === "MGM", "Ankara MGM airport-primary series should be labeled MGM even if payload source_label says METAR");
assert(ankaraMetarSeries?.label === "METAR", "Ankara METAR backup series should keep the METAR label");
const chengduMergedHourly = __mergePatchIntoHourlyForTest( const chengduMergedHourly = __mergePatchIntoHourlyForTest(
{ {
localTime: "05:25", localTime: "05:25",
@@ -0,0 +1,54 @@
import { __buildTemperatureStatsLabelsForTest } from "@/components/dashboard/scan-terminal/TemperatureStatsBars";
import { temp } from "@/components/dashboard/scan-terminal/utils";
function assert(condition: unknown, message: string) {
if (!condition) throw new Error(message);
}
export function runTests() {
const hongKong = __buildTemperatureStatsLabelsForTest({
isEn: true,
isShenzhen: false,
runwayHeaderLabel: "参考站点 (1分钟)",
metarHeaderLabel: "天文台实测 (10分钟)",
runwayHighLabel: "参考站点",
metarHighLabel: "天文台",
});
assert(hongKong.primary === "Reference Station (1m)", "Hong Kong English primary label should match 参考站点 (1分钟)");
assert(hongKong.compactSecondary === "HKO Live (10m)", "Hong Kong compact secondary label should match 天文台实测 (10分钟)");
assert(hongKong.expandedSecondary === "HKO Live (10m) · Daily High", "Hong Kong expanded secondary label should include HKO plus Daily High");
assert(hongKong.runwayHigh === "Reference Station", "Hong Kong high summary should translate 参考站点");
assert(hongKong.metarHigh === "HKO", "Hong Kong high summary should translate 天文台");
const shenzhen = __buildTemperatureStatsLabelsForTest({
isEn: true,
isShenzhen: true,
runwayHeaderLabel: "天文台实测 (10分钟)",
metarHeaderLabel: "天文台实测 (10分钟)",
runwayHighLabel: "天文台实测",
metarHighLabel: "天文台",
});
assert(shenzhen.primary === "HKO Live (10m)", "Shenzhen English primary label should match 天文台实测 (10分钟)");
assert(shenzhen.compactSecondary === "Daily High", "Shenzhen compact secondary label should match 当日最高");
assert(shenzhen.expandedSecondary === "HKO Live (10m) · Daily High", "Shenzhen expanded secondary label should match 天文台实测 + 当日最高");
assert(shenzhen.runwayHigh === "HKO Live", "Shenzhen high summary should translate 天文台实测");
assert(shenzhen.metarHigh === "HKO", "Shenzhen high summary should translate 天文台");
const zh = __buildTemperatureStatsLabelsForTest({
isEn: false,
isShenzhen: true,
runwayHeaderLabel: "天文台实测 (10分钟)",
metarHeaderLabel: "天文台实测 (10分钟)",
runwayHighLabel: "天文台实测",
metarHighLabel: "天文台",
});
assert(zh.primary === "天文台实测 (10分钟)", "Chinese primary label should remain unchanged");
assert(zh.compactSecondary === "当日最高", "Chinese Shenzhen compact secondary label should remain 当日最高");
assert(temp(null, "°C") === "--", "empty temperature values should not render as 0.0°C while city detail is loading");
assert(temp(undefined, "°C") === "--", "undefined temperature values should not render as 0.0°C while city detail is loading");
assert(temp("", "°C") === "--", "blank temperature values should not render as 0.0°C while city detail is loading");
}
@@ -65,7 +65,9 @@ export function runTests() {
degraded_auth_profile: true, degraded_auth_profile: true,
}); });
assert( assert(
coldUnknown.subscriptionActive === false && coldUnknown.authenticated === true, coldUnknown.subscriptionActive === false &&
"cold-start unknown subscription state must not fabricate Pro access", coldUnknown.authenticated === true &&
coldUnknown.loading === true,
"cold-start unknown subscription state must keep the terminal gate loading instead of showing a false paywall",
); );
} }
@@ -0,0 +1,218 @@
import {
createAuthProfileRequestCache,
loadTerminalAuthProfile,
type TerminalAuthProfilePayload,
} from "@/components/dashboard/scan-terminal/terminal-auth-bootstrap";
import {
buildSubscriptionRequiredAuthProfile,
isSubscriptionRequiredBackendResponse,
} from "@/lib/auth-profile-proxy";
function assert(condition: unknown, message: string) {
if (!condition) throw new Error(message);
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, reject, resolve };
}
async function flushMicrotasks() {
await Promise.resolve();
await Promise.resolve();
}
export async function runTests() {
const pendingProfile = deferred<TerminalAuthProfilePayload>();
let underlyingProfileLoads = 0;
const cachedLoadAuthProfile = createAuthProfileRequestCache((
accessToken?: string | null,
options?: { preferSnapshot?: boolean },
) => {
underlyingProfileLoads += 1;
assert(
accessToken === "shared-token" && options?.preferSnapshot === true,
"auth profile request cache should pass through the original token and snapshot preference",
);
return pendingProfile.promise;
});
const firstCachedProfile = cachedLoadAuthProfile("shared-token", {
preferSnapshot: true,
});
const secondCachedProfile = cachedLoadAuthProfile("shared-token", {
preferSnapshot: true,
});
assert(
firstCachedProfile === secondCachedProfile && underlyingProfileLoads === 1,
"auth profile request cache should dedupe identical in-flight profile loads",
);
pendingProfile.resolve({
authenticated: true,
user_id: "shared-user",
subscription_active: true,
});
await firstCachedProfile;
await cachedLoadAuthProfile("shared-token", { preferSnapshot: true });
assert(
underlyingProfileLoads === 2,
"auth profile request cache should clear a key after the in-flight load settles",
);
const slowCookieProfile = deferred<TerminalAuthProfilePayload>();
const fastSession = deferred<{ data: { session: { access_token: string } } }>();
const calls: string[] = [];
const resultPromise = loadTerminalAuthProfile({
hasSupabasePublicEnv: true,
getSession: () => {
calls.push("getSession");
return fastSession.promise;
},
loadAuthProfile: (accessToken, options) => {
const token = String(accessToken || "");
calls.push(
token
? `profile:${token}:${options?.preferSnapshot ? "snapshot" : "live"}`
: `profile:cookie:${options?.preferSnapshot ? "snapshot" : "live"}`,
);
if (!token) return slowCookieProfile.promise;
return Promise.resolve({
authenticated: true,
user_id: "bearer-user",
subscription_active: true,
});
},
});
await flushMicrotasks();
assert(
calls.includes("profile:cookie:snapshot") && calls.includes("getSession"),
"terminal auth bootstrap should start a snapshot-preferred cookie profile and Supabase session in parallel",
);
fastSession.resolve({ data: { session: { access_token: "fast-token" } } });
const result = await resultPromise;
assert(
calls.includes("profile:fast-token:snapshot"),
"terminal auth bootstrap should retry auth profile with the Supabase bearer token and snapshot hint",
);
assert(
result.authenticated === true && result.user_id === "bearer-user",
"terminal auth bootstrap should not wait for a slow cookie profile when bearer auth succeeds",
);
const slowSession = deferred<{ data: { session: { access_token: string } | null } }>();
const cookieOnlyResult = await loadTerminalAuthProfile({
hasSupabasePublicEnv: true,
getSession: () => slowSession.promise,
loadAuthProfile: (accessToken) => {
assert(!accessToken, "authenticated cookie profile should finish without requiring bearer profile");
return Promise.resolve({
authenticated: true,
user_id: "cookie-user",
subscription_active: true,
});
},
});
assert(
cookieOnlyResult.user_id === "cookie-user",
"terminal auth bootstrap should accept an authenticated cookie profile immediately",
);
const delayedBearerSession = deferred<{ data: { session: { access_token: string } } }>();
let coldStartSettled = false;
const coldStartResultPromise = loadTerminalAuthProfile({
hasSupabasePublicEnv: true,
getSession: () => delayedBearerSession.promise,
loadAuthProfile: (accessToken) => {
const token = String(accessToken || "");
if (!token) {
return Promise.resolve({
authenticated: true,
user_id: "cookie-user",
subscription_active: null,
degraded_auth_profile: true,
});
}
return Promise.resolve({
authenticated: true,
user_id: "bearer-paid-user",
subscription_active: true,
});
},
});
coldStartResultPromise.then(() => {
coldStartSettled = true;
});
await flushMicrotasks();
assert(
coldStartSettled === false,
"terminal auth bootstrap must not show a cold-start paywall from a degraded cookie profile before bearer auth has a chance to confirm Pro",
);
delayedBearerSession.resolve({ data: { session: { access_token: "paid-token" } } });
const coldStartResult = await coldStartResultPromise;
assert(
coldStartResult.user_id === "bearer-paid-user" &&
coldStartResult.subscription_active === true,
"terminal auth bootstrap should prefer the bearer-confirmed active Pro profile over a degraded cookie profile",
);
const failingBearerResult = loadTerminalAuthProfile({
hasSupabasePublicEnv: true,
getSession: () =>
Promise.resolve({ data: { session: { access_token: "paid-token" } } }),
loadAuthProfile: (accessToken) => {
if (!accessToken) {
return Promise.resolve({
authenticated: false,
subscription_active: false,
points: 0,
});
}
return Promise.reject(new Error("HTTP 500"));
},
});
let failedWithTransientAuthError = false;
try {
await failingBearerResult;
} catch (error) {
failedWithTransientAuthError = String(error).includes("HTTP 500");
}
assert(
failedWithTransientAuthError,
"terminal auth bootstrap must not resolve to an anonymous paywall when a bearer session exists but the auth profile request is transiently failing",
);
assert(
isSubscriptionRequiredBackendResponse(
403,
'{"detail":"Subscription required"}',
) === true,
"auth profile proxy should recognize backend subscription-required responses as confirmed inactive access",
);
assert(
isSubscriptionRequiredBackendResponse(
403,
'{"detail":"temporary entitlement outage"}',
) === false,
"auth profile proxy should keep unrelated backend 403 responses in the transient/degraded path",
);
const subscriptionRequiredProfile = buildSubscriptionRequiredAuthProfile({
email: "user@example.com",
userId: "user-1",
});
assert(
subscriptionRequiredProfile.authenticated === true &&
subscriptionRequiredProfile.user_id === "user-1" &&
subscriptionRequiredProfile.subscription_active === false &&
!("degraded_auth_profile" in subscriptionRequiredProfile),
"auth profile proxy must return confirmed inactive access instead of an endless unknown subscription state",
);
}
@@ -1,5 +1,6 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { __shouldKeepTemperatureChartLoadingForTest } from "@/components/dashboard/scan-terminal/TemperatureChartCanvas";
function assert(condition: unknown, message: string) { function assert(condition: unknown, message: string) {
if (!condition) throw new Error(message); if (!condition) throw new Error(message);
@@ -19,10 +20,22 @@ export function runTests() {
path.join(projectRoot, "components", "dashboard", "scan-terminal", "LiveTemperatureThresholdChart.tsx"), path.join(projectRoot, "components", "dashboard", "scan-terminal", "LiveTemperatureThresholdChart.tsx"),
"utf8", "utf8",
); );
const terminalPageSource = fs.readFileSync(
path.join(projectRoot, "app", "terminal", "page.tsx"),
"utf8",
);
const chartCanvasSource = fs.readFileSync( const chartCanvasSource = fs.readFileSync(
path.join(projectRoot, "components", "dashboard", "scan-terminal", "TemperatureChartCanvas.tsx"), path.join(projectRoot, "components", "dashboard", "scan-terminal", "TemperatureChartCanvas.tsx"),
"utf8", "utf8",
); );
const citySelectorSource = fs.readFileSync(
path.join(projectRoot, "components", "dashboard", "scan-terminal", "CitySelectorDropdown.tsx"),
"utf8",
);
const scanQuerySource = fs.readFileSync(
path.join(projectRoot, "components", "dashboard", "scan-terminal", "use-scan-terminal-query.ts"),
"utf8",
);
assert( assert(
dashboardSource.includes("MAX_TERMINAL_CHARTS = 9"), dashboardSource.includes("MAX_TERMINAL_CHARTS = 9"),
@@ -54,6 +67,42 @@ export function runTests() {
dashboardSource.includes("handleSelectCityForSlot(slotIndex, null);"), dashboardSource.includes("handleSelectCityForSlot(slotIndex, null);"),
"stale saved chart slots must render the empty city picker instead of a row=null Temperature Chart", "stale saved chart slots must render the empty city picker instead of a row=null Temperature Chart",
); );
assert(
dashboardSource.includes("absolute left-1/2 top-12 z-50") &&
!dashboardSource.includes("top-1/2 -translate-x-1/2 -translate-y-1/2"),
"empty top-row slots must open the city selector downward so the search input is not clipped by the viewport header",
);
assert(
citySelectorSource.includes("max-h-[calc(100vh-72px)]") &&
citySelectorSource.includes("min-h-0 flex-1 overflow-y-auto"),
"city selector dropdown must cap its viewport height and keep the result list scrollable",
);
assert(
citySelectorSource.includes("MIN_DROPDOWN_TOP_PX") &&
citySelectorSource.includes("viewportNudgeY") &&
citySelectorSource.includes("getBoundingClientRect()"),
"city selector dropdown must nudge itself inside the viewport when opened from top-row chart cards",
);
assert(
terminalPageSource.includes("dynamic(") &&
terminalPageSource.includes('import("@/components/dashboard/ScanTerminalDashboard")') &&
terminalPageSource.includes("DashboardShellSkeleton") &&
!terminalPageSource.includes('import { ScanTerminalDashboard } from "@/components/dashboard/ScanTerminalDashboard";'),
"terminal route must dynamically load the heavy dashboard behind the shell skeleton",
);
assert(
dashboardSource.includes('from "next/dynamic"') &&
dashboardSource.includes('import("@/components/dashboard/scan-terminal/TrainingDashboard")') &&
!dashboardSource.includes('import { TrainingDashboard } from "@/components/dashboard/scan-terminal/TrainingDashboard";'),
"terminal dashboard must lazy-load the training analytics tab so Recharts stays out of the default terminal path",
);
assert(
chartSource.includes('from "next/dynamic"') &&
chartSource.includes("TemperatureChartCanvasFallback") &&
chartSource.includes("import(\"@/components/dashboard/scan-terminal/TemperatureChartCanvas\")") &&
!chartSource.includes('import { TemperatureChartCanvas } from "@/components/dashboard/scan-terminal/TemperatureChartCanvas";'),
"terminal temperature charts must lazy-load the Recharts canvas behind a lightweight fallback",
);
assert( assert(
chartSource.includes("setLiveTemp(null);") && chartSource.includes("setLiveTemp(null);") &&
chartSource.includes("lastAppliedPatchRevisionRef.current = 0;"), chartSource.includes("lastAppliedPatchRevisionRef.current = 0;"),
@@ -65,4 +114,124 @@ export function runTests() {
chartCanvasSource.includes('compact ? "min-h-[120px]" : "min-h-[220px]"'), chartCanvasSource.includes('compact ? "min-h-[120px]" : "min-h-[220px]"'),
"compact grid charts must not force desktop minimum heights that get clipped inside 3x3 terminal slots", "compact grid charts must not force desktop minimum heights that get clipped inside 3x3 terminal slots",
); );
const signedOutBlock = dashboardSource.slice(
dashboardSource.indexOf('if (event === "SIGNED_OUT")'),
dashboardSource.indexOf('} else if (event === "TOKEN_REFRESHED"'),
);
assert(
signedOutBlock.includes("await supabase.auth.getSession()") &&
signedOutBlock.includes("mergeAccessStateWithAuthPayload(prev, payload)") &&
signedOutBlock.indexOf("await supabase.auth.getSession()") <
signedOutBlock.indexOf("setProAccess(createEmptyAccess(false))"),
"terminal auth listener must re-check the current Supabase session before clearing access on SIGNED_OUT events",
);
assert(
dashboardSource.includes('event === "INITIAL_SESSION"') &&
dashboardSource.indexOf('event === "INITIAL_SESSION"') <
dashboardSource.indexOf('event === "TOKEN_REFRESHED"'),
"terminal auth listener must hydrate access from Supabase INITIAL_SESSION events during first navigation from the landing page",
);
assert(
dashboardSource.includes("useDeferredValue") &&
dashboardSource.includes("deferredSearchQuery") &&
dashboardSource.includes("[rows, deferredSearchQuery]"),
"terminal search must defer expensive row filtering so typing stays responsive",
);
assert(
scanQuerySource.includes("MAX_STALE_SCAN_CACHE_MS") &&
scanQuerySource.includes("allowStale") &&
scanQuerySource.includes("setCachedRows(readScanCache(tradingRegion || \"\", { allowStale: true }))"),
"terminal data hook must render stale scan rows immediately while revalidating the first-screen API",
);
assert(
citySelectorSource.includes("useDeferredValue") &&
citySelectorSource.includes("deferredSearchQuery") &&
citySelectorSource.includes("[rows, deferredSearchQuery, activeTab]"),
"city selector search must defer expensive dropdown filtering so top-row selection stays responsive",
);
assert(
dashboardSource.includes("accessDecisionPending") &&
dashboardSource.includes("shouldShowPaywall") &&
dashboardSource.indexOf("if (accessDecisionPending)") <
dashboardSource.indexOf("if (shouldShowPaywall)"),
"terminal must keep showing verification while access is undecided instead of flashing the paywall",
);
assert(
dashboardSource.includes('trackAppEvent("enter_terminal"') &&
dashboardSource.includes('entry: "terminal"'),
"terminal must emit enter_terminal when an entitled user reaches the dashboard",
);
assert(
chartCanvasSource.includes("memo(") &&
chartCanvasSource.includes("TemperatureChartCanvasComponent"),
"temperature chart canvas must be memoized so unrelated terminal state does not remount Recharts",
);
assert(
__shouldKeepTemperatureChartLoadingForTest({
row: { city: "Moscow" } as any,
isHourlyLoading: true,
activeSeries: [],
probabilityOverlay: null,
zoomedData: [
{ label: "00:00", ts: 1 },
{ label: "05:00", ts: 2 },
],
}),
"temperature chart should show the loading skeleton while the first detail fetch is in flight and no drawable data exists",
);
assert(
!__shouldKeepTemperatureChartLoadingForTest({
row: { city: "Moscow" } as any,
isHourlyLoading: false,
activeSeries: [],
probabilityOverlay: null,
zoomedData: [
{ label: "00:00", ts: 1 },
{ label: "05:00", ts: 2 },
],
}),
"temperature chart must stop showing an indefinite loading overlay after the detail fetch finishes without drawable data",
);
assert(
!__shouldKeepTemperatureChartLoadingForTest({
row: { city: "Moscow" } as any,
isHourlyLoading: false,
activeSeries: [
{
key: "current",
label: "Current reference",
source: "Live",
color: "#009688",
values: [13, 13],
},
] as any,
probabilityOverlay: null,
zoomedData: [
{ label: "00:00", ts: 1, current: 13 },
{ label: "05:00", ts: 2, current: 13 },
],
}),
"temperature chart should render once a visible series has drawable values",
);
assert(
!__shouldKeepTemperatureChartLoadingForTest({
row: { city: "Moscow" } as any,
isHourlyLoading: true,
activeSeries: [
{
key: "current",
label: "Current reference",
source: "Live",
color: "#009688",
values: [13, 13],
},
] as any,
probabilityOverlay: null,
zoomedData: [
{ label: "00:00", ts: 1, current: 13 },
{ label: "05:00", ts: 2, current: 13 },
],
}),
"temperature chart must render seeded or cached data immediately while full detail continues loading in the background",
);
} }
@@ -3,6 +3,7 @@
import { import {
buildBrowserBackendHeaders, buildBrowserBackendHeaders,
fetchBackendApi, fetchBackendApi,
hasDirectBackendApiBaseUrl,
} from "@/lib/backend-api"; } from "@/lib/backend-api";
import { DASHBOARD_REFRESH_POLICY_MS } from "@/lib/refresh-policy"; import { DASHBOARD_REFRESH_POLICY_MS } from "@/lib/refresh-policy";
import type { import type {
@@ -144,11 +145,14 @@ async function getTerminal({
if (forceRefresh) { if (forceRefresh) {
params.set("_ts", String(Date.now())); params.set("_ts", String(Date.now()));
} }
const headers = await buildBrowserBackendHeaders({ Accept: "application/json" }); const directBackend = hasDirectBackendApiBaseUrl();
const headers = directBackend
? await buildBrowserBackendHeaders({ Accept: "application/json" })
: new Headers({ Accept: "application/json" });
return readJsonOrThrow<ScanTerminalResponse>( return readJsonOrThrow<ScanTerminalResponse>(
`/api/scan/terminal?${params.toString()}`, `/api/scan/terminal?${params.toString()}`,
{ {
cache: "no-store", cache: forceRefresh || directBackend ? "no-store" : "default",
headers, headers,
signal, signal,
}, },
@@ -77,10 +77,26 @@ function runwaySeriesKey(rwy: string) {
.join("_")}`; .join("_")}`;
} }
function runwaySeriesLabel(rwy: string, isSettlement: boolean, isEn: boolean) {
if (!isSettlement) return rwy;
return `${rwy} ${isEn ? "Settlement Runway" : "结算跑道"}`;
}
function isTemperatureSeriesVisibleByDefault(city: string, seriesKey: string) { function isTemperatureSeriesVisibleByDefault(city: string, seriesKey: string) {
if (seriesKey.startsWith("model_curve_")) { if (seriesKey.startsWith("model_curve_")) {
return normalizeCityKey(city) === "paris" && seriesKey === "model_curve_AROME HD"; return normalizeCityKey(city) === "paris" && seriesKey === "model_curve_AROME HD";
} }
if (seriesKey === "metar") {
const cityKey = normalizeCityKey(city);
return (
cityKey !== "hongkong" &&
cityKey !== "laufaushan" &&
cityKey !== "shenzhen"
);
}
if (seriesKey === "madis") {
return true;
}
return true; return true;
} }
@@ -110,21 +126,44 @@ function getVisibleTemperatureSeries(
}); });
} }
function getActiveTemperatureSeries( function isIndividualRunwaySeriesKey(seriesKey: string) {
return seriesKey.startsWith("runway_") && seriesKey !== "runway_max";
}
function isSettlementRunwaySeriesKey(city: string, seriesKey: string) {
if (!isIndividualRunwaySeriesKey(seriesKey)) return false;
const cityKey = normalizeCityKey(city);
const settlementPairs = SETTLEMENT_RUNWAY_PAIRS[cityKey] || [];
if (!settlementPairs.length) return false;
const normalized = seriesKey
.replace(/^runway_/, "")
.split("_")
.map(normalizeRunwayLabel)
.filter(Boolean)
.sort()
.join("/");
return settlementPairs.some((pair) => pairKey(pair) === normalized);
}
function getTemperatureSeriesForRunwayDetailsMode(
city: string, city: string,
chartSeries: EvidenceSeries[], series: EvidenceSeries[],
userToggledKeys: Record<string, boolean>,
showRunwayDetails: boolean, showRunwayDetails: boolean,
) { ) {
const rawVisible = getVisibleTemperatureSeries(city, chartSeries, userToggledKeys); const hasRunwayMax = series.some((item) => item.key === "runway_max");
const hasRunwayMax = rawVisible.some((item) => item.key === "runway_max"); const hasSettlementRunway = series.some((item) =>
isSettlementRunwaySeriesKey(city, item.key),
);
return rawVisible.filter((item) => { return series.filter((item) => {
const isIndividualRunway = const isIndividualRunway = isIndividualRunwaySeriesKey(item.key);
item.key.startsWith("runway_") && item.key !== "runway_max";
if (showRunwayDetails) { if (showRunwayDetails) {
return item.key !== "runway_max"; return item.key !== "runway_max";
} }
if (hasSettlementRunway) {
if (item.key === "runway_max") return false;
return !isIndividualRunway || isSettlementRunwaySeriesKey(city, item.key);
}
if (!hasRunwayMax) { if (!hasRunwayMax) {
return true; return true;
} }
@@ -132,6 +171,20 @@ function getActiveTemperatureSeries(
}); });
} }
function getActiveTemperatureSeries(
city: string,
chartSeries: EvidenceSeries[],
userToggledKeys: Record<string, boolean>,
showRunwayDetails: boolean,
) {
const modeSeries = getTemperatureSeriesForRunwayDetailsMode(
city,
chartSeries,
showRunwayDetails,
);
return getVisibleTemperatureSeries(city, modeSeries, userToggledKeys);
}
function buildRunwayPlates( function buildRunwayPlates(
amos: AmosData | null | undefined, amos: AmosData | null | undefined,
row: ScanOpportunityRow | null, row: ScanOpportunityRow | null,
@@ -303,6 +356,7 @@ const HOURLY_CACHE_TTL_MS = DASHBOARD_REFRESH_POLICY_MS.metar;
const _hourlyCache = new Map<string, { ts: number; data: HourlyForecast }>(); const _hourlyCache = new Map<string, { ts: number; data: HourlyForecast }>();
const _hourlyRequestCache = new Map<string, Promise<HourlyForecast>>(); const _hourlyRequestCache = new Map<string, Promise<HourlyForecast>>();
const MAX_HOURLY_DETAIL_CONCURRENT_REQUESTS = 3; const MAX_HOURLY_DETAIL_CONCURRENT_REQUESTS = 3;
const HOURLY_DETAIL_REQUEST_TIMEOUT_MS = 12_000;
let _hourlyActiveDetailRequests = 0; let _hourlyActiveDetailRequests = 0;
const _hourlyDetailRequestQueue: Array<() => void> = []; const _hourlyDetailRequestQueue: Array<() => void> = [];
const RUNWAY_LINE_COLORS = ["#00897b", "#d97706", "#7c3aed", "#0891b2", "#ea580c", "#64748b"]; const RUNWAY_LINE_COLORS = ["#00897b", "#d97706", "#7c3aed", "#0891b2", "#ea580c", "#64748b"];
@@ -310,19 +364,50 @@ const RUNWAY_LINE_COLORS = ["#00897b", "#d97706", "#7c3aed", "#0891b2", "#ea580c
const SESSION_CACHE_PREFIX = "polyweather_city_detail_v1:"; const SESSION_CACHE_PREFIX = "polyweather_city_detail_v1:";
const SESSION_CACHE_TTL_MS = DASHBOARD_REFRESH_POLICY_MS.metar; const SESSION_CACHE_TTL_MS = DASHBOARD_REFRESH_POLICY_MS.metar;
function readSessionCache(city: string): { ts: number; data: HourlyForecast } | null { type HourlyCacheEntry = { ts: number; data: HourlyForecast };
function isFreshHourlyCacheEntry(entry: HourlyCacheEntry | null | undefined) {
return Boolean(entry && Date.now() - Number(entry.ts || 0) < SESSION_CACHE_TTL_MS);
}
function readSessionCache(
city: string,
options: { allowStale?: boolean } = {},
): HourlyCacheEntry | null {
if (typeof window === "undefined") return null; if (typeof window === "undefined") return null;
try { try {
const raw = sessionStorage.getItem(`${SESSION_CACHE_PREFIX}${city}`); const raw = sessionStorage.getItem(`${SESSION_CACHE_PREFIX}${city}`);
if (!raw) return null; if (!raw) return null;
const item = JSON.parse(raw); const item = JSON.parse(raw);
if (item && item.ts && Date.now() - item.ts < SESSION_CACHE_TTL_MS) { if (
item &&
item.ts &&
(options.allowStale || Date.now() - item.ts < SESSION_CACHE_TTL_MS)
) {
return item; return item;
} }
} catch {} } catch {}
return null; return null;
} }
function readHourlyCacheEntry(
cacheKey: string,
options: { allowStale?: boolean } = {},
): HourlyCacheEntry | null {
const cached = _hourlyCache.get(cacheKey);
if (cached && (options.allowStale || isFreshHourlyCacheEntry(cached))) {
return cached;
}
const sessionEntry = readSessionCache(cacheKey, options);
if (sessionEntry) {
_hourlyCache.set(cacheKey, sessionEntry);
return sessionEntry;
}
return null;
}
function writeSessionCache(city: string, data: HourlyForecast) { function writeSessionCache(city: string, data: HourlyForecast) {
if (typeof window === "undefined" || !data) return; if (typeof window === "undefined" || !data) return;
try { try {
@@ -382,6 +467,7 @@ function __resetHourlyDetailRequestQueueForTest() {
} }
const __runQueuedHourlyDetailRequestForTest = runQueuedHourlyDetailRequest; const __runQueuedHourlyDetailRequestForTest = runQueuedHourlyDetailRequest;
const __readHourlyCacheEntryForTest = readHourlyCacheEntry;
function validNumber(value: unknown): number | null { function validNumber(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null; return typeof value === "number" && Number.isFinite(value) ? value : null;
@@ -564,6 +650,61 @@ function appendLatestAirportObservation(
return merged; return merged;
} }
function isMgmAirportPrimary(hourly: HourlyForecast) {
const primary = hourly?.airportPrimary;
const sourceTokens = [
primary?.source_code,
primary?.source_label,
(primary as any)?.source,
].map((value) => String(value || "").toLowerCase());
return sourceTokens.some((value) => value === "mgm" || value.includes("turkey_mgm"));
}
function canonicalAirportPrimarySourceLabel(hourly: HourlyForecast) {
const primary = hourly?.airportPrimary;
const tokens = [
primary?.source_code,
(primary as any)?.source,
].map((value) => String(value || "").trim().toLowerCase());
if (tokens.some((value) => value === "mgm" || value.includes("turkey_mgm"))) return "MGM";
if (tokens.some((value) => value.includes("jma"))) return "JMA";
if (tokens.some((value) => value.includes("fmi"))) return "FMI";
if (tokens.some((value) => value.includes("knmi"))) return "KNMI";
if (tokens.some((value) => value.includes("ims"))) return "IMS";
if (tokens.some((value) => value.includes("ncm"))) return "NCM";
if (tokens.some((value) => value.includes("aeroweb"))) return "AeroWeb";
if (tokens.some((value) => value.includes("singapore_mss") || value === "mss")) return "MSS";
if (tokens.some((value) => value.includes("madis") || value.includes("noaa"))) return "NOAA MADIS";
return "";
}
function isGenericAirportPrimaryLabel(label: string) {
const normalized = label.trim().toLowerCase();
return (
!normalized ||
normalized === "metar" ||
normalized === "madis" ||
normalized === "noaa madis"
);
}
function airportPrimarySeriesLabel(hourly: HourlyForecast, isHKO: boolean) {
if (isHKO) return "HKO";
const canonicalLabel = canonicalAirportPrimarySourceLabel(hourly);
if (canonicalLabel === "MGM") return canonicalLabel;
const payloadLabel = String(hourly?.airportPrimary?.source_label || "").trim();
if (payloadLabel && !isGenericAirportPrimaryLabel(payloadLabel)) return payloadLabel;
return canonicalLabel || payloadLabel || "NOAA MADIS";
}
function airportPrimaryObservationPoints(hourly: HourlyForecast) {
return appendLatestAirportObservation(
hourly?.airportPrimaryTodayObs,
hourly?.airportPrimary,
...(isMgmAirportPrimary(hourly) ? [] : [hourly?.airportCurrent]),
);
}
function seriesStats(values: Array<number | null>) { function seriesStats(values: Array<number | null>) {
const nums = values.filter((v): v is number => validNumber(v) !== null); const nums = values.filter((v): v is number => validNumber(v) !== null);
const latest = nums.length ? nums[nums.length - 1] : null; const latest = nums.length ? nums[nums.length - 1] : null;
@@ -638,7 +779,7 @@ function getObservationDisplayMetrics(
const settlementObs = normObs(hourly?.settlementTodayObs || row?.settlement_today_obs || row?.metar_context?.settlement_today_obs, tzOffset, MAX_OBS_POINTS, localDateStr); const settlementObs = normObs(hourly?.settlementTodayObs || row?.settlement_today_obs || row?.metar_context?.settlement_today_obs, tzOffset, MAX_OBS_POINTS, localDateStr);
const metarObs = normObs(hourly?.metarTodayObs || row?.metar_today_obs || row?.metar_context?.today_obs || row?.metar_recent_obs || row?.metar_context?.recent_obs, tzOffset, MAX_OBS_POINTS, localDateStr); const metarObs = normObs(hourly?.metarTodayObs || row?.metar_today_obs || row?.metar_context?.today_obs || row?.metar_recent_obs || row?.metar_context?.recent_obs, tzOffset, MAX_OBS_POINTS, localDateStr);
const madisObs = normObs( const madisObs = normObs(
appendLatestAirportObservation(hourly?.airportPrimaryTodayObs, hourly?.airportPrimary, hourly?.airportCurrent), airportPrimaryObservationPoints(hourly),
tzOffset, tzOffset,
MAX_OBS_POINTS, MAX_OBS_POINTS,
localDateStr, localDateStr,
@@ -708,12 +849,10 @@ function getObservationDisplayMetrics(
function selectDisplayRunwayTemp( function selectDisplayRunwayTemp(
liveTemp: number | null, liveTemp: number | null,
currentRunwayTemp: number | null, currentRunwayTemp: number | null,
hasRunwayData: boolean, _hasRunwayData: boolean,
) { ) {
if (hasRunwayData && currentRunwayTemp !== null) { if (currentRunwayTemp !== null) return currentRunwayTemp;
return currentRunwayTemp; return liveTemp;
}
return liveTemp ?? currentRunwayTemp;
} }
function isSettlementRunway(row: ScanOpportunityRow | null, rwy: string) { function isSettlementRunway(row: ScanOpportunityRow | null, rwy: string) {
@@ -781,6 +920,7 @@ type HourlyForecast = {
amos?: AmosData | null; amos?: AmosData | null;
airportCurrent?: AirportCurrentConditions | null; airportCurrent?: AirportCurrentConditions | null;
airportPrimary?: AirportCurrentConditions | null; airportPrimary?: AirportCurrentConditions | null;
wundergroundCurrent?: AirportCurrentConditions | null;
forecastDaily?: ForecastDay[]; forecastDaily?: ForecastDay[];
multiModelDaily?: Record<string, DailyModelForecast>; multiModelDaily?: Record<string, DailyModelForecast>;
probabilities?: LegacyGaussianProbabilitySource | null; probabilities?: LegacyGaussianProbabilitySource | null;
@@ -806,6 +946,7 @@ function seedHourlyForecastFromRow(row: ScanOpportunityRow | null): HourlyForeca
amos: null, amos: null,
airportCurrent: null, airportCurrent: null,
airportPrimary: null, airportPrimary: null,
wundergroundCurrent: (row as any)?.wunderground_current || null,
forecastDaily: [], forecastDaily: [],
multiModelDaily: {}, multiModelDaily: {},
probabilities: { probabilities: {
@@ -824,6 +965,29 @@ type HourlyForecastFetchOptions = {
resolution?: string; resolution?: string;
}; };
type CityDetailBatchPayload = {
cities?: string[];
details?: Record<string, CityDetail | null | undefined>;
errors?: Record<string, string>;
missing?: string[];
partial?: boolean;
};
type CityDetailBatchWaiter = {
resolve: (value: HourlyForecast) => void;
reject: (reason?: unknown) => void;
};
type CityDetailBatchQueue = {
cities: Set<string>;
waiters: Map<string, CityDetailBatchWaiter[]>;
timer: ReturnType<typeof setTimeout> | null;
};
const CITY_DETAIL_BATCH_WINDOW_MS = 25;
const CITY_DETAIL_BATCH_MAX_CITIES = 12;
const _cityDetailBatchQueues = new Map<string, CityDetailBatchQueue>();
function parseHourlyForecastFromCityDetail(json: CityDetail | null): HourlyForecast { function parseHourlyForecastFromCityDetail(json: CityDetail | null): HourlyForecast {
const hourlySource = (json as any)?.hourly ?? (json as any)?.timeseries?.hourly; const hourlySource = (json as any)?.hourly ?? (json as any)?.timeseries?.hourly;
if (!json || !hourlySource) return null; if (!json || !hourlySource) return null;
@@ -841,6 +1005,7 @@ function parseHourlyForecastFromCityDetail(json: CityDetail | null): HourlyForec
amos: json.amos || null, amos: json.amos || null,
airportCurrent: json.airport_current || null, airportCurrent: json.airport_current || null,
airportPrimary: json.airport_primary || null, airportPrimary: json.airport_primary || null,
wundergroundCurrent: (json as any).wunderground_current || (json as any)?.official?.wunderground_current || null,
forecastDaily: json.forecast?.daily || [], forecastDaily: json.forecast?.daily || [],
multiModelDaily: json.multi_model_daily || {}, multiModelDaily: json.multi_model_daily || {},
probabilities: json.probabilities || null, probabilities: json.probabilities || null,
@@ -851,6 +1016,171 @@ function parseHourlyForecastFromCityDetail(json: CityDetail | null): HourlyForec
}; };
} }
function primeCityDetailCache(
city: string,
resolution: string,
detail: CityDetail | null | undefined,
): HourlyForecast {
const data = parseHourlyForecastFromCityDetail(detail || null);
if (!data) return null;
const cacheKey = `${city}:${resolution}`;
_hourlyCache.set(cacheKey, { ts: Date.now(), data });
writeSessionCache(cacheKey, data);
return data;
}
async function fetchSingleHourlyForecastForCity(
city: string,
resolution: string,
): Promise<HourlyForecast> {
const res = await fetchCityDetailWithTimeout(city, resolution);
if (!res || !res.ok) return null;
const json = await res.json() as CityDetail;
return primeCityDetailCache(city, resolution, json);
}
function queueCityDetailBatch(city: string, resolution: string): Promise<HourlyForecast> {
return new Promise<HourlyForecast>((resolve, reject) => {
const queue = _cityDetailBatchQueues.get(resolution) || {
cities: new Set<string>(),
waiters: new Map<string, CityDetailBatchWaiter[]>(),
timer: null,
};
_cityDetailBatchQueues.set(resolution, queue);
const cityWaiters = queue.waiters.get(city) || [];
cityWaiters.push({ resolve, reject });
queue.waiters.set(city, cityWaiters);
queue.cities.add(city);
if (queue.timer === null) {
queue.timer = setTimeout(() => flushCityDetailBatch(resolution), CITY_DETAIL_BATCH_WINDOW_MS);
}
if (queue.cities.size >= CITY_DETAIL_BATCH_MAX_CITIES) {
flushCityDetailBatch(resolution);
}
});
}
function resolveBatchWaiters(
waiters: CityDetailBatchWaiter[] | undefined,
value: HourlyForecast,
) {
(waiters || []).forEach((waiter) => waiter.resolve(value));
}
function rejectBatchWaiters(
waiters: CityDetailBatchWaiter[] | undefined,
reason: unknown,
) {
(waiters || []).forEach((waiter) => waiter.reject(reason));
}
function resolveCityDetailFromBatch(
details: Record<string, CityDetail | null | undefined> | undefined,
city: string,
) {
if (!details) return undefined;
const trimmed = String(city || "").trim();
const direct =
details[city] ||
details[trimmed] ||
details[trimmed.toLowerCase()] ||
details[normalizeCityKey(trimmed)];
if (direct) return direct;
const requestedKey = normalizeCityKey(trimmed);
if (!requestedKey) return undefined;
for (const [key, detail] of Object.entries(details)) {
if (!detail) continue;
if (normalizeCityKey(key) === requestedKey) return detail;
const detailCity = (detail as any).city || detail.name || detail.display_name;
if (normalizeCityKey(detailCity) === requestedKey) return detail;
}
return undefined;
}
async function flushCityDetailBatch(resolution: string) {
const queue = _cityDetailBatchQueues.get(resolution);
if (!queue) return;
_cityDetailBatchQueues.delete(resolution);
if (queue.timer !== null) {
clearTimeout(queue.timer);
queue.timer = null;
}
const cities = Array.from(queue.cities).sort();
if (!cities.length) return;
try {
const payload = await fetchCityDetailBatchWithTimeout(cities, resolution);
const details = payload?.details || {};
const partialMissingCities =
payload?.partial === true
? new Set((payload.missing || []).map((city) => normalizeCityKey(city)))
: new Set<string>();
await Promise.all(
cities.map(async (city) => {
const waiters = queue.waiters.get(city);
const detail = resolveCityDetailFromBatch(details, city);
const data = primeCityDetailCache(city, resolution, detail);
if (data) {
resolveBatchWaiters(waiters, data);
return;
}
if (partialMissingCities.has(normalizeCityKey(city))) {
resolveBatchWaiters(waiters, null);
return;
}
try {
resolveBatchWaiters(
waiters,
await runQueuedHourlyDetailRequest(() => fetchSingleHourlyForecastForCity(city, resolution)),
);
} catch (error) {
rejectBatchWaiters(waiters, error);
}
}),
);
} catch (error) {
await Promise.all(
cities.map(async (city) => {
const waiters = queue.waiters.get(city);
try {
resolveBatchWaiters(
waiters,
await runQueuedHourlyDetailRequest(() => fetchSingleHourlyForecastForCity(city, resolution)),
);
} catch (fallbackError) {
rejectBatchWaiters(waiters, fallbackError || error);
}
}),
);
}
}
function fetchCityDetailBatchWithTimeout(cities: string[], resolution: string) {
const controller = new AbortController();
const timeoutId = globalThis.setTimeout(() => controller.abort(), HOURLY_DETAIL_REQUEST_TIMEOUT_MS);
const params = new URLSearchParams({
cities: cities.join(","),
depth: "full",
force_refresh: "false",
limit: String(Math.max(cities.length, CITY_DETAIL_BATCH_MAX_CITIES)),
resolution,
});
return fetch(`/api/cities/detail-batch?${params.toString()}`, {
headers: { Accept: "application/json" },
signal: controller.signal,
})
.then(async (res) => {
if (!res.ok) return null;
return res.json() as Promise<CityDetailBatchPayload>;
})
.catch(() => null)
.finally(() => globalThis.clearTimeout(timeoutId));
}
async function fetchHourlyForecastForCity( async function fetchHourlyForecastForCity(
city: string, city: string,
options: HourlyForecastFetchOptions = {}, options: HourlyForecastFetchOptions = {},
@@ -859,38 +1189,17 @@ async function fetchHourlyForecastForCity(
const cacheKey = `${city}:${resParam}`; const cacheKey = `${city}:${resParam}`;
if (!options.ignoreCache) { if (!options.ignoreCache) {
const cached = _hourlyCache.get(cacheKey); const cached = readHourlyCacheEntry(cacheKey);
if (cached && Date.now() - cached.ts < HOURLY_CACHE_TTL_MS) { if (cached) {
return cached.data; return cached.data;
} }
const sessionCached = readSessionCache(cacheKey);
if (sessionCached) {
_hourlyCache.set(cacheKey, sessionCached);
return sessionCached.data;
}
} }
const requestKey = options.ignoreCache ? `${city}:${resParam}:live` : `${city}:${resParam}`; const requestKey = options.ignoreCache ? `${city}:${resParam}:live` : `${city}:${resParam}`;
const pending = _hourlyRequestCache.get(requestKey); const pending = _hourlyRequestCache.get(requestKey);
if (pending) return pending; if (pending) return pending;
const request = runQueuedHourlyDetailRequest(() => const request = queueCityDetailBatch(city, resParam)
fetch(`/api/city/${encodeURIComponent(city)}/detail?depth=full&force_refresh=false&resolution=${resParam}`, {
headers: { Accept: "application/json" },
})
.then(async (res) => {
if (!res.ok) return null;
return res.json() as Promise<CityDetail>;
})
.then((json) => {
const data = parseHourlyForecastFromCityDetail(json);
if (!data) return null;
_hourlyCache.set(cacheKey, { ts: Date.now(), data });
writeSessionCache(cacheKey, data);
return data;
}),
)
.finally(() => { .finally(() => {
_hourlyRequestCache.delete(requestKey); _hourlyRequestCache.delete(requestKey);
}); });
@@ -899,6 +1208,17 @@ async function fetchHourlyForecastForCity(
return request; return request;
} }
function fetchCityDetailWithTimeout(city: string, resolution: string) {
const controller = new AbortController();
const timeoutId = globalThis.setTimeout(() => controller.abort(), HOURLY_DETAIL_REQUEST_TIMEOUT_MS);
return fetch(`/api/city/${encodeURIComponent(city)}/detail?depth=full&force_refresh=false&resolution=${resolution}`, {
headers: { Accept: "application/json" },
signal: controller.signal,
})
.catch(() => null)
.finally(() => globalThis.clearTimeout(timeoutId));
}
function shouldPollLiveChart({ function shouldPollLiveChart({
city, city,
compact, compact,
@@ -1153,6 +1473,7 @@ function buildRunwayHistorySeries(
tzOffset: number, tzOffset: number,
localDateStr: string, localDateStr: string,
minPoints = 2, minPoints = 2,
isEn = false,
): RunwayHistorySeries[] { ): RunwayHistorySeries[] {
const directHistory = const directHistory =
hourly?.runwayPlateHistory ?? hourly?.runwayPlateHistory ??
@@ -1175,7 +1496,7 @@ function buildRunwayHistorySeries(
const isSettlement = isSettlementRunway(row, normalizedRwy); const isSettlement = isSettlementRunway(row, normalizedRwy);
return { return {
key: runwaySeriesKey(normalizedRwy), key: runwaySeriesKey(normalizedRwy),
label: `${normalizedRwy}${isSettlement ? (row ? " 结算跑道" : " Settlement") : ""}`, label: runwaySeriesLabel(normalizedRwy, isSettlement, isEn),
rwy: normalizedRwy, rwy: normalizedRwy,
isSettlement, isSettlement,
color: isSettlement ? "#009688" : RUNWAY_LINE_COLORS[index % RUNWAY_LINE_COLORS.length], color: isSettlement ? "#009688" : RUNWAY_LINE_COLORS[index % RUNWAY_LINE_COLORS.length],
@@ -1249,7 +1570,7 @@ function buildRunwayHistorySeries(
if (values.length < minPoints) return null; if (values.length < minPoints) return null;
return { return {
key: runwaySeriesKey(rwy), key: runwaySeriesKey(rwy),
label: `${rwy}${isSettlement ? " 结算跑道" : ""}`, label: runwaySeriesLabel(rwy, isSettlement, isEn),
rwy, rwy,
isSettlement, isSettlement,
color: isSettlement ? "#009688" : RUNWAY_LINE_COLORS[index % RUNWAY_LINE_COLORS.length], color: isSettlement ? "#009688" : RUNWAY_LINE_COLORS[index % RUNWAY_LINE_COLORS.length],
@@ -1567,7 +1888,7 @@ function buildFullDayChartData(
); );
const madisObs = filterTimelinePointsToLocalDay( const madisObs = filterTimelinePointsToLocalDay(
normObs( normObs(
appendLatestAirportObservation(hourly?.airportPrimaryTodayObs, hourly?.airportPrimary, hourly?.airportCurrent), airportPrimaryObservationPoints(hourly),
tzOffset, tzOffset,
MAX_OBS_POINTS, MAX_OBS_POINTS,
localDateStr, localDateStr,
@@ -1575,7 +1896,7 @@ function buildFullDayChartData(
localDayBounds, localDayBounds,
); );
const runwayHistorySeries = filterRunwayHistoryToLocalDay( const runwayHistorySeries = filterRunwayHistoryToLocalDay(
buildRunwayHistorySeries(row, hourly, tzOffset, localDateStr), buildRunwayHistorySeries(row, hourly, tzOffset, localDateStr, 2, isEn),
localDayBounds, localDayBounds,
); );
@@ -1727,7 +2048,7 @@ function buildFullDayChartData(
if (madisVals.some((v) => v !== null)) { if (madisVals.some((v) => v !== null)) {
series.push({ series.push({
key: "madis", key: "madis",
label: isHKO ? "HKO" : (hourly?.airportPrimary?.source_label || "NOAA MADIS"), label: airportPrimarySeriesLabel(hourly, isHKO),
source: isHKO ? "HKO" : (hourly?.airportPrimary?.station_code || row?.airport || "MADIS"), source: isHKO ? "HKO" : (hourly?.airportPrimary?.station_code || row?.airport || "MADIS"),
color: "#0284c7", color: "#0284c7",
dashed: isHKO ? true : false, dashed: isHKO ? true : false,
@@ -2136,8 +2457,11 @@ function binObservationsToSlots(
export { export {
MAX_HOURLY_DETAIL_CONCURRENT_REQUESTS, MAX_HOURLY_DETAIL_CONCURRENT_REQUESTS,
HOURLY_DETAIL_REQUEST_TIMEOUT_MS,
HOURLY_CACHE_TTL_MS, HOURLY_CACHE_TTL_MS,
_hourlyCache, _hourlyCache,
__readHourlyCacheEntryForTest,
resolveCityDetailFromBatch as __resolveCityDetailFromBatchForTest,
__resetHourlyDetailRequestQueueForTest, __resetHourlyDetailRequestQueueForTest,
__runQueuedHourlyDetailRequestForTest, __runQueuedHourlyDetailRequestForTest,
buildChartDomain, buildChartDomain,
@@ -2149,6 +2473,7 @@ export {
buildRunwayPlates, buildRunwayPlates,
fetchHourlyForecastForCity, fetchHourlyForecastForCity,
getActiveTemperatureSeries, getActiveTemperatureSeries,
getTemperatureSeriesForRunwayDetailsMode,
getLiveObservationLabels, getLiveObservationLabels,
getObservationDisplayMetrics, getObservationDisplayMetrics,
getVisibleTemperatureSeries, getVisibleTemperatureSeries,

Some files were not shown because too many files have changed in this diff Show More