mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-15 01:54:43 +00:00
337 lines
12 KiB
TypeScript
337 lines
12 KiB
TypeScript
import { lazy, Suspense, useEffect, useState } from 'react';
|
|
import { useFetcher } from 'react-router';
|
|
import type { LatLngTuple } from 'leaflet';
|
|
import ClientOnly from '../../../components/ClientOnly';
|
|
import useDebouncedValue from '../../../hooks/useDebouncedValue';
|
|
import type { SimplifiedBrewery } from '../breweries.server';
|
|
import type { loader as nearbyLoader } from '../routes/breweries-nearby';
|
|
import { formatDistance, type DistanceUnit } 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 DEFAULT_RADIUS_KM = 120;
|
|
const MIN_RADIUS_KM = 10;
|
|
const MAX_RADIUS_KM = 150;
|
|
const RADIUS_STEP_KM = 10;
|
|
const RADIUS_DEBOUNCE_MS = 300;
|
|
|
|
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'
|
|
}`;
|
|
|
|
interface BreweriesNearByHeaderParams {
|
|
center: LatLngTuple | null;
|
|
withDistance: { brewery: SimplifiedBrewery; distanceMetres: number }[];
|
|
radiusKm: number;
|
|
unit: 'km' | 'mi';
|
|
centerLabel: string;
|
|
}
|
|
|
|
const BreweriesNearByHeader = ({
|
|
center,
|
|
centerLabel,
|
|
radiusKm,
|
|
unit,
|
|
withDistance,
|
|
}: BreweriesNearByHeaderParams) => {
|
|
return (
|
|
<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 ${formatDistance(radiusKm * 1000, unit)} of ${centerLabel}`
|
|
: 'Finding breweries near you…'}
|
|
</span>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
interface RadiusUnitControlsProps {
|
|
radiusKm: number;
|
|
unit: DistanceUnit;
|
|
onRadiusChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
|
onSelectKm: () => void;
|
|
onSelectMi: () => void;
|
|
}
|
|
|
|
const RadiusUnitControls = ({
|
|
radiusKm,
|
|
unit,
|
|
onRadiusChange,
|
|
onSelectKm,
|
|
onSelectMi,
|
|
}: RadiusUnitControlsProps) => {
|
|
return (
|
|
<div className="flex items-center justify-between gap-3">
|
|
<label className="flex-1 flex flex-col gap-1 text-sm text-base-content/70">
|
|
<span className="flex justify-between">
|
|
<span>Search radius</span>
|
|
<span className="font-semibold">{formatDistance(radiusKm * 1000, unit)}</span>
|
|
</span>
|
|
<input
|
|
type="range"
|
|
min={MIN_RADIUS_KM}
|
|
max={MAX_RADIUS_KM}
|
|
step={RADIUS_STEP_KM}
|
|
value={radiusKm}
|
|
onChange={onRadiusChange}
|
|
className="range range-primary range-sm"
|
|
/>
|
|
</label>
|
|
<div className="join self-end">
|
|
<button
|
|
type="button"
|
|
onClick={onSelectKm}
|
|
className={`btn btn-xs join-item ${unit === 'km' ? 'btn-active' : ''}`}
|
|
>
|
|
km
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={onSelectMi}
|
|
className={`btn btn-xs join-item ${unit === 'mi' ? 'btn-active' : ''}`}
|
|
>
|
|
mi
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
interface BreweryWithDistance {
|
|
brewery: SimplifiedBrewery;
|
|
distanceMetres: number;
|
|
}
|
|
|
|
interface NearbyBreweryListProps {
|
|
withDistance: BreweryWithDistance[];
|
|
center: LatLngTuple | null;
|
|
radiusKm: number;
|
|
unit: DistanceUnit;
|
|
renderItem: (item: BreweryWithDistance) => React.JSX.Element;
|
|
}
|
|
|
|
const NearbyBreweryList = ({
|
|
withDistance,
|
|
center,
|
|
radiusKm,
|
|
unit,
|
|
renderItem,
|
|
}: NearbyBreweryListProps) => {
|
|
return (
|
|
<>
|
|
{withDistance.length === 0 && center && (
|
|
<p className="text-sm text-base-content/60">
|
|
No partner breweries within {formatDistance(radiusKm * 1000, unit)}.
|
|
</p>
|
|
)}
|
|
{withDistance.map(renderItem)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
interface ChangeLocationButtonProps {
|
|
onClick: () => void;
|
|
}
|
|
|
|
const ChangeLocationButton = ({ onClick }: ChangeLocationButtonProps) => {
|
|
return (
|
|
<div className="pt-1">
|
|
<button type="button" onClick={onClick} className="btn btn-ghost btn-sm">
|
|
Change location
|
|
</button>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
interface ClientOnlyMapProps {
|
|
render: () => React.JSX.Element;
|
|
}
|
|
|
|
const ClientOnlyMap = ({ render }: ClientOnlyMapProps) => {
|
|
return <ClientOnly fallback={<div className={MAP_PLACEHOLDER_CLASS} />}>{render}</ClientOnly>;
|
|
};
|
|
|
|
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 [radiusKm, setRadiusKm] = useState(DEFAULT_RADIUS_KM);
|
|
const debouncedRadiusKm = useDebouncedValue(radiusKm, RADIUS_DEBOUNCE_MS);
|
|
const [unit, setUnit] = useState<DistanceUnit>('km');
|
|
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=${debouncedRadiusKm * 1000}`,
|
|
);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [center?.[0], center?.[1], debouncedRadiusKm]);
|
|
|
|
const nearby: SimplifiedBrewery[] = fetcher.data?.breweries ?? [];
|
|
const withDistance = center
|
|
? nearby
|
|
.filter((b) => b.location?.coordinates && b.distanceMetres !== null)
|
|
.map((b) => ({
|
|
brewery: b,
|
|
distanceMetres: b.distanceMetres as number,
|
|
}))
|
|
.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">
|
|
<BreweriesNearByHeader
|
|
center={center}
|
|
withDistance={withDistance}
|
|
radiusKm={radiusKm}
|
|
unit={unit}
|
|
centerLabel={centerLabel}
|
|
/>
|
|
|
|
<div className="grid gap-5 items-stretch md:grid-cols-[22rem_1fr]">
|
|
<div className="flex flex-col gap-3 order-2 md:order-1">
|
|
<RadiusUnitControls
|
|
radiusKm={radiusKm}
|
|
unit={unit}
|
|
onRadiusChange={(e) => setRadiusKm(Number(e.target.value))}
|
|
onSelectKm={() => setUnit('km')}
|
|
onSelectMi={() => setUnit('mi')}
|
|
/>
|
|
<NearbyBreweryList
|
|
withDistance={withDistance}
|
|
center={center}
|
|
radiusKm={radiusKm}
|
|
unit={unit}
|
|
renderItem={({ 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, unit)}
|
|
</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>
|
|
)}
|
|
/>
|
|
<ChangeLocationButton onClick={requestGeolocation} />
|
|
</div>
|
|
|
|
<div className="order-1 md:order-2">
|
|
<ClientOnlyMap
|
|
render={() =>
|
|
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} />
|
|
)
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
};
|
|
|
|
export default NearbyBreweriesSection;
|