← 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()">&#9788;</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 &middot; <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>