fix issue

This commit is contained in:
Marshal
2026-07-16 01:05:57 +00:00
parent 41fe04652f
commit fe29b38377
18 changed files with 518 additions and 51 deletions

View File

@@ -1,6 +1,10 @@
import axios from "axios";
import { API_BASE_URL } from "@/constants/apiConfig";
import {
emitApiError,
extractApiErrorPayload,
} from "@/components/errors/ApiErrorModal";
import {
AUTH_TOKEN_COOKIE,
REFRESH_TOKEN_COOKIE,
@@ -86,6 +90,12 @@ api.interceptors.response.use(
originalRequest.url?.includes("/auth/mfa-verify") ||
originalRequest.url?.includes("/auth/refresh-token")
) {
// Surface the server's actual error message in the global error modal
// (401s are handled by the session-refresh flow, so skip them).
if (error.response && error.response.status !== 401) {
const payload = extractApiErrorPayload(error);
if (payload) emitApiError(payload);
}
return Promise.reject(error);
}

View File

@@ -0,0 +1,143 @@
import { useEffect, useState } from "react";
import { Alert, Button, Group, List, Modal, Stack, Text } from "@mantine/core";
import { AlertTriangle } from "lucide-react";
/**
* Global API error modal.
*
* The axios client emits every failed request (with a server response) through
* `emitApiError`; this modal — mounted once at the app root — shows the
* SERVER'S actual `message` instead of a generic "Request failed with status
* code NNN". Pages listed in EXCLUDED_PATH_PATTERNS (warehouse, first/last
* mile, onboarding/auth screens) keep their own inline error handling and
* never trigger it.
*/
export interface ApiErrorPayload {
/** Server messages — the `message` field (string or class-validator array). */
messages: string[];
statusCode?: number;
/** API path that failed, shown as small helper text. */
path?: string;
}
type Listener = (payload: ApiErrorPayload) => void;
let listener: Listener | null = null;
/** Current-page path patterns where the global modal must stay silent. */
const EXCLUDED_PATH_PATTERNS = [
/^\/auth/,
/^\/callback/,
/warehouse/i,
/first-mile/i,
/last-mile/i,
/onboard/i,
/register/i,
];
export function isGlobalErrorModalSuppressed(pathname: string): boolean {
return EXCLUDED_PATH_PATTERNS.some((re) => re.test(pathname));
}
export function emitApiError(payload: ApiErrorPayload): void {
if (isGlobalErrorModalSuppressed(window.location.pathname)) return;
listener?.(payload);
}
/** Pull the server `message` out of an axios-style error. */
export function extractApiErrorPayload(error: unknown): ApiErrorPayload | null {
const err = error as {
response?: {
status?: number;
data?: {
message?: string | string[];
error?: string;
statusCode?: number;
path?: string;
};
};
};
const response = err?.response;
if (!response) return null; // network error / cancellation — not ours
const data = response.data;
const raw = data?.message;
const messages = Array.isArray(raw)
? raw.filter((m): m is string => typeof m === "string" && m.length > 0)
: typeof raw === "string" && raw.length > 0
? [raw]
: [];
if (messages.length === 0) {
messages.push(
data?.error ?? `Request failed with status code ${response.status}`,
);
}
return {
messages,
statusCode: data?.statusCode ?? response.status,
path: data?.path,
};
}
export function ApiErrorModal() {
const [payload, setPayload] = useState<ApiErrorPayload | null>(null);
useEffect(() => {
listener = (next) => {
// Don't stack identical messages while the modal is already showing them.
setPayload((current) =>
current && current.messages.join("\n") === next.messages.join("\n")
? current
: next,
);
};
return () => {
listener = null;
};
}, []);
const close = () => setPayload(null);
return (
<Modal
opened={payload !== null}
onClose={close}
centered
radius="md"
title={
<Group gap="xs">
<AlertTriangle size={18} color="var(--mantine-color-red-6)" />
<Text fw={700}>Request failed</Text>
</Group>
}
>
{payload && (
<Stack gap="md">
<Alert color="red" variant="light">
{payload.messages.length === 1 ? (
<Text size="sm">{payload.messages[0]}</Text>
) : (
<List size="sm" spacing={4}>
{payload.messages.map((m, i) => (
<List.Item key={i}>{m}</List.Item>
))}
</List>
)}
</Alert>
{(payload.statusCode || payload.path) && (
<Text size="xs" c="dimmed">
{payload.statusCode ? `Status ${payload.statusCode}` : null}
{payload.statusCode && payload.path ? " · " : null}
{payload.path}
</Text>
)}
<Group justify="flex-end">
<Button variant="default" onClick={close}>
Close
</Button>
</Group>
</Stack>
)}
</Modal>
);
}

View File

@@ -15,6 +15,7 @@ import { Toaster } from "react-hot-toast";
import "./i18n";
import App from "./App";
import { ApiErrorModal } from "./components/errors/ApiErrorModal";
import { ErrorBoundary } from "./components/ErrorBoundary";
import { AuthProvider } from "./auth/AuthProvider";
import { queryClient } from "./lib/queryClient";
@@ -57,6 +58,9 @@ createRoot(rootElement).render(
<ErrorBoundary>
<App />
</ErrorBoundary>
{/* Global API error modal — shows the server's actual error
message (suppressed on warehouse / mile / onboarding pages). */}
<ApiErrorModal />
<Toaster position="top-right" />
</AuthProvider>
</BrowserRouter>