Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions client/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,8 @@ export const mapsApi = {
placePhoto: (placeId: string, lat?: number, lng?: number, name?: string) => apiClient.get(`/maps/place-photo/${encodeURIComponent(placeId)}`, { params: { lat, lng, name } }).then(r => r.data),
reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => r.data),
resolveUrl: (url: string) => apiClient.post('/maps/resolve-url', { url }).then(r => r.data),
nearby: (lat: number, lng: number, type: string, radius?: number, lang?: string) =>
apiClient.post(`/maps/nearby?lang=${lang || 'en'}`, { lat, lng, type, radius }).then(r => r.data),
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lang passed as query param despite being a POST, Should be in the request body or use params option like other API calls

}

export const budgetApi = {
Expand Down
254 changes: 254 additions & 0 deletions client/src/components/Planner/NearbyPlacesModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
import { useState, useCallback } from 'react'
import Modal from '../shared/Modal'
import { mapsApi } from '../../api/client'
import { useTranslation } from '../../i18n'
import { MapPin, Star, Phone, ExternalLink, Plus, Loader2, ChevronLeft } from 'lucide-react'
import PlaceAvatar from '../shared/PlaceAvatar'

interface NearbyPlace {
google_place_id: string | null
osm_id: string | null
name: string
address: string
lat: number | null
lng: number | null
rating: number | null
website: string | null
phone: string | null
source: string
}

interface NearbyPlacesModalProps {
isOpen: boolean
onClose: () => void
lat: number
lng: number
locationName: string
onAddPlace: (data: Record<string, unknown>) => void
enabledCategories?: string
defaultRadius?: number
}

const ALL_TYPE_CATEGORIES = [
{ id: 'food', icon: '🍽️', color: '#f97316' },
{ id: 'attractions', icon: '🏛️', color: '#8b5cf6' },
{ id: 'shopping', icon: '🛍️', color: '#ec4899' },
{ id: 'nightlife', icon: '🌙', color: '#6366f1' },
{ id: 'outdoors', icon: '🌿', color: '#22c55e' },
{ id: 'transport', icon: '🚌', color: '#3b82f6' },
{ id: 'services', icon: '🏦', color: '#64748b' },
{ id: 'accommodation', icon: '🏨', color: '#0ea5e9' },
] as const

export default function NearbyPlacesModal({
isOpen, onClose, lat, lng, locationName, onAddPlace,
enabledCategories, defaultRadius = 1500,
}: NearbyPlacesModalProps) {
const { t, language } = useTranslation()
const [selectedType, setSelectedType] = useState<string | null>(null)
const [places, setPlaces] = useState<NearbyPlace[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [addedIds, setAddedIds] = useState<Set<string>>(new Set())

const enabledSet = enabledCategories
? new Set(enabledCategories.split(',').map(s => s.trim()).filter(Boolean))
: null
const visibleCategories = enabledSet
? ALL_TYPE_CATEGORIES.filter(c => enabledSet.has(c.id))
: ALL_TYPE_CATEGORIES

const [suggestedCategoryId, setSuggestedCategoryId] = useState<number | null>(null)

const searchNearby = useCallback(async (type: string) => {
setSelectedType(type)
setLoading(true)
setError(null)
setPlaces([])
setSuggestedCategoryId(null)
try {
const data = await mapsApi.nearby(lat, lng, type, defaultRadius, language)
setPlaces(data.places || [])
setSuggestedCategoryId(data.suggested_category_id || null)
if (!data.places?.length) {
setError(t('nearby.noResults'))
}
} catch {
setError(t('nearby.searchError'))
} finally {
setLoading(false)
}
}, [lat, lng, language, t])

const handleAdd = useCallback((place: NearbyPlace) => {
const key = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
onAddPlace({
name: place.name,
address: place.address,
lat: place.lat,
lng: place.lng,
google_place_id: place.google_place_id || undefined,
osm_id: place.osm_id || undefined,
website: place.website || undefined,
phone: place.phone || undefined,
category_id: suggestedCategoryId || undefined,
})
setAddedIds(prev => new Set(prev).add(key))
}, [onAddPlace, suggestedCategoryId])

const handleBack = () => {
setSelectedType(null)
setPlaces([])
setError(null)
}

const handleClose = () => {
setSelectedType(null)
setPlaces([])
setError(null)
setAddedIds(new Set())
onClose()
}

return (
<Modal isOpen={isOpen} onClose={handleClose} title={
selectedType ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<button
onClick={handleBack}
style={{ background: 'none', border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', padding: 4, borderRadius: 6, color: 'var(--text-secondary)' }}
>
<ChevronLeft size={18} />
</button>
<span>{ALL_TYPE_CATEGORIES.find(c => c.id === selectedType)?.icon} {t(`nearby.types.${selectedType}`)}</span>
</div>
) : t('nearby.title')
} size="lg">
{!selectedType ? (
<div style={{ padding: '4px 0' }}>
<div style={{ fontSize: 13, color: 'var(--text-muted)', marginBottom: 16, display: 'flex', alignItems: 'center', gap: 6 }}>
<MapPin size={14} />
<span>{t('nearby.searchingNear')} <strong>{locationName}</strong></span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 8 }}>
{visibleCategories.map(cat => (
<button
key={cat.id}
onClick={() => searchNearby(cat.id)}
style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '14px 16px', borderRadius: 12,
background: 'var(--bg-hover)', border: '1px solid var(--border-faint)',
cursor: 'pointer', fontFamily: 'inherit',
transition: 'all 0.15s',
}}
onMouseEnter={e => {
e.currentTarget.style.borderColor = cat.color
e.currentTarget.style.background = `${cat.color}10`
}}
onMouseLeave={e => {
e.currentTarget.style.borderColor = 'var(--border-faint)'
e.currentTarget.style.background = 'var(--bg-hover)'
}}
>
<span style={{ fontSize: 22 }}>{cat.icon}</span>
<span style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-primary)' }}>
{t(`nearby.types.${cat.id}`)}
</span>
</button>
))}
</div>
</div>
) : (
<div style={{ minHeight: 200 }}>
{loading && (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 40, gap: 8, color: 'var(--text-muted)' }}>
<Loader2 size={18} className="animate-spin" style={{ animation: 'spin 1s linear infinite' }} />
<span style={{ fontSize: 13 }}>{t('nearby.searching')}</span>
</div>
)}

{error && !loading && (
<div style={{ textAlign: 'center', padding: 40, color: 'var(--text-muted)', fontSize: 13 }}>
{error}
</div>
)}

{!loading && places.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, maxHeight: '60vh', overflowY: 'auto' }}>
{places.map((place, i) => {
const key = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
const isAdded = addedIds.has(key)
return (
<div
key={key || i}
style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '10px 12px', borderRadius: 10,
transition: 'background 0.12s',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
<PlaceAvatar place={{ id: i, name: place.name, image_url: null, google_place_id: place.google_place_id, osm_id: place.osm_id, lat: place.lat, lng: place.lng }} size={40} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{place.name}
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', marginTop: 2 }}>
{place.address}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 3 }}>
{place.rating && (
<span style={{ display: 'flex', alignItems: 'center', gap: 3, fontSize: 11, color: 'var(--text-secondary)' }}>
<Star size={10} fill="#facc15" color="#facc15" />
{place.rating.toFixed(1)}
</span>
)}
{place.phone && (
<span style={{ display: 'flex', alignItems: 'center', gap: 3, fontSize: 11, color: 'var(--text-muted)' }}>
<Phone size={10} />
</span>
)}
{place.website && (
<a href={place.website} target="_blank" rel="noopener noreferrer"
onClick={e => e.stopPropagation()}
style={{ display: 'flex', alignItems: 'center', gap: 3, fontSize: 11, color: 'var(--text-muted)', textDecoration: 'none' }}>
<ExternalLink size={10} />
</a>
)}
</div>
</div>
<button
onClick={() => !isAdded && handleAdd(place)}
disabled={isAdded}
style={{
display: 'flex', alignItems: 'center', gap: 4,
padding: '6px 12px', borderRadius: 8,
fontSize: 12, fontWeight: 500,
cursor: isAdded ? 'default' : 'pointer',
fontFamily: 'inherit',
border: 'none',
background: isAdded ? 'rgba(34,197,94,0.1)' : 'var(--accent)',
color: isAdded ? '#16a34a' : 'var(--accent-text)',
transition: 'all 0.15s',
flexShrink: 0,
}}
>
{isAdded ? (
<span>{t('nearby.added')}</span>
) : (
<><Plus size={13} /> {t('nearby.add')}</>
)}
</button>
</div>
)
})}
</div>
)}
</div>
)}
<style>{`@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }`}</style>
</Modal>
)
}
8 changes: 7 additions & 1 deletion client/src/components/Planner/PlaceInspector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { getAuthUrl } from '../../api/authUrl'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { X, Clock, MapPin, ExternalLink, Phone, Euro, Edit2, Trash2, Plus, Minus, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation, Users, Mountain, TrendingUp } from 'lucide-react'
import { X, Clock, MapPin, ExternalLink, Phone, Euro, Edit2, Trash2, Plus, Minus, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation, Users, Mountain, TrendingUp, Radar } from 'lucide-react'
import PlaceAvatar from '../shared/PlaceAvatar'
import { mapsApi } from '../../api/client'
import { useSettingsStore } from '../../store/settingsStore'
Expand Down Expand Up @@ -123,6 +123,7 @@ interface PlaceInspectorProps {
tripMembers?: TripMember[]
onSetParticipants: (assignmentId: number, dayId: number, participantIds: number[]) => void
onUpdatePlace: (placeId: number, data: Partial<Place>) => void
onFindNearby?: () => void
leftWidth?: number
rightWidth?: number
}
Expand All @@ -131,6 +132,7 @@ export default function PlaceInspector({
place, categories, days, selectedDayId, selectedAssignmentId, assignments, reservations = [],
onClose, onEdit, onDelete, onAssignToDay, onRemoveAssignment,
files, onFileUpload, tripMembers = [], onSetParticipants, onUpdatePlace,
onFindNearby,
leftWidth = 0, rightWidth = 0,
}: PlaceInspectorProps) {
const { t, locale, language } = useTranslation()
Expand Down Expand Up @@ -618,6 +620,10 @@ export default function PlaceInspector({
<ActionButton onClick={() => window.open(place.website || googleDetails?.website, '_blank')} variant="ghost" icon={<ExternalLink size={13} />}
label={<span className="hidden sm:inline">{t('inspector.website')}</span>} />
)}
{onFindNearby && place.lat && place.lng && (
<ActionButton onClick={onFindNearby} variant="ghost" icon={<Radar size={13} />}
label={<span className="hidden sm:inline">{t('inspector.findNearby')}</span>} />
)}
<div style={{ flex: 1 }} />
<ActionButton onClick={onEdit} variant="ghost" icon={<Edit2 size={13} />} label={<span className="hidden sm:inline">{t('common.edit')}</span>} />
<ActionButton onClick={onDelete} variant="danger" icon={<Trash2 size={13} />} label={<span className="hidden sm:inline">{t('common.delete')}</span>} />
Expand Down
22 changes: 22 additions & 0 deletions client/src/i18n/translations/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,12 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'admin.fileTypesHint': 'Konfiguriere welche Dateitypen hochgeladen werden dürfen.',
'admin.fileTypesFormat': 'Kommagetrennte Endungen (z.B. jpg,png,pdf,doc). Verwende * um alle Typen zu erlauben.',
'admin.fileTypesSaved': 'Dateityp-Einstellungen gespeichert',
'admin.nearbyTitle': 'Orte in der Nähe',
'admin.nearbyHint': 'Konfiguriere welche Kategorien bei "In der Nähe suchen" angezeigt werden und den Standard-Suchradius.',
'admin.nearbyCategories': 'Aktivierte Kategorien',
'admin.nearbyRadius': 'Standard-Suchradius',
'admin.nearbyMaxResults': 'Max. Ergebnisse',
'admin.nearbySaved': 'Einstellungen für Orte in der Nähe gespeichert',

// Packing Templates & Bag Tracking
'admin.bagTracking.title': 'Gepäck-Tracking',
Expand Down Expand Up @@ -854,6 +860,22 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'inspector.editRes': 'Reservierung bearbeiten',
'inspector.participants': 'Teilnehmer',
'inspector.trackStats': 'Streckendaten',
'inspector.findNearby': 'In der Nähe',
'nearby.title': 'Umgebung erkunden',
'nearby.searchingNear': 'Suche in der Nähe von',
'nearby.searching': 'Suche nach Orten in der Nähe...',
'nearby.noResults': 'Keine Orte in der Nähe gefunden. Versuche eine andere Kategorie.',
'nearby.searchError': 'Fehler bei der Suche nach Orten in der Nähe.',
'nearby.add': 'Hinzufügen',
'nearby.added': 'Hinzugefügt',
'nearby.types.food': 'Essen & Trinken',
'nearby.types.attractions': 'Sehenswürdigkeiten',
'nearby.types.shopping': 'Einkaufen',
'nearby.types.nightlife': 'Nachtleben',
'nearby.types.outdoors': 'Natur & Freizeit',
'nearby.types.transport': 'Verkehr',
'nearby.types.services': 'Dienstleistungen',
'nearby.types.accommodation': 'Unterkünfte',

// Reservations
'reservations.title': 'Buchungen',
Expand Down
23 changes: 23 additions & 0 deletions client/src/i18n/translations/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,12 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'admin.fileTypesHint': 'Configure which file types users can upload.',
'admin.fileTypesFormat': 'Comma-separated extensions (e.g. jpg,png,pdf,doc). Use * to allow all types.',
'admin.fileTypesSaved': 'File type settings saved',
'admin.nearbyTitle': 'Nearby Places',
'admin.nearbyHint': 'Configure which categories appear in the "Find Nearby" feature and the default search radius.',
'admin.nearbyCategories': 'Enabled Categories',
'admin.nearbyRadius': 'Default Search Radius',
'admin.nearbyMaxResults': 'Max Results',
'admin.nearbySaved': 'Nearby places settings saved',

// Packing Templates & Bag Tracking
'admin.bagTracking.title': 'Bag Tracking',
Expand Down Expand Up @@ -851,6 +857,23 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'inspector.editRes': 'Edit Reservation',
'inspector.participants': 'Participants',
'inspector.trackStats': 'Track Stats',
'inspector.findNearby': 'Find Nearby',
// Nearby Places
'nearby.title': 'Explore Nearby',
'nearby.searchingNear': 'Searching near',
'nearby.searching': 'Searching nearby places...',
'nearby.noResults': 'No places found nearby. Try a different category.',
'nearby.searchError': 'Failed to search nearby places.',
'nearby.add': 'Add',
'nearby.added': 'Added',
'nearby.types.food': 'Food & Drink',
'nearby.types.attractions': 'Attractions',
'nearby.types.shopping': 'Shopping',
'nearby.types.nightlife': 'Nightlife',
'nearby.types.outdoors': 'Outdoors',
'nearby.types.transport': 'Transport',
'nearby.types.services': 'Services',
'nearby.types.accommodation': 'Accommodation',

// Reservations
'reservations.title': 'Bookings',
Expand Down
Loading