diff --git a/frontend/src/app/chapters/page.tsx b/frontend/src/app/chapters/page.tsx index 15d93df6ea..e79d3c81b0 100644 --- a/frontend/src/app/chapters/page.tsx +++ b/frontend/src/app/chapters/page.tsx @@ -91,6 +91,7 @@ const ChaptersPage = () => { { const mapRef = useRef(null) const markerClusterRef = useRef(null) @@ -103,7 +106,26 @@ const ChapterMap = ({ markerClusterGroup.addLayers(markers) - if (showLocal && validGeoLocData.length > 0) { + if (userLocation && validGeoLocData.length > 0) { + const maxNearestChapters = 5 + const localChapters = validGeoLocData.slice(0, maxNearestChapters) + const localBounds = L.latLngBounds( + localChapters.map((chapter) => [ + chapter._geoloc?.lat ?? chapter.geoLocation?.lat, + chapter._geoloc?.lng ?? chapter.geoLocation?.lng, + ]) + ) + const maxZoom = 12 + const nearestChapter = validGeoLocData[0] + map.setView( + [ + nearestChapter._geoloc?.lat ?? nearestChapter.geoLocation?.lat, + nearestChapter._geoloc?.lng ?? nearestChapter.geoLocation?.lng, + ], + maxZoom + ) + map.fitBounds(localBounds, { maxZoom: maxZoom }) + } else if (showLocal && validGeoLocData.length > 0) { const maxNearestChapters = 5 const localChapters = validGeoLocData.slice(0, maxNearestChapters - 1) const localBounds = L.latLngBounds( @@ -123,7 +145,7 @@ const ChapterMap = ({ ) map.fitBounds(localBounds, { maxZoom: maxZoom }) } - }, [geoLocData, showLocal]) + }, [geoLocData, showLocal, userLocation]) return (
diff --git a/frontend/src/components/ChapterMapWrapper.tsx b/frontend/src/components/ChapterMapWrapper.tsx index 48fb313e4a..719767a28e 100644 --- a/frontend/src/components/ChapterMapWrapper.tsx +++ b/frontend/src/components/ChapterMapWrapper.tsx @@ -1,15 +1,105 @@ +import { faLocationDot } from '@fortawesome/free-solid-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { Button } from '@heroui/button' import dynamic from 'next/dynamic' -import React from 'react' +import React, { useState } from 'react' import type { Chapter } from 'types/chapter' +import { + getUserLocationFromBrowser, + sortChaptersByDistance, + type UserLocation, +} from 'utils/geolocationUtils' const ChapterMap = dynamic(() => import('./ChapterMap'), { ssr: false }) -const ChapterMapWrapper = (props: { +interface ChapterMapWrapperProps { geoLocData: Chapter[] showLocal: boolean style: React.CSSProperties -}) => { - return + showLocationSharing?: boolean +} + +const ChapterMapWrapper: React.FC = (props) => { + const [userLocation, setUserLocation] = useState(null) + const [isLoadingLocation, setIsLoadingLocation] = useState(false) + const [sortedData, setSortedData] = useState(null) + + const enableLocationSharing = props.showLocationSharing === true + + if (!enableLocationSharing) { + return + } + + const handleShareLocation = async () => { + if (userLocation) { + setUserLocation(null) + setSortedData(null) + return + } + + setIsLoadingLocation(true) + + try { + const location = await getUserLocationFromBrowser() + + if (location) { + setUserLocation(location) + const sorted = sortChaptersByDistance(props.geoLocData, location) + setSortedData(sorted.map(({ _distance, ...chapter }) => chapter as unknown as Chapter)) + } + } catch (error) { + // eslint-disable-next-line no-console + console.error('Error detecting location:', error) + } finally { + setIsLoadingLocation(false) + } + } + + const mapData = sortedData ?? props.geoLocData + + return ( +
+
+ + +
+ {userLocation ? ( + <> +
+ 📍 Showing chapters near you +
+
+ Location: {userLocation.latitude.toFixed(2)}, {userLocation.longitude.toFixed(2)} +
+ + ) : ( + <> +
+ Find chapters near you +
+
+ Click the blue button to use your current location +
+ + )} +
+
+ + +
+ ) } export default ChapterMapWrapper diff --git a/frontend/src/utils/geolocationUtils.ts b/frontend/src/utils/geolocationUtils.ts new file mode 100644 index 0000000000..a7adcd5f2f --- /dev/null +++ b/frontend/src/utils/geolocationUtils.ts @@ -0,0 +1,99 @@ +export interface UserLocation { + latitude: number + longitude: number + city?: string + country?: string +} + +interface ChapterCoordinates { + lat: number | null + lng: number | null +} + +export const calculateDistance = ( + lat1: number, + lon1: number, + lat2: number, + lon2: number +): number => { + const R = 6371 + const dLat = ((lat2 - lat1) * Math.PI) / 180 + const dLon = ((lon2 - lon1) * Math.PI) / 180 + + const a = + Math.sin(dLat / 2) ** 2 + + Math.cos((lat1 * Math.PI) / 180) * Math.cos((lat2 * Math.PI) / 180) * Math.sin(dLon / 2) ** 2 + + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) + return R * c +} + +export const getUserLocationFromBrowser = (): Promise => { + return new Promise((resolve) => { + if (!navigator.geolocation) { + // eslint-disable-next-line no-console + console.warn('Geolocation API not supported') + resolve(null) + return + } + + /* Geolocation permission is required for the "Find chapters near you" feature. + The user must explicitly opt in by clicking a button. The location data never + leaves the client and is not sent to the backend. It is used only to calculate + distances between the user and nearby chapters. + */ + navigator.geolocation.getCurrentPosition( + (position) => { + resolve({ + latitude: position.coords.latitude, + longitude: position.coords.longitude, + }) + }, + (error) => { + // eslint-disable-next-line no-console + console.warn('Browser geolocation error:', error.message) + resolve(null) + }, + { + enableHighAccuracy: false, + timeout: 10000, + maximumAge: 0, + } + ) + }) +} + +const extractChapterCoordinates = (chapter: Record): ChapterCoordinates => { + const lat = + (chapter._geoloc as Record)?.lat ?? + (chapter.geoLocation as Record)?.lat ?? + null + + const lng = + (chapter._geoloc as Record)?.lng ?? + (chapter.geoLocation as Record)?.lng ?? + null + + return { lat: lat as number | null, lng: lng as number | null } +} + +/** + * Sort chapters by distance from user + */ +export const sortChaptersByDistance = ( + chapters: Record[], + userLocation: UserLocation +): Array & { distance: number }> => { + return chapters + .map((chapter) => { + const { lat, lng } = extractChapterCoordinates(chapter) + + if (typeof lat !== 'number' || typeof lng !== 'number') return null + + const distance = calculateDistance(userLocation.latitude, userLocation.longitude, lat, lng) + + return { ...chapter, distance } + }) + .filter((item) => item !== null) + .sort((a, b) => a!.distance - b!.distance) +}