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:
6
package-lock.json
generated
Normal file
6
package-lock.json
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "the-biergarten-app",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {}
|
||||||
|
}
|
||||||
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;
|
payload: T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BreweryCoordinates {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BreweryLocation {
|
export interface BreweryLocation {
|
||||||
breweryPostLocationId: string;
|
breweryPostLocationId: string;
|
||||||
breweryPostId: string;
|
breweryPostId: string;
|
||||||
cityId: string;
|
cityId: string;
|
||||||
|
cityName: string;
|
||||||
|
stateProvinceName: string;
|
||||||
|
stateProvinceCode: string;
|
||||||
|
countryName: string;
|
||||||
|
countryCode: string;
|
||||||
addressLine1: string;
|
addressLine1: string;
|
||||||
addressLine2: string | null;
|
addressLine2: string | null;
|
||||||
postalCode: string;
|
postalCode: string;
|
||||||
|
coordinates: BreweryCoordinates | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Brewery {
|
export interface Brewery {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
import type { Brewery } from '../breweries.server';
|
import type { Brewery } from '../breweries.server';
|
||||||
|
import { formatBreweryAddress } from '../utils/format-address';
|
||||||
|
|
||||||
interface BreweryCardProps {
|
interface BreweryCardProps {
|
||||||
brewery: Brewery;
|
brewery: Brewery;
|
||||||
@@ -12,13 +13,32 @@ const BreweryCard = ({ brewery }: BreweryCardProps) => (
|
|||||||
>
|
>
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<h2 className="card-title">{brewery.breweryName}</h2>
|
<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 && (
|
{brewery.location && (
|
||||||
<p className="text-sm text-base-content/50 mt-2">
|
<p className="text-sm text-base-content/50 mt-2">
|
||||||
{brewery.location.addressLine1}
|
{formatBreweryAddress(brewery.location)}
|
||||||
{brewery.location.addressLine2 ? `, ${brewery.location.addressLine2}` : ''}
|
|
||||||
</p>
|
</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>
|
</div>
|
||||||
</Link>
|
</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 RouteErrorState from '../../../components/ui/error/RouteErrorState';
|
||||||
|
import ClientOnly from '../../../components/ClientOnly';
|
||||||
import BreweryCard from '../components/BreweryCard';
|
import BreweryCard from '../components/BreweryCard';
|
||||||
import { getBreweries } from '../breweries.server';
|
import { getBreweries, type Brewery } from '../breweries.server';
|
||||||
import type { Route } from './+types/breweries';
|
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) => {
|
export const meta = ({}: Route.MetaArgs) => {
|
||||||
return [{ title: 'Breweries | The Biergarten App' }];
|
return [{ title: 'Breweries | The Biergarten App' }];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const loader = async () => {
|
export const loader = async ({ request }: Route.LoaderArgs) => {
|
||||||
const breweries = await getBreweries();
|
const url = new URL(request.url);
|
||||||
return { breweries };
|
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 }: 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 (
|
return (
|
||||||
<div className="min-h-screen bg-base-200">
|
<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>
|
<h1 className="text-4xl font-bold mb-4">Breweries</h1>
|
||||||
<p className="text-base-content/70 mb-6">Discover our partner breweries.</p>
|
<p className="text-base-content/70 mb-6">Discover our partner breweries.</p>
|
||||||
|
|
||||||
{breweries.length === 0 ? (
|
<ClientOnly fallback={<div className={MAP_PLACEHOLDER_CLASS} />}>
|
||||||
<div className="alert alert-info alert-soft">
|
{() => (
|
||||||
<span>No breweries have been posted yet.</span>
|
<Suspense fallback={<div className={MAP_PLACEHOLDER_CLASS} />}>
|
||||||
</div>
|
<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">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mt-8">
|
||||||
{breweries.map((brewery) => (
|
{allBreweries.map((brewery) => (
|
||||||
<BreweryCard key={brewery.breweryPostId} brewery={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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{hasMore && <div ref={sentinelRef} className="h-1" />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { data, Link } from 'react-router';
|
import { data, Link } from 'react-router';
|
||||||
import RouteErrorState from '../../../components/ui/error/RouteErrorState';
|
import RouteErrorState from '../../../components/ui/error/RouteErrorState';
|
||||||
import { getBreweryById } from '../breweries.server';
|
import { getBreweryById } from '../breweries.server';
|
||||||
|
import { formatBreweryAddress } from '../utils/format-address';
|
||||||
import type { Route } from './+types/brewery-detail';
|
import type { Route } from './+types/brewery-detail';
|
||||||
|
|
||||||
export const loader = async ({ params }: Route.LoaderArgs) => {
|
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">
|
<p className="text-xs font-semibold uppercase tracking-widest text-base-content/50 mb-2">
|
||||||
Location
|
Location
|
||||||
</p>
|
</p>
|
||||||
<p>{brewery.location.addressLine1}</p>
|
<p>{formatBreweryAddress(brewery.location)}</p>
|
||||||
{brewery.location.addressLine2 && (
|
|
||||||
<p>{brewery.location.addressLine2}</p>
|
|
||||||
)}
|
|
||||||
<p>{brewery.location.postalCode}</p>
|
|
||||||
</div>
|
</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",
|
"@react-router/node": "^8.3.0",
|
||||||
"iconoir-react": "^7.11.1",
|
"iconoir-react": "^7.11.1",
|
||||||
"isbot": "^5.2.1",
|
"isbot": "^5.2.1",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
"react-hook-form": "^7.86.0",
|
"react-hook-form": "^7.86.0",
|
||||||
"react-hot-toast": "^2.6.0",
|
"react-hot-toast": "^2.6.0",
|
||||||
|
"react-leaflet": "^5.0.0-rc.2",
|
||||||
"react-router": "^8.3.0",
|
"react-router": "^8.3.0",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
@@ -33,6 +35,7 @@
|
|||||||
"@storybook/react-vite": "^10.2.19",
|
"@storybook/react-vite": "^10.2.19",
|
||||||
"@tailwindcss/postcss": "^4.2.1",
|
"@tailwindcss/postcss": "^4.2.1",
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
|
"@types/leaflet": "^1.9.22",
|
||||||
"@types/node": "^25.5.2",
|
"@types/node": "^25.5.2",
|
||||||
"@types/react": "^19.2.18",
|
"@types/react": "^19.2.18",
|
||||||
"@types/react-dom": "^19.2.4",
|
"@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"
|
"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": {
|
"node_modules/@react-router/dev": {
|
||||||
"version": "8.3.0",
|
"version": "8.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@react-router/dev/-/dev-8.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@react-router/dev/-/dev-8.3.0.tgz",
|
||||||
@@ -3558,6 +3572,13 @@
|
|||||||
"@types/send": "*"
|
"@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": {
|
"node_modules/@types/http-errors": {
|
||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
|
||||||
@@ -3572,6 +3593,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/mdx": {
|
||||||
"version": "2.0.14",
|
"version": "2.0.14",
|
||||||
"resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.14.tgz",
|
||||||
@@ -7685,6 +7716,12 @@
|
|||||||
"json-buffer": "3.0.1"
|
"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": {
|
"node_modules/levn": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||||
@@ -9061,6 +9098,20 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.18.0",
|
"version": "0.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
||||||
|
|||||||
@@ -25,10 +25,12 @@
|
|||||||
"@react-router/node": "^8.3.0",
|
"@react-router/node": "^8.3.0",
|
||||||
"iconoir-react": "^7.11.1",
|
"iconoir-react": "^7.11.1",
|
||||||
"isbot": "^5.2.1",
|
"isbot": "^5.2.1",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
"react-hook-form": "^7.86.0",
|
"react-hook-form": "^7.86.0",
|
||||||
"react-hot-toast": "^2.6.0",
|
"react-hot-toast": "^2.6.0",
|
||||||
|
"react-leaflet": "^5.0.0-rc.2",
|
||||||
"react-router": "^8.3.0",
|
"react-router": "^8.3.0",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
@@ -43,6 +45,7 @@
|
|||||||
"@storybook/react-vite": "^10.2.19",
|
"@storybook/react-vite": "^10.2.19",
|
||||||
"@tailwindcss/postcss": "^4.2.1",
|
"@tailwindcss/postcss": "^4.2.1",
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
|
"@types/leaflet": "^1.9.22",
|
||||||
"@types/node": "^25.5.2",
|
"@types/node": "^25.5.2",
|
||||||
"@types/react": "^19.2.18",
|
"@types/react": "^19.2.18",
|
||||||
"@types/react-dom": "^19.2.4",
|
"@types/react-dom": "^19.2.4",
|
||||||
|
|||||||
Reference in New Issue
Block a user