Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 78ea0326a5 | |||
| d3f444dbf6 | |||
| 8d26afdec0 | |||
| 5083b7c433 | |||
| 668f4d9bd3 | |||
| a4253c224a | |||
| 46effcd45f | |||
| 2a6d8748f4 | |||
| ca72072da0 | |||
| bf0fbe1b1d | |||
| 30e9513ce2 | |||
| aec9dd6a54 | |||
| 6222f60573 | |||
| a48a7bcca5 | |||
| 74a2d71644 | |||
| 9c239c0394 | |||
| 383dfe4d0e | |||
| 52f92d8650 | |||
| d9eea721ef | |||
| aa583e7440 | |||
| e00405df8a | |||
| 9bcfd9d1eb | |||
| 3fcda3f3cd | |||
| 345691ad71 | |||
| e864181c3c | |||
| 097971f107 | |||
| 90bc895000 | |||
| c0b20ed1bf | |||
| 5470da6b3a | |||
| a03b8095ee | |||
| 91896b4dad | |||
| 44638760b7 | |||
| c0b1edaed0 | |||
| 853a652e9b | |||
| c79619898a | |||
| 1c250906ed | |||
| ed8c898e0e | |||
| 57240e02ee | |||
| c26f89f134 | |||
| b3a63aa9af | |||
| 53268560ec | |||
| 68416b058e | |||
| 95c2258db0 | |||
| 8507afedd6 | |||
| 3e24080466 | |||
| 17ddd835e6 | |||
| 5228d2b3fd | |||
| 2f039abb2c | |||
| 522e35de7f | |||
| f8f5035225 | |||
| 23b5fafb25 | |||
| 2269aaefd7 | |||
| 9e3a4e2f45 | |||
| 8f52b1d80c | |||
| 1d9f0033a6 | |||
| 618bac8b57 | |||
| f2e90fbcda | |||
| 3cc2251b9b |
+13
-1
@@ -171,13 +171,24 @@ POLYWEATHER_BOT_DEB_QUERY_COST=1
|
||||
|
||||
# Payments
|
||||
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_RPC_URL=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_DIRECT_RECEIVER_ADDRESS=
|
||||
POLYWEATHER_PAYMENT_TOKEN_ADDRESS=0x3c499c542cef5e3811e1192ce70d8cc03d5c3359
|
||||
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_CONFIRMATIONS=2
|
||||
POLYWEATHER_PAYMENT_INTENT_TTL_SEC=1800
|
||||
@@ -189,7 +200,8 @@ POLYWEATHER_PAYMENT_TELEGRAM_NOTIFY_ENABLED=true
|
||||
POLYWEATHER_PAYMENT_POINTS_ENABLED=true
|
||||
POLYWEATHER_PAYMENT_POINTS_PER_USDC=500
|
||||
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=
|
||||
POLYMARKET_SIGNAL_EDGE_PCT=2
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ METEOBLUE_API_KEY=
|
||||
# Wallet / payments
|
||||
########################################
|
||||
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
|
||||
POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON=
|
||||
POLYWEATHER_PAYMENT_RECEIVER_CONTRACT=
|
||||
POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS=
|
||||
POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON=
|
||||
|
||||
@@ -122,6 +122,9 @@ jobs:
|
||||
needs: [build-and-push]
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: polyweather-production-deploy
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
@@ -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.
|
||||
- `/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.
|
||||
- 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.
|
||||
- Ops dashboard live: `/ops` for memberships, leaderboard, manual point grants, and payment incident triage.
|
||||
- Lightweight observability live: `/healthz`, `/api/system/status`, `/metrics`.
|
||||
@@ -102,7 +102,7 @@ flowchart LR
|
||||
API --> SSE["SSE /api/events"]
|
||||
WX --> SSE
|
||||
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"]
|
||||
```
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
| Command | Purpose |
|
||||
|
||||
+7
-7
@@ -14,16 +14,16 @@
|
||||
|
||||

|
||||
|
||||
## 当前产品状态(2026-05-28)
|
||||
## 当前产品状态(2026-05-30)
|
||||
|
||||
- 已上线订阅制:`Pro 月付 10 USDC`。
|
||||
- 已上线积分体系:群内发言赚分 + 首次发言欢迎奖励 (+20) + 每日首条消息奖励 (+2) + 每周全员参与奖。
|
||||
- `/city` 与 `/deb` 已改为免费(每日各 10 次);积分可用于支付抵扣(`500 分 = 1 USDC`,最多抵 `3 USDC`)。
|
||||
- 周榜奖励已改造:降低赢家积分加成 (200/100/50),所有周活跃用户均享参与奖。
|
||||
- 已上线链上支付:Polygon 合约支付(USDC / USDC.e)。
|
||||
- 已上线订阅制:`Pro 月付 29.9 USDC / 30 天`,`Pro 季度 79.9 USDC / 90 天`。
|
||||
- 积分获取已切换为邀请制度:被邀请人完成首次 Pro 付款后,邀请人获得 `3500` 积分;Telegram 群发言不再获得积分。
|
||||
- `/city` 与 `/deb` 已改为免费(每日各 10 次);积分可用于支付抵扣(`500 分 = 1 USDC`,月付最多抵 `3 USDC`,季度最多抵 `8 USDC`)。
|
||||
- 邀请首月价:被邀请人首次月付 `20 USDC`;每个邀请人每月最多 10 个有效付费邀请奖励。
|
||||
- 已上线链上支付:Polygon 合约支付(USDC / USDC.e)+ Ethereum 主网 USDC 直转确认。
|
||||
- 已上线自动补单:事件监听 + 周期确认双链路。
|
||||
- 已上线支付运行态与审计接口:`/api/payments/runtime`。
|
||||
- 已上线轻量运营后台:`/ops`(会员、周榜、补分、支付异常单)。
|
||||
- 已上线轻量运营后台:`/ops`(会员、积分、补分、支付异常单)。
|
||||
- 已上线轻量可观测性:`/healthz`、`/api/system/status`、`/metrics`。
|
||||
- 已补最小外部监控栈:Prometheus + Alertmanager + Grafana + Telegram 告警 relay。
|
||||
- 实时终端已切换到可重放事件流:可见城市图表通过 `/api/events?cities=...&since_revision=...` 订阅 `city_observation_patch.v1`,生产环境使用 Redis Stream 做短窗口 replay,本地/单进程可回退 SQLite event log。
|
||||
|
||||
@@ -5,6 +5,14 @@ GHCR_PAT="$1"
|
||||
NEW_TAG="${2:-latest}"
|
||||
TAG_FILE="/var/lib/polyweather/.current_tag"
|
||||
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
|
||||
|
||||
@@ -17,6 +25,18 @@ if [ -f "$TAG_FILE" ]; then
|
||||
echo "Previous tag: $PREVIOUS_TAG"
|
||||
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"
|
||||
pull_ok=0
|
||||
for pull_attempt in $(seq 1 6); do
|
||||
@@ -28,34 +48,115 @@ if [ "$pull_ok" != "1" ]; then
|
||||
echo "❌ Image pull failed after retries"
|
||||
exit 1
|
||||
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..."
|
||||
for i in $(seq 1 30); do
|
||||
sleep 5
|
||||
if curl -fsSo /dev/null --max-time 5 "https://api.polyweather.top/healthz"; then
|
||||
echo "✅ healthz ready after ${i}x5s"
|
||||
break
|
||||
fi
|
||||
echo " retry $i/30..."
|
||||
done
|
||||
wait_for_local_service "backend healthz" "http://127.0.0.1:8000/healthz" 5 30 5 || FAILED_BACKEND=1
|
||||
FAILED_BACKEND="${FAILED_BACKEND:-0}"
|
||||
if [ "$FAILED_BACKEND" = "1" ]; then
|
||||
echo "❌ Backend did not become healthy"
|
||||
rollback_to_previous
|
||||
exit 1
|
||||
fi
|
||||
|
||||
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
|
||||
curl -fsSo /dev/null --max-time 15 "https://api.polyweather.top/healthz" && echo "✅ healthz" || { echo "❌ healthz"; FAILED=1; }
|
||||
curl -fsSo /dev/null --max-time 10 "https://api.polyweather.top/api/cities" && echo "✅ cities" || { echo "❌ cities"; FAILED=1; }
|
||||
curl -fsSo /dev/null --max-time 10 "https://www.polyweather.top/" && echo "✅ frontend" || { echo "❌ frontend"; FAILED=1; }
|
||||
smoke_check "healthz" "https://api.polyweather.top/healthz" 15 3 5 || FAILED=1
|
||||
smoke_check "frontend cities" "https://polyweather.top/api/cities" 20 5 5 || FAILED=1
|
||||
smoke_check "frontend" "https://www.polyweather.top/" 15 3 5 || FAILED=1
|
||||
|
||||
if [ "$FAILED" = "1" ]; then
|
||||
echo "❌ Smoke tests failed. Rolling back..."
|
||||
if [ -n "$PREVIOUS_TAG" ]; then
|
||||
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
|
||||
rollback_to_previous
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
+3
-2
@@ -69,6 +69,7 @@ services:
|
||||
POLYWEATHER_API_BASE_URL: ${POLYWEATHER_API_BASE_URL:-http://polyweather_web:8000}
|
||||
POLYWEATHER_AUTH_ENABLED: ${POLYWEATHER_AUTH_ENABLED:-true}
|
||||
POLYWEATHER_AUTH_REQUIRED: ${POLYWEATHER_AUTH_REQUIRED:-true}
|
||||
POLYWEATHER_OPS_ADMIN_EMAILS: ${POLYWEATHER_OPS_ADMIN_EMAILS:-}
|
||||
healthcheck:
|
||||
interval: 30s
|
||||
retries: 3
|
||||
@@ -78,7 +79,7 @@ services:
|
||||
timeout: 5s
|
||||
image: ghcr.io/yangyuan-zhen/polyweather-frontend:${IMAGE_TAG:-latest}
|
||||
ports:
|
||||
- 3001:3000
|
||||
- "127.0.0.1:3001:3000"
|
||||
restart: unless-stopped
|
||||
polyweather_web:
|
||||
command: python web/app.py
|
||||
@@ -103,7 +104,7 @@ services:
|
||||
timeout: 5s
|
||||
image: ghcr.io/yangyuan-zhen/polyweather-backend:${IMAGE_TAG:-latest}
|
||||
ports:
|
||||
- 8000:8000
|
||||
- "127.0.0.1:8000:8000"
|
||||
restart: unless-stopped
|
||||
user: ${UID:-1000}:${GID:-1000}
|
||||
volumes:
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
# 商业化说明(Production)
|
||||
|
||||
最后更新:`2026-05-28`
|
||||
最后更新:`2026-05-30`
|
||||
|
||||
## 1. 定位
|
||||
|
||||
@@ -18,10 +18,12 @@ PolyWeather 是面向温度结算场景的气象决策层,不是通用天气
|
||||
| 能力 | 状态 | 备注 |
|
||||
| :-- | :-- | :-- |
|
||||
| 登录注册(Google + 邮箱) | 已上线 | Supabase 鉴权 |
|
||||
| 订阅套餐(Pro 月付) | 已上线 | `10 USDC / 30天` |
|
||||
| 积分抵扣 | 已上线 | `500分=1U`,最多 `3U` |
|
||||
| 订阅套餐(Pro 月付 / 季度) | 已上线 | `29.9 USDC / 30天`;`79.9 USDC / 90天` |
|
||||
| 邀请积分 | 已上线 | 被邀请人首月 `20U`;邀请人每个有效付费邀请 `+3500` 积分 |
|
||||
| 积分抵扣 | 已上线 | `500分=1U`,最多 `3U`;邀请首月价不叠加积分抵扣 |
|
||||
| 合约支付 | 已上线 | Polygon,USDC + USDC.e |
|
||||
| 支付自动确认 | 已上线 | Event Loop + Confirm Loop |
|
||||
| 多链直转支付 | 已上线 | Ethereum 主网 USDC 直转确认 |
|
||||
| 支付自动确认 | 已上线 | Event Loop + Confirm Loop,按 intent.chain_id 查链 |
|
||||
| 钱包绑定 | 已上线 | 浏览器钱包 + WalletConnect |
|
||||
| 私有频道推送 | 已上线 | 可拆分业务频道 |
|
||||
| 实时终端 | 已上线 | SSE Patch + Redis Stream replay |
|
||||
@@ -41,9 +43,11 @@ PolyWeather 是面向温度结算场景的气象决策层,不是通用天气
|
||||
|
||||
## 4. 收费与积分规则(默认)
|
||||
|
||||
- 套餐:`pro_monthly`(10 USDC / 30 天)
|
||||
- 抵扣:500 积分抵 1 USDC,最高抵 3 USDC
|
||||
- 实付下限:2 USDC(当积分满额时)
|
||||
- 套餐:`pro_monthly`(29.9 USDC / 30 天)、`pro_quarterly`(79.9 USDC / 90 天)
|
||||
- 邀请首月:被邀请人首次月付 20 USDC
|
||||
- 邀请奖励:邀请人 +3500 积分,每月最多 10 个有效付费邀请
|
||||
- 抵扣:500 积分抵 1 USDC;月付最高抵 3 USDC,季度最高抵 8 USDC
|
||||
- 积分来源:仅通过有效付费邀请和后台人工补发,不再通过 Telegram 群发言发放
|
||||
|
||||
> 说明:具体运营策略可按阶段调整,生产参数建议放私有仓库。
|
||||
|
||||
|
||||
@@ -110,6 +110,7 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
|
||||
- `POLYWEATHER_EVENT_STORE`
|
||||
- `POLYWEATHER_REDIS_REQUIRED`
|
||||
- `POLYWEATHER_PAYMENT_ENABLED`
|
||||
- `POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON`
|
||||
- `POLYGON_WALLET_WATCH_ENABLED`
|
||||
- `TELEGRAM_ALERT_PUSH_ENABLED`
|
||||
- `TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED`
|
||||
@@ -131,6 +132,7 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
|
||||
- `TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC`
|
||||
- `TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N`
|
||||
- `POLYWEATHER_PAYMENT_RPC_URLS`
|
||||
- `POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON`
|
||||
- `TAF_CACHE_TTL_SEC`
|
||||
- `POLYWEATHER_REDIS_URL`
|
||||
- `POLYWEATHER_REDIS_STREAM_KEY`
|
||||
@@ -259,7 +261,8 @@ POLYWEATHER_BACKEND_URL=http://polyweather_web:8000
|
||||
- `POLYWEATHER_STATE_STORAGE_MODE` 当前线上推荐直接使用 `sqlite`。
|
||||
- `POLYWEATHER_EVENT_STORE=redis` 表示实时观测 patch 使用 Redis Stream 做短窗口 replay 和多 worker fanout;本地或单进程可改为 `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 推送;同一轮已经触发关键提醒的城市不会重复出现在关注清单里。
|
||||
- `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 机器人市场监控建议配置
|
||||
|
||||
这套配置围绕市场本身做两类推送:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 前端部署配置(Vercel)
|
||||
|
||||
最后更新:`2026-05-28`
|
||||
最后更新:`2026-05-29`
|
||||
|
||||
本文只覆盖 `frontend` 目录对应的 Next.js 前端部署。
|
||||
|
||||
@@ -127,12 +127,15 @@ NEXT_PUBLIC_POLYWEATHER_EAGER_CITY_SUMMARIES=false
|
||||
1. 用户点击支付前,前端会重新请求 `/api/payments/config`
|
||||
2. 若发现 `receiver_contract` 与页面旧状态不一致,会自动切换到最新地址
|
||||
3. 若后端返回的 `tx_payload.to` 与最新 `receiver_contract` 不一致,会直接阻断支付
|
||||
4. 多链支付时,前端会展示后端返回的网络列表,并把用户选择的 `chain_id` 传给后端创建 intent
|
||||
5. Ethereum 主网 USDC 当前走手动直转确认,前端不会把它当成 Polygon checkout 合约支付
|
||||
|
||||
这层防护的目的,是降低以下事故概率:
|
||||
|
||||
- 用户使用长期未刷新的旧标签页
|
||||
- 命中旧 deployment URL
|
||||
- 页面本地状态残留旧收款地址
|
||||
- 用户在钱包默认网络(例如 Ethereum)付款,但系统按 Polygon intent 查账
|
||||
|
||||
如果你变更过支付收款地址,建议同步执行:
|
||||
|
||||
|
||||
+4
-2
@@ -1,6 +1,6 @@
|
||||
# 外部服务依赖总览
|
||||
|
||||
最后更新:`2026-05-28`
|
||||
最后更新:`2026-05-29`
|
||||
|
||||
项目调用外部天气、鉴权、支付和实时事件服务。原则是:核心链路必须有明确健康检查;可选数据源不可拖垮已可用城市;实时事件层可从 Redis Stream 降级到 SQLite event log。
|
||||
|
||||
@@ -52,7 +52,8 @@
|
||||
| --- | --- | --- |
|
||||
| MiMo (xiaomimimo) | 城市分析 AI 评论 | ✅ 当前使用 |
|
||||
| DeepSeek | AI fallback | 备用 |
|
||||
| Polygon RPC | 链上支付、自动确认 | ✅ |
|
||||
| Polygon RPC | checkout 合约支付、Polygon USDC / USDC.e 自动确认 | ✅ |
|
||||
| Ethereum RPC | Ethereum 主网 USDC 直转确认 | ✅(启用多链支付时必须) |
|
||||
| WalletConnect | 前端钱包连接 | ⚠️ 未配 key 时钱包入口降级 |
|
||||
|
||||
## 运维口径
|
||||
@@ -61,3 +62,4 @@
|
||||
- 本地或单进程兜底:`POLYWEATHER_EVENT_STORE=sqlite`。
|
||||
- Redis 只负责短窗口 replay 与多 worker fanout,不是长期天气历史库。
|
||||
- DEB hourly consensus 依赖 Open-Meteo 多模型小时曲线;若上游限流,图表应保留已有 snapshot 和实测 patch,不把缺失模型误报为实测缺失。
|
||||
- 支付多链确认依赖 `POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON`;如果启用 Ethereum 主网 USDC,必须配置 `chain_id=1` 的 RPC,否则用户提交 Ethereum tx hash 后无法自动确认。
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# Supabase + 登录 + 支付接入说明(v1.8.1)
|
||||
|
||||
最后更新:`2026-03-14`
|
||||
最后更新:`2026-05-29`
|
||||
|
||||
## 1. 目标
|
||||
|
||||
- 前端支持 Google 一键登录 + 邮箱注册/登录。
|
||||
- 后端支持 Supabase JWT 鉴权。
|
||||
- 支持 Polygon 合约支付(USDC / USDC.e)并自动确认开通订阅。
|
||||
- 支持 Polygon 合约支付(USDC / USDC.e)和 Ethereum 主网 USDC 直转支付,并自动确认开通订阅。
|
||||
|
||||
## 2. Supabase 控制台配置
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
在 Supabase SQL Editor 执行:
|
||||
|
||||
- `scripts/supabase/schema.sql`
|
||||
- 既有生产项目遇到 Disk IO Budget 告警时,再执行 `scripts/supabase/io_budget_indexes.sql`
|
||||
|
||||
会创建支付与订阅相关表:
|
||||
|
||||
@@ -33,6 +34,15 @@
|
||||
- `payment_intents`
|
||||
- `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.1 前端(Vercel / frontend/.env.local)
|
||||
@@ -66,30 +76,38 @@ SUPABASE_SERVICE_ROLE_KEY=
|
||||
SUPABASE_HTTP_TIMEOUT_SEC=8
|
||||
|
||||
POLYWEATHER_PAYMENT_ENABLED=true
|
||||
# 默认链仍是 Polygon,因为当前 checkout 合约部署在 Polygon。
|
||||
POLYWEATHER_PAYMENT_CHAIN_ID=137
|
||||
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_DIRECT_RECEIVER_ADDRESS=0x<treasury_or_receiver_wallet>
|
||||
POLYWEATHER_PAYMENT_CONFIRMATIONS=2
|
||||
POLYWEATHER_PAYMENT_INTENT_TTL_SEC=1800
|
||||
POLYWEATHER_PAYMENT_WALLET_CHALLENGE_TTL_SEC=600
|
||||
POLYWEATHER_PAYMENT_POLL_INTERVAL_SEC=4
|
||||
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_ALLOWED_PLAN_CODES=pro_monthly
|
||||
# 套餐
|
||||
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,pro_quarterly
|
||||
|
||||
# 积分抵扣
|
||||
POLYWEATHER_PAYMENT_POINTS_ENABLED=true
|
||||
POLYWEATHER_PAYMENT_POINTS_PER_USDC=500
|
||||
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_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. 验证步骤
|
||||
@@ -100,10 +118,16 @@ POLYWEATHER_PAYMENT_CONFIRM_LOOP_ENABLED=true
|
||||
- `POST /api/payments/wallets/challenge`
|
||||
- `POST /api/payments/wallets/verify`
|
||||
4. 支付流程:
|
||||
- `POST /api/payments/intents`
|
||||
- `POST /api/payments/intents`;多链时前端会带 `chain_id` 和 `token_address`
|
||||
- 发链上交易
|
||||
- `POST /api/payments/intents/{id}/submit`
|
||||
- `POST /api/payments/intents/{id}/confirm`
|
||||
5. 若前端显示 pending,轮询:
|
||||
- `GET /api/payments/intents/{id}`
|
||||
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` 事件。
|
||||
|
||||
@@ -60,7 +60,7 @@ flowchart TD
|
||||
|
||||
| 项目 | 影响 | 建议动作 |
|
||||
| :-- | :-- | :-- |
|
||||
| 积分发放可解释性 | 用户理解成本高 | 输出积分来源明细(发言/首次消息奖励/欢迎奖励/周排名奖励/周参与奖/手动补分) |
|
||||
| 积分发放可解释性 | 用户理解成本高 | 输出积分来源明细(有效付费邀请/后台人工补发/积分抵扣消费) |
|
||||
| 支付合约 V2 升级 | 当前仍是最小可用合约 | 升级到 SafeERC20 + Pausable + plan 绑定 |
|
||||
| 支付失败文案标准化 | 转化率受影响 | 建立错误码 -> 文案映射表 |
|
||||
|
||||
|
||||
@@ -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”。
|
||||
@@ -15,7 +15,7 @@ DEB(Dynamic Error Balancing)基于过去 N 天模型误差(MAE)倒数加
|
||||
趋势/概率引擎在 `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 和当前实测。
|
||||
**市场层(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 明确限制请求频率(含每分钟请求上限/建议降低频率与使用缓存文件)。
|
||||
**许可证**:仓库根目录 `LICENSE` 当前为 `AGPL-3.0-only`。同时 README 与策略文档明确:品牌、商标、生产私有数据与运营策略不随代码许可证一并授权。
|
||||
(插图:项目 README 中包含产品截图,可用于快速理解实时终端与 Telegram 推送形态)
|
||||
@@ -71,7 +71,7 @@ JSON[Legacy JSON files<br/>migration/export/explicit fallback only]
|
||||
HKO[data.weather.gov.hk]
|
||||
CWA[opendata.cwa.gov.tw]
|
||||
SB[Supabase Auth/REST]
|
||||
RPC[Polygon RPC]
|
||||
RPC[Polygon / Ethereum RPC]
|
||||
end
|
||||
|
||||
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 共用,避免“两套逻辑漂移”。前端城市决策卡在此基础上补足“机场报文解释 + 市场桶动作口径”,让用户从地图点击可以直接进入可解释决策。
|
||||
**面向外部 API 的工程防护意识较强**:Open-Meteo 429 冷却期、最小调用间隔、磁盘缓存、缓存 TTL 等措施表明作者已遭遇并处理速率限制与冷启动问题。 同时 AviationWeather 官方文档也明确建议控制频率并可使用 cache 文件降低负载,项目后续可进一步对齐最佳实践。
|
||||
**支付侧有“事件监听 + 确认补单”的双通路**:支付链路天然存在“交易 pending / RPC 延迟 / 日志索引不完整”等问题,项目通过 event loop 与 confirm loop 双机制提升最终一致性。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# PolyWeather 支付审计与防护说明
|
||||
|
||||
最后更新:`2026-03-21`
|
||||
最后更新:`2026-05-29`
|
||||
|
||||
## 1. 当前已落地的防护
|
||||
|
||||
@@ -19,9 +19,9 @@
|
||||
|
||||
### 事件确认边界
|
||||
|
||||
- 后端只认链上 `OrderPaid` 事件。
|
||||
- 后端只认链上可验证事件:checkout 合约路径认 `OrderPaid`,直转路径认对应链上 USDC `Transfer`。
|
||||
- 前端提交 intent 不会直接视为支付完成。
|
||||
- `confirm_loop` 会再次按链上交易与确认数校验 intent。
|
||||
- `confirm_loop` 会再次按 intent 的 `chain_id`、`token_address`、收款地址、金额与确认数校验链上交易。
|
||||
- 若确认失败,当前会明确把 intent / transaction 落为失败态,而不是长期停留在 `submitted`。
|
||||
|
||||
当前已显式识别的失败原因包括:
|
||||
@@ -29,19 +29,23 @@
|
||||
- `receiver_mismatch`
|
||||
- `sender_mismatch`
|
||||
- `event_mismatch`
|
||||
- `token_mismatch`
|
||||
- `tx_reverted`
|
||||
|
||||
### RPC 多节点容灾
|
||||
|
||||
- 支持 `POLYWEATHER_PAYMENT_RPC_URLS`
|
||||
- 支持默认链 `POLYWEATHER_PAYMENT_RPC_URLS`
|
||||
- 支持多链 `POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON`
|
||||
- 格式示例:
|
||||
|
||||
```env
|
||||
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。
|
||||
- 多链支付确认不会用默认链硬查交易;每笔 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)
|
||||
@@ -110,7 +130,7 @@ python scripts/replay_payment_events.py --from-block 10000000 --to-block 1000100
|
||||
4. 订单边界
|
||||
- 同一个 `orderId` 只能成功支付一次
|
||||
|
||||
## 3. 重入与重复支付判断
|
||||
## 4. 重入与重复支付判断
|
||||
|
||||
当前合约的 `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)
|
||||
@@ -160,7 +180,7 @@ python scripts/check_payment_contract_security.py
|
||||
- 是否使用 SafeERC20
|
||||
- 是否在链上绑定套餐价格
|
||||
|
||||
## 5. 当前主要剩余风险
|
||||
## 6. 当前主要剩余风险
|
||||
|
||||
1. 单地址 owner
|
||||
- 建议把 `owner` 迁移到多签钱包
|
||||
@@ -175,7 +195,7 @@ python scripts/check_payment_contract_security.py
|
||||
- 当前使用 `IERC20.transferFrom`
|
||||
- 升级版合约更建议改为 OpenZeppelin `SafeERC20`
|
||||
|
||||
## 6. 推荐操作
|
||||
## 7. 推荐操作
|
||||
|
||||
### 每次支付配置变更后
|
||||
|
||||
@@ -233,7 +253,7 @@ docker compose exec polyweather_web python scripts/reconcile_subscription_by_ema
|
||||
- 用户声称已付费但未开通
|
||||
- 需要快速确认最近一笔 intent 是否能自动恢复
|
||||
|
||||
## 7. 下一版合约建议
|
||||
## 8. 下一版合约建议
|
||||
|
||||
如果后续升级合约,优先级建议:
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ PolyWeather 是一个面向天气衍生品交易者的气象情报平台。核
|
||||
|------|------|------|
|
||||
| 免费 | 交互式全球天气地图 + 城市简报 | 无需登录 |
|
||||
| Pro 试用 | 3 天全功能 | 注册后自动获得 |
|
||||
| Pro 订阅 | 城市决策卡(结构化实况 + 模型证据 + 市场层)、日内分析、历史对账、未来预报 | 10 USDC/月(积分抵扣最多 3 USDC) |
|
||||
| Pro 订阅 | 城市决策卡(结构化实况 + 模型证据 + 市场层)、日内分析、历史对账、未来预报 | 月付 29.9 USDC / 30 天,季度 79.9 USDC / 90 天(月付最多抵 3 USDC,季度最多抵 8 USDC) |
|
||||
|
||||
### 页面结构(9 个路由)
|
||||
|
||||
|
||||
+4
-2
@@ -45,6 +45,7 @@ PolyWeather Pro 的生产前端工程。
|
||||
- 城市决策卡展示结构化实况、模型区间、市场温度桶和模型-市场差,不再请求 AI 解读
|
||||
- 市场价格层使用完整 `all_buckets` 匹配温度桶,并把 `模型-市场差` 解释为 `模型概率 - 市场隐含概率`
|
||||
- 概率区展示当前生产概率引擎输出(legacy 高斯或 EMOS),模型共识只作为辅助参考
|
||||
- 账户中心支付区支持后端下发的多链网络选择;Polygon 继续走 checkout 合约,Ethereum 主网 USDC 走手动直转确认
|
||||
- 缓存桶状态与 summary cache hit/miss
|
||||
|
||||
## 本地开发
|
||||
@@ -176,7 +177,8 @@ Ops:
|
||||
2. 若 `receiver_contract` 已更新,先切到最新地址
|
||||
3. 若后端返回的 `tx_payload.to` 与最新地址不一致,直接阻断支付
|
||||
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`
|
||||
|
||||
最后更新:`2026-05-28`
|
||||
最后更新:`2026-05-29`
|
||||
|
||||
@@ -7,49 +7,121 @@ import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
import {
|
||||
createProxyTimer,
|
||||
finishProxyTimedResponse,
|
||||
} from "@/lib/proxy-timing";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
const ANALYTICS_ENABLED =
|
||||
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) {
|
||||
const timer = createProxyTimer(req, "analytics_events");
|
||||
if (!ANALYTICS_ENABLED) {
|
||||
return new NextResponse(null, { status: 204 });
|
||||
}
|
||||
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
return finishProxyTimedResponse(
|
||||
new NextResponse(null, { status: 204 }),
|
||||
timer,
|
||||
"disabled",
|
||||
);
|
||||
}
|
||||
|
||||
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 {
|
||||
const body = await req.json();
|
||||
const auth = await buildBackendRequestHeaders(req, {
|
||||
includeSupabaseIdentity: false,
|
||||
});
|
||||
const body = await timer.measure("request_read", () => req.json());
|
||||
const payload =
|
||||
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);
|
||||
headers.set("Content-Type", "application/json");
|
||||
const res = await fetch(`${API_BASE}/api/analytics/events`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body ?? {}),
|
||||
cache: "no-store",
|
||||
});
|
||||
const res = await timer.measure("backend_fetch", () =>
|
||||
fetch(`${API_BASE}/api/analytics/events`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(enrichedBody),
|
||||
cache: "no-store",
|
||||
signal: controller.signal,
|
||||
}),
|
||||
);
|
||||
const backendServerTiming = res.headers.get("server-timing") || "";
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const raw = await timer.measure("backend_read", () => res.text());
|
||||
const response = buildUpstreamErrorResponse(res.status, raw, {
|
||||
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);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
return finishProxyTimedResponse(
|
||||
applyAuthResponseCookies(response, auth.response),
|
||||
timer,
|
||||
"ok",
|
||||
{ backendServerTiming },
|
||||
);
|
||||
} catch (error) {
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to track analytics event",
|
||||
});
|
||||
const timedOut = controller.signal.aborted;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,121 +4,680 @@ import {
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
import {
|
||||
getLocalDevAuthPayload,
|
||||
isLocalFullAccessHost,
|
||||
} 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;
|
||||
|
||||
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) {
|
||||
const timer = createAuthMeTimer(req);
|
||||
const requestHost =
|
||||
req.headers.get("x-forwarded-host") || req.headers.get("host") || req.nextUrl.host;
|
||||
if (
|
||||
isLocalFullAccessHost(requestHost) ||
|
||||
isLocalFullAccessHost(req.nextUrl.hostname)
|
||||
) {
|
||||
return NextResponse.json(getLocalDevAuthPayload(), {
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
});
|
||||
}
|
||||
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
return finishAuthMeResponse(
|
||||
NextResponse.json(getLocalDevAuthPayload(), {
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
}),
|
||||
timer,
|
||||
"local_full_access",
|
||||
);
|
||||
}
|
||||
|
||||
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 {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 6000);
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${API_BASE}/api/auth/me`, {
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
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) {
|
||||
auth = await timer.measure(
|
||||
"auth_headers",
|
||||
() => buildBackendRequestHeaders(req),
|
||||
);
|
||||
if (
|
||||
hasSupabaseServerEnv() &&
|
||||
!auth.authUserId &&
|
||||
!req.headers.get("authorization")
|
||||
) {
|
||||
const response = NextResponse.json({
|
||||
authenticated: false,
|
||||
subscription_active: false,
|
||||
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) {
|
||||
const raw = await res.text();
|
||||
const raw = await timer.measure("backend_read", () => res.text());
|
||||
if (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);
|
||||
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 = 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);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
applyEntitlementSnapshotFromAuthPayload(response, data);
|
||||
return finishAuthMeResponse(
|
||||
applyAuthResponseCookies(response, auth.response),
|
||||
timer,
|
||||
"ok",
|
||||
{ backendServerTiming },
|
||||
);
|
||||
} catch (error) {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
if (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: String(error),
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
if (auth?.authUserId) {
|
||||
return finishAuthMeResponse(
|
||||
await degradedAuthProfileResponse({
|
||||
email: auth.authEmail || null,
|
||||
reason: String(error),
|
||||
req,
|
||||
response: auth.response,
|
||||
userId: auth.authUserId,
|
||||
}),
|
||||
timer,
|
||||
"exception_degraded",
|
||||
);
|
||||
}
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch auth profile",
|
||||
});
|
||||
const identity = await getBearerIdentityOnce();
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,59 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { NextRequest } from "next/server";
|
||||
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 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) {
|
||||
if (!API_BASE) {
|
||||
const response = NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
return response;
|
||||
return staticCitiesFallback(req, "missing-api-base");
|
||||
}
|
||||
|
||||
return proxyBackendJsonGet(req, {
|
||||
cacheControl: "public, max-age=0, s-maxage=60, stale-while-revalidate=300",
|
||||
publicMessage: "Failed to fetch cities",
|
||||
revalidateSeconds: 60,
|
||||
url: `${API_BASE}/api/cities`,
|
||||
});
|
||||
const abortController = new AbortController();
|
||||
const timeout = setTimeout(() => abortController.abort(), CITIES_BACKEND_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
} from "@/lib/api-proxy";
|
||||
import { buildCachedJsonResponse } from "@/lib/http-cache";
|
||||
import { buildCityDetailProxyCachePolicy } from "@/lib/proxy-cache-policy";
|
||||
import {
|
||||
createProxyTimer,
|
||||
finishProxyTimedResponse,
|
||||
} from "@/lib/proxy-timing";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -34,15 +38,16 @@ export async function GET(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ name: string }> },
|
||||
) {
|
||||
const timer = createProxyTimer(req, "city_detail");
|
||||
if (!API_BASE) {
|
||||
const response = NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ 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 cachePolicy = buildCityDetailProxyCachePolicy(forceRefresh, 15);
|
||||
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()}`;
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req, {
|
||||
includeSupabaseIdentity: false,
|
||||
});
|
||||
const res = await fetch(url, {
|
||||
headers: auth.headers,
|
||||
...(cachePolicy.fetchMode === "no-store"
|
||||
? { cache: "no-store" as const }
|
||||
: { next: { revalidate: cachePolicy.revalidateSeconds ?? 15 } }),
|
||||
});
|
||||
const auth = await timer.measure("auth_headers", () =>
|
||||
buildBackendRequestHeaders(req, {
|
||||
includeSupabaseIdentity: false,
|
||||
}),
|
||||
);
|
||||
const res = await timer.measure("backend_fetch", () =>
|
||||
fetch(url, {
|
||||
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) {
|
||||
const raw = await res.text();
|
||||
const raw = await timer.measure("backend_read", () => res.text());
|
||||
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(
|
||||
req,
|
||||
data,
|
||||
cachePolicy.responseCacheControl,
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
return finishProxyTimedResponse(
|
||||
applyAuthResponseCookies(response, auth.response),
|
||||
timer,
|
||||
"ok",
|
||||
{ backendServerTiming },
|
||||
);
|
||||
} catch (error) {
|
||||
const response = buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch city detail aggregate",
|
||||
});
|
||||
return response;
|
||||
return finishProxyTimedResponse(response, timer, "exception");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
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;
|
||||
|
||||
@@ -17,6 +18,9 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
try {
|
||||
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 days = req.nextUrl.searchParams.get("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",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
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/config` : "";
|
||||
@@ -9,6 +10,9 @@ 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" } });
|
||||
@@ -20,6 +24,9 @@ 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();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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;
|
||||
|
||||
@@ -8,6 +9,9 @@ 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(`${API_BASE}/api/ops/health-check`, { 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" } });
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
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;
|
||||
|
||||
@@ -17,6 +18,9 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
try {
|
||||
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 limit = req.nextUrl.searchParams.get("limit");
|
||||
if (limit) url.searchParams.set("limit", limit);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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;
|
||||
|
||||
@@ -8,6 +9,8 @@ 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 url = new URL(`${API_BASE}/api/ops/memberships/growth`);
|
||||
const days = req.nextUrl.searchParams.get("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,
|
||||
} 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;
|
||||
|
||||
@@ -17,6 +18,8 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
const url = new URL(`${API_BASE}/api/ops/memberships`);
|
||||
const limit = req.nextUrl.searchParams.get("limit");
|
||||
if (limit) url.searchParams.set("limit", limit);
|
||||
|
||||
@@ -4,24 +4,46 @@ import {
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
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;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const timer = createProxyTimer(req, "ops_online_users");
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
return finishProxyTimedResponse(
|
||||
NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
),
|
||||
timer,
|
||||
"missing_api_base",
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const res = await fetch(`${API_BASE}/api/ops/online-users`, {
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
const raw = await res.text();
|
||||
const auth = await timer.measure("auth_headers", () =>
|
||||
buildBackendRequestHeaders(req),
|
||||
);
|
||||
const authError = timer.measureSync("ops_auth", () =>
|
||||
requireOpsProxyAuth(req, auth),
|
||||
);
|
||||
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, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
@@ -29,10 +51,19 @@ export async function GET(req: NextRequest) {
|
||||
"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) {
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch online users",
|
||||
});
|
||||
return finishProxyTimedResponse(
|
||||
buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch online users",
|
||||
}),
|
||||
timer,
|
||||
"exception",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
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;
|
||||
|
||||
@@ -21,6 +22,9 @@ export async function POST(req: NextRequest, context: RouteContext) {
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const { eventId } = await context.params;
|
||||
const res = await fetch(`${API_BASE}/api/ops/payments/incidents/${eventId}/resolve`, {
|
||||
method: "POST",
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
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;
|
||||
|
||||
@@ -17,6 +18,9 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
try {
|
||||
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 limit = req.nextUrl.searchParams.get("limit");
|
||||
if (limit) url.searchParams.set("limit", limit);
|
||||
|
||||
@@ -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 { 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 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 });
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const body = await req.text();
|
||||
const headers: Record<string, string> = { ...auth.headers as Record<string, string>, "Content-Type": "application/json" };
|
||||
if (ENTITLEMENT_TOKEN) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
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;
|
||||
|
||||
@@ -41,6 +42,23 @@ async function findSupabaseUserIdByEmail(email: string) {
|
||||
if (!supabaseUrl || !serviceRoleKey) {
|
||||
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(
|
||||
`${supabaseUrl}/auth/v1/admin/users?filter=${encodeURIComponent(`email.eq.${email}`)}`,
|
||||
{
|
||||
@@ -109,7 +127,7 @@ async function grantSubscriptionDirectly(req: NextRequest, bodyText: string, aut
|
||||
apikey: serviceRoleKey,
|
||||
Authorization: `Bearer ${serviceRoleKey}`,
|
||||
"Content-Type": "application/json",
|
||||
Prefer: "return=representation",
|
||||
Prefer: "return=minimal",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
cache: "no-store",
|
||||
@@ -138,6 +156,9 @@ async function grantSubscriptionDirectly(req: NextRequest, bodyText: string, aut
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const body = await req.text();
|
||||
if (!API_BASE) {
|
||||
return grantSubscriptionDirectly(req, body, auth.authEmail);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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;
|
||||
|
||||
@@ -8,6 +9,9 @@ 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(`${API_BASE}/api/ops/telegram/members-audit`, { 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" } });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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;
|
||||
|
||||
@@ -8,6 +9,9 @@ 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(`${API_BASE}/api/ops/training/accuracy`, { 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" } });
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
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;
|
||||
|
||||
@@ -17,6 +18,9 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const url = new URL(`${API_BASE}/api/ops/truth-history`);
|
||||
for (const key of ["city", "date_from", "date_to", "limit"]) {
|
||||
const value = req.nextUrl.searchParams.get(key);
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
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;
|
||||
|
||||
@@ -17,6 +18,9 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const body = await req.text();
|
||||
const res = await fetch(`${API_BASE}/api/ops/users/grant-points`, {
|
||||
method: "POST",
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
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;
|
||||
|
||||
@@ -17,6 +18,9 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const url = new URL(`${API_BASE}/api/ops/users`);
|
||||
const q = req.nextUrl.searchParams.get("q");
|
||||
const limit = req.nextUrl.searchParams.get("limit");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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;
|
||||
|
||||
@@ -8,6 +9,9 @@ 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 url = new URL(`${API_BASE}/api/ops/logs`);
|
||||
const level = req.nextUrl.searchParams.get("level");
|
||||
const lines = req.nextUrl.searchParams.get("lines");
|
||||
|
||||
@@ -13,7 +13,7 @@ export async function GET(req: NextRequest) {
|
||||
return proxyBackendJsonGet(req, {
|
||||
cacheControl: "public, max-age=0, s-maxage=300, stale-while-revalidate=900",
|
||||
detailLimit: 350,
|
||||
includeSupabaseIdentity: true,
|
||||
includeSupabaseIdentity: false,
|
||||
publicMessage: "Failed to fetch payment config",
|
||||
revalidateSeconds: 300,
|
||||
url: `${API_BASE}/api/payments/config`,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
requireBackendAuthUser,
|
||||
requireBackendPaymentAuth,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
@@ -25,7 +25,7 @@ export async function POST(
|
||||
try {
|
||||
const body = await req.json();
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireBackendAuthUser(auth);
|
||||
const authError = requireBackendPaymentAuth(auth);
|
||||
if (authError) return authError;
|
||||
const proxiedHeaders = new Headers(auth.headers);
|
||||
proxiedHeaders.set("Content-Type", "application/json");
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
requireBackendAuthUser,
|
||||
requireBackendPaymentAuth,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
@@ -25,7 +25,7 @@ export async function POST(
|
||||
try {
|
||||
const body = await req.json();
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireBackendAuthUser(auth);
|
||||
const authError = requireBackendPaymentAuth(auth);
|
||||
if (authError) return authError;
|
||||
const proxiedHeaders = new Headers(auth.headers);
|
||||
proxiedHeaders.set("Content-Type", "application/json");
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
requireBackendAuthUser,
|
||||
requireBackendPaymentAuth,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
@@ -25,7 +25,7 @@ export async function POST(
|
||||
try {
|
||||
const body = await req.json();
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireBackendAuthUser(auth);
|
||||
const authError = requireBackendPaymentAuth(auth);
|
||||
if (authError) return authError;
|
||||
const proxiedHeaders = new Headers(auth.headers);
|
||||
proxiedHeaders.set("Content-Type", "application/json");
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
requireBackendAuthUser,
|
||||
requireBackendPaymentAuth,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
@@ -36,7 +36,7 @@ export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireBackendAuthUser(auth);
|
||||
const authError = requireBackendPaymentAuth(auth);
|
||||
if (authError) return authError;
|
||||
const proxiedHeaders = new Headers(auth.headers);
|
||||
proxiedHeaders.set("Content-Type", "application/json");
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
requireBackendAuthUser,
|
||||
requireBackendPaymentAuth,
|
||||
} from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
|
||||
@@ -18,7 +18,7 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireBackendAuthUser(auth);
|
||||
const authError = requireBackendPaymentAuth(auth);
|
||||
if (authError) return authError;
|
||||
const res = await fetch(`${API_BASE}/api/payments/reconcile-latest`, {
|
||||
method: "POST",
|
||||
|
||||
@@ -16,7 +16,7 @@ export async function GET(req: NextRequest) {
|
||||
conditionalResponse: false,
|
||||
detailLimit: 500,
|
||||
fetchCache: "no-store",
|
||||
includeSupabaseIdentity: true,
|
||||
includeSupabaseIdentity: false,
|
||||
publicMessage: "Failed to fetch payment runtime",
|
||||
url: `${API_BASE}/api/payments/runtime`,
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
requireBackendAuthUser,
|
||||
requireBackendPaymentAuth,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
@@ -21,7 +21,7 @@ export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireBackendAuthUser(auth);
|
||||
const authError = requireBackendPaymentAuth(auth);
|
||||
if (authError) return authError;
|
||||
const proxiedHeaders = new Headers(auth.headers);
|
||||
proxiedHeaders.set("Content-Type", "application/json");
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
requireBackendAuthUser,
|
||||
requireBackendPaymentAuth,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
@@ -45,7 +45,7 @@ export async function DELETE(req: NextRequest) {
|
||||
}
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireBackendAuthUser(auth);
|
||||
const authError = requireBackendPaymentAuth(auth);
|
||||
if (authError) return authError;
|
||||
const proxiedHeaders = new Headers(auth.headers);
|
||||
proxiedHeaders.set("Content-Type", "application/json");
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
requireBackendAuthUser,
|
||||
requireBackendPaymentAuth,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
@@ -21,7 +21,7 @@ export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireBackendAuthUser(auth);
|
||||
const authError = requireBackendPaymentAuth(auth);
|
||||
if (authError) return authError;
|
||||
const proxiedHeaders = new Headers(auth.headers);
|
||||
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);
|
||||
|
||||
try {
|
||||
auth = await buildBackendRequestHeaders(req);
|
||||
auth = await buildBackendRequestHeaders(req, {
|
||||
includeSupabaseIdentity: false,
|
||||
});
|
||||
const headers = new Headers(auth.headers);
|
||||
headers.set("Content-Type", "application/json");
|
||||
headers.set("Accept", "application/json");
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { proxyBackendJsonGet } from "@/lib/api-proxy";
|
||||
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 SCAN_TERMINAL_PROXY_TIMEOUT_MS = Number(
|
||||
@@ -10,10 +15,15 @@ const SCAN_TERMINAL_PROXY_TIMEOUT_MS = Number(
|
||||
export const maxDuration = 45;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const timer = createProxyTimer(req, "scan_terminal");
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
return finishProxyTimedResponse(
|
||||
NextResponse.json(
|
||||
{ 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 !== "") {
|
||||
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()}`;
|
||||
|
||||
@@ -57,6 +70,7 @@ export async function GET(req: NextRequest) {
|
||||
revalidateSeconds: cachePolicy.revalidateSeconds,
|
||||
signal: controller.signal,
|
||||
timeoutPublicMessage: "Scan terminal request timed out",
|
||||
timing: timer,
|
||||
url,
|
||||
});
|
||||
} finally {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { BACKEND_ENTITLEMENT_HEADER } from "@/lib/backend-auth";
|
||||
import { createSupabaseRouteClient, hasSupabaseServerEnv } from "@/lib/supabase/server";
|
||||
import { getConfiguredSiteUrl } from "@/lib/site-url";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
function normalizeNextPath(input: string | null) {
|
||||
const fallback = "/";
|
||||
const raw = String(input || "").trim();
|
||||
@@ -11,6 +14,34 @@ function normalizeNextPath(input: string | null) {
|
||||
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) {
|
||||
const siteUrl = getConfiguredSiteUrl();
|
||||
if (siteUrl) {
|
||||
@@ -38,7 +69,10 @@ export async function GET(request: NextRequest) {
|
||||
const supabase = createSupabaseRouteClient(request, response);
|
||||
const code = request.nextUrl.searchParams.get("code");
|
||||
if (code) {
|
||||
await supabase.auth.exchangeCodeForSession(code);
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.exchangeCodeForSession(code);
|
||||
await warmSignupTrial(session?.access_token || "");
|
||||
}
|
||||
|
||||
return response;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -23,7 +23,7 @@ export const metadata: Metadata = {
|
||||
template: "%s | PolyWeather",
|
||||
},
|
||||
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",
|
||||
metadataBase: new URL("https://polyweather.top"),
|
||||
alternates: {
|
||||
@@ -34,7 +34,7 @@ export const metadata: Metadata = {
|
||||
siteName: "PolyWeather",
|
||||
title: "PolyWeather | Institutional Weather Signal Intelligence",
|
||||
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",
|
||||
images: [
|
||||
{
|
||||
|
||||
+21
-10
@@ -1,12 +1,11 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { PreloadTerminalData } from "@/components/landing/PreloadTerminalData";
|
||||
import { InstitutionalLandingPage } from "@/components/landing/InstitutionalLandingPage";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "PolyWeather | Institutional Weather Signal Intelligence",
|
||||
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: {
|
||||
preconnect: "https://api.polyweather.top",
|
||||
},
|
||||
@@ -38,16 +37,29 @@ export default async function HomePage({
|
||||
"@type": "WebApplication",
|
||||
name: "PolyWeather",
|
||||
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",
|
||||
applicationCategory: "BusinessApplication",
|
||||
operatingSystem: "Web",
|
||||
offers: {
|
||||
"@type": "Offer",
|
||||
price: "10.00",
|
||||
priceCurrency: "USD",
|
||||
description: "Monthly subscription",
|
||||
},
|
||||
offers: [
|
||||
{
|
||||
"@type": "Offer",
|
||||
name: "Pro monthly",
|
||||
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 (
|
||||
@@ -56,7 +68,6 @@ export default async function HomePage({
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
<PreloadTerminalData />
|
||||
<InstitutionalLandingPage />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -12,11 +12,6 @@ import {
|
||||
} from "lucide-react";
|
||||
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 = [
|
||||
{
|
||||
q_zh: "Pro 包含哪些功能?",
|
||||
@@ -27,14 +22,14 @@ const FAQ_ITEMS = [
|
||||
{
|
||||
q_zh: "当前订阅价格是多少?",
|
||||
q_en: "What is the current subscription price?",
|
||||
a_zh: "目前仅提供月付:10 USDC / 30 天。",
|
||||
a_en: "Monthly plan only: 10 USDC / 30 days.",
|
||||
a_zh: "Pro 月付 29.9 USDC / 30 天,Pro 季度 79.9 USDC / 90 天。",
|
||||
a_en: "Pro monthly is 29.9 USDC / 30 days. Pro quarterly is 79.9 USDC / 90 days.",
|
||||
},
|
||||
{
|
||||
q_zh: "积分如何抵扣?",
|
||||
q_en: "How do points work for discounts?",
|
||||
a_zh: "满 500 积分起兑,每 500 积分抵 1U,单次最多抵 3U。",
|
||||
a_en: "500 points minimum — every 500 points = 1 USDC off, up to 3 USDC discount per payment.",
|
||||
a_zh: "满 500 积分起兑,每 500 积分抵 1U。月付最多抵 3U,季度最多抵 8U。",
|
||||
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: "支持哪些钱包和支付方式?",
|
||||
@@ -55,11 +50,11 @@ export function SubscriptionHelpClient() {
|
||||
? "Complete subscription rules and payment guide."
|
||||
: "这里是完整的订阅规则和支付说明。你可以先在页面内绑定钱包,再直接开通 Pro。",
|
||||
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" : "积分抵扣",
|
||||
discountText: isEn ? "Up to 3 USDC off" : "最多抵 3U",
|
||||
communityLabel: isEn ? "Community Points" : "社群积分",
|
||||
communityLink: isEn ? "Join community to earn" : "加入社群即可赚取积分",
|
||||
discountText: isEn ? "Monthly 3U · Quarterly 8U" : "月付 3U · 季度 8U",
|
||||
communityLabel: isEn ? "Telegram Group" : "Telegram 群",
|
||||
communityLink: isEn ? "Open Account Center" : "前往账户中心",
|
||||
faqTitle: isEn ? "FAQ" : "常见问题",
|
||||
}), [isEn]);
|
||||
|
||||
@@ -102,8 +97,7 @@ export function SubscriptionHelpClient() {
|
||||
<span className="text-sm font-semibold">{copy.communityLabel}</span>
|
||||
</div>
|
||||
<Link
|
||||
href={TELEGRAM_GROUP_URL}
|
||||
target="_blank"
|
||||
href="/account"
|
||||
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}
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
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 = {
|
||||
title: "PolyWeather Terminal | Paid Product",
|
||||
|
||||
@@ -84,6 +84,8 @@ export function AccountCenter() {
|
||||
const [updatedAt, setUpdatedAt] = useState<string>("");
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [backend, setBackend] = useState<AuthMeResponse | null>(null);
|
||||
const [referralCodeInput, setReferralCodeInput] = useState("");
|
||||
const [referralApplying, setReferralApplying] = useState(false);
|
||||
|
||||
const supabaseReady = hasSupabasePublicEnv();
|
||||
const walletConnectEnabled = Boolean(WALLETCONNECT_PROJECT_ID);
|
||||
@@ -124,6 +126,7 @@ export function AccountCenter() {
|
||||
boundWallets,
|
||||
walletAddress,
|
||||
selectedPlanCode,
|
||||
selectedPaymentChainId,
|
||||
selectedTokenAddress,
|
||||
selectedWallet,
|
||||
providerMode,
|
||||
@@ -133,6 +136,8 @@ export function AccountCenter() {
|
||||
|
||||
// Shared setters
|
||||
setSelectedTokenAddress,
|
||||
setSelectedPaymentChainId,
|
||||
setSelectedPlanCode,
|
||||
setSelectedWallet,
|
||||
setSelectedInjectedProviderKey,
|
||||
setProviderMode,
|
||||
@@ -149,6 +154,8 @@ export function AccountCenter() {
|
||||
selectedPaymentToken,
|
||||
selectedTokenLabel,
|
||||
availableTokenList,
|
||||
availableChainList,
|
||||
selectedPaymentChain,
|
||||
effectivePlanList,
|
||||
resolvedSelectedTokenAddress,
|
||||
paymentReceiverAddress,
|
||||
@@ -189,19 +196,49 @@ export function AccountCenter() {
|
||||
useEffect(() => {
|
||||
if (!authIsAuthenticated || !authUserId) return;
|
||||
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")) {
|
||||
trackAppEvent("signup_completed", {
|
||||
entry: "account_center",
|
||||
user_id: authUserId,
|
||||
});
|
||||
}
|
||||
if (markAnalyticsOnce(`signup_success:${actorKey}`, "local")) {
|
||||
trackAppEvent("signup_success", {
|
||||
entry: "account_center",
|
||||
user_id: authUserId,
|
||||
});
|
||||
}
|
||||
if (markAnalyticsOnce(`dashboard_active:${actorKey}`, "session")) {
|
||||
trackAppEvent("dashboard_active", {
|
||||
entry: "account_center",
|
||||
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 ──────────────────────────────
|
||||
useEffect(() => {
|
||||
@@ -295,8 +332,16 @@ export function AccountCenter() {
|
||||
backend.authenticated === false),
|
||||
);
|
||||
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
|
||||
? copy.proMember
|
||||
? isTrialSubscription
|
||||
? copy.trialBadge
|
||||
: copy.proMember
|
||||
: isSubscriptionUnknown
|
||||
? copy.subscriptionChecking
|
||||
: isEn
|
||||
@@ -321,7 +366,8 @@ export function AccountCenter() {
|
||||
const hasQueuedExtension = Boolean(
|
||||
isSubscribed && queuedExtensionDays > 0,
|
||||
);
|
||||
const canAccessPaidTelegramGroup = Boolean(isSubscribed);
|
||||
const canAccessPaidTelegramGroup = Boolean(isSubscribed && !isTrialSubscription);
|
||||
const hasTelegramPanel = Boolean(isTrialSubscription || canAccessPaidTelegramGroup);
|
||||
const telegramBound =
|
||||
Number(backend?.telegram_pricing?.telegram_id || 0) > 0;
|
||||
const displayExpiryRaw = isSubscribed
|
||||
@@ -380,6 +426,21 @@ export function AccountCenter() {
|
||||
const expiryLabel = hasQueuedExtension
|
||||
? copy.accessUntil
|
||||
: 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 ──────────────────────
|
||||
useEffect(() => {
|
||||
@@ -400,20 +461,31 @@ export function AccountCenter() {
|
||||
showOverlay,
|
||||
]);
|
||||
|
||||
// ── Weekly points display (component-only derived) ──────
|
||||
const backendWeeklyPointsRaw = Number(backend?.weekly_points);
|
||||
const metadataWeeklyPointsRaw = Number(
|
||||
user?.user_metadata?.weekly_points ?? 0,
|
||||
);
|
||||
const weeklyPointsRaw = Number.isFinite(backendWeeklyPointsRaw)
|
||||
? backendWeeklyPointsRaw
|
||||
: metadataWeeklyPointsRaw;
|
||||
const weeklyRankRaw =
|
||||
backend?.weekly_rank ?? user?.user_metadata?.weekly_rank;
|
||||
const weeklyPoints = Number.isFinite(weeklyPointsRaw)
|
||||
? Math.max(0, weeklyPointsRaw)
|
||||
// ── Referral points display ────────────────────────────
|
||||
const referralRewardPointsRaw = Number(referral?.reward_points ?? 3500);
|
||||
const referralRewardPoints = Number.isFinite(referralRewardPointsRaw)
|
||||
? Math.max(0, referralRewardPointsRaw)
|
||||
: 3500;
|
||||
const monthlyReferralCountRaw = Number(referral?.monthly_reward_count ?? 0);
|
||||
const monthlyReferralCount = Number.isFinite(monthlyReferralCountRaw)
|
||||
? Math.max(0, monthlyReferralCountRaw)
|
||||
: 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 ──────────────────────────────
|
||||
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 ────────────────────────────────────────────
|
||||
|
||||
if (loading && !refreshing) {
|
||||
@@ -611,7 +732,7 @@ export function AccountCenter() {
|
||||
</p>
|
||||
<p className="flex items-center justify-center gap-2 text-xl font-bold text-slate-950">
|
||||
<TrendingUp size={16} className="text-emerald-400" />{" "}
|
||||
{weeklyPoints.toLocaleString()}
|
||||
{monthlyReferralCount.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<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 className="flex items-center justify-center gap-2 text-xl font-bold text-slate-950">
|
||||
<Trophy size={16} className="text-amber-400" />{" "}
|
||||
{weeklyRank === "--" ? weeklyRank : `#${weeklyRank}`}
|
||||
{monthlyReferralCount}/{monthlyReferralLimit}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Weekly Ranking Motivation */}
|
||||
{/* Referral rewards */}
|
||||
{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>
|
||||
@@ -637,35 +758,29 @@ export function AccountCenter() {
|
||||
<div className="space-y-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">
|
||||
<div className="w-5 h-5 bg-yellow-500 rounded text-black font-bold text-[10px] flex items-center justify-center">
|
||||
1
|
||||
</div>{" "}
|
||||
Top 1
|
||||
<Coins size={16} className="text-yellow-500" />{" "}
|
||||
{copy.referralRewardHint}
|
||||
</span>
|
||||
<span className="text-xs font-bold text-amber-600">
|
||||
+200 积分 & 7天Pro
|
||||
+{referralRewardPoints.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<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">
|
||||
<div className="w-5 h-5 bg-slate-300 rounded text-black font-bold text-[10px] flex items-center justify-center">
|
||||
2
|
||||
</div>{" "}
|
||||
Top 2-3
|
||||
<TrendingUp size={16} className="text-emerald-500" />{" "}
|
||||
{copy.weeklyPoints}
|
||||
</span>
|
||||
<span className="text-xs font-bold text-slate-600">
|
||||
+100 积分
|
||||
{monthlyReferralCount}/{monthlyReferralLimit}
|
||||
</span>
|
||||
</div>
|
||||
<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">
|
||||
<div className="w-5 h-5 bg-orange-800 rounded text-white font-bold text-[10px] flex items-center justify-center">
|
||||
4
|
||||
</div>{" "}
|
||||
Top 4-10
|
||||
<Trophy size={16} className="text-blue-500" />{" "}
|
||||
{copy.totalPoints}
|
||||
</span>
|
||||
<span className="text-xs font-bold text-orange-400">
|
||||
+50 积分
|
||||
{monthlyReferralPoints.toLocaleString()}/{monthlyReferralPointsLimit.toLocaleString()}
|
||||
</span>
|
||||
</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">
|
||||
<Info size={14} className="text-slate-500 mt-0.5 shrink-0" />
|
||||
<p className="text-[10px] text-slate-500 leading-normal italic">
|
||||
积分规则:群内有效发言(自动防刷检测)+
|
||||
每日首条发言额外奖励。每周一零点结算周榜,所有活跃用户均享参与奖。
|
||||
{copy.pointsRule}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -810,7 +924,7 @@ export function AccountCenter() {
|
||||
errorText={paymentError || undefined}
|
||||
infoText={paymentInfo || undefined}
|
||||
txHash={lastTxHash || undefined}
|
||||
chainId={paymentConfig?.chain_id || 137}
|
||||
chainId={selectedPaymentChainId || paymentConfig?.chain_id || 137}
|
||||
paymentTokenLabel={selectedTokenLabel}
|
||||
faqHref={SUBSCRIPTION_HELP_HREF}
|
||||
telegramGroupUrl=""
|
||||
@@ -821,9 +935,38 @@ export function AccountCenter() {
|
||||
|
||||
{/* Telegram Bot Section & Payment Details */}
|
||||
{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 && (
|
||||
<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
|
||||
size={140}
|
||||
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 */}
|
||||
<section
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
<section className="w-full rounded-2xl border border-slate-200 bg-white p-6 shadow-sm lg:p-7">
|
||||
<div>
|
||||
<h3 className="mb-6 flex items-center gap-2 text-sm font-bold uppercase text-blue-700">
|
||||
<Wallet size={18} /> {copy.paymentMgmt}
|
||||
@@ -939,117 +1078,287 @@ export function AccountCenter() {
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mb-5 space-y-3">
|
||||
<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={chainIdToDisplayName(paymentConfig?.chain_id)}
|
||||
/>
|
||||
<InfoRow
|
||||
icon={ExternalLink}
|
||||
label={copy.paymentHost}
|
||||
value={currentPaymentHost || "--"}
|
||||
/>
|
||||
<p className="text-[11px] text-slate-500">
|
||||
{copy.paymentGuardHint}
|
||||
</p>
|
||||
</div>
|
||||
{availableTokenList.length > 0 && (
|
||||
<div className="mb-5">
|
||||
<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.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
|
||||
data-testid="payment-management-grid"
|
||||
className={`grid gap-6 lg:items-start ${
|
||||
hasTelegramPanel ? "" : "lg:grid-cols-[minmax(0,1fr)_minmax(320px,380px)]"
|
||||
}`}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<p className="mb-2 text-[11px] uppercase text-slate-500">
|
||||
{copy.proPlan}
|
||||
</p>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{displayPlanList.map((plan) => {
|
||||
const code = String(plan.plan_code || "");
|
||||
const active = code === selectedPlanCode;
|
||||
const isQuarterly = code === "pro_quarterly";
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={code}
|
||||
onClick={() => setSelectedPlanCode(code)}
|
||||
disabled={paymentBusy}
|
||||
className={`rounded-xl border px-3 py-3 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="flex items-center justify-between gap-2 text-xs font-bold">
|
||||
<span>
|
||||
{isQuarterly ? copy.quarterlyPlan : copy.monthlyPlan}
|
||||
</span>
|
||||
<span>{plan.amount_usdc} USDC</span>
|
||||
</div>
|
||||
<div className="mt-1 text-[10px] opacity-80">
|
||||
{code} · {plan.duration_days} 天
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{appliedReferralCode && selectedPlanCode === "pro_monthly" ? (
|
||||
<p className="mt-2 rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2 text-[11px] text-emerald-800">
|
||||
{copy.referralDiscountHint}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<p className="text-xs font-bold uppercase text-slate-700">
|
||||
{copy.referralTitle}
|
||||
</p>
|
||||
<span className="text-[10px] text-slate-500">
|
||||
{copy.referralInviteLimit}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2">
|
||||
<div>
|
||||
<p className="mb-1 text-[10px] uppercase text-slate-500">
|
||||
{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 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>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Payment Method Tabs */}
|
||||
<div className="mt-6 border-t border-slate-200 pt-6">
|
||||
<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 className="space-y-5">
|
||||
{availableChainList.length > 1 && (
|
||||
<div>
|
||||
<p className="mb-2 text-[11px] uppercase text-slate-500">
|
||||
{copy.paymentNetwork}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{availableChainList.map((chain) => {
|
||||
const active = chain.chain_id === selectedPaymentChainId;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={chain.chain_id}
|
||||
onClick={() => {
|
||||
setSelectedPaymentChainId(chain.chain_id);
|
||||
const nextToken =
|
||||
paymentConfig?.tokens?.find(
|
||||
(token) =>
|
||||
Number(token.chain_id || chain.chain_id) ===
|
||||
chain.chain_id &&
|
||||
token.is_default,
|
||||
) ||
|
||||
paymentConfig?.tokens?.find(
|
||||
(token) =>
|
||||
Number(token.chain_id || chain.chain_id) ===
|
||||
chain.chain_id,
|
||||
);
|
||||
if (nextToken?.address) {
|
||||
setSelectedTokenAddress(
|
||||
String(nextToken.address).toLowerCase(),
|
||||
);
|
||||
}
|
||||
}}
|
||||
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">
|
||||
{chain.name || chainIdToDisplayName(chain.chain_id)}
|
||||
</div>
|
||||
<div className="text-[10px] opacity-80">
|
||||
{chain.native_currency_symbol || "ETH"} gas
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</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 ? (
|
||||
<div className="space-y-3">
|
||||
@@ -1073,7 +1382,7 @@ export function AccountCenter() {
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[10px]">
|
||||
{copy.polygonChain}
|
||||
{chainIdToDisplayName(w.chain_id)}
|
||||
</div>
|
||||
<div className="mt-2 flex justify-end">
|
||||
<button
|
||||
@@ -1298,8 +1607,8 @@ export function AccountCenter() {
|
||||
}
|
||||
disabled={
|
||||
paymentBusy ||
|
||||
(txValidation.checked &&
|
||||
!txValidation.valid)
|
||||
!(txValidation.checked &&
|
||||
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"
|
||||
>
|
||||
@@ -1312,6 +1621,8 @@ export function AccountCenter() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</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"),
|
||||
"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(
|
||||
accountCenterSource.includes("EXPECTED_PAYMENT_RECEIVER_ADDRESS") ||
|
||||
hookSource.includes("EXPECTED_PAYMENT_RECEIVER_ADDRESS") ||
|
||||
@@ -83,6 +100,11 @@ export function runTests() {
|
||||
backendAuthSource.includes("requireBackendAuthUser"),
|
||||
"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");
|
||||
assert(
|
||||
@@ -90,12 +112,113 @@ export function runTests() {
|
||||
!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",
|
||||
);
|
||||
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) {
|
||||
const routeSource = fs.readFileSync(path.join(projectRoot, route), "utf8");
|
||||
assert(
|
||||
routeSource.includes("requireBackendAuthUser"),
|
||||
`${route} must reject payment mutations without a real Supabase user`,
|
||||
routeSource.includes("requireBackendPaymentAuth") &&
|
||||
!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)
|
||||
? fs.readFileSync(hookPath, "utf8")
|
||||
: "";
|
||||
const paymentFlowSource = fs.readFileSync(
|
||||
path.join(accountDir, "usePaymentFlow.ts"),
|
||||
"utf8",
|
||||
);
|
||||
assert(
|
||||
(accountCenterSource.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"),
|
||||
"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(
|
||||
path.join(
|
||||
projectRoot,
|
||||
@@ -102,6 +126,26 @@ export function runTests() {
|
||||
),
|
||||
"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(
|
||||
accountCenterSource.includes(
|
||||
@@ -133,6 +177,22 @@ export function runTests() {
|
||||
accountFeatureSource.includes("只有 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(
|
||||
!appAnalyticsSource.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"'),
|
||||
"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(
|
||||
accountCenterSource.includes("isSubscriptionUnknown") &&
|
||||
accountCenterSource.includes("subscriptionStatusLabel") &&
|
||||
@@ -157,6 +238,18 @@ export function runTests() {
|
||||
hookSource.includes("retriedBackendJson"),
|
||||
"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(
|
||||
subscriptionsPageSource.includes("getSupabaseBrowserClient") &&
|
||||
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",
|
||||
);
|
||||
assert(
|
||||
grantRouteSource.includes("grantSubscriptionDirectly") &&
|
||||
grantRouteSource.includes("res.status === 404") &&
|
||||
grantRouteSource.includes('"status": "active"'),
|
||||
"ops subscription grant route must fall back to direct Supabase grant when the VPS backend route is missing",
|
||||
membershipsPageSource.includes("opsApi.membershipsOverview") &&
|
||||
!membershipsPageSource.includes('fetch("/api/ops/memberships/growth?days=90"') &&
|
||||
!membershipsPageSource.includes("Promise.all"),
|
||||
"ops memberships page must load memberships and growth through one overview proxy request to avoid duplicate Supabase session/subscription reads",
|
||||
);
|
||||
assert(
|
||||
authMeRouteSource.includes("if ((res.status === 401 || res.status === 403) && auth.authUserId)") &&
|
||||
authMeRouteSource.includes("degraded_reason: `backend_${res.status}`") &&
|
||||
opsOverviewSource.includes("opsApi.membershipsOverview(200, 30)") &&
|
||||
!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"),
|
||||
"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",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,9 +10,9 @@ export function createAccountCopy(isEn: boolean): Record<string, string> {
|
||||
guestUser: isEn ? "Signed-out account" : "未登录账户",
|
||||
joinedAt: isEn ? "Joined" : "加入时间",
|
||||
totalPoints: isEn ? "Total Points" : "总积分 (荣誉)",
|
||||
weeklyPoints: isEn ? "Weekly Points" : "本周积分 (竞技)",
|
||||
weeklyRank: isEn ? "Weekly Rank" : "周排行 (竞技)",
|
||||
weeklyRewards: isEn ? "Weekly Rewards" : "周榜奖励",
|
||||
weeklyPoints: isEn ? "This Month Invites" : "本月有效邀请",
|
||||
weeklyRank: isEn ? "Monthly Cap" : "月度上限",
|
||||
weeklyRewards: isEn ? "Referral Rewards" : "邀请奖励",
|
||||
membershipDetails: isEn ? "Membership Details" : "会员权限详情",
|
||||
identityStatus: isEn ? "Identity Status" : "身份状态",
|
||||
authMode: isEn ? "Auth Mode" : "鉴权模式",
|
||||
@@ -57,6 +57,31 @@ export function createAccountCopy(isEn: boolean): Record<string, string> {
|
||||
: "城市实测温度群",
|
||||
copyCommand: isEn ? "Copy fallback command" : "复制兜底命令",
|
||||
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" : "支付币种",
|
||||
paymentAccount: isEn ? "Subscription Account" : "订阅归属账号",
|
||||
paymentWallet: isEn ? "Paying Wallet" : "付款钱包",
|
||||
@@ -218,8 +243,8 @@ export function createAccountCopy(isEn: boolean): Record<string, string> {
|
||||
? "Current receiver contract: {address}"
|
||||
: "当前收款合约: {address}",
|
||||
manualOrderCreated: isEn
|
||||
? "Manual transfer order created. Please send {amount} USDC to the receiver on Polygon network. Then submit your tx hash below."
|
||||
: "手动转账订单已创建:请在 Polygon 网络转 {amount} USDC 到收款地址。完成后在下方提交 tx hash。",
|
||||
? "Manual transfer order created. Send {amount} {symbol} on {chain} to {receiver}, then submit your tx hash below."
|
||||
: "手动转账订单已创建:请在 {chain} 转 {amount} {symbol} 到 {receiver},完成后在下方提交 tx hash。",
|
||||
manualOrderRequired: isEn
|
||||
? "Please create a manual transfer order first."
|
||||
: "请先创建手动转账订单。",
|
||||
@@ -246,8 +271,8 @@ export function createAccountCopy(isEn: boolean): Record<string, string> {
|
||||
verifyUnknown: isEn ? "Unknown error" : "未知错误",
|
||||
// ── Telegram bind messages ────────────────────────────────────────
|
||||
telegramVerifySuccess: isEn
|
||||
? "Telegram group membership verified. Current membership price: {amount} U."
|
||||
: "Telegram 群成员验证成功,当前会员价 {amount}U。",
|
||||
? "Telegram group membership verified. Checkout follows the selected Pro plan."
|
||||
: "Telegram 群成员验证成功,结算金额以当前选择的 Pro 套餐为准。",
|
||||
telegramBindClickHint: isEn
|
||||
? "Open the Telegram Bot, click Start, and confirm binding. Then refresh this page to request group entry."
|
||||
: "已打开 Telegram Bot,请在 Bot 内点击 Start 并确认绑定;完成后刷新本页再申请入群。",
|
||||
@@ -309,8 +334,8 @@ export function createAccountCopy(isEn: boolean): Record<string, string> {
|
||||
: "加载支付配置失败: {raw}",
|
||||
// ── Points / Footer ────────────────────────────────────────────────
|
||||
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
|
||||
? "Available: {points} points discounting ${amount}. Applied automatically on renewal."
|
||||
: "当前可用 {points} 积分抵扣 ${amount},续费时会自动生效。",
|
||||
|
||||
@@ -7,6 +7,12 @@ export function chainIdToDisplayName(chainId: number | undefined | null): string
|
||||
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) {
|
||||
if (!value) return "--";
|
||||
try {
|
||||
|
||||
@@ -10,12 +10,31 @@ export type AuthMeResponse = {
|
||||
subscription_required?: boolean;
|
||||
subscription_active?: boolean | null;
|
||||
subscription_plan_code?: string | null;
|
||||
subscription_source?: string | null;
|
||||
subscription_is_trial?: boolean | null;
|
||||
subscription_starts_at?: string | null;
|
||||
subscription_expires_at?: string | null;
|
||||
subscription_total_expires_at?: string | null;
|
||||
subscription_queued_days?: number | null;
|
||||
subscription_queued_count?: number | 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 = {
|
||||
@@ -40,7 +59,24 @@ export type PaymentTokenOption = {
|
||||
name: string;
|
||||
address: string;
|
||||
decimals: number;
|
||||
chain_id?: number;
|
||||
chain_code?: string;
|
||||
chain_name?: 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;
|
||||
};
|
||||
|
||||
@@ -48,15 +84,18 @@ export type PointsRedemptionConfig = {
|
||||
enabled?: boolean;
|
||||
points_per_usdc?: number;
|
||||
max_discount_usdc?: number;
|
||||
max_discount_usdc_by_plan?: Record<string, number>;
|
||||
};
|
||||
|
||||
export type PaymentConfig = {
|
||||
enabled?: boolean;
|
||||
configured?: boolean;
|
||||
chain_id?: number;
|
||||
default_chain_id?: number;
|
||||
token_address?: string;
|
||||
token_decimals?: number;
|
||||
default_token_address?: string;
|
||||
chains?: PaymentChainOption[];
|
||||
tokens?: PaymentTokenOption[];
|
||||
receiver_contract?: string;
|
||||
confirmations?: number;
|
||||
@@ -93,6 +132,7 @@ export type CreatedIntent = {
|
||||
direct_payment?: {
|
||||
chain_id: number;
|
||||
chain?: string;
|
||||
chain_name?: string;
|
||||
token_symbol?: string;
|
||||
token_address: string;
|
||||
token_decimals?: number;
|
||||
@@ -101,6 +141,7 @@ export type CreatedIntent = {
|
||||
amount_usdc: string;
|
||||
intent_id: string;
|
||||
expires_at: string;
|
||||
explorer_tx_url?: string;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -146,23 +146,25 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
|
||||
[supabaseReady, getValidAccessToken],
|
||||
);
|
||||
|
||||
// ── loadSnapshot ──────────────────────────────────────────
|
||||
const loadSnapshot = useCallback(async () => {
|
||||
setErrorText("");
|
||||
const attempt = async (retry: boolean): Promise<void> => {
|
||||
// ── Auth snapshot refresh ────────────────────────────────
|
||||
const fetchAuthSnapshot = useCallback(
|
||||
async (retry: boolean): Promise<AuthMeResponse> => {
|
||||
const userPromise = supabaseReady
|
||||
? getSupabaseBrowserClient().auth.getUser()
|
||||
: Promise.resolve({ data: { user: null as User | null } });
|
||||
? getSupabaseBrowserClient()
|
||||
.auth.getSession()
|
||||
.then(({ data: { session } }) => session?.user ?? null)
|
||||
.catch(() => null as User | null)
|
||||
: Promise.resolve(null as User | null);
|
||||
const authHeadersPromise = buildAuthedHeaders(false);
|
||||
const backendPromise = authHeadersPromise.then((headers) =>
|
||||
fetch("/api/auth/me", { cache: "no-store", headers }),
|
||||
);
|
||||
const [userResult, backendResult] = await Promise.all([userPromise, backendPromise]);
|
||||
setUser(userResult.data?.user ?? null);
|
||||
const [localUser, backendResult] = await Promise.all([userPromise, backendPromise]);
|
||||
setUser(localUser);
|
||||
if (!backendResult.ok) {
|
||||
if (retry && backendResult.status === 401) {
|
||||
await new Promise((r) => setTimeout(r, 1200));
|
||||
return attempt(false);
|
||||
return fetchAuthSnapshot(false);
|
||||
}
|
||||
const raw = (await backendResult.text()).slice(0, 260);
|
||||
throw new Error(copy.httpError.replace("{status}", String(backendResult.status)).replace("{raw}", raw));
|
||||
@@ -171,7 +173,7 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
|
||||
if (
|
||||
retry &&
|
||||
supabaseReady &&
|
||||
userResult.data?.user &&
|
||||
localUser &&
|
||||
backendJson.authenticated === false
|
||||
) {
|
||||
try {
|
||||
@@ -199,10 +201,41 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
|
||||
}
|
||||
setBackend(backendJson);
|
||||
setUpdatedAt(new Date().toISOString());
|
||||
};
|
||||
try { await attempt(true); }
|
||||
return backendJson;
|
||||
},
|
||||
[
|
||||
buildAuthedHeaders,
|
||||
copy.httpError,
|
||||
setBackend,
|
||||
setUpdatedAt,
|
||||
setUser,
|
||||
supabaseReady,
|
||||
],
|
||||
);
|
||||
|
||||
// ── loadSnapshot ──────────────────────────────────────────
|
||||
const loadSnapshot = useCallback(async () => {
|
||||
setErrorText("");
|
||||
try { await fetchAuthSnapshot(true); }
|
||||
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 ───────────────────────────
|
||||
// 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 [walletAddress, setWalletAddress] = useState("");
|
||||
const [selectedPlanCode, setSelectedPlanCode] = useState("pro_monthly");
|
||||
const [selectedPaymentChainId, setSelectedPaymentChainId] = useState<number | null>(null);
|
||||
const [selectedTokenAddress, setSelectedTokenAddress] = useState("");
|
||||
const [selectedWallet, setSelectedWallet] = useState("");
|
||||
const [providerMode, setProviderMode] = useState<ProviderMode>("auto");
|
||||
@@ -218,7 +252,7 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
|
||||
const [selectedInjectedProviderKey, setSelectedInjectedProviderKey] = useState("");
|
||||
|
||||
// ── Chain ID derived from payment config ────────────────
|
||||
const chainId = paymentConfig?.chain_id ?? 137;
|
||||
const chainId = selectedPaymentChainId || paymentConfig?.default_chain_id || paymentConfig?.chain_id || 137;
|
||||
|
||||
// ── loadPaymentSnapshot ──────────────────────────────────
|
||||
// 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)
|
||||
? 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(
|
||||
configJson.default_token_address ||
|
||||
tokenOptionsForChain.find((row) => row.is_default)?.address ||
|
||||
tokenOptionsForChain[0]?.address ||
|
||||
tokenOptions.find((row) => row.is_default)?.address ||
|
||||
tokenOptions[0]?.address ||
|
||||
configJson.token_address || "",
|
||||
@@ -283,15 +344,13 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
|
||||
backend?.authenticated,
|
||||
buildAuthedHeaders,
|
||||
selectedPlanCode,
|
||||
selectedPaymentChainId,
|
||||
selectedWallet,
|
||||
walletAddress,
|
||||
]);
|
||||
|
||||
// ── Selected plan (derived, shared across sub-hooks) ───
|
||||
const monthlyPlanList = (paymentConfig?.plans || []).filter(
|
||||
(p) => String(p.plan_code || "").trim().toLowerCase() === "pro_monthly",
|
||||
);
|
||||
const effectivePlanList = monthlyPlanList.length ? monthlyPlanList : (paymentConfig?.plans || []);
|
||||
const effectivePlanList = paymentConfig?.plans || [];
|
||||
const selectedPlan = effectivePlanList.find((p) => p.plan_code === selectedPlanCode) || effectivePlanList[0];
|
||||
|
||||
// ── useWalletBind ──────────────────────────────────────
|
||||
@@ -354,6 +413,7 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
|
||||
getValidAccessToken,
|
||||
buildAuthedHeaders,
|
||||
loadSnapshot,
|
||||
refreshEntitlementAfterPayment,
|
||||
loadPaymentSnapshot: loadPaymentSnapshotImpl,
|
||||
user,
|
||||
});
|
||||
@@ -371,6 +431,8 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
|
||||
setPaymentConfig,
|
||||
selectedPlanCode,
|
||||
setSelectedPlanCode,
|
||||
selectedPaymentChainId: chainId,
|
||||
setSelectedPaymentChainId,
|
||||
selectedTokenAddress,
|
||||
setSelectedTokenAddress,
|
||||
boundWallets,
|
||||
@@ -407,6 +469,7 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
|
||||
getValidAccessToken,
|
||||
buildAuthedHeaders,
|
||||
loadSnapshot,
|
||||
refreshEntitlementAfterPayment,
|
||||
loadPaymentSnapshot: loadPaymentSnapshotImpl,
|
||||
waitForReceipt: walletBind.waitForReceipt,
|
||||
ensureTargetChain: walletBind.ensureTargetChain,
|
||||
@@ -451,6 +514,7 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
|
||||
boundWallets,
|
||||
walletAddress,
|
||||
selectedPlanCode,
|
||||
selectedPaymentChainId: chainId,
|
||||
selectedTokenAddress,
|
||||
selectedWallet,
|
||||
providerMode,
|
||||
@@ -460,6 +524,8 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
|
||||
|
||||
// Setters for shared state
|
||||
setSelectedTokenAddress,
|
||||
setSelectedPaymentChainId,
|
||||
setSelectedPlanCode,
|
||||
setSelectedWallet,
|
||||
setSelectedInjectedProviderKey,
|
||||
setProviderMode,
|
||||
@@ -476,6 +542,8 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
|
||||
selectedPaymentToken: paymentFlow.selectedPaymentToken,
|
||||
selectedTokenLabel: paymentFlow.selectedTokenLabel,
|
||||
availableTokenList: paymentFlow.availableTokenList,
|
||||
availableChainList: paymentFlow.availableChainList,
|
||||
selectedPaymentChain: paymentFlow.selectedPaymentChain,
|
||||
effectivePlanList,
|
||||
resolvedSelectedTokenAddress: paymentFlow.resolvedSelectedTokenAddress,
|
||||
paymentReceiverAddress: paymentFlow.paymentReceiverAddress,
|
||||
|
||||
@@ -53,6 +53,7 @@ export interface UseBillingParams {
|
||||
getValidAccessToken: () => Promise<string>;
|
||||
buildAuthedHeaders: (withJson?: boolean, requireAuth?: boolean) => Promise<Record<string, string>>;
|
||||
loadSnapshot: () => Promise<void>;
|
||||
refreshEntitlementAfterPayment: () => Promise<void>;
|
||||
loadPaymentSnapshot: () => Promise<void>;
|
||||
|
||||
// User for metadata points
|
||||
@@ -86,6 +87,7 @@ export function useBilling(params: UseBillingParams) {
|
||||
getValidAccessToken,
|
||||
buildAuthedHeaders,
|
||||
loadSnapshot,
|
||||
refreshEntitlementAfterPayment,
|
||||
loadPaymentSnapshot,
|
||||
user,
|
||||
} = params;
|
||||
@@ -116,10 +118,31 @@ export function useBilling(params: UseBillingParams) {
|
||||
|
||||
// ── Billing ──────────────────────────────────────────────
|
||||
const billing = useMemo(() => {
|
||||
const parsedPlanAmount = Number(
|
||||
backend?.telegram_pricing?.amount_usdc ?? selectedPlan?.amount_usdc ?? 10,
|
||||
const listAmountRaw = Number(selectedPlan?.amount_usdc ?? 29.9);
|
||||
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 pointsEnabled = pointsCfg.enabled !== false;
|
||||
@@ -127,7 +150,10 @@ export function useBilling(params: UseBillingParams) {
|
||||
const pointsPerUsdc =
|
||||
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(
|
||||
0,
|
||||
Math.min(
|
||||
@@ -137,14 +163,18 @@ export function useBilling(params: UseBillingParams) {
|
||||
);
|
||||
|
||||
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 pointsUsed = discountUnits * pointsPerUsdc;
|
||||
const canRedeem = pointsEnabled && maxDiscountUsdc > 0 && totalPoints >= pointsPerUsdc;
|
||||
const canRedeem = pointsCanApply && maxDiscountUsdc > 0 && totalPoints >= pointsPerUsdc;
|
||||
const applyDiscount = usePoints && canRedeem && pointsUsed > 0;
|
||||
|
||||
return {
|
||||
planAmount,
|
||||
listAmount,
|
||||
referralApplied: referralApplies,
|
||||
referralDiscountAmount: referralApplies ? listAmount - planAmount : 0,
|
||||
pointsEnabled,
|
||||
pointsPerUsdc,
|
||||
maxDiscountUsdc,
|
||||
@@ -155,7 +185,9 @@ export function useBilling(params: UseBillingParams) {
|
||||
};
|
||||
}, [
|
||||
paymentConfig?.points_redemption,
|
||||
backend?.telegram_pricing?.amount_usdc,
|
||||
backend?.referral,
|
||||
backend?.subscription_active,
|
||||
selectedPlan?.plan_code,
|
||||
selectedPlan?.amount_usdc,
|
||||
totalPoints,
|
||||
usePoints,
|
||||
@@ -177,7 +209,7 @@ export function useBilling(params: UseBillingParams) {
|
||||
if (json.ok) {
|
||||
setPaymentInfo(copy.walletRecoveryDone);
|
||||
setPaymentError("");
|
||||
await loadSnapshot();
|
||||
await refreshEntitlementAfterPayment();
|
||||
await loadPaymentSnapshot();
|
||||
return true;
|
||||
}
|
||||
@@ -189,7 +221,7 @@ export function useBilling(params: UseBillingParams) {
|
||||
}
|
||||
}, [
|
||||
authIsAuthenticated, buildAuthedHeaders, copy.walletRecoveryDone,
|
||||
loadPaymentSnapshot, loadSnapshot, reconcileBusy,
|
||||
loadPaymentSnapshot, refreshEntitlementAfterPayment, reconcileBusy,
|
||||
]);
|
||||
|
||||
// ── handleSubmit409 ────────────────────────────────────
|
||||
@@ -200,7 +232,7 @@ export function useBilling(params: UseBillingParams) {
|
||||
const ok = await reconcileLatestPayment();
|
||||
if (ok) return;
|
||||
setPaymentInfo(copy.orderAlreadyPaid);
|
||||
await loadSnapshot();
|
||||
await refreshEntitlementAfterPayment();
|
||||
await loadPaymentSnapshot();
|
||||
return;
|
||||
}
|
||||
@@ -220,7 +252,7 @@ export function useBilling(params: UseBillingParams) {
|
||||
const txHash = intentJson.intent?.tx_hash || txHashNorm;
|
||||
setPaymentInfo(`支付已确认,交易: ${shortAddress(txHash)}`);
|
||||
setPaymentError("");
|
||||
await loadSnapshot();
|
||||
await refreshEntitlementAfterPayment();
|
||||
await loadPaymentSnapshot();
|
||||
return;
|
||||
}
|
||||
@@ -231,7 +263,7 @@ export function useBilling(params: UseBillingParams) {
|
||||
}
|
||||
throw new Error(copy.submitTxFailed.replace("{raw}", raw));
|
||||
},
|
||||
[buildAuthedHeaders, copy, loadPaymentSnapshot, loadSnapshot, reconcileLatestPayment],
|
||||
[buildAuthedHeaders, copy, loadPaymentSnapshot, refreshEntitlementAfterPayment, reconcileLatestPayment],
|
||||
);
|
||||
|
||||
// ── openTelegramBotBindLink ──────────────────────────────
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
CreatedIntent,
|
||||
EvmProvider,
|
||||
IntentStatusResponse,
|
||||
PaymentChainOption,
|
||||
PaymentConfig,
|
||||
PaymentTokenOption,
|
||||
ProviderMode,
|
||||
@@ -16,7 +17,7 @@ import type {
|
||||
import {
|
||||
WALLET_TRANSACTION_REQUEST_TIMEOUT_MS,
|
||||
} from "./constants";
|
||||
import { clearStoredPaymentRecovery, shortAddress } from "./formatters";
|
||||
import { chainIdToDisplayName, clearStoredPaymentRecovery, shortAddress } from "./formatters";
|
||||
import {
|
||||
buildAllowanceCalldata,
|
||||
buildApproveCalldata,
|
||||
@@ -43,6 +44,8 @@ export interface UsePaymentFlowParams {
|
||||
setPaymentConfig: React.Dispatch<React.SetStateAction<PaymentConfig | null>>;
|
||||
selectedPlanCode: string;
|
||||
setSelectedPlanCode: React.Dispatch<React.SetStateAction<string>>;
|
||||
selectedPaymentChainId: number;
|
||||
setSelectedPaymentChainId: React.Dispatch<React.SetStateAction<number | null>>;
|
||||
selectedTokenAddress: string;
|
||||
setSelectedTokenAddress: React.Dispatch<React.SetStateAction<string>>;
|
||||
|
||||
@@ -98,9 +101,10 @@ export interface UsePaymentFlowParams {
|
||||
getValidAccessToken: () => Promise<string>;
|
||||
buildAuthedHeaders: (withJson?: boolean, requireAuth?: boolean) => Promise<Record<string, string>>;
|
||||
loadSnapshot: () => Promise<void>;
|
||||
refreshEntitlementAfterPayment: () => Promise<void>;
|
||||
loadPaymentSnapshot: () => Promise<void>;
|
||||
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
|
||||
connectAndBindWalletRef: React.MutableRefObject<((mode?: ProviderMode, options?: ConnectBindOptions) => Promise<boolean>) | null>;
|
||||
@@ -120,6 +124,8 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
||||
setPaymentConfig,
|
||||
selectedPlanCode,
|
||||
setSelectedPlanCode,
|
||||
selectedPaymentChainId,
|
||||
setSelectedPaymentChainId,
|
||||
selectedTokenAddress,
|
||||
setSelectedTokenAddress,
|
||||
boundWallets,
|
||||
@@ -155,6 +161,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
||||
getValidAccessToken,
|
||||
buildAuthedHeaders,
|
||||
loadSnapshot,
|
||||
refreshEntitlementAfterPayment,
|
||||
loadPaymentSnapshot,
|
||||
waitForReceipt,
|
||||
ensureTargetChain,
|
||||
@@ -165,11 +172,81 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
||||
|
||||
// ── Derived payment values ──────────────────────────────
|
||||
const planList = paymentConfig?.plans || [];
|
||||
const monthlyPlanList = planList.filter(
|
||||
(plan) => String(plan.plan_code || "").trim().toLowerCase() === "pro_monthly",
|
||||
);
|
||||
const effectivePlanList = monthlyPlanList.length ? monthlyPlanList : planList;
|
||||
const effectivePlanList = planList;
|
||||
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 configured = Array.isArray(paymentConfig?.tokens) ? paymentConfig?.tokens || [] : [];
|
||||
@@ -181,22 +258,32 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
||||
symbol: String(row.symbol || "USDC"),
|
||||
name: String(row.name || row.symbol || "USDC"),
|
||||
code: String(row.code || "usdc"),
|
||||
chain_id: Number(row.chain_id || effectivePaymentChainId),
|
||||
decimals: Number.isFinite(Number(row.decimals))
|
||||
? Number(row.decimals)
|
||||
: Number(paymentConfig?.token_decimals ?? 6),
|
||||
}));
|
||||
}))
|
||||
.filter((row) => Number(row.chain_id) === effectivePaymentChainId);
|
||||
if (clean.length) return clean;
|
||||
const fallbackAddress = String(paymentConfig?.token_address || "").toLowerCase();
|
||||
if (!fallbackAddress.startsWith("0x")) return [];
|
||||
return [{
|
||||
code: "usdc", symbol: "USDC", name: "USDC", address: fallbackAddress,
|
||||
chain_id: effectivePaymentChainId,
|
||||
decimals: Number(paymentConfig?.token_decimals ?? 6),
|
||||
receiver_contract: paymentConfig?.receiver_contract, is_default: true,
|
||||
}];
|
||||
}, [paymentConfig]);
|
||||
}, [effectivePaymentChainId, paymentConfig]);
|
||||
|
||||
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[0]?.address || paymentConfig?.token_address || "",
|
||||
).toLowerCase();
|
||||
@@ -225,21 +312,50 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
||||
if (!selectedPlanCode && configJson.plans?.length) {
|
||||
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)
|
||||
? 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(
|
||||
configJson.default_token_address ||
|
||||
tokenOptionsForChain.find((row) => row.is_default)?.address ||
|
||||
tokenOptionsForChain[0]?.address ||
|
||||
tokenOptions.find((row) => row.is_default)?.address ||
|
||||
tokenOptions[0]?.address || configJson.token_address || "",
|
||||
).toLowerCase();
|
||||
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;
|
||||
},
|
||||
[buildAuthedHeaders, selectedPlanCode],
|
||||
[buildAuthedHeaders, selectedPaymentChainId, selectedPlanCode],
|
||||
);
|
||||
|
||||
// ── pollIntentUntilConfirmed ────────────────────────────
|
||||
@@ -266,13 +382,13 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
||||
if (status === "confirmed") {
|
||||
setPaymentError("");
|
||||
setPaymentInfo(copy.paymentConfirmed.replace("{txHash}", shortAddress(txHash)));
|
||||
trackAppEvent("checkout_succeeded", {
|
||||
trackPaymentSuccess({
|
||||
entry: "account_center",
|
||||
plan_code: selectedPlan?.plan_code || "pro_monthly",
|
||||
intent_id: intentId,
|
||||
tx_hash: txHash || null,
|
||||
});
|
||||
await loadSnapshot();
|
||||
await refreshEntitlementAfterPayment();
|
||||
await loadPaymentSnapshot();
|
||||
return;
|
||||
}
|
||||
@@ -284,7 +400,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
||||
}
|
||||
throw new Error(copy.paymentPendingTimeout);
|
||||
},
|
||||
[loadPaymentSnapshot, loadSnapshot, selectedPlan?.plan_code],
|
||||
[loadPaymentSnapshot, refreshEntitlementAfterPayment, selectedPlan?.plan_code, trackPaymentSuccess],
|
||||
);
|
||||
|
||||
// ── createIntentAndPay ──────────────────────────────────
|
||||
@@ -314,6 +430,8 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
||||
setPaymentBusy(true);
|
||||
let approvedInThisRun = false;
|
||||
try {
|
||||
const authReady = await verifyPaymentAuthReady();
|
||||
const authHeaders = authReady.authHeaders;
|
||||
const providerSelection = await resolvePaymentProvider(providerMode, selectedInjectedProviderKey);
|
||||
const eth = providerSelection.provider;
|
||||
const activeAccounts = await requestWalletWithTimeout<string[]>(
|
||||
@@ -331,21 +449,32 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
||||
setSelectedWallet(payingWallet);
|
||||
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);
|
||||
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");
|
||||
if (paymentConfig?.receiver_contract && String(paymentConfig.receiver_contract).toLowerCase() !== expectedReceiver) {
|
||||
setPaymentInfo(copy.paymentConfigUpdated.replace("{address}", shortAddress(expectedReceiver)));
|
||||
@@ -353,8 +482,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
||||
setPaymentInfo(copy.currentReceiver.replace("{address}", shortAddress(expectedReceiver)));
|
||||
}
|
||||
|
||||
const targetChainId = Number(latestConfig.chain_id || 137);
|
||||
await ensureTargetChain(eth, targetChainId);
|
||||
await ensureTargetChain(eth, targetChainId, targetChain);
|
||||
|
||||
const createRes = await fetch("/api/payments/intents", {
|
||||
method: "POST",
|
||||
@@ -363,13 +491,15 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
||||
plan_code: selectedPlan?.plan_code || "pro_monthly",
|
||||
payment_mode: "strict",
|
||||
allowed_wallet: payingWallet,
|
||||
token_address: resolvedSelectedTokenAddress || undefined,
|
||||
chain_id: targetChainId,
|
||||
token_address: String(selectedLatestToken?.address || resolvedSelectedTokenAddress || "").toLowerCase() || undefined,
|
||||
use_points: billing.canRedeem && usePoints,
|
||||
points_to_consume: billing.canRedeem && usePoints ? billing.pointsUsed : 0,
|
||||
metadata: {
|
||||
source: "account_center",
|
||||
frontend_host: currentPaymentHost || 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 txPayload = created.tx_payload;
|
||||
if (!intentId || !txPayload?.to || !txPayload?.data) throw new Error(copy.intentPayloadInvalid);
|
||||
trackAppEvent("checkout_started", {
|
||||
trackPaymentStart({
|
||||
entry: "account_center",
|
||||
plan_code: selectedPlan?.plan_code || "pro_monthly",
|
||||
intent_id: intentId,
|
||||
@@ -446,6 +576,8 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
||||
const txHashNorm = String(txHash || "").toLowerCase();
|
||||
setLastTxHash(txHashNorm);
|
||||
setLastPaymentStartedAt(Date.now());
|
||||
setPaymentInfo(`交易已提交: ${shortAddress(txHashNorm)},等待链上回执...`);
|
||||
await waitForReceipt(txHashNorm, eth);
|
||||
|
||||
const submitRes = await fetch(`/api/payments/intents/${intentId}/submit`, {
|
||||
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)));
|
||||
trackAppEvent("checkout_succeeded", {
|
||||
trackPaymentSuccess({
|
||||
entry: "account_center",
|
||||
plan_code: selectedPlan?.plan_code || "pro_monthly",
|
||||
intent_id: intentId,
|
||||
tx_hash: txHashNorm,
|
||||
});
|
||||
await loadSnapshot();
|
||||
await refreshEntitlementAfterPayment();
|
||||
await loadPaymentSnapshot();
|
||||
} catch (error) {
|
||||
const normalized = normalizePaymentError(error);
|
||||
@@ -530,13 +662,14 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
||||
|
||||
setPaymentBusy(true);
|
||||
try {
|
||||
const authHeaders = await buildAuthedHeaders(true, false);
|
||||
const authHeaders = await buildAuthedHeaders(true, true);
|
||||
const createRes = await fetch("/api/payments/intents", {
|
||||
method: "POST",
|
||||
headers: authHeaders,
|
||||
body: JSON.stringify({
|
||||
plan_code: selectedPlan?.plan_code || "pro_monthly",
|
||||
payment_mode: "direct",
|
||||
chain_id: effectivePaymentChainId,
|
||||
token_address: resolvedSelectedTokenAddress || undefined,
|
||||
use_points: billing.canRedeem && usePoints,
|
||||
points_to_consume: billing.canRedeem && usePoints ? billing.pointsUsed : 0,
|
||||
@@ -563,8 +696,15 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
||||
setManualPayment(direct);
|
||||
setPaymentMethodTab("manual");
|
||||
setShowOverlay(false);
|
||||
setPaymentInfo(`手动转账订单已创建:请在 Polygon 网络转 ${direct.amount_usdc} ${direct.token_symbol || selectedTokenLabel} 到 ${direct.receiver_address},请在下方【手动转账】面板中查看详情并复制地址,完成后提交 tx hash。`);
|
||||
trackAppEvent("checkout_started", {
|
||||
const chainName = direct.chain_name || chainIdToDisplayName(direct.chain_id);
|
||||
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",
|
||||
plan_code: selectedPlan?.plan_code || "pro_monthly",
|
||||
intent_id: intentId,
|
||||
@@ -594,7 +734,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
||||
setPaymentBusy(true);
|
||||
setPaymentError("");
|
||||
try {
|
||||
const authHeaders = await buildAuthedHeaders(true, false);
|
||||
const authHeaders = await buildAuthedHeaders(true, true);
|
||||
const submitRes = await fetch(`/api/payments/intents/${intentIdVal}/submit`, {
|
||||
method: "POST", headers: authHeaders, body: JSON.stringify({ tx_hash: txHashNorm }),
|
||||
});
|
||||
@@ -627,13 +767,13 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
||||
setManualPayment(null);
|
||||
setManualTxHash("");
|
||||
setTxValidation({ loading: false, checked: false });
|
||||
trackAppEvent("checkout_succeeded", {
|
||||
trackPaymentSuccess({
|
||||
entry: "account_center_manual_transfer",
|
||||
plan_code: selectedPlan?.plan_code || "pro_monthly",
|
||||
intent_id: intentIdVal,
|
||||
tx_hash: txHashNorm,
|
||||
});
|
||||
await loadSnapshot();
|
||||
await refreshEntitlementAfterPayment();
|
||||
await loadPaymentSnapshot();
|
||||
} catch (error) {
|
||||
setPaymentError(normalizePaymentError(error).message);
|
||||
@@ -652,7 +792,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
||||
}
|
||||
setTxValidation({ loading: true, checked: false });
|
||||
try {
|
||||
const headers = await buildAuthedHeaders(true, false);
|
||||
const headers = await buildAuthedHeaders(true, true);
|
||||
const res = await fetch(`/api/payments/intents/${intentId}/validate`, {
|
||||
method: "POST", headers, body: JSON.stringify({ tx_hash: hashNorm }),
|
||||
});
|
||||
@@ -700,6 +840,8 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
||||
validateTxHash,
|
||||
handleOverlayCheckout,
|
||||
availableTokenList,
|
||||
availableChainList,
|
||||
selectedPaymentChain,
|
||||
resolvedSelectedTokenAddress,
|
||||
selectedPaymentToken,
|
||||
selectedTokenLabel,
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
Eip6963ProviderDetail,
|
||||
EvmProvider,
|
||||
InjectedProviderOption,
|
||||
PaymentChainOption,
|
||||
ProviderMode,
|
||||
ProviderSelection,
|
||||
} 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(
|
||||
(await requestWalletWithTimeout<string>(eth, { method: "eth_chainId" }, copy.chainReadError)) || "",
|
||||
);
|
||||
const targetChainHex = `0x${targetChainId.toString(16)}`;
|
||||
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 {
|
||||
await requestWalletWithTimeout(
|
||||
eth,
|
||||
@@ -206,24 +225,31 @@ export function useWalletBind(params: UseWalletBindParams) {
|
||||
const code = Number(err?.code);
|
||||
if (code === 4902 || targetChainId === 137) {
|
||||
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(
|
||||
eth,
|
||||
{
|
||||
method: "wallet_addEthereumChain",
|
||||
params: [{
|
||||
chainId: "0x89",
|
||||
chainName: "Polygon Mainnet",
|
||||
nativeCurrency: { name: "POL", symbol: "POL", decimals: 18 },
|
||||
rpcUrls: ["https://polygon-rpc.com"],
|
||||
blockExplorerUrls: ["https://polygonscan.com"],
|
||||
}],
|
||||
params: [addParams],
|
||||
},
|
||||
copy.chainAddPolygon,
|
||||
isEn ? `Add ${chainName}` : `添加 ${chainName}`,
|
||||
);
|
||||
return;
|
||||
} 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" : "网络切换失败")})`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -64,7 +64,10 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) {
|
||||
? "Set at least 6 characters"
|
||||
: "设置至少 6 位密码",
|
||||
loginSubmit: isEn ? "Start your weather decision journey" : "开启气象决策之旅",
|
||||
loginSubmitting: isEn ? "Signing in..." : "正在登录...",
|
||||
signupSubmit: isEn ? "Create account now" : "立即创建账号",
|
||||
signupSubmitting: isEn ? "Creating account..." : "正在创建账号...",
|
||||
googleSubmitting: isEn ? "Connecting Google..." : "正在连接 Google...",
|
||||
loginHint: isEn
|
||||
? "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."
|
||||
: "继续操作即代表您同意隐私政策与服务条款。",
|
||||
desc: isEn
|
||||
? "Access robust METAR observations, advanced DEB forecast blends, and real-time AI decision cards that bring clarity to your weather risk analyses."
|
||||
: "提供精准的机场 METAR 实况、先进的 DEB 智能融合预测和实时 AI 决策卡片,助您理清气象风险脉络。",
|
||||
? "Access robust METAR observations, advanced DEB forecast blends, and structured decision context for weather risk analysis."
|
||||
: "提供精准的机场 METAR 实况、先进的 DEB 智能融合预测和结构化决策背景,助您理清气象风险脉络。",
|
||||
trusted: isEn ? "Trusted by industry professionals" : "深受行业决策人员信赖",
|
||||
} 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 () => {
|
||||
setErrorText("");
|
||||
@@ -126,7 +137,7 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) {
|
||||
const supabase = getSupabaseBrowserClient();
|
||||
const { error } = await supabase.auth.resetPasswordForEmail(email.trim(), {
|
||||
redirectTo: `${siteOrigin}/auth/callback?next=${encodeURIComponent(
|
||||
"/account",
|
||||
`/auth/reset-password?next=${encodeURIComponent(nextPath || "/account")}`,
|
||||
)}`,
|
||||
});
|
||||
if (error) {
|
||||
@@ -489,10 +500,12 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) {
|
||||
<button
|
||||
type="submit"
|
||||
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"
|
||||
>
|
||||
<span>{isLogin ? copy.loginSubmit : copy.signupSubmit}</span>
|
||||
<ArrowRight size={16} className="transition-transform group-hover:translate-x-1" />
|
||||
{loading ? loadingSpinner : null}
|
||||
<span>{loading ? submittingLabel : (isLogin ? copy.loginSubmit : copy.signupSubmit)}</span>
|
||||
{!loading && <ArrowRight size={16} className="transition-transform group-hover:translate-x-1" />}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -508,10 +521,11 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) {
|
||||
type="button"
|
||||
onClick={() => void onGoogleSignIn()}
|
||||
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"
|
||||
>
|
||||
<Chrome className="h-4 w-4 text-blue-600" />
|
||||
<span>{copy.googleOneClick}</span>
|
||||
{loading ? loadingSpinner : <Chrome className="h-4 w-4 text-blue-600" />}
|
||||
<span>{loading ? googleSubmittingLabel : copy.googleOneClick}</span>
|
||||
</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}
|
||||
|
||||
@@ -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";
|
||||
|
||||
import clsx from "clsx";
|
||||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Activity,
|
||||
BookOpenCheck,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
GraduationCap,
|
||||
Menu,
|
||||
Search,
|
||||
Table2,
|
||||
UserRound,
|
||||
Users,
|
||||
} 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 { getInitialLocaleFromNavigator } from "@/lib/i18n";
|
||||
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 { useRelativeTime } from "@/hooks/useRelativeTime";
|
||||
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 { KoyfinRowsTable } from "@/components/dashboard/scan-terminal/KoyfinRowsTable";
|
||||
import { rowName, pct, money, temp, edgeClass } from "@/components/dashboard/scan-terminal/utils";
|
||||
@@ -54,10 +55,32 @@ import {
|
||||
mergeAccessStateWithAuthPayload,
|
||||
type AuthProfilePayload,
|
||||
} from "@/components/dashboard/scan-terminal/terminal-access-state";
|
||||
import {
|
||||
createAuthProfileRequestCache,
|
||||
loadTerminalAuthProfile,
|
||||
} from "@/components/dashboard/scan-terminal/terminal-auth-bootstrap";
|
||||
import {
|
||||
cityListItemsToScanRows,
|
||||
mergeScanRowsWithCityFallbackRows,
|
||||
} 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 {
|
||||
return {
|
||||
@@ -89,7 +112,13 @@ function createLocalAccess(): ProAccessState {
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
function createTransientAccess(error: unknown): ProAccessState {
|
||||
return {
|
||||
...createEmptyAccess(true),
|
||||
authenticated: true,
|
||||
error: String(error),
|
||||
};
|
||||
}
|
||||
|
||||
const TERM = {
|
||||
cityThreshold: { en: "City / Threshold", zh: "城市 / 阈值" },
|
||||
@@ -100,7 +129,7 @@ const TERM = {
|
||||
liq: { en: "Liq", zh: "流动性" },
|
||||
signal: { en: "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: "选中阈值监控" },
|
||||
probabilityDistribution: { en: "Probability Distribution", zh: "概率分布" },
|
||||
signalList: { en: "Signal List", zh: "信号列表" },
|
||||
@@ -125,7 +154,7 @@ const TERM = {
|
||||
logIn: { en: "Log in", zh: "登录" },
|
||||
createAccount: { en: "Create an account", zh: "注册账号" },
|
||||
learnAbout: { en: "Learn about PolyWeather", zh: "了解 PolyWeather" },
|
||||
proAccessRequired: { en: "Pro Access Required", zh: "需要付费订阅" },
|
||||
proAccessRequired: { en: "Pro subscription required", zh: "需要开通 Pro" },
|
||||
proDesc: {
|
||||
en: "The PolyWeather terminal is a paid product. Subscribe to unlock real-time weather-signal intelligence.",
|
||||
zh: "PolyWeather 决策台为付费产品。订阅以解锁实时天气信号情报。",
|
||||
@@ -135,7 +164,7 @@ const TERM = {
|
||||
zh: "按月计费,随时可取消。通过 Polygon 链 USDC 支付。",
|
||||
},
|
||||
month: { en: "/ month", zh: "/ 月" },
|
||||
subscribeNow: { en: "Subscribe Now — $10/mo", zh: "立即订阅 — $10/月" },
|
||||
subscribeNow: { en: "View Pro plans", zh: "查看订阅方案" },
|
||||
subscribePrompt: {
|
||||
en: "You need an active subscription to access the terminal.",
|
||||
zh: "你需要开通有效订阅才能访问决策台。",
|
||||
@@ -401,14 +430,22 @@ function PolyWeatherTerminal({
|
||||
|
||||
useEffect(() => {
|
||||
const fetchOnline = () => {
|
||||
if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
|
||||
fetch("/api/ops/online-users", { headers: { Accept: "application/json" } })
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((d) => { if (d?.online != null) setOnlineCount(d.online); })
|
||||
.catch(() => {});
|
||||
};
|
||||
fetchOnline();
|
||||
const id = setInterval(fetchOnline, 60_000);
|
||||
return () => clearInterval(id);
|
||||
const handleVisibilityChange = () => {
|
||||
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>(() => {
|
||||
@@ -465,8 +502,9 @@ function PolyWeatherTerminal({
|
||||
};
|
||||
|
||||
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: "guide", Icon: BookOpenCheck, labelEn: "Guide", labelZh: "使用指南" },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
@@ -719,6 +757,8 @@ function PolyWeatherTerminal({
|
||||
<main className="min-h-0 flex-1 overflow-hidden flex flex-col p-2 bg-[#eef2f6]">
|
||||
{activeNavKey === "training" ? (
|
||||
<TrainingDashboard isEn={isEn} />
|
||||
) : activeNavKey === "guide" ? (
|
||||
<UsageGuideDashboard isEn={isEn} />
|
||||
) : (
|
||||
<>
|
||||
{/* Mobile layout */}
|
||||
@@ -855,7 +895,7 @@ function PolyWeatherTerminal({
|
||||
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>
|
||||
@@ -925,20 +965,64 @@ function ScanTerminalScreen() {
|
||||
createEmptyAccess(true),
|
||||
);
|
||||
|
||||
const loadAuthProfile = useCallback(
|
||||
async (accessToken?: string | null): Promise<AuthProfilePayload> => {
|
||||
const rawLoadAuthProfile = useCallback(
|
||||
async (
|
||||
accessToken?: string | null,
|
||||
options?: { preferSnapshot?: boolean },
|
||||
): Promise<AuthProfilePayload> => {
|
||||
const headers: Record<string, string> = { Accept: "application/json" };
|
||||
const token = String(accessToken || "").trim();
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
const response = await fetch("/api/auth/me", {
|
||||
cache: "no-store",
|
||||
headers,
|
||||
});
|
||||
const response = await fetch(
|
||||
options?.preferSnapshot
|
||||
? "/api/auth/me?prefer_snapshot=1"
|
||||
: "/api/auth/me",
|
||||
{
|
||||
cache: "no-store",
|
||||
headers,
|
||||
},
|
||||
);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
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)
|
||||
useEffect(() => {
|
||||
@@ -949,9 +1033,26 @@ function ScanTerminalScreen() {
|
||||
data: { subscription },
|
||||
} = supabase.auth.onAuthStateChange(async (event, session) => {
|
||||
if (event === "SIGNED_OUT") {
|
||||
setProAccess(createEmptyAccess(false));
|
||||
} else if (event === "TOKEN_REFRESHED" || event === "SIGNED_IN") {
|
||||
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);
|
||||
setProAccess((prev) => mergeAccessStateWithAuthPayload(prev, payload));
|
||||
} catch {}
|
||||
@@ -986,6 +1087,9 @@ function ScanTerminalScreen() {
|
||||
hydrated && (proAccess.authenticated || canUseLocalFullAccess);
|
||||
const isPro =
|
||||
hydrated && (proAccess.subscriptionActive || canUseLocalFullAccess);
|
||||
const accessDecisionPending =
|
||||
!hydrated || (proAccess.loading && !canUseLocalFullAccess);
|
||||
const shouldShowPaywall = !accessDecisionPending && (!isAuthenticated || !isPro);
|
||||
const userLocalTime = useUserLocalClock();
|
||||
const { themeMode } = useScanTerminalTheme();
|
||||
const [selectedRegionKey, setSelectedRegionKey] = useState<string>("all");
|
||||
@@ -1030,28 +1134,92 @@ function ScanTerminalScreen() {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
const sessionPromise = hasSupabasePublicEnv()
|
||||
? getSupabaseBrowserClient().auth.getSession()
|
||||
: Promise.resolve({ data: { session: null } });
|
||||
|
||||
sessionPromise
|
||||
.then(({ data: { session } }) => loadAuthProfile(session?.access_token))
|
||||
const supabaseEnabled = hasSupabasePublicEnv();
|
||||
loadTerminalAuthProfile({
|
||||
getSession: () =>
|
||||
supabaseEnabled
|
||||
? getSupabaseBrowserClient().auth.getSession()
|
||||
: Promise.resolve({ data: { session: null } }),
|
||||
hasSupabasePublicEnv: supabaseEnabled,
|
||||
loadAuthProfile,
|
||||
})
|
||||
.then((payload) => {
|
||||
if (cancelled) return;
|
||||
setProAccess((prev) => mergeAccessStateWithAuthPayload(prev, payload));
|
||||
if (payload.entitlement_snapshot === true) {
|
||||
window.setTimeout(() => {
|
||||
if (!cancelled) void refreshLiveAuthProfile();
|
||||
}, 0);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) return;
|
||||
setProAccess((prev) => (
|
||||
prev.subscriptionActive
|
||||
? { ...prev, loading: false, error: String(error) }
|
||||
: { ...createEmptyAccess(false), error: String(error) }
|
||||
: createTransientAccess(error)
|
||||
));
|
||||
});
|
||||
return () => {
|
||||
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(() => {
|
||||
setSelectedRegionKey("all");
|
||||
@@ -1070,12 +1238,25 @@ function ScanTerminalScreen() {
|
||||
timezoneOffsetSeconds: useLocalTimezoneDefault ? localTimezoneOffsetSeconds : null,
|
||||
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(() => {
|
||||
clearCityDetailCache();
|
||||
refreshScanTerminalManually();
|
||||
}, [refreshScanTerminalManually]);
|
||||
|
||||
const [cityFallbackRows, setCityFallbackRows] = useState<ScanOpportunityRow[]>([]);
|
||||
const [cityFallbackRows, setCityFallbackRows] = useState<ScanOpportunityRow[]>(() =>
|
||||
cityListItemsToScanRows(STATIC_CITY_LIST),
|
||||
);
|
||||
const rows = useMemo(
|
||||
() => {
|
||||
const scanRows = terminalData?.rows || [];
|
||||
@@ -1109,11 +1290,12 @@ function ScanTerminalScreen() {
|
||||
return () => controller.abort();
|
||||
}, [isPro]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const deferredSearchQuery = useDeferredValue(searchQuery);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
if (!searchQuery.trim()) return rows;
|
||||
const q = searchQuery.toLowerCase().trim();
|
||||
if (!deferredSearchQuery.trim()) return rows;
|
||||
const q = deferredSearchQuery.toLowerCase().trim();
|
||||
return rows.filter((row) => {
|
||||
const haystack = [
|
||||
row.city,
|
||||
@@ -1132,7 +1314,7 @@ function ScanTerminalScreen() {
|
||||
.map((v) => String(v).toLowerCase());
|
||||
return haystack.some((s) => s.includes(q));
|
||||
});
|
||||
}, [rows, searchQuery]);
|
||||
}, [rows, deferredSearchQuery]);
|
||||
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [selectedCity, setSelectedCity] = useState<string | null>(null);
|
||||
@@ -1145,7 +1327,7 @@ function ScanTerminalScreen() {
|
||||
}, []);
|
||||
const generatedText = useRelativeTime(terminalData?.generated_at ?? null);
|
||||
|
||||
if (!hydrated || (proAccess.loading && !canUseLocalFullAccess)) {
|
||||
if (accessDecisionPending) {
|
||||
return (
|
||||
<ScanTerminalLoadingScreen
|
||||
isEn={isEn}
|
||||
@@ -1156,7 +1338,7 @@ function ScanTerminalScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated || !isPro) {
|
||||
if (shouldShowPaywall) {
|
||||
return (
|
||||
<ProductAccessRequired
|
||||
isAuthenticated={isAuthenticated}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
|
||||
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();
|
||||
};
|
||||
|
||||
const MIN_DROPDOWN_TOP_PX = 56;
|
||||
const DROPDOWN_VIEWPORT_PADDING_PX = 12;
|
||||
|
||||
export function CitySelectorDropdown({
|
||||
isEn,
|
||||
rows,
|
||||
@@ -123,7 +126,9 @@ export function CitySelectorDropdown({
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const deferredSearchQuery = useDeferredValue(searchQuery);
|
||||
const [activeTab, setActiveTab] = useState<string>("all");
|
||||
const [viewportNudgeY, setViewportNudgeY] = useState(0);
|
||||
|
||||
// Auto-focus input on mount
|
||||
useEffect(() => {
|
||||
@@ -166,7 +171,7 @@ export function CitySelectorDropdown({
|
||||
|
||||
// Filter rows
|
||||
const filteredRows = useMemo(() => {
|
||||
const q = searchQuery.toLowerCase().trim();
|
||||
const q = deferredSearchQuery.toLowerCase().trim();
|
||||
return rows.filter((row) => {
|
||||
// 1. Region filter
|
||||
if (activeTab !== "all") {
|
||||
@@ -191,7 +196,36 @@ export function CitySelectorDropdown({
|
||||
.map((s) => s!.toLowerCase());
|
||||
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 match = REGIONS.find((r) => r.key === regionKey);
|
||||
@@ -203,9 +237,10 @@ export function CitySelectorDropdown({
|
||||
<div
|
||||
ref={containerRef}
|
||||
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
|
||||
)}
|
||||
style={viewportNudgeY ? { marginTop: viewportNudgeY } : undefined}
|
||||
onClick={(e) => e.stopPropagation()} // Prevent triggering slot clicks
|
||||
>
|
||||
{/* Search Input Area */}
|
||||
@@ -243,7 +278,7 @@ export function CitySelectorDropdown({
|
||||
</div>
|
||||
|
||||
{/* 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 ? (
|
||||
<div className="p-4 text-center text-slate-400 font-medium">
|
||||
{isEn ? "No matching cities" : "无匹配城市"}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
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 { useLatestPatch, useSseResyncVersion } from "@/hooks/use-sse-patches";
|
||||
import { Panel } from "@/components/dashboard/scan-terminal/Panel";
|
||||
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 { TemperatureStatsBars } from "@/components/dashboard/scan-terminal/TemperatureStatsBars";
|
||||
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 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) {
|
||||
if (state === "watch") return isEn ? "Watch" : "关注";
|
||||
if (state === "near_peak") return isEn ? "Near peak" : "接近峰值";
|
||||
@@ -89,6 +101,26 @@ function formatCityLocalDate(tzOffsetSeconds: number | null | undefined) {
|
||||
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 ─────────────────────────────────────────────────────
|
||||
|
||||
export function LiveTemperatureThresholdChart({
|
||||
@@ -125,11 +157,18 @@ export function LiveTemperatureThresholdChart({
|
||||
const [userToggledKeys, setUserToggledKeys] = useState<Record<string, boolean>>({});
|
||||
const [liveTemp, setLiveTemp] = useState<number | null>(null);
|
||||
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 chartVisibilityRef = useRef<HTMLDivElement | null>(null);
|
||||
const lastPatchAtRef = useRef<number>(Date.now());
|
||||
const lastAppliedPatchRevisionRef = useRef<number>(0);
|
||||
const lastProbabilityRefreshAtRef = useRef<number>(0);
|
||||
const localDayRolloverFetchDateRef = useRef<string>("");
|
||||
const [isChartVisible, setIsChartVisible] = useState(
|
||||
() => typeof IntersectionObserver === "undefined",
|
||||
);
|
||||
|
||||
const [showRunwayDetails, setShowRunwayDetails] = useState<boolean>(true);
|
||||
const [refAreaLeft, setRefAreaLeft] = useState<number | null>(null);
|
||||
@@ -151,6 +190,9 @@ export function LiveTemperatureThresholdChart({
|
||||
setHourly(seedHourlyForecastFromRow(row));
|
||||
setLiveTemp(null);
|
||||
setIsHourlyLoading(Boolean(city));
|
||||
setDetailError(null);
|
||||
setDetailRetryNonce(0);
|
||||
setShowingStaleDetail(false);
|
||||
hasLoadedHourlyDetailRef.current = false;
|
||||
lastPatchAtRef.current = Date.now();
|
||||
lastAppliedPatchRevisionRef.current = 0;
|
||||
@@ -159,6 +201,23 @@ export function LiveTemperatureThresholdChart({
|
||||
setCurrentCityLocalDate(formatCityLocalDate(row?.tz_offset_seconds));
|
||||
}, [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(() => {
|
||||
setCurrentCityLocalDate(formatCityLocalDate(row?.tz_offset_seconds));
|
||||
const id = setInterval(() => {
|
||||
@@ -172,30 +231,47 @@ export function LiveTemperatureThresholdChart({
|
||||
setIsHourlyLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!shouldFetchCityDetailForChart({
|
||||
city,
|
||||
documentHidden:
|
||||
typeof document !== "undefined" && document.visibilityState === "hidden",
|
||||
isChartVisible,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cacheKey = `${city}:${targetResolution}`;
|
||||
// Check in-memory cache first
|
||||
let cached = _hourlyCache.get(cacheKey);
|
||||
if (!cached) {
|
||||
// Fallback to session cache
|
||||
const sessionEntry = readSessionCache(cacheKey);
|
||||
if (!cached || Date.now() - Number(cached.ts || 0) >= HOURLY_CACHE_TTL_MS) {
|
||||
const sessionEntry = readSessionCache(cacheKey, { allowStale: true });
|
||||
if (sessionEntry) {
|
||||
cached = 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;
|
||||
setHourly(cached.data);
|
||||
setShowingStaleDetail(!hasFreshCache);
|
||||
}
|
||||
|
||||
if (hasFreshCache) {
|
||||
setIsHourlyLoading(false);
|
||||
setDetailError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasLoadedHourlyDetailRef.current) {
|
||||
if (!cached && !hasLoadedHourlyDetailRef.current) {
|
||||
setHourly(seedHourlyForecastFromRow(row));
|
||||
setShowingStaleDetail(false);
|
||||
}
|
||||
setIsHourlyLoading(!hasLoadedHourlyDetailRef.current);
|
||||
setIsHourlyLoading(true);
|
||||
let cancelled = false;
|
||||
|
||||
// Prioritize active slots, stagger/delay background slots to optimize load performance
|
||||
@@ -204,11 +280,19 @@ export function LiveTemperatureThresholdChart({
|
||||
const timer = setTimeout(() => {
|
||||
fetchHourlyForecastForCity(city, { resolution: targetResolution })
|
||||
.then((data) => {
|
||||
if (cancelled || !data) return;
|
||||
if (cancelled) return;
|
||||
if (!data) {
|
||||
setDetailError(isEn ? "Data temporarily unavailable." : "数据暂不可用");
|
||||
return;
|
||||
}
|
||||
hasLoadedHourlyDetailRef.current = true;
|
||||
setHourly(data);
|
||||
setDetailError(null);
|
||||
setShowingStaleDetail(false);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setDetailError(isEn ? "Data temporarily unavailable." : "数据暂不可用");
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
if (!cancelled) setIsHourlyLoading(false);
|
||||
});
|
||||
@@ -218,7 +302,7 @@ export function LiveTemperatureThresholdChart({
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [city, row, isActive, slotIndex, targetResolution]);
|
||||
}, [city, row, isActive, slotIndex, targetResolution, isChartVisible, detailRetryNonce, isEn]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!latestPatch || latestPatch.revision <= lastAppliedPatchRevisionRef.current) return;
|
||||
@@ -285,6 +369,8 @@ export function LiveTemperatureThresholdChart({
|
||||
.then((data) => {
|
||||
if (cancelled || !data) return;
|
||||
hasLoadedHourlyDetailRef.current = true;
|
||||
const temp = getLiveTempFromHourly(data);
|
||||
if (temp !== null) setLiveTemp(temp);
|
||||
setHourly(data);
|
||||
})
|
||||
.catch(() => {})
|
||||
@@ -297,15 +383,6 @@ export function LiveTemperatureThresholdChart({
|
||||
if (typeof document !== "undefined" && document.visibilityState === "hidden") 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();
|
||||
};
|
||||
|
||||
@@ -323,19 +400,12 @@ export function LiveTemperatureThresholdChart({
|
||||
const refreshForegroundFullDetail = () => {
|
||||
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 })
|
||||
.then((data) => {
|
||||
if (cancelled || !data) return;
|
||||
hasLoadedHourlyDetailRef.current = true;
|
||||
const temp = getLiveTempFromHourly(data);
|
||||
if (temp !== null) setLiveTemp(temp);
|
||||
setHourly(data);
|
||||
})
|
||||
.catch(() => {});
|
||||
@@ -444,19 +514,29 @@ export function LiveTemperatureThresholdChart({
|
||||
}, [row, chartHourly, tzOffset, chartLocalDate]);
|
||||
|
||||
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 chartSeries = useMemo(() => {
|
||||
return series;
|
||||
}, [series]);
|
||||
|
||||
const isSeriesVisible = (sKey: string) => {
|
||||
const isSeriesVisible = useCallback((sKey: string) => {
|
||||
if (userToggledKeys[sKey] !== undefined) {
|
||||
return userToggledKeys[sKey];
|
||||
}
|
||||
return isTemperatureSeriesVisibleByDefault(city, sKey);
|
||||
};
|
||||
}, [city, userToggledKeys]);
|
||||
|
||||
const activeSeries = useMemo(() => {
|
||||
return getActiveTemperatureSeries(
|
||||
@@ -480,7 +560,7 @@ export function LiveTemperatureThresholdChart({
|
||||
[row, chartHourly, settlementPlate],
|
||||
);
|
||||
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 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 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 ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
@@ -592,7 +734,7 @@ export function LiveTemperatureThresholdChart({
|
||||
{zoomRange && (
|
||||
<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"
|
||||
>
|
||||
{isEn ? "Reset Zoom" : "重置缩放"}
|
||||
@@ -603,10 +745,7 @@ export function LiveTemperatureThresholdChart({
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setViewMode(mode);
|
||||
setZoomRange(null);
|
||||
}}
|
||||
onClick={() => handleViewModeChange(mode)}
|
||||
className={clsx(
|
||||
"px-2 py-0.5 text-[9px] font-bold rounded transition-all cursor-pointer",
|
||||
viewMode === mode
|
||||
@@ -656,45 +795,7 @@ export function LiveTemperatureThresholdChart({
|
||||
</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 (
|
||||
<Panel
|
||||
@@ -702,7 +803,7 @@ export function LiveTemperatureThresholdChart({
|
||||
actions={timeframeActions}
|
||||
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
|
||||
isEn={isEn}
|
||||
compact={compact}
|
||||
@@ -749,20 +850,18 @@ export function LiveTemperatureThresholdChart({
|
||||
hasRunwayData={hasRunwayData}
|
||||
showRunwayDetails={showRunwayDetails}
|
||||
isHourlyLoading={isHourlyLoading}
|
||||
detailError={detailError}
|
||||
showingStaleDetail={showingStaleDetail}
|
||||
refAreaLeft={refAreaLeft}
|
||||
refAreaRight={refAreaRight}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onZoomReset={() => setZoomRange(null)}
|
||||
onZoomReset={handleZoomReset}
|
||||
isSeriesVisible={isSeriesVisible}
|
||||
onSeriesToggle={(seriesKey) => {
|
||||
setUserToggledKeys((prev) => ({
|
||||
...prev,
|
||||
[seriesKey]: !isSeriesVisible(seriesKey),
|
||||
}));
|
||||
}}
|
||||
onSeriesToggle={handleSeriesToggle}
|
||||
onShowRunwayDetailsChange={setShowRunwayDetails}
|
||||
onRetryDetail={handleRetryDetail}
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
@@ -785,6 +884,8 @@ export const __getDebPeakWindowRangeForTest = getDebPeakWindowRange;
|
||||
export const __getLiveObservationLabelsForTest = getLiveObservationLabels;
|
||||
export const __getObservationDisplayMetricsForTest = getObservationDisplayMetrics;
|
||||
export const __getPeakGlowStateForTest = getPeakGlowState;
|
||||
export const __getWundergroundDailyHighForTest = getWundergroundDailyHigh;
|
||||
export const __shouldFetchCityDetailForChartForTest = shouldFetchCityDetailForChart;
|
||||
export const __shouldPollLiveChartForTest = shouldPollLiveChart;
|
||||
export const __mergePatchIntoHourlyForTest = mergePatchIntoHourly;
|
||||
export const __selectDisplayRunwayTempForTest = selectDisplayRunwayTemp;
|
||||
|
||||
@@ -5,9 +5,9 @@ import { LockKeyhole, CreditCard, LogIn } from "lucide-react";
|
||||
|
||||
const ACCESS_TERM = {
|
||||
signInToContinue: { en: "Sign in to continue", zh: "请先登录" },
|
||||
proAccessRequired: { en: "Pro Access Required", zh: "需要付费订阅" },
|
||||
month: { en: "/ month", zh: "/ 月" },
|
||||
subscribeNow: { en: "Subscribe Now — $10/mo", zh: "立即订阅 — $10/月" },
|
||||
proAccessRequired: { en: "Pro subscription required", zh: "需要开通 Pro" },
|
||||
month: { en: "/ 30 days", zh: "/ 30 天" },
|
||||
subscribeNow: { en: "View Pro plans", zh: "查看订阅方案" },
|
||||
backToProduct: { en: "Back to product overview", zh: "返回产品介绍页" },
|
||||
} as const;
|
||||
|
||||
@@ -19,16 +19,16 @@ function t(key: keyof typeof ACCESS_TERM, isEn: boolean) {
|
||||
function SubscriptionGate({ isEn }: { isEn: boolean }) {
|
||||
const features = isEn
|
||||
? [
|
||||
"Real-time METAR observations across 500+ stations",
|
||||
"DEB forecast blends with 0–240h horizon",
|
||||
"AI decision cards with Poly-score ranking",
|
||||
"Historical backtesting & weather signals",
|
||||
"Real-time station, METAR, and runway signals",
|
||||
"DEB forecast curves and model comparison",
|
||||
"Full terminal grid with high-frequency refresh",
|
||||
"API and paid Telegram group access on paid Pro",
|
||||
]
|
||||
: [
|
||||
"500+ 气象站实时 METAR 实况",
|
||||
"DEB 智能融合预测(0–240 小时)",
|
||||
"AI 决策卡片 + Poly-score 排名",
|
||||
"历史回测与天气信号",
|
||||
"实时气象站、METAR 与跑道信号",
|
||||
"DEB 预测曲线与模型对比",
|
||||
"完整终端网格与高频刷新",
|
||||
"付费 Pro 可进入 API 与付费 Telegram 群",
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -57,7 +57,7 @@ function SubscriptionGate({ isEn }: { isEn: boolean }) {
|
||||
|
||||
<div className="p-8">
|
||||
<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">
|
||||
{t("month", isEn)}
|
||||
</span>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import clsx from "clsx";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { memo, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Area,
|
||||
CartesianGrid,
|
||||
@@ -15,7 +15,11 @@ import {
|
||||
} from "recharts";
|
||||
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
|
||||
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 = {
|
||||
threshold: number;
|
||||
@@ -24,7 +28,74 @@ type CityThreshold = {
|
||||
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,
|
||||
compact,
|
||||
timeframe,
|
||||
@@ -39,6 +110,8 @@ export function TemperatureChartCanvas({
|
||||
hasRunwayData,
|
||||
showRunwayDetails,
|
||||
isHourlyLoading,
|
||||
detailError,
|
||||
showingStaleDetail,
|
||||
refAreaLeft,
|
||||
refAreaRight,
|
||||
onMouseDown,
|
||||
@@ -48,6 +121,7 @@ export function TemperatureChartCanvas({
|
||||
isSeriesVisible,
|
||||
onSeriesToggle,
|
||||
onShowRunwayDetailsChange,
|
||||
onRetryDetail,
|
||||
}: {
|
||||
isEn: boolean;
|
||||
compact: boolean;
|
||||
@@ -63,6 +137,8 @@ export function TemperatureChartCanvas({
|
||||
hasRunwayData: boolean;
|
||||
showRunwayDetails: boolean;
|
||||
isHourlyLoading: boolean;
|
||||
detailError?: string | null;
|
||||
showingStaleDetail?: boolean;
|
||||
refAreaLeft: number | null;
|
||||
refAreaRight: number | null;
|
||||
onMouseDown: (event: any) => void;
|
||||
@@ -72,6 +148,7 @@ export function TemperatureChartCanvas({
|
||||
isSeriesVisible: (seriesKey: string) => boolean;
|
||||
onSeriesToggle: (seriesKey: string) => void;
|
||||
onShowRunwayDetailsChange: (value: boolean) => void;
|
||||
onRetryDetail?: () => void;
|
||||
}) {
|
||||
const chartHostRef = useRef<HTMLDivElement | null>(null);
|
||||
const [chartSize, setChartSize] = useState({ width: 0, height: 0 });
|
||||
@@ -120,23 +197,43 @@ export function TemperatureChartCanvas({
|
||||
const individualRunwaySeriesCount = chartSeries.filter(
|
||||
(series) => series.key.startsWith("runway_") && series.key !== "runway_max",
|
||||
).length;
|
||||
const collapsedRunwaySeries = getTemperatureSeriesForRunwayDetailsMode(
|
||||
row?.city || "",
|
||||
chartSeries,
|
||||
false,
|
||||
);
|
||||
const canToggleRunwayDetails =
|
||||
hasRunwayData &&
|
||||
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 (
|
||||
<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">
|
||||
{chartSeries.length > 1 &&
|
||||
chartSeries
|
||||
.filter((s) => {
|
||||
const isIndividualRunway = s.key.startsWith("runway_") && s.key !== "runway_max";
|
||||
if (showRunwayDetails) {
|
||||
return s.key !== "runway_max";
|
||||
}
|
||||
return !isIndividualRunway;
|
||||
})
|
||||
getTemperatureSeriesForRunwayDetailsMode(
|
||||
row?.city || "",
|
||||
chartSeries,
|
||||
showRunwayDetails,
|
||||
)
|
||||
.map((s) => (
|
||||
<button
|
||||
key={s.key}
|
||||
@@ -189,7 +286,8 @@ export function TemperatureChartCanvas({
|
||||
)}
|
||||
</div>
|
||||
<div ref={chartHostRef} className={clsx("relative flex-1", compact ? "min-h-[120px]" : "min-h-[220px]")}>
|
||||
{canRenderChart && (
|
||||
{!shouldRenderChart && <TemperatureChartSkeleton compact={compact} />}
|
||||
{shouldRenderChart && (
|
||||
<ReComposedChart
|
||||
width={chartWidth}
|
||||
height={chartHeight}
|
||||
@@ -331,8 +429,47 @@ export function TemperatureChartCanvas({
|
||||
))}
|
||||
</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>
|
||||
{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="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" />
|
||||
@@ -343,3 +480,6 @@ export function TemperatureChartCanvas({
|
||||
</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 { 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({
|
||||
isEn,
|
||||
compact,
|
||||
@@ -46,18 +108,27 @@ export function TemperatureStatsBars({
|
||||
spreadLabelEn: string;
|
||||
formattedUpdateTime: string;
|
||||
}) {
|
||||
const labels = buildStatsLabels({
|
||||
isEn,
|
||||
isShenzhen,
|
||||
runwayHeaderLabel,
|
||||
metarHeaderLabel,
|
||||
runwayHighLabel,
|
||||
metarHighLabel,
|
||||
});
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="shrink-0 border-b border-slate-200 bg-white px-3 py-1.5 flex items-center justify-between">
|
||||
{timeframe === "1D" ? (
|
||||
<div className="flex items-center gap-4 text-[11px]">
|
||||
<span className="font-semibold text-slate-500">
|
||||
{isEn ? "Runway" : runwayHeaderLabel}:{" "}
|
||||
{labels.primary}:{" "}
|
||||
<strong className="text-[#009688] font-mono">{temp(displayRunwayTemp, tempSymbol)}</strong>
|
||||
</span>
|
||||
<span className="text-slate-300">|</span>
|
||||
<span className="font-semibold text-slate-500">
|
||||
{isEn ? "METAR" : (isShenzhen ? "当日最高" : metarHeaderLabel)}:{" "}
|
||||
{labels.compactSecondary}:{" "}
|
||||
<strong className="text-blue-600 font-mono">{temp(observedHighMetar, tempSymbol)}</strong>
|
||||
</span>
|
||||
</div>
|
||||
@@ -93,7 +164,7 @@ export function TemperatureStatsBars({
|
||||
<div className="flex items-center gap-12">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[11px] font-semibold text-slate-500 uppercase tracking-wider">
|
||||
{isEn ? "Runway Live (1m)" : `${runwayHeaderLabel}`}
|
||||
{labels.primary}
|
||||
</span>
|
||||
<span className="text-2xl font-bold font-mono text-[#009688] mt-1">
|
||||
{temp(displayRunwayTemp, tempSymbol)}
|
||||
@@ -101,7 +172,7 @@ export function TemperatureStatsBars({
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[11px] font-semibold text-slate-500 uppercase tracking-wider">
|
||||
{isEn ? "METAR Settlement · Daily High" : `${metarHeaderLabel} · 当日最高`}
|
||||
{labels.expandedSecondary}
|
||||
</span>
|
||||
<span className="text-2xl font-bold font-mono text-blue-600 mt-1">
|
||||
{temp(observedHighMetar, tempSymbol)}
|
||||
@@ -131,12 +202,12 @@ export function TemperatureStatsBars({
|
||||
|
||||
<div className="hidden sm:flex flex-col items-end text-right">
|
||||
<span className="text-[10px] text-slate-400 uppercase font-semibold">
|
||||
{isEn ? "Daily Peak" : "当日最高气温"}
|
||||
{labels.dailyPeakTitle}
|
||||
</span>
|
||||
<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>{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 && (
|
||||
<>
|
||||
<span>|</span>
|
||||
@@ -187,3 +258,5 @@ export function TemperatureStatsBars({
|
||||
</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"),
|
||||
"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[] = [
|
||||
{
|
||||
@@ -116,8 +121,18 @@ export function runTests() {
|
||||
|
||||
assert(
|
||||
dashboardSource.includes("cityListItemsToScanRows") &&
|
||||
dashboardSource.includes("STATIC_CITY_LIST") &&
|
||||
dashboardSource.includes("useState<ScanOpportunityRow[]>(() =>") &&
|
||||
dashboardSource.includes("/api/cities") &&
|
||||
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 fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
buildCityDetailProxyCachePolicy,
|
||||
buildForceRefreshProxyCachePolicy,
|
||||
@@ -22,4 +24,63 @@ export function runTests() {
|
||||
|
||||
const scanForced = buildForceRefreshProxyCachePolicy("true", 10);
|
||||
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",
|
||||
);
|
||||
}
|
||||
|
||||
+137
-1
@@ -5,11 +5,18 @@ import {
|
||||
DASHBOARD_REFRESH_POLICY_SEC,
|
||||
} from "@/lib/refresh-policy";
|
||||
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 {
|
||||
MAX_HOURLY_DETAIL_CONCURRENT_REQUESTS,
|
||||
HOURLY_CACHE_TTL_MS,
|
||||
__resolveCityDetailFromBatchForTest,
|
||||
__readHourlyCacheEntryForTest,
|
||||
__resetHourlyDetailRequestQueueForTest,
|
||||
__runQueuedHourlyDetailRequestForTest,
|
||||
clearCityDetailCache,
|
||||
} from "@/components/dashboard/scan-terminal/temperature-chart-logic";
|
||||
|
||||
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"),
|
||||
"utf8",
|
||||
);
|
||||
const dashboardSource = fs.readFileSync(
|
||||
path.join(projectRoot, "components", "dashboard", "ScanTerminalDashboard.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert(
|
||||
querySource.includes("useSsePatchVersion") &&
|
||||
@@ -79,6 +90,72 @@ export async function runTests() {
|
||||
chartSource.includes("setHourly(seedHourlyForecastFromRow(row))"),
|
||||
"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();
|
||||
let activeRequests = 0;
|
||||
@@ -130,4 +207,63 @@ export async function runTests() {
|
||||
"city detail queue should resolve every queued request in order",
|
||||
);
|
||||
__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)"),
|
||||
"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('useState<"auto" | "full">("full")'), "temperature chart must default every city panel to the all-day view");
|
||||
assert(
|
||||
@@ -233,6 +237,13 @@ export function runTests() {
|
||||
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",
|
||||
);
|
||||
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("build3DayChartData"), "temperature chart component must not render future prediction curves");
|
||||
assert(
|
||||
|
||||
+361
-1
@@ -5,11 +5,13 @@ import {
|
||||
__getLiveObservationLabelsForTest,
|
||||
__getObservationDisplayMetricsForTest,
|
||||
__getPeakGlowStateForTest,
|
||||
__getWundergroundDailyHighForTest,
|
||||
__getVisibleTemperatureSeriesForTest,
|
||||
__isTemperatureSeriesVisibleByDefaultForTest,
|
||||
__mergePatchIntoHourlyForTest,
|
||||
__selectDisplayRunwayTempForTest,
|
||||
} from "@/components/dashboard/scan-terminal/LiveTemperatureThresholdChart";
|
||||
import { __buildTemperatureTooltipRowsForTest } from "@/components/dashboard/scan-terminal/TemperatureTooltipContent";
|
||||
|
||||
function assert(condition: unknown, message: string): asserts condition {
|
||||
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",
|
||||
);
|
||||
|
||||
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 = {
|
||||
city: "guangzhou",
|
||||
local_date: "2026-05-25",
|
||||
@@ -175,7 +193,133 @@ export function runTests() {
|
||||
);
|
||||
assert(
|
||||
__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(
|
||||
!__isTemperatureSeriesVisibleByDefaultForTest("guangzhou", "model_curve_ECMWF"),
|
||||
@@ -342,6 +486,50 @@ export function runTests() {
|
||||
assert(!highlighted.dashed, `${city} settlement runway should be solid`);
|
||||
const auxiliary = seriesByKey(chart.series, "runway_99_00") as any;
|
||||
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(
|
||||
@@ -758,6 +946,94 @@ export function runTests() {
|
||||
"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(
|
||||
{
|
||||
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",
|
||||
);
|
||||
|
||||
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(
|
||||
{
|
||||
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,
|
||||
});
|
||||
assert(
|
||||
coldUnknown.subscriptionActive === false && coldUnknown.authenticated === true,
|
||||
"cold-start unknown subscription state must not fabricate Pro access",
|
||||
coldUnknown.subscriptionActive === false &&
|
||||
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 path from "node:path";
|
||||
import { __shouldKeepTemperatureChartLoadingForTest } from "@/components/dashboard/scan-terminal/TemperatureChartCanvas";
|
||||
|
||||
function assert(condition: unknown, message: string) {
|
||||
if (!condition) throw new Error(message);
|
||||
@@ -19,10 +20,22 @@ export function runTests() {
|
||||
path.join(projectRoot, "components", "dashboard", "scan-terminal", "LiveTemperatureThresholdChart.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const terminalPageSource = fs.readFileSync(
|
||||
path.join(projectRoot, "app", "terminal", "page.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const chartCanvasSource = fs.readFileSync(
|
||||
path.join(projectRoot, "components", "dashboard", "scan-terminal", "TemperatureChartCanvas.tsx"),
|
||||
"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(
|
||||
dashboardSource.includes("MAX_TERMINAL_CHARTS = 9"),
|
||||
@@ -54,6 +67,42 @@ export function runTests() {
|
||||
dashboardSource.includes("handleSelectCityForSlot(slotIndex, null);"),
|
||||
"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(
|
||||
chartSource.includes("setLiveTemp(null);") &&
|
||||
chartSource.includes("lastAppliedPatchRevisionRef.current = 0;"),
|
||||
@@ -65,4 +114,124 @@ export function runTests() {
|
||||
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",
|
||||
);
|
||||
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 {
|
||||
buildBrowserBackendHeaders,
|
||||
fetchBackendApi,
|
||||
hasDirectBackendApiBaseUrl,
|
||||
} from "@/lib/backend-api";
|
||||
import { DASHBOARD_REFRESH_POLICY_MS } from "@/lib/refresh-policy";
|
||||
import type {
|
||||
@@ -144,11 +145,14 @@ async function getTerminal({
|
||||
if (forceRefresh) {
|
||||
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>(
|
||||
`/api/scan/terminal?${params.toString()}`,
|
||||
{
|
||||
cache: "no-store",
|
||||
cache: forceRefresh || directBackend ? "no-store" : "default",
|
||||
headers,
|
||||
signal,
|
||||
},
|
||||
|
||||
@@ -77,10 +77,26 @@ function runwaySeriesKey(rwy: string) {
|
||||
.join("_")}`;
|
||||
}
|
||||
|
||||
function runwaySeriesLabel(rwy: string, isSettlement: boolean, isEn: boolean) {
|
||||
if (!isSettlement) return rwy;
|
||||
return `${rwy} ${isEn ? "Settlement Runway" : "结算跑道"}`;
|
||||
}
|
||||
|
||||
function isTemperatureSeriesVisibleByDefault(city: string, seriesKey: string) {
|
||||
if (seriesKey.startsWith("model_curve_")) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
chartSeries: EvidenceSeries[],
|
||||
userToggledKeys: Record<string, boolean>,
|
||||
series: EvidenceSeries[],
|
||||
showRunwayDetails: boolean,
|
||||
) {
|
||||
const rawVisible = getVisibleTemperatureSeries(city, chartSeries, userToggledKeys);
|
||||
const hasRunwayMax = rawVisible.some((item) => item.key === "runway_max");
|
||||
const hasRunwayMax = series.some((item) => item.key === "runway_max");
|
||||
const hasSettlementRunway = series.some((item) =>
|
||||
isSettlementRunwaySeriesKey(city, item.key),
|
||||
);
|
||||
|
||||
return rawVisible.filter((item) => {
|
||||
const isIndividualRunway =
|
||||
item.key.startsWith("runway_") && item.key !== "runway_max";
|
||||
return series.filter((item) => {
|
||||
const isIndividualRunway = isIndividualRunwaySeriesKey(item.key);
|
||||
if (showRunwayDetails) {
|
||||
return item.key !== "runway_max";
|
||||
}
|
||||
if (hasSettlementRunway) {
|
||||
if (item.key === "runway_max") return false;
|
||||
return !isIndividualRunway || isSettlementRunwaySeriesKey(city, item.key);
|
||||
}
|
||||
if (!hasRunwayMax) {
|
||||
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(
|
||||
amos: AmosData | null | undefined,
|
||||
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 _hourlyRequestCache = new Map<string, Promise<HourlyForecast>>();
|
||||
const MAX_HOURLY_DETAIL_CONCURRENT_REQUESTS = 3;
|
||||
const HOURLY_DETAIL_REQUEST_TIMEOUT_MS = 12_000;
|
||||
let _hourlyActiveDetailRequests = 0;
|
||||
const _hourlyDetailRequestQueue: Array<() => void> = [];
|
||||
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_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;
|
||||
try {
|
||||
const raw = sessionStorage.getItem(`${SESSION_CACHE_PREFIX}${city}`);
|
||||
if (!raw) return null;
|
||||
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;
|
||||
}
|
||||
} catch {}
|
||||
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) {
|
||||
if (typeof window === "undefined" || !data) return;
|
||||
try {
|
||||
@@ -382,6 +467,7 @@ function __resetHourlyDetailRequestQueueForTest() {
|
||||
}
|
||||
|
||||
const __runQueuedHourlyDetailRequestForTest = runQueuedHourlyDetailRequest;
|
||||
const __readHourlyCacheEntryForTest = readHourlyCacheEntry;
|
||||
|
||||
function validNumber(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
@@ -564,6 +650,61 @@ function appendLatestAirportObservation(
|
||||
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>) {
|
||||
const nums = values.filter((v): v is number => validNumber(v) !== 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 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(
|
||||
appendLatestAirportObservation(hourly?.airportPrimaryTodayObs, hourly?.airportPrimary, hourly?.airportCurrent),
|
||||
airportPrimaryObservationPoints(hourly),
|
||||
tzOffset,
|
||||
MAX_OBS_POINTS,
|
||||
localDateStr,
|
||||
@@ -708,12 +849,10 @@ function getObservationDisplayMetrics(
|
||||
function selectDisplayRunwayTemp(
|
||||
liveTemp: number | null,
|
||||
currentRunwayTemp: number | null,
|
||||
hasRunwayData: boolean,
|
||||
_hasRunwayData: boolean,
|
||||
) {
|
||||
if (hasRunwayData && currentRunwayTemp !== null) {
|
||||
return currentRunwayTemp;
|
||||
}
|
||||
return liveTemp ?? currentRunwayTemp;
|
||||
if (currentRunwayTemp !== null) return currentRunwayTemp;
|
||||
return liveTemp;
|
||||
}
|
||||
|
||||
function isSettlementRunway(row: ScanOpportunityRow | null, rwy: string) {
|
||||
@@ -781,6 +920,7 @@ type HourlyForecast = {
|
||||
amos?: AmosData | null;
|
||||
airportCurrent?: AirportCurrentConditions | null;
|
||||
airportPrimary?: AirportCurrentConditions | null;
|
||||
wundergroundCurrent?: AirportCurrentConditions | null;
|
||||
forecastDaily?: ForecastDay[];
|
||||
multiModelDaily?: Record<string, DailyModelForecast>;
|
||||
probabilities?: LegacyGaussianProbabilitySource | null;
|
||||
@@ -806,6 +946,7 @@ function seedHourlyForecastFromRow(row: ScanOpportunityRow | null): HourlyForeca
|
||||
amos: null,
|
||||
airportCurrent: null,
|
||||
airportPrimary: null,
|
||||
wundergroundCurrent: (row as any)?.wunderground_current || null,
|
||||
forecastDaily: [],
|
||||
multiModelDaily: {},
|
||||
probabilities: {
|
||||
@@ -824,6 +965,29 @@ type HourlyForecastFetchOptions = {
|
||||
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 {
|
||||
const hourlySource = (json as any)?.hourly ?? (json as any)?.timeseries?.hourly;
|
||||
if (!json || !hourlySource) return null;
|
||||
@@ -841,6 +1005,7 @@ function parseHourlyForecastFromCityDetail(json: CityDetail | null): HourlyForec
|
||||
amos: json.amos || null,
|
||||
airportCurrent: json.airport_current || null,
|
||||
airportPrimary: json.airport_primary || null,
|
||||
wundergroundCurrent: (json as any).wunderground_current || (json as any)?.official?.wunderground_current || null,
|
||||
forecastDaily: json.forecast?.daily || [],
|
||||
multiModelDaily: json.multi_model_daily || {},
|
||||
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(
|
||||
city: string,
|
||||
options: HourlyForecastFetchOptions = {},
|
||||
@@ -859,38 +1189,17 @@ async function fetchHourlyForecastForCity(
|
||||
const cacheKey = `${city}:${resParam}`;
|
||||
|
||||
if (!options.ignoreCache) {
|
||||
const cached = _hourlyCache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.ts < HOURLY_CACHE_TTL_MS) {
|
||||
const cached = readHourlyCacheEntry(cacheKey);
|
||||
if (cached) {
|
||||
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 pending = _hourlyRequestCache.get(requestKey);
|
||||
if (pending) return pending;
|
||||
|
||||
const request = runQueuedHourlyDetailRequest(() =>
|
||||
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;
|
||||
}),
|
||||
)
|
||||
const request = queueCityDetailBatch(city, resParam)
|
||||
.finally(() => {
|
||||
_hourlyRequestCache.delete(requestKey);
|
||||
});
|
||||
@@ -899,6 +1208,17 @@ async function fetchHourlyForecastForCity(
|
||||
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({
|
||||
city,
|
||||
compact,
|
||||
@@ -1153,6 +1473,7 @@ function buildRunwayHistorySeries(
|
||||
tzOffset: number,
|
||||
localDateStr: string,
|
||||
minPoints = 2,
|
||||
isEn = false,
|
||||
): RunwayHistorySeries[] {
|
||||
const directHistory =
|
||||
hourly?.runwayPlateHistory ??
|
||||
@@ -1175,7 +1496,7 @@ function buildRunwayHistorySeries(
|
||||
const isSettlement = isSettlementRunway(row, normalizedRwy);
|
||||
return {
|
||||
key: runwaySeriesKey(normalizedRwy),
|
||||
label: `${normalizedRwy}${isSettlement ? (row ? " 结算跑道" : " Settlement") : ""}`,
|
||||
label: runwaySeriesLabel(normalizedRwy, isSettlement, isEn),
|
||||
rwy: normalizedRwy,
|
||||
isSettlement,
|
||||
color: isSettlement ? "#009688" : RUNWAY_LINE_COLORS[index % RUNWAY_LINE_COLORS.length],
|
||||
@@ -1249,7 +1570,7 @@ function buildRunwayHistorySeries(
|
||||
if (values.length < minPoints) return null;
|
||||
return {
|
||||
key: runwaySeriesKey(rwy),
|
||||
label: `${rwy}${isSettlement ? " 结算跑道" : ""}`,
|
||||
label: runwaySeriesLabel(rwy, isSettlement, isEn),
|
||||
rwy,
|
||||
isSettlement,
|
||||
color: isSettlement ? "#009688" : RUNWAY_LINE_COLORS[index % RUNWAY_LINE_COLORS.length],
|
||||
@@ -1567,7 +1888,7 @@ function buildFullDayChartData(
|
||||
);
|
||||
const madisObs = filterTimelinePointsToLocalDay(
|
||||
normObs(
|
||||
appendLatestAirportObservation(hourly?.airportPrimaryTodayObs, hourly?.airportPrimary, hourly?.airportCurrent),
|
||||
airportPrimaryObservationPoints(hourly),
|
||||
tzOffset,
|
||||
MAX_OBS_POINTS,
|
||||
localDateStr,
|
||||
@@ -1575,7 +1896,7 @@ function buildFullDayChartData(
|
||||
localDayBounds,
|
||||
);
|
||||
const runwayHistorySeries = filterRunwayHistoryToLocalDay(
|
||||
buildRunwayHistorySeries(row, hourly, tzOffset, localDateStr),
|
||||
buildRunwayHistorySeries(row, hourly, tzOffset, localDateStr, 2, isEn),
|
||||
localDayBounds,
|
||||
);
|
||||
|
||||
@@ -1727,7 +2048,7 @@ function buildFullDayChartData(
|
||||
if (madisVals.some((v) => v !== null)) {
|
||||
series.push({
|
||||
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"),
|
||||
color: "#0284c7",
|
||||
dashed: isHKO ? true : false,
|
||||
@@ -2136,8 +2457,11 @@ function binObservationsToSlots(
|
||||
|
||||
export {
|
||||
MAX_HOURLY_DETAIL_CONCURRENT_REQUESTS,
|
||||
HOURLY_DETAIL_REQUEST_TIMEOUT_MS,
|
||||
HOURLY_CACHE_TTL_MS,
|
||||
_hourlyCache,
|
||||
__readHourlyCacheEntryForTest,
|
||||
resolveCityDetailFromBatch as __resolveCityDetailFromBatchForTest,
|
||||
__resetHourlyDetailRequestQueueForTest,
|
||||
__runQueuedHourlyDetailRequestForTest,
|
||||
buildChartDomain,
|
||||
@@ -2149,6 +2473,7 @@ export {
|
||||
buildRunwayPlates,
|
||||
fetchHourlyForecastForCity,
|
||||
getActiveTemperatureSeries,
|
||||
getTemperatureSeriesForRunwayDetailsMode,
|
||||
getLiveObservationLabels,
|
||||
getObservationDisplayMetrics,
|
||||
getVisibleTemperatureSeries,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user