From 569f3eef97faf1e5b88f87950e44593ef43a0c56 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Wed, 29 Apr 2026 11:22:49 +0800 Subject: [PATCH] Make weather-market UI reliable before shipping Proxy routes now share one upstream-error adapter so client-actionable statuses such as auth, entitlement, validation, and rate limits survive the BFF instead of becoming opaque 502s. The scan terminal mobile overrides also load last and remove desktop rail constraints so phones get a single readable column. Constraint: Mobile users reported the dashboard was unreadable, and BFF proxy errors were masking expected client states. Rejected: Let every route keep bespoke error JSON | continued inconsistent status codes and production detail leakage. Confidence: high Scope-risk: moderate Directive: Keep ScanTerminalMobile.module.css imported after desktop scan-terminal CSS so mobile breakpoints win. Tested: npx tsc --noEmit --pretty false --project frontend/tsconfig.json Tested: npm run build Tested: npm run test:business Tested: Chrome mobile smoke test at 390px with no horizontal overflow Not-tested: Real device Safari/Android manual QA --- frontend/app/api/analytics/events/route.ts | 18 +-- frontend/app/api/auth/me/route.ts | 16 +-- frontend/app/api/cities/route.ts | 16 +-- frontend/app/api/city/[name]/detail/route.ts | 16 +-- .../app/api/city/[name]/market-scan/route.ts | 35 ++--- frontend/app/api/city/[name]/route.ts | 16 +-- frontend/app/api/city/[name]/summary/route.ts | 16 +-- frontend/app/api/healthz/route.ts | 8 +- frontend/app/api/history/[name]/route.ts | 16 +-- .../app/api/ops/analytics/funnel/route.ts | 8 +- .../app/api/ops/leaderboard/weekly/route.ts | 8 +- frontend/app/api/ops/memberships/route.ts | 8 +- .../incidents/[eventId]/resolve/route.ts | 8 +- .../app/api/ops/payments/incidents/route.ts | 8 +- frontend/app/api/ops/truth-history/route.ts | 8 +- .../app/api/ops/users/grant-points/route.ts | 8 +- frontend/app/api/ops/users/route.ts | 8 +- frontend/app/api/payments/config/route.ts | 18 +-- .../intents/[intentId]/confirm/route.ts | 18 +-- .../api/payments/intents/[intentId]/route.ts | 18 +-- .../intents/[intentId]/submit/route.ts | 18 +-- frontend/app/api/payments/intents/route.ts | 18 +-- .../api/payments/reconcile-latest/route.ts | 8 +- frontend/app/api/payments/runtime/route.ts | 18 +-- .../api/payments/wallets/challenge/route.ts | 23 ++-- frontend/app/api/payments/wallets/route.ts | 37 +++--- .../app/api/payments/wallets/verify/route.ts | 23 ++-- .../app/api/scan/terminal/ai-city/route.ts | 24 ++-- .../api/scan/terminal/ai-city/stream/route.ts | 21 ++- frontend/app/api/scan/terminal/ai/route.ts | 24 ++-- frontend/app/api/scan/terminal/route.ts | 24 ++-- frontend/app/api/system/status/route.ts | 18 +-- .../dashboard/ScanTerminalDashboard.tsx | 2 +- .../dashboard/ScanTerminalMobile.module.css | 124 ++++++++++++++++++ frontend/lib/api-proxy.ts | 72 ++++++++++ frontend/lib/system-priority-proxy.ts | 24 ++-- 36 files changed, 471 insertions(+), 282 deletions(-) create mode 100644 frontend/lib/api-proxy.ts diff --git a/frontend/app/api/analytics/events/route.ts b/frontend/app/api/analytics/events/route.ts index 4a895a2a..01517a3c 100644 --- a/frontend/app/api/analytics/events/route.ts +++ b/frontend/app/api/analytics/events/route.ts @@ -3,6 +3,10 @@ import { applyAuthResponseCookies, buildBackendRequestHeaders, } from "@/lib/backend-auth"; +import { + buildProxyExceptionResponse, + buildUpstreamErrorResponse, +} from "@/lib/api-proxy"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const ANALYTICS_ENABLED = @@ -33,19 +37,17 @@ export async function POST(req: NextRequest) { }); if (!res.ok) { const raw = await res.text(); - const response = NextResponse.json( - { error: `Backend returned ${res.status}`, detail: raw.slice(0, 260) }, - { status: res.status }, - ); + const response = buildUpstreamErrorResponse(res.status, raw, { + detailLimit: 260, + }); return applyAuthResponseCookies(response, auth.response); } const data = await res.json(); const response = NextResponse.json(data); return applyAuthResponseCookies(response, auth.response); } catch (error) { - return NextResponse.json( - { error: "Failed to track analytics event", detail: String(error) }, - { status: 500 }, - ); + return buildProxyExceptionResponse(error, { + publicMessage: "Failed to track analytics event", + }); } } diff --git a/frontend/app/api/auth/me/route.ts b/frontend/app/api/auth/me/route.ts index d300002e..4cefedf2 100644 --- a/frontend/app/api/auth/me/route.ts +++ b/frontend/app/api/auth/me/route.ts @@ -3,6 +3,10 @@ import { applyAuthResponseCookies, buildBackendRequestHeaders, } from "@/lib/backend-auth"; +import { + buildProxyExceptionResponse, + buildUpstreamErrorResponse, +} from "@/lib/api-proxy"; import { getLocalDevAuthPayload, isLocalFullAccessHost, @@ -70,10 +74,7 @@ export async function GET(req: NextRequest) { }); return applyAuthResponseCookies(response, auth.response); } - const response = NextResponse.json( - { error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) }, - { status: res.status }, - ); + const response = buildUpstreamErrorResponse(res.status, raw); return applyAuthResponseCookies(response, auth.response); } const data = await res.json(); @@ -98,10 +99,9 @@ export async function GET(req: NextRequest) { }); return applyAuthResponseCookies(response, auth.response); } - return NextResponse.json( - { error: "Failed to fetch auth profile", detail: String(error) }, - { status: 500 }, - ); + return buildProxyExceptionResponse(error, { + publicMessage: "Failed to fetch auth profile", + }); } } diff --git a/frontend/app/api/cities/route.ts b/frontend/app/api/cities/route.ts index 1c73c84a..8a9d2267 100644 --- a/frontend/app/api/cities/route.ts +++ b/frontend/app/api/cities/route.ts @@ -3,6 +3,10 @@ import { applyAuthResponseCookies, buildBackendRequestHeaders, } from "@/lib/backend-auth"; +import { + buildProxyExceptionResponse, + buildUpstreamErrorResponse, +} from "@/lib/api-proxy"; import { buildCachedJsonResponse } from "@/lib/http-cache"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -27,10 +31,7 @@ export async function GET(req: NextRequest) { }); if (!res.ok) { const raw = await res.text(); - const response = NextResponse.json( - { error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) }, - { status: 502 }, - ); + const response = buildUpstreamErrorResponse(res.status, raw); return applyAuthResponseCookies(response, auth.response); } const data = await res.json(); @@ -41,10 +42,9 @@ export async function GET(req: NextRequest) { ); return applyAuthResponseCookies(response, auth.response); } catch (error) { - const response = NextResponse.json( - { error: "Failed to fetch cities", detail: String(error) }, - { status: 500 }, - ); + const response = buildProxyExceptionResponse(error, { + publicMessage: "Failed to fetch cities", + }); return response; } } diff --git a/frontend/app/api/city/[name]/detail/route.ts b/frontend/app/api/city/[name]/detail/route.ts index 745a6437..f8d40621 100644 --- a/frontend/app/api/city/[name]/detail/route.ts +++ b/frontend/app/api/city/[name]/detail/route.ts @@ -3,6 +3,10 @@ import { applyAuthResponseCookies, buildBackendRequestHeaders, } from "@/lib/backend-auth"; +import { + buildProxyExceptionResponse, + buildUpstreamErrorResponse, +} from "@/lib/api-proxy"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -41,20 +45,16 @@ export async function GET( }); if (!res.ok) { const raw = await res.text(); - const response = NextResponse.json( - { error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) }, - { status: 502 }, - ); + 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) { - const response = NextResponse.json( - { error: "Failed to fetch city detail aggregate", detail: String(error) }, - { status: 500 }, - ); + const response = buildProxyExceptionResponse(error, { + publicMessage: "Failed to fetch city detail aggregate", + }); return response; } } diff --git a/frontend/app/api/city/[name]/market-scan/route.ts b/frontend/app/api/city/[name]/market-scan/route.ts index 165e419b..78b96827 100644 --- a/frontend/app/api/city/[name]/market-scan/route.ts +++ b/frontend/app/api/city/[name]/market-scan/route.ts @@ -3,17 +3,13 @@ import { applyAuthResponseCookies, buildBackendRequestHeaders, } from "@/lib/backend-auth"; +import { + buildProxyExceptionResponse, + buildUpstreamErrorResponse, +} from "@/lib/api-proxy"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; -function parseBackendError(raw: string) { - try { - return JSON.parse(raw) as unknown; - } catch { - return raw.slice(0, 800); - } -} - export async function GET( req: NextRequest, context: { params: Promise<{ name: string }> }, @@ -56,14 +52,10 @@ export async function GET( }); if (!res.ok) { const raw = await res.text(); - const response = NextResponse.json( - { - error: "Backend city market scan failed", - upstream_status: res.status, - detail: parseBackendError(raw), - }, - { status: 502 }, - ); + const response = buildUpstreamErrorResponse(res.status, raw, { + detailLimit: 800, + error: "Backend city market scan failed", + }); return applyAuthResponseCookies(response, auth.response); } const data = await res.json(); @@ -74,13 +66,10 @@ export async function GET( }); return applyAuthResponseCookies(response, auth.response); } catch (error) { - const response = NextResponse.json( - { - error: "Failed to fetch city market scan", - detail: String(error), - }, - { status: 502 }, - ); + const response = buildProxyExceptionResponse(error, { + publicMessage: "Failed to fetch city market scan", + status: 502, + }); return response; } } diff --git a/frontend/app/api/city/[name]/route.ts b/frontend/app/api/city/[name]/route.ts index 9678079a..44b5c469 100644 --- a/frontend/app/api/city/[name]/route.ts +++ b/frontend/app/api/city/[name]/route.ts @@ -3,6 +3,10 @@ import { applyAuthResponseCookies, buildBackendRequestHeaders, } from "@/lib/backend-auth"; +import { + buildProxyExceptionResponse, + buildUpstreamErrorResponse, +} from "@/lib/api-proxy"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -150,20 +154,16 @@ export async function GET( return applyAuthResponseCookies(response, auth.response); } - const response = NextResponse.json( - { error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) }, - { status: 502 }, - ); + const response = buildUpstreamErrorResponse(res.status, raw); return applyAuthResponseCookies(response, auth.response); } const data = normalizeCityDetailPayload(await res.json()); const response = NextResponse.json(data); return applyAuthResponseCookies(response, auth.response); } catch (error) { - const response = NextResponse.json( - { error: "Failed to fetch city detail", detail: String(error) }, - { status: 500 }, - ); + const response = buildProxyExceptionResponse(error, { + publicMessage: "Failed to fetch city detail", + }); return response; } } diff --git a/frontend/app/api/city/[name]/summary/route.ts b/frontend/app/api/city/[name]/summary/route.ts index e8f5d634..212a1efb 100644 --- a/frontend/app/api/city/[name]/summary/route.ts +++ b/frontend/app/api/city/[name]/summary/route.ts @@ -3,6 +3,10 @@ import { applyAuthResponseCookies, buildBackendRequestHeaders, } from "@/lib/backend-auth"; +import { + buildProxyExceptionResponse, + buildUpstreamErrorResponse, +} from "@/lib/api-proxy"; import { buildCachedJsonResponse } from "@/lib/http-cache"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -43,10 +47,7 @@ export async function GET( }); if (!res.ok) { const raw = await res.text(); - const response = NextResponse.json( - { error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) }, - { status: 502 }, - ); + const response = buildUpstreamErrorResponse(res.status, raw); return applyAuthResponseCookies(response, auth.response); } const data = await res.json(); @@ -65,10 +66,9 @@ export async function GET( ); return applyAuthResponseCookies(response, auth.response); } catch (error) { - const response = NextResponse.json( - { error: "Failed to fetch city summary", detail: String(error) }, - { status: 500 }, - ); + const response = buildProxyExceptionResponse(error, { + publicMessage: "Failed to fetch city summary", + }); return response; } } diff --git a/frontend/app/api/healthz/route.ts b/frontend/app/api/healthz/route.ts index 04515f3a..987d22d9 100644 --- a/frontend/app/api/healthz/route.ts +++ b/frontend/app/api/healthz/route.ts @@ -3,6 +3,7 @@ import { applyAuthResponseCookies, buildBackendRequestHeaders, } from "@/lib/backend-auth"; +import { buildProxyExceptionResponse } from "@/lib/api-proxy"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -32,9 +33,8 @@ export async function GET(req: NextRequest) { }); return applyAuthResponseCookies(response, auth.response); } catch (error) { - return NextResponse.json( - { error: "Failed to fetch healthz", detail: String(error) }, - { status: 500 }, - ); + return buildProxyExceptionResponse(error, { + publicMessage: "Failed to fetch healthz", + }); } } diff --git a/frontend/app/api/history/[name]/route.ts b/frontend/app/api/history/[name]/route.ts index 9dec7f4d..e2ad2e49 100644 --- a/frontend/app/api/history/[name]/route.ts +++ b/frontend/app/api/history/[name]/route.ts @@ -3,6 +3,10 @@ import { applyAuthResponseCookies, buildBackendRequestHeaders, } from "@/lib/backend-auth"; +import { + buildProxyExceptionResponse, + buildUpstreamErrorResponse, +} from "@/lib/api-proxy"; import { buildCachedJsonResponse } from "@/lib/http-cache"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -33,10 +37,7 @@ export async function GET( }); if (!res.ok) { const raw = await res.text(); - const response = NextResponse.json( - { error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) }, - { status: 502 }, - ); + const response = buildUpstreamErrorResponse(res.status, raw); return applyAuthResponseCookies(response, auth.response); } const data = await res.json(); @@ -47,10 +48,9 @@ export async function GET( ); return applyAuthResponseCookies(response, auth.response); } catch (error) { - const response = NextResponse.json( - { error: "Failed to fetch history", detail: String(error) }, - { status: 500 }, - ); + const response = buildProxyExceptionResponse(error, { + publicMessage: "Failed to fetch history", + }); return response; } } diff --git a/frontend/app/api/ops/analytics/funnel/route.ts b/frontend/app/api/ops/analytics/funnel/route.ts index 0949e6e0..72b11a9c 100644 --- a/frontend/app/api/ops/analytics/funnel/route.ts +++ b/frontend/app/api/ops/analytics/funnel/route.ts @@ -3,6 +3,7 @@ import { applyAuthResponseCookies, buildBackendRequestHeaders, } from "@/lib/backend-auth"; +import { buildProxyExceptionResponse } from "@/lib/api-proxy"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -35,9 +36,8 @@ export async function GET(req: NextRequest) { }); return applyAuthResponseCookies(response, auth.response); } catch (error) { - return NextResponse.json( - { error: "Failed to fetch analytics funnel", detail: String(error) }, - { status: 500 }, - ); + return buildProxyExceptionResponse(error, { + publicMessage: "Failed to fetch analytics funnel", + }); } } diff --git a/frontend/app/api/ops/leaderboard/weekly/route.ts b/frontend/app/api/ops/leaderboard/weekly/route.ts index 97bcc6ce..b2559e93 100644 --- a/frontend/app/api/ops/leaderboard/weekly/route.ts +++ b/frontend/app/api/ops/leaderboard/weekly/route.ts @@ -3,6 +3,7 @@ import { applyAuthResponseCookies, buildBackendRequestHeaders, } from "@/lib/backend-auth"; +import { buildProxyExceptionResponse } from "@/lib/api-proxy"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -34,9 +35,8 @@ export async function GET(req: NextRequest) { }); return applyAuthResponseCookies(response, auth.response); } catch (error) { - return NextResponse.json( - { error: "Failed to fetch weekly leaderboard", detail: String(error) }, - { status: 500 }, - ); + return buildProxyExceptionResponse(error, { + publicMessage: "Failed to fetch weekly leaderboard", + }); } } diff --git a/frontend/app/api/ops/memberships/route.ts b/frontend/app/api/ops/memberships/route.ts index 77d62618..ad38e350 100644 --- a/frontend/app/api/ops/memberships/route.ts +++ b/frontend/app/api/ops/memberships/route.ts @@ -3,6 +3,7 @@ import { applyAuthResponseCookies, buildBackendRequestHeaders, } from "@/lib/backend-auth"; +import { buildProxyExceptionResponse } from "@/lib/api-proxy"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -34,9 +35,8 @@ export async function GET(req: NextRequest) { }); return applyAuthResponseCookies(response, auth.response); } catch (error) { - return NextResponse.json( - { error: "Failed to fetch memberships", detail: String(error) }, - { status: 500 }, - ); + return buildProxyExceptionResponse(error, { + publicMessage: "Failed to fetch memberships", + }); } } diff --git a/frontend/app/api/ops/payments/incidents/[eventId]/resolve/route.ts b/frontend/app/api/ops/payments/incidents/[eventId]/resolve/route.ts index bcd34dac..93ba3ef9 100644 --- a/frontend/app/api/ops/payments/incidents/[eventId]/resolve/route.ts +++ b/frontend/app/api/ops/payments/incidents/[eventId]/resolve/route.ts @@ -3,6 +3,7 @@ import { applyAuthResponseCookies, buildBackendRequestHeaders, } from "@/lib/backend-auth"; +import { buildProxyExceptionResponse } from "@/lib/api-proxy"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -36,9 +37,8 @@ export async function POST(req: NextRequest, context: RouteContext) { }); return applyAuthResponseCookies(response, auth.response); } catch (error) { - return NextResponse.json( - { error: "Failed to resolve payment incident", detail: String(error) }, - { status: 500 }, - ); + return buildProxyExceptionResponse(error, { + publicMessage: "Failed to resolve payment incident", + }); } } diff --git a/frontend/app/api/ops/payments/incidents/route.ts b/frontend/app/api/ops/payments/incidents/route.ts index 25ac3c8c..21a114bf 100644 --- a/frontend/app/api/ops/payments/incidents/route.ts +++ b/frontend/app/api/ops/payments/incidents/route.ts @@ -3,6 +3,7 @@ import { applyAuthResponseCookies, buildBackendRequestHeaders, } from "@/lib/backend-auth"; +import { buildProxyExceptionResponse } from "@/lib/api-proxy"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -34,9 +35,8 @@ export async function GET(req: NextRequest) { }); return applyAuthResponseCookies(response, auth.response); } catch (error) { - return NextResponse.json( - { error: "Failed to fetch payment incidents", detail: String(error) }, - { status: 500 }, - ); + return buildProxyExceptionResponse(error, { + publicMessage: "Failed to fetch payment incidents", + }); } } diff --git a/frontend/app/api/ops/truth-history/route.ts b/frontend/app/api/ops/truth-history/route.ts index 86e6efa5..8a4d7596 100644 --- a/frontend/app/api/ops/truth-history/route.ts +++ b/frontend/app/api/ops/truth-history/route.ts @@ -3,6 +3,7 @@ import { applyAuthResponseCookies, buildBackendRequestHeaders, } from "@/lib/backend-auth"; +import { buildProxyExceptionResponse } from "@/lib/api-proxy"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -36,9 +37,8 @@ export async function GET(req: NextRequest) { }); return applyAuthResponseCookies(response, auth.response); } catch (error) { - return NextResponse.json( - { error: "Failed to fetch truth history", detail: String(error) }, - { status: 500 }, - ); + return buildProxyExceptionResponse(error, { + publicMessage: "Failed to fetch truth history", + }); } } diff --git a/frontend/app/api/ops/users/grant-points/route.ts b/frontend/app/api/ops/users/grant-points/route.ts index 5bdd09a3..2e11e476 100644 --- a/frontend/app/api/ops/users/grant-points/route.ts +++ b/frontend/app/api/ops/users/grant-points/route.ts @@ -3,6 +3,7 @@ import { applyAuthResponseCookies, buildBackendRequestHeaders, } from "@/lib/backend-auth"; +import { buildProxyExceptionResponse } from "@/lib/api-proxy"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -36,9 +37,8 @@ export async function POST(req: NextRequest) { }); return applyAuthResponseCookies(response, auth.response); } catch (error) { - return NextResponse.json( - { error: "Failed to grant points", detail: String(error) }, - { status: 500 }, - ); + return buildProxyExceptionResponse(error, { + publicMessage: "Failed to grant points", + }); } } diff --git a/frontend/app/api/ops/users/route.ts b/frontend/app/api/ops/users/route.ts index 7f235168..5b8fab3b 100644 --- a/frontend/app/api/ops/users/route.ts +++ b/frontend/app/api/ops/users/route.ts @@ -3,6 +3,7 @@ import { applyAuthResponseCookies, buildBackendRequestHeaders, } from "@/lib/backend-auth"; +import { buildProxyExceptionResponse } from "@/lib/api-proxy"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -36,9 +37,8 @@ export async function GET(req: NextRequest) { }); return applyAuthResponseCookies(response, auth.response); } catch (error) { - return NextResponse.json( - { error: "Failed to fetch ops users", detail: String(error) }, - { status: 500 }, - ); + return buildProxyExceptionResponse(error, { + publicMessage: "Failed to fetch ops users", + }); } } diff --git a/frontend/app/api/payments/config/route.ts b/frontend/app/api/payments/config/route.ts index 3de834a2..1ed6e9ae 100644 --- a/frontend/app/api/payments/config/route.ts +++ b/frontend/app/api/payments/config/route.ts @@ -3,6 +3,10 @@ import { applyAuthResponseCookies, buildBackendRequestHeaders, } from "@/lib/backend-auth"; +import { + buildProxyExceptionResponse, + buildUpstreamErrorResponse, +} from "@/lib/api-proxy"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -21,10 +25,9 @@ export async function GET(req: NextRequest) { }); if (!res.ok) { const raw = await res.text(); - const response = NextResponse.json( - { error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) }, - { status: res.status }, - ); + const response = buildUpstreamErrorResponse(res.status, raw, { + detailLimit: 350, + }); return applyAuthResponseCookies(response, auth.response); } const data = await res.json(); @@ -33,10 +36,9 @@ export async function GET(req: NextRequest) { }); return applyAuthResponseCookies(response, auth.response); } catch (error) { - return NextResponse.json( - { error: "Failed to fetch payment config", detail: String(error) }, - { status: 500 }, - ); + return buildProxyExceptionResponse(error, { + publicMessage: "Failed to fetch payment config", + }); } } diff --git a/frontend/app/api/payments/intents/[intentId]/confirm/route.ts b/frontend/app/api/payments/intents/[intentId]/confirm/route.ts index 6da70365..0a0a2fe1 100644 --- a/frontend/app/api/payments/intents/[intentId]/confirm/route.ts +++ b/frontend/app/api/payments/intents/[intentId]/confirm/route.ts @@ -3,6 +3,10 @@ import { applyAuthResponseCookies, buildBackendRequestHeaders, } from "@/lib/backend-auth"; +import { + buildProxyExceptionResponse, + buildUpstreamErrorResponse, +} from "@/lib/api-proxy"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -33,19 +37,17 @@ export async function POST( ); if (!res.ok) { const raw = await res.text(); - const response = NextResponse.json( - { error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) }, - { status: res.status }, - ); + const response = buildUpstreamErrorResponse(res.status, raw, { + detailLimit: 350, + }); return applyAuthResponseCookies(response, auth.response); } const data = await res.json(); const response = NextResponse.json(data); return applyAuthResponseCookies(response, auth.response); } catch (error) { - return NextResponse.json( - { error: "Failed to confirm payment tx", detail: String(error) }, - { status: 500 }, - ); + return buildProxyExceptionResponse(error, { + publicMessage: "Failed to confirm payment tx", + }); } } diff --git a/frontend/app/api/payments/intents/[intentId]/route.ts b/frontend/app/api/payments/intents/[intentId]/route.ts index 6d9ddcac..bf869afa 100644 --- a/frontend/app/api/payments/intents/[intentId]/route.ts +++ b/frontend/app/api/payments/intents/[intentId]/route.ts @@ -3,6 +3,10 @@ import { applyAuthResponseCookies, buildBackendRequestHeaders, } from "@/lib/backend-auth"; +import { + buildProxyExceptionResponse, + buildUpstreamErrorResponse, +} from "@/lib/api-proxy"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -29,19 +33,17 @@ export async function GET( ); if (!res.ok) { const raw = await res.text(); - const response = NextResponse.json( - { error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) }, - { status: res.status }, - ); + const response = buildUpstreamErrorResponse(res.status, raw, { + detailLimit: 350, + }); return applyAuthResponseCookies(response, auth.response); } const data = await res.json(); const response = NextResponse.json(data); return applyAuthResponseCookies(response, auth.response); } catch (error) { - return NextResponse.json( - { error: "Failed to fetch payment intent", detail: String(error) }, - { status: 500 }, - ); + return buildProxyExceptionResponse(error, { + publicMessage: "Failed to fetch payment intent", + }); } } diff --git a/frontend/app/api/payments/intents/[intentId]/submit/route.ts b/frontend/app/api/payments/intents/[intentId]/submit/route.ts index e6679d5f..f44956f0 100644 --- a/frontend/app/api/payments/intents/[intentId]/submit/route.ts +++ b/frontend/app/api/payments/intents/[intentId]/submit/route.ts @@ -3,6 +3,10 @@ import { applyAuthResponseCookies, buildBackendRequestHeaders, } from "@/lib/backend-auth"; +import { + buildProxyExceptionResponse, + buildUpstreamErrorResponse, +} from "@/lib/api-proxy"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -33,19 +37,17 @@ export async function POST( ); if (!res.ok) { const raw = await res.text(); - const response = NextResponse.json( - { error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) }, - { status: res.status }, - ); + const response = buildUpstreamErrorResponse(res.status, raw, { + detailLimit: 350, + }); return applyAuthResponseCookies(response, auth.response); } const data = await res.json(); const response = NextResponse.json(data); return applyAuthResponseCookies(response, auth.response); } catch (error) { - return NextResponse.json( - { error: "Failed to submit payment tx", detail: String(error) }, - { status: 500 }, - ); + return buildProxyExceptionResponse(error, { + publicMessage: "Failed to submit payment tx", + }); } } diff --git a/frontend/app/api/payments/intents/route.ts b/frontend/app/api/payments/intents/route.ts index 3f1780d4..11f6d478 100644 --- a/frontend/app/api/payments/intents/route.ts +++ b/frontend/app/api/payments/intents/route.ts @@ -3,6 +3,10 @@ import { applyAuthResponseCookies, buildBackendRequestHeaders, } from "@/lib/backend-auth"; +import { + buildProxyExceptionResponse, + buildUpstreamErrorResponse, +} from "@/lib/api-proxy"; import { isPaymentHostAllowed } from "@/lib/payment-host"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -41,20 +45,18 @@ export async function POST(req: NextRequest) { }); if (!res.ok) { const raw = await res.text(); - const response = NextResponse.json( - { error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) }, - { status: res.status }, - ); + const response = buildUpstreamErrorResponse(res.status, raw, { + detailLimit: 350, + }); return applyAuthResponseCookies(response, auth.response); } const data = await res.json(); const response = NextResponse.json(data); return applyAuthResponseCookies(response, auth.response); } catch (error) { - return NextResponse.json( - { error: "Failed to create payment intent", detail: String(error) }, - { status: 500 }, - ); + return buildProxyExceptionResponse(error, { + publicMessage: "Failed to create payment intent", + }); } } diff --git a/frontend/app/api/payments/reconcile-latest/route.ts b/frontend/app/api/payments/reconcile-latest/route.ts index 66d34b4b..52a32837 100644 --- a/frontend/app/api/payments/reconcile-latest/route.ts +++ b/frontend/app/api/payments/reconcile-latest/route.ts @@ -3,6 +3,7 @@ import { applyAuthResponseCookies, buildBackendRequestHeaders, } from "@/lib/backend-auth"; +import { buildProxyExceptionResponse } from "@/lib/api-proxy"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -31,9 +32,8 @@ export async function POST(req: NextRequest) { }); return applyAuthResponseCookies(response, auth.response); } catch (error) { - return NextResponse.json( - { error: "Failed to reconcile latest payment", detail: String(error) }, - { status: 500 }, - ); + return buildProxyExceptionResponse(error, { + publicMessage: "Failed to reconcile latest payment", + }); } } diff --git a/frontend/app/api/payments/runtime/route.ts b/frontend/app/api/payments/runtime/route.ts index f941f112..5c97ee05 100644 --- a/frontend/app/api/payments/runtime/route.ts +++ b/frontend/app/api/payments/runtime/route.ts @@ -3,6 +3,10 @@ import { applyAuthResponseCookies, buildBackendRequestHeaders, } from "@/lib/backend-auth"; +import { + buildProxyExceptionResponse, + buildUpstreamErrorResponse, +} from "@/lib/api-proxy"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -22,10 +26,9 @@ export async function GET(req: NextRequest) { }); if (!res.ok) { const raw = await res.text(); - const response = NextResponse.json( - { error: `Backend returned ${res.status}`, detail: raw.slice(0, 500) }, - { status: res.status }, - ); + const response = buildUpstreamErrorResponse(res.status, raw, { + detailLimit: 500, + }); return applyAuthResponseCookies(response, auth.response); } @@ -35,9 +38,8 @@ export async function GET(req: NextRequest) { }); return applyAuthResponseCookies(response, auth.response); } catch (error) { - return NextResponse.json( - { error: "Failed to fetch payment runtime", detail: String(error) }, - { status: 500 }, - ); + return buildProxyExceptionResponse(error, { + publicMessage: "Failed to fetch payment runtime", + }); } } diff --git a/frontend/app/api/payments/wallets/challenge/route.ts b/frontend/app/api/payments/wallets/challenge/route.ts index eb8d454c..53b29cd7 100644 --- a/frontend/app/api/payments/wallets/challenge/route.ts +++ b/frontend/app/api/payments/wallets/challenge/route.ts @@ -3,6 +3,10 @@ import { applyAuthResponseCookies, buildBackendRequestHeaders, } from "@/lib/backend-auth"; +import { + buildProxyExceptionResponse, + buildUpstreamErrorResponse, +} from "@/lib/api-proxy"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -26,30 +30,25 @@ export async function POST(req: NextRequest) { }); if (!res.ok) { const raw = await res.text(); - const response = NextResponse.json( - { - error: `Backend returned ${res.status}`, - detail: raw.slice(0, 350), - proxy_debug: { + 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"), - }, }, - { status: res.status }, - ); + }); return applyAuthResponseCookies(response, auth.response); } const data = await res.json(); const response = NextResponse.json(data); return applyAuthResponseCookies(response, auth.response); } catch (error) { - return NextResponse.json( - { error: "Failed to create wallet challenge", detail: String(error) }, - { status: 500 }, - ); + return buildProxyExceptionResponse(error, { + publicMessage: "Failed to create wallet challenge", + }); } } diff --git a/frontend/app/api/payments/wallets/route.ts b/frontend/app/api/payments/wallets/route.ts index 8348abc4..2d0d398e 100644 --- a/frontend/app/api/payments/wallets/route.ts +++ b/frontend/app/api/payments/wallets/route.ts @@ -3,6 +3,10 @@ import { applyAuthResponseCookies, buildBackendRequestHeaders, } from "@/lib/backend-auth"; +import { + buildProxyExceptionResponse, + buildUpstreamErrorResponse, +} from "@/lib/api-proxy"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -21,10 +25,9 @@ export async function GET(req: NextRequest) { }); if (!res.ok) { const raw = await res.text(); - const response = NextResponse.json( - { error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) }, - { status: res.status }, - ); + const response = buildUpstreamErrorResponse(res.status, raw, { + detailLimit: 350, + }); return applyAuthResponseCookies(response, auth.response); } const data = await res.json(); @@ -33,10 +36,9 @@ export async function GET(req: NextRequest) { }); return applyAuthResponseCookies(response, auth.response); } catch (error) { - return NextResponse.json( - { error: "Failed to fetch wallets", detail: String(error) }, - { status: 500 }, - ); + return buildProxyExceptionResponse(error, { + publicMessage: "Failed to fetch wallets", + }); } } @@ -65,11 +67,9 @@ export async function DELETE(req: NextRequest) { }); const raw = await res.text(); if (!res.ok) { - const response = NextResponse.json( - { - error: `Backend returned ${res.status}`, - detail: raw.slice(0, 350), - proxy_debug: { + const response = buildUpstreamErrorResponse(res.status, raw, { + detailLimit: 350, + extraDebug: { incoming_has_authorization: Boolean( String(req.headers.get("authorization") || "").trim(), ), @@ -79,10 +79,8 @@ export async function DELETE(req: NextRequest) { "x-polyweather-auth-user-id", ), has_forwarded_email: proxiedHeaders.has("x-polyweather-auth-email"), - }, }, - { status: res.status }, - ); + }); return applyAuthResponseCookies(response, auth.response); } let data: unknown = { ok: true }; @@ -98,10 +96,9 @@ export async function DELETE(req: NextRequest) { }); return applyAuthResponseCookies(response, auth.response); } catch (error) { - return NextResponse.json( - { error: "Failed to unbind wallet", detail: String(error) }, - { status: 500 }, - ); + return buildProxyExceptionResponse(error, { + publicMessage: "Failed to unbind wallet", + }); } } diff --git a/frontend/app/api/payments/wallets/verify/route.ts b/frontend/app/api/payments/wallets/verify/route.ts index 55100957..ea182eec 100644 --- a/frontend/app/api/payments/wallets/verify/route.ts +++ b/frontend/app/api/payments/wallets/verify/route.ts @@ -3,6 +3,10 @@ import { applyAuthResponseCookies, buildBackendRequestHeaders, } from "@/lib/backend-auth"; +import { + buildProxyExceptionResponse, + buildUpstreamErrorResponse, +} from "@/lib/api-proxy"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -26,31 +30,26 @@ export async function POST(req: NextRequest) { }); if (!res.ok) { const raw = await res.text(); - const response = NextResponse.json( - { - error: `Backend returned ${res.status}`, - detail: raw.slice(0, 350), - proxy_debug: { + 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"), - }, }, - { status: res.status }, - ); + }); return applyAuthResponseCookies(response, auth.response); } const data = await res.json(); const response = NextResponse.json(data); return applyAuthResponseCookies(response, auth.response); } catch (error) { - return NextResponse.json( - { error: "Failed to verify wallet binding", detail: String(error) }, - { status: 500 }, - ); + return buildProxyExceptionResponse(error, { + publicMessage: "Failed to verify wallet binding", + }); } } diff --git a/frontend/app/api/scan/terminal/ai-city/route.ts b/frontend/app/api/scan/terminal/ai-city/route.ts index e404f409..f42e76b4 100644 --- a/frontend/app/api/scan/terminal/ai-city/route.ts +++ b/frontend/app/api/scan/terminal/ai-city/route.ts @@ -3,6 +3,10 @@ import { applyAuthResponseCookies, buildBackendRequestHeaders, } from "@/lib/backend-auth"; +import { + buildProxyExceptionResponse, + buildUpstreamErrorResponse, +} from "@/lib/api-proxy"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const AI_CITY_GATEWAY_TIMEOUT_MS = Math.max( @@ -63,10 +67,7 @@ export async function POST(req: NextRequest) { status: res.status, detail: raw.slice(0, 180), }); - const response = NextResponse.json( - { error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) }, - { status: res.status === 402 || res.status === 403 ? res.status : 502 }, - ); + const response = buildUpstreamErrorResponse(res.status, raw); return applyAuthResponseCookies(response, auth.response); } const data = await res.json(); @@ -93,18 +94,17 @@ export async function POST(req: NextRequest) { timeout_ms: AI_CITY_GATEWAY_TIMEOUT_MS, error: String(error), }); - const response = NextResponse.json( - { - error: timedOut - ? "City AI gateway timed out before backend responded" - : "Failed to fetch city AI data", - detail: String(error), + const response = buildProxyExceptionResponse(error, { + publicMessage: timedOut + ? "City AI gateway timed out before backend responded" + : "Failed to fetch city AI data", + status: timedOut ? 504 : 500, + extra: { elapsed_ms: elapsedMs, timeout_ms: AI_CITY_GATEWAY_TIMEOUT_MS, city: requestBody.city, }, - { status: timedOut ? 504 : 500 }, - ); + }); return auth ? applyAuthResponseCookies(response, auth.response) : response; } finally { clearTimeout(timeoutId); diff --git a/frontend/app/api/scan/terminal/ai-city/stream/route.ts b/frontend/app/api/scan/terminal/ai-city/stream/route.ts index 76391b9c..ebcba9b0 100644 --- a/frontend/app/api/scan/terminal/ai-city/stream/route.ts +++ b/frontend/app/api/scan/terminal/ai-city/stream/route.ts @@ -3,6 +3,10 @@ import { applyAuthResponseCookies, buildBackendRequestHeaders, } from "@/lib/backend-auth"; +import { + buildProxyExceptionResponse, + buildUpstreamErrorResponse, +} from "@/lib/api-proxy"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -40,10 +44,7 @@ export async function POST(req: NextRequest) { }); if (!res.ok || !res.body) { const raw = await res.text(); - const response = NextResponse.json( - { error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) }, - { status: res.status === 402 || res.status === 403 ? res.status : 502 }, - ); + const response = buildUpstreamErrorResponse(res.status, raw); return applyAuthResponseCookies(response, auth.response); } @@ -57,14 +58,10 @@ export async function POST(req: NextRequest) { }); return applyAuthResponseCookies(response, auth.response); } catch (error) { - const response = NextResponse.json( - { - error: "Failed to stream city AI data", - detail: String(error), - city: requestBody.city, - }, - { status: 500 }, - ); + const response = buildProxyExceptionResponse(error, { + publicMessage: "Failed to stream city AI data", + extra: { city: requestBody.city }, + }); return applyAuthResponseCookies(response, auth.response); } } diff --git a/frontend/app/api/scan/terminal/ai/route.ts b/frontend/app/api/scan/terminal/ai/route.ts index 616bf616..847537de 100644 --- a/frontend/app/api/scan/terminal/ai/route.ts +++ b/frontend/app/api/scan/terminal/ai/route.ts @@ -3,6 +3,10 @@ import { applyAuthResponseCookies, buildBackendRequestHeaders, } from "@/lib/backend-auth"; +import { + buildProxyExceptionResponse, + buildUpstreamErrorResponse, +} from "@/lib/api-proxy"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const SCAN_AI_PROXY_TIMEOUT_MS = Math.max( @@ -46,10 +50,7 @@ export async function POST(req: NextRequest) { }); if (!res.ok) { const raw = await res.text(); - const response = NextResponse.json( - { error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) }, - { status: res.status === 402 || res.status === 403 ? res.status : 502 }, - ); + const response = buildUpstreamErrorResponse(res.status, raw); return applyAuthResponseCookies(response, auth.response); } const data = await res.json(); @@ -61,15 +62,12 @@ export async function POST(req: NextRequest) { return applyAuthResponseCookies(response, auth.response); } catch (error) { const timedOut = controller.signal.aborted; - const response = NextResponse.json( - { - error: timedOut - ? "Scan AI request timed out" - : "Failed to fetch scan AI data", - detail: String(error), - }, - { status: timedOut ? 504 : 500 }, - ); + const response = buildProxyExceptionResponse(error, { + publicMessage: timedOut + ? "Scan AI request timed out" + : "Failed to fetch scan AI data", + status: timedOut ? 504 : 500, + }); return auth ? applyAuthResponseCookies(response, auth.response) : response; } finally { clearTimeout(timeoutId); diff --git a/frontend/app/api/scan/terminal/route.ts b/frontend/app/api/scan/terminal/route.ts index bb1c4ccd..f9f6f12c 100644 --- a/frontend/app/api/scan/terminal/route.ts +++ b/frontend/app/api/scan/terminal/route.ts @@ -3,6 +3,10 @@ import { applyAuthResponseCookies, buildBackendRequestHeaders, } from "@/lib/backend-auth"; +import { + buildProxyExceptionResponse, + buildUpstreamErrorResponse, +} from "@/lib/api-proxy"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const SCAN_TERMINAL_PROXY_TIMEOUT_MS = Number( @@ -54,10 +58,7 @@ export async function GET(req: NextRequest) { }); if (!res.ok) { const raw = await res.text(); - const response = NextResponse.json( - { error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) }, - { status: 502 }, - ); + const response = buildUpstreamErrorResponse(res.status, raw); return applyAuthResponseCookies(response, auth.response); } const data = await res.json(); @@ -69,15 +70,12 @@ export async function GET(req: NextRequest) { return applyAuthResponseCookies(response, auth.response); } catch (error) { const timedOut = controller.signal.aborted; - const response = NextResponse.json( - { - error: timedOut - ? "Scan terminal request timed out" - : "Failed to fetch scan terminal data", - detail: String(error), - }, - { status: timedOut ? 504 : 500 }, - ); + const response = buildProxyExceptionResponse(error, { + publicMessage: timedOut + ? "Scan terminal request timed out" + : "Failed to fetch scan terminal data", + status: timedOut ? 504 : 500, + }); return auth ? applyAuthResponseCookies(response, auth.response) : response; } finally { clearTimeout(timeoutId); diff --git a/frontend/app/api/system/status/route.ts b/frontend/app/api/system/status/route.ts index 5c8273da..70e2a88d 100644 --- a/frontend/app/api/system/status/route.ts +++ b/frontend/app/api/system/status/route.ts @@ -3,6 +3,10 @@ import { applyAuthResponseCookies, buildBackendRequestHeaders, } from "@/lib/backend-auth"; +import { + buildProxyExceptionResponse, + buildUpstreamErrorResponse, +} from "@/lib/api-proxy"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -22,10 +26,9 @@ export async function GET(req: NextRequest) { }); if (!res.ok) { const raw = await res.text(); - const response = NextResponse.json( - { error: `Backend returned ${res.status}`, detail: raw.slice(0, 500) }, - { status: res.status }, - ); + const response = buildUpstreamErrorResponse(res.status, raw, { + detailLimit: 500, + }); return applyAuthResponseCookies(response, auth.response); } @@ -35,9 +38,8 @@ export async function GET(req: NextRequest) { }); return applyAuthResponseCookies(response, auth.response); } catch (error) { - return NextResponse.json( - { error: "Failed to fetch system status", detail: String(error) }, - { status: 500 }, - ); + return buildProxyExceptionResponse(error, { + publicMessage: "Failed to fetch system status", + }); } } diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index f8ff185c..6d351127 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -31,7 +31,6 @@ import modalChromeStyles from "./ModalChrome.module.css"; import scanTerminalCalendarStyles from "./ScanTerminalCalendar.module.css"; import scanTerminalCardStyles from "./ScanTerminalCard.module.css"; import scanTerminalLightThemeStyles from "./ScanTerminalLightTheme.module.css"; -import scanTerminalMobileStyles from "./ScanTerminalMobile.module.css"; import scanTerminalOpportunityStyles from "./ScanTerminalOpportunity.module.css"; import scanTerminalStyles from "./ScanTerminal.module.css"; import scanTerminalBoardStyles from "./ScanTerminalBoard.module.css"; @@ -40,6 +39,7 @@ import scanTerminalFiltersStyles from "./ScanTerminalFilters.module.css"; import scanTerminalListStyles from "./ScanTerminalList.module.css"; import scanTerminalShellStyles from "./ScanTerminalShell.module.css"; import scanTerminalStateStyles from "./ScanTerminalState.module.css"; +import scanTerminalMobileStyles from "./ScanTerminalMobile.module.css"; import { ProFeaturePaywall } from "@/components/dashboard/ProFeaturePaywall"; import { DashboardStoreProvider, diff --git a/frontend/components/dashboard/ScanTerminalMobile.module.css b/frontend/components/dashboard/ScanTerminalMobile.module.css index b7ae2cea..1874b17f 100644 --- a/frontend/components/dashboard/ScanTerminalMobile.module.css +++ b/frontend/components/dashboard/ScanTerminalMobile.module.css @@ -144,6 +144,8 @@ @media (max-width: 1100px) { .root :global(.scan-terminal) { grid-template-columns: 1fr; + min-height: 100dvh; + overflow-x: hidden; } .root :global(.scan-filter-panel), @@ -153,6 +155,27 @@ min-height: auto; } + .root :global(.scan-data-grid) { + max-height: none; + overflow: visible; + } + + .root :global(.scan-list-section), + .root :global(.scan-table-shell), + .root :global(.scan-ai-workspace) { + overflow: visible; + } + + .root :global(.scan-ai-workspace) { + max-height: none; + } + + .root :global(.scan-terminal > .detail-panel.scan-city-detail-rail) { + width: 100%; + min-width: 0; + max-width: 100%; + } + .root :global(.scan-kpi-bar) { grid-template-columns: repeat(2, minmax(0, 1fr)); } @@ -267,6 +290,26 @@ padding: 10px; } + .root :global(.scan-data-grid) { + padding: 12px; + gap: 14px; + border-radius: 18px; + } + + .root :global(.scan-topbar-actions) { + width: 100%; + justify-content: flex-start; + gap: 8px; + } + + .root :global(.scan-topbar-title strong) { + font-size: 23px; + } + + .root :global(.scan-topbar-title span) { + font-size: 13px; + } + .root :global(.scan-kpi-bar) { grid-template-columns: 1fr; } @@ -389,6 +432,40 @@ align-items: flex-start; } + .root :global(.scan-list-tabs) { + width: 100%; + gap: 8px; + overflow-x: auto; + padding-bottom: 4px; + scrollbar-width: none; + } + + .root :global(.scan-list-tabs::-webkit-scrollbar) { + display: none; + } + + .root :global(.scan-list-tabs button) { + flex: 0 0 auto; + min-height: 36px; + border: 1px solid rgba(99, 132, 180, 0.18); + border-radius: 999px; + background: rgba(8, 19, 34, 0.76); + padding: 0 12px; + font-size: 13px; + white-space: nowrap; + } + + .root :global(.scan-list-tabs button.active) { + border-color: rgba(77, 163, 255, 0.38); + background: rgba(77, 163, 255, 0.16); + } + + .root :global(.scan-list-status) { + width: 100%; + flex-wrap: wrap; + gap: 8px; + } + .root :global(.scan-opportunity-groups) { padding: 12px; } @@ -411,3 +488,50 @@ padding: 0 13px 13px; } } + +@media (max-width: 520px) { + .root :global(.scan-terminal) { + padding: 6px; + } + + .root :global(.scan-data-grid) { + padding: 10px; + border-radius: 16px; + } + + .root :global(.scan-upgrade-announcement) { + margin: -2px 0 2px; + padding: 12px; + border-radius: 16px; + } + + .root :global(.scan-upgrade-announcement-copy p) { + display: none; + } + + .root :global(.scan-upgrade-announcement ul) { + display: none; + } + + .root :global(.scan-status-chip) { + height: 32px; + padding: 0 10px; + font-size: 11px; + } + + .root :global(.scan-primary-button) { + min-height: 38px; + padding: 9px 12px; + font-size: 13px; + } + + .root :global(.scan-account-button) { + width: 38px; + height: 38px; + } + + .root :global(.scan-theme-button) { + width: 38px; + height: 38px; + } +} diff --git a/frontend/lib/api-proxy.ts b/frontend/lib/api-proxy.ts new file mode 100644 index 00000000..a02688d3 --- /dev/null +++ b/frontend/lib/api-proxy.ts @@ -0,0 +1,72 @@ +import { NextResponse } from "next/server"; + +const PASSTHROUGH_UPSTREAM_STATUSES = new Set([ + 400, + 401, + 402, + 403, + 404, + 409, + 422, + 429, +]); + +function shouldExposeProxyErrorDetail() { + return ( + process.env.NODE_ENV !== "production" || + process.env.POLYWEATHER_EXPOSE_PROXY_ERROR_DETAIL === "true" + ); +} + +export function clientStatusFromUpstream(status: number) { + if (PASSTHROUGH_UPSTREAM_STATUSES.has(status)) { + return status; + } + return 502; +} + +export function buildUpstreamErrorResponse( + upstreamStatus: number, + rawDetail: string, + options?: { + detailLimit?: number; + error?: string; + extraDebug?: Record; + }, +) { + const body: Record = { + error: options?.error || "Upstream request failed", + upstream_status: upstreamStatus, + }; + + if (shouldExposeProxyErrorDetail()) { + body.detail = String(rawDetail || "").slice(0, options?.detailLimit ?? 300); + if (options?.extraDebug) { + body.proxy_debug = options.extraDebug; + } + } + + return NextResponse.json(body, { + status: clientStatusFromUpstream(upstreamStatus), + }); +} + +export function buildProxyExceptionResponse( + error: unknown, + options: { + status?: number; + publicMessage: string; + extra?: Record; + }, +) { + const body: Record = { + error: options.publicMessage, + ...(options.extra || {}), + }; + + if (shouldExposeProxyErrorDetail()) { + body.detail = String(error); + } + + return NextResponse.json(body, { status: options.status ?? 500 }); +} diff --git a/frontend/lib/system-priority-proxy.ts b/frontend/lib/system-priority-proxy.ts index b0357fc4..082c28de 100644 --- a/frontend/lib/system-priority-proxy.ts +++ b/frontend/lib/system-priority-proxy.ts @@ -3,6 +3,7 @@ import { applyAuthResponseCookies, buildBackendRequestHeaders, } from "@/lib/backend-auth"; +import { buildProxyExceptionResponse } from "@/lib/api-proxy"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -47,15 +48,15 @@ export async function forwardPriorityWarmHint(req: NextRequest) { if (!res.ok) { const raw = await res.text(); - const response = NextResponse.json( - { + const response = buildProxyExceptionResponse(raw, { + status: 202, + publicMessage: "Priority warm hint was skipped", + extra: { ok: false, skipped: true, - error: `Backend returned ${res.status}`, - detail: raw.slice(0, 500), + upstream_status: res.status, }, - { status: 202 }, - ); + }); return applyAuthResponseCookies(response, auth.response); } @@ -65,14 +66,13 @@ export async function forwardPriorityWarmHint(req: NextRequest) { }); return applyAuthResponseCookies(response, auth.response); } catch (error) { - return NextResponse.json( - { + return buildProxyExceptionResponse(error, { + status: 202, + publicMessage: "Failed to send priority warm hint", + extra: { ok: false, skipped: true, - error: "Failed to send priority warm hint", - detail: String(error), }, - { status: 202 }, - ); + }); } }