710 lines
23 KiB
TypeScript
710 lines
23 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;
|
|
hasMicrowave: boolean;
|
|
hasFreeParking: boolean;
|
|
evChargingAvailable: boolean;
|
|
evChargingOnSite: boolean;
|
|
wheelchairAccessible: boolean;
|
|
hasSkiPass: boolean;
|
|
maxGuests: number;
|
|
bedrooms: number;
|
|
beds: number;
|
|
bathrooms: number;
|
|
priceWeekdayEuros: number | null;
|
|
priceWeekendEuros: 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: "🍖",
|
|
microwave: "🍲",
|
|
parking: "🅿️",
|
|
accessible: "♿",
|
|
ski: "⛷️",
|
|
ev: "⚡",
|
|
evOnSite: "🔌",
|
|
};
|
|
|
|
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: "© 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 [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 = filteredByAddress;
|
|
|
|
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,
|
|
},
|
|
{
|
|
key: "microwave",
|
|
label: t("amenityMicrowave"),
|
|
icon: amenityIcons.microwave,
|
|
},
|
|
{
|
|
key: "parking",
|
|
label: t("amenityFreeParking"),
|
|
icon: amenityIcons.parking,
|
|
},
|
|
{
|
|
key: "accessible",
|
|
label: t("amenityWheelchairAccessible"),
|
|
icon: amenityIcons.accessible,
|
|
},
|
|
{ key: "skipass", label: t("amenitySkiPass"), icon: amenityIcons.ski },
|
|
{ key: "ev", label: t("amenityEvNearby"), icon: amenityIcons.ev },
|
|
{
|
|
key: "ev-onsite",
|
|
label: t("amenityEvOnSite"),
|
|
icon: amenityIcons.evOnSite,
|
|
},
|
|
];
|
|
|
|
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 (startDate) params.set("availableStart", startDate);
|
|
if (endDate) params.set("availableEnd", endDate);
|
|
const evSelected = amenities.includes("ev");
|
|
if (evSelected) params.set("evCharging", "true");
|
|
amenities
|
|
.filter((a) => a !== "ev")
|
|
.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>
|
|
</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("");
|
|
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>
|
|
{l.priceWeekdayEuros || l.priceWeekendEuros ? (
|
|
<div style={{ color: "#cbd5e1", fontSize: 14 }}>
|
|
{t("priceStartingFromShort", {
|
|
price: Math.min(
|
|
...[
|
|
l.priceWeekdayEuros,
|
|
l.priceWeekendEuros,
|
|
].filter((p): p is number => typeof p === "number"),
|
|
),
|
|
})}
|
|
</div>
|
|
) : null}
|
|
<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.evChargingOnSite ? (
|
|
<span className="badge">{t("amenityEvOnSite")}</span>
|
|
) : null}
|
|
{l.evChargingAvailable && !l.evChargingOnSite ? (
|
|
<span className="badge">{t("amenityEvNearby")}</span>
|
|
) : null}
|
|
{l.wheelchairAccessible ? (
|
|
<span className="badge">
|
|
{t("amenityWheelchairAccessible")}
|
|
</span>
|
|
) : null}
|
|
{l.hasSkiPass ? (
|
|
<span className="badge">{t("amenitySkiPass")}</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.hasMicrowave ? (
|
|
<span className="badge">{t("amenityMicrowave")}</span>
|
|
) : null}
|
|
{l.hasFreeParking ? (
|
|
<span className="badge">{t("amenityFreeParking")}</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>
|
|
);
|
|
}
|