feat: polish latest listings carousel and stabilize map

This commit is contained in:
Tero Halla-aho 2025-11-24 21:11:07 +02:00
parent 86de2cabdf
commit 1257042a66
5 changed files with 82 additions and 31 deletions

View file

@ -229,6 +229,28 @@ p {
font-size: 13px; font-size: 13px;
} }
.dot-row {
display: flex;
gap: 6px;
align-items: center;
justify-content: center;
margin-top: 4px;
}
.dot {
width: 8px;
height: 8px;
border-radius: 999px;
background: rgba(148, 163, 184, 0.4);
cursor: pointer;
transition: transform 120ms ease, background 120ms ease;
}
.dot.active {
background: var(--accent);
transform: scale(1.2);
}
.results-grid { .results-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));

View file

@ -47,33 +47,24 @@ function haversineKm(a: LatLng, b: LatLng) {
return 2 * R * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h)); return 2 * R * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h));
} }
function loadLeaflet(): Promise<any> { async function loadLeaflet(): Promise<any> {
if (typeof window === 'undefined') return Promise.reject(); if (typeof window === 'undefined') return Promise.reject();
if ((window as any).L) return Promise.resolve((window as any).L); if ((window as any).L) return (window as any).L;
return new Promise((resolve, reject) => { const linkId = 'leaflet-css';
const existingStyle = document.querySelector('link[data-leaflet-style]'); if (!document.getElementById(linkId)) {
if (!existingStyle) {
const link = document.createElement('link'); const link = document.createElement('link');
link.id = linkId;
link.rel = 'stylesheet'; link.rel = 'stylesheet';
link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css'; link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
link.setAttribute('data-leaflet-style', 'true');
document.head.appendChild(link); document.head.appendChild(link);
} }
try {
const existingScript = document.querySelector('script[data-leaflet]'); const mod = await import('leaflet');
if (existingScript) { (window as any).L = mod;
existingScript.addEventListener('load', () => resolve((window as any).L)); return mod;
existingScript.addEventListener('error', reject); } catch (err) {
return; return Promise.reject(err);
} }
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({ function ListingsMap({
@ -93,6 +84,7 @@ function ListingsMap({
const mapRef = useRef<any>(null); const mapRef = useRef<any>(null);
const markersRef = useRef<any[]>([]); const markersRef = useRef<any[]>([]);
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
const [mapError, setMapError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@ -100,6 +92,7 @@ function ListingsMap({
.then((L) => { .then((L) => {
if (cancelled) return; if (cancelled) return;
setReady(true); setReady(true);
setMapError(null);
if (!mapContainerRef.current) return; if (!mapContainerRef.current) return;
if (!mapRef.current) { if (!mapRef.current) {
mapRef.current = L.map(mapContainerRef.current).setView([64.5, 26], 5); mapRef.current = L.map(mapContainerRef.current).setView([64.5, 26], 5);
@ -131,8 +124,10 @@ function ListingsMap({
mapRef.current.fitBounds(group.getBounds().pad(0.25)); mapRef.current.fitBounds(group.getBounds().pad(0.25));
} }
}) })
.catch(() => { .catch((err) => {
console.error('Leaflet load failed', err);
setReady(false); setReady(false);
setMapError('Map could not be loaded right now.');
}); });
return () => { return () => {
@ -151,6 +146,7 @@ function ListingsMap({
return ( return (
<div className="map-frame"> <div className="map-frame">
{!ready ? <div className="map-placeholder">{loadingText}</div> : null} {!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 ref={mapContainerRef} style={{ width: '100%', height: '100%' }} />
</div> </div>
); );

View file

@ -40,23 +40,24 @@ export default function HomePage() {
const [latest, setLatest] = useState<LatestListing[]>([]); const [latest, setLatest] = useState<LatestListing[]>([]);
const [activeIndex, setActiveIndex] = useState(0); const [activeIndex, setActiveIndex] = useState(0);
const [loadingLatest, setLoadingLatest] = useState(false); const [loadingLatest, setLoadingLatest] = useState(false);
const [paused, setPaused] = useState(false);
useEffect(() => { useEffect(() => {
setLoadingLatest(true); setLoadingLatest(true);
fetch('/api/listings?limit=8', { cache: 'no-store' }) fetch('/api/listings?limit=8', { cache: 'no-store' })
.then((res) => res.json()) .then((res) => res.json())
.then((data) => setLatest(data.listings ?? [])) .then((data) => setLatest((data.listings ?? []).slice(0, 5)))
.catch(() => setLatest([])) .catch(() => setLatest([]))
.finally(() => setLoadingLatest(false)); .finally(() => setLoadingLatest(false));
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!latest.length) return; if (!latest.length || paused) return;
const id = setInterval(() => { const id = setInterval(() => {
setActiveIndex((prev) => (prev + 1) % latest.length); setActiveIndex((prev) => (prev + 1) % latest.length);
}, 4200); }, 4200);
return () => clearInterval(id); return () => clearInterval(id);
}, [latest]); }, [latest, paused]);
useEffect(() => { useEffect(() => {
if (activeIndex >= latest.length) { if (activeIndex >= latest.length) {
@ -126,7 +127,7 @@ export default function HomePage() {
) : !activeListing ? ( ) : !activeListing ? (
<p style={{ color: '#cbd5e1', marginTop: 10 }}>{t('mapNoResults')}</p> <p style={{ color: '#cbd5e1', marginTop: 10 }}>{t('mapNoResults')}</p>
) : ( ) : (
<div className="latest-grid"> <div className="latest-grid" onMouseEnter={() => setPaused(true)} onMouseLeave={() => setPaused(false)}>
<div className="latest-card"> <div className="latest-card">
{activeListing.coverImage ? ( {activeListing.coverImage ? (
<img src={activeListing.coverImage} alt={activeListing.title} className="latest-cover" /> <img src={activeListing.coverImage} alt={activeListing.title} className="latest-cover" />
@ -162,6 +163,11 @@ export default function HomePage() {
</div> </div>
</button> </button>
))} ))}
<div className="dot-row">
{latest.map((_, idx) => (
<span key={idx} className={`dot ${idx === activeIndex ? 'active' : ''}`} onClick={() => setActiveIndex(idx)} />
))}
</div>
</div> </div>
</div> </div>
)} )}

25
package-lock.json generated
View file

@ -12,6 +12,7 @@
"@prisma/client": "^7.0.0", "@prisma/client": "^7.0.0",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"jose": "^6.1.2", "jose": "^6.1.2",
"leaflet": "^1.9.4",
"next": "^14.2.32", "next": "^14.2.32",
"nodemailer": "^7.0.10", "nodemailer": "^7.0.10",
"pg": "^8.16.3", "pg": "^8.16.3",
@ -20,6 +21,7 @@
"react-dom": "^18.2.0" "react-dom": "^18.2.0"
}, },
"devDependencies": { "devDependencies": {
"@types/leaflet": "^1.9.21",
"@types/node": "^20.12.7", "@types/node": "^20.12.7",
"@types/nodemailer": "^7.0.4", "@types/nodemailer": "^7.0.4",
"@types/pg": "^8.15.6", "@types/pg": "^8.15.6",
@ -2117,6 +2119,13 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json5": { "node_modules/@types/json5": {
"version": "0.0.29", "version": "0.0.29",
"resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
@ -2124,6 +2133,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/leaflet": {
"version": "1.9.21",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "20.12.7", "version": "20.12.7",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.7.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.7.tgz",
@ -5449,6 +5468,12 @@
"node": ">=0.10" "node": ">=0.10"
} }
}, },
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
},
"node_modules/levn": { "node_modules/levn": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",

View file

@ -15,6 +15,7 @@
"@prisma/client": "^7.0.0", "@prisma/client": "^7.0.0",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"jose": "^6.1.2", "jose": "^6.1.2",
"leaflet": "^1.9.4",
"next": "^14.2.32", "next": "^14.2.32",
"nodemailer": "^7.0.10", "nodemailer": "^7.0.10",
"pg": "^8.16.3", "pg": "^8.16.3",
@ -23,6 +24,7 @@
"react-dom": "^18.2.0" "react-dom": "^18.2.0"
}, },
"devDependencies": { "devDependencies": {
"@types/leaflet": "^1.9.21",
"@types/node": "^20.12.7", "@types/node": "^20.12.7",
"@types/nodemailer": "^7.0.4", "@types/nodemailer": "^7.0.4",
"@types/pg": "^8.15.6", "@types/pg": "^8.15.6",