mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-15 01:54:43 +00:00
Add leaflet-powered brewery map
This commit is contained in:
21
web/frontend/app/components/ClientOnly.tsx
Normal file
21
web/frontend/app/components/ClientOnly.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { useSyncExternalStore, type ReactNode } from 'react';
|
||||
|
||||
interface ClientOnlyProps {
|
||||
children: () => ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
const noop = () => undefined;
|
||||
const emptySubscribe = () => noop;
|
||||
|
||||
const ClientOnly = ({ children, fallback = null }: ClientOnlyProps) => {
|
||||
const mounted = useSyncExternalStore(
|
||||
emptySubscribe,
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
|
||||
return mounted ? children() : fallback;
|
||||
};
|
||||
|
||||
export default ClientOnly;
|
||||
@@ -7,13 +7,24 @@ interface ApiResponse<T> {
|
||||
payload: T;
|
||||
}
|
||||
|
||||
export interface BreweryCoordinates {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
export interface BreweryLocation {
|
||||
breweryPostLocationId: string;
|
||||
breweryPostId: string;
|
||||
cityId: string;
|
||||
cityName: string;
|
||||
stateProvinceName: string;
|
||||
stateProvinceCode: string;
|
||||
countryName: string;
|
||||
countryCode: string;
|
||||
addressLine1: string;
|
||||
addressLine2: string | null;
|
||||
postalCode: string;
|
||||
coordinates: BreweryCoordinates | null;
|
||||
}
|
||||
|
||||
export interface Brewery {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Link } from 'react-router';
|
||||
import type { Brewery } from '../breweries.server';
|
||||
import { formatBreweryAddress } from '../utils/format-address';
|
||||
|
||||
interface BreweryCardProps {
|
||||
brewery: Brewery;
|
||||
@@ -12,13 +13,32 @@ const BreweryCard = ({ brewery }: BreweryCardProps) => (
|
||||
>
|
||||
<div className="card-body">
|
||||
<h2 className="card-title">{brewery.breweryName}</h2>
|
||||
<p className="text-base-content/70 line-clamp-3">{brewery.description}</p>
|
||||
{brewery.location && (
|
||||
<p className="text-sm font-medium text-base-content/70">
|
||||
{brewery.location.cityName}, {brewery.location.stateProvinceCode}
|
||||
</p>
|
||||
)}
|
||||
{brewery.description && (
|
||||
<p className="text-sm leading-5 text-base-content/70 line-clamp-2 max-h-10 overflow-hidden">
|
||||
{brewery.description}
|
||||
</p>
|
||||
)}
|
||||
{brewery.location && (
|
||||
<p className="text-sm text-base-content/50 mt-2">
|
||||
{brewery.location.addressLine1}
|
||||
{brewery.location.addressLine2 ? `, ${brewery.location.addressLine2}` : ''}
|
||||
{formatBreweryAddress(brewery.location)}
|
||||
</p>
|
||||
)}
|
||||
{brewery.location?.coordinates && (
|
||||
<a
|
||||
href={`https://www.openstreetmap.org/?mlat=${brewery.location.coordinates.latitude}&mlon=${brewery.location.coordinates.longitude}#map=16/${brewery.location.coordinates.latitude}/${brewery.location.coordinates.longitude}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="link link-primary text-sm mt-2 self-start"
|
||||
>
|
||||
View on map →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useEffect } from 'react';
|
||||
import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet';
|
||||
import L, { type LatLngExpression, type LatLngTuple } from 'leaflet';
|
||||
import iconRetinaUrl from 'leaflet/dist/images/marker-icon-2x.png';
|
||||
import iconUrl from 'leaflet/dist/images/marker-icon.png';
|
||||
import shadowUrl from 'leaflet/dist/images/marker-shadow.png';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import useMediaQuery from '../../../hooks/useMediaQuery';
|
||||
import type { BreweryLocation } from '../breweries.server';
|
||||
import { formatBreweryAddress } from '../utils/format-address';
|
||||
|
||||
delete (L.Icon.Default.prototype as unknown as { _getIconUrl?: unknown })._getIconUrl;
|
||||
L.Icon.Default.mergeOptions({ iconRetinaUrl, iconUrl, shadowUrl });
|
||||
|
||||
export interface BreweryMapPin {
|
||||
id: string;
|
||||
name: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
location: BreweryLocation | null;
|
||||
}
|
||||
|
||||
interface BreweryMapProps {
|
||||
breweries: BreweryMapPin[];
|
||||
}
|
||||
|
||||
const FALLBACK_CENTER: LatLngExpression = [20, 0];
|
||||
|
||||
const FitBounds = ({ bounds }: { bounds: LatLngTuple[] }) => {
|
||||
const map = useMap();
|
||||
|
||||
useEffect(() => {
|
||||
if (bounds.length > 0) {
|
||||
map.fitBounds(bounds, { padding: [32, 32] });
|
||||
}
|
||||
}, [map, bounds]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const BreweryMap = ({ breweries }: BreweryMapProps) => {
|
||||
const bounds: LatLngTuple[] = breweries.map((brewery) => [
|
||||
brewery.latitude,
|
||||
brewery.longitude,
|
||||
]);
|
||||
|
||||
return (
|
||||
<MapContainer
|
||||
center={bounds[0] ?? FALLBACK_CENTER}
|
||||
zoom={4}
|
||||
scrollWheelZoom={false}
|
||||
className="h-112 md:h-136 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"
|
||||
/>
|
||||
<FitBounds bounds={bounds} />
|
||||
{breweries.map((brewery) => (
|
||||
<Marker key={brewery.id} position={[brewery.latitude, brewery.longitude]}>
|
||||
<Popup>
|
||||
<p className="font-semibold">{brewery.name}</p>
|
||||
{brewery.location && (
|
||||
<p className="text-sm text-base-content/70">
|
||||
{formatBreweryAddress(brewery.location)}
|
||||
</p>
|
||||
)}
|
||||
<a href={`/breweries/${brewery.id}`} className="link link-primary text-sm">
|
||||
View brewery →
|
||||
</a>
|
||||
</Popup>
|
||||
</Marker>
|
||||
))}
|
||||
</MapContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default BreweryMap;
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { BreweryLocation } from '../breweries.server';
|
||||
|
||||
export const formatBreweryAddress = (location: BreweryLocation): string => {
|
||||
const streetLine = location.addressLine2
|
||||
? `${location.addressLine1}, ${location.addressLine2}`
|
||||
: location.addressLine1;
|
||||
|
||||
return `${streetLine}, ${location.cityName}, ${location.stateProvinceCode} ${location.postalCode}, ${location.countryName}`;
|
||||
};
|
||||
22
web/frontend/app/hooks/useMediaQuery.ts
Normal file
22
web/frontend/app/hooks/useMediaQuery.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
const subscribe = (query: string) => (callback: () => void) => {
|
||||
const media = window.matchMedia(query);
|
||||
media.addEventListener('change', callback);
|
||||
return () => media.removeEventListener('change', callback);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tracks whether a CSS media query currently matches the viewport, updating on change.
|
||||
*
|
||||
* @example
|
||||
* const isSmallScreen = useMediaQuery('(max-width: 640px)');
|
||||
*/
|
||||
const useMediaQuery = (query: `(${string})`) =>
|
||||
useSyncExternalStore(
|
||||
subscribe(query),
|
||||
() => window.matchMedia(query).matches,
|
||||
() => false,
|
||||
);
|
||||
|
||||
export default useMediaQuery;
|
||||
51
web/frontend/package-lock.json
generated
51
web/frontend/package-lock.json
generated
@@ -15,10 +15,12 @@
|
||||
"@react-router/node": "^8.3.0",
|
||||
"iconoir-react": "^7.11.1",
|
||||
"isbot": "^5.2.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-hook-form": "^7.86.0",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-leaflet": "^5.0.0-rc.2",
|
||||
"react-router": "^8.3.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -33,6 +35,7 @@
|
||||
"@storybook/react-vite": "^10.2.19",
|
||||
"@tailwindcss/postcss": "^4.2.1",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/leaflet": "^1.9.22",
|
||||
"@types/node": "^25.5.2",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
@@ -2301,6 +2304,17 @@
|
||||
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-leaflet/core": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
|
||||
"integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==",
|
||||
"license": "Hippocratic-2.1",
|
||||
"peerDependencies": {
|
||||
"leaflet": "^1.9.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-router/dev": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-router/dev/-/dev-8.3.0.tgz",
|
||||
@@ -3558,6 +3572,13 @@
|
||||
"@types/send": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/http-errors": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
|
||||
@@ -3572,6 +3593,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/leaflet": {
|
||||
"version": "1.9.22",
|
||||
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.22.tgz",
|
||||
"integrity": "sha512-h3lhECYEKDasG7LFHu+GiHqAvsgLuQvlJvVZzJDGONo3sEL+wUOqSFLnwkZlK0qVxnxbuGFW8iBlJNYs5wgndA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/mdx": {
|
||||
"version": "2.0.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.14.tgz",
|
||||
@@ -7685,6 +7716,12 @@
|
||||
"json-buffer": "3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/leaflet": {
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/levn": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||
@@ -9061,6 +9098,20 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-leaflet": {
|
||||
"version": "5.0.0-rc.2",
|
||||
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0-rc.2.tgz",
|
||||
"integrity": "sha512-1xQGYG9mEIW+nfkQhqgHImwUuB1UDlnzYFSzv6PrBFDBeYrFmv0BbpwpNAFdJg/UQ2yz5UZSL7ZwlUxjwb8MZw==",
|
||||
"license": "Hippocratic-2.1",
|
||||
"dependencies": {
|
||||
"@react-leaflet/core": "^3.0.0-rc.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"leaflet": "^1.9.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.18.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
||||
|
||||
@@ -25,10 +25,12 @@
|
||||
"@react-router/node": "^8.3.0",
|
||||
"iconoir-react": "^7.11.1",
|
||||
"isbot": "^5.2.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-hook-form": "^7.86.0",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-leaflet": "^5.0.0-rc.2",
|
||||
"react-router": "^8.3.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -43,6 +45,7 @@
|
||||
"@storybook/react-vite": "^10.2.19",
|
||||
"@tailwindcss/postcss": "^4.2.1",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/leaflet": "^1.9.22",
|
||||
"@types/node": "^25.5.2",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
|
||||
Reference in New Issue
Block a user