403 lines
15 KiB
TypeScript
403 lines
15 KiB
TypeScript
'use client';
|
|
|
|
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;
|
|
priceHintPerNightCents: number | null;
|
|
coverImage: string | null;
|
|
};
|
|
|
|
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));
|
|
}
|
|
|
|
function loadLeaflet(): Promise<any> {
|
|
if (typeof window === 'undefined') return Promise.reject();
|
|
if ((window as any).L) return Promise.resolve((window as any).L);
|
|
return new Promise((resolve, reject) => {
|
|
const existingStyle = document.querySelector('link[data-leaflet-style]');
|
|
if (!existingStyle) {
|
|
const link = document.createElement('link');
|
|
link.rel = 'stylesheet';
|
|
link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
|
|
link.setAttribute('data-leaflet-style', 'true');
|
|
document.head.appendChild(link);
|
|
}
|
|
|
|
const existingScript = document.querySelector('script[data-leaflet]');
|
|
if (existingScript) {
|
|
existingScript.addEventListener('load', () => resolve((window as any).L));
|
|
existingScript.addEventListener('error', reject);
|
|
return;
|
|
}
|
|
const script = document.createElement('script');
|
|
script.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js';
|
|
script.async = true;
|
|
script.setAttribute('data-leaflet', 'true');
|
|
script.onload = () => resolve((window as any).L);
|
|
script.onerror = reject;
|
|
document.body.appendChild(script);
|
|
});
|
|
}
|
|
|
|
function ListingsMap({
|
|
listings,
|
|
center,
|
|
selectedId,
|
|
onSelect,
|
|
loadingText,
|
|
}: {
|
|
listings: ListingResult[];
|
|
center: LatLng | null;
|
|
selectedId: string | null;
|
|
onSelect: (id: string) => void;
|
|
loadingText: string;
|
|
}) {
|
|
const mapContainerRef = useRef<HTMLDivElement | null>(null);
|
|
const mapRef = useRef<any>(null);
|
|
const markersRef = useRef<any[]>([]);
|
|
const [ready, setReady] = useState(false);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
loadLeaflet()
|
|
.then((L) => {
|
|
if (cancelled) return;
|
|
setReady(true);
|
|
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(() => {
|
|
setReady(false);
|
|
});
|
|
|
|
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 (
|
|
<div className="map-frame">
|
|
{!ready ? <div className="map-placeholder">{loadingText}</div> : null}
|
|
<div ref={mapContainerRef} style={{ width: '100%', height: '100%' }} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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<ListingResult[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
const [addressQuery, setAddressQuery] = useState('');
|
|
const [addressCenter, setAddressCenter] = useState<LatLng | null>(null);
|
|
const [radiusKm, setRadiusKm] = useState(50);
|
|
const [geocoding, setGeocoding] = useState(false);
|
|
const [geoError, setGeoError] = useState<string | null>(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 });
|
|
|
|
return (
|
|
<main>
|
|
<section className="panel">
|
|
<div className="breadcrumb">
|
|
<Link href="/">{t('homeCrumb')}</Link> / <span>{t('navBrowse')}</span>
|
|
</div>
|
|
<h1>{t('browseListingsTitle')}</h1>
|
|
<p style={{ marginTop: 8 }}>{t('browseListingsLead')}</p>
|
|
<div className="search-grid" style={{ marginTop: 16 }}>
|
|
<label>
|
|
{t('searchLabel')}
|
|
<input
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
placeholder={t('searchPlaceholder')}
|
|
autoComplete="off"
|
|
/>
|
|
</label>
|
|
<label>
|
|
{t('cityFilter')}
|
|
<input value={city} onChange={(e) => setCity(e.target.value)} placeholder={t('cityFilter')} />
|
|
</label>
|
|
<label>
|
|
{t('regionFilter')}
|
|
<input value={region} onChange={(e) => setRegion(e.target.value)} placeholder={t('regionFilter')} />
|
|
</label>
|
|
<label>
|
|
{t('evChargingLabel')}
|
|
<select value={evCharging} onChange={(e) => setEvCharging(e.target.value as any)}>
|
|
<option value="ALL">{t('evChargingAny')}</option>
|
|
<option value="FREE">{t('evChargingFree')}</option>
|
|
<option value="PAID">{t('evChargingPaid')}</option>
|
|
<option value="NONE">{t('evChargingNone')}</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
<div style={{ display: 'flex', gap: 10, marginTop: 12, flexWrap: 'wrap' }}>
|
|
<button className="button" onClick={fetchListings} disabled={loading}>
|
|
{loading ? t('loading') : t('searchButton')}
|
|
</button>
|
|
<button
|
|
className="button secondary"
|
|
onClick={() => {
|
|
setQuery('');
|
|
setCity('');
|
|
setRegion('');
|
|
setEvCharging('ALL');
|
|
setAddressCenter(null);
|
|
setAddressQuery('');
|
|
}}
|
|
>
|
|
{t('clearFilters')}
|
|
</button>
|
|
<span style={{ alignSelf: 'center', color: '#cbd5e1' }}>{countLabel}</span>
|
|
</div>
|
|
{error ? <p style={{ marginTop: 8, color: '#ef4444' }}>{error}</p> : null}
|
|
</section>
|
|
|
|
<section className="map-grid" style={{ marginTop: 18 }}>
|
|
<div className="panel">
|
|
<div style={{ display: 'grid', gap: 10, marginBottom: 12 }}>
|
|
<label>
|
|
{t('addressSearchLabel')}
|
|
<input
|
|
value={addressQuery}
|
|
onChange={(e) => setAddressQuery(e.target.value)}
|
|
placeholder={t('addressSearchPlaceholder')}
|
|
/>
|
|
</label>
|
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
|
<button className="button secondary" onClick={locateAddress} disabled={geocoding}>
|
|
{geocoding ? t('loading') : t('locateAddress')}
|
|
</button>
|
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
<input
|
|
type="range"
|
|
min={10}
|
|
max={200}
|
|
step={10}
|
|
value={radiusKm}
|
|
onChange={(e) => setRadiusKm(Number(e.target.value))}
|
|
disabled={!addressCenter}
|
|
/>
|
|
<span style={{ color: '#cbd5e1' }}>{t('addressRadiusLabel', { km: radiusKm })}</span>
|
|
</label>
|
|
{addressCenter ? (
|
|
<button className="button secondary" onClick={() => setAddressCenter(null)}>
|
|
{t('clearFilters')}
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
{geoError ? <p style={{ color: '#ef4444' }}>{geoError}</p> : null}
|
|
</div>
|
|
<ListingsMap
|
|
listings={filtered}
|
|
center={addressCenter}
|
|
selectedId={selectedId}
|
|
onSelect={setSelectedId}
|
|
loadingText={t('loadingMap')}
|
|
/>
|
|
</div>
|
|
<div className="panel">
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
|
|
<strong>{countLabel}</strong>
|
|
{addressCenter ? (
|
|
<span className="badge">{t('addressRadiusLabel', { km: radiusKm })}</span>
|
|
) : null}
|
|
</div>
|
|
{filtered.length === 0 ? (
|
|
<p>{t('mapNoResults')}</p>
|
|
) : (
|
|
<div className="results-grid">
|
|
{filtered.map((l) => (
|
|
<article
|
|
key={l.id}
|
|
className={`listing-card ${selectedId === l.id ? 'active' : ''}`}
|
|
onMouseEnter={() => setSelectedId(l.id)}
|
|
>
|
|
{l.coverImage ? (
|
|
<img src={l.coverImage} alt={l.title} style={{ width: '100%', height: 140, objectFit: 'cover', borderRadius: 12 }} />
|
|
) : (
|
|
<div
|
|
style={{
|
|
height: 140,
|
|
borderRadius: 12,
|
|
background: 'linear-gradient(120deg, rgba(34,211,238,0.12), rgba(14,165,233,0.12))',
|
|
}}
|
|
/>
|
|
)}
|
|
<div style={{ display: 'grid', gap: 6, marginTop: 8 }}>
|
|
<h3 style={{ margin: 0 }}>{l.title}</h3>
|
|
<p style={{ margin: 0 }}>{l.teaser ?? ''}</p>
|
|
<div style={{ color: '#cbd5e1', fontSize: 14 }}>
|
|
{l.streetAddress ? `${l.streetAddress}, ` : ''}
|
|
{l.city}, {l.region}
|
|
</div>
|
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', fontSize: 13 }}>
|
|
<span className="badge">{t('capacityGuests', { count: l.maxGuests })}</span>
|
|
<span className="badge">{t('capacityBedrooms', { count: l.bedrooms })}</span>
|
|
{l.evCharging === 'FREE' ? <span className="badge">{t('amenityEvFree')}</span> : null}
|
|
{l.evCharging === 'PAID' ? <span className="badge">{t('amenityEvPaid')}</span> : null}
|
|
{l.hasAirConditioning ? <span className="badge">{t('amenityAirConditioning')}</span> : null}
|
|
{l.hasSauna ? <span className="badge">{t('amenitySauna')}</span> : null}
|
|
{l.hasWifi ? <span className="badge">{t('amenityWifi')}</span> : null}
|
|
</div>
|
|
<div style={{ display: 'flex', gap: 8, marginTop: 6 }}>
|
|
<Link className="button secondary" href={`/listings/${l.slug}`}>
|
|
{t('openListing')}
|
|
</Link>
|
|
<button className="button secondary" onClick={() => setSelectedId(l.id)}>
|
|
{t('locateAddress')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
</main>
|
|
);
|
|
}
|