Add contacts/phone book UI with search integration

New components:
- ContactModal.jsx: Save/edit overlay with form fields and soft delete
- ContactList.jsx: Contacts tab with filter, create, and tap-to-navigate

Modified:
- store.js: Add contacts slice (contacts, activeTab, editingContact)
- api.js: Add contacts API functions (fetch, create, update, delete, nearby)
- config.js: Add has_contacts fallback flag
- Panel.jsx: Routes/Contacts tab bar (only when has_contacts enabled)
- PlaceDetail.jsx: Save button opens ContactModal, proximity annotation
- SearchBar.jsx: Prepend matching contacts before Photon results
- App.jsx: Render ContactModal at top level
- index.css: Modal overlay, tab bar, contact list item styles

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt 2026-04-22 05:30:19 +00:00
commit 3ce860c1e8
10 changed files with 1087 additions and 66 deletions

View file

@ -1,25 +1,187 @@
import { useEffect, useState, useRef, useCallback } from 'react'
import { X, Navigation, Plus, Bookmark, ChevronDown, Copy } from 'lucide-react'
import {
X, Navigation, Plus, Bookmark, ChevronDown, ChevronUp, Copy,
Clock, Phone, Globe, Mail, BookOpen, Info,
} from 'lucide-react'
import OpeningHours from 'opening_hours'
import toast from 'react-hot-toast'
import { useStore } from '../store'
import { fetchElevation } from '../api'
import { fetchElevation, fetchPlaceDetails, fetchDriveTime, fetchNearbyContacts } from '../api'
import { hasFeature } from '../config'
import { buildAddress } from '../utils/place'
/** Meters to feet */
const M_TO_FT = 3.28084
/** Build display address from raw result data */
function buildAddress(place) {
if (place.address) return place.address
const raw = place.raw || {}
const parts = [raw.street, raw.city, raw.state, raw.postcode].filter(Boolean)
return parts.join(', ') || null
/** 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`
}
/** Copy popover — small dropdown beneath the Copy button */
// 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 (
<div
className="place-detail-section"
style={first ? {} : { borderTop: '1px solid var(--border-subtle)', paddingTop: '10px' }}
>
<div className="place-detail-section-header">
{Icon && <Icon size={12} style={{ opacity: 0.6 }} />}
<span>{label}</span>
</div>
{children}
</div>
)
}
// Hours display
function HoursDisplay({ hoursStr, first }) {
const [expanded, setExpanded] = useState(false)
const parsed = parseHours(hoursStr)
if (!parsed) {
return (
<DetailSection label="Hours" icon={Clock} first={first}>
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>{hoursStr}</p>
</DetailSection>
)
}
return (
<DetailSection label="Hours" icon={Clock} first={first}>
<button
onClick={() => setExpanded((v) => !v)}
className="w-full flex items-center justify-between text-xs"
style={{ color: 'var(--text-primary)' }}
>
<span>
<span
className="inline-block w-1.5 h-1.5 rounded-full mr-1.5"
style={{ background: parsed.isOpen ? 'var(--accent)' : 'var(--tan)' }}
/>
{parsed.todayStr}
</span>
{expanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
</button>
{expanded && (
<div className="mt-2 flex flex-col gap-0.5">
{parsed.week.map((d) => (
<div
key={d.day}
className="flex justify-between text-xs"
style={{
color: d.isToday ? 'var(--text-primary)' : 'var(--text-secondary)',
fontWeight: d.isToday ? 600 : 400,
}}
>
<span>{d.day}</span>
<span>{d.hours}</span>
</div>
))}
</div>
)}
</DetailSection>
)
}
// Copy popover
function CopyPopover({ address, selectedPlace, onClose }) {
const ref = useRef(null)
// Close on click-outside
useEffect(() => {
function handleClick(e) {
if (ref.current && !ref.current.contains(e.target)) onClose()
@ -86,6 +248,124 @@ function CopyPopover({ address, selectedPlace, onClose }) {
)
}
// 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 (
<div className="mt-3 flex flex-col gap-2.5">
{hasAbout && (
<DetailSection label="About" icon={Info} first={idx++ === 0}>
<span className="category-badge">{category}</span>
</DetailSection>
)}
{hasHours && <HoursDisplay hoursStr={et.opening_hours} first={idx++ === 0} />}
{hasContact && (
<DetailSection label="Contact" icon={Phone} first={idx++ === 0}>
<div className="flex flex-col gap-1.5">
{et.phone && (
<a href={`tel:${et.phone}`} className="flex items-center gap-2 text-xs" style={{ color: 'var(--text-primary)' }}>
<Phone size={13} style={{ color: 'var(--text-tertiary)', flexShrink: 0 }} />
{formatPhone(et.phone)}
</a>
)}
{et.website && (
<a
href={et.website.startsWith('http') ? et.website : `https://${et.website}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-xs truncate"
style={{ color: 'var(--accent)' }}
>
<Globe size={13} style={{ color: 'var(--text-tertiary)', flexShrink: 0 }} />
{et.website.replace(/^https?:\/\//, '').replace(/\/$/, '')}
</a>
)}
{et.email && (
<a href={`mailto:${et.email}`} className="flex items-center gap-2 text-xs" style={{ color: 'var(--text-primary)' }}>
<Mail size={13} style={{ color: 'var(--text-tertiary)', flexShrink: 0 }} />
{et.email}
</a>
)}
</div>
</DetailSection>
)}
{hasDetails && (
<DetailSection label="Details" icon={Info} first={idx++ === 0}>
<div className="flex flex-col gap-1 text-xs" style={{ color: 'var(--text-secondary)' }}>
{et.cuisine && <span>Cuisine: {et.cuisine.replace(/_/g, ' ').replace(/;/g, ', ')}</span>}
{et.operator && <span>Operated by {et.operator}</span>}
{et.fee && <span>{et.fee === 'no' ? 'Free' : `Fee: ${et.fee}`}</span>}
{et.wheelchair && wheelchairLabel(et.wheelchair) && <span>{wheelchairLabel(et.wheelchair)}</span>}
{et.takeaway === 'yes' && <span>Takeaway available</span>}
</div>
</DetailSection>
)}
{hasLinks && (
<DetailSection label="Links" icon={BookOpen} first={idx++ === 0}>
<div className="flex flex-col gap-1.5">
{et.wikipedia && wikiUrl(et.wikipedia) && (
<a
href={wikiUrl(et.wikipedia)}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-xs"
style={{ color: 'var(--accent)' }}
>
<BookOpen size={13} style={{ color: 'var(--text-tertiary)', flexShrink: 0 }} />
{wikiLabel(et.wikipedia)}
</a>
)}
{et.wikidata && (
<a
href={`https://www.wikidata.org/wiki/${et.wikidata}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-xs font-mono"
style={{ color: 'var(--text-tertiary)' }}
>
Wikidata: {et.wikidata}
</a>
)}
</div>
</DetailSection>
)}
</div>
)
}
// Skeleton loader
function EnrichmentSkeleton() {
return (
<div className="mt-3 flex flex-col gap-2.5 animate-pulse">
<div className="h-3 rounded w-16" style={{ background: 'var(--border-subtle)' }} />
<div className="h-3 rounded w-32" style={{ background: 'var(--border-subtle)' }} />
<div className="h-3 rounded w-24" style={{ background: 'var(--border-subtle)' }} />
</div>
)
}
// Main component
export default function PlaceDetail() {
const selectedPlace = useStore((s) => s.selectedPlace)
const clearSelectedPlace = useStore((s) => s.clearSelectedPlace)
@ -93,12 +373,18 @@ export default function PlaceDetail() {
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 [copyForPlace, setCopyForPlace] = useState(null)
const [copyOpen, setCopyOpen] = useState(false)
const [placeDetails, setPlaceDetails] = useState(null)
const [driveTime, setDriveTime] = useState(null)
const [nearbyLabel, setNearbyLabel] = useState(null)
const closeCopy = useCallback(() => setCopyForPlace(null), [])
const closeCopy = useCallback(() => setCopyOpen(false), [])
useEffect(() => {
const check = () => setIsMobile(window.innerWidth < 768)
@ -107,6 +393,8 @@ export default function PlaceDetail() {
return () => window.removeEventListener('resize', check)
}, [])
// Close copy popover when place changes
useEffect(() => { setCopyOpen(false) }, [selectedPlace])
// Escape key closes panel
useEffect(() => {
@ -130,22 +418,96 @@ export default function PlaceDetail() {
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 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])
// 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
const placeKey = selectedPlace ? `${selectedPlace.lat},${selectedPlace.lon}` : null
if (!selectedPlace) return null
const address = buildAddress(selectedPlace)
const elevFeet = elevation != null ? Math.round(elevation * M_TO_FT) : null
const raw = selectedPlace.raw || {}
// 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) {
@ -165,7 +527,25 @@ export default function PlaceDetail() {
}
const handleSave = () => {
toast('Saved places coming soon')
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 = (
@ -183,13 +563,23 @@ export default function PlaceDetail() {
{/* Place name */}
<div className="pr-8">
<h2 className="text-md font-semibold" style={{ color: 'var(--text-primary)' }}>
{selectedPlace.name}
{selectedPlace.type === 'poi' && selectedPlace.raw?.name
? selectedPlace.raw.name
: selectedPlace.name}
</h2>
{selectedPlace.type && (
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
{selectedPlace.type}
</span>
)}
{(() => {
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 (
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
{parts.join(' \u00b7 ')}
</span>
)
})()}
</div>
{/* Address */}
@ -208,24 +598,9 @@ export default function PlaceDetail() {
</span>
</div>
{/* Optional extras */}
{(raw.opening_hours || raw.website || raw.phone) && (
<div className="mt-3 flex flex-col gap-1 text-xs" style={{ color: 'var(--text-secondary)' }}>
{raw.opening_hours && <span>{raw.opening_hours}</span>}
{raw.website && (
<a
href={raw.website}
target="_blank"
rel="noopener noreferrer"
className="underline truncate"
style={{ color: 'var(--accent)' }}
>
{raw.website.replace(/^https?:\/\//, '').replace(/\/$/, '')}
</a>
)}
{raw.phone && <span>{raw.phone}</span>}
</div>
)}
{/* OSM enrichment sections */}
{placeDetails === 'loading' && <EnrichmentSkeleton />}
{placeDetails && placeDetails !== 'loading' && <EnrichmentSections details={placeDetails} />}
{/* Action buttons */}
<div className="mt-auto pt-4 flex gap-2">
@ -259,16 +634,20 @@ export default function PlaceDetail() {
<button
onClick={handleSave}
className="p-2 rounded-lg"
style={{ background: 'var(--tan-muted)', color: 'var(--tan)', border: '1px solid var(--border)' }}
aria-label="Save place"
style={{
background: savedContact ? 'var(--accent-muted)' : 'var(--tan-muted)',
color: savedContact ? 'var(--accent)' : 'var(--tan)',
border: '1px solid var(--border)',
}}
aria-label={savedContact ? 'Edit saved contact' : 'Save place'}
>
<Bookmark size={14} />
<Bookmark size={14} fill={savedContact ? 'currentColor' : 'none'} />
</button>
{/* Copy dropdown */}
<div className="relative">
<button
onClick={() => setCopyForPlace((v) => v === placeKey ? null : placeKey)}
onClick={() => 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"
@ -276,7 +655,7 @@ export default function PlaceDetail() {
<Copy size={14} />
<ChevronDown size={10} />
</button>
{copyForPlace === placeKey && (
{copyOpen && (
<CopyPopover
address={address}
selectedPlace={selectedPlace}
@ -296,7 +675,8 @@ export default function PlaceDetail() {
style={{
background: 'var(--bg-raised)',
borderTop: '1px solid var(--border)',
maxHeight: '50vh',
maxHeight: '60vh',
overflowY: 'auto',
}}
>
{panelContent}