Add leaflet-powered brewery map

This commit is contained in:
Aaron Po
2026-08-26 03:40:11 -04:00
parent 4ca35de7ef
commit d6306cbcba
11 changed files with 306 additions and 20 deletions

View File

@@ -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<typeof loaderData | undefined>(undefined);
const fetcher = useFetcher<typeof loader>();
const sentinelRef = useRef<HTMLDivElement>(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 (
<div className="min-h-screen bg-base-200">
<div className="container mx-auto p-4">
<div className="container mx-auto p-14">
<h1 className="text-4xl font-bold mb-4">Breweries</h1>
<p className="text-base-content/70 mb-6">Discover our partner breweries.</p>
{breweries.length === 0 ? (
<div className="alert alert-info alert-soft">
<span>No breweries have been posted yet.</span>
</div>
<ClientOnly fallback={<div className={MAP_PLACEHOLDER_CLASS} />}>
{() => (
<Suspense fallback={<div className={MAP_PLACEHOLDER_CLASS} />}>
<BreweryMap breweries={pins} />
</Suspense>
)}
</ClientOnly>
{allBreweries.length === 0 ? (
<p className="text-center text-base-content/60 mt-12">
No breweries found yet.
</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{breweries.map((brewery) => (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mt-8">
{allBreweries.map((brewery) => (
<BreweryCard key={brewery.breweryPostId} brewery={brewery} />
))}
{fetcher.state !== 'idle' &&
Array.from({ length: PAGE_SIZE }).map((_, index) => (
<div
key={index}
className="skeleton h-32 w-full rounded-box"
/>
))}
</div>
)}
{hasMore && <div ref={sentinelRef} className="h-1" />}
</div>
</div>
);

View File

@@ -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) => {
<p className="text-xs font-semibold uppercase tracking-widest text-base-content/50 mb-2">
Location
</p>
<p>{brewery.location.addressLine1}</p>
{brewery.location.addressLine2 && (
<p>{brewery.location.addressLine2}</p>
)}
<p>{brewery.location.postalCode}</p>
<p>{formatBreweryAddress(brewery.location)}</p>
</div>
)}