mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-15 10:04:44 +00:00
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:
@@ -50,10 +50,10 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
c.CityName,
|
c.CityName,
|
||||||
sp.StateProvinceID,
|
sp.StateProvinceID,
|
||||||
sp.StateProvinceName,
|
sp.StateProvinceName,
|
||||||
sp.ISO3166_2,
|
sp.ISO3166_2 AS Iso31662,
|
||||||
co.CountryID,
|
co.CountryID,
|
||||||
co.CountryName,
|
co.CountryName,
|
||||||
co.ISO3166_1
|
co.ISO3166_1 AS Iso31661
|
||||||
FROM dbo.BreweryPost bp
|
FROM dbo.BreweryPost bp
|
||||||
LEFT JOIN dbo.BreweryPostLocation bpl ON bp.BreweryPostID = bpl.BreweryPostID
|
LEFT JOIN dbo.BreweryPostLocation bpl ON bp.BreweryPostID = bpl.BreweryPostID
|
||||||
LEFT JOIN dbo.City c ON bpl.CityID = c.CityID
|
LEFT JOIN dbo.City c ON bpl.CityID = c.CityID
|
||||||
@@ -100,10 +100,10 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
c.CityName,
|
c.CityName,
|
||||||
sp.StateProvinceID,
|
sp.StateProvinceID,
|
||||||
sp.StateProvinceName,
|
sp.StateProvinceName,
|
||||||
sp.ISO3166_2,
|
sp.ISO3166_2 AS Iso31662,
|
||||||
co.CountryID,
|
co.CountryID,
|
||||||
co.CountryName,
|
co.CountryName,
|
||||||
co.ISO3166_1
|
co.ISO3166_1 AS Iso31661
|
||||||
FROM dbo.BreweryPost bp
|
FROM dbo.BreweryPost bp
|
||||||
LEFT JOIN dbo.BreweryPostLocation bpl ON bp.BreweryPostID = bpl.BreweryPostID
|
LEFT JOIN dbo.BreweryPostLocation bpl ON bp.BreweryPostID = bpl.BreweryPostID
|
||||||
LEFT JOIN dbo.City c ON bpl.CityID = c.CityID
|
LEFT JOIN dbo.City c ON bpl.CityID = c.CityID
|
||||||
|
|||||||
@@ -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"];
|
|
||||||
}
|
|
||||||
@@ -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"];
|
|
||||||
}
|
|
||||||
35
web/frontend/app/components/ui/error/RouteErrorState.tsx
Normal file
35
web/frontend/app/components/ui/error/RouteErrorState.tsx
Normal 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;
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { data } from 'react-router';
|
||||||
|
|
||||||
const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:8080';
|
const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:8080';
|
||||||
|
|
||||||
interface ApiResponse<T> {
|
interface ApiResponse<T> {
|
||||||
@@ -24,32 +26,49 @@ export interface Brewery {
|
|||||||
location: BreweryLocation | null;
|
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[]> => {
|
export const getBreweries = async (limit?: number, offset?: number): Promise<Brewery[]> => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (limit !== undefined) params.set('limit', String(limit));
|
if (limit !== undefined) params.set('limit', String(limit));
|
||||||
if (offset !== undefined) params.set('offset', String(offset));
|
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) {
|
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();
|
const body: ApiResponse<Brewery[]> = await res.json();
|
||||||
return data.payload;
|
return body.payload;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getBreweryById = async (id: string): Promise<Brewery | null> => {
|
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) {
|
if (res.status === 404) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!res.ok) {
|
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();
|
const body: ApiResponse<Brewery> = await res.json();
|
||||||
return data.payload;
|
return body.payload;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import RouteErrorState from '../../../components/ui/error/RouteErrorState';
|
||||||
import BreweryCard from '../components/BreweryCard';
|
import BreweryCard from '../components/BreweryCard';
|
||||||
import { getBreweries } from '../breweries.server';
|
import { getBreweries } from '../breweries.server';
|
||||||
import type { Route } from './+types/breweries';
|
import type { Route } from './+types/breweries';
|
||||||
@@ -37,3 +38,7 @@ const Breweries = ({ loaderData }: Route.ComponentProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default Breweries;
|
export default Breweries;
|
||||||
|
|
||||||
|
export const ErrorBoundary = ({ error }: Route.ErrorBoundaryProps) => (
|
||||||
|
<RouteErrorState error={error} />
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { data, Link } from 'react-router';
|
import { data, Link } from 'react-router';
|
||||||
|
import RouteErrorState from '../../../components/ui/error/RouteErrorState';
|
||||||
import { getBreweryById } from '../breweries.server';
|
import { getBreweryById } from '../breweries.server';
|
||||||
import type { Route } from './+types/brewery-detail';
|
import type { Route } from './+types/brewery-detail';
|
||||||
|
|
||||||
export const loader = async ({ params }: Route.LoaderArgs) => {
|
export const loader = async ({ params }: Route.LoaderArgs) => {
|
||||||
const brewery = await getBreweryById(params.id);
|
const brewery = await getBreweryById(params.id);
|
||||||
if (!brewery) {
|
if (!brewery) {
|
||||||
throw data('Brewery not found.', { status: 404 });
|
throw data('Brewery not found.', { status: 404, statusText: 'Not Found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
return { brewery };
|
return { brewery };
|
||||||
@@ -57,3 +58,7 @@ const BreweryDetail = ({ loaderData }: Route.ComponentProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default BreweryDetail;
|
export default BreweryDetail;
|
||||||
|
|
||||||
|
export const ErrorBoundary = ({ error }: Route.ErrorBoundaryProps) => (
|
||||||
|
<RouteErrorState error={error} />
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,26 +1,16 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import { useRouteLoaderData } from 'react-router';
|
||||||
biergartenThemes,
|
import type { loader as rootLoader } from '../../../root';
|
||||||
defaultThemeName,
|
import { biergartenThemes, defaultThemeName, themeCookieName, type ThemeName } from '../themes';
|
||||||
isBiergartenTheme,
|
|
||||||
type ThemeName,
|
|
||||||
themeStorageKey,
|
|
||||||
} from '../themes';
|
|
||||||
|
|
||||||
const applyTheme = (theme: ThemeName) => {
|
const applyTheme = (theme: ThemeName) => {
|
||||||
document.documentElement.setAttribute('data-theme', theme);
|
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 ThemeSwitcher = () => {
|
||||||
const [selectedTheme, setSelectedTheme] = useState<ThemeName>(() => {
|
const rootTheme = useRouteLoaderData<typeof rootLoader>('root')?.theme ?? defaultThemeName;
|
||||||
if (typeof window === 'undefined') {
|
const [selectedTheme, setSelectedTheme] = useState<ThemeName>(rootTheme);
|
||||||
return defaultThemeName;
|
|
||||||
}
|
|
||||||
|
|
||||||
const savedTheme = localStorage.getItem(themeStorageKey);
|
|
||||||
return isBiergartenTheme(savedTheme) ? savedTheme : defaultThemeName;
|
|
||||||
});
|
|
||||||
|
|
||||||
const activeTheme =
|
const activeTheme =
|
||||||
biergartenThemes.find((theme) => theme.value === selectedTheme) ?? biergartenThemes[0];
|
biergartenThemes.find((theme) => theme.value === selectedTheme) ?? biergartenThemes[0];
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export interface ThemeOption {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const defaultThemeName: ThemeName = 'biergarten-lager';
|
export const defaultThemeName: ThemeName = 'biergarten-lager';
|
||||||
export const themeStorageKey = 'biergarten-theme';
|
export const themeCookieName = 'biergarten-theme';
|
||||||
|
|
||||||
export const biergartenThemes: ThemeOption[] = [
|
export const biergartenThemes: ThemeOption[] = [
|
||||||
{
|
{
|
||||||
@@ -36,3 +36,9 @@ export const biergartenThemes: ThemeOption[] = [
|
|||||||
export const isBiergartenTheme = (value: string | null | undefined): value is ThemeName => {
|
export const isBiergartenTheme = (value: string | null | undefined): value is ThemeName => {
|
||||||
return biergartenThemes.some((theme) => theme.value === value);
|
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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Outlet,
|
Outlet,
|
||||||
Scripts,
|
Scripts,
|
||||||
ScrollRestoration,
|
ScrollRestoration,
|
||||||
|
useRouteLoaderData,
|
||||||
} from 'react-router';
|
} from 'react-router';
|
||||||
|
|
||||||
import type { Route } from './+types/root';
|
import type { Route } from './+types/root';
|
||||||
@@ -12,16 +13,7 @@ import './app.css';
|
|||||||
import Navbar from './components/ui/navbar/Navbar';
|
import Navbar from './components/ui/navbar/Navbar';
|
||||||
import ToastProvider from './components/ui/toast/ToastProvider';
|
import ToastProvider from './components/ui/toast/ToastProvider';
|
||||||
import { getOptionalAuth } from './features/auth/auth.server';
|
import { getOptionalAuth } from './features/auth/auth.server';
|
||||||
import { themeStorageKey } from './features/theme/themes';
|
import { defaultThemeName, parseThemeCookie } 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) {}
|
|
||||||
})();
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const links: Route.LinksFunction = () => [
|
export const links: Route.LinksFunction = () => [
|
||||||
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
|
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
|
||||||
@@ -38,18 +30,20 @@ export const links: Route.LinksFunction = () => [
|
|||||||
|
|
||||||
export const loader = async ({ request }: Route.LoaderArgs) => {
|
export const loader = async ({ request }: Route.LoaderArgs) => {
|
||||||
const auth = await getOptionalAuth(request);
|
const auth = await getOptionalAuth(request);
|
||||||
return { auth };
|
const theme = parseThemeCookie(request.headers.get('Cookie'));
|
||||||
|
return { auth, theme };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Layout = ({ children }: { children: React.ReactNode }) => {
|
export const Layout = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
const theme = useRouteLoaderData<typeof loader>('root')?.theme ?? defaultThemeName;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en" data-theme={theme}>
|
||||||
<head>
|
<head>
|
||||||
<meta charSet="utf-8" />
|
<meta charSet="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<Meta />
|
<Meta />
|
||||||
<Links />
|
<Links />
|
||||||
<script dangerouslySetInnerHTML={{ __html: themeInitScript }} />
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
Reference in New Issue
Block a user