Add leaflet-powered brewery map

This commit is contained in:
Aaron Po
2026-08-26 03:40:11 -04:00
parent 4ca35de7ef
commit d6306cbcba
11 changed files with 306 additions and 20 deletions

6
package-lock.json generated Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "the-biergarten-app",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

View File

@@ -0,0 +1,21 @@
import { useSyncExternalStore, type ReactNode } from 'react';
interface ClientOnlyProps {
children: () => ReactNode;
fallback?: ReactNode;
}
const noop = () => undefined;
const emptySubscribe = () => noop;
const ClientOnly = ({ children, fallback = null }: ClientOnlyProps) => {
const mounted = useSyncExternalStore(
emptySubscribe,
() => true,
() => false,
);
return mounted ? children() : fallback;
};
export default ClientOnly;

View File

@@ -7,13 +7,24 @@ interface ApiResponse<T> {
payload: T;
}
export interface BreweryCoordinates {
latitude: number;
longitude: number;
}
export interface BreweryLocation {
breweryPostLocationId: string;
breweryPostId: string;
cityId: string;
cityName: string;
stateProvinceName: string;
stateProvinceCode: string;
countryName: string;
countryCode: string;
addressLine1: string;
addressLine2: string | null;
postalCode: string;
coordinates: BreweryCoordinates | null;
}
export interface Brewery {

View File

@@ -1,5 +1,6 @@
import { Link } from 'react-router';
import type { Brewery } from '../breweries.server';
import { formatBreweryAddress } from '../utils/format-address';
interface BreweryCardProps {
brewery: Brewery;
@@ -12,13 +13,32 @@ const BreweryCard = ({ brewery }: BreweryCardProps) => (
>
<div className="card-body">
<h2 className="card-title">{brewery.breweryName}</h2>
<p className="text-base-content/70 line-clamp-3">{brewery.description}</p>
{brewery.location && (
<p className="text-sm font-medium text-base-content/70">
{brewery.location.cityName}, {brewery.location.stateProvinceCode}
</p>
)}
{brewery.description && (
<p className="text-sm leading-5 text-base-content/70 line-clamp-2 max-h-10 overflow-hidden">
{brewery.description}
</p>
)}
{brewery.location && (
<p className="text-sm text-base-content/50 mt-2">
{brewery.location.addressLine1}
{brewery.location.addressLine2 ? `, ${brewery.location.addressLine2}` : ''}
{formatBreweryAddress(brewery.location)}
</p>
)}
{brewery.location?.coordinates && (
<a
href={`https://www.openstreetmap.org/?mlat=${brewery.location.coordinates.latitude}&mlon=${brewery.location.coordinates.longitude}#map=16/${brewery.location.coordinates.latitude}/${brewery.location.coordinates.longitude}`}
target="_blank"
rel="noreferrer"
onClick={(e) => e.stopPropagation()}
className="link link-primary text-sm mt-2 self-start"
>
View on map &rarr;
</a>
)}
</div>
</Link>
);

View File

@@ -0,0 +1,78 @@
import { useEffect } from 'react';
import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet';
import L, { type LatLngExpression, type LatLngTuple } from 'leaflet';
import iconRetinaUrl from 'leaflet/dist/images/marker-icon-2x.png';
import iconUrl from 'leaflet/dist/images/marker-icon.png';
import shadowUrl from 'leaflet/dist/images/marker-shadow.png';
import 'leaflet/dist/leaflet.css';
import useMediaQuery from '../../../hooks/useMediaQuery';
import type { BreweryLocation } from '../breweries.server';
import { formatBreweryAddress } from '../utils/format-address';
delete (L.Icon.Default.prototype as unknown as { _getIconUrl?: unknown })._getIconUrl;
L.Icon.Default.mergeOptions({ iconRetinaUrl, iconUrl, shadowUrl });
export interface BreweryMapPin {
id: string;
name: string;
latitude: number;
longitude: number;
location: BreweryLocation | null;
}
interface BreweryMapProps {
breweries: BreweryMapPin[];
}
const FALLBACK_CENTER: LatLngExpression = [20, 0];
const FitBounds = ({ bounds }: { bounds: LatLngTuple[] }) => {
const map = useMap();
useEffect(() => {
if (bounds.length > 0) {
map.fitBounds(bounds, { padding: [32, 32] });
}
}, [map, bounds]);
return null;
};
const BreweryMap = ({ breweries }: BreweryMapProps) => {
const bounds: LatLngTuple[] = breweries.map((brewery) => [
brewery.latitude,
brewery.longitude,
]);
return (
<MapContainer
center={bounds[0] ?? FALLBACK_CENTER}
zoom={4}
scrollWheelZoom={false}
className="h-112 md:h-136 w-full rounded-box relative z-0"
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<FitBounds bounds={bounds} />
{breweries.map((brewery) => (
<Marker key={brewery.id} position={[brewery.latitude, brewery.longitude]}>
<Popup>
<p className="font-semibold">{brewery.name}</p>
{brewery.location && (
<p className="text-sm text-base-content/70">
{formatBreweryAddress(brewery.location)}
</p>
)}
<a href={`/breweries/${brewery.id}`} className="link link-primary text-sm">
View brewery &rarr;
</a>
</Popup>
</Marker>
))}
</MapContainer>
);
};
export default BreweryMap;

View File

@@ -1,37 +1,105 @@
import RouteErrorState from '../../../components/ui/error/RouteErrorState';
import ClientOnly from '../../../components/ClientOnly';
import BreweryCard from '../components/BreweryCard';
import { getBreweries } from '../breweries.server';
import { getBreweries, type Brewery } from '../breweries.server';
import type { Route } from './+types/breweries';
import { lazy, Suspense, useEffect, useRef, useState } from 'react';
import { useFetcher } from 'react-router';
import type { BreweryMapPin } from '../components/BreweryMap';
const PAGE_SIZE = 12;
const MAP_PLACEHOLDER_CLASS = 'h-[28rem] md:h-[34rem] w-full rounded-box bg-base-300';
export const meta = ({}: Route.MetaArgs) => {
return [{ title: 'Breweries | The Biergarten App' }];
};
export const loader = async () => {
const breweries = await getBreweries();
return { breweries };
export const loader = async ({ request }: Route.LoaderArgs) => {
const url = new URL(request.url);
const limit = Number(url.searchParams.get('limit')) || PAGE_SIZE;
const offset = Number(url.searchParams.get('offset')) || 0;
const breweries = await getBreweries(limit, offset);
return { breweries, limit, offset };
};
const BreweryMap = lazy(() => import('../components/BreweryMap'));
const toPins = (breweries: Brewery[]): BreweryMapPin[] =>
breweries
.filter((brewery) => brewery.location?.coordinates)
.map((brewery) => ({
id: brewery.breweryPostId,
name: brewery.breweryName,
latitude: brewery.location!.coordinates!.latitude,
longitude: brewery.location!.coordinates!.longitude,
location: brewery.location,
}));
const Breweries = ({ loaderData }: Route.ComponentProps) => {
const { breweries } = loaderData;
const [allBreweries, setAllBreweries] = useState(loaderData.breweries);
const [offset, setOffset] = useState(loaderData.breweries.length);
const [hasMore, setHasMore] = useState(loaderData.breweries.length === loaderData.limit);
const [loadedPage, setLoadedPage] = useState<typeof loaderData | undefined>(undefined);
const fetcher = useFetcher<typeof loader>();
const sentinelRef = useRef<HTMLDivElement>(null);
if (fetcher.data && fetcher.data !== loadedPage) {
setLoadedPage(fetcher.data);
setAllBreweries((prev) => [...prev, ...fetcher.data!.breweries]);
setOffset((prev) => prev + fetcher.data!.breweries.length);
setHasMore(fetcher.data.breweries.length === PAGE_SIZE);
}
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel || !hasMore) return;
const observer = new IntersectionObserver((entries) => {
if (entries[0]?.isIntersecting && fetcher.state === 'idle') {
fetcher.load(`/breweries?limit=${PAGE_SIZE}&offset=${offset}`);
}
});
observer.observe(sentinel);
return () => observer.disconnect();
}, [hasMore, offset, fetcher]);
const pins = toPins(allBreweries);
return (
<div className="min-h-screen bg-base-200">
<div className="container mx-auto p-4">
<div className="container mx-auto p-14">
<h1 className="text-4xl font-bold mb-4">Breweries</h1>
<p className="text-base-content/70 mb-6">Discover our partner breweries.</p>
{breweries.length === 0 ? (
<div className="alert alert-info alert-soft">
<span>No breweries have been posted yet.</span>
</div>
<ClientOnly fallback={<div className={MAP_PLACEHOLDER_CLASS} />}>
{() => (
<Suspense fallback={<div className={MAP_PLACEHOLDER_CLASS} />}>
<BreweryMap breweries={pins} />
</Suspense>
)}
</ClientOnly>
{allBreweries.length === 0 ? (
<p className="text-center text-base-content/60 mt-12">
No breweries found yet.
</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{breweries.map((brewery) => (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mt-8">
{allBreweries.map((brewery) => (
<BreweryCard key={brewery.breweryPostId} brewery={brewery} />
))}
{fetcher.state !== 'idle' &&
Array.from({ length: PAGE_SIZE }).map((_, index) => (
<div
key={index}
className="skeleton h-32 w-full rounded-box"
/>
))}
</div>
)}
{hasMore && <div ref={sentinelRef} className="h-1" />}
</div>
</div>
);

View File

@@ -1,6 +1,7 @@
import { data, Link } from 'react-router';
import RouteErrorState from '../../../components/ui/error/RouteErrorState';
import { getBreweryById } from '../breweries.server';
import { formatBreweryAddress } from '../utils/format-address';
import type { Route } from './+types/brewery-detail';
export const loader = async ({ params }: Route.LoaderArgs) => {
@@ -37,11 +38,7 @@ const BreweryDetail = ({ loaderData }: Route.ComponentProps) => {
<p className="text-xs font-semibold uppercase tracking-widest text-base-content/50 mb-2">
Location
</p>
<p>{brewery.location.addressLine1}</p>
{brewery.location.addressLine2 && (
<p>{brewery.location.addressLine2}</p>
)}
<p>{brewery.location.postalCode}</p>
<p>{formatBreweryAddress(brewery.location)}</p>
</div>
)}

View File

@@ -0,0 +1,9 @@
import type { BreweryLocation } from '../breweries.server';
export const formatBreweryAddress = (location: BreweryLocation): string => {
const streetLine = location.addressLine2
? `${location.addressLine1}, ${location.addressLine2}`
: location.addressLine1;
return `${streetLine}, ${location.cityName}, ${location.stateProvinceCode} ${location.postalCode}, ${location.countryName}`;
};

View File

@@ -0,0 +1,22 @@
import { useSyncExternalStore } from 'react';
const subscribe = (query: string) => (callback: () => void) => {
const media = window.matchMedia(query);
media.addEventListener('change', callback);
return () => media.removeEventListener('change', callback);
};
/**
* Tracks whether a CSS media query currently matches the viewport, updating on change.
*
* @example
* const isSmallScreen = useMediaQuery('(max-width: 640px)');
*/
const useMediaQuery = (query: `(${string})`) =>
useSyncExternalStore(
subscribe(query),
() => window.matchMedia(query).matches,
() => false,
);
export default useMediaQuery;

View File

@@ -15,10 +15,12 @@
"@react-router/node": "^8.3.0",
"iconoir-react": "^7.11.1",
"isbot": "^5.2.1",
"leaflet": "^1.9.4",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-hook-form": "^7.86.0",
"react-hot-toast": "^2.6.0",
"react-leaflet": "^5.0.0-rc.2",
"react-router": "^8.3.0",
"zod": "^4.4.3"
},
@@ -33,6 +35,7 @@
"@storybook/react-vite": "^10.2.19",
"@tailwindcss/postcss": "^4.2.1",
"@types/express": "^5.0.6",
"@types/leaflet": "^1.9.22",
"@types/node": "^25.5.2",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
@@ -2301,6 +2304,17 @@
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@react-leaflet/core": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
"integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==",
"license": "Hippocratic-2.1",
"peerDependencies": {
"leaflet": "^1.9.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
},
"node_modules/@react-router/dev": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/@react-router/dev/-/dev-8.3.0.tgz",
@@ -3558,6 +3572,13 @@
"@types/send": "*"
}
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/http-errors": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
@@ -3572,6 +3593,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/leaflet": {
"version": "1.9.22",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.22.tgz",
"integrity": "sha512-h3lhECYEKDasG7LFHu+GiHqAvsgLuQvlJvVZzJDGONo3sEL+wUOqSFLnwkZlK0qVxnxbuGFW8iBlJNYs5wgndA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/mdx": {
"version": "2.0.14",
"resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.14.tgz",
@@ -7685,6 +7716,12 @@
"json-buffer": "3.0.1"
}
},
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
},
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -9061,6 +9098,20 @@
"dev": true,
"license": "MIT"
},
"node_modules/react-leaflet": {
"version": "5.0.0-rc.2",
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0-rc.2.tgz",
"integrity": "sha512-1xQGYG9mEIW+nfkQhqgHImwUuB1UDlnzYFSzv6PrBFDBeYrFmv0BbpwpNAFdJg/UQ2yz5UZSL7ZwlUxjwb8MZw==",
"license": "Hippocratic-2.1",
"dependencies": {
"@react-leaflet/core": "^3.0.0-rc.2"
},
"peerDependencies": {
"leaflet": "^1.9.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
},
"node_modules/react-refresh": {
"version": "0.18.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",

View File

@@ -25,10 +25,12 @@
"@react-router/node": "^8.3.0",
"iconoir-react": "^7.11.1",
"isbot": "^5.2.1",
"leaflet": "^1.9.4",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-hook-form": "^7.86.0",
"react-hot-toast": "^2.6.0",
"react-leaflet": "^5.0.0-rc.2",
"react-router": "^8.3.0",
"zod": "^4.4.3"
},
@@ -43,6 +45,7 @@
"@storybook/react-vite": "^10.2.19",
"@tailwindcss/postcss": "^4.2.1",
"@types/express": "^5.0.6",
"@types/leaflet": "^1.9.22",
"@types/node": "^25.5.2",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",