diff --git a/web/frontend/app/features/breweries/components/FeaturedBreweryCard.tsx b/web/frontend/app/features/breweries/components/FeaturedBreweryCard.tsx
new file mode 100644
index 00000000..4bc2f2a6
--- /dev/null
+++ b/web/frontend/app/features/breweries/components/FeaturedBreweryCard.tsx
@@ -0,0 +1,60 @@
+import { Link } from 'react-router';
+import type { Brewery } from '../breweries.server';
+import { formatBreweryAddress } from '../utils/format-address';
+
+interface FeaturedBreweryCardProps {
+ brewery: Brewery;
+ onShowOnMap: () => void;
+}
+
+// Founded year, beers-listed count, and house style aren't tracked anywhere yet -
+// see BREWERY_INDEX_HANDOFF.md. Filler until those fields/queries exist.
+const FILLER_STATS = [
+ { label: 'Founded', value: '2014' },
+ { label: 'Beers listed', value: '18' },
+ { label: 'House style', value: 'Seasonal ale' },
+];
+
+const FeaturedBreweryCard = ({ brewery, onShowOnMap }: FeaturedBreweryCardProps) => (
+
+
+
+ {brewery.location && (
+
+ {brewery.location.cityName}, {brewery.location.stateProvinceCode}
+
+ )}
+
{brewery.breweryName}
+
{brewery.description}
+ {brewery.location && (
+
+ {formatBreweryAddress(brewery.location)}
+
+ )}
+
+
+ View brewery
+
+ {brewery.location?.coordinates && (
+
+ )}
+
+
+
+
+ {FILLER_STATS.map((stat) => (
+
+
+ {stat.label}
+
+
{stat.value}
+
+ ))}
+
+
+
+);
+
+export default FeaturedBreweryCard;
diff --git a/web/frontend/app/features/breweries/components/NearbyBreweriesSection.tsx b/web/frontend/app/features/breweries/components/NearbyBreweriesSection.tsx
new file mode 100644
index 00000000..23d3c0c7
--- /dev/null
+++ b/web/frontend/app/features/breweries/components/NearbyBreweriesSection.tsx
@@ -0,0 +1,187 @@
+import { lazy, Suspense, useEffect, useState } from 'react';
+import { useFetcher } from 'react-router';
+import type { LatLngTuple } from 'leaflet';
+import ClientOnly from '../../../components/ClientOnly';
+import type { SimplifiedBrewery } from '../breweries.server';
+import type { loader as nearbyLoader } from '../routes/breweries-nearby';
+import { formatDistance, haversineDistanceMetres } from '../utils/distance';
+import type { NearbyMapPin } from './NearbyBreweryMap';
+
+const NearbyBreweryMap = lazy(() => import('./NearbyBreweryMap'));
+
+const MAP_PLACEHOLDER_CLASS = 'min-h-[30rem] h-full w-full rounded-box bg-base-300';
+const RADIUS_METRES = 120_000;
+
+export interface FeaturedPin {
+ id: string;
+ name: string;
+ latitude: number;
+ longitude: number;
+}
+
+interface FallbackCenter {
+ latitude: number;
+ longitude: number;
+ label: string;
+}
+
+interface NearbyBreweriesSectionProps {
+ featured: FeaturedPin | null;
+ fallbackCenter: FallbackCenter | null;
+ selectedId: string | null;
+ onSelect: (id: string) => void;
+}
+
+// Notes aren't tracked on brewery locations yet - see BREWERY_INDEX_HANDOFF.md.
+const FILLER_NOTE = 'A stop worth the detour.';
+
+const cardClassName = (selected: boolean) =>
+ `text-left rounded-box bg-base-100 shadow p-4 cursor-pointer transition-shadow hover:shadow-lg border-l-4 ${
+ selected ? 'border-l-primary' : 'border-l-transparent'
+ }`;
+
+const NearbyBreweriesSection = ({
+ featured,
+ fallbackCenter,
+ selectedId,
+ onSelect,
+}: NearbyBreweriesSectionProps) => {
+ const [userLocation, setUserLocation] = useState(null);
+ const [locationLabel, setLocationLabel] = useState('your location');
+ const [geoAttempted, setGeoAttempted] = useState(false);
+ const fetcher = useFetcher();
+
+ const requestGeolocation = () => {
+ if (!navigator.geolocation) {
+ Promise.resolve().then(() => setGeoAttempted(true));
+ return;
+ }
+ navigator.geolocation.getCurrentPosition(
+ (pos) => {
+ setUserLocation([pos.coords.latitude, pos.coords.longitude]);
+ setLocationLabel('your location');
+ setGeoAttempted(true);
+ },
+ () => setGeoAttempted(true),
+ { enableHighAccuracy: true },
+ );
+ };
+
+ useEffect(() => {
+ requestGeolocation();
+ }, []);
+
+ const center: LatLngTuple | null =
+ userLocation ?? (geoAttempted && fallbackCenter ? [fallbackCenter.latitude, fallbackCenter.longitude] : null);
+ const centerLabel = userLocation ? locationLabel : (fallbackCenter?.label ?? locationLabel);
+
+ useEffect(() => {
+ if (!center) return;
+ fetcher.load(
+ `/breweries/nearby?latitude=${center[0]}&longitude=${center[1]}&rangeInMetres=${RADIUS_METRES}`,
+ );
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [center?.[0], center?.[1]]);
+
+ const nearby: SimplifiedBrewery[] = fetcher.data?.breweries ?? [];
+ const withDistance = center
+ ? nearby
+ .filter((b) => b.location?.coordinates)
+ .map((b) => ({
+ brewery: b,
+ distanceMetres: haversineDistanceMetres(
+ { latitude: center[0], longitude: center[1] },
+ b.location!.coordinates!,
+ ),
+ }))
+ .sort((a, b) => a.distanceMetres - b.distanceMetres)
+ : [];
+
+ const pins: NearbyMapPin[] = withDistance.map(({ brewery }) => ({
+ id: brewery.breweryPostId,
+ name: brewery.breweryName,
+ cityLabel: brewery.location ? `${brewery.location.cityName}, ${brewery.location.stateProvinceCode}` : null,
+ latitude: brewery.location!.coordinates!.latitude,
+ longitude: brewery.location!.coordinates!.longitude,
+ }));
+ if (featured && !pins.some((p) => p.id === featured.id)) {
+ pins.push({
+ id: featured.id,
+ name: featured.name,
+ cityLabel: null,
+ latitude: featured.latitude,
+ longitude: featured.longitude,
+ });
+ }
+
+ return (
+
+
+
Breweries near you
+
+ {center
+ ? `${withDistance.length} within ${RADIUS_METRES / 1000} km of ${centerLabel}`
+ : 'Finding breweries near you…'}
+
+
+
+
+
+ {withDistance.length === 0 && center && (
+
+ No partner breweries within {RADIUS_METRES / 1000} km.
+
+ )}
+ {withDistance.map(({ brewery, distanceMetres }) => (
+
+ ))}
+
+
+
+
+
+
+
}>
+ {() =>
+ center ? (
+
}>
+
+
+ ) : (
+
+ )
+ }
+
+
+
+
+ );
+};
+
+export default NearbyBreweriesSection;
diff --git a/web/frontend/app/features/breweries/components/NearbyBreweryMap.tsx b/web/frontend/app/features/breweries/components/NearbyBreweryMap.tsx
new file mode 100644
index 00000000..72aa0b11
--- /dev/null
+++ b/web/frontend/app/features/breweries/components/NearbyBreweryMap.tsx
@@ -0,0 +1,111 @@
+import { useEffect, useRef } from 'react';
+import { MapContainer, TileLayer, Marker, Popup, CircleMarker, useMap } from 'react-leaflet';
+import L, { type LatLngTuple } from 'leaflet';
+import 'leaflet/dist/leaflet.css';
+
+export interface NearbyMapPin {
+ id: string;
+ name: string;
+ cityLabel: string | null;
+ latitude: number;
+ longitude: number;
+}
+
+interface NearbyBreweryMapProps {
+ pins: NearbyMapPin[];
+ selectedId: string | null;
+ onSelect: (id: string) => void;
+ userLocation: LatLngTuple | null;
+ userLocationLabel: string;
+}
+
+const pinIcon = (active: boolean) =>
+ L.divIcon({
+ className: '',
+ html: ``,
+ iconSize: [18, 18],
+ iconAnchor: [9, 9],
+ });
+
+const FitAndFocus = ({
+ pins,
+ userLocation,
+ selectedId,
+}: {
+ pins: NearbyMapPin[];
+ userLocation: LatLngTuple | null;
+ selectedId: string | null;
+}) => {
+ const map = useMap();
+ const didInitialFit = useRef(false);
+
+ useEffect(() => {
+ if (didInitialFit.current) return;
+ const points: LatLngTuple[] = pins.map((pin) => [pin.latitude, pin.longitude]);
+ if (userLocation) points.push(userLocation);
+ if (points.length > 0) {
+ map.fitBounds(points, { padding: [40, 40] });
+ didInitialFit.current = true;
+ }
+ setTimeout(() => map.invalidateSize(), 200);
+ }, [map, pins, userLocation]);
+
+ useEffect(() => {
+ if (!didInitialFit.current || !selectedId) return;
+ const pin = pins.find((p) => p.id === selectedId);
+ if (!pin) return;
+ map.flyTo([pin.latitude, pin.longitude], Math.max(map.getZoom(), 11), { duration: 0.8 });
+ }, [map, pins, selectedId]);
+
+ return null;
+};
+
+const NearbyBreweryMap = ({
+ pins,
+ selectedId,
+ onSelect,
+ userLocation,
+ userLocationLabel,
+}: NearbyBreweryMapProps) => (
+
+
+
+ {userLocation && (
+
+ You are here — {userLocationLabel}
+
+ )}
+ {pins.map((pin) => (
+ onSelect(pin.id) }}
+ >
+
+ {pin.name}
+ {pin.cityLabel && {pin.cityLabel}
}
+
+ View brewery →
+
+
+
+ ))}
+
+);
+
+export default NearbyBreweryMap;
diff --git a/web/frontend/app/features/breweries/components/RecentBreweries.tsx b/web/frontend/app/features/breweries/components/RecentBreweries.tsx
new file mode 100644
index 00000000..04984593
--- /dev/null
+++ b/web/frontend/app/features/breweries/components/RecentBreweries.tsx
@@ -0,0 +1,42 @@
+import { Link } from 'react-router';
+import type { Brewery } from '../breweries.server';
+
+interface RecentBreweriesProps {
+ breweries: Brewery[];
+}
+
+const RecentBreweries = ({ breweries }: RecentBreweriesProps) => {
+ if (breweries.length === 0) return null;
+
+ return (
+
+ Recently added
+
+ {breweries.map((brewery) => (
+
+
{brewery.breweryName}
+ {brewery.location && (
+
+ {brewery.location.cityName}, {brewery.location.stateProvinceCode}
+
+ )}
+
+ {brewery.description}
+
+
+ View brewery →
+
+
+ ))}
+
+
+ );
+};
+
+export default RecentBreweries;
diff --git a/web/frontend/app/features/breweries/routes/breweries.tsx b/web/frontend/app/features/breweries/routes/breweries.tsx
new file mode 100644
index 00000000..2a0d3749
--- /dev/null
+++ b/web/frontend/app/features/breweries/routes/breweries.tsx
@@ -0,0 +1,107 @@
+import { useState } from 'react';
+import { Link } from 'react-router';
+import RouteErrorState from '../../../components/ui/error/RouteErrorState';
+import FeaturedBreweryCard from '../components/FeaturedBreweryCard';
+import NearbyBreweriesSection from '../components/NearbyBreweriesSection';
+import RecentBreweries from '../components/RecentBreweries';
+import { getBreweries } from '../breweries.server';
+import type { Route } from './+types/breweries';
+
+// No count endpoint exists yet - see BREWERY_INDEX_HANDOFF.md.
+const FILLER_TOTAL_BREWERY_COUNT = 128;
+
+export const meta = ({}: Route.MetaArgs) => {
+ return [{ title: 'Breweries | The Biergarten App' }];
+};
+
+export const loader = async () => {
+ // "Featured" is just the most recently posted brewery for now - see BREWERY_INDEX_HANDOFF.md.
+ const [featured, ...recent] = await getBreweries(4, 0);
+ return { featured: featured ?? null, recent };
+};
+
+const Breweries = ({ loaderData }: Route.ComponentProps) => {
+ const { featured, recent } = loaderData;
+ const [selectedId, setSelectedId] = useState(null);
+
+ const featuredPin =
+ featured?.location?.coordinates ?
+ {
+ id: featured.breweryPostId,
+ name: featured.breweryName,
+ latitude: featured.location.coordinates.latitude,
+ longitude: featured.location.coordinates.longitude,
+ }
+ : null;
+
+ const fallbackCenter =
+ featured?.location?.coordinates ?
+ {
+ latitude: featured.location.coordinates.latitude,
+ longitude: featured.location.coordinates.longitude,
+ label: featured.location.cityName,
+ }
+ : null;
+
+ return (
+
+
+
Breweries
+
+ Discover our partner breweries — start with this week's feature, then see
+ what's pouring near you.
+
+
+
+ {featured && (
+
+
+
Featured brewery
+
+ This week
+
+
+ {
+ setSelectedId(featured.breweryPostId);
+ document
+ .getElementById('breweries-near-you')
+ ?.scrollIntoView({ behavior: 'smooth' });
+ }}
+ />
+
+ )}
+
+
+
+
+
+
+
+
+
Looking for something specific?
+
+ All {FILLER_TOTAL_BREWERY_COUNT} partner breweries, filterable by
+ country, region, and beer style.
+
+
+
+ Browse the full directory
+
+
+
+
+ );
+};
+
+export default Breweries;
+
+export const ErrorBoundary = ({ error }: Route.ErrorBoundaryProps) => (
+
+);
diff --git a/web/frontend/app/features/breweries/utils/distance.ts b/web/frontend/app/features/breweries/utils/distance.ts
new file mode 100644
index 00000000..b0b949ba
--- /dev/null
+++ b/web/frontend/app/features/breweries/utils/distance.ts
@@ -0,0 +1,25 @@
+const EARTH_RADIUS_METRES = 6_371_000;
+
+export interface Coordinates {
+ latitude: number;
+ longitude: number;
+}
+
+export const haversineDistanceMetres = (a: Coordinates, b: Coordinates): number => {
+ const toRadians = (degrees: number) => (degrees * Math.PI) / 180;
+ const deltaLat = toRadians(b.latitude - a.latitude);
+ const deltaLng = toRadians(b.longitude - a.longitude);
+ const lat1 = toRadians(a.latitude);
+ const lat2 = toRadians(b.latitude);
+
+ const h =
+ Math.sin(deltaLat / 2) ** 2 +
+ Math.cos(lat1) * Math.cos(lat2) * Math.sin(deltaLng / 2) ** 2;
+
+ return 2 * EARTH_RADIUS_METRES * Math.asin(Math.sqrt(h));
+};
+
+export const formatDistance = (metres: number): string => {
+ const km = metres / 1000;
+ return km < 10 ? `${km.toFixed(1)} km` : `${Math.round(km)} km`;
+};