From d6306cbcba96953a4fffa524488fdb90ee2d84df Mon Sep 17 00:00:00 2001 From: Aaron Po Date: Wed, 26 Aug 2026 03:40:11 -0400 Subject: [PATCH] Add leaflet-powered brewery map --- package-lock.json | 6 ++ web/frontend/app/components/ClientOnly.tsx | 21 +++++ .../features/breweries/breweries.server.ts | 11 +++ .../breweries/components/BreweryCard.tsx | 26 +++++- .../breweries/components/BreweryMap.tsx | 78 ++++++++++++++++ .../features/breweries/routes/breweries.tsx | 92 ++++++++++++++++--- .../breweries/routes/brewery-detail.tsx | 7 +- .../breweries/utils/format-address.ts | 9 ++ web/frontend/app/hooks/useMediaQuery.ts | 22 +++++ web/frontend/package-lock.json | 51 ++++++++++ web/frontend/package.json | 3 + 11 files changed, 306 insertions(+), 20 deletions(-) create mode 100644 package-lock.json create mode 100644 web/frontend/app/components/ClientOnly.tsx create mode 100644 web/frontend/app/features/breweries/components/BreweryMap.tsx create mode 100644 web/frontend/app/features/breweries/utils/format-address.ts create mode 100644 web/frontend/app/hooks/useMediaQuery.ts diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..ff320062 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "the-biergarten-app", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/web/frontend/app/components/ClientOnly.tsx b/web/frontend/app/components/ClientOnly.tsx new file mode 100644 index 00000000..ace1eaf5 --- /dev/null +++ b/web/frontend/app/components/ClientOnly.tsx @@ -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; diff --git a/web/frontend/app/features/breweries/breweries.server.ts b/web/frontend/app/features/breweries/breweries.server.ts index c10612d1..e5a364c4 100644 --- a/web/frontend/app/features/breweries/breweries.server.ts +++ b/web/frontend/app/features/breweries/breweries.server.ts @@ -7,13 +7,24 @@ interface ApiResponse { 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 { diff --git a/web/frontend/app/features/breweries/components/BreweryCard.tsx b/web/frontend/app/features/breweries/components/BreweryCard.tsx index b1a1a96f..045a0743 100644 --- a/web/frontend/app/features/breweries/components/BreweryCard.tsx +++ b/web/frontend/app/features/breweries/components/BreweryCard.tsx @@ -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) => ( >

{brewery.breweryName}

-

{brewery.description}

+ {brewery.location && ( +

+ {brewery.location.cityName}, {brewery.location.stateProvinceCode} +

+ )} + {brewery.description && ( +

+ {brewery.description} +

+ )} {brewery.location && (

- {brewery.location.addressLine1} - {brewery.location.addressLine2 ? `, ${brewery.location.addressLine2}` : ''} + {formatBreweryAddress(brewery.location)}

)} + {brewery.location?.coordinates && ( + e.stopPropagation()} + className="link link-primary text-sm mt-2 self-start" + > + View on map → + + )}
); diff --git a/web/frontend/app/features/breweries/components/BreweryMap.tsx b/web/frontend/app/features/breweries/components/BreweryMap.tsx new file mode 100644 index 00000000..b2c3c390 --- /dev/null +++ b/web/frontend/app/features/breweries/components/BreweryMap.tsx @@ -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 ( + + + + {breweries.map((brewery) => ( + + +

{brewery.name}

+ {brewery.location && ( +

+ {formatBreweryAddress(brewery.location)} +

+ )} + + View brewery → + +
+
+ ))} +
+ ); +}; + +export default BreweryMap; diff --git a/web/frontend/app/features/breweries/routes/breweries.tsx b/web/frontend/app/features/breweries/routes/breweries.tsx index 48516785..bfe56d91 100644 --- a/web/frontend/app/features/breweries/routes/breweries.tsx +++ b/web/frontend/app/features/breweries/routes/breweries.tsx @@ -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(undefined); + const fetcher = useFetcher(); + const sentinelRef = useRef(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 (
-
+

Breweries

Discover our partner breweries.

- {breweries.length === 0 ? ( -
- No breweries have been posted yet. -
+ }> + {() => ( + }> + + + )} + + + {allBreweries.length === 0 ? ( +

+ No breweries found yet. +

) : ( -
- {breweries.map((brewery) => ( +
+ {allBreweries.map((brewery) => ( ))} + {fetcher.state !== 'idle' && + Array.from({ length: PAGE_SIZE }).map((_, index) => ( +
+ ))}
)} + + {hasMore &&
}
); diff --git a/web/frontend/app/features/breweries/routes/brewery-detail.tsx b/web/frontend/app/features/breweries/routes/brewery-detail.tsx index 494a201c..466458a6 100644 --- a/web/frontend/app/features/breweries/routes/brewery-detail.tsx +++ b/web/frontend/app/features/breweries/routes/brewery-detail.tsx @@ -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) => {

Location

-

{brewery.location.addressLine1}

- {brewery.location.addressLine2 && ( -

{brewery.location.addressLine2}

- )} -

{brewery.location.postalCode}

+

{formatBreweryAddress(brewery.location)}

)} diff --git a/web/frontend/app/features/breweries/utils/format-address.ts b/web/frontend/app/features/breweries/utils/format-address.ts new file mode 100644 index 00000000..d011353f --- /dev/null +++ b/web/frontend/app/features/breweries/utils/format-address.ts @@ -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}`; +}; diff --git a/web/frontend/app/hooks/useMediaQuery.ts b/web/frontend/app/hooks/useMediaQuery.ts new file mode 100644 index 00000000..78540c85 --- /dev/null +++ b/web/frontend/app/hooks/useMediaQuery.ts @@ -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; diff --git a/web/frontend/package-lock.json b/web/frontend/package-lock.json index 3b7282d7..76c0d1b7 100644 --- a/web/frontend/package-lock.json +++ b/web/frontend/package-lock.json @@ -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", diff --git a/web/frontend/package.json b/web/frontend/package.json index 53428261..8c16e650 100644 --- a/web/frontend/package.json +++ b/web/frontend/package.json @@ -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",