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`; +};