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' && (
|
||||
<button
|
||||
onClick={goIndoor}
|
||||
<a
|
||||
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"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
|
@ -120,7 +122,10 @@ export default function DualMapView({
|
|||
/>
|
||||
</svg>
|
||||
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 */}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import type { ParticipantLocation, WaypointType } from '@/types';
|
||||
|
||||
interface MeetingPointModalProps {
|
||||
|
|
@ -14,6 +14,13 @@ interface MeetingPointModalProps {
|
|||
}) => void;
|
||||
}
|
||||
|
||||
interface SearchResult {
|
||||
place_id: number;
|
||||
display_name: string;
|
||||
lat: string;
|
||||
lon: string;
|
||||
}
|
||||
|
||||
const EMOJI_OPTIONS = ['📍', '🎯', '🏁', '⭐', '🍺', '☕', '🍕', '🎪', '🚻', '🚪'];
|
||||
|
||||
export default function MeetingPointModal({
|
||||
|
|
@ -23,37 +30,122 @@ export default function MeetingPointModal({
|
|||
}: MeetingPointModalProps) {
|
||||
const [name, setName] = 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 [customLng, setCustomLng] = useState('');
|
||||
|
||||
// Use current location if available
|
||||
useEffect(() => {
|
||||
if (currentLocation) {
|
||||
setCustomLat(currentLocation.latitude.toFixed(6));
|
||||
setCustomLng(currentLocation.longitude.toFixed(6));
|
||||
setSelectedLocation({
|
||||
lat: currentLocation.latitude,
|
||||
lng: currentLocation.longitude,
|
||||
name: 'My current location',
|
||||
});
|
||||
}
|
||||
}, [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) => {
|
||||
e.preventDefault();
|
||||
|
||||
let latitude: number;
|
||||
let longitude: number;
|
||||
|
||||
if (useCurrentLocation && currentLocation) {
|
||||
latitude = currentLocation.latitude;
|
||||
longitude = currentLocation.longitude;
|
||||
} else {
|
||||
if (locationMode === 'manual') {
|
||||
latitude = parseFloat(customLat);
|
||||
longitude = parseFloat(customLng);
|
||||
if (isNaN(latitude) || isNaN(longitude)) {
|
||||
alert('Please enter valid coordinates');
|
||||
setLocationError('Please enter valid coordinates');
|
||||
return;
|
||||
}
|
||||
} else if (selectedLocation) {
|
||||
latitude = selectedLocation.lat;
|
||||
longitude = selectedLocation.lng;
|
||||
} else {
|
||||
setLocationError('Please select a location');
|
||||
return;
|
||||
}
|
||||
|
||||
onSetMeetingPoint({
|
||||
name: name || 'Meeting Point',
|
||||
name: name || selectedLocation?.name || 'Meeting Point',
|
||||
emoji,
|
||||
location: { latitude, longitude },
|
||||
type: 'meetup',
|
||||
|
|
@ -62,17 +154,15 @@ export default function MeetingPointModal({
|
|||
onClose();
|
||||
};
|
||||
|
||||
const hasLocation = currentLocation || (!isNaN(parseFloat(customLat)) && !isNaN(parseFloat(customLng)));
|
||||
|
||||
return (
|
||||
<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>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Name input */}
|
||||
<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
|
||||
type="text"
|
||||
value={name}
|
||||
|
|
@ -103,34 +193,96 @@ export default function MeetingPointModal({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Location options */}
|
||||
{/* Location selection */}
|
||||
<div>
|
||||
<label className="block text-sm text-white/60 mb-2">Location</label>
|
||||
|
||||
{currentLocation && (
|
||||
<label className="flex items-center gap-2 mb-3 cursor-pointer">
|
||||
{/* Quick actions */}
|
||||
<div className="flex gap-2 mb-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={getCurrentLocation}
|
||||
disabled={isGettingLocation}
|
||||
className={`flex-1 py-2 px-3 rounded-lg text-sm flex items-center justify-center gap-2 transition-colors ${
|
||||
locationMode === 'current' && selectedLocation
|
||||
? 'bg-rmaps-primary text-white'
|
||||
: '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="radio"
|
||||
checked={useCurrentLocation}
|
||||
onChange={() => setUseCurrentLocation(true)}
|
||||
className="accent-rmaps-primary"
|
||||
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"
|
||||
/>
|
||||
<span className="text-sm">Use my current location</span>
|
||||
</label>
|
||||
<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">
|
||||
<input
|
||||
type="radio"
|
||||
checked={!useCurrentLocation}
|
||||
onChange={() => setUseCurrentLocation(false)}
|
||||
className="accent-rmaps-primary"
|
||||
/>
|
||||
<span className="text-sm">Enter coordinates manually</span>
|
||||
</label>
|
||||
{/* Manual coordinates toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLocationMode(locationMode === 'manual' ? 'search' : 'manual')}
|
||||
className="text-xs text-white/40 hover:text-white/60 mb-2"
|
||||
>
|
||||
{locationMode === 'manual' ? 'Hide manual entry' : 'Enter coordinates manually'}
|
||||
</button>
|
||||
|
||||
{!useCurrentLocation && (
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
{/* Manual coordinates input */}
|
||||
{locationMode === 'manual' && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="block text-xs text-white/40 mb-1">Latitude</label>
|
||||
<input
|
||||
|
|
@ -154,10 +306,19 @@ export default function MeetingPointModal({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{!hasLocation && !useCurrentLocation && (
|
||||
<p className="text-xs text-yellow-400 mt-2">
|
||||
Share your location first, or enter coordinates manually
|
||||
</p>
|
||||
{/* Selected location display */}
|
||||
{selectedLocation && locationMode !== 'manual' && (
|
||||
<div className="bg-rmaps-primary/20 rounded-lg px-3 py-2 text-sm mt-2">
|
||||
<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>
|
||||
|
||||
|
|
@ -173,7 +334,7 @@ export default function MeetingPointModal({
|
|||
<button
|
||||
type="submit"
|
||||
className="btn-primary flex-1"
|
||||
disabled={!hasLocation && useCurrentLocation}
|
||||
disabled={!selectedLocation && locationMode !== 'manual'}
|
||||
>
|
||||
Set Point
|
||||
</button>
|
||||
|
|
|
|||
Loading…
Reference in New Issue