|
|
|
|
@@ -0,0 +1,630 @@
|
|
|
|
|
<!DOCTYPE html>
|
|
|
|
|
<html lang="nl">
|
|
|
|
|
<head>
|
|
|
|
|
<meta charset="UTF-8">
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
|
|
|
<title>Huizenbot</title>
|
|
|
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
|
|
|
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@400;600;700;800&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
|
|
|
<style>
|
|
|
|
|
:root {
|
|
|
|
|
--bg: #0f0f0f;
|
|
|
|
|
--surface: #181818;
|
|
|
|
|
--surface2: #222222;
|
|
|
|
|
--border: #2a2a2a;
|
|
|
|
|
--accent: #c8f060;
|
|
|
|
|
--accent-dim: #8aaa30;
|
|
|
|
|
--text: #e8e8e8;
|
|
|
|
|
--text-dim: #888;
|
|
|
|
|
--text-dimmer: #555;
|
|
|
|
|
--red: #ff5f5f;
|
|
|
|
|
--orange: #ffaa44;
|
|
|
|
|
--radius: 10px;
|
|
|
|
|
--font-ui: 'Syne', sans-serif;
|
|
|
|
|
--font-mono: 'DM Mono', monospace;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
|
|
|
|
|
|
|
|
body {
|
|
|
|
|
background: var(--bg);
|
|
|
|
|
color: var(--text);
|
|
|
|
|
font-family: var(--font-ui);
|
|
|
|
|
min-height: 100vh;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ── Header ── */
|
|
|
|
|
header {
|
|
|
|
|
padding: 1.25rem 1rem 0;
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: baseline;
|
|
|
|
|
gap: 0.75rem;
|
|
|
|
|
}
|
|
|
|
|
header h1 {
|
|
|
|
|
font-size: 1.5rem;
|
|
|
|
|
font-weight: 800;
|
|
|
|
|
letter-spacing: -0.03em;
|
|
|
|
|
color: var(--accent);
|
|
|
|
|
}
|
|
|
|
|
#count {
|
|
|
|
|
font-family: var(--font-mono);
|
|
|
|
|
font-size: 0.75rem;
|
|
|
|
|
color: var(--text-dim);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ── Filters ── */
|
|
|
|
|
#filters {
|
|
|
|
|
position: sticky;
|
|
|
|
|
top: 0;
|
|
|
|
|
z-index: 100;
|
|
|
|
|
background: var(--bg);
|
|
|
|
|
border-bottom: 1px solid var(--border);
|
|
|
|
|
padding: 0.75rem 1rem;
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
gap: 0.5rem;
|
|
|
|
|
align-items: center;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.filter-group {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 0.35rem;
|
|
|
|
|
background: var(--surface);
|
|
|
|
|
border: 1px solid var(--border);
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
padding: 0.3rem 0.6rem;
|
|
|
|
|
}
|
|
|
|
|
.filter-group label {
|
|
|
|
|
font-size: 0.7rem;
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
color: var(--text-dim);
|
|
|
|
|
letter-spacing: 0.04em;
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
}
|
|
|
|
|
.filter-group input[type=number] {
|
|
|
|
|
background: transparent;
|
|
|
|
|
border: none;
|
|
|
|
|
color: var(--accent);
|
|
|
|
|
font-family: var(--font-mono);
|
|
|
|
|
font-size: 0.8rem;
|
|
|
|
|
width: 3.2rem;
|
|
|
|
|
outline: none;
|
|
|
|
|
text-align: right;
|
|
|
|
|
}
|
|
|
|
|
.filter-group input[type=number]::-webkit-inner-spin-button { opacity: 0.3; }
|
|
|
|
|
|
|
|
|
|
.filter-group select {
|
|
|
|
|
background: transparent;
|
|
|
|
|
border: none;
|
|
|
|
|
color: var(--text);
|
|
|
|
|
font-family: var(--font-ui);
|
|
|
|
|
font-size: 0.75rem;
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
outline: none;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
}
|
|
|
|
|
.filter-group select option { background: var(--surface2); }
|
|
|
|
|
|
|
|
|
|
#filter-reset {
|
|
|
|
|
background: none;
|
|
|
|
|
border: 1px solid var(--border);
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
color: var(--text-dimmer);
|
|
|
|
|
font-family: var(--font-ui);
|
|
|
|
|
font-size: 0.7rem;
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
letter-spacing: 0.04em;
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
padding: 0.3rem 0.7rem;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
transition: color 0.15s, border-color 0.15s;
|
|
|
|
|
}
|
|
|
|
|
#filter-reset:hover { color: var(--text); border-color: var(--text-dim); }
|
|
|
|
|
|
|
|
|
|
/* ── Card list ── */
|
|
|
|
|
#listings {
|
|
|
|
|
padding: 0.75rem 1rem 3rem;
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
gap: 0.5rem;
|
|
|
|
|
max-width: 900px;
|
|
|
|
|
margin: 0 auto;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#empty {
|
|
|
|
|
text-align: center;
|
|
|
|
|
color: var(--text-dimmer);
|
|
|
|
|
font-family: var(--font-mono);
|
|
|
|
|
font-size: 0.85rem;
|
|
|
|
|
padding: 4rem 1rem;
|
|
|
|
|
display: none;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ── Card ── */
|
|
|
|
|
.card {
|
|
|
|
|
background: var(--surface);
|
|
|
|
|
border: 1px solid var(--border);
|
|
|
|
|
border-radius: var(--radius);
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
transition: border-color 0.15s;
|
|
|
|
|
}
|
|
|
|
|
.card:hover { border-color: #3a3a3a; }
|
|
|
|
|
|
|
|
|
|
.card-compact {
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-columns: 1fr 2fr;
|
|
|
|
|
min-height: 110px;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
user-select: none;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Image */
|
|
|
|
|
.card-img {
|
|
|
|
|
position: relative;
|
|
|
|
|
background: var(--surface2);
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
}
|
|
|
|
|
.card-img img {
|
|
|
|
|
width: 100%;
|
|
|
|
|
height: 100%;
|
|
|
|
|
object-fit: cover;
|
|
|
|
|
display: block;
|
|
|
|
|
transition: transform 0.3s ease;
|
|
|
|
|
}
|
|
|
|
|
.card:hover .card-img img { transform: scale(1.03); }
|
|
|
|
|
.card-img-placeholder {
|
|
|
|
|
width: 100%;
|
|
|
|
|
height: 100%;
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
color: var(--text-dimmer);
|
|
|
|
|
font-size: 1.5rem;
|
|
|
|
|
}
|
|
|
|
|
.card-source {
|
|
|
|
|
position: absolute;
|
|
|
|
|
bottom: 0.4rem;
|
|
|
|
|
left: 0.4rem;
|
|
|
|
|
background: rgba(0,0,0,0.7);
|
|
|
|
|
backdrop-filter: blur(4px);
|
|
|
|
|
color: var(--text-dim);
|
|
|
|
|
font-family: var(--font-mono);
|
|
|
|
|
font-size: 0.6rem;
|
|
|
|
|
padding: 0.15rem 0.4rem;
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
letter-spacing: 0.03em;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Data section */
|
|
|
|
|
.card-data {
|
|
|
|
|
padding: 0.7rem 0.75rem;
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
gap: 0.35rem;
|
|
|
|
|
min-width: 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.card-header {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: flex-start;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
gap: 0.5rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.card-adres {
|
|
|
|
|
font-size: 0.85rem;
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
line-height: 1.3;
|
|
|
|
|
color: var(--text);
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
flex: 1;
|
|
|
|
|
min-width: 0;
|
|
|
|
|
}
|
|
|
|
|
.card-stad {
|
|
|
|
|
font-size: 0.7rem;
|
|
|
|
|
color: var(--text-dim);
|
|
|
|
|
font-weight: 400;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Link chip — always clickable, does NOT expand card */
|
|
|
|
|
.card-link {
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 0.25rem;
|
|
|
|
|
background: var(--surface2);
|
|
|
|
|
border: 1px solid var(--border);
|
|
|
|
|
border-radius: 5px;
|
|
|
|
|
color: var(--text-dim);
|
|
|
|
|
font-family: var(--font-mono);
|
|
|
|
|
font-size: 0.6rem;
|
|
|
|
|
padding: 0.2rem 0.45rem;
|
|
|
|
|
text-decoration: none;
|
|
|
|
|
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
}
|
|
|
|
|
.card-link:hover {
|
|
|
|
|
color: var(--accent);
|
|
|
|
|
border-color: var(--accent-dim);
|
|
|
|
|
background: rgba(200,240,96,0.06);
|
|
|
|
|
}
|
|
|
|
|
.card-link svg { flex-shrink: 0; }
|
|
|
|
|
|
|
|
|
|
.card-prijs {
|
|
|
|
|
font-size: 1rem;
|
|
|
|
|
font-weight: 800;
|
|
|
|
|
color: var(--accent);
|
|
|
|
|
letter-spacing: -0.02em;
|
|
|
|
|
font-family: var(--font-mono);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.card-meta {
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-columns: 1fr 1fr;
|
|
|
|
|
gap: 0.2rem 0.5rem;
|
|
|
|
|
margin-top: 0.1rem;
|
|
|
|
|
}
|
|
|
|
|
.card-meta-item {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 0.3rem;
|
|
|
|
|
font-size: 0.68rem;
|
|
|
|
|
color: var(--text-dim);
|
|
|
|
|
font-family: var(--font-mono);
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
}
|
|
|
|
|
.card-meta-item .icon { font-size: 0.75rem; }
|
|
|
|
|
.card-meta-item .val { color: var(--text); font-weight: 500; }
|
|
|
|
|
.card-meta-item.warn .val { color: var(--orange); }
|
|
|
|
|
.card-meta-item.ok .val { color: var(--accent); }
|
|
|
|
|
|
|
|
|
|
/* Expand toggle indicator */
|
|
|
|
|
.card-toggle {
|
|
|
|
|
align-self: flex-end;
|
|
|
|
|
color: var(--text-dimmer);
|
|
|
|
|
font-size: 0.65rem;
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
letter-spacing: 0.04em;
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
margin-top: auto;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ── Expanded panel ── */
|
|
|
|
|
.card-expanded {
|
|
|
|
|
display: none;
|
|
|
|
|
border-top: 1px solid var(--border);
|
|
|
|
|
padding: 0.9rem 1rem;
|
|
|
|
|
background: var(--surface2);
|
|
|
|
|
}
|
|
|
|
|
.card.open .card-expanded { display: block; }
|
|
|
|
|
.card.open .card-toggle { color: var(--accent-dim); }
|
|
|
|
|
|
|
|
|
|
.expanded-grid {
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
|
|
|
|
gap: 0.5rem 1rem;
|
|
|
|
|
margin-bottom: 0.75rem;
|
|
|
|
|
}
|
|
|
|
|
.expanded-field {
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
gap: 0.1rem;
|
|
|
|
|
}
|
|
|
|
|
.expanded-field .ef-label {
|
|
|
|
|
font-size: 0.62rem;
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
color: var(--text-dimmer);
|
|
|
|
|
letter-spacing: 0.06em;
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
}
|
|
|
|
|
.expanded-field .ef-val {
|
|
|
|
|
font-size: 0.8rem;
|
|
|
|
|
color: var(--text);
|
|
|
|
|
font-family: var(--font-mono);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.extra-section {
|
|
|
|
|
border-top: 1px solid var(--border);
|
|
|
|
|
padding-top: 0.6rem;
|
|
|
|
|
margin-top: 0.25rem;
|
|
|
|
|
}
|
|
|
|
|
.extra-section h4 {
|
|
|
|
|
font-size: 0.62rem;
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
color: var(--text-dimmer);
|
|
|
|
|
letter-spacing: 0.06em;
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
margin-bottom: 0.4rem;
|
|
|
|
|
}
|
|
|
|
|
.extra-kv {
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
gap: 0.35rem;
|
|
|
|
|
}
|
|
|
|
|
.extra-kv-item {
|
|
|
|
|
background: var(--surface);
|
|
|
|
|
border: 1px solid var(--border);
|
|
|
|
|
border-radius: 5px;
|
|
|
|
|
padding: 0.2rem 0.5rem;
|
|
|
|
|
font-family: var(--font-mono);
|
|
|
|
|
font-size: 0.68rem;
|
|
|
|
|
color: var(--text-dim);
|
|
|
|
|
}
|
|
|
|
|
.extra-kv-item .ek { color: var(--text-dimmer); }
|
|
|
|
|
.extra-kv-item .ev { color: var(--text); margin-left: 0.3rem; }
|
|
|
|
|
|
|
|
|
|
/* ── No results ── */
|
|
|
|
|
#empty { display: none; }
|
|
|
|
|
#empty.visible { display: block; }
|
|
|
|
|
</style>
|
|
|
|
|
</head>
|
|
|
|
|
<body>
|
|
|
|
|
|
|
|
|
|
<header>
|
|
|
|
|
<h1>Huizenbot</h1>
|
|
|
|
|
<span id="count"></span>
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
<div id="filters">
|
|
|
|
|
<div class="filter-group">
|
|
|
|
|
<label>OV Mark ≤</label>
|
|
|
|
|
<input type="number" id="f-ov-mark" value="45" min="0" max="120">
|
|
|
|
|
<label>min</label>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="filter-group">
|
|
|
|
|
<label>OV Michelle ≤</label>
|
|
|
|
|
<input type="number" id="f-ov-michelle" value="45" min="0" max="120">
|
|
|
|
|
<label>min</label>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="filter-group">
|
|
|
|
|
<label>Fiets Mark ≤</label>
|
|
|
|
|
<input type="number" id="f-fiets-mark" value="40" min="0" max="90">
|
|
|
|
|
<label>min</label>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="filter-group">
|
|
|
|
|
<label>Max prijs</label>
|
|
|
|
|
<input type="number" id="f-prijs" value="300000" min="0" step="5000">
|
|
|
|
|
</div>
|
|
|
|
|
<div class="filter-group">
|
|
|
|
|
<label>Sorteer</label>
|
|
|
|
|
<select id="f-sort">
|
|
|
|
|
<option value="first_seen_desc">Nieuwste eerst</option>
|
|
|
|
|
<option value="first_seen_asc">Oudste eerst</option>
|
|
|
|
|
<option value="prijs_asc">Prijs ↑</option>
|
|
|
|
|
<option value="prijs_desc">Prijs ↓</option>
|
|
|
|
|
<option value="ov_mark_asc">OV Mark ↑</option>
|
|
|
|
|
<option value="fiets_mark_asc">Fiets Mark ↑</option>
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
<button id="filter-reset">Reset</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div id="listings"></div>
|
|
|
|
|
<div id="empty">Geen woningen gevonden met deze filters.</div>
|
|
|
|
|
|
|
|
|
|
<script>
|
|
|
|
|
const LISTINGS = {{ listings_json | safe }};
|
|
|
|
|
|
|
|
|
|
const DEFAULTS = {
|
|
|
|
|
'f-ov-mark': 45,
|
|
|
|
|
'f-ov-michelle': 45,
|
|
|
|
|
'f-fiets-mark': 40,
|
|
|
|
|
'f-prijs': 300000,
|
|
|
|
|
'f-sort': 'first_seen_desc',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ── Helpers ──
|
|
|
|
|
|
|
|
|
|
function fmt_prijs(p) {
|
|
|
|
|
if (!p) return '—';
|
|
|
|
|
return '€\u202f' + p.toLocaleString('nl-NL');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function fmt_min(m) {
|
|
|
|
|
if (m == null) return '—';
|
|
|
|
|
return m + ' min';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function travel_class(val, warn, good) {
|
|
|
|
|
if (val == null) return '';
|
|
|
|
|
if (val <= good) return 'ok';
|
|
|
|
|
if (val <= warn) return '';
|
|
|
|
|
return 'warn';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function fmt_date(iso) {
|
|
|
|
|
if (!iso) return '—';
|
|
|
|
|
return iso.slice(0, 10);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function fmt_extra_val(v) {
|
|
|
|
|
if (v === null || v === undefined) return null;
|
|
|
|
|
if (typeof v === 'boolean') return v ? 'ja' : 'nee';
|
|
|
|
|
if (Array.isArray(v)) {
|
|
|
|
|
if (v.length === 0) return null;
|
|
|
|
|
// photos array: just show count
|
|
|
|
|
return v.length + ' foto\'s';
|
|
|
|
|
}
|
|
|
|
|
if (typeof v === 'object') return JSON.stringify(v).slice(0, 60);
|
|
|
|
|
const s = String(v);
|
|
|
|
|
if (s === '' || s === 'null') return null;
|
|
|
|
|
// truncate long description
|
|
|
|
|
return s.length > 120 ? s.slice(0, 120) + '…' : s;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ef(label, val) {
|
|
|
|
|
if (val == null || val === '' || val === 'null') return '';
|
|
|
|
|
return `<div class="expanded-field">
|
|
|
|
|
<span class="ef-label">${label}</span>
|
|
|
|
|
<span class="ef-val">${val}</span>
|
|
|
|
|
</div>`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Card renderer ──
|
|
|
|
|
|
|
|
|
|
function render_card(l) {
|
|
|
|
|
const img = l.hero_image_url
|
|
|
|
|
? `<img src="${l.hero_image_url}" alt="${l.adres || ''}" loading="lazy">`
|
|
|
|
|
: `<div class="card-img-placeholder">🏠</div>`;
|
|
|
|
|
|
|
|
|
|
const ovM = travel_class(l.ov_mark, 45, 30);
|
|
|
|
|
const ovMi = travel_class(l.ov_michelle, 45, 30);
|
|
|
|
|
const fM = travel_class(l.fiets_mark, 40, 25);
|
|
|
|
|
const fMi = travel_class(l.fiets_michelle, 50, 35);
|
|
|
|
|
|
|
|
|
|
const extra_items = Object.entries(l.extra || {})
|
|
|
|
|
.map(([k, v]) => {
|
|
|
|
|
const fv = fmt_extra_val(v);
|
|
|
|
|
if (fv === null) return '';
|
|
|
|
|
return `<span class="extra-kv-item"><span class="ek">${k}</span><span class="ev">${fv}</span></span>`;
|
|
|
|
|
}).join('');
|
|
|
|
|
|
|
|
|
|
const extra_section = extra_items
|
|
|
|
|
? `<div class="extra-section"><h4>Extra</h4><div class="extra-kv">${extra_items}</div></div>`
|
|
|
|
|
: '';
|
|
|
|
|
|
|
|
|
|
return `
|
|
|
|
|
<div class="card" data-id="${l.id}">
|
|
|
|
|
<div class="card-compact">
|
|
|
|
|
<div class="card-img">
|
|
|
|
|
${img}
|
|
|
|
|
<span class="card-source">${l.source_makelaar}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="card-data">
|
|
|
|
|
<div class="card-header">
|
|
|
|
|
<div>
|
|
|
|
|
<div class="card-adres">${l.adres || '—'}</div>
|
|
|
|
|
<div class="card-stad">${l.stad || ''} ${l.postcode || ''}</div>
|
|
|
|
|
</div>
|
|
|
|
|
<a class="card-link" href="${l.url}" target="_blank" rel="noopener" onclick="event.stopPropagation()">
|
|
|
|
|
<svg width="9" height="9" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M5 3H2a1 1 0 00-1 1v6a1 1 0 001 1h6a1 1 0 001-1V7M8 1h3m0 0v3m0-3L5 7"/></svg>
|
|
|
|
|
link
|
|
|
|
|
</a>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="card-prijs">${fmt_prijs(l.prijs)}</div>
|
|
|
|
|
<div class="card-meta">
|
|
|
|
|
<div class="card-meta-item ${ovM}">
|
|
|
|
|
<span class="icon">🚌</span><span>Mark</span><span class="val">${fmt_min(l.ov_mark)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="card-meta-item ${ovMi}">
|
|
|
|
|
<span class="icon">🚌</span><span>Michelle</span><span class="val">${fmt_min(l.ov_michelle)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="card-meta-item ${fM}">
|
|
|
|
|
<span class="icon">🚲</span><span>Mark</span><span class="val">${fmt_min(l.fiets_mark)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="card-meta-item ${fMi}">
|
|
|
|
|
<span class="icon">🚲</span><span>Michelle</span><span class="val">${fmt_min(l.fiets_michelle)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
${l.woonoppervlak ? `<div class="card-meta-item"><span class="icon">📐</span><span class="val">${l.woonoppervlak} m²</span></div>` : ''}
|
|
|
|
|
${l.kamers ? `<div class="card-meta-item"><span class="icon">🚪</span><span class="val">${l.kamers} kamers</span></div>` : ''}
|
|
|
|
|
</div>
|
|
|
|
|
<div class="card-toggle">meer ↓</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="card-expanded">
|
|
|
|
|
<div class="expanded-grid">
|
|
|
|
|
${ef('Eerste gezien', fmt_date(l.first_seen))}
|
|
|
|
|
${ef('Datum aanmelding', l.datum_aanmelding ? fmt_date(l.datum_aanmelding) : null)}
|
|
|
|
|
${ef('Woningtype', l.woningtype)}
|
|
|
|
|
${ef('Bouwjaar', l.bouwjaar)}
|
|
|
|
|
${ef('Woonoppervlak', l.woonoppervlak ? l.woonoppervlak + ' m²' : null)}
|
|
|
|
|
${ef('Perceeloppervlak', l.perceeloppervlak ? l.perceeloppervlak + ' m²' : null)}
|
|
|
|
|
${ef('Kamers', l.kamers)}
|
|
|
|
|
${ef('Slaapkamers', l.slaapkamers)}
|
|
|
|
|
${ef('Energielabel', l.energielabel)}
|
|
|
|
|
${ef('Postcode', l.postcode)}
|
|
|
|
|
${ef('OV Mark', fmt_min(l.ov_mark))}
|
|
|
|
|
${ef('OV Michelle', fmt_min(l.ov_michelle))}
|
|
|
|
|
${ef('Fiets Mark', fmt_min(l.fiets_mark))}
|
|
|
|
|
${ef('Fiets Michelle', fmt_min(l.fiets_michelle))}
|
|
|
|
|
</div>
|
|
|
|
|
${extra_section}
|
|
|
|
|
</div>
|
|
|
|
|
</div>`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Filter + sort + render ──
|
|
|
|
|
|
|
|
|
|
function get_filters() {
|
|
|
|
|
return {
|
|
|
|
|
ov_mark: parseInt(document.getElementById('f-ov-mark').value) || Infinity,
|
|
|
|
|
ov_michelle: parseInt(document.getElementById('f-ov-michelle').value) || Infinity,
|
|
|
|
|
fiets_mark: parseInt(document.getElementById('f-fiets-mark').value) || Infinity,
|
|
|
|
|
prijs: parseInt(document.getElementById('f-prijs').value) || Infinity,
|
|
|
|
|
sort: document.getElementById('f-sort').value,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const SORT_FNS = {
|
|
|
|
|
first_seen_desc: (a, b) => (b.first_seen || '').localeCompare(a.first_seen || ''),
|
|
|
|
|
first_seen_asc: (a, b) => (a.first_seen || '').localeCompare(b.first_seen || ''),
|
|
|
|
|
prijs_asc: (a, b) => (a.prijs || 0) - (b.prijs || 0),
|
|
|
|
|
prijs_desc: (a, b) => (b.prijs || 0) - (a.prijs || 0),
|
|
|
|
|
ov_mark_asc: (a, b) => (a.ov_mark ?? 999) - (b.ov_mark ?? 999),
|
|
|
|
|
fiets_mark_asc: (a, b) => (a.fiets_mark ?? 999) - (b.fiets_mark ?? 999),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function apply() {
|
|
|
|
|
const f = get_filters();
|
|
|
|
|
let filtered = LISTINGS.filter(l => {
|
|
|
|
|
if (f.ov_mark < Infinity && (l.ov_mark == null || l.ov_mark > f.ov_mark)) return false;
|
|
|
|
|
if (f.ov_michelle < Infinity && (l.ov_michelle == null || l.ov_michelle > f.ov_michelle)) return false;
|
|
|
|
|
if (f.fiets_mark < Infinity && (l.fiets_mark == null || l.fiets_mark > f.fiets_mark)) return false;
|
|
|
|
|
if (l.prijs != null && l.prijs > f.prijs) return false;
|
|
|
|
|
return true;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
filtered.sort(SORT_FNS[f.sort] || SORT_FNS.first_seen_desc);
|
|
|
|
|
|
|
|
|
|
const container = document.getElementById('listings');
|
|
|
|
|
const empty = document.getElementById('empty');
|
|
|
|
|
const count = document.getElementById('count');
|
|
|
|
|
|
|
|
|
|
// Preserve open state
|
|
|
|
|
const open_ids = new Set(
|
|
|
|
|
[...container.querySelectorAll('.card.open')].map(el => el.dataset.id)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
container.innerHTML = filtered.map(render_card).join('');
|
|
|
|
|
count.textContent = filtered.length + ' / ' + LISTINGS.length + ' woningen';
|
|
|
|
|
|
|
|
|
|
// Restore open state
|
|
|
|
|
open_ids.forEach(id => {
|
|
|
|
|
const el = container.querySelector(`.card[data-id="${id}"]`);
|
|
|
|
|
if (el) el.classList.add('open');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Toggle on compact click
|
|
|
|
|
container.querySelectorAll('.card-compact').forEach(compact => {
|
|
|
|
|
compact.addEventListener('click', () => {
|
|
|
|
|
compact.closest('.card').classList.toggle('open');
|
|
|
|
|
const toggle = compact.querySelector('.card-toggle');
|
|
|
|
|
const isOpen = compact.closest('.card').classList.contains('open');
|
|
|
|
|
toggle.textContent = isOpen ? 'minder ↑' : 'meer ↓';
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
empty.classList.toggle('visible', filtered.length === 0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Init ──
|
|
|
|
|
|
|
|
|
|
document.querySelectorAll('#filters input, #filters select').forEach(el => {
|
|
|
|
|
el.addEventListener('input', apply);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
document.getElementById('filter-reset').addEventListener('click', () => {
|
|
|
|
|
Object.entries(DEFAULTS).forEach(([id, val]) => {
|
|
|
|
|
document.getElementById(id).value = val;
|
|
|
|
|
});
|
|
|
|
|
apply();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
apply();
|
|
|
|
|
</script>
|
|
|
|
|
</body>
|
|
|
|
|
</html>
|