Compare commits
145 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 961c9bd37c | |||
| b0fec20370 | |||
| 8f6925108c | |||
| 96dde3b50e | |||
| 505b75d6ce | |||
| 956cb8a8e3 | |||
| 721173aea3 | |||
| ec84243fb9 | |||
| e1054e684a | |||
| 58a2881fc8 | |||
| 4f4617ba0a | |||
| 26775693e7 | |||
| 139e5dbb06 | |||
| bbe3871b7c | |||
| 4389d4a40d | |||
| c3f9b974c3 | |||
| 39026344dc | |||
| 41e819735d | |||
| 12935f502f | |||
| c4b47a81a7 | |||
| 27e4f1efe0 | |||
| f66127da44 | |||
| fb4a144fcf | |||
| 3951735259 | |||
| a40b1ba137 | |||
| 555d3fa68f | |||
| f7625218ae | |||
| a5de604d85 | |||
| 11fd482378 | |||
| 524ae7c8cf | |||
| 98b69b180a | |||
| d354fb12dd | |||
| 2f5e4a40dd | |||
| 7a38e3588a | |||
| c2f9bc5994 | |||
| 89f77b8db4 | |||
| 2766b6463c | |||
| 2a6c826f50 | |||
| 09979f3bb5 | |||
| e6a1d14b09 | |||
| 2af1cf9cd8 | |||
| c3fd0582ad | |||
| 00f96150c5 | |||
| 92307a9f97 | |||
| a440f3d2b5 | |||
| afffacb529 | |||
| 137b0d8b3a | |||
| 876417c5ec | |||
| 35a93cbd9e | |||
| 9d136a337c | |||
| fc0a8b8ff5 | |||
| 8fc9f3ae1a | |||
| f1e577ea8a | |||
| 2fd8027a41 | |||
| 6407d0a29d | |||
| 573353421b | |||
| ac5b512a72 | |||
| 31fb1b3f28 | |||
| 49cb0bb42d | |||
| fb6c354317 | |||
| fee402145e | |||
| d1633331bc | |||
| 6e00160897 | |||
| 6bc53c6e5d | |||
| 7625a99020 | |||
| 3447a043ff | |||
| 10cf557b11 | |||
| eaf17c7a32 | |||
| 14526d6ce8 | |||
| 9a0d7ae444 | |||
| de5083ef18 | |||
| 2424b68532 | |||
| ff2a3da100 | |||
| 7040598e26 | |||
| 22f4ac1f26 | |||
| 3e5ff4ea8f | |||
| 39c7c1903c | |||
| eecaa48ec2 | |||
| d911af1225 | |||
| 38c6cefb27 | |||
| a3df2e00dc | |||
| fde69123cd | |||
| 2d963c16f3 | |||
| 071d749ef0 | |||
| 8d552a9279 | |||
| 9c5a08dc1e | |||
| ada5f274d3 | |||
| 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=
|
||||
|
||||
+41
-38
@@ -73,9 +73,6 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
@@ -83,55 +80,61 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push backend
|
||||
if: matrix.image.name == 'backend'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ${{ matrix.image.context }}
|
||||
file: ${{ matrix.image.file }}
|
||||
push: true
|
||||
tags: |
|
||||
${{ matrix.image.tag }}:latest
|
||||
${{ matrix.image.tag }}:${{ github.sha }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
- name: Build and push image
|
||||
env:
|
||||
IMAGE_NAME: ${{ matrix.image.name }}
|
||||
IMAGE_CONTEXT: ${{ matrix.image.context }}
|
||||
IMAGE_FILE: ${{ matrix.image.file }}
|
||||
IMAGE_TAG: ${{ matrix.image.tag }}
|
||||
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
|
||||
NEXT_PUBLIC_SITE_URL: ${{ secrets.NEXT_PUBLIC_SITE_URL || 'https://polyweather.top' }}
|
||||
NEXT_PUBLIC_POLYWEATHER_API_BASE_URL: ${{ secrets.NEXT_PUBLIC_POLYWEATHER_API_BASE_URL || '' }}
|
||||
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID: ${{ secrets.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID || '' }}
|
||||
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL: ${{ secrets.NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL || 'https://polygon-bor-rpc.publicnode.com' }}
|
||||
NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS: ${{ secrets.NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS || 'polyweather.top,www.polyweather.top' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
- name: Build and push frontend
|
||||
if: matrix.image.name == 'frontend'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ${{ matrix.image.context }}
|
||||
file: ${{ matrix.image.file }}
|
||||
push: true
|
||||
tags: |
|
||||
${{ matrix.image.tag }}:latest
|
||||
${{ matrix.image.tag }}:${{ github.sha }}
|
||||
build-args: |
|
||||
NEXT_PUBLIC_SUPABASE_URL=${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
|
||||
NEXT_PUBLIC_SITE_URL=${{ secrets.NEXT_PUBLIC_SITE_URL || 'https://polyweather.top' }}
|
||||
NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=${{ secrets.NEXT_PUBLIC_POLYWEATHER_API_BASE_URL || '' }}
|
||||
NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS=false
|
||||
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=${{ secrets.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID || '' }}
|
||||
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=${{ secrets.NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL || 'https://polygon-bor-rpc.publicnode.com' }}
|
||||
NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=${{ secrets.NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS || 'polyweather.top,www.polyweather.top' }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
tags=(-t "${IMAGE_TAG}:latest" -t "${IMAGE_TAG}:${GITHUB_SHA}")
|
||||
build_args=()
|
||||
|
||||
if [ "${IMAGE_NAME}" = "frontend" ]; then
|
||||
build_args=(
|
||||
--build-arg "NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}"
|
||||
--build-arg "NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}"
|
||||
--build-arg "NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL}"
|
||||
--build-arg "NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=${NEXT_PUBLIC_POLYWEATHER_API_BASE_URL}"
|
||||
--build-arg "NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS=false"
|
||||
--build-arg "NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=${NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID}"
|
||||
--build-arg "NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=${NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL}"
|
||||
--build-arg "NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=${NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS}"
|
||||
)
|
||||
fi
|
||||
|
||||
docker build -f "${IMAGE_FILE}" "${tags[@]}" "${build_args[@]}" "${IMAGE_CONTEXT}"
|
||||
docker push "${IMAGE_TAG}:latest"
|
||||
docker push "${IMAGE_TAG}:${GITHUB_SHA}"
|
||||
|
||||
deploy:
|
||||
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
|
||||
|
||||
- name: Deploy to VPS
|
||||
env:
|
||||
GHCR_PAT: ${{ secrets.GHCR_PAT }}
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.VPS_SSH_KEY }}" > ~/.ssh/id_rsa
|
||||
chmod 600 ~/.ssh/id_rsa
|
||||
scp -o StrictHostKeyChecking=accept-new deploy.sh ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }}:/tmp/deploy.sh
|
||||
ssh -o StrictHostKeyChecking=accept-new ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} "
|
||||
bash /tmp/deploy.sh '${{ secrets.GHCR_PAT }}' '${{ github.sha }}'
|
||||
printf '%s\n' "$GHCR_PAT" | ssh -o StrictHostKeyChecking=accept-new ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} "
|
||||
bash /tmp/deploy.sh '${{ github.sha }}'
|
||||
"
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# PolyWeather Agent Instructions
|
||||
|
||||
## 语言和沟通
|
||||
|
||||
- 默认用中文回复用户。
|
||||
- 直接说明正在做什么、查到了什么、下一步是什么;不要写空泛客套话。
|
||||
- 如果用户要求“提交推送”“部署”“看日志”,必须在本地验证后再提交、推送,并检查 GitHub Actions 或线上状态。
|
||||
- 不要把多个不相关任务混在一个结论里;遇到新方向时,建议用户在同一个 Project 下开新 Thread。
|
||||
|
||||
## 项目和线程使用
|
||||
|
||||
- 一个 Project 对应 PolyWeather 这个共享代码库和长期方向。
|
||||
- 每个具体任务使用一条独立 Thread / Chat,例如:
|
||||
- 落地页与产品包装
|
||||
- 观测数据采集与 SSE patch
|
||||
- Telegram 推送
|
||||
- 付款与会员
|
||||
- 部署、CI、服务器状态
|
||||
- 同一个 Project 下的 Thread 共享文件夹和 `AGENTS.md`,但上下文分开,避免旧问题影响新任务判断。
|
||||
|
||||
## 代码工作原则
|
||||
|
||||
- 先读现有代码和配置,再改动;优先沿用项目已有模式。
|
||||
- 使用 `rg` / `rg --files` 查找文件和文本。
|
||||
- 手动编辑文件使用 `apply_patch`。
|
||||
- 不要回滚用户或其他 agent 已经做过的无关改动。
|
||||
- 只改和当前任务直接相关的文件,避免顺手重构。
|
||||
- 新增复杂逻辑时补充聚焦测试;窄改动保持验证范围匹配风险。
|
||||
|
||||
## 前端约定
|
||||
|
||||
- 前端位于 `frontend/`,使用 Next.js、React、TypeScript。
|
||||
- UI 修改必须关注移动端响应式、文本不重叠、按钮和标签不溢出。
|
||||
- 图表、终端、详情面板等工作界面应保持信息密度和可扫描性,避免营销式装饰。
|
||||
- 常用验证:
|
||||
- `cd frontend && npm run test:business`
|
||||
- `cd frontend && npm run typecheck`
|
||||
- 必要时启动本地预览并用浏览器检查桌面和移动视口;检查完关闭本地端口。
|
||||
|
||||
## 后端和数据约定
|
||||
|
||||
- Python 代码主要位于 `src/`、`web/`、`tests/`。
|
||||
- 观测数据刷新应以数据源原生频率为准,避免 Web、collector、Telegram 同时强刷同一外部源。
|
||||
- Telegram 默认只读最新缓存/DB;除非完全没有缓存,才允许兜底刷新。
|
||||
- 对外部源调用要考虑 singleflight、冷却、缓存和失败降级,避免 502/408 或 Supabase/磁盘 IO 压力。
|
||||
- 常用验证:
|
||||
- `python -m ruff check .`
|
||||
- `python -m pytest`
|
||||
|
||||
## CI、提交和部署
|
||||
|
||||
- `main` push 会触发 `.github/workflows/ci.yml`:
|
||||
- `python-quality`
|
||||
- `frontend-quality`
|
||||
- `build-and-push`
|
||||
- `deploy`
|
||||
- 提交前至少运行和改动相关的验证;推送前确认 `git status --short`。
|
||||
- 推送后检查 GitHub Actions 最新 run;如果失败,先定位失败 job 和 step,再修改。
|
||||
- 线上 smoke check 优先检查:
|
||||
- `https://api.polyweather.top/healthz`
|
||||
- `https://polyweather.top/`
|
||||
- 相关页面或 API 路径
|
||||
|
||||
## 产品方向备忘
|
||||
|
||||
- PolyWeather 当前重点不是售卖 API。
|
||||
- 核心差异化是结算源优先、实时观测源、跑道/城市细粒度温度、SSE patch、Telegram 缓存读取和面向交易/预测市场的解释能力。
|
||||
- 公开包装、教育内容、图表变量完整度和付费分层可以加强,但不要把产品定位改成通用天气 API。
|
||||
|
||||
## Memory 说明
|
||||
|
||||
- `AGENTS.md` 是项目内显式规则,跟随仓库和 Project。
|
||||
- Memory 是用户账号级偏好设置,agent 不能代替用户开启。
|
||||
- 建议在 Codex / ChatGPT 设置中开启 Memory,并保存长期偏好,例如:
|
||||
- PolyWeather 项目默认中文回复。
|
||||
- 每个任务开独立 Thread。
|
||||
- 修改后优先验证、提交、推送并检查部署状态。
|
||||
- 不要主动把 PolyWeather 包装成 API 售卖产品。
|
||||
@@ -5,13 +5,13 @@ Production weather-intelligence stack for temperature settlement markets.
|
||||
Official dashboard: [polyweather.top](https://polyweather.top/)
|
||||
中文说明: [README_ZH.md](README_ZH.md)
|
||||
|
||||
Public docs center: `/docs/intro` on the main site (bilingual product documentation, including intraday analysis, calibrated probability, model stack, TAF, settlement sources, history, and extension).
|
||||
Public docs center: `/docs/intro` on the main site (bilingual product documentation for the current terminal, chart reading, realtime source cadence, settlement stations, and the browser extension).
|
||||
|
||||
## Product Screenshots
|
||||
|
||||
### Realtime Terminal
|
||||
|
||||

|
||||

|
||||
|
||||
### Telegram Runway Alerts
|
||||
|
||||
@@ -21,22 +21,21 @@ Public docs center: `/docs/intro` on the main site (bilingual product documentat
|
||||
|
||||
[](https://star-history.com/#yangyuan-zhen/PolyWeather&Date)
|
||||
|
||||
## Product Status (2026-05-28)
|
||||
## Product Status (2026-06-07)
|
||||
|
||||
- Subscription live: `Pro Monthly 10 USDC`.
|
||||
- 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).
|
||||
- Subscription live: `Pro Monthly 29.9 USDC / 30 days` and `Pro Quarterly 79.9 USDC / 90 days`.
|
||||
- Referral pricing live: invited users can get the first monthly Pro at `20 USDC`; inviters receive `3500` points after a valid first Pro payment, capped at 10 paid invites per month.
|
||||
- `/city` and `/deb` now free (daily cap 10 each); points redeemable for payment discount (`500 pts = 1 USDC`, monthly max `3 USDC`, quarterly max `8 USDC`). Useful user feedback can also receive manual point rewards through ops.
|
||||
- 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.
|
||||
- Ops dashboard live: `/ops` for memberships, leaderboard, user feedback triage, manual point grants, and payment incident triage.
|
||||
- Lightweight observability live: `/healthz`, `/api/system/status`, `/metrics`.
|
||||
- Realtime terminal live: visible city charts subscribe through `/api/events?cities=...&since_revision=...`, receive `city_observation_patch.v1` SSE patches, and replay short gaps from Redis Stream in production or SQLite fallback in local/single-node mode.
|
||||
- Chart refresh is observation-driven: live patches merge into the current chart without a loading overlay; only visible charts run a 60s no-patch fallback, and returning from a background browser tab triggers a foreground catch-up refresh.
|
||||
- Temperature charts default to All Day, keep an optional Peak window derived from the DEB hourly path, and render all timestamps in the selected city's local time.
|
||||
- The chart core has been split into focused logic/canvas/state modules; Recharts now receives explicit measured dimensions to avoid 0x0 rendering and disappearing curves.
|
||||
- DEB hourly consensus (`deb_hourly_consensus.v1`) is now the preferred hourly forecast path for peak-window detection and chart overlays; DEB remains a forecast curve, never an observation source.
|
||||
- Legacy Gaussian probability is rendered as horizontal probability bands and a `mu` reference line on the chart, rather than as a fake time-series curve.
|
||||
- Legacy Gaussian probability stays out of the default temperature chart surface; hover tooltips show `Gaussian μ` plus the full bucket distribution by temperature range.
|
||||
- Settlement runway curves are visible by default for AMSC/AMOS cities; the configured settlement runway is highlighted and auxiliary runways are shown as secondary context.
|
||||
- Hong Kong uses CoWIN station `6087` (Po Leung Kuk Choi Kai Yau School) as the 1-minute reference-station curve, with HKO 10-minute observations kept as the official meteorological layer.
|
||||
- Telegram airport/runway pushes are bilingual by default and use settlement-endpoint runway temperatures for slope/current/summary copy.
|
||||
@@ -44,10 +43,10 @@ Public docs center: `/docs/intro` on the main site (bilingual product documentat
|
||||
- EMOS/CRPS calibration is wired and trainable, but production should stay on `legacy` or `emos_shadow`; `emos_primary` is only for candidates that pass local offline evaluation and manual rollout.
|
||||
- Intraday analysis is now positioned as a professional meteorology read: headline, confidence, base/upside/downside paths, next observation point, evidence chain, failure modes, and confirmation rules.
|
||||
- Intraday modal now blocks stale cached detail during refresh, so users do not briefly trade off old city/date data before full detail arrives.
|
||||
- Terminal city cards now combine settlement observations, DEB hourly consensus, model cluster context, calibrated probability, and market-bucket mapping without blocking the chart on AI text generation.
|
||||
- Terminal chart/detail workflow now combines settlement observations, DEB hourly consensus, model context, probability distribution tooltips, and market-bucket mapping without blocking the chart on AI text generation.
|
||||
- Terminal data uses page memory cache, browser `localStorage`, backend short-TTL cache, SSE patch replay, and foreground refresh so returning from another tab restores the latest visible chart state quickly.
|
||||
- Market bucket matching now uses the full `all_buckets` surface and strict exact / range / or-higher / or-lower direction checks, reducing bad matches to unreasonable tail buckets.
|
||||
- The card label “model-market difference” means `model probability - market-implied probability`; positive values indicate weather probability above market pricing, while negative values indicate the YES is already priced more fully.
|
||||
- The market-signal difference means `model probability - market-implied probability`; positive values indicate weather probability above market pricing, while negative values indicate the YES is already priced more fully.
|
||||
- Calibrated model probability is now the primary probability panel. It shows the active production probability engine (legacy Gaussian or EMOS), while model consensus remains a secondary reference.
|
||||
- Non-Hong Kong airport cities now ingest `TAF` and parse `FM / TEMPO / BECMG / PROB30/40`.
|
||||
- Temperature chart now overlays `TAF Timing` markers near the expected peak window.
|
||||
@@ -73,9 +72,11 @@ See: [AGPL-3.0 & Commercial Boundary](docs/OPEN_CORE_POLICY.md)
|
||||
- Uses DEB (Dynamic Error Balancing) to blend multi-model highs.
|
||||
- Builds a DEB-weighted hourly consensus path for peak-window logic and chart display.
|
||||
- Generates settlement-oriented calibrated probability buckets (`mu` + bucket distribution) via legacy Gaussian or EMOS/CRPS calibration.
|
||||
- Adds city decision cards that combine live observations, expected-high centers, full market-bucket mapping, and model-market difference in one view.
|
||||
- Adds terminal chart/detail workflows that combine live observations, DEB-centered high-temperature context, market-bucket mapping, and model-market difference.
|
||||
- Shows calibrated Gaussian context in chart tooltips as `mu` plus the full temperature-range probability distribution, without reintroducing probability bands into the main temperature view.
|
||||
- Reuses one analysis core across web dashboard and Telegram bot.
|
||||
- Adds payment audit trails, replay tooling, and incident visibility in ops.
|
||||
- Adds an in-app feedback loop with chart context, user-visible feedback status, ops triage, and manual point rewards for useful reports and suggestions.
|
||||
- Adds peak-window-oriented intraday analysis with meteorology headline, path buckets, evidence chain, invalidation rules, and confirmation rules.
|
||||
- Adds airport-side `TAF` timing overlays and airport suppression/disruption interpretation for non-Hong Kong airport cities.
|
||||
- Adds official nearby-network and runway-level enhancement layers for China, Japan, Korea (AMOS runway sensors for Seoul/Busan), Hong Kong, Taiwan, and Turkey without replacing airport settlement anchors.
|
||||
@@ -102,7 +103,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"]
|
||||
```
|
||||
|
||||
@@ -131,6 +132,8 @@ npm run dev
|
||||
|
||||
## Recent Highlights
|
||||
|
||||
- Gaussian probability tooltip now lists the full temperature-range distribution instead of only the highest-probability bucket, while the main chart remains focused on observations and forecasts.
|
||||
- User feedback is now a product loop: terminal submissions attach chart context, users can track status in-app, and ops can reward useful feedback with points.
|
||||
- Airport-linked contracts use the METAR / airport primary observing site as the settlement anchor. Wunderground pages are reference/history pages, not stations.
|
||||
- Taipei and Shenzhen retain their explicitly configured station history pages for reconciliation, but the docs avoid describing Wunderground itself as a physical station.
|
||||
- Hong Kong keeps `HKO` official readings in dashboard and history, without falling back to airport METAR lines.
|
||||
@@ -198,6 +201,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 |
|
||||
@@ -236,4 +243,4 @@ curl http://127.0.0.1:8000/api/payments/runtime
|
||||
## Version
|
||||
|
||||
- Version: `v1.8.1`
|
||||
- Last Updated: `2026-05-28`
|
||||
- Last Updated: `2026-06-07`
|
||||
|
||||
+21
-14
@@ -8,22 +8,22 @@
|
||||
|
||||
### 实时终端
|
||||
|
||||

|
||||

|
||||
|
||||
### Telegram 跑道推送
|
||||
|
||||

|
||||
|
||||
## 当前产品状态(2026-05-28)
|
||||
## 当前产品状态(2026-06-07)
|
||||
|
||||
- 已上线订阅制:`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。
|
||||
@@ -31,7 +31,7 @@
|
||||
- 城市图表默认展示“全天”,可选“高温”窗口由 DEB hourly path 推导;所有图表横轴都按城市当地时间展示,不按用户浏览器时区。
|
||||
- 核心图表组件已拆分为逻辑、状态与 canvas 渲染模块;Recharts 使用 `ResizeObserver` 后的明确宽高,规避 0x0 渲染和长时间挂页后曲线消失。
|
||||
- DEB hourly consensus(`deb_hourly_consensus.v1`)已作为峰值窗口和图表 DEB 曲线的优先小时路径;DEB 仍然是预测曲线,不作为实测来源。
|
||||
- legacy 高斯概率在图表上展示为概率温度带和 `mu` 参考线,不再伪装成一条时间序列曲线。
|
||||
- legacy 高斯概率不再占用默认温度图主视图;hover tooltip 会展示 `Gaussian μ` 和完整温度区间概率分布。
|
||||
- AMSC/AMOS 城市的结算跑道曲线默认展示并高亮,辅助跑道作为弱化曲线保留;釜山单跑道只展示 `SR/SL` 结算跑道,不再重复显示 AMOS 聚合线。
|
||||
- 香港默认展示 CoWIN `6087`(保良局陈守仁小学)1 分钟参考站曲线,HKO 10 分钟实测保留为官方气象层。
|
||||
- Telegram 机场/跑道推送默认中英文双语,并统一使用结算端点跑道温度计算当前值、15 分钟趋势和文案。
|
||||
@@ -49,10 +49,10 @@
|
||||
- `/ops` 现已展示缓存桶数量、summary cache hit/miss 与运行态 heartbeat。
|
||||
- 今日日内分析已改为“专业气象判断台”:顶部先给气象主判断、置信度、基准/上修/下修路径、下一观测点,再展示证据链、失效条件、确认条件和模型层。
|
||||
- 日内分析弹窗在 full detail / market detail 同步完成前会锁住旧内容并显示刷新状态,避免用户短暂看到上一轮缓存数据后误判。
|
||||
- 终端城市卡已改为结构化实况 + DEB hourly consensus + 多模型集群 + 校准概率 + 市场温度桶,不再让图表等待 AI 文案生成。
|
||||
- 终端图表/详情工作流已改为结构化实况 + DEB hourly consensus + 多模型集群 + 概率分布 tooltip + 市场温度桶,不再让图表等待 AI 文案生成。
|
||||
- 终端数据同时使用页面内存缓存、浏览器 `localStorage`、后端短 TTL 缓存、SSE patch replay 和前台恢复刷新;从其他选项卡切回时会优先恢复最新可见图表状态。
|
||||
- 市场温度桶匹配已改为完整 `all_buckets` 映射,按 exact / range / or higher / or lower 方向严格匹配,避免把天气中枢错配到不合理尾部桶。
|
||||
- 决策卡中的“模型-市场差”口径为 `模型概率 - 市场隐含概率`,正值表示天气概率高于市场报价,负值表示市场已经更充分计价。
|
||||
- 市场信号中的“模型-市场差”口径为 `模型概率 - 市场隐含概率`,正值表示天气概率高于市场报价,负值表示市场已经更充分计价。
|
||||
- 概率区已改为”校准模型概率”;默认展示生产概率引擎输出(legacy 高斯或 EMOS),模型共识作为辅助参考。
|
||||
- 今日日内结构解读以规则与结构化信号为主,AI 文案只作为可降级辅助层,不替代实测、DEB、TAF 或结算逻辑。
|
||||
- 前端设计系统全面重构:统一 CSS token 体系、消除 !important 滥用(134→49)、合并断点(18→10)、数百处硬编码颜色迁移至 CSS 变量、添加 ARIA 无障碍属性和键盘导航。完整审查记录见 `docs/frontend-ui-design-review.md`。
|
||||
@@ -73,9 +73,11 @@
|
||||
- DEB(Dynamic Error Balancing)融合多模型最高温。
|
||||
- 构建 DEB 加权小时共识曲线,用于峰值窗口判断和图表默认 DEB 展示。
|
||||
- 输出结算导向校准概率分布(`mu` + 温度桶),通过 legacy 高斯或 EMOS/CRPS 校准引擎。
|
||||
- 地图城市决策卡把结构化实况、最高温中枢、完整市场温度桶和模型-市场差放在同一张卡中展示。
|
||||
- 天气决策台把结构化实况、DEB 高温路径、完整市场温度桶和模型-市场差放进图表/详情工作流。
|
||||
- 图表 tooltip 展示校准高斯上下文:`mu` 加完整温度区间概率分布,不把概率温度带重新放回主图。
|
||||
- Web 仪表盘与 Telegram Bot 复用同一分析内核。
|
||||
- 支付链路具备事件重放、SQLite 审计事件与 RPC 容灾能力。
|
||||
- 已上线站内反馈闭环:提交反馈时自动附带图表上下文,用户可查看处理状态,运营后台可为有价值反馈人工奖励积分。
|
||||
- 官方增强层与跑道级传感器支持按国家 provider 统一接入(含韩国 AMOS 首尔/釜山跑道实测),不替代机场主站、METAR 或明确官方结算站。
|
||||
|
||||
## 参考架构
|
||||
@@ -126,6 +128,11 @@ npm ci
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 近期更新
|
||||
|
||||
- 高斯概率 tooltip 已改为展示完整温度区间概率分布,不再只显示最高概率的单个区间;主图继续聚焦实测和预测曲线。
|
||||
- 用户反馈已形成产品闭环:终端提交会自动附带图表上下文,用户可在站内查看处理状态,运营侧可为真实、有建设性的反馈发放积分奖励。
|
||||
|
||||
## 运行数据目录(VPS 推荐)
|
||||
|
||||
建议将运行态数据放到仓库外(避免 `git pull` 被 SQLite 卡住):
|
||||
@@ -242,7 +249,7 @@ POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
|
||||
- 支付 V2 升级方案:[docs/payments/PAYMENT_UPGRADE_V2_ZH.md](docs/payments/PAYMENT_UPGRADE_V2_ZH.md)
|
||||
- 运营后台说明:[docs/OPS_ADMIN_ZH.md](docs/OPS_ADMIN_ZH.md)
|
||||
- 外部监控说明:[docs/MONITORING_ZH.md](docs/MONITORING_ZH.md)
|
||||
- 模型栈与 DEB:[docs/MODEL_STACK_AND_DEB_ZH.md](docs/MODEL_STACK_AND_DEB_ZH.md)
|
||||
- DEB 模型家族去重规则:[docs/MODEL_STACK_AND_DEB_ZH.md](docs/MODEL_STACK_AND_DEB_ZH.md)
|
||||
- 深度评估报告:[docs/deep-research-report.md](docs/deep-research-report.md)
|
||||
- 发布流程:[RELEASE.md](RELEASE.md)
|
||||
- 变更记录:[CHANGELOG.md](CHANGELOG.md)
|
||||
@@ -250,4 +257,4 @@ POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
|
||||
## 当前版本
|
||||
|
||||
- 版本:`v1.8.1`
|
||||
- 文档最后更新:`2026-05-28`
|
||||
- 文档最后更新:`2026-06-07`
|
||||
|
||||
+2
-2
@@ -19,8 +19,8 @@ cities:
|
||||
- id: new_york
|
||||
city: New York
|
||||
country: USA
|
||||
latitude: 40.7128
|
||||
longitude: -74.006
|
||||
latitude: 40.7769
|
||||
longitude: -73.874
|
||||
- id: chicago
|
||||
city: Chicago
|
||||
country: USA
|
||||
|
||||
@@ -1,12 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
GHCR_PAT="$1"
|
||||
NEW_TAG="${2:-latest}"
|
||||
NEW_TAG="${1:-latest}"
|
||||
TAG_FILE="/var/lib/polyweather/.current_tag"
|
||||
COMPOSE_DIR="/root/PolyWeather"
|
||||
LOCK_FILE="${POLYWEATHER_DEPLOY_LOCK_FILE:-/var/lock/polyweather-deploy.lock}"
|
||||
|
||||
echo "$GHCR_PAT" | docker login ghcr.io -u yangyuan-zhen --password-stdin
|
||||
GHCR_PAT=""
|
||||
if ! IFS= read -r GHCR_PAT && [ -z "$GHCR_PAT" ]; then
|
||||
echo "❌ GHCR token must be provided on stdin"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$GHCR_PAT" ]; then
|
||||
echo "❌ GHCR token must be provided on stdin"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$LOCK_FILE")"
|
||||
exec 9>"$LOCK_FILE"
|
||||
if ! flock -n 9; then
|
||||
echo "❌ Another PolyWeather deploy is already running"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf '%s' "$GHCR_PAT" | docker login ghcr.io -u yangyuan-zhen --password-stdin
|
||||
unset GHCR_PAT
|
||||
|
||||
cd "$COMPOSE_DIR"
|
||||
git fetch origin main && git reset --hard origin/main
|
||||
@@ -17,7 +35,45 @@ 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
|
||||
compose_up_retry "rollback" -d
|
||||
echo "✅ Rolled back to $PREVIOUS_TAG"
|
||||
else
|
||||
echo "⚠️ No previous tag to rollback to"
|
||||
fi
|
||||
}
|
||||
|
||||
compose_up_retry() {
|
||||
local name="$1"
|
||||
shift
|
||||
local output=""
|
||||
|
||||
for attempt in $(seq 1 6); do
|
||||
if output=$(docker compose up "$@" 2>&1); then
|
||||
echo "$output"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "$output"
|
||||
if echo "$output" | grep -qi "removal of container .* is already in progress"; then
|
||||
echo "Container removal is still in progress during ${name}; retry ${attempt}/6..."
|
||||
sleep 5
|
||||
continue
|
||||
fi
|
||||
|
||||
return 1
|
||||
done
|
||||
|
||||
echo "❌ docker compose up failed for ${name} after retries"
|
||||
return 1
|
||||
}
|
||||
|
||||
export IMAGE_TAG="$NEW_TAG"
|
||||
export POLYWEATHER_API_BASE_URL="${POLYWEATHER_FRONTEND_INTERNAL_API_BASE_URL:-http://polyweather_web:8000}"
|
||||
pull_ok=0
|
||||
for pull_attempt in $(seq 1 6); do
|
||||
docker compose pull && pull_ok=1 && break
|
||||
@@ -28,34 +84,176 @@ 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)
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
read_env_file_value() {
|
||||
local key="$1"
|
||||
if [ ! -f ".env" ]; then
|
||||
return 0
|
||||
fi
|
||||
echo " retry $i/30..."
|
||||
done
|
||||
awk -F= -v key="$key" '
|
||||
$0 ~ "^[[:space:]]*" key "[[:space:]]*=" {
|
||||
value=$0
|
||||
sub("^[^=]*=", "", value)
|
||||
gsub("^[[:space:]]+|[[:space:]]+$", "", value)
|
||||
gsub(/^["'"'"']|["'"'"']$/, "", value)
|
||||
print value
|
||||
}
|
||||
' .env | tail -n 1
|
||||
}
|
||||
|
||||
validate_frontend_api_base_url() {
|
||||
local api_base="${POLYWEATHER_API_BASE_URL:-}"
|
||||
if [ -z "$api_base" ]; then
|
||||
api_base="$(read_env_file_value "POLYWEATHER_API_BASE_URL")"
|
||||
fi
|
||||
local normalized
|
||||
normalized="$(printf '%s' "$api_base" | tr '[:upper:]' '[:lower:]' | sed 's/[[:space:]]//g; s#/*$##')"
|
||||
case "$normalized" in
|
||||
http://polyweather.top|https://polyweather.top|http://www.polyweather.top|https://www.polyweather.top)
|
||||
echo "❌ POLYWEATHER_API_BASE_URL must not point at the frontend site: $api_base"
|
||||
echo " Use the internal backend URL http://polyweather_web:8000 or the backend API host https://api.polyweather.top."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
PUBLIC_SMOKE_RECHECK_DELAY_SEC="${POLYWEATHER_PUBLIC_SMOKE_RECHECK_DELAY_SEC:-20}"
|
||||
|
||||
run_public_smoke_checks() {
|
||||
local phase="${1:-initial}"
|
||||
local failed=0
|
||||
|
||||
if [ "$phase" = "recheck" ]; then
|
||||
smoke_check "healthz recheck" "https://api.polyweather.top/healthz" 20 6 10 || failed=1
|
||||
smoke_check "frontend cities recheck" "https://polyweather.top/api/cities" 30 8 10 || failed=1
|
||||
smoke_check "frontend recheck" "https://www.polyweather.top/" 20 6 10 || failed=1
|
||||
else
|
||||
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
|
||||
fi
|
||||
|
||||
return "$failed"
|
||||
}
|
||||
|
||||
validate_frontend_api_base_url
|
||||
|
||||
echo "Updating Redis dependency..."
|
||||
compose_up_retry "redis" -d polyweather_redis
|
||||
|
||||
echo "Updating backend services..."
|
||||
compose_up_retry "backend services" -d --no-deps polyweather_web polyweather
|
||||
|
||||
echo "Waiting for backend..."
|
||||
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 observation collector..."
|
||||
compose_up_retry "observation collector" -d --no-deps polyweather_collector
|
||||
|
||||
echo "Updating frontend..."
|
||||
compose_up_retry "frontend" -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; }
|
||||
run_public_smoke_checks || FAILED=1
|
||||
|
||||
if [ "$FAILED" = "1" ]; then
|
||||
echo "⚠️ Initial public smoke failed; retrying before rollback..."
|
||||
sleep "$PUBLIC_SMOKE_RECHECK_DELAY_SEC"
|
||||
FAILED=0
|
||||
run_public_smoke_checks "recheck" || FAILED=1
|
||||
fi
|
||||
|
||||
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
|
||||
|
||||
|
||||
+77
-3
@@ -28,6 +28,11 @@ services:
|
||||
env_file: &id001
|
||||
- .env
|
||||
environment:
|
||||
POLYWEATHER_REDIS_URL: ${POLYWEATHER_REDIS_URL:-redis://polyweather_redis:6379/0}
|
||||
POLYWEATHER_COLLECTOR_PATCH_ENDPOINT: ''
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'false'
|
||||
POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED: 'false'
|
||||
POLYWEATHER_SERVICE_ROLE: bot
|
||||
TELEGRAM_AIRPORT_PUSH_INTERVAL_SEC: ${POLYWEATHER_BOT_AIRPORT_PUSH_INTERVAL_SEC:-180}
|
||||
TELEGRAM_AIRPORT_PUSH_MAX_WORKERS: ${POLYWEATHER_BOT_AIRPORT_PUSH_MAX_WORKERS:-1}
|
||||
healthcheck:
|
||||
@@ -66,9 +71,10 @@ services:
|
||||
NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL}
|
||||
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL: ${NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL:-https://polygon-bor-rpc.publicnode.com}
|
||||
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID: ${NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID:-}
|
||||
POLYWEATHER_API_BASE_URL: ${POLYWEATHER_API_BASE_URL:-http://polyweather_web:8000}
|
||||
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 +84,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
|
||||
@@ -92,6 +98,23 @@ services:
|
||||
max-file: "3"
|
||||
container_name: polyweather_web
|
||||
env_file: *id001
|
||||
environment:
|
||||
POLYWEATHER_REDIS_URL: ${POLYWEATHER_REDIS_URL:-redis://polyweather_redis:6379/0}
|
||||
POLYWEATHER_COLLECTOR_PATCH_ENDPOINT: ''
|
||||
POLYWEATHER_CITY_DETAIL_BATCH_CONCURRENCY: ${POLYWEATHER_CITY_DETAIL_BATCH_CONCURRENCY:-1}
|
||||
POLYWEATHER_CITY_DETAIL_BATCH_GLOBAL_CONCURRENCY: ${POLYWEATHER_CITY_DETAIL_BATCH_GLOBAL_CONCURRENCY:-1}
|
||||
POLYWEATHER_CITY_DETAIL_BATCH_PARTIAL_TIMEOUT_MS: ${POLYWEATHER_CITY_DETAIL_BATCH_PARTIAL_TIMEOUT_MS:-3000}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_AMOS_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_AMOS_SEC:-60}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_AMSC_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_AMSC_SEC:-180}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_COWIN_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_COWIN_SEC:-60}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'false'
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_HKO_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_HKO_SEC:-600}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_INITIAL_DELAY_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_INITIAL_DELAY_SEC:-5}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_MADIS_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_MADIS_SEC:-300}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_TICK_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_TICK_SEC:-30}
|
||||
POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED: 'false'
|
||||
POLYWEATHER_SERVICE_ROLE: web
|
||||
UVICORN_WORKERS: ${UVICORN_WORKERS:-2}
|
||||
healthcheck:
|
||||
interval: 30s
|
||||
retries: 3
|
||||
@@ -103,7 +126,58 @@ 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
|
||||
ulimits:
|
||||
nofile:
|
||||
soft: 65535
|
||||
hard: 65535
|
||||
user: ${UID:-1000}:${GID:-1000}
|
||||
volumes:
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/var/lib/polyweather
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/app/data
|
||||
polyweather_collector:
|
||||
command: python -m web.observation_collector_worker
|
||||
container_name: polyweather_collector
|
||||
depends_on:
|
||||
polyweather_redis:
|
||||
condition: service_healthy
|
||||
polyweather_web:
|
||||
condition: service_started
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
cpus: ${POLYWEATHER_COLLECTOR_CPUS:-0.75}
|
||||
env_file: *id001
|
||||
environment:
|
||||
POLYWEATHER_COLLECTOR_PATCH_ENDPOINT: ${POLYWEATHER_COLLECTOR_PATCH_ENDPOINT:-http://polyweather_web:8000/api/internal/collector-patch}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_AMOS_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_AMOS_SEC:-60}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_AMSC_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_AMSC_SEC:-180}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_COWIN_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_COWIN_SEC:-60}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'true'
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_HKO_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_HKO_SEC:-600}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_INITIAL_DELAY_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_INITIAL_DELAY_SEC:-15}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_MADIS_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_MADIS_SEC:-300}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_TICK_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_TICK_SEC:-30}
|
||||
POLYWEATHER_REDIS_URL: ${POLYWEATHER_REDIS_URL:-redis://polyweather_redis:6379/0}
|
||||
POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED: 'false'
|
||||
POLYWEATHER_SERVICE_ROLE: collector
|
||||
healthcheck:
|
||||
interval: 60s
|
||||
retries: 3
|
||||
test:
|
||||
- CMD
|
||||
- python
|
||||
- -c
|
||||
- import sqlite3; c=sqlite3.connect('/var/lib/polyweather/polyweather.db');
|
||||
c.execute('SELECT 1'); c.close()
|
||||
timeout: 10s
|
||||
image: ghcr.io/yangyuan-zhen/polyweather-backend:${IMAGE_TAG:-latest}
|
||||
mem_limit: ${POLYWEATHER_COLLECTOR_MEM_LIMIT:-768m}
|
||||
memswap_limit: ${POLYWEATHER_COLLECTOR_MEMSWAP_LIMIT:-1g}
|
||||
pids_limit: 256
|
||||
restart: unless-stopped
|
||||
user: ${UID:-1000}:${GID:-1000}
|
||||
volumes:
|
||||
|
||||
@@ -11,13 +11,13 @@
|
||||
| 香港 | CoWIN 6087 | 6087 | CoWIN (`cowin.hku.hk`) | 1 分钟 | 参考站温度(保良局陈守仁小学) | 免费 |
|
||||
| 香港 | HKO | HKO | HKO 官方 CSV (`data.weather.gov.hk`) | 10 分钟 | 官方气象站温度 | 免费 |
|
||||
| 台北 | 松山/中央气象署 | 466920 | CWA 开放数据 | 10 分钟 | 官方站点温度 | 免费 |
|
||||
| 北京 | 首都机场 | ZBAA | AMSC AWOS | 1 分钟 | 跑道端点气温 | 免费 |
|
||||
| 上海 | 浦东机场 | ZSPD | AMSC AWOS | 1 分钟 | 跑道端点气温 | 免费 |
|
||||
| 广州 | 白云机场 | ZGGG | AMSC AWOS | 1 分钟 | 跑道端点气温 | 免费 |
|
||||
| 成都 | 双流机场 | ZUUU | AMSC AWOS | 1 分钟 | 跑道端点气温 | 免费 |
|
||||
| 重庆 | 江北机场 | ZUCK | AMSC AWOS | 1 分钟 | 跑道端点气温 | 免费 |
|
||||
| 武汉 | 天河机场 | ZHHH | AMSC AWOS | 1 分钟 | 跑道端点气温 | 免费 |
|
||||
| 青岛 | 胶东机场 | ZSQD | AMSC AWOS | 1 分钟 | 跑道端点气温 | 免费 |
|
||||
| 北京 | 首都机场 | ZBAA | AMSC AWOS | 3 分钟 | 跑道端点气温 | 免费 |
|
||||
| 上海 | 浦东机场 | ZSPD | AMSC AWOS | 3 分钟 | 跑道端点气温 | 免费 |
|
||||
| 广州 | 白云机场 | ZGGG | AMSC AWOS | 3 分钟 | 跑道端点气温 | 免费 |
|
||||
| 成都 | 双流机场 | ZUUU | AMSC AWOS | 3 分钟 | 跑道端点气温 | 免费 |
|
||||
| 重庆 | 江北机场 | ZUCK | AMSC AWOS | 3 分钟 | 跑道端点气温 | 免费 |
|
||||
| 武汉 | 天河机场 | ZHHH | AMSC AWOS | 3 分钟 | 跑道端点气温 | 免费 |
|
||||
| 青岛 | 胶东机场 | ZSQD | AMSC AWOS | 3 分钟 | 跑道端点气温 | 免费 |
|
||||
| 东京 | 羽田 | RJTT | JMA AMeDAS (`jma.go.jp`) | 10 分钟 | 机场站点实时温度 | 免费 |
|
||||
| 安卡拉 | Esenboğa | 17128 | MGM (`servis.mgm.gov.tr`) | 5-15 分钟 | 机场站点实时温度 | 免费 |
|
||||
| 伊斯坦布尔 | 伊斯坦布尔机场 | 17058 | MGM (`servis.mgm.gov.tr`) | 5-15 分钟 | 机场站点实时温度 | 免费 |
|
||||
@@ -56,11 +56,20 @@
|
||||
> `MID_TEMP` / `END_TEMP`。这些字段是跑道观测位置气温,不是道面温度。
|
||||
> 结算跑道展示使用配置的结算端点;辅助跑道只作为背景曲线。
|
||||
|
||||
## 推送机制
|
||||
## 独立观测采集器
|
||||
|
||||
- Web/API 进程启动 `observation-collector` 后台线程,按源频率独立采集,不依赖 Telegram 推送循环
|
||||
- 默认频率:AMOS 60s、AMSC AWOS 180s、MADIS HFMETAR 300s、CoWIN 60s、HKO 600s
|
||||
- 每次采集复用 `weather_sources.py` 现有 `_attach_*` 写入逻辑,负责写 `airport_obs_log` / `runway_obs_log` / 今日观测缓存,并通过 `/api/internal/collector-patch` 写 Redis Stream 或 SQLite event log 后广播 SSE
|
||||
- 采集成功后刷新对应城市 `panel` cache;前端继续使用 HTTP snapshot + SSE patch,不需要依赖 Telegram 触发更新
|
||||
- `observation_source_gate.py` 对 AMSC、AMOS、MADIS、HKO、CoWIN 做 per-source/per-city singleflight 和 SQLite cooldown,防止 Web 请求、collector 和兜底分析同时打同一个外部源
|
||||
|
||||
## Telegram 推送机制
|
||||
|
||||
- 每城按原生频率独立推送,不捆绑
|
||||
- 首尔/釜山 60s,其余 600s
|
||||
- 首尔/釜山 60s,中国 AMSC 城市 180s,其余 600s
|
||||
- 循环轮询 60s 以匹配最快频率
|
||||
- Telegram 推送优先读取网站侧 `full`/`panel` 城市缓存;缓存缺失时只做非强制 `panel` 分析兜底,不触发 `force_refresh_observations_only`
|
||||
- 仅当当前温度距 DEB 预测最高 ≤3°C 时推送
|
||||
- 确认过峰值后自动停止
|
||||
|
||||
@@ -69,7 +78,7 @@
|
||||
为了向用户提供接近行情盘的实况响应并降低服务器负载,系统使用 **HTTP snapshot + Server-Sent Events (SSE) Patch + 可重放事件日志** 架构。生产环境推荐 Redis Stream;本地或单进程可回退 SQLite event log。
|
||||
|
||||
### 1. 数据推送链路 (Data Pipeline)
|
||||
1. **Collector 采集端触发**:在 `weather_sources.py` 中,当高频实况源(如 AMOS, CoWIN, MADIS 等)采集到温度更新或观测时间变更时,会调用 `_emit_temperature_patch_if_changed` 过滤重复值,并异步向 `/api/internal/collector-patch` 发送 POST 报文。
|
||||
1. **Observation Collector 采集端触发**:`web.observation_collector_service` 按源频率调用采集层;在 `weather_sources.py` 中,当高频实况源(如 AMOS, AMSC, CoWIN, HKO, MADIS 等)采集到温度更新或观测时间变更时,会调用 `_emit_temperature_patch_if_changed` 过滤重复值,并异步向 `/api/internal/collector-patch` 发送 POST 报文。
|
||||
2. **标准化事件**:`realtime_patch_schema.py` 将旧 `city_patch` 或新 payload 统一成 `city_observation_patch.v1`。
|
||||
3. **事件存储**:生产环境写入 Redis Stream(`stream:city_observation`)并生成全局递增 `revision`;SQLite `observation_patch_events` 保留为本地/兜底 replay。
|
||||
4. **FastAPI SSE 广播**:FastAPI 后端的 `sse_router.py` 根据城市订阅集合向匹配连接推送 patch;断线重连时按 `since_revision` replay。
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -13,13 +13,13 @@
|
||||
| hong kong | CoWIN 6087 | ~1 min | cowin.hku.hk, 保良局陳守仁小學,前端图表默认展示 |
|
||||
| hong kong | HKO 官方 CSV | ~10 min | data.weather.gov.hk(文件名虽含 1min,实际 10min 一报) |
|
||||
| singapore | MSS 官方 API | ~1 min | api.data.gov.sg, 站号 S24 |
|
||||
| beijing | AMSC AWOS (ZBAA) | ~1 min | 中国 |
|
||||
| shanghai | AMSC AWOS (ZSPD) | ~1 min | 中国 |
|
||||
| guangzhou | AMSC AWOS (ZGGG) | ~1 min | 中国 |
|
||||
| chengdu | AMSC AWOS (ZUUU) | ~1 min | 中国 |
|
||||
| chongqing | AMSC AWOS (ZUCK) | ~1 min | 中国 |
|
||||
| wuhan | AMSC AWOS (ZHHH) | ~1 min | 中国 |
|
||||
| qingdao | AMSC AWOS (ZSQD) | ~1 min | 中国 |
|
||||
| beijing | AMSC AWOS (ZBAA) | 3 min | 中国 |
|
||||
| shanghai | AMSC AWOS (ZSPD) | 3 min | 中国 |
|
||||
| guangzhou | AMSC AWOS (ZGGG) | 3 min | 中国 |
|
||||
| chengdu | AMSC AWOS (ZUUU) | 3 min | 中国 |
|
||||
| chongqing | AMSC AWOS (ZUCK) | 3 min | 中国 |
|
||||
| wuhan | AMSC AWOS (ZHHH) | 3 min | 中国 |
|
||||
| qingdao | AMSC AWOS (ZSQD) | 3 min | 中国 |
|
||||
|
||||
### Tier 2 — 5 分钟高频 (MADIS)
|
||||
|
||||
@@ -123,7 +123,8 @@ ankara, istanbul, helsinki, amsterdam, paris
|
||||
|
||||
频率取决于源头:
|
||||
|
||||
- AMSC / AMOS / CoWIN / MSS:源头约 1 分钟,图表按 1 分钟粒度追加。
|
||||
- AMOS / CoWIN / MSS:源头约 1 分钟,图表按 1 分钟粒度追加。
|
||||
- AMSC:中国跑道观测城市按 3 分钟采集,不再强制 60 秒刷新。
|
||||
- MADIS:源头约 5 分钟。
|
||||
- HKO / CWA / JMA / FMI / KNMI:源头约 10 分钟。
|
||||
- METAR-only 城市:按 METAR 可用频率和缓存 TTL,不伪装成 1 分钟实测。
|
||||
|
||||
@@ -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 个路由)
|
||||
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
# Feedback Reward Account Detail Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Let users see which submitted feedback earned points, how many points were awarded, and the reward reason in the account page.
|
||||
|
||||
**Architecture:** Store reward metadata directly on `user_feedback` rows so the existing feedback list API can expose the reward source without a separate ledger join. The account feedback panel renders reward state inline with each feedback item and stays silent for rows with no reward metadata.
|
||||
|
||||
**Tech Stack:** Python SQLite `DBManager`, FastAPI feedback service, Next.js/React account components, TypeScript source-based business tests.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- Modify `src/database/db_manager.py`: add reward columns, serialize them, and add `update_user_feedback_reward()` for future ops reward workflows.
|
||||
- Modify `tests/test_user_feedback.py`: add backend coverage for reward defaults and reward metadata round trip.
|
||||
- Modify `frontend/types/ops.ts`: add optional reward fields to `UserFeedbackEntry`.
|
||||
- Modify `frontend/components/account/AccountFeedbackPanel.tsx`: render reward labels and reasons inside each feedback row.
|
||||
- Modify `frontend/components/account/__tests__/accountFeedbackPanel.test.ts`: assert the panel handles reward display states.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Backend Feedback Reward Metadata
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/test_user_feedback.py`
|
||||
- Modify: `src/database/db_manager.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing backend test**
|
||||
|
||||
Add this test to `tests/test_user_feedback.py`:
|
||||
|
||||
```python
|
||||
def test_user_feedback_reward_metadata_round_trip(tmp_path):
|
||||
db = DBManager(str(tmp_path / "polyweather-feedback-reward.db"))
|
||||
|
||||
created = db.append_user_feedback(
|
||||
category="data",
|
||||
message="Hong Kong COWIN reading was stale.",
|
||||
user_id="user-reward",
|
||||
user_email="reward@example.com",
|
||||
)
|
||||
|
||||
assert created["reward_points"] == 0
|
||||
assert created["reward_reason"] == ""
|
||||
assert created["reward_status"] == ""
|
||||
assert created["rewarded_at"] is None
|
||||
|
||||
rewarded = db.update_user_feedback_reward(
|
||||
created["id"],
|
||||
points=300,
|
||||
reason="Valid data freshness report",
|
||||
status="granted",
|
||||
)
|
||||
|
||||
assert rewarded is not None
|
||||
assert rewarded["reward_points"] == 300
|
||||
assert rewarded["reward_reason"] == "Valid data freshness report"
|
||||
assert rewarded["reward_status"] == "granted"
|
||||
assert rewarded["rewarded_at"]
|
||||
|
||||
row = db.list_user_feedback(
|
||||
limit=10,
|
||||
user_id="user-reward",
|
||||
user_email="reward@example.com",
|
||||
)[0]
|
||||
assert row["id"] == created["id"]
|
||||
assert row["reward_points"] == 300
|
||||
assert row["reward_reason"] == "Valid data freshness report"
|
||||
assert row["reward_status"] == "granted"
|
||||
assert row["rewarded_at"] == rewarded["rewarded_at"]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the backend test and verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
python -m pytest tests/test_user_feedback.py::test_user_feedback_reward_metadata_round_trip -q
|
||||
```
|
||||
|
||||
Expected: failure because `reward_points` is missing or `update_user_feedback_reward` is undefined.
|
||||
|
||||
- [ ] **Step 3: Add reward columns and serialization**
|
||||
|
||||
In `src/database/db_manager.py`, update `user_feedback` schema and migration:
|
||||
|
||||
```python
|
||||
reward_points INTEGER DEFAULT 0,
|
||||
reward_reason TEXT DEFAULT '',
|
||||
rewarded_at TIMESTAMP,
|
||||
reward_status TEXT DEFAULT ''
|
||||
```
|
||||
|
||||
Add `_ensure_column()` calls for the same columns in the existing migration block.
|
||||
|
||||
Update every feedback `SELECT` to include:
|
||||
|
||||
```sql
|
||||
reward_points, reward_reason, rewarded_at, reward_status
|
||||
```
|
||||
|
||||
Update `_feedback_row_to_dict()` to return:
|
||||
|
||||
```python
|
||||
"reward_points": max(0, int(row["reward_points"] or 0)),
|
||||
"reward_reason": str(row["reward_reason"] or ""),
|
||||
"rewarded_at": row["rewarded_at"],
|
||||
"reward_status": str(row["reward_status"] or ""),
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add reward update method**
|
||||
|
||||
Add this method near `update_user_feedback_status()` in `src/database/db_manager.py`:
|
||||
|
||||
```python
|
||||
def update_user_feedback_reward(
|
||||
self,
|
||||
feedback_id: int,
|
||||
*,
|
||||
points: int,
|
||||
reason: str = "",
|
||||
status: str = "granted",
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
safe_points = max(0, int(points or 0))
|
||||
normalized_reason = str(reason or "").strip()[:500]
|
||||
normalized_status = str(status or "").strip().lower()[:40]
|
||||
if not normalized_status:
|
||||
normalized_status = "granted" if safe_points > 0 else "skipped"
|
||||
now = datetime.now().isoformat()
|
||||
with self._get_connection() as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE user_feedback
|
||||
SET reward_points = ?,
|
||||
reward_reason = ?,
|
||||
reward_status = ?,
|
||||
rewarded_at = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(
|
||||
safe_points,
|
||||
normalized_reason,
|
||||
normalized_status,
|
||||
now,
|
||||
now,
|
||||
int(feedback_id),
|
||||
),
|
||||
)
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT id, category, message, source, status, contact, user_id,
|
||||
user_email, context_json, reward_points, reward_reason,
|
||||
rewarded_at, reward_status, created_at, updated_at
|
||||
FROM user_feedback
|
||||
WHERE id = ?
|
||||
""",
|
||||
(int(feedback_id),),
|
||||
).fetchone()
|
||||
conn.commit()
|
||||
return self._feedback_row_to_dict(row) if row else None
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run backend tests**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
python -m pytest tests/test_user_feedback.py -q
|
||||
```
|
||||
|
||||
Expected: all `test_user_feedback.py` tests pass.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Account Feedback Reward Display
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/types/ops.ts`
|
||||
- Modify: `frontend/components/account/AccountFeedbackPanel.tsx`
|
||||
- Modify: `frontend/components/account/__tests__/accountFeedbackPanel.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing frontend test**
|
||||
|
||||
Extend the final assertion in `frontend/components/account/__tests__/accountFeedbackPanel.test.ts` or add a new assertion:
|
||||
|
||||
```ts
|
||||
assert(
|
||||
feedbackPanelSource.includes("reward_points") &&
|
||||
feedbackPanelSource.includes("reward_reason") &&
|
||||
feedbackPanelSource.includes("reward_status") &&
|
||||
feedbackPanelSource.includes("formatRewardPoints") &&
|
||||
feedbackPanelSource.includes("renderFeedbackReward") &&
|
||||
feedbackPanelSource.includes("奖励原因"),
|
||||
"account feedback panel must show per-feedback reward points and reward reasons",
|
||||
);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the frontend business test and verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
cd frontend; npm run test:business -- accountFeedbackPanel
|
||||
```
|
||||
|
||||
Expected: failure because the panel does not yet reference the reward fields.
|
||||
|
||||
- [ ] **Step 3: Extend frontend type**
|
||||
|
||||
Add optional fields to `UserFeedbackEntry` in `frontend/types/ops.ts`:
|
||||
|
||||
```ts
|
||||
reward_points?: number;
|
||||
reward_reason?: string;
|
||||
rewarded_at?: string | null;
|
||||
reward_status?: string;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add account reward rendering helpers**
|
||||
|
||||
In `AccountFeedbackPanel.tsx`, add helpers:
|
||||
|
||||
```tsx
|
||||
function formatRewardPoints(points?: number) {
|
||||
const value = Math.max(0, Number(points || 0));
|
||||
return `+${value.toLocaleString()} points`;
|
||||
}
|
||||
|
||||
function rewardStatusText(status?: string, isEn = false) {
|
||||
const key = String(status || "").toLowerCase();
|
||||
if (key === "pending") return isEn ? "Reward pending" : "奖励待处理";
|
||||
if (key === "skipped") return isEn ? "No points awarded" : "未发放积分";
|
||||
return isEn ? "Feedback reward" : "反馈奖励";
|
||||
}
|
||||
|
||||
function renderFeedbackReward(entry: UserFeedbackEntry, isEn: boolean) {
|
||||
const points = Math.max(0, Number(entry.reward_points || 0));
|
||||
const rewardStatus = String(entry.reward_status || "").toLowerCase();
|
||||
const reason = String(entry.reward_reason || "").trim();
|
||||
if (points <= 0 && !rewardStatus && !reason) return null;
|
||||
const granted = points > 0 || rewardStatus === "granted";
|
||||
return (
|
||||
<div className={`mt-2 rounded-lg border px-3 py-2 text-xs ${
|
||||
granted
|
||||
? "border-amber-200 bg-amber-50 text-amber-800"
|
||||
: "border-slate-200 bg-slate-50 text-slate-600"
|
||||
}`}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="font-bold">{rewardStatusText(rewardStatus, isEn)}</span>
|
||||
{points > 0 ? <span className="font-mono font-black">{formatRewardPoints(points)}</span> : null}
|
||||
</div>
|
||||
{reason ? (
|
||||
<div className="mt-1 leading-5 text-slate-600">
|
||||
{isEn ? "Reason" : "奖励原因"}: {reason}
|
||||
</div>
|
||||
) : null}
|
||||
{entry.rewarded_at ? (
|
||||
<div className="mt-1 font-mono text-[11px] text-slate-400">
|
||||
{compactDate(entry.rewarded_at)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Render reward detail in each feedback row**
|
||||
|
||||
Inside each feedback row, after the feedback message paragraph, add:
|
||||
|
||||
```tsx
|
||||
{renderFeedbackReward(entry, isEn)}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run frontend business test**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
cd frontend; npm run test:business -- accountFeedbackPanel
|
||||
```
|
||||
|
||||
Expected: `accountFeedbackPanel` passes.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Verification and Commit
|
||||
|
||||
**Files:**
|
||||
- Verify all touched files.
|
||||
|
||||
- [ ] **Step 1: Run targeted backend verification**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
python -m pytest tests/test_user_feedback.py -q
|
||||
```
|
||||
|
||||
Expected: all tests pass.
|
||||
|
||||
- [ ] **Step 2: Run targeted frontend verification**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
cd frontend; npm run test:business -- accountFeedbackPanel
|
||||
```
|
||||
|
||||
Expected: `accountFeedbackPanel` test passes.
|
||||
|
||||
- [ ] **Step 3: Run broader low-cost verification**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
python -m ruff check .
|
||||
cd frontend; npm run typecheck
|
||||
```
|
||||
|
||||
Expected: both commands pass.
|
||||
|
||||
- [ ] **Step 4: Check status and commit**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
git status --short
|
||||
git diff --check
|
||||
git add docs/superpowers/plans/2026-06-08-feedback-reward-account-detail.md tests/test_user_feedback.py src/database/db_manager.py frontend/types/ops.ts frontend/components/account/AccountFeedbackPanel.tsx frontend/components/account/__tests__/accountFeedbackPanel.test.ts
|
||||
git commit -m "Show feedback reward details in account"
|
||||
```
|
||||
|
||||
Expected: commit succeeds with only planned files staged.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
- Spec coverage: reward metadata fields, existing API payload, account inline display, missing metadata fallback, and future ops reuse are covered.
|
||||
- Scope control: the plan does not implement automatic reward issuance or external notifications.
|
||||
- Type consistency: frontend fields use `reward_points`, `reward_reason`, `rewarded_at`, and `reward_status`, matching backend serialization.
|
||||
@@ -0,0 +1,124 @@
|
||||
# Feedback Reward Account Detail Design
|
||||
|
||||
## Goal
|
||||
|
||||
Show users which submitted feedback earned points, how many points were awarded, and why.
|
||||
|
||||
The first implementation focuses on account-page visibility. The later ops workflow for automatic reward issuance should reuse the same fields and display contract.
|
||||
|
||||
## Current Context
|
||||
|
||||
- `user_feedback` already stores each submitted feedback item with `status`, `message`, `user_id`, `user_email`, timestamps, and context JSON.
|
||||
- `/api/feedback` already returns the current user's feedback list.
|
||||
- `AccountFeedbackPanel` already renders the current user's feedback in the account page.
|
||||
- Current points balance is shown through `/api/auth/me`, but account UI has no per-feedback reward source detail.
|
||||
- Existing points grant helpers update `users.points`; feedback-specific rewards are not yet linked back to a feedback row.
|
||||
|
||||
## Scope
|
||||
|
||||
In scope:
|
||||
|
||||
- Add feedback reward metadata to feedback rows.
|
||||
- Return reward metadata through the existing current-user feedback API.
|
||||
- Display feedback reward details in the account page next to the matching feedback item.
|
||||
- Keep the data contract ready for future ops automation: a processed feedback item can carry reward points and a human-readable reason.
|
||||
|
||||
Out of scope for this phase:
|
||||
|
||||
- Building the ops action that decides and grants points.
|
||||
- Sending push/email/Telegram notifications after ops processing.
|
||||
- Rebuilding a general points ledger for all point sources.
|
||||
- Merging historical Supabase referral ledger entries into the account page.
|
||||
|
||||
## Data Model
|
||||
|
||||
Extend `user_feedback` with nullable columns:
|
||||
|
||||
- `reward_points INTEGER DEFAULT 0`
|
||||
- `reward_reason TEXT DEFAULT ''`
|
||||
- `rewarded_at TIMESTAMP`
|
||||
- `reward_status TEXT DEFAULT ''`
|
||||
|
||||
Recommended `reward_status` values:
|
||||
|
||||
- empty string: no reward decision recorded
|
||||
- `granted`: points were awarded for this feedback
|
||||
- `skipped`: reviewed but no points awarded
|
||||
- `pending`: reward decision is queued or awaiting processing
|
||||
|
||||
The display should treat `reward_points > 0` as the strongest signal that the user earned points.
|
||||
|
||||
## API Contract
|
||||
|
||||
`DBManager._feedback_row_to_dict()` should include:
|
||||
|
||||
- `reward_points`
|
||||
- `reward_reason`
|
||||
- `rewarded_at`
|
||||
- `reward_status`
|
||||
|
||||
`GET /api/feedback` should continue using the same response shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"feedback": [
|
||||
{
|
||||
"id": 123,
|
||||
"status": "resolved",
|
||||
"message": "Hong Kong COWIN reading looked stale",
|
||||
"reward_points": 300,
|
||||
"reward_reason": "Valid data freshness report",
|
||||
"rewarded_at": "2026-06-08T12:00:00",
|
||||
"reward_status": "granted"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The frontend proxy does not need a new route if it transparently forwards the existing payload.
|
||||
|
||||
## Account UI
|
||||
|
||||
`AccountFeedbackPanel` should show reward detail inside each feedback row:
|
||||
|
||||
- Granted reward: `+300 points` plus reward reason.
|
||||
- Skipped reward: a muted note that no points were awarded, with reason if available.
|
||||
- Pending reward: a small pending label.
|
||||
- No reward metadata: render nothing extra to avoid noise.
|
||||
|
||||
The account page should remain dense and scannable. Reward content belongs inside the existing feedback row, not in a separate marketing-style card.
|
||||
|
||||
## Future Ops Workflow
|
||||
|
||||
The later feedback-processing feature can use a single backend operation:
|
||||
|
||||
1. Update feedback status.
|
||||
2. Grant points to the matching user.
|
||||
3. Write `reward_points`, `reward_reason`, `rewarded_at`, and `reward_status = granted` back to the same feedback row.
|
||||
|
||||
If the grant fails, the operation should not mark the feedback as granted. It should either keep `reward_status = pending` or return a clear ops error.
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Missing reward fields should default to zero or empty strings in API responses.
|
||||
- If old SQLite databases do not have the new columns, `DBManager` migration should add them with `_ensure_column`.
|
||||
- Account UI should tolerate partial payloads without crashing.
|
||||
- Reward reason should be bounded in storage and display to avoid oversized rows.
|
||||
|
||||
## Testing
|
||||
|
||||
Backend tests:
|
||||
|
||||
- Existing feedback rows serialize reward fields with defaults.
|
||||
- Updating reward metadata for a feedback row returns the fields in `/api/feedback`.
|
||||
- Old rows without rewards still list normally.
|
||||
|
||||
Frontend tests:
|
||||
|
||||
- Account feedback panel displays `+N points` and reason for rewarded feedback.
|
||||
- Skipped and pending reward states render without implying points were granted.
|
||||
- Rows without reward metadata do not show an empty reward block.
|
||||
|
||||
## Rollout
|
||||
|
||||
This is a backward-compatible schema and UI change. Existing feedback rows keep working with empty reward metadata. The account page can ship before the ops reward action; it will simply show reward details once rows carry those fields.
|
||||
+15
-24
@@ -9,7 +9,7 @@ PolyWeather Pro 的生产前端工程。
|
||||
|
||||
- Next.js App Router
|
||||
- React + Tailwind
|
||||
- Leaflet + Recharts
|
||||
- 自研温度图表 + Recharts 运营图表
|
||||
- Supabase Auth
|
||||
- WalletConnect + 浏览器 EVM 钱包
|
||||
|
||||
@@ -21,30 +21,20 @@ PolyWeather Pro 的生产前端工程。
|
||||
|
||||
## 当前前端能力
|
||||
|
||||
- 主站 Dashboard 支持地图、城市详情、今日日内分析和账户中心
|
||||
- `/docs` 已提供公开双语产品文档中心,解释日内分析、校准概率、模型栈、TAF 和结算来源
|
||||
- 主站为实时天气决策台,包含 `天气决策 / 训练数据 / 使用指南` 三个主入口
|
||||
- `/docs` 提供公开双语产品文档中心,当前保留简介、图表阅读、实时数据频率、结算站点和浏览器插件说明
|
||||
- 天气决策台支持 1x1 到 3x3 图表槽位,可按区域、搜索和城市选择进行多城市巡检
|
||||
- 终端图表默认展示全天,可切换高温窗口;所有横轴与 tooltip 时间按城市当地时间渲染
|
||||
- 可见终端图表通过 SSE patch 无痛增量刷新,后台切回前台时主动补齐最新 detail,不用 loading 遮罩覆盖已有曲线
|
||||
- 可见终端图表通过 SSE patch 增量刷新,后台切回前台时主动补齐最新 detail,不用 loading 遮罩覆盖已有曲线
|
||||
- AMSC/AMOS 跑道城市默认展示结算跑道曲线并高亮,辅助跑道弱化展示;单跑道机场不重复展示聚合线
|
||||
- 香港默认展示 CoWIN 6087 参考站 1 分钟曲线,并保留 HKO 10 分钟官方气象层
|
||||
- legacy 高斯概率在图表上展示为概率温度带和 `mu` 参考线,不作为时间序列曲线
|
||||
- 今日日内分析支持:
|
||||
- `锚点状态`
|
||||
- `当前节奏`
|
||||
- `校准模型概率`
|
||||
- `模型区间与分歧`
|
||||
- `专业气象结论条`
|
||||
- `气象证据链 / 失效条件 / 确认条件`
|
||||
- 非香港机场城市的 `TAF` 时段提示与走势图联动
|
||||
- 使用指南内置图表阅读顺序、图层含义、常用操作和默认可见性规则
|
||||
- `/ops` 已支持桌面表格 + 手机端卡片化视图
|
||||
- 点击城市图标后会显示地图顶部同步提醒与详情面板内同步徽标,避免用户误判为卡住
|
||||
- 城市详情会自动识别“单模型 / 单日”的稀疏缓存并主动刷新,避免误把残缺 detail 当作完整结果
|
||||
- 右侧详情面板在多日预报仍未补齐时会显示同步占位卡,不再把“只有今天一张卡”的中间态伪装成完整数据
|
||||
- 日内分析弹窗在 full detail / market scan 同步时会锁住旧内容并显示刷新状态,避免用户短暂看到旧城市或旧日期的数据
|
||||
- 城市决策卡支持从地图点击城市进入;机会榜和日历仍按 Pro 权限控制,地图探索和城市简报可作为轻量入口
|
||||
- 城市决策卡展示结构化实况、模型区间、市场温度桶和模型-市场差,不再请求 AI 解读
|
||||
- 市场价格层使用完整 `all_buckets` 匹配温度桶,并把 `模型-市场差` 解释为 `模型概率 - 市场隐含概率`
|
||||
- 概率区展示当前生产概率引擎输出(legacy 高斯或 EMOS),模型共识只作为辅助参考
|
||||
- 城市 detail 自动识别稀疏缓存并主动刷新,避免误把残缺 detail 当作完整结果
|
||||
- 市场价格层使用完整 `all_buckets` 匹配温度桶,市场信号作为交易判断层,不替代实测结算源
|
||||
- legacy 高斯概率在图表上展示为概率温度带和 `mu` 参考线,不作为时间序列曲线
|
||||
- 账户中心支付区支持后端下发的多链网络选择;Polygon 继续走 checkout 合约,Ethereum 主网 USDC 走手动直转确认
|
||||
- 缓存桶状态与 summary cache hit/miss
|
||||
|
||||
## 本地开发
|
||||
@@ -176,7 +166,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 的链确认交易
|
||||
|
||||
这意味着:
|
||||
|
||||
@@ -189,8 +180,8 @@ Ops:
|
||||
- `summary?force_refresh=true`:`no-store`
|
||||
- 支付相关路由:`no-store`
|
||||
- 当 detail 缓存只返回单模型或单日 forecast 时,前端会自动强刷完整 detail,并在补齐前显示同步提示 / 占位卡
|
||||
- 今日日内分析打开时如果正在切换城市、日期或 detail 深度,弹窗会阻断旧内容点击并显示刷新锁
|
||||
- 终端图表订阅 `/api/events?cities=...&since_revision=...&replay_limit=500`,接收 `city_observation_patch.v1`;无 patch 超过 2 分钟时,可见图表才触发 60 秒兜底刷新
|
||||
- detail 正在切换城市、日期或分辨率时,图表保留已有曲线并显示同步提示,避免把旧数据当作当前状态
|
||||
- 终端图表订阅 `/api/events?cities=...&since_revision=...&replay_limit=按可见城市数动态限制`,接收 `city_observation_patch.v1`;无 patch 超过 2 分钟时,可见图表才触发 60 秒兜底刷新
|
||||
- 前端只消费 HTTP snapshot + SSE patch,不直接感知 Redis;Redis Stream / SQLite event log 都由后端统一封装
|
||||
|
||||
## Vercel 节流建议
|
||||
@@ -206,4 +197,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,689 @@ 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 buildBackendAuthMeUrl(req: NextRequest) {
|
||||
const url = new URL(`${API_BASE!.replace(/\/+$/, "")}/api/auth/me`);
|
||||
if (req.nextUrl.searchParams.get("scope") === "entitlement") {
|
||||
url.searchParams.set("scope", "entitlement");
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
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("Cache-Control", "no-store");
|
||||
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(buildBackendAuthMeUrl(req), {
|
||||
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,100 @@
|
||||
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 || "15000",
|
||||
);
|
||||
|
||||
function parseRequestedCities(req: NextRequest) {
|
||||
const requestedLimit = Number(req.nextUrl.searchParams.get("limit") || "12");
|
||||
const limit = Number.isFinite(requestedLimit)
|
||||
? Math.max(1, Math.min(24, requestedLimit))
|
||||
: 12;
|
||||
const seen = new Set<string>();
|
||||
const requestedCities: string[] = [];
|
||||
|
||||
for (const item of (req.nextUrl.searchParams.get("cities") || "").split(
|
||||
",",
|
||||
)) {
|
||||
const city = item.trim();
|
||||
if (!city || seen.has(city)) continue;
|
||||
seen.add(city);
|
||||
requestedCities.push(city);
|
||||
if (requestedCities.length >= limit) break;
|
||||
}
|
||||
|
||||
return requestedCities;
|
||||
}
|
||||
|
||||
function buildCityDetailBatchTimeoutPayload(requestedCities: string[]) {
|
||||
return {
|
||||
cities: requestedCities,
|
||||
details: {},
|
||||
errors: {},
|
||||
missing: requestedCities,
|
||||
partial: true,
|
||||
timeout: true,
|
||||
};
|
||||
}
|
||||
|
||||
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 requestedCities = parseRequestedCities(req);
|
||||
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", "scope"]) {
|
||||
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,
|
||||
timeoutResponse: () =>
|
||||
NextResponse.json(buildCityDetailBatchTimeoutPayload(requestedCities), {
|
||||
headers: { "Cache-Control": "no-store, max-age=0" },
|
||||
status: 200,
|
||||
}),
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
requireBackendPaymentAuth,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
|
||||
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 },
|
||||
);
|
||||
}
|
||||
|
||||
let auth: Awaited<ReturnType<typeof buildBackendRequestHeaders>> | null = null;
|
||||
try {
|
||||
auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireBackendPaymentAuth(auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const upstream = new URL(`${API_BASE}/api/feedback`);
|
||||
const limit = req.nextUrl.searchParams.get("limit");
|
||||
if (limit) upstream.searchParams.set("limit", limit);
|
||||
|
||||
const res = await fetch(upstream, {
|
||||
method: "GET",
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
const raw = await res.text();
|
||||
if (!res.ok) {
|
||||
return applyAuthResponseCookies(
|
||||
buildUpstreamErrorResponse(res.status, raw, {
|
||||
detailLimit: 260,
|
||||
error: "Feedback status request failed",
|
||||
}),
|
||||
auth.response,
|
||||
);
|
||||
}
|
||||
const response = new NextResponse(raw, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": res.headers.get("content-type") || "application/json",
|
||||
},
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
const response = buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch feedback status",
|
||||
});
|
||||
return auth ? applyAuthResponseCookies(response, auth.response) : response;
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
let auth: Awaited<ReturnType<typeof buildBackendRequestHeaders>> | null = null;
|
||||
try {
|
||||
const body = await req.json();
|
||||
auth = await buildBackendRequestHeaders(req);
|
||||
const headers = new Headers(auth.headers);
|
||||
headers.set("Content-Type", "application/json");
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/feedback`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
cache: "no-store",
|
||||
});
|
||||
const raw = await res.text();
|
||||
if (!res.ok) {
|
||||
return applyAuthResponseCookies(
|
||||
buildUpstreamErrorResponse(res.status, raw, {
|
||||
detailLimit: 260,
|
||||
error: "Feedback request failed",
|
||||
}),
|
||||
auth.response,
|
||||
);
|
||||
}
|
||||
const response = new NextResponse(raw, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": res.headers.get("content-type") || "application/json",
|
||||
},
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
const response = buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to submit feedback",
|
||||
});
|
||||
return auth ? applyAuthResponseCookies(response, auth.response) : response;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
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 POST(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ feedbackId: string }> },
|
||||
) {
|
||||
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 { feedbackId } = await context.params;
|
||||
const body = await req.json();
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/ops/feedback/${encodeURIComponent(feedbackId)}/status`,
|
||||
{
|
||||
method: "POST",
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
...Object.fromEntries(new Headers(auth.headers).entries()),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
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 update feedback status",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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/feedback`);
|
||||
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 feedback",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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/observation-collector-status`);
|
||||
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: "Observation collector status check failed",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
@@ -11,6 +11,35 @@ import {
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
function looksLikeHtmlDocument(value: string) {
|
||||
const text = String(value || "").trim().toLowerCase();
|
||||
return (
|
||||
text.startsWith("<!doctype html") ||
|
||||
text.startsWith("<html") ||
|
||||
/<title>[^<]*(50\d|cloudflare|polyweather\.top)/i.test(String(value || ""))
|
||||
);
|
||||
}
|
||||
|
||||
function submitErrorMessage(raw: string) {
|
||||
try {
|
||||
const parsed = JSON.parse(String(raw || "")) as {
|
||||
detail?: unknown;
|
||||
error?: unknown;
|
||||
message?: unknown;
|
||||
};
|
||||
const message = [parsed.detail, parsed.error, parsed.message].find(
|
||||
(item) => typeof item === "string" && item.trim(),
|
||||
);
|
||||
if (typeof message === "string") {
|
||||
const trimmed = message.trim();
|
||||
if (!looksLikeHtmlDocument(trimmed)) return trimmed.slice(0, 350);
|
||||
}
|
||||
} catch {
|
||||
// Non-JSON upstream errors are commonly HTML 50x pages; do not expose them.
|
||||
}
|
||||
return "Payment submit upstream failed";
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ intentId: string }> },
|
||||
@@ -25,7 +54,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");
|
||||
@@ -40,14 +69,9 @@ export async function POST(
|
||||
);
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
let detail = raw.slice(0, 350);
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed.detail) detail = String(parsed.detail).slice(0, 350);
|
||||
} catch {}
|
||||
const response = buildUpstreamErrorResponse(res.status, raw, {
|
||||
detailLimit: 350,
|
||||
error: detail || undefined,
|
||||
error: submitErrorMessage(raw),
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -10,6 +10,42 @@ import {
|
||||
} from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
const BACKEND_RETRY_DELAY_MS = 250;
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function fetchWalletChallengeWithRetry(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
attempts = 2,
|
||||
) {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||
try {
|
||||
return await fetch(url, init);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (attempt >= attempts) break;
|
||||
await sleep(BACKEND_RETRY_DELAY_MS * attempt);
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
function logWalletChallengeProxyError(
|
||||
error: unknown,
|
||||
authDebug: Record<string, unknown>,
|
||||
) {
|
||||
const source = error as { name?: unknown; message?: unknown; cause?: unknown };
|
||||
console.error("[payment-wallet-challenge-proxy-exception]", {
|
||||
error_name: String(source?.name || "Error"),
|
||||
error_message: String(source?.message || error || "unknown"),
|
||||
error_cause: source?.cause ? String(source.cause) : "",
|
||||
...authDebug,
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
@@ -18,31 +54,45 @@ export async function POST(req: NextRequest) {
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid wallet challenge request" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
let authDebug: Record<string, unknown> = {};
|
||||
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");
|
||||
const res = await fetch(`${API_BASE}/api/payments/wallets/challenge`, {
|
||||
method: "POST",
|
||||
headers: proxiedHeaders,
|
||||
body: JSON.stringify(body ?? {}),
|
||||
cache: "no-store",
|
||||
});
|
||||
authDebug = {
|
||||
incoming_has_authorization: Boolean(
|
||||
String(req.headers.get("authorization") || "").trim(),
|
||||
),
|
||||
has_authorization: proxiedHeaders.has("authorization"),
|
||||
has_entitlement: proxiedHeaders.has("x-polyweather-entitlement"),
|
||||
has_forwarded_user_id: proxiedHeaders.has("x-polyweather-auth-user-id"),
|
||||
has_forwarded_email: proxiedHeaders.has("x-polyweather-auth-email"),
|
||||
};
|
||||
const res = await fetchWalletChallengeWithRetry(
|
||||
`${API_BASE}/api/payments/wallets/challenge`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: proxiedHeaders,
|
||||
body: JSON.stringify(body ?? {}),
|
||||
cache: "no-store",
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = buildUpstreamErrorResponse(res.status, raw, {
|
||||
detailLimit: 350,
|
||||
extraDebug: {
|
||||
has_authorization: proxiedHeaders.has("authorization"),
|
||||
has_entitlement: proxiedHeaders.has("x-polyweather-entitlement"),
|
||||
has_forwarded_user_id: proxiedHeaders.has(
|
||||
"x-polyweather-auth-user-id",
|
||||
),
|
||||
has_forwarded_email: proxiedHeaders.has("x-polyweather-auth-email"),
|
||||
},
|
||||
extraDebug: authDebug,
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
@@ -50,8 +100,12 @@ export async function POST(req: NextRequest) {
|
||||
const response = NextResponse.json(data);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
logWalletChallengeProxyError(error, authDebug);
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to create wallet challenge",
|
||||
status: 502,
|
||||
publicMessage:
|
||||
"Wallet challenge service is temporarily unavailable. Please retry.",
|
||||
extra: { retryable: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,19 +1,29 @@
|
||||
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(
|
||||
process.env.POLYWEATHER_SCAN_TERMINAL_PROXY_TIMEOUT_MS || "40000",
|
||||
process.env.POLYWEATHER_SCAN_TERMINAL_PROXY_TIMEOUT_MS || "35000",
|
||||
);
|
||||
|
||||
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,59 @@ function normalizeNextPath(input: string | null) {
|
||||
return raw;
|
||||
}
|
||||
|
||||
function normalizeAuthError(input: string | null) {
|
||||
const fallback = "Authentication failed. Please sign in again.";
|
||||
const raw = String(input || "")
|
||||
.replace(/[\r\n]+/g, " ")
|
||||
.trim();
|
||||
if (!raw) return fallback;
|
||||
return raw.slice(0, 240);
|
||||
}
|
||||
|
||||
function redirectToLoginWithError({
|
||||
baseUrl,
|
||||
error,
|
||||
nextPath,
|
||||
}: {
|
||||
baseUrl: string;
|
||||
error: string | null;
|
||||
nextPath: string;
|
||||
}) {
|
||||
const loginUrl = new URL("/auth/login", baseUrl);
|
||||
loginUrl.searchParams.set("next", nextPath);
|
||||
loginUrl.searchParams.set("auth_error", "1");
|
||||
loginUrl.searchParams.set("error", normalizeAuthError(error));
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
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.replace(/\/+$/, "")}/api/auth/me?scope=entitlement`, {
|
||||
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) {
|
||||
@@ -29,6 +85,16 @@ export async function GET(request: NextRequest) {
|
||||
const nextPath = normalizeNextPath(request.nextUrl.searchParams.get("next"));
|
||||
const baseUrl = siteUrl || request.nextUrl.origin;
|
||||
const redirectUrl = new URL(nextPath, baseUrl);
|
||||
const callbackError =
|
||||
request.nextUrl.searchParams.get("error_description") ||
|
||||
request.nextUrl.searchParams.get("error");
|
||||
if (callbackError) {
|
||||
return redirectToLoginWithError({
|
||||
baseUrl,
|
||||
error: callbackError,
|
||||
nextPath,
|
||||
});
|
||||
}
|
||||
|
||||
if (!hasSupabaseServerEnv()) {
|
||||
return NextResponse.redirect(redirectUrl);
|
||||
@@ -38,7 +104,25 @@ 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 },
|
||||
error: exchangeError,
|
||||
} = await supabase.auth.exchangeCodeForSession(code);
|
||||
if (exchangeError) {
|
||||
return redirectToLoginWithError({
|
||||
baseUrl,
|
||||
error: exchangeError.message,
|
||||
nextPath,
|
||||
});
|
||||
}
|
||||
if (!session?.access_token) {
|
||||
return redirectToLoginWithError({
|
||||
baseUrl,
|
||||
error: "Authentication session was not created. Please sign in again.",
|
||||
nextPath,
|
||||
});
|
||||
}
|
||||
await warmSignupTrial(session?.access_token || "");
|
||||
}
|
||||
|
||||
return response;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { LoginClient } from "@/components/auth/LoginClient";
|
||||
import { I18nProvider } from "@/hooks/useI18n";
|
||||
|
||||
type PageProps = {
|
||||
searchParams?: Promise<{ next?: string; mode?: string }>;
|
||||
searchParams?: Promise<{ error?: string; mode?: string; next?: string }>;
|
||||
};
|
||||
|
||||
function normalizeNextPath(input: string | undefined) {
|
||||
@@ -19,13 +19,26 @@ function normalizeMode(input: string | undefined): "login" | "signup" {
|
||||
return "login";
|
||||
}
|
||||
|
||||
function normalizeAuthError(input: string | undefined) {
|
||||
const raw = String(input || "")
|
||||
.replace(/[\r\n]+/g, " ")
|
||||
.trim();
|
||||
if (!raw) return "";
|
||||
return raw.slice(0, 240);
|
||||
}
|
||||
|
||||
export default async function LoginPage({ searchParams }: PageProps) {
|
||||
const params = (await searchParams) || {};
|
||||
const nextPath = normalizeNextPath(params.next);
|
||||
const initialMode = normalizeMode(params.mode);
|
||||
const initialError = normalizeAuthError(params.error);
|
||||
return (
|
||||
<I18nProvider>
|
||||
<LoginClient nextPath={nextPath} initialMode={initialMode} />
|
||||
<LoginClient
|
||||
nextPath={nextPath}
|
||||
initialMode={initialMode}
|
||||
initialError={initialError}
|
||||
/>
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -285,6 +285,133 @@
|
||||
0%, 100% { opacity: 0.3; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
|
||||
@keyframes landingRise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(18px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes landingFloat {
|
||||
0%,
|
||||
100% {
|
||||
translate: 0 0;
|
||||
}
|
||||
50% {
|
||||
translate: 0 -10px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes landingScan {
|
||||
0% {
|
||||
transform: translateY(-120%);
|
||||
opacity: 0;
|
||||
}
|
||||
18%,
|
||||
72% {
|
||||
opacity: 0.48;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(120%);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes landingPulseDot {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(37, 99, 235, 0.22);
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 7px rgba(37, 99, 235, 0);
|
||||
transform: scale(1.08);
|
||||
}
|
||||
}
|
||||
|
||||
.landing-rise {
|
||||
opacity: 0;
|
||||
animation: landingRise 720ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
.landing-delay-1 {
|
||||
animation-delay: 100ms;
|
||||
}
|
||||
|
||||
.landing-delay-2 {
|
||||
animation-delay: 190ms;
|
||||
}
|
||||
|
||||
.landing-delay-3 {
|
||||
animation-delay: 280ms;
|
||||
}
|
||||
|
||||
.landing-float {
|
||||
animation: landingFloat 7s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.landing-float-slow {
|
||||
animation: landingFloat 9s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.landing-hover-lift {
|
||||
transition:
|
||||
transform 220ms ease,
|
||||
border-color 220ms ease,
|
||||
box-shadow 220ms ease;
|
||||
}
|
||||
|
||||
.landing-hover-lift:hover {
|
||||
transform: translateY(-4px);
|
||||
border-color: rgba(148, 163, 184, 0.78);
|
||||
box-shadow: 0 18px 44px rgba(15, 23, 42, 0.1);
|
||||
}
|
||||
|
||||
.landing-screen-glow {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.landing-screen-glow::after {
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
inset: 44px 8px 8px;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
transparent 0%,
|
||||
rgba(37, 99, 235, 0.12) 48%,
|
||||
transparent 100%
|
||||
);
|
||||
mix-blend-mode: multiply;
|
||||
opacity: 0;
|
||||
animation: landingScan 5.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.landing-pulse-dot {
|
||||
animation: landingPulseDot 2.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.landing-rise,
|
||||
.landing-float,
|
||||
.landing-float-slow,
|
||||
.landing-screen-glow::after,
|
||||
.landing-pulse-dot {
|
||||
opacity: 1;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.landing-hover-lift,
|
||||
.landing-hover-lift:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Reduced motion: disable all animations and transitions ── */
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
import { requireOpsAdmin } from "@/lib/ops-admin";
|
||||
import { FeedbackPageClient } from "@/components/ops/feedback/FeedbackPageClient";
|
||||
|
||||
export const metadata: Metadata = { title: "用户反馈 — PolyWeather Ops" };
|
||||
|
||||
export default async function OpsFeedbackPage() {
|
||||
await requireOpsAdmin("/ops/feedback");
|
||||
return <FeedbackPageClient />;
|
||||
}
|
||||
+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,29 +12,24 @@ 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 包含哪些功能?",
|
||||
q_en: "What features does Pro include?",
|
||||
a_zh: "开通后可解锁:今日日内机场报文规则分析(含高温时段)、未来日期分析、城市决策卡、全平台智能气象推送。",
|
||||
a_en: "Unlocks: intraday METAR rule analysis (including peak window), future-date analysis, city decision cards, and cross-platform smart weather push.",
|
||||
a_zh: "开通后可解锁:天气决策台、多城市图表巡检、未来日期分析、全平台智能气象推送。",
|
||||
a_en: "Unlocks: weather decision terminal, multi-city chart monitoring, future-date analysis, and cross-platform smart weather push.",
|
||||
},
|
||||
{
|
||||
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,10 +1,21 @@
|
||||
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",
|
||||
description:
|
||||
"Paid PolyWeather decision terminal for weather-signal analysis and city decision cards.",
|
||||
"Paid PolyWeather decision terminal for weather-signal analysis and multi-city chart monitoring.",
|
||||
};
|
||||
|
||||
export default function TerminalPage() {
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
WALLETCONNECT_PROJECT_ID,
|
||||
} from "./constants";
|
||||
import { InfoRow, PlusIcon } from "./AccountInfoRow";
|
||||
import { AccountFeedbackPanel } from "./AccountFeedbackPanel";
|
||||
import {
|
||||
chainIdToDisplayName,
|
||||
clearStoredPaymentRecovery,
|
||||
@@ -84,6 +85,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 +127,7 @@ export function AccountCenter() {
|
||||
boundWallets,
|
||||
walletAddress,
|
||||
selectedPlanCode,
|
||||
selectedPaymentChainId,
|
||||
selectedTokenAddress,
|
||||
selectedWallet,
|
||||
providerMode,
|
||||
@@ -133,6 +137,8 @@ export function AccountCenter() {
|
||||
|
||||
// Shared setters
|
||||
setSelectedTokenAddress,
|
||||
setSelectedPaymentChainId,
|
||||
setSelectedPlanCode,
|
||||
setSelectedWallet,
|
||||
setSelectedInjectedProviderKey,
|
||||
setProviderMode,
|
||||
@@ -149,6 +155,8 @@ export function AccountCenter() {
|
||||
selectedPaymentToken,
|
||||
selectedTokenLabel,
|
||||
availableTokenList,
|
||||
availableChainList,
|
||||
selectedPaymentChain,
|
||||
effectivePlanList,
|
||||
resolvedSelectedTokenAddress,
|
||||
paymentReceiverAddress,
|
||||
@@ -189,19 +197,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 +333,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 +367,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 +427,29 @@ 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 selectedPlanDurationDays = Number(selectedPlan?.duration_days || 30);
|
||||
const overlayPlanLabel =
|
||||
selectedPlanCode === "pro_quarterly"
|
||||
? copy.quarterlyPlan
|
||||
: copy.monthlyPlan;
|
||||
const overlayPeriodLabel = isEn
|
||||
? `/ ${selectedPlanDurationDays} days`
|
||||
: `/ ${selectedPlanDurationDays} 天`;
|
||||
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 +470,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 +509,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 +741,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 +750,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 +767,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 +797,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>
|
||||
@@ -790,6 +913,8 @@ export function AccountCenter() {
|
||||
<UnlockProOverlay
|
||||
points={totalPoints}
|
||||
planPriceUsd={billing.planAmount}
|
||||
planLabel={overlayPlanLabel}
|
||||
periodLabel={overlayPeriodLabel}
|
||||
usePoints={usePoints}
|
||||
onToggleUsePoints={() => setUsePoints((prev) => !prev)}
|
||||
billing={{
|
||||
@@ -807,10 +932,11 @@ export function AccountCenter() {
|
||||
payBusy={paymentBusy}
|
||||
payLabel={hasPayingWallet ? copy.payNow : copy.connectAndPay}
|
||||
manualPayLabel="手动转账"
|
||||
locale={locale}
|
||||
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=""
|
||||
@@ -819,11 +945,47 @@ export function AccountCenter() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AccountFeedbackPanel
|
||||
isEn={isEn}
|
||||
title={copy.accountFeedbackTitle}
|
||||
description={copy.accountFeedbackDescription}
|
||||
refreshLabel={copy.refresh}
|
||||
/>
|
||||
|
||||
{/* 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 +1063,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 +1097,289 @@ 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 +1403,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 +1628,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 +1642,8 @@ export function AccountCenter() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { MessageSquare, RefreshCw } from "lucide-react";
|
||||
import type { UserFeedbackEntry, UserFeedbackPayload } from "@/types/ops";
|
||||
import {
|
||||
feedbackStatusLabel,
|
||||
feedbackStatusTone,
|
||||
} from "@/components/dashboard/scan-terminal/feedback-status";
|
||||
|
||||
function compactDate(value?: string) {
|
||||
if (!value) return "--";
|
||||
return value.slice(0, 16).replace("T", " ");
|
||||
}
|
||||
|
||||
function categoryLabel(value?: string, isEn = false) {
|
||||
const key = String(value || "").toLowerCase();
|
||||
if (key === "bug") return "Bug";
|
||||
if (key === "data") return isEn ? "Data" : "数据";
|
||||
if (key === "idea") return isEn ? "Suggestion" : "建议";
|
||||
if (key === "payment") return isEn ? "Payment" : "支付";
|
||||
if (key === "account") return isEn ? "Account" : "账号";
|
||||
return isEn ? "Other" : "其他";
|
||||
}
|
||||
|
||||
function formatRewardPoints(points?: number, isEn = false) {
|
||||
const raw = Number(points || 0);
|
||||
const value = Number.isFinite(raw) ? Math.max(0, raw) : 0;
|
||||
return isEn
|
||||
? `+${value.toLocaleString()} points`
|
||||
: `+${value.toLocaleString()} 积分`;
|
||||
}
|
||||
|
||||
function rewardStatusText(status?: string, isEn = false) {
|
||||
const key = String(status || "").toLowerCase();
|
||||
if (key === "pending") return isEn ? "Reward pending" : "奖励待处理";
|
||||
if (key === "skipped") return isEn ? "No points awarded" : "未发放积分";
|
||||
return isEn ? "Feedback reward" : "反馈奖励";
|
||||
}
|
||||
|
||||
function renderFeedbackReward(entry: UserFeedbackEntry, isEn: boolean) {
|
||||
const rawPoints = Number(entry.reward_points || 0);
|
||||
const points = Number.isFinite(rawPoints) ? Math.max(0, rawPoints) : 0;
|
||||
const rewardStatus = String(entry.reward_status || "").toLowerCase();
|
||||
const reason = String(entry.reward_reason || "").trim();
|
||||
if (points <= 0 && !rewardStatus && !reason) return null;
|
||||
|
||||
const granted = points > 0 || rewardStatus === "granted";
|
||||
return (
|
||||
<div
|
||||
className={`mt-2 rounded-lg border px-3 py-2 text-xs ${
|
||||
granted
|
||||
? "border-amber-200 bg-amber-50 text-amber-800"
|
||||
: "border-slate-200 bg-slate-50 text-slate-600"
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="font-bold">{rewardStatusText(rewardStatus, isEn)}</span>
|
||||
{points > 0 ? (
|
||||
<span className="font-mono font-black">
|
||||
{formatRewardPoints(points, isEn)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{reason ? (
|
||||
<div className="mt-1 leading-5 text-slate-600">
|
||||
{isEn ? "Reason" : "奖励原因"}: {reason}
|
||||
</div>
|
||||
) : null}
|
||||
{entry.rewarded_at ? (
|
||||
<div className="mt-1 font-mono text-[11px] text-slate-400">
|
||||
{compactDate(entry.rewarded_at)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccountFeedbackPanel({
|
||||
isEn,
|
||||
title,
|
||||
description,
|
||||
refreshLabel,
|
||||
}: {
|
||||
isEn: boolean;
|
||||
title: string;
|
||||
description: string;
|
||||
refreshLabel: string;
|
||||
}) {
|
||||
const [entries, setEntries] = useState<UserFeedbackEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [available, setAvailable] = useState(true);
|
||||
|
||||
const load = useCallback(async (signal?: AbortSignal) => {
|
||||
if (typeof fetch !== "function") return;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await fetch("/api/feedback?limit=10", {
|
||||
cache: "no-store",
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
setAvailable(false);
|
||||
setEntries([]);
|
||||
return;
|
||||
}
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const payload = (await res.json()) as UserFeedbackPayload;
|
||||
setEntries(Array.isArray(payload.feedback) ? payload.feedback : []);
|
||||
setAvailable(true);
|
||||
} catch (err) {
|
||||
if (signal?.aborted) return;
|
||||
setError(String(err).slice(0, 140));
|
||||
} finally {
|
||||
if (!signal?.aborted) setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void load(controller.signal);
|
||||
return () => controller.abort();
|
||||
}, [load]);
|
||||
|
||||
const emptyText = useMemo(() => {
|
||||
if (loading) return isEn ? "Loading feedback..." : "正在加载反馈...";
|
||||
return isEn ? "No submitted feedback yet." : "暂无已提交反馈。";
|
||||
}, [isEn, loading]);
|
||||
|
||||
if (!available) return null;
|
||||
|
||||
return (
|
||||
<section className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm lg:col-span-12">
|
||||
<div className="mb-5 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h3 className="flex items-center gap-2 text-sm font-bold uppercase text-slate-700">
|
||||
<MessageSquare size={18} className="text-blue-500" />
|
||||
{title}
|
||||
</h3>
|
||||
<p className="mt-1 text-xs leading-5 text-slate-500">{description}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void load()}
|
||||
disabled={loading}
|
||||
className="inline-flex h-9 items-center justify-center gap-2 rounded-lg border border-slate-200 bg-white px-3 text-xs font-bold text-slate-600 transition hover:bg-slate-50 disabled:cursor-wait disabled:opacity-60"
|
||||
>
|
||||
<RefreshCw size={14} className={loading ? "animate-spin" : ""} />
|
||||
{refreshLabel}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-3 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700">
|
||||
{isEn ? "Failed to load feedback: " : "反馈加载失败:"}{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entries.length === 0 ? (
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-8 text-center text-sm text-slate-500">
|
||||
{emptyText}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-slate-100 overflow-hidden rounded-xl border border-slate-200">
|
||||
{entries.map((entry) => (
|
||||
<div key={entry.id} className="grid gap-3 bg-white px-4 py-3 md:grid-cols-[minmax(0,1fr)_auto]">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={`rounded border px-2 py-0.5 text-[10px] font-black ${feedbackStatusTone(entry.status)}`}>
|
||||
{feedbackStatusLabel(entry.status, isEn)}
|
||||
</span>
|
||||
<span className="text-[11px] font-bold text-slate-500">
|
||||
{categoryLabel(entry.category, isEn)}
|
||||
</span>
|
||||
<span className="text-[11px] text-slate-400">
|
||||
{compactDate(entry.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 line-clamp-2 text-sm font-semibold leading-5 text-slate-900">
|
||||
{entry.message || (isEn ? "Feedback" : "反馈")}
|
||||
</p>
|
||||
{renderFeedbackReward(entry, isEn)}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 md:text-right">
|
||||
<div className="font-mono">{compactDate(entry.updated_at)}</div>
|
||||
<div className="mt-1 text-[11px] text-slate-400">
|
||||
{isEn ? "Updated" : "最近更新"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
function assert(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
export function runTests() {
|
||||
const projectRoot = process.cwd();
|
||||
const accountDir = path.join(projectRoot, "components", "account");
|
||||
const accountCenterSource = fs.readFileSync(
|
||||
path.join(accountDir, "AccountCenter.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const feedbackPanelPath = path.join(accountDir, "AccountFeedbackPanel.tsx");
|
||||
|
||||
assert(fs.existsSync(feedbackPanelPath), "account center must ship a My Feedback panel component");
|
||||
|
||||
const feedbackPanelSource = fs.readFileSync(feedbackPanelPath, "utf8");
|
||||
assert(
|
||||
accountCenterSource.includes('import { AccountFeedbackPanel } from "./AccountFeedbackPanel";') &&
|
||||
accountCenterSource.includes("<AccountFeedbackPanel") &&
|
||||
accountCenterSource.includes("accountFeedbackTitle"),
|
||||
"account center must mount the My Feedback panel with localized account copy",
|
||||
);
|
||||
assert(
|
||||
feedbackPanelSource.includes("/api/feedback?limit=10") &&
|
||||
feedbackPanelSource.includes("feedbackStatusLabel") &&
|
||||
feedbackPanelSource.includes("RefreshCw") &&
|
||||
feedbackPanelSource.includes("useEffect") &&
|
||||
!feedbackPanelSource.includes("setInterval"),
|
||||
"account feedback panel must load the current user's feedback once, support manual refresh, and avoid polling",
|
||||
);
|
||||
assert(
|
||||
feedbackPanelSource.includes("reward_points") &&
|
||||
feedbackPanelSource.includes("reward_reason") &&
|
||||
feedbackPanelSource.includes("reward_status") &&
|
||||
feedbackPanelSource.includes("formatRewardPoints") &&
|
||||
feedbackPanelSource.includes("renderFeedbackReward") &&
|
||||
feedbackPanelSource.includes("奖励原因"),
|
||||
"account feedback panel must show per-feedback reward points and reward reasons",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
buildAuthMePath,
|
||||
mergeAccountAuthSnapshot,
|
||||
} from "@/lib/auth-snapshot";
|
||||
import type { AuthSnapshotLike } from "@/lib/auth-snapshot";
|
||||
|
||||
function assert(condition: unknown, message: string) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
export function runTests() {
|
||||
assert(
|
||||
buildAuthMePath({ scope: "entitlement" }) ===
|
||||
"/api/auth/me?scope=entitlement",
|
||||
"account entitlement probe must use the lightweight auth/me scope",
|
||||
);
|
||||
assert(
|
||||
buildAuthMePath({ preferSnapshot: true, scope: "entitlement" }) ===
|
||||
"/api/auth/me?prefer_snapshot=1&scope=entitlement",
|
||||
"terminal auth probe must combine snapshot preference with lightweight entitlement scope",
|
||||
);
|
||||
|
||||
const active: AuthSnapshotLike & { referral?: { code?: string } } = {
|
||||
authenticated: true,
|
||||
user_id: "user-1",
|
||||
subscription_active: true,
|
||||
subscription_plan_code: "pro_monthly",
|
||||
subscription_expires_at: "2026-07-01T00:00:00Z",
|
||||
subscription_total_expires_at: "2026-07-01T00:00:00Z",
|
||||
subscription_queued_days: 0,
|
||||
points: 120,
|
||||
referral: { code: "PW-ABC" },
|
||||
};
|
||||
|
||||
const degraded = mergeAccountAuthSnapshot(active, {
|
||||
authenticated: true,
|
||||
user_id: "user-1",
|
||||
subscription_active: null,
|
||||
subscription_plan_code: null,
|
||||
subscription_expires_at: null,
|
||||
subscription_total_expires_at: null,
|
||||
subscription_queued_days: 0,
|
||||
points: 0,
|
||||
degraded_auth_profile: true,
|
||||
});
|
||||
|
||||
assert(
|
||||
degraded.subscription_active === true &&
|
||||
degraded.subscription_plan_code === "pro_monthly" &&
|
||||
degraded.points === 120 &&
|
||||
degraded.referral?.code === "PW-ABC",
|
||||
"account snapshot merge must preserve confirmed Pro status when a later auth/me response is degraded",
|
||||
);
|
||||
|
||||
const inactive = mergeAccountAuthSnapshot(active, {
|
||||
authenticated: true,
|
||||
user_id: "user-1",
|
||||
subscription_active: false,
|
||||
subscription_plan_code: null,
|
||||
points: 0,
|
||||
});
|
||||
|
||||
assert(
|
||||
inactive.subscription_active === false,
|
||||
"account snapshot merge must still accept a confirmed inactive subscription response",
|
||||
);
|
||||
}
|
||||
@@ -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,116 @@
|
||||
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 useBilling = fs.readFileSync(
|
||||
path.join(projectRoot, "components", "account", "useBilling.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const unlockProOverlay = fs.readFileSync(
|
||||
path.join(projectRoot, "components", "subscription", "UnlockProOverlay.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const telegramPricing = fs.readFileSync(
|
||||
path.join(projectRoot, "components", "account", "telegram-pricing.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(
|
||||
useAccountPayment.includes("applyTelegramGroupPricingToPlanList") &&
|
||||
useAccountPayment.includes("backend?.telegram_pricing") &&
|
||||
useAccountPayment.includes("isTelegramPrivateGroupPriceEligible") &&
|
||||
telegramPricing.includes("is_private_group_member") &&
|
||||
telegramPricing.includes("telegram_private_group_member") &&
|
||||
!telegramPricing.includes("is_group_member") &&
|
||||
useAccountPayment.includes('=== "pro_monthly"') &&
|
||||
useAccountPayment.includes("amount_usdc: telegramAmountUsdc"),
|
||||
"account payment plan cards must only display the 5 USDC discounted monthly price after verified /bind eligibility",
|
||||
);
|
||||
assert(
|
||||
useBilling.includes("telegramGroupPriceApplies") &&
|
||||
useBilling.includes("isTelegramPrivateGroupPriceEligible") &&
|
||||
useBilling.includes("backend?.telegram_pricing") &&
|
||||
useBilling.includes("!telegramGroupPriceApplies"),
|
||||
"billing must not let referral first-month pricing override the lower verified monthly price",
|
||||
);
|
||||
assert(
|
||||
!accountCenter.includes(["private", "Group", "Monthly", "Plan"].join("")) &&
|
||||
!accountCopy.includes(["Private", "group", "monthly"].join(" ")) &&
|
||||
!accountCopy.includes(["私", "密", "群", "月", "付"].join("")),
|
||||
"account plan card and checkout overlay should not expose a separate discounted monthly label",
|
||||
);
|
||||
assert(
|
||||
accountCenter.includes("overlayPlanLabel") &&
|
||||
accountCenter.includes("overlayPeriodLabel") &&
|
||||
!accountCenter.includes(`copy.${["private", "Group", "Monthly", "Plan"].join("")}`) &&
|
||||
unlockProOverlay.includes("planLabel") &&
|
||||
unlockProOverlay.includes("USDC") &&
|
||||
!unlockProOverlay.includes("<span className={s.price}>${planPriceUsd.toFixed(2)}</span>") &&
|
||||
!unlockProOverlay.includes('<span className={s.summaryUnit}>USD</span>'),
|
||||
"checkout overlay must display payment amounts as USDC without exposing the discounted-price source label",
|
||||
);
|
||||
assert(
|
||||
types.includes("ReferralSummary") &&
|
||||
types.includes("referral?: ReferralSummary | null") &&
|
||||
types.includes("is_private_group_member?: boolean") &&
|
||||
types.includes("duration_days: number") &&
|
||||
types.includes("max_discount_usdc_by_plan"),
|
||||
"account auth and payment types must include referral summary, private Telegram pricing, and plan durations",
|
||||
);
|
||||
}
|
||||
@@ -44,6 +44,10 @@ export function runTests() {
|
||||
);
|
||||
|
||||
const accountCenterSource = fs.readFileSync(accountCenterPath, "utf8");
|
||||
const walletBindSource = fs.readFileSync(
|
||||
path.join(projectRoot, "components", "account", "useWalletBind.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const hookPath = path.join(
|
||||
projectRoot,
|
||||
"components",
|
||||
@@ -62,6 +66,10 @@ export function runTests() {
|
||||
const paymentFlowSource = fs.existsSync(paymentFlowPath)
|
||||
? fs.readFileSync(paymentFlowPath, "utf8")
|
||||
: "";
|
||||
const paymentUtilsSource = fs.readFileSync(
|
||||
path.join(projectRoot, "components", "account", "payment-utils.ts"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
// The receiver validation now lives in the extracted hook file (called
|
||||
// from createManualPaymentIntent and createIntentAndPay).
|
||||
@@ -71,6 +79,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 +108,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 +120,164 @@ 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`,
|
||||
);
|
||||
}
|
||||
|
||||
const walletChallengeRouteSource = fs.readFileSync(
|
||||
path.join(projectRoot, "app/api/payments/wallets/challenge/route.ts"),
|
||||
"utf8",
|
||||
);
|
||||
assert(
|
||||
walletChallengeRouteSource.includes("fetchWalletChallengeWithRetry"),
|
||||
"wallet challenge proxy must retry a transient backend connection failure before blocking payment",
|
||||
);
|
||||
assert(
|
||||
walletChallengeRouteSource.includes("Invalid wallet challenge request"),
|
||||
"wallet challenge proxy must return a client error for malformed JSON instead of a generic payment failure",
|
||||
);
|
||||
assert(
|
||||
walletChallengeRouteSource.includes("retryable: true"),
|
||||
"wallet challenge proxy exception response must mark transient failures as retryable",
|
||||
);
|
||||
assert(
|
||||
walletBindSource.includes("readPaymentApiErrorMessage"),
|
||||
"wallet binding errors must show the API error message instead of raw JSON",
|
||||
);
|
||||
assert(
|
||||
paymentUtilsSource.includes("looksLikeHtmlDocument") &&
|
||||
paymentUtilsSource.includes("Payment service is temporarily unavailable") &&
|
||||
paymentUtilsSource.includes("支付服务暂时不可用"),
|
||||
"payment API error parsing must collapse upstream HTML/Cloudflare 50x pages into a user-safe message",
|
||||
);
|
||||
assert(
|
||||
paymentFlowSource.includes("readPaymentApiErrorMessage") &&
|
||||
!paymentFlowSource.includes("(await submitRes.text()).slice(0, 350)") &&
|
||||
!paymentFlowSource.includes("(await confirmRes.text()).slice(0, 350)"),
|
||||
"payment submit/confirm failures must use sanitized API error messages instead of raw response text",
|
||||
);
|
||||
const submitRouteSource = fs.readFileSync(
|
||||
path.join(
|
||||
projectRoot,
|
||||
"app",
|
||||
"api",
|
||||
"payments",
|
||||
"intents",
|
||||
"[intentId]",
|
||||
"submit",
|
||||
"route.ts",
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
assert(
|
||||
submitRouteSource.includes("Payment submit upstream failed") &&
|
||||
!submitRouteSource.includes("error: detail || undefined"),
|
||||
"payment submit proxy must not copy raw upstream HTML into the public error field",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,129 @@ 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(buildBackendAuthMeUrl(req)"),
|
||||
"auth profile proxy must return unauthenticated locally for no-session Supabase requests instead of forwarding the backend entitlement token",
|
||||
);
|
||||
assert(
|
||||
authMeRouteSource.includes("function buildBackendAuthMeUrl") &&
|
||||
authMeRouteSource.includes('url.searchParams.set("scope", "entitlement")'),
|
||||
"auth profile proxy must forward the lightweight entitlement scope to the backend auth/me endpoint",
|
||||
);
|
||||
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,17 +10,21 @@ 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" : "身份状态",
|
||||
accountFeedbackTitle: isEn ? "My Feedback" : "我的反馈",
|
||||
accountFeedbackDescription: isEn
|
||||
? "Recent reports you submitted and their current handling status."
|
||||
: "你最近提交过的反馈及当前处理状态。",
|
||||
authMode: isEn ? "Auth Mode" : "鉴权模式",
|
||||
weatherEngine: isEn ? "Weather Engine" : "气象引擎",
|
||||
intradayAnalysis: isEn ? "Intraday Analysis" : "今日内分析",
|
||||
historyFuture: isEn
|
||||
? "Future-date + Decision Card Analysis"
|
||||
: "未来日期分析 + 城市决策卡",
|
||||
? "Future-date + Multi-city Chart Monitoring"
|
||||
: "未来日期分析 + 多城市图表巡检",
|
||||
smartPush: isEn
|
||||
? "Cross-platform Smart Weather Push"
|
||||
: "全平台智能气象查询",
|
||||
@@ -57,6 +61,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 +247,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 +275,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. Member monthly price {amount} USDC is active."
|
||||
: "Telegram 群成员验证成功,群友月付价 {amount} USDC 已生效。",
|
||||
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 +338,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 {
|
||||
|
||||
@@ -68,6 +68,54 @@ export type NormalizedPaymentError = {
|
||||
userRejected: boolean;
|
||||
};
|
||||
|
||||
export function looksLikeHtmlDocument(value: string) {
|
||||
const text = String(value || "").trim().toLowerCase();
|
||||
return (
|
||||
text.startsWith("<!doctype html") ||
|
||||
text.startsWith("<html") ||
|
||||
/<title>[^<]*(50\d|cloudflare|polyweather\.top)/i.test(String(value || ""))
|
||||
);
|
||||
}
|
||||
|
||||
function containsCjk(value: string) {
|
||||
return /[\u3400-\u9fff]/.test(value);
|
||||
}
|
||||
|
||||
function safePaymentServiceUnavailableMessage(fallback: string) {
|
||||
return containsCjk(fallback)
|
||||
? "支付服务暂时不可用,请稍后重试;如果已经付款,请保存 Tx Hash 联系管理员。"
|
||||
: "Payment service is temporarily unavailable. Please retry shortly; if you already paid, keep the Tx Hash and contact support.";
|
||||
}
|
||||
|
||||
export async function readPaymentApiErrorMessage(
|
||||
response: Response,
|
||||
fallback = "Request failed",
|
||||
limit = 300,
|
||||
) {
|
||||
const raw = await response.text();
|
||||
const trimmed = String(raw || "").trim();
|
||||
if (!trimmed) return fallback;
|
||||
let message = "";
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as {
|
||||
error?: unknown;
|
||||
detail?: unknown;
|
||||
message?: unknown;
|
||||
};
|
||||
const parsedMessage = [parsed.error, parsed.detail, parsed.message].find(
|
||||
(item) => typeof item === "string" && item.trim(),
|
||||
);
|
||||
if (typeof parsedMessage === "string") message = parsedMessage.trim();
|
||||
} catch {
|
||||
message = trimmed;
|
||||
}
|
||||
const candidate = message || trimmed;
|
||||
if (looksLikeHtmlDocument(candidate)) {
|
||||
return safePaymentServiceUnavailableMessage(fallback);
|
||||
}
|
||||
return candidate.slice(0, limit);
|
||||
}
|
||||
|
||||
export function normalizePaymentError(error: unknown): NormalizedPaymentError {
|
||||
const source = error as any;
|
||||
const code = Number(
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { TelegramPricing } from "./types";
|
||||
|
||||
export function isTelegramPrivateGroupPriceEligible(
|
||||
pricing?: TelegramPricing | null,
|
||||
) {
|
||||
return Boolean(
|
||||
pricing?.is_private_group_member ||
|
||||
pricing?.pricing_source === "telegram_private_group_member",
|
||||
);
|
||||
}
|
||||
|
||||
export function telegramPrivateGroupAmountUsdc(
|
||||
pricing?: TelegramPricing | null,
|
||||
) {
|
||||
if (!isTelegramPrivateGroupPriceEligible(pricing)) return "";
|
||||
const amount = String(pricing?.amount_usdc || "").trim();
|
||||
const numeric = Number(amount);
|
||||
return Number.isFinite(numeric) && numeric > 0 ? amount : "";
|
||||
}
|
||||
@@ -10,12 +10,35 @@ 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;
|
||||
degraded_auth_profile?: boolean | null;
|
||||
degraded_reason?: string | null;
|
||||
entitlement_snapshot?: boolean | null;
|
||||
entitlement_snapshot_reason?: string | 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 = {
|
||||
@@ -23,6 +46,7 @@ export type TelegramPricing = {
|
||||
telegram_id?: number | null;
|
||||
telegram_status?: string | null;
|
||||
is_group_member?: boolean;
|
||||
is_private_group_member?: boolean;
|
||||
amount_usdc?: string;
|
||||
pricing_source?: string;
|
||||
};
|
||||
@@ -40,7 +64,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 +89,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 +137,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 +146,7 @@ export type CreatedIntent = {
|
||||
amount_usdc: string;
|
||||
intent_id: string;
|
||||
expires_at: string;
|
||||
explorer_tx_url?: string;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,20 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import type { User } from "@supabase/supabase-js";
|
||||
import {
|
||||
buildAuthMePath,
|
||||
mergeAccountAuthSnapshot,
|
||||
} from "@/lib/auth-snapshot";
|
||||
import { getSupabaseBrowserClient } from "@/lib/supabase/client";
|
||||
|
||||
import type {
|
||||
AuthMeResponse,
|
||||
BoundWallet,
|
||||
PaymentConfig,
|
||||
PaymentPlan,
|
||||
ProviderMode,
|
||||
InjectedProviderOption,
|
||||
TelegramPricing,
|
||||
} from "./types";
|
||||
import { usePaymentState } from "./usePaymentState";
|
||||
import { useWalletBind } from "./useWalletBind";
|
||||
import { usePaymentFlow } from "./usePaymentFlow";
|
||||
import { useBilling } from "./useBilling";
|
||||
import {
|
||||
isTelegramPrivateGroupPriceEligible,
|
||||
telegramPrivateGroupAmountUsdc,
|
||||
} from "./telegram-pricing";
|
||||
|
||||
function applyTelegramGroupPricingToPlanList(
|
||||
plans: PaymentPlan[],
|
||||
pricing?: TelegramPricing | null,
|
||||
): PaymentPlan[] {
|
||||
if (!isTelegramPrivateGroupPriceEligible(pricing)) return plans;
|
||||
const telegramAmountUsdc = telegramPrivateGroupAmountUsdc(pricing);
|
||||
if (!telegramAmountUsdc) return plans;
|
||||
return plans.map((plan) =>
|
||||
String(plan.plan_code || "").toLowerCase() === "pro_monthly"
|
||||
? { ...plan, amount_usdc: telegramAmountUsdc }
|
||||
: plan,
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
export interface UseAccountPaymentParams {
|
||||
@@ -25,7 +50,7 @@ export interface UseAccountPaymentParams {
|
||||
backend: AuthMeResponse | null;
|
||||
user: User | null;
|
||||
setUser: (user: User | null) => void;
|
||||
setBackend: (backend: AuthMeResponse | null) => void;
|
||||
setBackend: Dispatch<SetStateAction<AuthMeResponse | null>>;
|
||||
setErrorText: (text: string) => void;
|
||||
setUpdatedAt: (text: string) => void;
|
||||
showOverlay: boolean;
|
||||
@@ -146,32 +171,58 @@ 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 } });
|
||||
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);
|
||||
if (!backendResult.ok) {
|
||||
if (retry && backendResult.status === 401) {
|
||||
await new Promise((r) => setTimeout(r, 1200));
|
||||
return attempt(false);
|
||||
? getSupabaseBrowserClient()
|
||||
.auth.getSession()
|
||||
.then(({ data: { session } }) => session?.user ?? null)
|
||||
.catch(() => null as User | null)
|
||||
: Promise.resolve(null as User | null);
|
||||
const [localUser, authHeaders] = await Promise.all([
|
||||
userPromise,
|
||||
buildAuthedHeaders(false),
|
||||
]);
|
||||
setUser(localUser);
|
||||
|
||||
const readAuthSnapshot = async (
|
||||
headers: Record<string, string>,
|
||||
options: Parameters<typeof buildAuthMePath>[0] = {},
|
||||
) => {
|
||||
const response = await fetch(buildAuthMePath(options), {
|
||||
cache: "no-store",
|
||||
headers,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const raw = (await response.text()).slice(0, 260);
|
||||
const message = copy.httpError
|
||||
.replace("{status}", String(response.status))
|
||||
.replace("{raw}", raw);
|
||||
const error = new Error(message) as Error & { status?: number };
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
}
|
||||
const raw = (await backendResult.text()).slice(0, 260);
|
||||
throw new Error(copy.httpError.replace("{status}", String(backendResult.status)).replace("{raw}", raw));
|
||||
return (await response.json()) as AuthMeResponse;
|
||||
};
|
||||
|
||||
let latestHeaders = authHeaders;
|
||||
let backendJson: AuthMeResponse;
|
||||
try {
|
||||
backendJson = await readAuthSnapshot(authHeaders, {
|
||||
scope: "entitlement",
|
||||
});
|
||||
} catch (error) {
|
||||
if (retry && (error as { status?: number }).status === 401) {
|
||||
await new Promise((r) => setTimeout(r, 1200));
|
||||
return fetchAuthSnapshot(false);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
let backendJson = (await backendResult.json()) as AuthMeResponse;
|
||||
if (
|
||||
retry &&
|
||||
supabaseReady &&
|
||||
userResult.data?.user &&
|
||||
localUser &&
|
||||
backendJson.authenticated === false
|
||||
) {
|
||||
try {
|
||||
@@ -182,27 +233,66 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
|
||||
refreshedSession?.access_token || "",
|
||||
).trim();
|
||||
if (refreshedToken) {
|
||||
const retriedBackendResult = await fetch("/api/auth/me", {
|
||||
cache: "no-store",
|
||||
headers: { Authorization: `Bearer ${refreshedToken}` },
|
||||
latestHeaders = { Authorization: `Bearer ${refreshedToken}` };
|
||||
const retriedBackendJson = await readAuthSnapshot(latestHeaders, {
|
||||
scope: "entitlement",
|
||||
});
|
||||
if (retriedBackendResult.ok) {
|
||||
const retriedBackendJson =
|
||||
(await retriedBackendResult.json()) as AuthMeResponse;
|
||||
backendJson = retriedBackendJson;
|
||||
}
|
||||
backendJson = retriedBackendJson;
|
||||
}
|
||||
} catch {
|
||||
// Keep the first response; the UI treats a logged-in local user with
|
||||
// an unauthenticated backend snapshot as a temporary sync state.
|
||||
}
|
||||
}
|
||||
setBackend(backendJson);
|
||||
setBackend((previous) => mergeAccountAuthSnapshot(previous, backendJson));
|
||||
setUpdatedAt(new Date().toISOString());
|
||||
};
|
||||
try { await attempt(true); }
|
||||
if (backendJson.authenticated !== false) {
|
||||
void readAuthSnapshot(latestHeaders)
|
||||
.then((fullJson) => {
|
||||
setBackend((previous) =>
|
||||
mergeAccountAuthSnapshot(previous, fullJson),
|
||||
);
|
||||
setUpdatedAt(new Date().toISOString());
|
||||
})
|
||||
.catch(() => {
|
||||
// The lightweight entitlement snapshot already resolved the UI.
|
||||
});
|
||||
}
|
||||
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 +301,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 +309,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 +334,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 +401,16 @@ 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 = applyTelegramGroupPricingToPlanList(
|
||||
paymentConfig?.plans || [],
|
||||
backend?.telegram_pricing,
|
||||
);
|
||||
const effectivePlanList = monthlyPlanList.length ? monthlyPlanList : (paymentConfig?.plans || []);
|
||||
const selectedPlan = effectivePlanList.find((p) => p.plan_code === selectedPlanCode) || effectivePlanList[0];
|
||||
|
||||
// ── useWalletBind ──────────────────────────────────────
|
||||
@@ -354,6 +473,7 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
|
||||
getValidAccessToken,
|
||||
buildAuthedHeaders,
|
||||
loadSnapshot,
|
||||
refreshEntitlementAfterPayment,
|
||||
loadPaymentSnapshot: loadPaymentSnapshotImpl,
|
||||
user,
|
||||
});
|
||||
@@ -371,6 +491,8 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
|
||||
setPaymentConfig,
|
||||
selectedPlanCode,
|
||||
setSelectedPlanCode,
|
||||
selectedPaymentChainId: chainId,
|
||||
setSelectedPaymentChainId,
|
||||
selectedTokenAddress,
|
||||
setSelectedTokenAddress,
|
||||
boundWallets,
|
||||
@@ -407,6 +529,7 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
|
||||
getValidAccessToken,
|
||||
buildAuthedHeaders,
|
||||
loadSnapshot,
|
||||
refreshEntitlementAfterPayment,
|
||||
loadPaymentSnapshot: loadPaymentSnapshotImpl,
|
||||
waitForReceipt: walletBind.waitForReceipt,
|
||||
ensureTargetChain: walletBind.ensureTargetChain,
|
||||
@@ -451,6 +574,7 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
|
||||
boundWallets,
|
||||
walletAddress,
|
||||
selectedPlanCode,
|
||||
selectedPaymentChainId: chainId,
|
||||
selectedTokenAddress,
|
||||
selectedWallet,
|
||||
providerMode,
|
||||
@@ -460,6 +584,8 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
|
||||
|
||||
// Setters for shared state
|
||||
setSelectedTokenAddress,
|
||||
setSelectedPaymentChainId,
|
||||
setSelectedPlanCode,
|
||||
setSelectedWallet,
|
||||
setSelectedInjectedProviderKey,
|
||||
setProviderMode,
|
||||
@@ -476,6 +602,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,
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from "./constants";
|
||||
import { clearStoredPaymentRecovery, shortAddress } from "./formatters";
|
||||
import { normalizePaymentError } from "./payment-utils";
|
||||
import { isTelegramPrivateGroupPriceEligible } from "./telegram-pricing";
|
||||
import { trackAppEvent } from "@/lib/app-analytics";
|
||||
|
||||
// ============================================================
|
||||
@@ -53,6 +54,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 +88,7 @@ export function useBilling(params: UseBillingParams) {
|
||||
getValidAccessToken,
|
||||
buildAuthedHeaders,
|
||||
loadSnapshot,
|
||||
refreshEntitlementAfterPayment,
|
||||
loadPaymentSnapshot,
|
||||
user,
|
||||
} = params;
|
||||
@@ -116,10 +119,36 @@ 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 telegramGroupPriceApplies = Boolean(
|
||||
selectedPlanCode === "pro_monthly" &&
|
||||
isTelegramPrivateGroupPriceEligible(backend?.telegram_pricing),
|
||||
);
|
||||
const planAmount = Number.isFinite(parsedPlanAmount) && parsedPlanAmount > 0 ? parsedPlanAmount : 10;
|
||||
const referral = backend?.referral;
|
||||
const referralPending = Boolean(
|
||||
referral?.applied_code ||
|
||||
String(referral?.attribution_status || "").toLowerCase() === "pending",
|
||||
);
|
||||
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 &&
|
||||
!telegramGroupPriceApplies &&
|
||||
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 +156,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 +169,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 +191,10 @@ export function useBilling(params: UseBillingParams) {
|
||||
};
|
||||
}, [
|
||||
paymentConfig?.points_redemption,
|
||||
backend?.telegram_pricing?.amount_usdc,
|
||||
backend?.referral,
|
||||
backend?.subscription_active,
|
||||
backend?.telegram_pricing,
|
||||
selectedPlan?.plan_code,
|
||||
selectedPlan?.amount_usdc,
|
||||
totalPoints,
|
||||
usePoints,
|
||||
@@ -177,7 +216,7 @@ export function useBilling(params: UseBillingParams) {
|
||||
if (json.ok) {
|
||||
setPaymentInfo(copy.walletRecoveryDone);
|
||||
setPaymentError("");
|
||||
await loadSnapshot();
|
||||
await refreshEntitlementAfterPayment();
|
||||
await loadPaymentSnapshot();
|
||||
return true;
|
||||
}
|
||||
@@ -189,7 +228,7 @@ export function useBilling(params: UseBillingParams) {
|
||||
}
|
||||
}, [
|
||||
authIsAuthenticated, buildAuthedHeaders, copy.walletRecoveryDone,
|
||||
loadPaymentSnapshot, loadSnapshot, reconcileBusy,
|
||||
loadPaymentSnapshot, refreshEntitlementAfterPayment, reconcileBusy,
|
||||
]);
|
||||
|
||||
// ── handleSubmit409 ────────────────────────────────────
|
||||
@@ -200,7 +239,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 +259,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 +270,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 ──────────────────────────────
|
||||
@@ -367,8 +406,9 @@ export function useBilling(params: UseBillingParams) {
|
||||
throw new Error(copy.bindFailed.replace("{raw}", raw));
|
||||
}
|
||||
const data = (await res.json()) as { telegram_pricing?: TelegramPricing | null };
|
||||
if (data.telegram_pricing?.is_group_member) {
|
||||
const amount = data.telegram_pricing.amount_usdc || "10";
|
||||
const telegramPricing = data.telegram_pricing;
|
||||
if (isTelegramPrivateGroupPriceEligible(telegramPricing)) {
|
||||
const amount = telegramPricing?.amount_usdc || "10";
|
||||
setPaymentInfo(copy.telegramVerifySuccess.replace("{amount}", amount));
|
||||
}
|
||||
await loadSnapshot();
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
CreatedIntent,
|
||||
EvmProvider,
|
||||
IntentStatusResponse,
|
||||
PaymentChainOption,
|
||||
PaymentConfig,
|
||||
PaymentTokenOption,
|
||||
ProviderMode,
|
||||
@@ -16,16 +17,18 @@ import type {
|
||||
import {
|
||||
WALLET_TRANSACTION_REQUEST_TIMEOUT_MS,
|
||||
} from "./constants";
|
||||
import { clearStoredPaymentRecovery, shortAddress } from "./formatters";
|
||||
import { chainIdToDisplayName, clearStoredPaymentRecovery, shortAddress } from "./formatters";
|
||||
import {
|
||||
buildAllowanceCalldata,
|
||||
buildApproveCalldata,
|
||||
buildBalanceOfCalldata,
|
||||
formatTokenUnits,
|
||||
normalizePaymentError,
|
||||
readPaymentApiErrorMessage,
|
||||
requestWalletWithTimeout,
|
||||
} from "./payment-utils";
|
||||
import { trackAppEvent } from "@/lib/app-analytics";
|
||||
import { buildAuthMePath } from "@/lib/auth-snapshot";
|
||||
import {
|
||||
assertExpectedPaymentReceiver,
|
||||
EXPECTED_PAYMENT_RECEIVER_ADDRESS,
|
||||
@@ -43,6 +46,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 +103,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 +126,8 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
||||
setPaymentConfig,
|
||||
selectedPlanCode,
|
||||
setSelectedPlanCode,
|
||||
selectedPaymentChainId,
|
||||
setSelectedPaymentChainId,
|
||||
selectedTokenAddress,
|
||||
setSelectedTokenAddress,
|
||||
boundWallets,
|
||||
@@ -155,6 +163,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
||||
getValidAccessToken,
|
||||
buildAuthedHeaders,
|
||||
loadSnapshot,
|
||||
refreshEntitlementAfterPayment,
|
||||
loadPaymentSnapshot,
|
||||
waitForReceipt,
|
||||
ensureTargetChain,
|
||||
@@ -165,11 +174,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(buildAuthMePath({ scope: "entitlement" }), {
|
||||
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 +260,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();
|
||||
@@ -216,7 +305,11 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
||||
const headers = authHeaders || (await buildAuthedHeaders(false));
|
||||
const configRes = await fetch("/api/payments/config", { cache: "no-store", headers });
|
||||
if (!configRes.ok) {
|
||||
const raw = (await configRes.text()).slice(0, 350);
|
||||
const raw = await readPaymentApiErrorMessage(
|
||||
configRes,
|
||||
isEn ? "Payment config request failed." : "支付配置请求失败。",
|
||||
350,
|
||||
);
|
||||
throw new Error(copy.loadConfigFailed.replace("{raw}", raw));
|
||||
}
|
||||
const configJson = (await configRes.json()) as PaymentConfig;
|
||||
@@ -225,21 +318,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 ────────────────────────────
|
||||
@@ -256,7 +378,11 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
||||
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
||||
continue;
|
||||
}
|
||||
const raw = (await statusRes.text()).slice(0, 260);
|
||||
const raw = await readPaymentApiErrorMessage(
|
||||
statusRes,
|
||||
isEn ? "Payment status request failed." : "支付状态请求失败。",
|
||||
260,
|
||||
);
|
||||
throw new Error(copy.queryIntentFailed.replace("{raw}", raw));
|
||||
}
|
||||
const statusJson = (await statusRes.json()) as IntentStatusResponse;
|
||||
@@ -266,13 +392,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 +410,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 +440,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 +459,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 +492,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,18 +501,24 @@ 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,
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (!createRes.ok) {
|
||||
const raw = (await createRes.text()).slice(0, 350);
|
||||
const raw = await readPaymentApiErrorMessage(
|
||||
createRes,
|
||||
isEn ? "Payment order request failed." : "支付订单请求失败。",
|
||||
350,
|
||||
);
|
||||
throw new Error(copy.createIntentFailed.replace("{raw}", raw));
|
||||
}
|
||||
|
||||
@@ -382,7 +526,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,12 +590,18 @@ 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 }),
|
||||
});
|
||||
if (!submitRes.ok) {
|
||||
const raw = (await submitRes.text()).slice(0, 350);
|
||||
const raw = await readPaymentApiErrorMessage(
|
||||
submitRes,
|
||||
isEn ? "Payment submit request failed." : "支付提交请求失败。",
|
||||
350,
|
||||
);
|
||||
if (submitRes.status === 409) {
|
||||
if (handleSubmit409Ref.current) await handleSubmit409Ref.current(intentId, txHashNorm, raw);
|
||||
return;
|
||||
@@ -463,7 +613,11 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
||||
method: "POST", headers: authHeaders, body: JSON.stringify({ tx_hash: txHashNorm }),
|
||||
});
|
||||
if (!confirmRes.ok) {
|
||||
const raw = (await confirmRes.text()).slice(0, 350);
|
||||
const raw = await readPaymentApiErrorMessage(
|
||||
confirmRes,
|
||||
isEn ? "Payment confirm request failed." : "支付确认请求失败。",
|
||||
350,
|
||||
);
|
||||
const lowerRaw = raw.toLowerCase();
|
||||
const maybePending =
|
||||
(confirmRes.status === 404 && !lowerRaw.includes("payment intent not found")) ||
|
||||
@@ -478,13 +632,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 +684,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,
|
||||
@@ -548,7 +703,11 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
||||
}),
|
||||
});
|
||||
if (!createRes.ok) {
|
||||
const raw = (await createRes.text()).slice(0, 350);
|
||||
const raw = await readPaymentApiErrorMessage(
|
||||
createRes,
|
||||
isEn ? "Manual payment order request failed." : "手动转账订单请求失败。",
|
||||
350,
|
||||
);
|
||||
throw new Error(copy.createManualIntentFailed.replace("{raw}", raw));
|
||||
}
|
||||
|
||||
@@ -563,8 +722,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,12 +760,16 @@ 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 }),
|
||||
});
|
||||
if (!submitRes.ok) {
|
||||
const raw = (await submitRes.text()).slice(0, 350);
|
||||
const raw = await readPaymentApiErrorMessage(
|
||||
submitRes,
|
||||
isEn ? "Payment submit request failed." : "支付提交请求失败。",
|
||||
350,
|
||||
);
|
||||
if (submitRes.status === 409) {
|
||||
if (handleSubmit409Ref.current) await handleSubmit409Ref.current(intentIdVal, txHashNorm, raw);
|
||||
return;
|
||||
@@ -610,7 +780,11 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
||||
method: "POST", headers: authHeaders, body: JSON.stringify({ tx_hash: txHashNorm }),
|
||||
});
|
||||
if (!confirmRes.ok) {
|
||||
const raw = (await confirmRes.text()).slice(0, 350);
|
||||
const raw = await readPaymentApiErrorMessage(
|
||||
confirmRes,
|
||||
isEn ? "Payment confirm request failed." : "支付确认请求失败。",
|
||||
350,
|
||||
);
|
||||
const lowerRaw = raw.toLowerCase();
|
||||
const maybePending =
|
||||
confirmRes.status === 408 ||
|
||||
@@ -627,13 +801,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 +826,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 +874,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";
|
||||
@@ -15,7 +16,11 @@ import {
|
||||
WALLET_TRANSACTION_REQUEST_TIMEOUT_MS,
|
||||
} from "./constants";
|
||||
import { shortAddress } from "./formatters";
|
||||
import { normalizePaymentError, requestWalletWithTimeout } from "./payment-utils";
|
||||
import {
|
||||
normalizePaymentError,
|
||||
readPaymentApiErrorMessage,
|
||||
requestWalletWithTimeout,
|
||||
} from "./payment-utils";
|
||||
import {
|
||||
eip6963Providers,
|
||||
getEvmProvider,
|
||||
@@ -190,12 +195,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 +229,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" : "网络切换失败")})`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -274,8 +304,8 @@ export function useWalletBind(params: UseWalletBindParams) {
|
||||
method: "POST", headers: authHeaders, body: JSON.stringify({ address }),
|
||||
});
|
||||
if (!challengeRes.ok) {
|
||||
const raw = (await challengeRes.text()).slice(0, 300);
|
||||
throw new Error(copy.challengeFailed.replace("{raw}", raw));
|
||||
const message = await readPaymentApiErrorMessage(challengeRes);
|
||||
throw new Error(copy.challengeFailed.replace("{raw}", message));
|
||||
}
|
||||
|
||||
const challengeJson = (await challengeRes.json()) as { nonce?: string; message?: string };
|
||||
@@ -288,8 +318,8 @@ export function useWalletBind(params: UseWalletBindParams) {
|
||||
method: "POST", headers: authHeaders, body: JSON.stringify({ address, nonce, signature }),
|
||||
});
|
||||
if (!verifyRes.ok) {
|
||||
const raw = (await verifyRes.text()).slice(0, 300);
|
||||
throw new Error(copy.verifyFailedRaw.replace("{raw}", raw));
|
||||
const message = await readPaymentApiErrorMessage(verifyRes);
|
||||
throw new Error(copy.verifyFailedRaw.replace("{raw}", message));
|
||||
}
|
||||
|
||||
setPaymentInfo(`${walletLabel} 绑定成功: ${shortAddress(address)}。${binanceBindHint || "现在可点击“立即订阅并激活服务”。"}`);
|
||||
|
||||
@@ -7,11 +7,8 @@ import {
|
||||
ArrowRight,
|
||||
ChevronLeft,
|
||||
Chrome,
|
||||
CloudRain,
|
||||
CloudSun,
|
||||
Lock,
|
||||
Mail,
|
||||
Sun,
|
||||
Eye,
|
||||
EyeOff,
|
||||
} from "lucide-react";
|
||||
@@ -26,17 +23,18 @@ type Mode = "login" | "signup";
|
||||
|
||||
type LoginClientProps = {
|
||||
nextPath: string;
|
||||
initialError?: string;
|
||||
initialMode?: Mode;
|
||||
};
|
||||
|
||||
export function LoginClient({ nextPath, initialMode }: LoginClientProps) {
|
||||
export function LoginClient({ nextPath, initialError, initialMode }: LoginClientProps) {
|
||||
const router = useRouter();
|
||||
const { locale } = useI18n();
|
||||
const [mode, setMode] = useState<Mode>(initialMode ?? "login");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errorText, setErrorText] = useState("");
|
||||
const [errorText, setErrorText] = useState(initialError || "");
|
||||
const [infoText, setInfoText] = useState("");
|
||||
const [resetSent, setResetSent] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
@@ -50,21 +48,27 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) {
|
||||
|
||||
const copy = {
|
||||
backHome: isEn ? "Back to Home" : "返回首页",
|
||||
subtitle: isEn
|
||||
? "Explore weather details from every corner of the world"
|
||||
: "探索世界每一个角落的气象细节",
|
||||
loginSubtitle: isEn
|
||||
? "Sign in to continue to your weather decision terminal."
|
||||
: "登录后进入你的天气决策终端。",
|
||||
signupSubtitle: isEn
|
||||
? "Create an account and get a one-time 3-day trial. No payment required first."
|
||||
: "创建账号后自动开启一次 3 天试用,无需先付款。",
|
||||
googleOneClick: isEn
|
||||
? "Continue with Google"
|
||||
: "使用 Google 账号一键登录",
|
||||
orEmail: isEn ? "Or continue with email" : "或使用邮箱",
|
||||
orGoogle: isEn ? "Or continue with Google" : "或使用 Google",
|
||||
login: isEn ? "Sign In" : "登录",
|
||||
signup: isEn ? "Sign Up" : "注册",
|
||||
passwordLoginPlaceholder: isEn ? "Enter password" : "输入密码",
|
||||
passwordSignupPlaceholder: isEn
|
||||
? "Set at least 6 characters"
|
||||
: "设置至少 6 位密码",
|
||||
loginSubmit: isEn ? "Start your weather decision journey" : "开启气象决策之旅",
|
||||
signupSubmit: isEn ? "Create account now" : "立即创建账号",
|
||||
loginSubmit: isEn ? "Enter PolyWeather Terminal" : "进入 PolyWeather 终端",
|
||||
loginSubmitting: isEn ? "Signing in..." : "正在登录...",
|
||||
signupSubmit: isEn ? "Create account and start trial" : "创建账号并领取试用",
|
||||
signupSubmitting: isEn ? "Creating account..." : "正在创建账号...",
|
||||
googleSubmitting: isEn ? "Connecting Google..." : "正在连接 Google...",
|
||||
loginHint: isEn
|
||||
? "After signing in, your homepage will be personalized."
|
||||
: "登录后将为您个性化定制首页数据",
|
||||
@@ -93,22 +97,47 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) {
|
||||
loginFailedHint: isEn
|
||||
? "If you just signed up, please verify your email first. Check your inbox or spam folder."
|
||||
: "如果刚注册,请先点击邮箱中的验证链接。检查收件箱或垃圾邮件。",
|
||||
|
||||
// New translations for Koyfin-style layouts
|
||||
workEmail: isEn ? "Work email" : "工作邮箱",
|
||||
password: isEn ? "Password" : "密码",
|
||||
welcomeBack: isEn ? "Welcome Back" : "欢迎回来",
|
||||
signUpTitle: isEn ? "Sign up for your PolyWeather account" : "注册您的 PolyWeather 账户",
|
||||
welcomeBack: isEn ? "Sign in to PolyWeather" : "登录 PolyWeather",
|
||||
signUpTitle: isEn ? "Create your PolyWeather account" : "创建 PolyWeather 账号",
|
||||
newToPoly: isEn ? "New to PolyWeather?" : "还没有 PolyWeather 账号?",
|
||||
alreadyHave: isEn ? "Already have an account?" : "已经有账号了?",
|
||||
termsAgreement: isEn
|
||||
? "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 决策卡片,助您理清气象风险脉络。",
|
||||
? "Use the same terminal palette as the product: live temperature evidence, DEB paths, and settlement-source context in one calm workspace."
|
||||
: "沿用终端界面的配色和信息密度:实时温度证据、DEB 路径和结算源背景放在一个安静工作台里。",
|
||||
trusted: isEn ? "Trusted by industry professionals" : "深受行业决策人员信赖",
|
||||
} as const;
|
||||
const submittingLabel = isLogin ? copy.loginSubmitting : copy.signupSubmitting;
|
||||
const googleSubmittingLabel = copy.googleSubmitting;
|
||||
const formSubtitle = isLogin ? copy.loginSubtitle : copy.signupSubtitle;
|
||||
const accessHighlights = isEn
|
||||
? ["Live temperature charts", "DEB forecast path", "Runway and settlement alerts"]
|
||||
: ["实时温度图表", "DEB 预测路径", "跑道与结算提醒"];
|
||||
const sideStats = isEn
|
||||
? [
|
||||
{ label: "Trial", value: "3 days" },
|
||||
{ label: "Access", value: "Terminal" },
|
||||
{ label: "Signals", value: "Runway" },
|
||||
]
|
||||
: [
|
||||
{ label: "试用", value: "3 天" },
|
||||
{ label: "入口", value: "终端" },
|
||||
{ label: "提醒", value: "跑道" },
|
||||
];
|
||||
const loadingSpinner = (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"
|
||||
/>
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setErrorText(initialError || "");
|
||||
}, [initialError]);
|
||||
|
||||
const onResetPassword = async () => {
|
||||
setErrorText("");
|
||||
@@ -126,7 +155,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) {
|
||||
@@ -236,167 +265,112 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen w-full bg-[#f8fafc] font-sans text-slate-900">
|
||||
{/* Left Column (Shared Dark Column with Illustrative Widget) */}
|
||||
<div className="relative hidden lg:flex lg:w-[460px] xl:w-[500px] 2xl:w-[560px] flex-col justify-between bg-gradient-to-br from-[#060913] via-[#0f1527] to-[#040815] p-10 text-white shrink-0 overflow-hidden border-r border-white/5">
|
||||
{/* Ambient Glows */}
|
||||
<div className="absolute -left-20 -top-20 h-96 w-96 rounded-full bg-blue-600/10 blur-[130px] pointer-events-none" />
|
||||
<div className="absolute -right-20 -bottom-20 h-[360px] w-[360px] rounded-full bg-purple-500/10 blur-[120px] pointer-events-none" />
|
||||
<div className="absolute top-1/2 right-0 h-[240px] w-[240px] -translate-y-1/2 rounded-full bg-amber-500/5 blur-[100px] pointer-events-none" />
|
||||
|
||||
{/* Grid overlay */}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,rgba(255,255,255,0.015)_1px,transparent_1px),linear-gradient(to_bottom,rgba(255,255,255,0.015)_1px,transparent_1px)] bg-[size:32px_32px] pointer-events-none" />
|
||||
<div className="flex min-h-screen w-full bg-[#e9edf3] font-sans text-slate-950">
|
||||
<aside className="hidden w-[56px] shrink-0 flex-col items-center justify-between bg-[#171d24] py-5 text-white lg:flex">
|
||||
<Link
|
||||
href="/"
|
||||
aria-label="PolyWeather"
|
||||
className="grid h-9 w-9 place-items-center rounded-lg border border-white/10 bg-white/8 text-[11px] font-black text-sky-200"
|
||||
>
|
||||
PW
|
||||
</Link>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<span className="h-2 w-2 rounded-full bg-[#00897b]" />
|
||||
<span className="h-2 w-2 rounded-full bg-[#2563eb]" />
|
||||
<span className="h-2 w-2 rounded-full bg-slate-500" />
|
||||
</div>
|
||||
<Link
|
||||
href="/"
|
||||
className="grid h-9 w-9 place-items-center rounded-lg text-slate-400 transition hover:bg-white/8 hover:text-white"
|
||||
aria-label={copy.backHome}
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
</Link>
|
||||
</aside>
|
||||
|
||||
<div className="relative z-10 flex flex-col gap-14">
|
||||
<Link href="/" className="flex items-center hover:opacity-90 transition-opacity">
|
||||
<img src="/logo.png" alt="PolyWeather" className="h-8 w-auto object-contain brightness-0 invert" />
|
||||
<section className="relative hidden min-h-screen w-[44vw] max-w-[620px] shrink-0 overflow-hidden border-r border-[#d8e0ec] bg-[#171d24] p-6 text-white lg:flex lg:flex-col">
|
||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,rgba(255,255,255,0.035)_1px,transparent_1px),linear-gradient(to_bottom,rgba(255,255,255,0.03)_1px,transparent_1px)] bg-[size:34px_34px]" />
|
||||
<div className="relative z-10 flex items-center justify-between">
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-2 text-base font-black tracking-tight text-white transition hover:text-sky-100"
|
||||
>
|
||||
<span>PolyWeather</span>
|
||||
</Link>
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-emerald-400/20 bg-emerald-400/10 px-3 py-1 text-[11px] font-black uppercase tracking-[0.16em] text-emerald-200">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-300" />
|
||||
Terminal
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 mt-12 max-w-md">
|
||||
<p className="text-xs font-black uppercase tracking-[0.18em] text-sky-200">
|
||||
{isEn ? "Access Gate" : "终端入口"}
|
||||
</p>
|
||||
<h2 className="mt-4 text-3xl font-black leading-tight tracking-tight text-white">
|
||||
{isEn
|
||||
? "Sign in where the terminal starts."
|
||||
: "从这里进入天气决策终端。"}
|
||||
</h2>
|
||||
<p className="mt-4 text-sm leading-7 text-slate-300">
|
||||
{copy.desc}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 mt-10 overflow-hidden rounded-xl border border-white/10 bg-[#0b1220] shadow-[0_24px_60px_rgba(0,0,0,0.28)]">
|
||||
<div className="flex h-11 items-center gap-2 border-b border-white/10 bg-[#111827] px-4">
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-[#ff5f56]" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-[#ffbd2e]" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-[#27c93f]" />
|
||||
<span className="ml-2 font-mono text-[10px] text-slate-400">
|
||||
polyweather.top/terminal
|
||||
</span>
|
||||
</div>
|
||||
<img
|
||||
src="/static/web.webp"
|
||||
width="680"
|
||||
height="340"
|
||||
alt={isEn ? "PolyWeather terminal preview" : "PolyWeather 终端预览"}
|
||||
className="aspect-[16/9] w-full object-cover object-top"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 mt-5 grid grid-cols-3 gap-2">
|
||||
{sideStats.map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className="rounded-lg border border-white/10 bg-white/[0.06] px-3 py-3"
|
||||
>
|
||||
<div className="font-mono text-sm font-black text-white">{item.value}</div>
|
||||
<div className="mt-1 text-[10px] font-bold text-slate-400">{item.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 mt-auto flex items-center justify-between border-t border-white/10 pt-5 text-[11px] text-slate-400">
|
||||
<span>{isEn ? "Live charts" : "实时图表"}</span>
|
||||
<span>{isEn ? "DEB path" : "DEB 路径"}</span>
|
||||
<span>{isEn ? "Runway alerts" : "跑道提醒"}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="relative flex min-h-screen flex-1 flex-col justify-between overflow-hidden bg-[#e9edf3] p-4 sm:p-6 lg:p-8">
|
||||
<div className="pointer-events-none absolute inset-0 bg-[linear-gradient(to_right,rgba(15,23,42,0.045)_1px,transparent_1px),linear-gradient(to_bottom,rgba(15,23,42,0.035)_1px,transparent_1px)] bg-[size:32px_32px]" />
|
||||
|
||||
<div className="relative z-10 flex items-center justify-between gap-3 lg:justify-end">
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-2 text-sm font-black tracking-tight text-slate-950 transition-opacity hover:opacity-80 lg:hidden"
|
||||
>
|
||||
<span className="grid h-8 w-8 place-items-center rounded-lg border border-slate-300 bg-white text-[10px] font-black text-blue-700 shadow-sm">
|
||||
PW
|
||||
</span>
|
||||
<span className="hidden min-[360px]:inline">PolyWeather</span>
|
||||
</Link>
|
||||
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-3xl font-black leading-[1.25] tracking-tight text-white animate-fade-up [animation-delay:150ms] opacity-0">
|
||||
{isEn ? (
|
||||
<>
|
||||
Weather intelligence and risk management{" "}
|
||||
<span className="inline-block px-2.5 py-0.5 mt-1 rounded bg-gradient-to-r from-blue-600 to-indigo-500 text-white font-bold text-[0.9em] shadow-lg shadow-blue-600/25 animate-gradient bg-[length:200%_auto]">
|
||||
simplified.
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
天气信息与风险管理{" "}
|
||||
<span className="inline-block px-2.5 py-0.5 mt-1 rounded bg-gradient-to-r from-blue-600 to-indigo-500 text-white font-bold text-[0.9em] shadow-lg shadow-blue-600/25 animate-gradient bg-[length:200%_auto]">
|
||||
化繁为简。
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</h2>
|
||||
<p className="text-sm leading-7 text-slate-400 max-w-md animate-fade-up [animation-delay:300ms] opacity-0">
|
||||
{copy.desc}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* High-Fidelity Mock Terminal Preview Widget */}
|
||||
<div className="relative z-10 my-auto p-[1px] bg-gradient-to-b from-white/15 to-transparent rounded-2xl shadow-2xl overflow-hidden hover:scale-[1.01] hover:shadow-blue-500/10 transition-all duration-500 max-w-[420px] w-full animate-fade-up [animation-delay:450ms] opacity-0">
|
||||
<div className="bg-[#0b0f19]/80 backdrop-blur-xl rounded-2xl p-6">
|
||||
{/* Terminal Top Window Controls */}
|
||||
<div className="flex items-center gap-1.5 mb-5">
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-[#ff5f56]" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-[#ffbd2e]" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-[#27c93f]" />
|
||||
<span className="ml-2 font-mono text-[9px] text-slate-500 tracking-wider">POLYWEATHER_CONSOLE_v1.7</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-b border-white/5 pb-3.5 mb-4">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="h-2 w-2 rounded-full bg-emerald-500 animate-pulse" />
|
||||
<span className="font-mono text-[10px] uppercase tracking-wider text-slate-300">
|
||||
{isEn ? "Runway 02L Consensus" : "跑道 02L 实测校验"}
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-mono text-[9px] font-black text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded border border-emerald-500/20 tracking-wider">LIVE</span>
|
||||
</div>
|
||||
|
||||
{/* Terminal metrics grid */}
|
||||
<div className="grid grid-cols-2 gap-3 mb-5">
|
||||
<div className="p-2.5 rounded-xl bg-white/[0.02] border border-white/[0.04]">
|
||||
<span className="block text-[9px] uppercase tracking-wider text-slate-400 mb-0.5">{isEn ? "Current Temp" : "当前温度"}</span>
|
||||
<span className="font-mono text-base font-bold text-white tracking-tight">28.8°C</span>
|
||||
</div>
|
||||
<div className="p-2.5 rounded-xl bg-white/[0.02] border border-white/[0.04]">
|
||||
<span className="block text-[9px] uppercase tracking-wider text-slate-400 mb-0.5">{isEn ? "Target Threshold" : "监控阈值"}</span>
|
||||
<span className="font-mono text-base font-bold text-rose-400 tracking-tight">30.0°C</span>
|
||||
</div>
|
||||
<div className="p-2.5 rounded-xl bg-white/[0.02] border border-white/[0.04]">
|
||||
<span className="block text-[9px] uppercase tracking-wider text-slate-400 mb-0.5">{isEn ? "Model Blend" : "模型融合"}</span>
|
||||
<span className="font-mono text-base font-bold text-blue-400 tracking-tight">88.5%</span>
|
||||
</div>
|
||||
<div className="p-2.5 rounded-xl bg-white/[0.02] border border-white/[0.04]">
|
||||
<span className="block text-[9px] uppercase tracking-wider text-slate-400 mb-0.5">{isEn ? "Observed Peak" : "今日最高"}</span>
|
||||
<span className="font-mono text-base font-bold text-emerald-400 tracking-tight">29.2°C</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SVG Interactive Line Chart Preview */}
|
||||
<div className="mb-2">
|
||||
<div className="flex items-center justify-between text-[9px] text-slate-400 mb-2 font-mono">
|
||||
<span>{isEn ? "TEMP TREND (24H)" : "气温趋势 (24小时)"}</span>
|
||||
<span className="text-blue-400 font-bold">Blend vs Obs</span>
|
||||
</div>
|
||||
<div className="relative h-28 w-full bg-slate-950/60 rounded-xl p-2 border border-white/5">
|
||||
<svg className="w-full h-full" viewBox="0 0 340 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
{/* Grid Lines */}
|
||||
<line x1="0" y1="20" x2="340" y2="20" stroke="rgba(255,255,255,0.03)" strokeWidth="1" />
|
||||
<line x1="0" y1="50" x2="340" y2="50" stroke="rgba(255,255,255,0.03)" strokeWidth="1" />
|
||||
<line x1="0" y1="80" x2="340" y2="80" stroke="rgba(255,255,255,0.03)" strokeWidth="1" />
|
||||
<line x1="85" y1="0" x2="85" y2="100" stroke="rgba(255,255,255,0.03)" strokeWidth="1" />
|
||||
<line x1="170" y1="0" x2="170" y2="100" stroke="rgba(255,255,255,0.03)" strokeWidth="1" />
|
||||
<line x1="255" y1="0" x2="255" y2="100" stroke="rgba(255,255,255,0.03)" strokeWidth="1" />
|
||||
|
||||
{/* Threshold Line (30.0°C) */}
|
||||
<line x1="0" y1="40" x2="340" y2="40" stroke="#f43f5e" strokeWidth="1" strokeDasharray="3 3" opacity="0.8" />
|
||||
<text x="5" y="36" fill="#f43f5e" className="text-[8px] font-mono font-semibold">30.0°C Target</text>
|
||||
|
||||
{/* Gradient Area under Forecast */}
|
||||
<path
|
||||
d="M 0 85 Q 40 75 85 60 T 170 35 T 255 45 T 340 55 L 340 100 L 0 100 Z"
|
||||
fill="url(#chartGradient)"
|
||||
opacity="0.15"
|
||||
/>
|
||||
|
||||
{/* Forecast Line (Blue) */}
|
||||
<path
|
||||
d="M 0 85 Q 40 75 85 60 T 170 35 T 255 45 T 340 55"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
|
||||
{/* Observation Line (Green Solid, ending at current 170px) */}
|
||||
<path
|
||||
d="M 0 87 Q 40 78 85 63 T 170 33"
|
||||
stroke="#10b981"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
|
||||
{/* Current Temp Point */}
|
||||
<circle cx="170" cy="33" r="4" fill="#10b981" />
|
||||
<circle cx="170" cy="33" r="8" stroke="#10b981" strokeWidth="1.5" className="animate-ping" opacity="0.5" />
|
||||
|
||||
{/* Definitions for Gradients */}
|
||||
<defs>
|
||||
<linearGradient id="chartGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#3b82f6" />
|
||||
<stop offset="100%" stopColor="#3b82f6" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="relative z-10 text-[10px] text-slate-500 font-mono">
|
||||
{isEn ? "PolyWeather institutional analytics suite" : "PolyWeather 机构版天气决策系统"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column (Forms) */}
|
||||
<div className="flex flex-1 flex-col justify-between p-6 sm:p-10 bg-gradient-to-br from-[#f8fafc] via-[#ffffff] to-[#eff4f9] min-h-screen relative overflow-hidden">
|
||||
{/* Subtle mesh background */}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,rgba(15,23,42,0.01)_1px,transparent_1px),linear-gradient(to_bottom,rgba(15,23,42,0.01)_1px,transparent_1px)] bg-[size:24px_24px] pointer-events-none animate-pulse" style={{ animationDuration: '4s' }} />
|
||||
|
||||
{/* Top Header Switch */}
|
||||
<div className="relative z-10 flex justify-between lg:justify-end items-center gap-3">
|
||||
{/* Logo on top-left for mobile only */}
|
||||
<Link href="/" className="flex items-center hover:opacity-90 transition-opacity lg:hidden">
|
||||
<img src="/logo.png" alt="PolyWeather" className="h-7 w-auto object-contain" />
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-slate-500">
|
||||
<span className="hidden text-xs text-slate-500 sm:inline">
|
||||
{isLogin ? copy.newToPoly : copy.alreadyHave}
|
||||
</span>
|
||||
<button
|
||||
@@ -406,28 +380,40 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) {
|
||||
setInfoText("");
|
||||
setMode(isLogin ? "signup" : "login");
|
||||
}}
|
||||
className="rounded-xl border border-slate-200 bg-white px-4 py-2 text-xs font-bold text-slate-700 shadow-sm transition-all hover:border-slate-300 hover:bg-slate-50 hover:text-slate-900 active:scale-[0.98]"
|
||||
className="whitespace-nowrap rounded-xl border border-slate-300 bg-white px-4 py-2 text-xs font-bold text-slate-700 shadow-sm transition hover:border-blue-300 hover:text-blue-700 active:scale-[0.98]"
|
||||
>
|
||||
{isLogin ? copy.signup : copy.login}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center Form Card */}
|
||||
<div className="relative z-10 flex flex-1 items-center justify-center my-10">
|
||||
<div className="w-full max-w-[440px] bg-white/90 backdrop-blur-xl border border-slate-200/50 rounded-2xl p-6 sm:p-10 shadow-[0_24px_60px_rgba(8,16,36,0.06)] animate-fade-up [animation-delay:200ms] opacity-0 transition-transform hover:-translate-y-1 hover:shadow-[0_32px_80px_rgba(8,16,36,0.08)] duration-500">
|
||||
<div className="relative z-10 flex flex-1 items-start justify-center pt-10 sm:pt-12 lg:my-10 lg:items-center lg:pt-0">
|
||||
<div className="w-full max-w-[440px] rounded-xl border border-[#d8e0ec] bg-white p-6 shadow-[0_18px_50px_rgba(15,23,42,0.08)] sm:p-8">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-black tracking-tight text-slate-900 mb-2">
|
||||
<div className="mb-4 inline-flex items-center gap-2 rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-[11px] font-black uppercase tracking-[0.12em] text-emerald-700">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-600" />
|
||||
{isLogin ? copy.loginSubmit : isEn ? "3-day trial" : "3 天试用"}
|
||||
</div>
|
||||
<h1 className="mb-2 text-2xl font-black tracking-tight text-slate-950">
|
||||
{isLogin ? copy.welcomeBack : copy.signUpTitle}
|
||||
</h1>
|
||||
<p className="text-xs text-slate-500 leading-relaxed">
|
||||
{copy.subtitle}
|
||||
<p className="text-sm leading-6 text-slate-600">
|
||||
{formSubtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={(event) => void onEmailSubmit(event)} className="space-y-5">
|
||||
<div className="space-y-2 animate-fade-up [animation-delay:350ms] opacity-0">
|
||||
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider block">
|
||||
<div className="mb-6 grid gap-2">
|
||||
{accessHighlights.map((item) => (
|
||||
<div key={item} className="flex items-center gap-2 text-xs font-semibold text-slate-600">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-blue-600" />
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<form onSubmit={(event) => void onEmailSubmit(event)} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="block text-[11px] font-black uppercase tracking-[0.12em] text-slate-500">
|
||||
{copy.workEmail}
|
||||
</label>
|
||||
<div className="relative">
|
||||
@@ -438,14 +424,14 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) {
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
placeholder="yourname@email.com"
|
||||
className="w-full rounded-xl border border-slate-200 bg-slate-50/50 py-3 pl-11 pr-4 text-sm text-slate-900 placeholder:text-slate-400 transition-all duration-200 focus:border-blue-500 focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10"
|
||||
className="w-full rounded-lg border border-slate-300 bg-[#f8fafc] py-3 pl-11 pr-4 text-sm text-slate-950 placeholder:text-slate-400 transition focus:border-blue-500 focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 animate-fade-up [animation-delay:450ms] opacity-0">
|
||||
<div className="flex justify-between items-center">
|
||||
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider block">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label className="block text-[11px] font-black uppercase tracking-[0.12em] text-slate-500">
|
||||
{copy.password}
|
||||
</label>
|
||||
{isLogin && !resetSent ? (
|
||||
@@ -453,7 +439,7 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) {
|
||||
type="button"
|
||||
onClick={() => void onResetPassword()}
|
||||
disabled={loading}
|
||||
className="text-xs font-semibold text-blue-600 hover:text-blue-700 transition-colors"
|
||||
className="text-xs font-bold text-blue-600 transition hover:text-blue-700"
|
||||
>
|
||||
{copy.reset}
|
||||
</button>
|
||||
@@ -468,66 +454,73 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) {
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder={isLogin ? copy.passwordLoginPlaceholder : copy.passwordSignupPlaceholder}
|
||||
className="w-full rounded-xl border border-slate-200 bg-slate-50/50 py-3 pl-11 pr-11 text-sm text-slate-900 placeholder:text-slate-400 transition-all duration-200 focus:border-blue-500 focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10"
|
||||
className="w-full rounded-lg border border-slate-300 bg-[#f8fafc] py-3 pl-11 pr-11 text-sm text-slate-950 placeholder:text-slate-400 transition focus:border-blue-500 focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 transition-colors"
|
||||
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-slate-400 transition hover:text-slate-700"
|
||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||
>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isLogin && (
|
||||
<p className="text-[11px] leading-relaxed text-slate-400">
|
||||
{!isLogin ? (
|
||||
<p className="text-[11px] leading-relaxed text-slate-500">
|
||||
{copy.termsAgreement}
|
||||
</p>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={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"
|
||||
aria-busy={loading}
|
||||
className="group mt-6 flex w-full items-center justify-center gap-2 rounded-lg bg-blue-600 px-4 py-3.5 text-sm font-black text-white shadow-[0_10px_22px_rgba(37,99,235,0.22)] transition hover:bg-blue-700 active:scale-[0.99] disabled:opacity-50"
|
||||
>
|
||||
<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" /> : null}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="my-6 flex items-center">
|
||||
<div className="h-px flex-grow bg-slate-200/60" />
|
||||
<span className="px-3 text-[10px] font-bold uppercase tracking-wider text-slate-400">
|
||||
{isEn ? "or" : "或"}
|
||||
<div className="h-px flex-grow bg-slate-200" />
|
||||
<span className="px-3 text-[10px] font-black uppercase tracking-[0.14em] text-slate-400">
|
||||
{copy.orGoogle}
|
||||
</span>
|
||||
<div className="h-px flex-grow bg-slate-200/60" />
|
||||
<div className="h-px flex-grow bg-slate-200" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onGoogleSignIn()}
|
||||
disabled={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"
|
||||
aria-busy={loading}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg border border-slate-300 bg-white px-4 py-3 text-sm font-bold text-slate-700 shadow-sm transition hover:border-blue-300 hover:text-blue-700 active:scale-[0.99] 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}
|
||||
{infoText ? <p className="mt-4 rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2.5 text-xs text-emerald-700 leading-normal">{infoText}</p> : null}
|
||||
{errorText ? (
|
||||
<p className="mt-4 rounded-lg border border-rose-200 bg-rose-50 px-3 py-2.5 text-xs leading-normal text-rose-700">{errorText}</p>
|
||||
) : null}
|
||||
{infoText ? (
|
||||
<p className="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2.5 text-xs leading-normal text-emerald-700">{infoText}</p>
|
||||
) : null}
|
||||
{errorText && isLogin && errorText.includes("Invalid login") ? (
|
||||
<p className="mt-2 text-center text-xs text-slate-500 leading-relaxed">{copy.loginFailedHint}</p>
|
||||
<p className="mt-2 text-center text-xs leading-relaxed text-slate-500">{copy.loginFailedHint}</p>
|
||||
) : null}
|
||||
{infoText === copy.signupCheckEmail ? (
|
||||
<p className="mt-2 text-center text-xs text-slate-500 leading-relaxed">{copy.resendVerify}</p>
|
||||
<p className="mt-2 text-center text-xs leading-relaxed text-slate-500">{copy.resendVerify}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="relative z-10 text-[10px] text-slate-400 text-center font-mono">
|
||||
© {new Date().getFullYear()} PolyWeather. All rights reserved.
|
||||
<div className="relative z-10 text-center font-mono text-[10px] text-slate-500">
|
||||
© {new Date().getFullYear()} PolyWeather
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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,42 @@
|
||||
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 source = fs.readFileSync(
|
||||
path.join(process.cwd(), "components", "auth", "LoginClient.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert(
|
||||
source.includes("登录 PolyWeather") &&
|
||||
source.includes("创建 PolyWeather 账号") &&
|
||||
source.includes("创建账号并领取试用"),
|
||||
"auth entry copy must be product-specific and explain the signup trial path",
|
||||
);
|
||||
assert(
|
||||
source.includes("PW") && source.includes("PolyWeather"),
|
||||
"auth pages must show a readable PolyWeather brand mark",
|
||||
);
|
||||
assert(
|
||||
!source.includes("brightness-0 invert"),
|
||||
"auth dark panel must not invert the square logo image into a white block",
|
||||
);
|
||||
assert(
|
||||
source.includes("items-start justify-center pt-10") &&
|
||||
source.includes("lg:items-center lg:pt-0"),
|
||||
"auth form must sit higher on mobile while staying centered on desktop",
|
||||
);
|
||||
assert(
|
||||
!source.includes("hover:-translate-y-1"),
|
||||
"auth form card must not shift position on hover",
|
||||
);
|
||||
assert(
|
||||
source.includes("hidden text-xs text-slate-500 sm:inline") &&
|
||||
source.includes("whitespace-nowrap rounded-xl"),
|
||||
"auth mode switch prompt must not crowd or wrap the mobile header",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
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 loginPagePath = path.join(
|
||||
projectRoot,
|
||||
"app",
|
||||
"auth",
|
||||
"login",
|
||||
"page.tsx",
|
||||
);
|
||||
|
||||
const loginClientSource = fs.readFileSync(loginClientPath, "utf8");
|
||||
const loginPageSource = fs.readFileSync(loginPagePath, "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",
|
||||
);
|
||||
assert(
|
||||
loginPageSource.includes("error?: string") &&
|
||||
loginPageSource.includes("normalizeAuthError") &&
|
||||
loginPageSource.includes("params.error") &&
|
||||
loginPageSource.includes("initialError={initialError}") &&
|
||||
loginClientSource.includes("initialError?: string") &&
|
||||
loginClientSource.includes("useState(initialError || \"\")"),
|
||||
"login page must surface auth callback errors in the login form instead of silently returning users to the terminal",
|
||||
);
|
||||
assert(
|
||||
authCallbackSource.includes("redirectToLoginWithError") &&
|
||||
authCallbackSource.includes('request.nextUrl.searchParams.get("error_description")') &&
|
||||
authCallbackSource.includes('request.nextUrl.searchParams.get("error")') &&
|
||||
authCallbackSource.includes("exchangeError") &&
|
||||
authCallbackSource.includes("exchangeCodeForSession(code)") &&
|
||||
authCallbackSource.includes("auth_error"),
|
||||
"auth callback must redirect failed OAuth/session exchanges back to login with an error message",
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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" : "无匹配城市"}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user