import { useEffect, useState, useRef, useCallback } from 'react' import { X, Navigation, Plus, Bookmark, ChevronDown, ChevronUp, Copy, Clock, Phone, Globe, Mail, BookOpen, Info, Trees, } from 'lucide-react' import OpeningHours from 'opening_hours' import toast from 'react-hot-toast' import { useStore } from '../store' import { fetchElevation, fetchPlaceDetails, fetchPlaceByWikidata, fetchDriveTime, fetchNearbyContacts, fetchLandclass } from '../api' import { hasFeature } from '../config' import { buildAddress } from '../utils/place' /** Meters to feet */ const M_TO_FT = 3.28084 /** Format drive time (seconds) to human-readable string */ function formatDriveTime(seconds) { const mins = Math.round(seconds / 60) if (mins < 2) return '< 2 min drive' if (mins < 120) return `${mins} min drive` const h = Math.floor(mins / 60) const m = mins % 60 return m > 0 ? `${h}h ${m}m drive` : `${h}h drive` } // ── Opening hours helpers ────────────────────────────────────────────── const DAY_SHORT = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] function parseHours(hoursStr) { try { const oh = new OpeningHours(hoursStr, { address: { country_code: 'us', state: 'Idaho' } }) const now = new Date() const isOpen = oh.getState(now) const nextChange = oh.getNextChange(now) let todayStr = '' if (isOpen) { todayStr = 'Open now' if (nextChange) { const closeTime = nextChange.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }) todayStr += ` \u00b7 Closes at ${closeTime}` } } else { todayStr = 'Closed' if (nextChange) { const openTime = nextChange.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }) const isToday = nextChange.getDate() === now.getDate() todayStr += ` \u00b7 Opens ${isToday ? 'at' : 'tomorrow at'} ${openTime}` } } const week = [] for (let d = 0; d < 7; d++) { const date = new Date(now) const diff = (d - now.getDay() + 7) % 7 date.setDate(now.getDate() + diff) date.setHours(0, 0, 0, 0) const intervals = oh.getOpenIntervals(date, new Date(date.getTime() + 86400000)) if (intervals.length === 0) { week.push({ day: DAY_SHORT[d], hours: 'Closed', isToday: d === now.getDay() }) } else { const parts = intervals.map(([start, end]) => { const s = start.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }) const e = end.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }) return `${s} \u2013 ${e}` }) week.push({ day: DAY_SHORT[d], hours: parts.join(', '), isToday: d === now.getDay() }) } } return { isOpen, todayStr, week } } catch { return null } } // ── Formatting helpers ───────────────────────────────────────────────── function formatPhone(phone) { if (!phone) return null const digits = phone.replace(/[^\d]/g, '') if (digits.length === 11 && digits[0] === '1') { return `(${digits.slice(1, 4)}) ${digits.slice(4, 7)}-${digits.slice(7)}` } if (digits.length === 10) { return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}` } return phone } function wheelchairLabel(val) { if (!val) return null const map = { yes: 'Accessible', limited: 'Limited access', no: 'Not accessible' } return map[val.toLowerCase()] || null } function wikiUrl(wp) { if (!wp) return null const match = wp.match(/^([a-z-]+):(.+)$/) if (!match) return null return `https://${match[1]}.wikipedia.org/wiki/${encodeURIComponent(match[2].replace(/ /g, '_'))}` } function wikiLabel(wp) { if (!wp) return null const match = wp.match(/^[a-z-]+:(.+)$/) return match ? match[1].replace(/_/g, ' ') : wp } // ── Section wrapper ──────────────────────────────────────────────────── function DetailSection({ label, icon: Icon, first, children }) { return (
{Icon && } {label}
{children}
) } // ── Hours display ────────────────────────────────────────────────────── function HoursDisplay({ hoursStr, first }) { const [expanded, setExpanded] = useState(false) const parsed = parseHours(hoursStr) if (!parsed) { return (

{hoursStr}

) } return ( {expanded && (
{parsed.week.map((d) => (
{d.day} {d.hours}
))}
)}
) } // ── Copy popover ─────────────────────────────────────────────────────── function CopyPopover({ address, selectedPlace, onClose }) { const ref = useRef(null) useEffect(() => { function handleClick(e) { if (ref.current && !ref.current.contains(e.target)) onClose() } function handleKey(e) { if (e.key === 'Escape') onClose() } document.addEventListener('mousedown', handleClick) document.addEventListener('keydown', handleKey) return () => { document.removeEventListener('mousedown', handleClick) document.removeEventListener('keydown', handleKey) } }, [onClose]) const copyAddress = () => { const text = [selectedPlace.name, address].filter(Boolean).join('\n') navigator.clipboard.writeText(text).then( () => toast('Address copied'), () => toast.error('Failed to copy') ) onClose() } const copyCoords = () => { const text = `${selectedPlace.lat.toFixed(6)}, ${selectedPlace.lon.toFixed(6)}` navigator.clipboard.writeText(text).then( () => toast('Coordinates copied'), () => toast.error('Failed to copy') ) onClose() } return (
) } // ── Enrichment sections ──────────────────────────────────────────────── function EnrichmentSections({ details }) { if (!details) return null const { category, extratags } = details const et = extratags || {} const hasAbout = category const hasHours = et.opening_hours const hasContact = et.phone || et.website || et.email const hasDetails = et.cuisine || et.operator || et.fee || et.wheelchair || et.takeaway const hasLinks = et.wikipedia || et.wikidata if (!hasAbout && !hasHours && !hasContact && !hasDetails && !hasLinks) return null let idx = 0 return (
{hasAbout && ( {category} )} {hasHours && } {hasContact && (
{et.phone && ( {formatPhone(et.phone)} )} {et.website && ( {et.website.replace(/^https?:\/\//, '').replace(/\/$/, '')} )} {et.email && ( {et.email} )}
)} {hasDetails && (
{et.cuisine && Cuisine: {et.cuisine.replace(/_/g, ' ').replace(/;/g, ', ')}} {et.operator && Operated by {et.operator}} {et.fee && {et.fee === 'no' ? 'Free' : `Fee: ${et.fee}`}} {et.wheelchair && wheelchairLabel(et.wheelchair) && {wheelchairLabel(et.wheelchair)}} {et.takeaway === 'yes' && Takeaway available}
)} {hasLinks && (
{et.wikipedia && wikiUrl(et.wikipedia) && ( {wikiLabel(et.wikipedia)} )} {et.wikidata && ( Wikidata: {et.wikidata} )}
)}
) } // ── Skeleton loader ──────────────────────────────────────────────────── // ── Land classification display ────────────────────────────────────────────────────────────────────── function LandclassSection({ data }) { if (!data || data.is_public !== true || !data.classifications?.length) return null return (
{data.classifications.map((c, i) => (
{c.unit_name} {(c.owner_type || c.manager_name || c.designation_type) && ( {[c.owner_type, c.manager_name, c.designation_type].filter(Boolean).join(' \u203a ')} )} {c.public_access && c.public_access !== 'Unknown' && ( {c.public_access} )}
))}
) } function PrivateLandIndicator({ data }) { if (!data || data.is_private !== true) return null return (

Private land

) } function EnrichmentSkeleton() { return (
) } // ── Main component ───────────────────────────────────────────────────── export default function PlaceDetail() { const selectedPlace = useStore((s) => s.selectedPlace) const clearSelectedPlace = useStore((s) => s.clearSelectedPlace) const startDirections = useStore((s) => s.startDirections) const addStop = useStore((s) => s.addStop) const stops = useStore((s) => s.stops) const geoPermission = useStore((s) => s.geoPermission) const userLocation = useStore((s) => s.userLocation) const contacts = useStore((s) => s.contacts) const setEditingContact = useStore((s) => s.setEditingContact) const [elevResult, setElevResult] = useState({ lat: null, lon: null, value: null }) const [isMobile, setIsMobile] = useState(false) const [copyOpen, setCopyOpen] = useState(false) const [placeDetails, setPlaceDetails] = useState(null) const [driveTime, setDriveTime] = useState(null) const [nearbyLabel, setNearbyLabel] = useState(null) const [landclass, setLandclass] = useState(null) const closeCopy = useCallback(() => setCopyOpen(false), []) useEffect(() => { const check = () => setIsMobile(window.innerWidth < 768) check() window.addEventListener('resize', check) return () => window.removeEventListener('resize', check) }, []) // Close copy popover when place changes useEffect(() => { setCopyOpen(false) }, [selectedPlace]) // Escape key closes panel useEffect(() => { if (!selectedPlace) return function handleKey(e) { if (e.key === 'Escape') clearSelectedPlace() } document.addEventListener('keydown', handleKey) return () => document.removeEventListener('keydown', handleKey) }, [selectedPlace, clearSelectedPlace]) // Fetch elevation when place changes const placeLat = selectedPlace?.lat const placeLon = selectedPlace?.lon useEffect(() => { if (placeLat == null || placeLon == null) return let cancelled = false fetchElevation(placeLat, placeLon).then((h) => { if (!cancelled) setElevResult({ lat: placeLat, lon: placeLon, value: h }) }) return () => { cancelled = true } }, [placeLat, placeLon]) // Fetch place details when place changes (if feature enabled) const osmType = selectedPlace?.raw?.osm_type const osmId = selectedPlace?.raw?.osm_id useEffect(() => { if (!hasFeature('has_nominatim_details') || !osmType || !osmId) { setPlaceDetails(null) return } const controller = new AbortController() setPlaceDetails('loading') fetchPlaceDetails(osmType, osmId, controller.signal).then((data) => { if (!controller.signal.aborted) { setPlaceDetails(data || null) } }) return () => controller.abort() }, [osmType, osmId]) // Fetch wikidata enrichment when place has wikidata but no OSM details const wikidataId = selectedPlace?.wikidata || selectedPlace?.raw?.wikidata useEffect(() => { // Skip if OSM details are available (they provide richer data) if (osmType && osmId) return // Skip if no wikidata ID if (!wikidataId) return const controller = new AbortController() fetchPlaceByWikidata(wikidataId, controller.signal).then((data) => { if (!controller.signal.aborted && data) { // Merge wikidata info into placeDetails (description, population, etc.) setPlaceDetails((prev) => ({ ...(prev === 'loading' ? {} : prev || {}), description: data.description, population: data.population, osm_relation_id: data.osm_relation_id, extratags: { ...(prev && prev !== 'loading' ? prev.extratags : {}), ...data.extratags, }, })) } }) return () => controller.abort() }, [wikidataId, osmType, osmId]) // Fetch drive time when place or user location changes useEffect(() => { if (!userLocation || placeLat == null || placeLon == null) { setDriveTime(null) return } setDriveTime(null) const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), 3000) fetchDriveTime( userLocation.lat, userLocation.lon, placeLat, placeLon, controller.signal ).then((time) => { if (!controller.signal.aborted) setDriveTime(time) }) return () => { controller.abort() clearTimeout(timeout) } }, [userLocation?.lat, userLocation?.lon, placeLat, placeLon]) // Fetch nearby contacts for proximity annotation useEffect(() => { if (!hasFeature('has_contacts') || placeLat == null || placeLon == null) { setNearbyLabel(null) return } const controller = new AbortController() fetchNearbyContacts(placeLat, placeLon, 75, controller.signal).then((nearby) => { if (!controller.signal.aborted && nearby.length > 0) { setNearbyLabel(nearby[0].label) } else if (!controller.signal.aborted) { setNearbyLabel(null) } }) return () => controller.abort() }, [placeLat, placeLon]) // Fetch land classification when place changes (if feature enabled) useEffect(() => { if (!hasFeature('has_landclass') || placeLat == null || placeLon == null) { setLandclass(null) return } const controller = new AbortController() fetchLandclass(placeLat, placeLon, controller.signal).then((data) => { if (!controller.signal.aborted && data) { setLandclass(data) // Upgrade "Dropped pin" name to land summary if reverse geocode didn't resolve if (data.summary && useStore.getState().selectedPlace?.name === 'Dropped pin') { const current = useStore.getState().selectedPlace useStore.getState().setSelectedPlace({ ...current, name: data.summary }) } } else if (!controller.signal.aborted) { setLandclass(null) } }) return () => controller.abort() }, [placeLat, placeLon]) // Derive elevation/loading from comparing result to current place const elevLoading = placeLat != null && (elevResult.lat !== placeLat || elevResult.lon !== placeLon) const elevation = !elevLoading ? elevResult.value : null if (!selectedPlace) return null const address = buildAddress(selectedPlace) const elevFeet = elevation != null ? Math.round(elevation * M_TO_FT) : null // Check if place is already in stops const existingStopIndex = stops.findIndex( (s) => Math.abs(s.lat - selectedPlace.lat) < 0.00001 && Math.abs(s.lon - selectedPlace.lon) < 0.00001 ) // Check if place is already saved as a contact const savedContact = hasFeature('has_contacts') ? contacts.find((c) => { if (c.osm_type && c.osm_id && osmType && osmId) { return c.osm_type === osmType && c.osm_id === osmId } if (c.lat != null && c.lon != null) { return Math.abs(c.lat - selectedPlace.lat) < 0.0001 && Math.abs(c.lon - selectedPlace.lon) < 0.0001 } return false }) : null const handleDirections = () => { startDirections(selectedPlace) if (geoPermission !== 'granted' && stops.length === 0) { toast('Set a starting point to get directions', { icon: '\u{1F4CD}' }) } } const handleAddStop = () => { addStop({ lat: selectedPlace.lat, lon: selectedPlace.lon, name: selectedPlace.name, source: selectedPlace.source, matchCode: selectedPlace.matchCode, }) clearSelectedPlace() } const handleSave = () => { if (!hasFeature('has_contacts')) { toast('Saved places coming soon') return } if (savedContact) { // Edit existing contact setEditingContact(savedContact) } else { // New contact pre-populated from place setEditingContact({ label: '', lat: selectedPlace.lat, lon: selectedPlace.lon, osm_type: osmType || null, osm_id: osmId || null, address: address || '', name: selectedPlace.type === 'poi' && selectedPlace.raw?.name ? selectedPlace.raw.name : '', }) } } const panelContent = ( <> {/* Close button */} {/* Place name */}

{selectedPlace.type === 'poi' && selectedPlace.raw?.name ? selectedPlace.raw.name : selectedPlace.name}

{(() => { const cat = placeDetails && placeDetails !== 'loading' ? placeDetails.category : null const parts = [] if (cat) parts.push(cat) if (nearbyLabel) parts.push(`near ${nearbyLabel}`) if (driveTime != null) parts.push(formatDriveTime(driveTime)) if (parts.length === 0) return null return ( {parts.join(' \u00b7 ')} ) })()}
{/* Address */} {address && (

{address}

)} {/* Coordinates + elevation */}
{selectedPlace.lat.toFixed(6)}, {selectedPlace.lon.toFixed(6)} · {elevLoading ? '...' : elevFeet != null ? `${elevFeet.toLocaleString()} ft` : '\u2014'}
{/* OSM enrichment sections */} {/* Land classification (PAD-US) */} {/* OSM enrichment sections */} {placeDetails === 'loading' && } {placeDetails && placeDetails !== 'loading' && } {/* Action buttons */}
{existingStopIndex >= 0 ? ( Added as stop {String.fromCharCode(65 + existingStopIndex)} ) : ( )} {/* Copy dropdown */}
{copyOpen && ( )}
) // Mobile: bottom overlay if (isMobile) { return (
{panelContent}
) } // Desktop: side panel return (
{panelContent}
) }