lomavuokraus/app/listings/page.tsx
2025-11-27 19:03:11 +02:00

503 lines
19 KiB
TypeScript

'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;
hasKitchen: boolean;
hasDishwasher: boolean;
hasWashingMachine: boolean;
hasBarbecue: boolean;
evCharging: 'NONE' | 'FREE' | 'PAID';
maxGuests: number;
bedrooms: number;
beds: number;
bathrooms: number;
priceHintPerNightEuros: number | null;
coverImage: string | null;
isSample: boolean;
hasCalendar: boolean;
availableForDates?: 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<LeafletLib> {
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;
}
const amenityIcons: Record<string, string> = {
sauna: '🧖',
fireplace: '🔥',
wifi: '📶',
pets: '🐾',
lake: '🌊',
ac: '❄️',
kitchen: '🍽️',
dishwasher: '🧼',
washer: '🧺',
barbecue: '🍖',
};
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);
const [mapError, setMapError] = useState<string | null>(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: '&copy; 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 (
<div className="map-frame">
{!ready ? <div className="map-placeholder">{loadingText}</div> : null}
{mapError ? <div className="map-placeholder">{mapError}</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 [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [amenities, setAmenities] = useState<string[]>([]);
const scrollRef = useRef<HTMLDivElement | 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]);
const amenityOptions = [
{ key: 'sauna', label: t('amenitySauna'), icon: amenityIcons.sauna },
{ key: 'fireplace', label: t('amenityFireplace'), icon: amenityIcons.fireplace },
{ key: 'wifi', label: t('amenityWifi'), icon: amenityIcons.wifi },
{ key: 'pets', label: t('amenityPets'), icon: amenityIcons.pets },
{ key: 'lake', label: t('amenityLake'), icon: amenityIcons.lake },
{ key: 'ac', label: t('amenityAirConditioning'), icon: amenityIcons.ac },
{ key: 'kitchen', label: t('amenityKitchen'), icon: amenityIcons.kitchen },
{ key: 'dishwasher', label: t('amenityDishwasher'), icon: amenityIcons.dishwasher },
{ key: 'washer', label: t('amenityWashingMachine'), icon: amenityIcons.washer },
{ key: 'barbecue', label: t('amenityBarbecue'), icon: amenityIcons.barbecue },
];
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);
if (startDate) params.set('availableStart', startDate);
if (endDate) params.set('availableEnd', endDate);
amenities.forEach((a) => params.append('amenity', a));
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);
}
}
function toggleAmenity(key: string) {
setAmenities((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]));
}
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<HTMLElement>(`[data-listing-id="${selectedId}"]`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, [selectedId]);
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: 'grid', gap: 10, gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', marginTop: 12 }}>
<label>
{t('startDate')}
<input type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} />
</label>
<label>
{t('endDate')}
<input type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} />
</label>
</div>
<div style={{ marginTop: 8, color: '#cbd5e1', fontSize: 13 }}>{t('availabilityOnlyWithCalendar')}</div>
<div className="amenity-grid" style={{ marginTop: 12 }}>
<div style={{ gridColumn: '1 / -1', color: '#cbd5e1', fontWeight: 600 }}>{t('searchAmenities')}</div>
{amenityOptions.map((opt) => (
<button
key={opt.key}
type="button"
className={`amenity-option ${amenities.includes(opt.key) ? 'selected' : ''}`}
aria-pressed={amenities.includes(opt.key)}
onClick={() => toggleAmenity(opt.key)}
>
<div className="amenity-option-meta">
<span className="amenity-emoji" aria-hidden>
{opt.icon}
</span>
<span className="amenity-name">{opt.label}</span>
</div>
<span className="amenity-check" aria-hidden>
{amenities.includes(opt.key) ? '✓' : ''}
</span>
</button>
))}
</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('');
setStartDate('');
setEndDate('');
setAmenities([]);
}}
>
{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" ref={scrollRef}>
{filtered.map((l) => (
<article
key={l.id}
className={`listing-card ${selectedId === l.id ? 'active' : ''}`}
data-listing-id={l.id}
onMouseEnter={() => setSelectedId(l.id)}
onClick={() => setSelectedId(l.id)}
>
<Link href={`/listings/${l.slug}`} aria-label={l.title} style={{ display: 'block' }}>
{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))',
}}
/>
)}
</Link>
<div style={{ display: 'grid', gap: 6, marginTop: 8 }}>
<h3 style={{ margin: 0 }}>{l.title}</h3>
{l.isSample ? (
<span className="badge warning" style={{ width: 'fit-content' }}>
{t('sampleBadge')}
</span>
) : null}
<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.hasCalendar ? <span className="badge secondary">{t('calendarConnected')}</span> : null}
{startDate && endDate && l.availableForDates ? (
<span className="badge">{t('availableForDates')}</span>
) : null}
{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.hasKitchen ? <span className="badge">{t('amenityKitchen')}</span> : null}
{l.hasDishwasher ? <span className="badge">{t('amenityDishwasher')}</span> : null}
{l.hasWashingMachine ? <span className="badge">{t('amenityWashingMachine')}</span> : null}
{l.hasBarbecue ? <span className="badge">{t('amenityBarbecue')}</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>
);
}