mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-15 01:54:43 +00:00
Build curated brewery index page: featured card, nearby list/map sync, recently added
This commit is contained in:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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='© <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 — {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 →
|
||||
</a>
|
||||
</Popup>
|
||||
</Marker>
|
||||
))}
|
||||
</MapContainer>
|
||||
);
|
||||
|
||||
export default NearbyBreweryMap;
|
||||
@@ -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 →
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecentBreweries;
|
||||
107
web/frontend/app/features/breweries/routes/breweries.tsx
Normal file
107
web/frontend/app/features/breweries/routes/breweries.tsx
Normal file
@@ -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<string | null>(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 (
|
||||
<div className="min-h-screen bg-base-200 text-base-content pb-16">
|
||||
<div className="max-w-7xl mx-auto px-5 pt-10">
|
||||
<h1 className="font-serif text-5xl leading-tight mb-2">Breweries</h1>
|
||||
<p className="text-lg text-base-content/60 max-w-xl m-0">
|
||||
Discover our partner breweries — start with this week's feature, then see
|
||||
what's pouring near you.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{featured && (
|
||||
<section className="max-w-7xl mx-auto px-5 pt-8">
|
||||
<div className="flex items-baseline gap-3 mb-3.5">
|
||||
<h2 className="font-serif text-2xl m-0">Featured brewery</h2>
|
||||
<span className="badge badge-primary badge-sm uppercase tracking-widest font-bold">
|
||||
This week
|
||||
</span>
|
||||
</div>
|
||||
<FeaturedBreweryCard
|
||||
brewery={featured}
|
||||
onShowOnMap={() => {
|
||||
setSelectedId(featured.breweryPostId);
|
||||
document
|
||||
.getElementById('breweries-near-you')
|
||||
?.scrollIntoView({ behavior: 'smooth' });
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<NearbyBreweriesSection
|
||||
featured={featuredPin}
|
||||
fallbackCenter={fallbackCenter}
|
||||
selectedId={selectedId}
|
||||
onSelect={setSelectedId}
|
||||
/>
|
||||
|
||||
<RecentBreweries breweries={recent} />
|
||||
|
||||
<section className="max-w-7xl mx-auto px-5 pt-11">
|
||||
<div className="bg-[var(--color-highlight)] text-[var(--color-highlight-content)] rounded-box p-7 flex items-center justify-between gap-6 flex-wrap">
|
||||
<div>
|
||||
<h2 className="font-serif text-2xl mb-1">Looking for something specific?</h2>
|
||||
<p className="m-0">
|
||||
All {FILLER_TOTAL_BREWERY_COUNT} partner breweries, filterable by
|
||||
country, region, and beer style.
|
||||
</p>
|
||||
</div>
|
||||
<Link to="/breweries/directory" className="btn btn-primary">
|
||||
Browse the full directory
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Breweries;
|
||||
|
||||
export const ErrorBoundary = ({ error }: Route.ErrorBoundaryProps) => (
|
||||
<RouteErrorState error={error} />
|
||||
);
|
||||
25
web/frontend/app/features/breweries/utils/distance.ts
Normal file
25
web/frontend/app/features/breweries/utils/distance.ts
Normal file
@@ -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`;
|
||||
};
|
||||
Reference in New Issue
Block a user