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)
This commit is contained in:
2026-08-23 03:26:48 -04:00
committed by Aaron Po
parent bfd45842fe
commit 31da1d557c
10 changed files with 97 additions and 185 deletions

View File

@@ -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

View File

@@ -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<Info & { module: Module, matches: Matches }>;
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"];
}

View File

@@ -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<Info & { module: Module, matches: Matches }>;
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"];
}

View File

@@ -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 (
<div className="min-h-[50vh] flex items-center justify-center p-4">
<div
role="alert"
className="alert alert-error alert-soft max-w-md flex-col items-start gap-2"
>
<span className="font-semibold">{title}</span>
<span>{message}</span>
<button
type="button"
className="btn btn-sm btn-outline mt-2"
onClick={() => window.location.reload()}
>
Try again
</button>
</div>
</div>
);
};
export default RouteErrorState;

View File

@@ -1,3 +1,5 @@
import { data } from 'react-router';
const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:8080';
interface ApiResponse<T> {
@@ -24,32 +26,49 @@ export interface Brewery {
location: BreweryLocation | null;
}
const fetchApi = async (path: string): Promise<Response> => {
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<Brewery[]> => {
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<Brewery[]> = await res.json();
return data.payload;
const body: ApiResponse<Brewery[]> = await res.json();
return body.payload;
};
export const getBreweryById = async (id: string): Promise<Brewery | null> => {
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<Brewery> = await res.json();
return data.payload;
const body: ApiResponse<Brewery> = await res.json();
return body.payload;
};

View File

@@ -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) => (
<RouteErrorState error={error} />
);

View File

@@ -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) => (
<RouteErrorState error={error} />
);

View File

@@ -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<ThemeName>(() => {
if (typeof window === 'undefined') {
return defaultThemeName;
}
const savedTheme = localStorage.getItem(themeStorageKey);
return isBiergartenTheme(savedTheme) ? savedTheme : defaultThemeName;
});
const rootTheme = useRouteLoaderData<typeof rootLoader>('root')?.theme ?? defaultThemeName;
const [selectedTheme, setSelectedTheme] = useState<ThemeName>(rootTheme);
const activeTheme =
biergartenThemes.find((theme) => theme.value === selectedTheme) ?? biergartenThemes[0];

View File

@@ -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;
};

View File

@@ -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<typeof loader>('root')?.theme ?? defaultThemeName;
return (
<html lang="en">
<html lang="en" data-theme={theme}>
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
<script dangerouslySetInnerHTML={{ __html: themeInitScript }} />
</head>
<body>
{children}