From 581663480948c727f0d2cd0616da16cd5a789a7b Mon Sep 17 00:00:00 2001 From: Aaron Po Date: Sun, 23 Aug 2026 03:26:48 -0400 Subject: [PATCH] Handle fetch fail in client app, fix data binding in location data (#243) * Improve error handling for server rendered routes * Fix data binding issue for country/state codes (use alias in sql) --- .../Repository/BreweryRepository.cs | 8 +-- .../types/app/routes/+types/account.ts | 71 ------------------- .../types/app/routes/+types/brewery-detail.ts | 71 ------------------- .../components/ui/error/RouteErrorState.tsx | 35 +++++++++ .../features/breweries/breweries.server.ts | 35 ++++++--- .../features/breweries/routes/breweries.tsx | 5 ++ .../breweries/routes/brewery-detail.tsx | 7 +- .../theme/components/ThemeSwitcher.tsx | 22 ++---- web/frontend/app/features/theme/themes.ts | 8 ++- web/frontend/app/root.tsx | 20 ++---- 10 files changed, 97 insertions(+), 185 deletions(-) delete mode 100644 web/frontend/.react-router/types/app/routes/+types/account.ts delete mode 100644 web/frontend/.react-router/types/app/routes/+types/brewery-detail.ts create mode 100644 web/frontend/app/components/ui/error/RouteErrorState.tsx diff --git a/web/backend/Features/Features.Breweries/Repository/BreweryRepository.cs b/web/backend/Features/Features.Breweries/Repository/BreweryRepository.cs index ff975477..1d5b5d6d 100644 --- a/web/backend/Features/Features.Breweries/Repository/BreweryRepository.cs +++ b/web/backend/Features/Features.Breweries/Repository/BreweryRepository.cs @@ -50,10 +50,10 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) c.CityName, sp.StateProvinceID, sp.StateProvinceName, - sp.ISO3166_2, + sp.ISO3166_2 AS Iso31662, co.CountryID, co.CountryName, - co.ISO3166_1 + co.ISO3166_1 AS Iso31661 FROM dbo.BreweryPost bp LEFT JOIN dbo.BreweryPostLocation bpl ON bp.BreweryPostID = bpl.BreweryPostID LEFT JOIN dbo.City c ON bpl.CityID = c.CityID @@ -100,10 +100,10 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) c.CityName, sp.StateProvinceID, sp.StateProvinceName, - sp.ISO3166_2, + sp.ISO3166_2 AS Iso31662, co.CountryID, co.CountryName, - co.ISO3166_1 + co.ISO3166_1 AS Iso31661 FROM dbo.BreweryPost bp LEFT JOIN dbo.BreweryPostLocation bpl ON bp.BreweryPostID = bpl.BreweryPostID LEFT JOIN dbo.City c ON bpl.CityID = c.CityID diff --git a/web/frontend/.react-router/types/app/routes/+types/account.ts b/web/frontend/.react-router/types/app/routes/+types/account.ts deleted file mode 100644 index 17f37b01..00000000 --- a/web/frontend/.react-router/types/app/routes/+types/account.ts +++ /dev/null @@ -1,71 +0,0 @@ -// Generated by React Router - -import type { GetInfo, GetAnnotations } from "react-router/internal"; - -type Module = typeof import("../account.js") - -type Info = GetInfo<{ - file: "routes/account.tsx", - module: Module -}> - -type Matches = [{ - id: "root"; - module: typeof import("../../root.js"); -}, { - id: "routes/account"; - module: typeof import("../account.js"); -}]; - -type Annotations = GetAnnotations; - -export namespace Route { - // links - export type LinkDescriptors = Annotations["LinkDescriptors"]; - export type LinksFunction = Annotations["LinksFunction"]; - - // meta - export type MetaArgs = Annotations["MetaArgs"]; - export type MetaDescriptors = Annotations["MetaDescriptors"]; - export type MetaFunction = Annotations["MetaFunction"]; - - // headers - export type HeadersArgs = Annotations["HeadersArgs"]; - export type HeadersFunction = Annotations["HeadersFunction"]; - - // middleware - export type MiddlewareFunction = Annotations["MiddlewareFunction"]; - - // clientMiddleware - export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"]; - - // loader - export type LoaderArgs = Annotations["LoaderArgs"]; - - // clientLoader - export type ClientLoaderArgs = Annotations["ClientLoaderArgs"]; - - // action - export type ActionArgs = Annotations["ActionArgs"]; - - // clientAction - export type ClientActionArgs = Annotations["ClientActionArgs"]; - - // HydrateFallback - export type HydrateFallbackProps = Annotations["HydrateFallbackProps"]; - - // ServerHydrateFallback - export type ServerHydrateFallbackProps = Annotations["ServerHydrateFallbackProps"]; - - // Component - export type ComponentProps = Annotations["ComponentProps"]; - - // ServerComponent - export type ServerComponentProps = Annotations["ServerComponentProps"]; - - // ErrorBoundary - export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"]; - - // ServerErrorBoundary - export type ServerErrorBoundaryProps = Annotations["ServerErrorBoundaryProps"]; -} \ No newline at end of file diff --git a/web/frontend/.react-router/types/app/routes/+types/brewery-detail.ts b/web/frontend/.react-router/types/app/routes/+types/brewery-detail.ts deleted file mode 100644 index 54aeca27..00000000 --- a/web/frontend/.react-router/types/app/routes/+types/brewery-detail.ts +++ /dev/null @@ -1,71 +0,0 @@ -// Generated by React Router - -import type { GetInfo, GetAnnotations } from "react-router/internal"; - -type Module = typeof import("../brewery-detail.js") - -type Info = GetInfo<{ - file: "routes/brewery-detail.tsx", - module: Module -}> - -type Matches = [{ - id: "root"; - module: typeof import("../../root.js"); -}, { - id: "routes/brewery-detail"; - module: typeof import("../brewery-detail.js"); -}]; - -type Annotations = GetAnnotations; - -export namespace Route { - // links - export type LinkDescriptors = Annotations["LinkDescriptors"]; - export type LinksFunction = Annotations["LinksFunction"]; - - // meta - export type MetaArgs = Annotations["MetaArgs"]; - export type MetaDescriptors = Annotations["MetaDescriptors"]; - export type MetaFunction = Annotations["MetaFunction"]; - - // headers - export type HeadersArgs = Annotations["HeadersArgs"]; - export type HeadersFunction = Annotations["HeadersFunction"]; - - // middleware - export type MiddlewareFunction = Annotations["MiddlewareFunction"]; - - // clientMiddleware - export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"]; - - // loader - export type LoaderArgs = Annotations["LoaderArgs"]; - - // clientLoader - export type ClientLoaderArgs = Annotations["ClientLoaderArgs"]; - - // action - export type ActionArgs = Annotations["ActionArgs"]; - - // clientAction - export type ClientActionArgs = Annotations["ClientActionArgs"]; - - // HydrateFallback - export type HydrateFallbackProps = Annotations["HydrateFallbackProps"]; - - // ServerHydrateFallback - export type ServerHydrateFallbackProps = Annotations["ServerHydrateFallbackProps"]; - - // Component - export type ComponentProps = Annotations["ComponentProps"]; - - // ServerComponent - export type ServerComponentProps = Annotations["ServerComponentProps"]; - - // ErrorBoundary - export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"]; - - // ServerErrorBoundary - export type ServerErrorBoundaryProps = Annotations["ServerErrorBoundaryProps"]; -} \ No newline at end of file diff --git a/web/frontend/app/components/ui/error/RouteErrorState.tsx b/web/frontend/app/components/ui/error/RouteErrorState.tsx new file mode 100644 index 00000000..0528c4b1 --- /dev/null +++ b/web/frontend/app/components/ui/error/RouteErrorState.tsx @@ -0,0 +1,35 @@ +import { isRouteErrorResponse } from 'react-router'; + +const RouteErrorState = ({ error }: { error: unknown }) => { + let title = 'Something went wrong'; + let message = 'An unexpected error occurred.'; + + if (isRouteErrorResponse(error)) { + title = error.status === 503 ? 'Service unavailable' : `Error ${error.status}`; + message = + typeof error.data === 'string' && error.data ? error.data : error.statusText || message; + } else if (import.meta.env.DEV && error instanceof Error) { + message = error.message; + } + + return ( +
+
+ {title} + {message} + +
+
+ ); +}; + +export default RouteErrorState; diff --git a/web/frontend/app/features/breweries/breweries.server.ts b/web/frontend/app/features/breweries/breweries.server.ts index 201d98fd..c10612d1 100644 --- a/web/frontend/app/features/breweries/breweries.server.ts +++ b/web/frontend/app/features/breweries/breweries.server.ts @@ -1,3 +1,5 @@ +import { data } from 'react-router'; + const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:8080'; interface ApiResponse { @@ -24,32 +26,49 @@ export interface Brewery { location: BreweryLocation | null; } +const fetchApi = async (path: string): Promise => { + try { + return await fetch(`${API_BASE_URL}${path}`); + } catch { + throw data('The brewery service is unreachable right now. Please try again in a moment.', { + status: 503, + statusText: 'Service Unavailable', + }); + } +}; + export const getBreweries = async (limit?: number, offset?: number): Promise => { const params = new URLSearchParams(); if (limit !== undefined) params.set('limit', String(limit)); if (offset !== undefined) params.set('offset', String(offset)); - const res = await fetch(`${API_BASE_URL}/api/brewery?${params}`); + const res = await fetchApi(`/api/brewery?${params}`); if (!res.ok) { - throw new Error(`Failed to load breweries (${res.status})`); + throw data(`Failed to load breweries (${res.status}).`, { + status: res.status, + statusText: res.statusText, + }); } - const data: ApiResponse = await res.json(); - return data.payload; + const body: ApiResponse = await res.json(); + return body.payload; }; export const getBreweryById = async (id: string): Promise => { - const res = await fetch(`${API_BASE_URL}/api/brewery/${encodeURIComponent(id)}`); + const res = await fetchApi(`/api/brewery/${encodeURIComponent(id)}`); if (res.status === 404) { return null; } if (!res.ok) { - throw new Error(`Failed to load brewery (${res.status})`); + throw data(`Failed to load brewery (${res.status}).`, { + status: res.status, + statusText: res.statusText, + }); } - const data: ApiResponse = await res.json(); - return data.payload; + const body: ApiResponse = await res.json(); + return body.payload; }; diff --git a/web/frontend/app/features/breweries/routes/breweries.tsx b/web/frontend/app/features/breweries/routes/breweries.tsx index 987645da..48516785 100644 --- a/web/frontend/app/features/breweries/routes/breweries.tsx +++ b/web/frontend/app/features/breweries/routes/breweries.tsx @@ -1,3 +1,4 @@ +import RouteErrorState from '../../../components/ui/error/RouteErrorState'; import BreweryCard from '../components/BreweryCard'; import { getBreweries } from '../breweries.server'; import type { Route } from './+types/breweries'; @@ -37,3 +38,7 @@ const Breweries = ({ loaderData }: Route.ComponentProps) => { }; export default Breweries; + +export const ErrorBoundary = ({ error }: Route.ErrorBoundaryProps) => ( + +); diff --git a/web/frontend/app/features/breweries/routes/brewery-detail.tsx b/web/frontend/app/features/breweries/routes/brewery-detail.tsx index 45de9ba0..494a201c 100644 --- a/web/frontend/app/features/breweries/routes/brewery-detail.tsx +++ b/web/frontend/app/features/breweries/routes/brewery-detail.tsx @@ -1,11 +1,12 @@ import { data, Link } from 'react-router'; +import RouteErrorState from '../../../components/ui/error/RouteErrorState'; import { getBreweryById } from '../breweries.server'; import type { Route } from './+types/brewery-detail'; export const loader = async ({ params }: Route.LoaderArgs) => { const brewery = await getBreweryById(params.id); if (!brewery) { - throw data('Brewery not found.', { status: 404 }); + throw data('Brewery not found.', { status: 404, statusText: 'Not Found' }); } return { brewery }; @@ -57,3 +58,7 @@ const BreweryDetail = ({ loaderData }: Route.ComponentProps) => { }; export default BreweryDetail; + +export const ErrorBoundary = ({ error }: Route.ErrorBoundaryProps) => ( + +); diff --git a/web/frontend/app/features/theme/components/ThemeSwitcher.tsx b/web/frontend/app/features/theme/components/ThemeSwitcher.tsx index 60c9f5bc..58f3ee92 100644 --- a/web/frontend/app/features/theme/components/ThemeSwitcher.tsx +++ b/web/frontend/app/features/theme/components/ThemeSwitcher.tsx @@ -1,26 +1,16 @@ import { useState } from 'react'; -import { - biergartenThemes, - defaultThemeName, - isBiergartenTheme, - type ThemeName, - themeStorageKey, -} from '../themes'; +import { useRouteLoaderData } from 'react-router'; +import type { loader as rootLoader } from '../../../root'; +import { biergartenThemes, defaultThemeName, themeCookieName, type ThemeName } from '../themes'; const applyTheme = (theme: ThemeName) => { document.documentElement.setAttribute('data-theme', theme); - localStorage.setItem(themeStorageKey, theme); + document.cookie = `${themeCookieName}=${theme}; path=/; max-age=${60 * 60 * 24 * 365}; SameSite=Lax`; }; const ThemeSwitcher = () => { - const [selectedTheme, setSelectedTheme] = useState(() => { - if (typeof window === 'undefined') { - return defaultThemeName; - } - - const savedTheme = localStorage.getItem(themeStorageKey); - return isBiergartenTheme(savedTheme) ? savedTheme : defaultThemeName; - }); + const rootTheme = useRouteLoaderData('root')?.theme ?? defaultThemeName; + const [selectedTheme, setSelectedTheme] = useState(rootTheme); const activeTheme = biergartenThemes.find((theme) => theme.value === selectedTheme) ?? biergartenThemes[0]; diff --git a/web/frontend/app/features/theme/themes.ts b/web/frontend/app/features/theme/themes.ts index 14bc9f1e..70a3b54a 100644 --- a/web/frontend/app/features/theme/themes.ts +++ b/web/frontend/app/features/theme/themes.ts @@ -8,7 +8,7 @@ export interface ThemeOption { } export const defaultThemeName: ThemeName = 'biergarten-lager'; -export const themeStorageKey = 'biergarten-theme'; +export const themeCookieName = 'biergarten-theme'; export const biergartenThemes: ThemeOption[] = [ { @@ -36,3 +36,9 @@ export const biergartenThemes: ThemeOption[] = [ export const isBiergartenTheme = (value: string | null | undefined): value is ThemeName => { return biergartenThemes.some((theme) => theme.value === value); }; + +export const parseThemeCookie = (cookieHeader: string | null): ThemeName => { + const match = cookieHeader?.match(new RegExp(`(?:^|; )${themeCookieName}=([^;]*)`)); + const value = match ? decodeURIComponent(match[1]) : null; + return isBiergartenTheme(value) ? value : defaultThemeName; +}; diff --git a/web/frontend/app/root.tsx b/web/frontend/app/root.tsx index ee2937d8..ad4b3a59 100644 --- a/web/frontend/app/root.tsx +++ b/web/frontend/app/root.tsx @@ -5,6 +5,7 @@ import { Outlet, Scripts, ScrollRestoration, + useRouteLoaderData, } from 'react-router'; import type { Route } from './+types/root'; @@ -12,16 +13,7 @@ import './app.css'; import Navbar from './components/ui/navbar/Navbar'; import ToastProvider from './components/ui/toast/ToastProvider'; import { getOptionalAuth } from './features/auth/auth.server'; -import { themeStorageKey } from './features/theme/themes'; - -const themeInitScript = ` - (function () { - try { - var theme = localStorage.getItem(${JSON.stringify(themeStorageKey)}); - if (theme) document.documentElement.setAttribute('data-theme', theme); - } catch (e) {} - })(); -`; +import { defaultThemeName, parseThemeCookie } from './features/theme/themes'; export const links: Route.LinksFunction = () => [ { rel: 'preconnect', href: 'https://fonts.googleapis.com' }, @@ -38,18 +30,20 @@ export const links: Route.LinksFunction = () => [ export const loader = async ({ request }: Route.LoaderArgs) => { const auth = await getOptionalAuth(request); - return { auth }; + const theme = parseThemeCookie(request.headers.get('Cookie')); + return { auth, theme }; }; export const Layout = ({ children }: { children: React.ReactNode }) => { + const theme = useRouteLoaderData('root')?.theme ?? defaultThemeName; + return ( - + -