Skip to content

Commit 9d8a644

Browse files
committed
feat: add error boundary, favicon and loading
1 parent 0bed7bc commit 9d8a644

File tree

9 files changed

+160
-46
lines changed

9 files changed

+160
-46
lines changed

.env.example

+1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ ENCRYPTION_SECRET="encryptionsecret"
88
# Portal
99
PORTAL_PROXY_URL="http://portal:3334"
1010
PORTAL_REFERER_URL="https://example.com"
11+
PORTAL_FAVICON_URL="https://example.com/favicon.svg"
1112
PORTAL_LOGO="https://example.com" # URL or SVG string
1213
PORTAL_ORGANIZATION_NAME="Acme"
1314
PORTAL_FORCE_THEME="light"

internal/portal/src/app.tsx

+35-29
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,13 @@ import { SWRConfig } from "swr";
55

66
import "./global.scss";
77
import "./app.scss";
8+
import { Loading } from "./common/Icons";
9+
import ErrorBoundary from "./common/ErrorBoundary/ErrorBoundary";
810

911
export function App() {
1012
const token = useToken();
1113
const tenant = useTenant(token ?? undefined);
1214
useTheme();
13-
1415
return (
1516
<>
1617
<div className="layout">
@@ -35,35 +36,40 @@ export function App() {
3536
Back to {ORGANIZATION_NAME} {"->"}
3637
</a>
3738
</header>
38-
{/* TODO: Add loading state */}
39-
{tenant ? (
40-
<SWRConfig
41-
value={{
42-
fetcher: (path: string) =>
43-
fetch(`http://localhost:3333/api/v1/${tenant.id}/${path}`, {
44-
headers: {
45-
Authorization: `Bearer ${token}`,
46-
},
47-
}).then((res) => res.json()),
48-
}}
49-
>
50-
<BrowserRouter
51-
future={{
52-
v7_startTransition: true,
53-
v7_relativeSplatPath: true,
39+
<ErrorBoundary>
40+
{tenant ? (
41+
<SWRConfig
42+
value={{
43+
fetcher: (path: string) =>
44+
fetch(`http://localhost:3333/api/v1/${tenant.id}/${path}`, {
45+
headers: {
46+
Authorization: `Bearer ${token}`,
47+
},
48+
}).then((res) => res.json()),
5449
}}
5550
>
56-
<Routes>
57-
<Route path="/" Component={DestinationList} />
58-
<Route path="/new" element={<div>New Destination</div>} />
59-
<Route
60-
path="/destinations/:destination_id"
61-
element={<div>Specific Destination</div>}
62-
/>
63-
</Routes>
64-
</BrowserRouter>
65-
</SWRConfig>
66-
) : null}
51+
<BrowserRouter
52+
future={{
53+
v7_startTransition: true,
54+
v7_relativeSplatPath: true,
55+
}}
56+
>
57+
<Routes>
58+
<Route path="/" Component={DestinationList} />
59+
<Route path="/new" element={<div>New Destination</div>} />
60+
<Route
61+
path="/destinations/:destination_id"
62+
element={<div>Specific Destination</div>}
63+
/>
64+
</Routes>
65+
</BrowserRouter>
66+
</SWRConfig>
67+
) : (
68+
<div className="fullscreen-loading">
69+
<Loading />
70+
</div>
71+
)}
72+
</ErrorBoundary>
6773
</div>
6874
<div className="powered-by subtitle-s">
6975
Powered by{" "}
@@ -157,7 +163,7 @@ function useTheme() {
157163
useEffect(() => {
158164
const searchParams = new URLSearchParams(window.location.search);
159165
const queryTheme = searchParams.get("theme");
160-
166+
161167
if (queryTheme === "dark" || queryTheme === "light") {
162168
// Save new theme preference
163169
localStorage.setItem("theme", queryTheme);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
.error-boundary {
2+
padding: var(--spacing-4);
3+
background-color: var(--colors-surface-neutral-variant);
4+
border-radius: var(--radius-m);
5+
border: 1px solid var(--colors-outline-neutral);
6+
7+
h1 {
8+
margin: 0;
9+
margin-bottom: var(--spacing-1);
10+
}
11+
p {
12+
margin: 0;
13+
}
14+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { Component, ReactNode, ErrorInfo } from "react";
2+
3+
import "./ErrorBoundary.scss";
4+
5+
class ErrorBoundary extends Component<{ children: ReactNode }> {
6+
state = { hasError: false, error: null };
7+
8+
static getDerivedStateFromError(error: Error) {
9+
return { hasError: true, error };
10+
}
11+
12+
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
13+
console.error("Error caught:", error, errorInfo);
14+
}
15+
16+
render() {
17+
if (this.state.hasError) {
18+
return (
19+
<div className="error-boundary">
20+
<h1 className="title-l">Something went wrong.</h1>
21+
<p className="body-s muted">Please try refreshing the page.</p>
22+
</div>
23+
);
24+
}
25+
return this.props.children;
26+
}
27+
}
28+
29+
export default ErrorBoundary;

internal/portal/src/common/Icons.tsx

+53
Original file line numberDiff line numberDiff line change
@@ -129,3 +129,56 @@ export const CloseIcon = () => (
129129
<path d="M8.00088 8.85L5.26755 11.5833C5.14533 11.7056 5.00644 11.7639 4.85088 11.7583C4.69533 11.7528 4.55644 11.6889 4.43422 11.5667C4.31199 11.4444 4.25088 11.3028 4.25088 11.1417C4.25088 10.9806 4.31199 10.8389 4.43422 10.7167L7.15088 8L4.41755 5.26667C4.29533 5.14445 4.23699 5.00278 4.24255 4.84167C4.24811 4.68056 4.31199 4.53889 4.43422 4.41667C4.55644 4.29445 4.69811 4.23334 4.85922 4.23334C5.02033 4.23334 5.16199 4.29445 5.28422 4.41667L8.00088 7.15L10.7342 4.41667C10.8564 4.29445 10.9981 4.23334 11.1592 4.23334C11.3203 4.23334 11.462 4.29445 11.5842 4.41667C11.7064 4.53889 11.7675 4.68056 11.7675 4.84167C11.7675 5.00278 11.7064 5.14445 11.5842 5.26667L8.85088 8L11.5842 10.7333C11.7064 10.8556 11.7675 10.9944 11.7675 11.15C11.7675 11.3056 11.7064 11.4444 11.5842 11.5667C11.462 11.6889 11.3203 11.75 11.1592 11.75C10.9981 11.75 10.8564 11.6889 10.7342 11.5667L8.00088 8.85Z" />
130130
</svg>
131131
);
132+
133+
export const Loading = () => (
134+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" width="24px">
135+
<radialGradient
136+
id="a12"
137+
cx=".66"
138+
fx=".66"
139+
cy=".3125"
140+
fy=".3125"
141+
gradientTransform="scale(1.5)"
142+
>
143+
<stop offset="0" stopColor="var(--colors-on-surface-secondary)"></stop>
144+
<stop offset=".3" stopColor="var(--colors-on-surface-secondary)" stopOpacity=".9"></stop>
145+
<stop offset=".6" stopColor="var(--colors-on-surface-secondary)" stopOpacity=".6"></stop>
146+
<stop offset=".8" stopColor="var(--colors-on-surface-secondary)" stopOpacity=".3"></stop>
147+
<stop offset="1" stopColor="var(--colors-on-surface-secondary)" stopOpacity="0"></stop>
148+
</radialGradient>
149+
<circle
150+
transform-origin="center"
151+
fill="none"
152+
stroke="url(#a12)"
153+
stroke-width="15"
154+
stroke-linecap="round"
155+
stroke-dasharray="200 1000"
156+
stroke-dashoffset="0"
157+
cx="100"
158+
cy="100"
159+
r="70"
160+
>
161+
<animateTransform
162+
type="rotate"
163+
attributeName="transform"
164+
calcMode="spline"
165+
dur="0.75"
166+
values="360;0"
167+
keyTimes="0;1"
168+
keySplines="0 0 1 1"
169+
repeatCount="indefinite"
170+
></animateTransform>
171+
</circle>
172+
<circle
173+
transform-origin="center"
174+
fill="none"
175+
opacity=".2"
176+
stroke="var(--colors-on-surface-secondary)"
177+
stroke-width="15"
178+
stroke-linecap="round"
179+
cx="100"
180+
cy="100"
181+
r="70"
182+
></circle>
183+
</svg>
184+
);

internal/portal/src/main.tsx

+9
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,15 @@ import { StrictMode } from "react";
22
import { createRoot } from "react-dom/client";
33
import { App } from "./app";
44

5+
if (FAVICON_URL) {
6+
const favicon =
7+
document.querySelector('link[rel="icon"]') ||
8+
document.createElement("link");
9+
favicon.setAttribute("rel", "icon");
10+
favicon.setAttribute("href", FAVICON_URL);
11+
document.head.appendChild(favicon);
12+
}
13+
514
const container = document.getElementById("root") as HTMLElement;
615

716
const root = createRoot(container);

internal/portal/src/scenes/DestinationsList/DestinationList.tsx

+17-17
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,6 @@ const DestinationList: React.FC = () => {
3333
const { data: destinations } = useSWR<Destination[]>("destinations");
3434
const [searchTerm, setSearchTerm] = useState("");
3535

36-
if (!destinations) {
37-
return <div>Loading...</div>;
38-
}
39-
4036
const table_columns = [
4137
{ header: "Type", width: 160 },
4238
{ header: "Target" },
@@ -135,19 +131,23 @@ const DestinationList: React.FC = () => {
135131
</Button>
136132
</div>
137133
</div>
138-
{destinations.length === 0 ? (
139-
<div className="destination-list__empty-state">
140-
<span className="body-m muted">
141-
No event destinations yet. Add your first destination to get
142-
started.
143-
</span>
144-
</div>
145-
) : (
146-
<Table
147-
columns={table_columns}
148-
rows={table_rows}
149-
footer_label="event destinations"
150-
/>
134+
{destinations && (
135+
<>
136+
{destinations.length === 0 ? (
137+
<div className="destination-list__empty-state">
138+
<span className="body-m muted">
139+
No event destinations yet. Add your first destination to get
140+
started.
141+
</span>
142+
</div>
143+
) : (
144+
<Table
145+
columns={table_columns}
146+
rows={table_rows}
147+
footer_label="event destinations"
148+
/>
149+
)}
150+
</>
151151
)}
152152
</div>
153153
);

internal/portal/vite-env.d.ts

+1
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ declare const REFERER_URL: string;
33
declare const LOGO: string;
44
declare const FORCE_THEME: string;
55
declare const TOPICS: string;
6+
declare const FAVICON_URL: string;

internal/portal/vite.config.ts

+1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export default defineConfig(() => {
2323
},
2424
define: {
2525
REFERER_URL: JSON.stringify(process.env.PORTAL_REFERER_URL),
26+
FAVICON_URL: JSON.stringify(process.env.PORTAL_FAVICON_URL),
2627
LOGO: JSON.stringify(process.env.PORTAL_LOGO),
2728
ORGANIZATION_NAME: JSON.stringify(process.env.PORTAL_ORGANIZATION_NAME),
2829
FORCE_THEME: JSON.stringify(process.env.PORTAL_FORCE_THEME),

0 commit comments

Comments
 (0)