'use client'; import 'leaflet/dist/leaflet.css'; import Link from 'next/link'; import { useEffect, useMemo, useRef, useState } from 'react'; import { useI18n } from '../components/I18nProvider'; type ListingResult = { id: string; title: string; slug: string; teaser: string | null; locale: string | null; country: string; region: string; city: string; streetAddress: string | null; addressNote: string | null; latitude: number | null; longitude: number | null; hasSauna: boolean; hasFireplace: boolean; hasWifi: boolean; petsAllowed: boolean; byTheLake: boolean; hasAirConditioning: boolean; evCharging: 'NONE' | 'FREE' | 'PAID'; maxGuests: number; bedrooms: number; beds: number; bathrooms: number; priceHintPerNightEuros: number | null; coverImage: string | null; isSample: boolean; }; type LatLng = { lat: number; lon: number }; function haversineKm(a: LatLng, b: LatLng) { const toRad = (v: number) => (v * Math.PI) / 180; const R = 6371; const dLat = toRad(b.lat - a.lat); const dLon = toRad(b.lon - a.lon); const lat1 = toRad(a.lat); const lat2 = toRad(b.lat); const sinLat = Math.sin(dLat / 2); const sinLon = Math.sin(dLon / 2); const h = sinLat * sinLat + Math.cos(lat1) * Math.cos(lat2) * sinLon * sinLon; return 2 * R * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h)); } type LeafletLib = typeof import('leaflet'); async function loadLeaflet(): Promise { if (typeof window === 'undefined') return Promise.reject(new Error('No window')); if ((window as any).L) return (window as any).L as LeafletLib; const linkId = 'leaflet-css'; if (!document.getElementById(linkId)) { const link = document.createElement('link'); link.id = linkId; link.rel = 'stylesheet'; link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css'; document.head.appendChild(link); } const mod: LeafletLib = await import('leaflet'); (window as any).L = mod; return mod; } function ListingsMap({ listings, center, selectedId, onSelect, loadingText, }: { listings: ListingResult[]; center: LatLng | null; selectedId: string | null; onSelect: (id: string) => void; loadingText: string; }) { const mapContainerRef = useRef(null); const mapRef = useRef(null); const markersRef = useRef([]); const [ready, setReady] = useState(false); const [mapError, setMapError] = useState(null); useEffect(() => { let cancelled = false; loadLeaflet() .then((L) => { if (cancelled) return; setReady(true); setMapError(null); if (!mapContainerRef.current) return; if (!mapRef.current) { mapRef.current = L.map(mapContainerRef.current).setView([64.5, 26], 5); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap contributors', maxZoom: 18, }).addTo(mapRef.current); } markersRef.current.forEach((m) => m.remove()); markersRef.current = []; listings .filter((l) => l.latitude !== null && l.longitude !== null) .forEach((l) => { const marker = L.marker([l.latitude!, l.longitude!], { title: l.title }); marker.addTo(mapRef.current); marker.on('click', () => onSelect(l.id)); markersRef.current.push(marker); }); const withCoords = listings.filter((l) => l.latitude !== null && l.longitude !== null); if (center && mapRef.current) { mapRef.current.setView([center.lat, center.lon], 8); } else if (withCoords.length && mapRef.current) { const group = L.featureGroup( withCoords.map((l) => L.marker([l.latitude as number, l.longitude as number])) ); mapRef.current.fitBounds(group.getBounds().pad(0.25)); } }) .catch((err) => { console.error('Leaflet load failed', err); setReady(false); setMapError('Map could not be loaded right now.'); }); return () => { cancelled = true; }; }, [listings, center, onSelect]); useEffect(() => { if (!mapRef.current || !selectedId) return; const listing = listings.find((l) => l.id === selectedId); if (listing && listing.latitude && listing.longitude) { mapRef.current.setView([listing.latitude, listing.longitude], 10); } }, [selectedId, listings]); return (
{!ready ?
{loadingText}
: null} {mapError ?
{mapError}
: null}
); } export default function ListingsIndexPage() { const { t } = useI18n(); const [query, setQuery] = useState(''); const [city, setCity] = useState(''); const [region, setRegion] = useState(''); const [evCharging, setEvCharging] = useState<'ALL' | 'FREE' | 'PAID' | 'NONE'>('ALL'); const [listings, setListings] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [selectedId, setSelectedId] = useState(null); const [addressQuery, setAddressQuery] = useState(''); const [addressCenter, setAddressCenter] = useState(null); const [radiusKm, setRadiusKm] = useState(50); const [geocoding, setGeocoding] = useState(false); const [geoError, setGeoError] = useState(null); const scrollRef = useRef(null); const filteredByAddress = useMemo(() => { if (!addressCenter) return listings; return listings.filter((l) => { if (l.latitude === null || l.longitude === null) return false; const d = haversineKm(addressCenter, { lat: l.latitude, lon: l.longitude }); return d <= radiusKm; }); }, [listings, addressCenter, radiusKm]); const filtered = useMemo(() => { if (evCharging === 'ALL') return filteredByAddress; return filteredByAddress.filter((l) => l.evCharging === evCharging); }, [filteredByAddress, evCharging]); async function fetchListings() { setLoading(true); setError(null); try { const params = new URLSearchParams(); if (query) params.set('q', query); if (city) params.set('city', city); if (region) params.set('region', region); if (evCharging !== 'ALL') params.set('evCharging', evCharging); const res = await fetch(`/api/listings?${params.toString()}`, { cache: 'no-store' }); const data = await res.json(); if (!res.ok || data.error) { throw new Error(data.error || 'Failed to load listings'); } setListings(data.listings ?? []); setSelectedId(data.listings?.[0]?.id ?? null); } catch (e: any) { setError(e.message || 'Failed to load listings'); } finally { setLoading(false); } } async function locateAddress() { if (!addressQuery.trim()) return; setGeocoding(true); setGeoError(null); try { const res = await fetch( `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(addressQuery)}&limit=1` ); const data = await res.json(); if (Array.isArray(data) && data.length > 0) { const hit = data[0]; setAddressCenter({ lat: parseFloat(hit.lat), lon: parseFloat(hit.lon) }); } else { setGeoError(t('addressNotFound')); } } catch (e) { setGeoError(t('addressLookupFailed')); } finally { setGeocoding(false); } } useEffect(() => { fetchListings(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const countLabel = t('listingsFound', { count: filtered.length }); useEffect(() => { if (!selectedId) return; const el = document.querySelector(`[data-listing-id="${selectedId}"]`); if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'center' }); } }, [selectedId]); return (
{t('homeCrumb')} / {t('navBrowse')}

{t('browseListingsTitle')}

{t('browseListingsLead')}

{countLabel}
{error ?

{error}

: null}
{addressCenter ? ( ) : null}
{geoError ?

{geoError}

: null}
{countLabel} {addressCenter ? ( {t('addressRadiusLabel', { km: radiusKm })} ) : null}
{filtered.length === 0 ? (

{t('mapNoResults')}

) : (
{filtered.map((l) => (
setSelectedId(l.id)} onClick={() => setSelectedId(l.id)} > {l.coverImage ? ( {l.title} ) : (
)}

{l.title}

{l.isSample ? ( {t('sampleBadge')} ) : null}

{l.teaser ?? ''}

{l.streetAddress ? `${l.streetAddress}, ` : ''} {l.city}, {l.region}
{t('capacityGuests', { count: l.maxGuests })} {t('capacityBedrooms', { count: l.bedrooms })} {l.evCharging === 'FREE' ? {t('amenityEvFree')} : null} {l.evCharging === 'PAID' ? {t('amenityEvPaid')} : null} {l.hasAirConditioning ? {t('amenityAirConditioning')} : null} {l.hasSauna ? {t('amenitySauna')} : null} {l.hasWifi ? {t('amenityWifi')} : null}
{t('openListing')}
))}
)}
); }