← back to Beverlyhillsbutler
public/property-search.html
286 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Beverly Hills Property Search — Butler</title>
<meta name="description" content="Search Beverly Hills property records — assessor data for 90210, 90211, 90212.">
<script>
(function() {
var saved = localStorage.getItem('bhb-theme');
if (saved === 'dark' || (!saved && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.setAttribute('data-theme', 'dark');
}
})();
</script>
<style>
:root {
--bg: #f9f7f4; --ink: #1a1a1a; --ink-soft: #4a4a4a; --ink-faint: #9a9a9a;
--line: #e2ddd8; --card-bg: #ffffff; --header-bg: rgba(249,247,244,0.94);
--accent: #8b6f4e; --accent-light: #c4a882; --accent-dim: #f3ede5;
--green: #2d7a4f; --green-dim: #e8f5ee;
}
[data-theme="dark"] {
--bg: #111010; --ink: #f0ede8; --ink-soft: #b8b2aa; --ink-faint: #6a6460;
--line: #2a2826; --card-bg: #1c1a18; --header-bg: rgba(17,16,16,0.94);
--accent-dim: #2a2318; --green-dim: #112218;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Georgia', 'Times New Roman', serif; background: var(--bg); color: var(--ink); min-height: 100vh; }
header {
position: sticky; top: 0; z-index: 100;
background: var(--header-bg); backdrop-filter: blur(12px);
border-bottom: 1px solid var(--line);
display: flex; align-items: center; justify-content: space-between;
padding: 0 1.5rem; height: 56px;
}
.logo { font-size: 1rem; font-weight: 600; letter-spacing: .04em; color: var(--ink); text-decoration: none; }
.logo em { font-style: normal; color: var(--accent); }
.theme-btn { background: none; border: 1px solid var(--line); border-radius: 6px; padding: .3rem .6rem; cursor: pointer; font-size: .8rem; color: var(--ink-soft); }
main { max-width: 860px; margin: 0 auto; padding: 2rem 1rem 3rem; }
.search-hero { text-align: center; margin-bottom: 2rem; }
.search-hero h1 { font-size: 1.8rem; font-weight: 700; margin-bottom: .4rem; }
.search-hero p { color: var(--ink-soft); font-size: .95rem; }
.search-form { display: flex; gap: .5rem; margin-bottom: 1rem; }
.search-form input { flex: 1; padding: .7rem 1rem; border: 1.5px solid var(--line); border-radius: 8px; background: var(--card-bg); color: var(--ink); font-family: inherit; font-size: 1rem; }
.search-form input:focus { outline: none; border-color: var(--accent); }
.search-form button { padding: .7rem 1.4rem; background: var(--accent); color: #fff; border: none; border-radius: 8px; font-family: inherit; font-size: 1rem; cursor: pointer; white-space: nowrap; }
.filters { display: flex; gap: .5rem; flex-wrap: wrap; margin-bottom: 1.5rem; }
.filter-select { padding: .4rem .75rem; border: 1.5px solid var(--line); border-radius: 20px; background: var(--card-bg); color: var(--ink); font-size: .85rem; cursor: pointer; font-family: inherit; }
.filter-select:focus { outline: none; border-color: var(--accent); }
.stats-bar { background: var(--card-bg); border: 1px solid var(--line); border-radius: 10px; padding: .85rem 1.25rem; margin-bottom: 1.5rem; display: flex; gap: 1.5rem; flex-wrap: wrap; font-size: .85rem; }
.stat-item { display: flex; flex-direction: column; gap: .1rem; }
.stat-label { font-size: .72rem; text-transform: uppercase; letter-spacing: .08em; color: var(--ink-faint); }
.stat-value { font-weight: 700; font-size: .95rem; }
/* Sort + density controls (per standing rule) */
.grid-controls { display: flex; gap: .75rem; align-items: center; margin-bottom: 1rem; flex-wrap: wrap; }
.grid-controls label { font-size: .8rem; color: var(--ink-soft); }
.grid-controls select { padding: .35rem .65rem; border: 1px solid var(--line); border-radius: 6px; background: var(--card-bg); color: var(--ink); font-size: .85rem; font-family: inherit; cursor: pointer; }
.density-wrap { display: flex; align-items: center; gap: .4rem; font-size: .8rem; color: var(--ink-soft); }
.density-wrap input { width: 70px; accent-color: var(--accent); }
.results-count { font-size: .85rem; color: var(--ink-soft); margin-left: auto; }
/* Property grid */
.prop-grid { display: grid; grid-template-columns: repeat(var(--cols, 2), 1fr); gap: 1rem; }
@media (max-width: 480px) { .prop-grid { grid-template-columns: 1fr; } }
.prop-card { background: var(--card-bg); border: 1px solid var(--line); border-radius: 10px; padding: 1.1rem; cursor: pointer; transition: border-color .15s, box-shadow .15s; text-decoration: none; color: var(--ink); display: block; }
.prop-card:hover { border-color: var(--accent); box-shadow: 0 4px 16px rgba(139,111,78,.12); }
.card-address { font-size: .95rem; font-weight: 700; line-height: 1.3; margin-bottom: .2rem; }
.card-sub { font-size: .78rem; color: var(--ink-soft); margin-bottom: .6rem; }
.card-badges { display: flex; gap: .3rem; flex-wrap: wrap; margin-bottom: .65rem; }
.badge { display: inline-block; padding: .18rem .55rem; border-radius: 16px; font-size: .72rem; font-weight: 600; }
.badge-use { background: var(--accent-dim); color: var(--accent); }
.badge-zip { background: var(--green-dim); color: var(--green); }
.card-facts { display: flex; gap: 1rem; font-size: .8rem; color: var(--ink-soft); flex-wrap: wrap; }
.card-val { margin-top: .65rem; display: flex; justify-content: space-between; align-items: baseline; }
.card-val-num { font-size: 1.05rem; font-weight: 700; }
.card-val-label { font-size: .72rem; color: var(--ink-faint); }
.no-results { text-align: center; padding: 3rem 1rem; color: var(--ink-soft); }
#state-msg { text-align: center; padding: 2rem; color: var(--ink-soft); }
.spinner { display: inline-block; width: 24px; height: 24px; border: 3px solid var(--line); border-top-color: var(--accent); border-radius: 50%; animation: spin .8s linear infinite; vertical-align: middle; margin-right: .5rem; }
@keyframes spin { to { transform: rotate(360deg); } }
footer { text-align: center; padding: 2rem 1rem; font-size: .78rem; color: var(--ink-faint); border-top: 1px solid var(--line); margin-top: 1rem; }
footer a { color: var(--ink-faint); }
</style>
</head>
<body>
<header>
<a class="logo" href="/">Beverly Hills <em>Butler</em></a>
<button class="theme-btn" onclick="toggleTheme()">☼</button>
</header>
<main>
<div class="search-hero">
<h1>Beverly Hills Property Records</h1>
<p>Search 13,000+ properties across 90210, 90211, and 90212 from LA County Assessor Roll 2025</p>
</div>
<form class="search-form" onsubmit="doSearch(event)">
<input type="text" id="q" placeholder="Address, street name, or APN…" autocomplete="off" autofocus>
<button type="submit">Search</button>
</form>
<div class="filters">
<select class="filter-select" id="f-zip" onchange="doSearchImmediate()">
<option value="">All ZIP Codes</option>
<option value="90210">90210</option>
<option value="90211">90211</option>
<option value="90212">90212</option>
</select>
<select class="filter-select" id="f-use" onchange="doSearchImmediate()">
<option value="">All Use Types</option>
<option value="Residential">Residential</option>
<option value="Commercial">Commercial</option>
<option value="Industrial">Industrial</option>
<option value="Institutional">Institutional</option>
<option value="Vacant">Vacant</option>
</select>
</div>
<div class="stats-bar" id="stats-bar" style="display:none"></div>
<div class="grid-controls">
<label>Sort</label>
<select id="sort-select" onchange="applySortAndRender()">
<option value="default">Default</option>
<option value="value-desc">Value: High to Low</option>
<option value="value-asc">Value: Low to High</option>
<option value="address">Address A–Z</option>
<option value="year-desc">Newest Built</option>
<option value="year-asc">Oldest Built</option>
<option value="sqft-desc">Largest SqFt</option>
</select>
<div class="density-wrap">
<span>Density</span>
<input type="range" id="density" min="1" max="4" value="2" oninput="setDensity(this.value)">
</div>
<span class="results-count" id="results-count"></span>
</div>
<div id="state-msg"></div>
<div class="prop-grid" id="prop-grid" style="--cols:2"></div>
</main>
<footer>
<p>Data: LA County Assessor Roll 2025 · <a href="https://assessor.lacounty.gov" target="_blank" rel="noopener">assessor.lacounty.gov</a></p>
</footer>
<script>
function toggleTheme() {
var c = document.documentElement.getAttribute('data-theme');
var n = c === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', n);
localStorage.setItem('bhb-theme', n);
}
var allResults = [];
var statsCache = null;
// Restore preferences
(function() {
var s = localStorage.getItem('bhb-sort'); if (s) document.getElementById('sort-select').value = s;
var d = localStorage.getItem('bhb-density'); if (d) { document.getElementById('density').value = d; setDensity(d); }
})();
async function loadStats() {
if (statsCache) return;
try {
var r = await fetch('/api/stats'); statsCache = await r.json();
var bar = document.getElementById('stats-bar');
bar.innerHTML = `
<div class="stat-item"><div class="stat-label">Total Properties</div><div class="stat-value">${statsCache.total_properties.toLocaleString()}</div></div>
<div class="stat-item"><div class="stat-label">Avg Assessed Value</div><div class="stat-value">$${Math.round(statsCache.avg_assessed_value).toLocaleString()}</div></div>
<div class="stat-item"><div class="stat-label">Max Assessed Value</div><div class="stat-value">$${Math.round(statsCache.max_assessed_value).toLocaleString()}</div></div>
<div class="stat-item"><div class="stat-label">Coverage</div><div class="stat-value">${statsCache.coverage}</div></div>
`;
bar.style.display = 'flex';
} catch (_) {}
}
loadStats();
async function doSearch(e) {
if (e) e.preventDefault();
var q = document.getElementById('q').value.trim();
var zip = document.getElementById('f-zip').value;
var use = document.getElementById('f-use').value;
var params = new URLSearchParams({ limit: '200' });
if (q) params.set('q', q);
if (zip) params.set('zip', zip);
if (use) params.set('use', use);
// Update URL
var newUrl = '/property-search.html?' + (q ? 'q=' + encodeURIComponent(q) + '&' : '') + (zip ? 'zip=' + zip + '&' : '') + (use ? 'use=' + encodeURIComponent(use) : '');
history.replaceState(null, '', newUrl.replace(/&$/, ''));
document.getElementById('state-msg').innerHTML = '<span class="spinner"></span> Searching…';
document.getElementById('prop-grid').innerHTML = '';
document.getElementById('results-count').textContent = '';
try {
var r = await fetch('/api/properties?' + params.toString());
var data = await r.json();
allResults = data.properties || [];
applySortAndRender();
document.getElementById('state-msg').innerHTML = '';
} catch (err) {
document.getElementById('state-msg').innerHTML = '<p style="color:#c0392b">Search failed: ' + err.message + '</p>';
}
}
function doSearchImmediate() { doSearch(null); }
function applySortAndRender() {
var sort = document.getElementById('sort-select').value;
localStorage.setItem('bhb-sort', sort);
var list = allResults.slice();
if (sort === 'value-desc') list.sort((a,b) => (b.total_value||0) - (a.total_value||0));
else if (sort === 'value-asc') list.sort((a,b) => (a.total_value||0) - (b.total_value||0));
else if (sort === 'address') list.sort((a,b) => (a.address||'').localeCompare(b.address||''));
else if (sort === 'year-desc') list.sort((a,b) => (parseInt(b.year_built)||0) - (parseInt(a.year_built)||0));
else if (sort === 'year-asc') list.sort((a,b) => (parseInt(a.year_built)||9999) - (parseInt(b.year_built)||9999));
else if (sort === 'sqft-desc') list.sort((a,b) => (b.sqft||0) - (a.sqft||0));
renderResults(list);
}
function setDensity(val) {
document.getElementById('prop-grid').style.setProperty('--cols', Math.max(1, parseInt(val)));
localStorage.setItem('bhb-density', val);
}
function fmtUSD(n) { return n ? '$' + Math.round(n).toLocaleString() : '—'; }
function renderResults(list) {
var grid = document.getElementById('prop-grid');
var count = document.getElementById('results-count');
if (!list.length) {
grid.innerHTML = '<div class="no-results" style="grid-column:1/-1">No properties found. Try a different search.</div>';
count.textContent = '0 results';
return;
}
count.textContent = list.length.toLocaleString() + ' result' + (list.length !== 1 ? 's' : '');
grid.innerHTML = list.map(p => `
<a class="prop-card" href="/property-profile.html?apn=${encodeURIComponent(p.ain)}">
<div class="card-address">${p.address || '(no address)'}</div>
<div class="card-sub">${p.city || 'Beverly Hills'}, CA ${p.zip}</div>
<div class="card-badges">
<span class="badge badge-use">${p.use_desc || 'Unknown'}</span>
<span class="badge badge-zip">ZIP ${p.zip}</span>
</div>
<div class="card-facts">
${p.year_built ? '<span>Built ' + p.year_built + '</span>' : ''}
${p.sqft ? '<span>' + Number(p.sqft).toLocaleString() + ' sf</span>' : ''}
</div>
<div class="card-val">
<div class="card-val-label">Assessed Value</div>
<div class="card-val-num">${p.total_value_fmt || fmtUSD(p.total_value)}</div>
</div>
</a>
`).join('');
}
// Bootstrap from URL params
(function() {
var params = new URLSearchParams(window.location.search);
var q = params.get('q'); if (q) document.getElementById('q').value = q;
var zip = params.get('zip'); if (zip) document.getElementById('f-zip').value = zip;
var use = params.get('use'); if (use) document.getElementById('f-use').value = use;
if (q || zip || use) doSearch(null);
})();
</script>
</body>
</html>