feat: polish latest listings carousel and stabilize map
This commit is contained in:
parent
86de2cabdf
commit
1257042a66
5 changed files with 82 additions and 31 deletions
|
|
@ -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));
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
14
app/page.tsx
14
app/page.tsx
|
|
@ -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
25
package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue