Skip to content

Commit 7d4d22f

Browse files
authored
feat: placeId in URL to share easily / location / offsites (#14080)
* feat: placeId in URL to share easily * hide offsites for now * try to get user location based on ip * make them session based
1 parent 861eaa5 commit 7d4d22f

File tree

6 files changed

+182
-18
lines changed

6 files changed

+182
-18
lines changed

src/components/HogMap/EventsMap.tsx

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React, { useEffect, useRef, useState, useCallback } from 'react'
22
import { useEventsMapData } from './EventsLayer'
3+
import { useUserLocation } from '../../hooks/useUserLocation'
34
import 'mapbox-gl/dist/mapbox-gl.css'
45
type EventItem = {
56
id: number
@@ -63,6 +64,8 @@ export default function EventsMap({
6364
selectedEventId?: number | null
6465
}): JSX.Element {
6566
const [isClient, setIsClient] = useState(false)
67+
const { location: userLocation, isLoading: isLocationLoading } = useUserLocation()
68+
6669
useEffect(() => {
6770
setIsClient(true)
6871
}, [])
@@ -144,6 +147,10 @@ export default function EventsMap({
144147
console.error('Not client')
145148
return
146149
}
150+
if (isLocationLoading) {
151+
// Wait for location to load before initializing map
152+
return
153+
}
147154
const mapboxgl = getMapbox()
148155
if (!mapboxgl) {
149156
console.error('No mapboxgl')
@@ -356,7 +363,7 @@ export default function EventsMap({
356363
mapRef.current = new mapboxgl.Map({
357364
container: mapContainerRef.current as HTMLDivElement,
358365
style: styleUrl,
359-
center: [-0.1276, 51.5074], // London
366+
center: [userLocation.longitude, userLocation.latitude],
360367
zoom: 4,
361368
attributionControl: true,
362369
})
@@ -401,7 +408,7 @@ export default function EventsMap({
401408
mapRef.current = null
402409
}
403410
}
404-
}, [isClient, token, styleUrl])
411+
}, [isClient, token, styleUrl, isLocationLoading, userLocation])
405412

406413
useEffect(() => {
407414
return setupMap()
@@ -506,7 +513,12 @@ export default function EventsMap({
506513
}, [handleExternalSelection])
507514

508515
return (
509-
<div className="box-border w-full h-full rounded border border-primary overflow-hidden">
516+
<div className="box-border w-full h-full rounded border border-primary overflow-hidden relative">
517+
{isLocationLoading && (
518+
<div className="absolute inset-0 flex items-center justify-center bg-primary/50 z-20">
519+
<div className="text-primary text-sm">Loading map...</div>
520+
</div>
521+
)}
510522
<div ref={mapContainerRef} className="w-full h-full" />
511523
</div>
512524
)

src/components/HogMap/PeopleMap.tsx

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react'
22
import { navigate } from 'gatsby'
33
import 'mapbox-gl/dist/mapbox-gl.css'
4+
import { useUserLocation } from '../../hooks/useUserLocation'
45
import {
56
computeOffsets,
67
getMapbox,
@@ -127,6 +128,8 @@ const useCoordsByQuery = (isClient: boolean, token: string | undefined, members:
127128

128129
export default function PeopleMap({ members: membersProp }: { members?: any[] }): JSX.Element {
129130
const [isClient, setIsClient] = useState(false)
131+
const { location: userLocation, isLoading: isLocationLoading } = useUserLocation()
132+
130133
useEffect(() => {
131134
setIsClient(true)
132135
}, [])
@@ -200,6 +203,10 @@ export default function PeopleMap({ members: membersProp }: { members?: any[] })
200203
console.error('Not client')
201204
return
202205
}
206+
if (isLocationLoading) {
207+
// Wait for location to load before initializing map
208+
return
209+
}
203210
const mapboxgl = getMapbox()
204211
if (!mapboxgl) {
205212
console.error('No mapboxgl')
@@ -338,7 +345,7 @@ export default function PeopleMap({ members: membersProp }: { members?: any[] })
338345
mapRef.current = new mapboxgl.Map({
339346
container: mapContainerRef.current as HTMLDivElement,
340347
style: styleUrl,
341-
center: [-0.1276, 51.5074], // London
348+
center: [userLocation.longitude, userLocation.latitude],
342349
zoom: 4,
343350
attributionControl: true,
344351
})
@@ -383,7 +390,7 @@ export default function PeopleMap({ members: membersProp }: { members?: any[] })
383390
mapRef.current = null
384391
}
385392
}
386-
}, [isClient, token, styleUrl])
393+
}, [isClient, token, styleUrl, isLocationLoading, userLocation])
387394

388395
useEffect(() => {
389396
return setupMap()
@@ -400,7 +407,12 @@ export default function PeopleMap({ members: membersProp }: { members?: any[] })
400407
}, [coordsByQuery, members])
401408

402409
return (
403-
<div className="box-border w-full h-full rounded border border-primary overflow-hidden">
410+
<div className="box-border w-full h-full rounded border border-primary overflow-hidden relative">
411+
{isLocationLoading && (
412+
<div className="absolute inset-0 flex items-center justify-center bg-primary/50 z-20">
413+
<div className="text-primary text-sm">Loading map...</div>
414+
</div>
415+
)}
404416
<div ref={mapContainerRef} className="w-full h-full" />
405417
</div>
406418
)

src/components/HogMap/PlacesMap.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React, { useEffect, useRef, useState, useCallback } from 'react'
22
import 'mapbox-gl/dist/mapbox-gl.css'
33
import { PlaceType, PlaceItem } from './types'
44
import { useUser } from '../../hooks/useUser'
5+
import { useUserLocation } from '../../hooks/useUserLocation'
56
import SearchBar, { createSearchMarker } from './SearchBar'
67
import { usePlacesMapData, Coordinates } from './PlacesLayer'
78
import { renderToString } from 'react-dom/server'
@@ -59,6 +60,7 @@ export default function PlacesMap({
5960
}): JSX.Element {
6061
const [isClient, setIsClient] = useState(false)
6162
const { isModerator, getJwt } = useUser()
63+
const { location: userLocation, isLoading: isLocationLoading } = useUserLocation()
6264

6365
useEffect(() => {
6466
setIsClient(true)
@@ -147,6 +149,10 @@ export default function PlacesMap({
147149
console.error('Not client')
148150
return
149151
}
152+
if (isLocationLoading) {
153+
// Wait for location to load before initializing map
154+
return
155+
}
150156
const mapboxgl = getMapbox()
151157
if (!mapboxgl) {
152158
console.error('No mapboxgl')
@@ -260,7 +266,7 @@ export default function PlacesMap({
260266
mapRef.current = new mapboxgl.Map({
261267
container: mapContainerRef.current as HTMLDivElement,
262268
style: styleUrl,
263-
center: [-0.1276, 51.5074], // London
269+
center: [userLocation.longitude, userLocation.latitude],
264270
zoom: 4,
265271
attributionControl: true,
266272
})
@@ -307,7 +313,7 @@ export default function PlacesMap({
307313
mapRef.current = null
308314
}
309315
}
310-
}, [isClient, token, styleUrl])
316+
}, [isClient, token, styleUrl, isLocationLoading, userLocation])
311317

312318
useEffect(() => {
313319
return setupMap()
@@ -428,6 +434,11 @@ export default function PlacesMap({
428434

429435
return (
430436
<div className="box-border w-full h-full overflow-hidden relative">
437+
{isLocationLoading && (
438+
<div className="absolute inset-0 flex items-center justify-center bg-primary/50 z-20">
439+
<div className="text-primary text-sm">Loading map...</div>
440+
</div>
441+
)}
431442
<div ref={mapContainerRef} className="w-full h-full" />
432443
{isModerator && (
433444
<div className="absolute top-4 left-4 z-10">

src/components/HogMap/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ export enum PlaceType {
44
HOTEL = 'Hotel',
55
AIRBNB = 'Airbnb',
66
CO_WORKING = 'Co-working',
7-
OFFSITE = 'Offsite',
7+
//OFFSITE = 'Offsite',
88
}
99
export interface PlaceItem {
1010
id: number

src/hooks/useUserLocation.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { useState, useEffect } from 'react'
2+
3+
interface UserLocation {
4+
latitude: number
5+
longitude: number
6+
country?: string
7+
city?: string
8+
}
9+
10+
const DEFAULT_LOCATION: UserLocation = {
11+
latitude: 51.5074,
12+
longitude: -0.1276,
13+
country: 'GB',
14+
city: 'London',
15+
}
16+
17+
// Module-level cache (in-memory only, not persisted to storage)
18+
let cachedLocation: UserLocation | null = null
19+
let fetchPromise: Promise<UserLocation> | null = null
20+
21+
/**
22+
* Hook to get the user's approximate location based on their IP address.
23+
* Caches the result in memory for the browser session (no persistent storage).
24+
* Falls back to London if geolocation fails or is unavailable.
25+
*/
26+
export function useUserLocation(): { location: UserLocation; isLoading: boolean } {
27+
const [location, setLocation] = useState<UserLocation>(cachedLocation || DEFAULT_LOCATION)
28+
const [isLoading, setIsLoading] = useState<boolean>(!cachedLocation)
29+
30+
useEffect(() => {
31+
// Only run on client side
32+
if (typeof window === 'undefined') return
33+
34+
// If we already have a cached location, use it
35+
if (cachedLocation) {
36+
setLocation(cachedLocation)
37+
setIsLoading(false)
38+
return
39+
}
40+
41+
// If a fetch is already in progress, wait for it
42+
if (fetchPromise) {
43+
fetchPromise.then((loc) => {
44+
setLocation(loc)
45+
setIsLoading(false)
46+
})
47+
return
48+
}
49+
50+
// Start a new fetch
51+
fetchPromise = fetch('https://ipapi.co/json/')
52+
.then((response) => response.json())
53+
.then((data) => {
54+
if (data.latitude && data.longitude) {
55+
const userLocation: UserLocation = {
56+
latitude: data.latitude,
57+
longitude: data.longitude,
58+
country: data.country_code,
59+
city: data.city,
60+
}
61+
cachedLocation = userLocation
62+
return userLocation
63+
}
64+
cachedLocation = DEFAULT_LOCATION
65+
return DEFAULT_LOCATION
66+
})
67+
.catch((error) => {
68+
console.log('Could not determine user location, using default:', error)
69+
cachedLocation = DEFAULT_LOCATION
70+
return DEFAULT_LOCATION
71+
})
72+
73+
fetchPromise.then((loc) => {
74+
setLocation(loc)
75+
setIsLoading(false)
76+
})
77+
}, [])
78+
79+
return { location, isLoading }
80+
}

src/pages/places/index.tsx

Lines changed: 58 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ function Places(): JSX.Element {
1515
const [selectedLayers, setSelectedLayers] = useState<string[]>(Object.values(PlaceType))
1616
const [places, setPlaces] = useState<PlaceItem[]>([])
1717
const [selectedPlace, setSelectedPlace] = useState<PlaceItem | null>(null)
18+
const [isInitialized, setIsInitialized] = useState(false)
1819
const placesRef = useRef<PlaceItem[]>([])
1920

2021
// Receive places data from PlacesMap component
@@ -32,7 +33,7 @@ function Places(): JSX.Element {
3233
setTimeout(() => {
3334
const place = placesRef.current.find((p) => p.id === placeId)
3435
if (place) {
35-
setSelectedPlace(place)
36+
handlePlaceSelect(place)
3637
}
3738
}, 500)
3839
}
@@ -42,14 +43,64 @@ function Places(): JSX.Element {
4243
return () => window.removeEventListener('hogmap:places-updated', handlePlaceAdded as EventListener)
4344
}, [])
4445

45-
// Show place detail overlay when a marker is clicked
46-
const handlePlaceClick = useCallback((placeId: number) => {
47-
const place = placesRef.current.find((p) => p.id === placeId)
48-
if (place) {
49-
setSelectedPlace(place)
46+
// Select a place and update URL hash
47+
const handlePlaceSelect = useCallback((place: PlaceItem, updateHash = true) => {
48+
setSelectedPlace(place)
49+
50+
if (updateHash) {
51+
window.history.replaceState(null, '', `#placeId=${place.id}`)
5052
}
5153
}, [])
5254

55+
// Show place detail overlay when a marker is clicked
56+
const handlePlaceClick = useCallback(
57+
(placeId: number) => {
58+
const place = placesRef.current.find((p) => p.id === placeId)
59+
if (place) {
60+
handlePlaceSelect(place)
61+
}
62+
},
63+
[handlePlaceSelect]
64+
)
65+
66+
// Close place detail and clear URL hash
67+
const handleClosePlace = useCallback(() => {
68+
setSelectedPlace(null)
69+
window.history.replaceState(null, '', window.location.pathname)
70+
}, [])
71+
72+
// Initialize from URL hash on page load
73+
useEffect(() => {
74+
if (!isInitialized && places.length > 0) {
75+
const hash = window.location.hash
76+
const match = hash.match(/#placeId=(\d+)/)
77+
78+
if (match) {
79+
const placeId = parseInt(match[1], 10)
80+
const place = places.find((p) => p.id === placeId)
81+
82+
if (place) {
83+
// Select place without updating hash (since we're reading from it)
84+
handlePlaceSelect(place, false)
85+
}
86+
}
87+
88+
setIsInitialized(true)
89+
}
90+
}, [places, isInitialized, handlePlaceSelect])
91+
92+
// Handle ESC key to close detail panel
93+
useEffect(() => {
94+
const handleKeyDown = (e: KeyboardEvent) => {
95+
if (e.key === 'Escape' && selectedPlace) {
96+
handleClosePlace()
97+
}
98+
}
99+
100+
window.addEventListener('keydown', handleKeyDown)
101+
return () => window.removeEventListener('keydown', handleKeyDown)
102+
}, [selectedPlace, handleClosePlace])
103+
53104
// Count places by type
54105
const placesByType = places.reduce((acc, place) => {
55106
const type = place.type || PlaceType.COFFEE
@@ -156,9 +207,7 @@ function Places(): JSX.Element {
156207

157208
<div className="flex-1 relative h-full border-primary border-t @xl:border-t-0">
158209
<AnimatePresence>
159-
{selectedPlace && (
160-
<PlaceDetail place={selectedPlace} onClose={() => setSelectedPlace(null)} />
161-
)}
210+
{selectedPlace && <PlaceDetail place={selectedPlace} onClose={handleClosePlace} />}
162211
</AnimatePresence>
163212

164213
<PlacesMap

0 commit comments

Comments
 (0)