Skip to content
This repository was archived by the owner on Oct 15, 2025. It is now read-only.

Commit c0bcbb3

Browse files
committed
feat(web): add error boundary with toast notification for failed requests
1 parent 3f45e1a commit c0bcbb3

File tree

16 files changed

+258
-61
lines changed

16 files changed

+258
-61
lines changed

packages/server/src/modules/feedbacks/routes.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,6 @@ const feedbacksRoute: FastifyPluginAsync = async (app) => {
5151
querystring: feedbackListQuerySchema,
5252
response: {
5353
200: feedbackListResponseSchema,
54-
404: genericResponseSchema,
5554
},
5655
},
5756
handler: getFeedbackListController,

packages/web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"clsx": "^2.1.1",
2323
"react": "^19.0.0",
2424
"react-dom": "^19.0.0",
25+
"react-hot-toast": "^2.5.2",
2526
"react-redux": "^9.2.0",
2627
"zod": "^3.24.2"
2728
},

packages/web/src/assets/illustration-error.svg

Lines changed: 23 additions & 0 deletions
Loading
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
.error-container {
2+
display: grid;
3+
min-height: 100vh;
4+
place-content: center;
5+
}
6+
7+
.error-card {
8+
background-color: var(--clr-pure-white);
9+
border-radius: var(--radius);
10+
display: grid;
11+
text-align: center;
12+
place-content: center;
13+
place-items: center;
14+
padding: 3em 1.5em;
15+
}
16+
17+
.error-card__desc {
18+
max-width: 410px;
19+
margin: 0.3em auto 1.5em;
20+
}
21+
22+
.error-card__title {
23+
color: var(--clr-deep-slate-blue);
24+
margin-top: 1.25em;
25+
}
26+
27+
@media (max-width: 480px) {
28+
.error-card__title {
29+
font-size: var(--fs-500);
30+
line-height: 1.63;
31+
letter-spacing: -0.25px;
32+
}
33+
34+
.error-card__desc {
35+
font-size: var(--fs-100);
36+
}
37+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { Link } from "@tanstack/react-router";
2+
import { Component, ErrorInfo } from "react";
3+
import { buttonVariants } from "./button";
4+
import IllustrationError from "@/assets/illustration-error.svg?react";
5+
import styles from "./error-boundary.module.css";
6+
import { cn } from "@/lib/utils";
7+
8+
interface Props {
9+
children?: React.ReactNode;
10+
}
11+
12+
interface State {
13+
hasError: boolean;
14+
}
15+
16+
export class ErrorBoundary extends Component<Props, State> {
17+
public state: State = {
18+
hasError: false,
19+
};
20+
21+
static getDerivedStateFromError(_: Error) {
22+
// Update state so the next render will show the fallback UI.
23+
return { hasError: true };
24+
}
25+
26+
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
27+
// You can also log the error to an error reporting service
28+
console.error("Uncaught error:", error, errorInfo);
29+
}
30+
31+
render() {
32+
if (this.state.hasError) {
33+
// You can render any custom fallback UI
34+
return (
35+
<div className={styles["error-container"]}>
36+
<section className={styles["error-card"]}>
37+
<IllustrationError />
38+
<h1 className={cn("h1", styles["error-card__title"])}>Oops!</h1>
39+
<p className={styles["error-card__desc"]}>
40+
It looks like something unexpected happened. We're working to fix
41+
it as quickly as possible. Please try again later, or refresh the
42+
page if needed
43+
</p>
44+
<Link to="/" className={buttonVariants["primary"]}>
45+
Visit home
46+
</Link>
47+
</section>
48+
</div>
49+
);
50+
}
51+
52+
return this.props.children;
53+
}
54+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { Toaster as ReactHotToast } from "react-hot-toast";
2+
3+
function Toaster({ ...props }: React.ComponentProps<typeof ReactHotToast>) {
4+
return <ReactHotToast position="top-center" {...props} />;
5+
}
6+
7+
export default Toaster;

packages/web/src/features/user/slice.ts

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
22
import { User } from "./types";
3-
import userApi from "./service";
43

54
type UserState = {
65
data: User | null;
@@ -21,17 +20,6 @@ const userSlice = createSlice({
2120
state.data = null;
2221
},
2322
},
24-
extraReducers: (builder) => {
25-
builder.addMatcher(
26-
userApi.endpoints.getMe.matchFulfilled,
27-
(state, action) => {
28-
state.data = action.payload;
29-
},
30-
);
31-
builder.addMatcher(userApi.endpoints.getMe.matchRejected, (state) => {
32-
state.data = null;
33-
});
34-
},
3523
});
3624

3725
export const userReducer = userSlice.reducer;

packages/web/src/lib/http/client.ts

Lines changed: 50 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import axios, { AxiosError, AxiosRequestConfig } from "axios";
22
import { config } from "@/config";
33
import { BaseQueryFn } from "@reduxjs/toolkit/query";
4+
import { clearUser, setUser } from "@/features/user/slice";
45

5-
console.log(config.apiUrl);
66
const instance = axios.create({
77
baseURL: config.apiUrl,
88
timeout: 5_000,
@@ -17,6 +17,11 @@ const instance = axios.create({
1717
],
1818
});
1919

20+
type HttpBaseQueryError = {
21+
status: number;
22+
data: { message: string };
23+
};
24+
2025
const httpBaseQuery =
2126
(
2227
{ baseUrl }: { baseUrl: string } = { baseUrl: "" },
@@ -29,9 +34,9 @@ const httpBaseQuery =
2934
headers?: AxiosRequestConfig["headers"];
3035
},
3136
unknown,
32-
unknown
37+
HttpBaseQueryError
3338
> =>
34-
async ({ url, method, data, params, headers }) => {
39+
async ({ url, method, data, params, headers }, api) => {
3540
try {
3641
const result = await instance({
3742
url: baseUrl + url,
@@ -40,18 +45,54 @@ const httpBaseQuery =
4045
params,
4146
headers,
4247
});
48+
4349
return {
4450
data: result,
4551
};
4652
} catch (axiosError) {
4753
const error = axiosError as AxiosError;
48-
return {
49-
error: {
50-
status: error.status,
51-
data: error.response?.data || error.message,
52-
},
53-
};
54+
55+
if (error.response?.status != 401) {
56+
return {
57+
error: {
58+
status: error.response?.status ?? 500,
59+
data: (error.response?.data as { message: string }) || {
60+
message: error.message,
61+
},
62+
},
63+
};
64+
}
65+
66+
try {
67+
const response = await instance.post("/auth/refresh");
68+
api.dispatch(setUser(response.data));
69+
70+
const retryResult = await instance({
71+
url: baseUrl + url,
72+
method,
73+
data,
74+
params,
75+
headers,
76+
});
77+
78+
return {
79+
data: retryResult.data,
80+
};
81+
} catch (axiosError) {
82+
const error = axiosError as AxiosError;
83+
api.dispatch(clearUser());
84+
85+
return {
86+
error: {
87+
status: error.response?.status ?? 500,
88+
data: (error.response?.data as { message: string }) || {
89+
message: error.message,
90+
},
91+
},
92+
};
93+
}
5494
}
5595
};
5696

5797
export { instance, httpBaseQuery };
98+
export type { HttpBaseQueryError };

packages/web/src/lib/http/interceptors.ts

Lines changed: 1 addition & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,11 @@
1-
import type { AxiosError, AxiosResponse } from "axios";
21
import { instance } from "./client";
32

4-
let refreshPromise: Promise<AxiosResponse> | null = null;
5-
6-
async function refreshToken() {
7-
if (!refreshPromise) {
8-
refreshPromise = instance.post("/auth/refresh").finally(() => {
9-
refreshPromise = null;
10-
});
11-
}
12-
13-
return refreshPromise;
14-
}
15-
163
function setupResponseInterceptors() {
174
instance.interceptors.response.use(
185
function (response) {
196
return Promise.resolve(response.data);
207
},
21-
async function (error: AxiosError) {
22-
if (!error.config || error.config?.url?.includes("refresh")) {
23-
return Promise.reject(error);
24-
}
25-
26-
if (error.status == 401) {
27-
try {
28-
await refreshToken();
29-
return instance.request(error.config);
30-
} catch (error) {
31-
return Promise.reject(error);
32-
}
33-
}
34-
8+
async function (error) {
359
return Promise.reject(error);
3610
},
3711
);

packages/web/src/lib/http/utils.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import type { HttpBaseQueryError } from "./client";
2+
3+
export function isHttpBaseQueryError(
4+
error: unknown,
5+
): error is HttpBaseQueryError {
6+
return (
7+
typeof error == "object" &&
8+
error !== null &&
9+
"status" in error &&
10+
"data" in error &&
11+
typeof error.status == "number" &&
12+
typeof error.data == "object" &&
13+
error.data !== null &&
14+
"message" in error.data
15+
);
16+
}

0 commit comments

Comments
 (0)