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:
parent
0a234f902a
commit
50e1feb62c
|
|
@ -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 */}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue