Build curated brewery index page: featured card, nearby list/map sync, recently added

This commit is contained in:
Aaron Po
2026-08-27 00:29:07 -04:00
parent 643ecb76db
commit d32f6fa4e1
6 changed files with 532 additions and 0 deletions

View File

@@ -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) => (
<div className="card bg-base-100 shadow-md rounded-box">
<div className="card-body gap-3.5 p-8 md:grid md:grid-cols-[1.35fr_1fr] md:gap-10 md:items-start">
<div className="flex flex-col gap-3.5">
{brewery.location && (
<div className="text-sm font-semibold text-base-content/60">
{brewery.location.cityName}, {brewery.location.stateProvinceCode}
</div>
)}
<h3 className="font-serif text-4xl leading-tight m-0">{brewery.breweryName}</h3>
<p className="text-lg leading-snug text-pretty m-0">{brewery.description}</p>
{brewery.location && (
<p className="text-sm text-base-content/60 m-0">
{formatBreweryAddress(brewery.location)}
</p>
)}
<div className="flex gap-3 pt-2">
<Link to={`/breweries/${brewery.breweryPostId}`} className="btn btn-primary">
View brewery
</Link>
{brewery.location?.coordinates && (
<button type="button" onClick={onShowOnMap} className="btn btn-outline">
Show on map
</button>
)}
</div>
</div>
<div className="stats stats-vertical shadow w-full mt-6 md:mt-0">
{FILLER_STATS.map((stat) => (
<div key={stat.label} className="stat">
<div className="stat-title text-xs font-bold uppercase tracking-widest">
{stat.label}
</div>
<div className="stat-value font-serif text-2xl">{stat.value}</div>
</div>
))}
</div>
</div>
</div>
);
export default FeaturedBreweryCard;

View File

@@ -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<LatLngTuple | null>(null);
const [locationLabel, setLocationLabel] = useState('your location');
const [geoAttempted, setGeoAttempted] = useState(false);
const fetcher = useFetcher<typeof nearbyLoader>();
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 (
<section id="breweries-near-you" className="max-w-7xl mx-auto px-5 pt-11">
<div className="flex items-baseline justify-between gap-4 flex-wrap mb-3.5">
<h2 className="font-serif text-2xl m-0">Breweries near you</h2>
<span className="text-sm text-base-content/60">
{center
? `${withDistance.length} within ${RADIUS_METRES / 1000} km of ${centerLabel}`
: 'Finding breweries near you…'}
</span>
</div>
<div className="grid gap-5 items-stretch md:grid-cols-[22rem_1fr]">
<div className="flex flex-col gap-3 order-2 md:order-1">
{withDistance.length === 0 && center && (
<p className="text-sm text-base-content/60">
No partner breweries within {RADIUS_METRES / 1000} km.
</p>
)}
{withDistance.map(({ brewery, distanceMetres }) => (
<button
key={brewery.breweryPostId}
type="button"
onClick={() => onSelect(brewery.breweryPostId)}
className={cardClassName(selectedId === brewery.breweryPostId)}
>
<div className="flex justify-between items-baseline gap-3">
<h3 className="font-serif text-lg m-0">{brewery.breweryName}</h3>
<span className="text-sm font-semibold text-primary whitespace-nowrap">
{formatDistance(distanceMetres)}
</span>
</div>
{brewery.location && (
<div className="text-sm text-base-content/60 mt-1">
{brewery.location.cityName}, {brewery.location.stateProvinceCode}
</div>
)}
<div className="text-sm mt-2">{FILLER_NOTE}</div>
</button>
))}
<div className="pt-1">
<button type="button" onClick={requestGeolocation} className="btn btn-ghost btn-sm">
Change location
</button>
</div>
</div>
<div className="order-1 md:order-2">
<ClientOnly fallback={<div className={MAP_PLACEHOLDER_CLASS} />}>
{() =>
center ? (
<Suspense fallback={<div className={MAP_PLACEHOLDER_CLASS} />}>
<NearbyBreweryMap
pins={pins}
selectedId={selectedId}
onSelect={onSelect}
userLocation={userLocation}
userLocationLabel={centerLabel}
/>
</Suspense>
) : (
<div className={MAP_PLACEHOLDER_CLASS} />
)
}
</ClientOnly>
</div>
</div>
</section>
);
};
export default NearbyBreweriesSection;

View File

@@ -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: `<span class="block h-[18px] w-[18px] rounded-full border-[3px] border-base-100 shadow-[0_1px_4px_rgba(0,0,0,.35)] ${
active ? 'bg-secondary scale-125' : 'bg-primary'
}"></span>`,
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) => (
<MapContainer
center={userLocation ?? [pins[0]?.latitude ?? 20, pins[0]?.longitude ?? 0]}
zoom={11}
scrollWheelZoom={false}
className="min-h-[30rem] h-full 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"
/>
<FitAndFocus pins={pins} userLocation={userLocation} selectedId={selectedId} />
{userLocation && (
<CircleMarker
center={userLocation}
radius={6}
pathOptions={{ color: '#fff', weight: 2, fillColor: '#2b6cb0', fillOpacity: 1 }}
>
<Popup>You are here &mdash; {userLocationLabel}</Popup>
</CircleMarker>
)}
{pins.map((pin) => (
<Marker
key={pin.id}
position={[pin.latitude, pin.longitude]}
icon={pinIcon(pin.id === selectedId)}
eventHandlers={{ click: () => onSelect(pin.id) }}
>
<Popup>
<p className="font-semibold font-serif">{pin.name}</p>
{pin.cityLabel && <p className="text-sm text-base-content/70">{pin.cityLabel}</p>}
<a href={`/breweries/${pin.id}`} className="link link-primary text-sm">
View brewery &rarr;
</a>
</Popup>
</Marker>
))}
</MapContainer>
);
export default NearbyBreweryMap;

View File

@@ -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 (
<section className="max-w-7xl mx-auto px-5 pt-11">
<h2 className="font-serif text-2xl mb-3.5">Recently added</h2>
<div className="grid gap-5 grid-cols-[repeat(auto-fit,minmax(15rem,1fr))]">
{breweries.map((brewery) => (
<div
key={brewery.breweryPostId}
className="card bg-base-100 shadow p-6 flex flex-col gap-1.5"
>
<h3 className="font-serif text-xl m-0">{brewery.breweryName}</h3>
{brewery.location && (
<div className="text-sm text-base-content/60">
{brewery.location.cityName}, {brewery.location.stateProvinceCode}
</div>
)}
<p className="text-sm leading-snug mt-1 line-clamp-2">
{brewery.description}
</p>
<Link
to={`/breweries/${brewery.breweryPostId}`}
className="link link-hover text-sm font-semibold text-secondary hover:text-primary mt-2"
>
View brewery &rarr;
</Link>
</div>
))}
</div>
</section>
);
};
export default RecentBreweries;