import { useEffect, useState, useRef, useCallback } from "react"
import {
X, Navigation, Plus, Bookmark, ChevronDown, ChevronUp, Copy, LogIn,
Clock, Phone, Globe, Mail, BookOpen, Info, Trees, GripVertical, Map, Users,
} 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, fetchReverse } from "../api"
import { hasFeature } from "../config"
import { buildAddress } from "../utils/place"
// Wiki service icons (simplified monochrome versions)
const WikipediaIcon = ({ size = 13, style }) => (
)
const WikivoyageIcon = ({ size = 13, style }) => (
)
const WikidataIcon = ({ size = 13, style }) => (
)
const M_TO_FT = 3.28084
function formatDriveTime(seconds) {
const mins = Math.round(seconds / 60)
if (mins < 2) return "< 2 min"
if (mins < 120) return `${mins} min`
const h = Math.floor(mins / 60)
const m = mins % 60
return m > 0 ? `${h}h ${m}m` : `${h}h`
}
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 " + closeTime
}
} else {
todayStr = "Closed"
if (nextChange) {
const openTime = nextChange.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })
const isTodayOpen = nextChange.getDate() === now.getDate()
todayStr += " \u00b7 Opens " + (isTodayOpen ? "at " : "tomorrow ") + 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", isTodayRow: 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(", "), isTodayRow: d === now.getDay() })
}
}
return { isOpen, todayStr, week }
} catch {
return null
}
}
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 [lang, ...rest] = wp.split(":")
const title = rest.join(":").replace(/ /g, "_")
return "https://" + lang + ".wikipedia.org/wiki/" + encodeURIComponent(title)
}
function wikiLabel(wp) {
if (!wp) return null
const [, ...rest] = wp.split(":")
return rest.join(":").replace(/_/g, " ")
}
function DetailSection({ label, icon: Icon, first, children }) {
return (
)
}
function HoursDisplay({ hoursStr, first }) {
const [expanded, setExpanded] = useState(false)
const parsed = parseHours(hoursStr)
if (!parsed) return null
const { isOpen, todayStr, week } = parsed
return (
setExpanded((v) => !v)} className="w-full flex items-center justify-between text-xs" style={{ color: "var(--text-primary)" }}>
{todayStr}
{expanded ? : }
{expanded && (
{week.map((w) => (
{w.day}
{w.hours}
))}
)}
)
}
function LandclassSection({ data }) {
if (!data || !data.summary) return null
return (
{data.summary}
{data.unit_name && {data.unit_name} }
)
}
function PrivateLandIndicator({ data }) {
if (!data || data.gap_status !== "4") return null
return (
Private land — permission required
)
}
function WikiSummarySection({ details }) {
// Gate on has_kiwix_wiki feature flag
if (!hasFeature('has_kiwix_wiki')) return null
if (!details || !details.wiki_summary) return null
return (
{/* Summary text */}
{details.wiki_summary}
{/* Population */}
{details.wiki_population && (
Pop. {details.wiki_population.toLocaleString ? details.wiki_population.toLocaleString() : details.wiki_population}
)}
)
}
function EnrichmentSkeleton() {
return (
)
}
function EnrichmentSections({ details }) {
if (!details) return null
const { category, extratags, wiki_url, wikivoyage_url } = 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 || wiki_url || wikivoyage_url
if (!hasAbout && !hasHours && !hasContact && !hasDetails && !hasLinks) return null
let idx = 0
return (
{hasAbout && (
{category}
)}
{hasHours &&
}
{hasContact && (
)}
{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 && (
)}
)
}
function CopyPopover({ address, place, onClose }) {
const ref = useRef(null)
useEffect(() => {
function handleClick(e) { if (ref.current && !ref.current.contains(e.target)) onClose() }
document.addEventListener("mousedown", handleClick)
return () => document.removeEventListener("mousedown", handleClick)
}, [onClose])
const copyAddress = () => {
const text = [place.name, address].filter(Boolean).join("\n")
navigator.clipboard.writeText(text).then(() => toast("Address copied"), () => toast.error("Failed to copy"))
onClose()
}
const copyCoords = () => {
const text = place.lat.toFixed(6) + ", " + place.lon.toFixed(6)
navigator.clipboard.writeText(text).then(() => toast("Coordinates copied"), () => toast.error("Failed to copy"))
onClose()
}
return (
Address
Coordinates
)
}
export function PlaceCard({ place, variant = "preview", expanded = true, onToggleExpand, onClose, onRemove, stopIndex, draggable = false, dragHandleProps = {} }) {
const contacts = useStore((s) => s.contacts)
const userLocation = useStore((s) => s.userLocation)
const stops = useStore((s) => s.stops)
const geoPermission = useStore((s) => s.geoPermission)
const addStop = useStore((s) => s.addStop)
const startDirections = useStore((s) => s.startDirections)
const clearSelectedPlace = useStore((s) => s.clearSelectedPlace)
const setEditingContact = useStore((s) => s.setEditingContact)
const auth = useStore((s) => s.auth)
const [elevResult, setElevResult] = useState({ lat: null, lon: null, value: null })
const [placeDetails, setPlaceDetails] = useState(null)
const [driveTime, setDriveTime] = useState(null)
const [nearbyLabel, setNearbyLabel] = useState(null)
const [landclass, setLandclass] = useState(null)
const [copyOpen, setCopyOpen] = useState(false)
const placeLat = place?.lat
const placeLon = place?.lon
const osmType = place?.raw?.osm_type
const osmId = place?.raw?.osm_id
const wikidataId = place?.wikidata || place?.raw?.wikidata
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])
// Reverse geocode to get OSM type/id if not present (e.g., basemap label clicks)
useEffect(() => {
if (!hasFeature('has_nominatim_details')) return
if (wikidataId) return // Prefer wikidata path for basemap features with wikidata
if (placeLat == null || placeLon == null) return
if (osmType && osmId) return
// Skip for dropped pins - they get reverse geocoded by MapView
if (place?.source === 'map_click') return
const controller = new AbortController()
fetchReverse(placeLat, placeLon).then((result) => {
if (controller.signal.aborted) return
if (result?.raw?.osm_type && result?.raw?.osm_id) {
const current = useStore.getState().selectedPlace
if (current && current.lat === placeLat && current.lon === placeLon) {
// Merge OSM data into raw, preserving existing data
useStore.getState().setSelectedPlace({
...current,
raw: { ...current.raw, osm_type: result.raw.osm_type, osm_id: result.raw.osm_id }
})
}
}
})
return () => controller.abort()
}, [wikidataId, placeLat, placeLon, osmType, osmId, place?.source])
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)
if (data?.boundary) {
const current = useStore.getState().selectedPlace
if (current && current.lat === placeLat && current.lon === placeLon) {
useStore.getState().setSelectedPlace({ ...current, boundary: data.boundary })
// Call updateBoundary directly - bypass React render cycle
const updateBoundary = useStore.getState().updateBoundary
if (updateBoundary) updateBoundary(data.boundary)
}
}
}
})
return () => controller.abort()
}, [osmType, osmId, placeLat, placeLon])
useEffect(() => {
if (!wikidataId) return
const controller = new AbortController()
fetchPlaceByWikidata(wikidataId, controller.signal).then((data) => {
if (!controller.signal.aborted && data) {
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 },
}))
if (data?.boundary) {
const current = useStore.getState().selectedPlace
if (current && current.lat === placeLat && current.lon === placeLon) {
useStore.getState().setSelectedPlace({ ...current, boundary: data.boundary })
// Call updateBoundary directly - bypass React render cycle
const updateBoundary = useStore.getState().updateBoundary
if (updateBoundary) updateBoundary(data.boundary)
}
}
}
})
return () => controller.abort()
}, [wikidataId, osmType, osmId, placeLat, placeLon])
useEffect(() => {
if (variant !== "preview" || !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) }
}, [variant, userLocation?.lat, userLocation?.lon, placeLat, placeLon])
useEffect(() => {
if (!hasFeature("has_contacts") || !auth.authenticated || 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])
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)
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])
if (!place) return null
const address = buildAddress(place)
const elevLoading = placeLat != null && (elevResult.lat !== placeLat || elevResult.lon !== placeLon)
const elevation = !elevLoading ? elevResult.value : null
const elevFeet = elevation != null ? Math.round(elevation * M_TO_FT) : null
const existingStopIndex = stops.findIndex((s) => s.lat === place.lat && s.lon === place.lon)
const savedContact = contacts.find((c) => c.lat === place.lat && c.lon === place.lon)
const handleDirections = () => {
// No toast - empty origin slot is the visual prompt
startDirections(place)
}
const handleAddStop = () => {
addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode })
clearSelectedPlace()
}
const handleSave = () => {
if (!hasFeature("has_contacts")) { toast("Saved places coming soon"); return }
if (savedContact) setEditingContact(savedContact)
else setEditingContact({ label: "", lat: place.lat, lon: place.lon, osm_type: osmType || null, osm_id: osmId || null, address: address || "", name: place.type === "poi" && place.raw?.name ? place.raw.name : "" })
}
const closeCopy = useCallback(() => setCopyOpen(false), [])
const stopLetter = stopIndex != null ? String.fromCharCode(65 + stopIndex) : null
if (!expanded) {
return (
{draggable &&
}
{stopLetter &&
{stopLetter}
}
{(place.raw?.name || place.name) || "Unknown place"}
{onRemove &&
{ e.stopPropagation(); onRemove() }} className="p-1 rounded hover:opacity-80" style={{ color: "var(--text-tertiary)" }}> }
)
}
return (
{draggable &&
}
{stopLetter &&
{stopLetter}
}
{(place.raw?.name || place.name) || "Unknown place"}
{place.type && !place.type.includes("_") && !["poi", "unknown", "yes", "no", "other", ""].includes(place.type.toLowerCase()) && {place.type} }
{driveTime != null && <>{"\u00b7"} {formatDriveTime(driveTime)} drive >}
{nearbyLabel && <>{"\u00b7"} Near {nearbyLabel} >}
{onToggleExpand && variant === "stop" && }
{onClose && }
{address &&
{address}
}
{place.lat.toFixed(6)}, {place.lon.toFixed(6)}
{"\u00b7"}
{elevLoading ? "..." : elevFeet != null ? elevFeet.toLocaleString() + " ft" : "\u2014"}
{placeDetails === "loading" &&
}
{placeDetails && placeDetails !== "loading" &&
}
{placeDetails && placeDetails !== "loading" &&
}
{variant === "preview" && (
<>
{stops.length < 2 &&
Get Directions}
{existingStopIndex >= 0 ? (
Stop {String.fromCharCode(65 + existingStopIndex)}
) : (
Add stop
)}
>
)}
{variant === "stop" && onRemove &&
Remove}
{auth.authenticated ? (
) : (
{ window.location.href = "/outpost.goauthentik.io/start?rd=%2F" }} className="flex items-center gap-1 px-2 py-1.5 rounded-lg text-xs" style={{ background: "var(--accent-muted)", color: "var(--accent)", border: "1px solid var(--border)" }} title="Log in to save places">Save
)}
setCopyOpen((v) => !v)} className="p-2 rounded-lg flex items-center gap-0.5" style={{ background: "var(--tan-muted)", color: "var(--tan)", border: "1px solid var(--border)" }} aria-label="Copy">
{copyOpen && }
)
}
export default PlaceCard