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
This commit is contained in:
2569718930@qq.com
2026-04-29 11:22:49 +08:00
parent 4630cee06f
commit 569f3eef97
36 changed files with 471 additions and 282 deletions
+10 -8
View File
@@ -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",
});
}
}
+8 -8
View File
@@ -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",
});
}
}
+8 -8
View File
@@ -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;
}
}
+8 -8
View File
@@ -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;
}
}
@@ -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;
}
}
+8 -8
View File
@@ -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;
}
}
@@ -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;
}
}
+4 -4
View File
@@ -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",
});
}
}
+8 -8
View File
@@ -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;
}
}
@@ -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",
});
}
}
@@ -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",
});
}
}
+4 -4
View File
@@ -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",
});
}
}
@@ -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",
});
}
}
@@ -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",
});
}
}
+4 -4
View File
@@ -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",
});
}
}
@@ -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",
});
}
}
+4 -4
View File
@@ -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",
});
}
}
+10 -8
View File
@@ -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",
});
}
}
@@ -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",
});
}
}
@@ -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",
});
}
}
@@ -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",
});
}
}
+10 -8
View File
@@ -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",
});
}
}
@@ -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",
});
}
}
+10 -8
View File
@@ -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",
});
}
}
@@ -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",
});
}
}
+17 -20
View File
@@ -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",
});
}
}
@@ -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",
});
}
}
+12 -12
View File
@@ -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);
@@ -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);
}
}
+11 -13
View File
@@ -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);
+11 -13
View File
@@ -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);
+10 -8
View File
@@ -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",
});
}
}
@@ -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,
@@ -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;
}
}
+72
View File
@@ -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<string, unknown>;
},
) {
const body: Record<string, unknown> = {
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<string, unknown>;
},
) {
const body: Record<string, unknown> = {
error: options.publicMessage,
...(options.extra || {}),
};
if (shouldExposeProxyErrorDetail()) {
body.detail = String(error);
}
return NextResponse.json(body, { status: options.status ?? 500 });
}
+12 -12
View File
@@ -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 },
);
});
}
}