Improve Meeting Point UX: add address search, fix Indoor Map

- Indoor Map now opens c3nav in new tab (iframe blocked by X-Frame-Options)
- Meeting Point modal now has:
  - "Use My Location" button that fetches GPS position on demand
  - Address search using OpenStreetMap Nominatim API
  - Search results dropdown with clickable options
  - Manual coordinates entry (hidden by default)
  - Selected location preview with coordinates

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2025-12-15 16:38:14 -05:00
parent 0a234f902a
commit 50e1feb62c
2 changed files with 211 additions and 45 deletions

View File

@ -105,10 +105,12 @@ export default function DualMapView({
/> />
)} )}
{/* Mode toggle (when outdoor) */} {/* Indoor Map button - opens c3nav in new tab (iframe embedding blocked) */}
{activeView === 'outdoor' && ( {activeView === 'outdoor' && (
<button <a
onClick={goIndoor} href={`https://${eventId}.c3nav.de`}
target="_blank"
rel="noopener noreferrer"
className="absolute bottom-4 left-4 bg-rmaps-dark/90 text-white px-3 py-2 rounded-lg text-sm hover:bg-rmaps-dark transition-colors flex items-center gap-2 z-30" className="absolute bottom-4 left-4 bg-rmaps-dark/90 text-white px-3 py-2 rounded-lg text-sm hover:bg-rmaps-dark transition-colors flex items-center gap-2 z-30"
> >
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -120,7 +122,10 @@ export default function DualMapView({
/> />
</svg> </svg>
Indoor Map Indoor Map
</button> <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
)} )}
{/* Auto-mode indicator */} {/* Auto-mode indicator */}

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } from 'react';
import type { ParticipantLocation, WaypointType } from '@/types'; import type { ParticipantLocation, WaypointType } from '@/types';
interface MeetingPointModalProps { interface MeetingPointModalProps {
@ -14,6 +14,13 @@ interface MeetingPointModalProps {
}) => void; }) => void;
} }
interface SearchResult {
place_id: number;
display_name: string;
lat: string;
lon: string;
}
const EMOJI_OPTIONS = ['📍', '🎯', '🏁', '⭐', '🍺', '☕', '🍕', '🎪', '🚻', '🚪']; const EMOJI_OPTIONS = ['📍', '🎯', '🏁', '⭐', '🍺', '☕', '🍕', '🎪', '🚻', '🚪'];
export default function MeetingPointModal({ export default function MeetingPointModal({
@ -23,37 +30,122 @@ export default function MeetingPointModal({
}: MeetingPointModalProps) { }: MeetingPointModalProps) {
const [name, setName] = useState(''); const [name, setName] = useState('');
const [emoji, setEmoji] = useState('📍'); const [emoji, setEmoji] = useState('📍');
const [useCurrentLocation, setUseCurrentLocation] = useState(true); const [locationMode, setLocationMode] = useState<'current' | 'search' | 'manual'>('search');
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [selectedLocation, setSelectedLocation] = useState<{ lat: number; lng: number; name?: string } | null>(null);
const [isGettingLocation, setIsGettingLocation] = useState(false);
const [locationError, setLocationError] = useState<string | null>(null);
const [customLat, setCustomLat] = useState(''); const [customLat, setCustomLat] = useState('');
const [customLng, setCustomLng] = useState(''); const [customLng, setCustomLng] = useState('');
// Use current location if available
useEffect(() => { useEffect(() => {
if (currentLocation) { if (currentLocation) {
setCustomLat(currentLocation.latitude.toFixed(6)); setSelectedLocation({
setCustomLng(currentLocation.longitude.toFixed(6)); lat: currentLocation.latitude,
lng: currentLocation.longitude,
name: 'My current location',
});
} }
}, [currentLocation]); }, [currentLocation]);
// Search for addresses using Nominatim
const searchAddress = useCallback(async () => {
if (!searchQuery.trim()) return;
setIsSearching(true);
try {
const response = await fetch(
`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(searchQuery)}&limit=5`,
{
headers: {
'User-Agent': 'rMaps.online/1.0',
},
}
);
const data: SearchResult[] = await response.json();
setSearchResults(data);
} catch (error) {
console.error('Search failed:', error);
setSearchResults([]);
} finally {
setIsSearching(false);
}
}, [searchQuery]);
// Get current location on demand
const getCurrentLocation = useCallback(() => {
if (!('geolocation' in navigator)) {
setLocationError('Geolocation is not supported by your browser');
return;
}
setIsGettingLocation(true);
setLocationError(null);
navigator.geolocation.getCurrentPosition(
(position) => {
setSelectedLocation({
lat: position.coords.latitude,
lng: position.coords.longitude,
name: 'My current location',
});
setIsGettingLocation(false);
setLocationMode('current');
},
(error) => {
setLocationError(
error.code === 1
? 'Location permission denied'
: error.code === 2
? 'Location unavailable'
: 'Location request timed out'
);
setIsGettingLocation(false);
},
{
enableHighAccuracy: true,
timeout: 15000,
maximumAge: 0,
}
);
}, []);
const selectSearchResult = (result: SearchResult) => {
setSelectedLocation({
lat: parseFloat(result.lat),
lng: parseFloat(result.lon),
name: result.display_name.split(',')[0],
});
setSearchResults([]);
setSearchQuery(result.display_name.split(',')[0]);
};
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
let latitude: number; let latitude: number;
let longitude: number; let longitude: number;
if (useCurrentLocation && currentLocation) { if (locationMode === 'manual') {
latitude = currentLocation.latitude;
longitude = currentLocation.longitude;
} else {
latitude = parseFloat(customLat); latitude = parseFloat(customLat);
longitude = parseFloat(customLng); longitude = parseFloat(customLng);
if (isNaN(latitude) || isNaN(longitude)) { if (isNaN(latitude) || isNaN(longitude)) {
alert('Please enter valid coordinates'); setLocationError('Please enter valid coordinates');
return; return;
} }
} else if (selectedLocation) {
latitude = selectedLocation.lat;
longitude = selectedLocation.lng;
} else {
setLocationError('Please select a location');
return;
} }
onSetMeetingPoint({ onSetMeetingPoint({
name: name || 'Meeting Point', name: name || selectedLocation?.name || 'Meeting Point',
emoji, emoji,
location: { latitude, longitude }, location: { latitude, longitude },
type: 'meetup', type: 'meetup',
@ -62,17 +154,15 @@ export default function MeetingPointModal({
onClose(); onClose();
}; };
const hasLocation = currentLocation || (!isNaN(parseFloat(customLat)) && !isNaN(parseFloat(customLng)));
return ( return (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4 z-50"> <div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4 z-50">
<div className="room-panel rounded-2xl p-6 w-full max-w-md"> <div className="room-panel rounded-2xl p-6 w-full max-w-md max-h-[90vh] overflow-y-auto">
<h2 className="text-xl font-bold mb-4">Set Meeting Point</h2> <h2 className="text-xl font-bold mb-4">Set Meeting Point</h2>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
{/* Name input */} {/* Name input */}
<div> <div>
<label className="block text-sm text-white/60 mb-1">Name</label> <label className="block text-sm text-white/60 mb-1">Name (optional)</label>
<input <input
type="text" type="text"
value={name} value={name}
@ -103,34 +193,96 @@ export default function MeetingPointModal({
</div> </div>
</div> </div>
{/* Location options */} {/* Location selection */}
<div> <div>
<label className="block text-sm text-white/60 mb-2">Location</label> <label className="block text-sm text-white/60 mb-2">Location</label>
{currentLocation && ( {/* Quick actions */}
<label className="flex items-center gap-2 mb-3 cursor-pointer"> <div className="flex gap-2 mb-3">
<input <button
type="radio" type="button"
checked={useCurrentLocation} onClick={getCurrentLocation}
onChange={() => setUseCurrentLocation(true)} disabled={isGettingLocation}
className="accent-rmaps-primary" className={`flex-1 py-2 px-3 rounded-lg text-sm flex items-center justify-center gap-2 transition-colors ${
/> locationMode === 'current' && selectedLocation
<span className="text-sm">Use my current location</span> ? 'bg-rmaps-primary text-white'
</label> : 'bg-white/10 hover:bg-white/20'
}`}
>
{isGettingLocation ? (
<>
<div className="w-4 h-4 border-2 border-white/40 border-t-white rounded-full animate-spin" />
Getting...
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Use My Location
</>
)}
</button>
</div>
{/* Search input */}
<div className="relative mb-3">
<input
type="text"
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
setLocationMode('search');
}}
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), searchAddress())}
placeholder="Search for an address..."
className="w-full bg-white/10 border border-white/20 rounded-lg px-3 py-2 pr-10 text-white placeholder:text-white/40 focus:outline-none focus:border-rmaps-primary"
/>
<button
type="button"
onClick={searchAddress}
disabled={isSearching || !searchQuery.trim()}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-white/10"
>
{isSearching ? (
<div className="w-5 h-5 border-2 border-white/40 border-t-white rounded-full animate-spin" />
) : (
<svg className="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
)}
</button>
</div>
{/* Search results */}
{searchResults.length > 0 && (
<div className="bg-white/5 rounded-lg border border-white/10 mb-3 max-h-40 overflow-y-auto">
{searchResults.map((result) => (
<button
key={result.place_id}
type="button"
onClick={() => selectSearchResult(result)}
className="w-full text-left px-3 py-2 hover:bg-white/10 text-sm border-b border-white/5 last:border-0"
>
<div className="truncate">{result.display_name}</div>
</button>
))}
</div>
)} )}
<label className="flex items-center gap-2 cursor-pointer"> {/* Manual coordinates toggle */}
<input <button
type="radio" type="button"
checked={!useCurrentLocation} onClick={() => setLocationMode(locationMode === 'manual' ? 'search' : 'manual')}
onChange={() => setUseCurrentLocation(false)} className="text-xs text-white/40 hover:text-white/60 mb-2"
className="accent-rmaps-primary" >
/> {locationMode === 'manual' ? 'Hide manual entry' : 'Enter coordinates manually'}
<span className="text-sm">Enter coordinates manually</span> </button>
</label>
{!useCurrentLocation && ( {/* Manual coordinates input */}
<div className="mt-3 grid grid-cols-2 gap-2"> {locationMode === 'manual' && (
<div className="grid grid-cols-2 gap-2">
<div> <div>
<label className="block text-xs text-white/40 mb-1">Latitude</label> <label className="block text-xs text-white/40 mb-1">Latitude</label>
<input <input
@ -154,10 +306,19 @@ export default function MeetingPointModal({
</div> </div>
)} )}
{!hasLocation && !useCurrentLocation && ( {/* Selected location display */}
<p className="text-xs text-yellow-400 mt-2"> {selectedLocation && locationMode !== 'manual' && (
Share your location first, or enter coordinates manually <div className="bg-rmaps-primary/20 rounded-lg px-3 py-2 text-sm mt-2">
</p> <div className="font-medium">{selectedLocation.name || 'Selected location'}</div>
<div className="text-white/60 text-xs">
{selectedLocation.lat.toFixed(6)}, {selectedLocation.lng.toFixed(6)}
</div>
</div>
)}
{/* Error display */}
{locationError && (
<p className="text-xs text-red-400 mt-2">{locationError}</p>
)} )}
</div> </div>
@ -173,7 +334,7 @@ export default function MeetingPointModal({
<button <button
type="submit" type="submit"
className="btn-primary flex-1" className="btn-primary flex-1"
disabled={!hasLocation && useCurrentLocation} disabled={!selectedLocation && locationMode !== 'manual'}
> >
Set Point Set Point
</button> </button>