import { useEffect, useState, useRef, useCallback } from "react" import { X, Navigation, Plus, Bookmark, ChevronDown, ChevronUp, Copy, Clock, Phone, Globe, Mail, BookOpen, Info, Trees, GripVertical, } 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" 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 (
{label}
{children}
) } function HoursDisplay({ hoursStr, first }) { const [expanded, setExpanded] = useState(false) const parsed = parseHours(hoursStr) if (!parsed) return null const { isOpen, todayStr, week } = parsed return ( {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 EnrichmentSkeleton() { return (
) } 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 && ( )} {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 && View on Wikidata}
)}
) } 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 (
) } export function PlaceCard({ place, variant = "preview", expanded = true, onToggleExpand, onClose, onRemove, stopIndex, draggable = false, dragHandleProps = {} }) { const { contacts, userLocation, stops, geoPermission, addStop, startDirections, clearSelectedPlace, setEditingContact } = useStore() 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]) 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 }) } } } }) return () => controller.abort() }, [osmType, osmId, placeLat, placeLon]) useEffect(() => { if (osmType && osmId) return 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 }) } } } }) 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") || 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 && }
) } return (
{draggable &&
} {stopLetter &&
{stopLetter}
}
{(place.raw?.name || place.name) || "Unknown place"}
{place.type && {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" && }
{variant === "preview" && ( <> {stops.length < 2 && } {existingStopIndex >= 0 ? ( Stop {String.fromCharCode(65 + existingStopIndex)} ) : ( )} )} {variant === "stop" && onRemove && }
{copyOpen && }
) } export default PlaceCard