← back to Costa Rica

public/index.html

260 lines

<!doctype html>
<html lang="en" data-theme="light">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>Costa Rica Directory — tourism · rentals · local services</title>
  <meta name="description" content="A working directory of Costa Rica businesses across tourism, rentals, and local services. Aggregated from government registries (Hacienda, MEIC, ICT) and local listings." />
  <script>
    (function(){ try{ var t=localStorage.getItem('cr-theme'); if(t) document.documentElement.setAttribute('data-theme',t);}catch(e){} })();
  </script>
  <style>
    :root {
      --bg:#fafaf7; --fg:#0e1015; --muted:#5a6270; --line:#e3e0d6;
      --card:#fff; --card-line:#ebe7da; --accent:#0a7a3a; --accent-2:#c2a85a;
      --shadow: 0 1px 2px rgba(20,20,20,.04), 0 8px 24px rgba(20,20,20,.05);
      --cols: 4;
    }
    [data-theme="dark"] {
      --bg:#0e1015; --fg:#f3f1ea; --muted:#9aa3b2; --line:#23262d;
      --card:#15181f; --card-line:#262932; --accent:#3ec07a; --accent-2:#d8c074;
      --shadow: 0 1px 2px rgba(0,0,0,.5), 0 8px 24px rgba(0,0,0,.5);
    }
    *{box-sizing:border-box}
    html,body{margin:0;padding:0;background:var(--bg);color:var(--fg);font-family:-apple-system,BlinkMacSystemFont,'Inter','Segoe UI',Roboto,sans-serif;font-size:15px;line-height:1.55;-webkit-font-smoothing:antialiased}
    a{color:inherit;text-decoration:none} a:hover{text-decoration:underline}
    .nav{position:sticky;top:0;z-index:30;background:color-mix(in srgb,var(--bg) 88%,transparent);backdrop-filter:saturate(160%) blur(10px);border-bottom:1px solid var(--line)}
    .nav-in{max-width:1320px;margin:0 auto;padding:14px 22px;display:flex;align-items:center;gap:18px;flex-wrap:wrap}
    .brand{font-weight:700;letter-spacing:-.01em;font-size:18px}
    .brand .pin{color:var(--accent)}
    .nav-links{display:flex;gap:14px;flex-wrap:wrap;color:var(--muted);font-size:14px}
    .nav-links a.active{color:var(--fg);font-weight:600}
    .ml-auto{margin-left:auto}
    .icon-btn{appearance:none;background:transparent;color:inherit;border:1px solid var(--line);border-radius:999px;width:36px;height:36px;display:inline-flex;align-items:center;justify-content:center;cursor:pointer}
    .icon-btn:hover{background:color-mix(in srgb,var(--fg) 6%,transparent)}
    .hero{max-width:1320px;margin:0 auto;padding:32px 22px 18px}
    h1{font-size:clamp(28px,4vw,44px);letter-spacing:-.02em;margin:0 0 8px;font-weight:700}
    .lede{color:var(--muted);max-width:780px;margin:0 0 22px}
    .filter-bar{display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin:18px 0 8px}
    .pill{display:inline-flex;align-items:center;gap:6px;padding:7px 13px;border:1px solid var(--line);border-radius:999px;background:var(--card);color:var(--fg);font-size:13px;cursor:pointer}
    .pill.active{background:var(--fg);color:var(--bg);border-color:var(--fg)}
    .pill .n{font-variant-numeric:tabular-nums;color:var(--muted);font-size:12px;margin-left:4px}
    .pill.active .n{color:color-mix(in srgb,var(--bg) 70%, transparent)}
    .controls{display:flex;flex-wrap:wrap;gap:12px;align-items:center;margin:14px 0 6px}
    .search{flex:1;min-width:240px;display:flex;align-items:center;gap:10px;padding:10px 14px;border:1px solid var(--line);border-radius:12px;background:var(--card)}
    .search input{flex:1;border:0;outline:0;background:transparent;color:inherit;font-size:15px}
    .ctrl{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--muted)}
    .ctrl select, .ctrl input[type=range]{appearance:none;background:var(--card);color:var(--fg);border:1px solid var(--line);border-radius:8px;padding:6px 10px;font-size:13px}
    .ctrl input[type=range]{padding:0;width:140px}
    main{max-width:1320px;margin:0 auto;padding:0 22px 60px}
    .meta{color:var(--muted);font-size:13px;margin:8px 0 14px}
    .grid{display:grid;grid-template-columns:repeat(var(--cols),minmax(0,1fr));gap:18px}
    .card{background:var(--card);border:1px solid var(--card-line);border-radius:14px;overflow:hidden;display:flex;flex-direction:column;transition:transform .15s ease, box-shadow .15s ease}
    .card:hover{transform:translateY(-2px);box-shadow:var(--shadow)}
    .card .img{aspect-ratio:16/10;background:linear-gradient(135deg, color-mix(in srgb, var(--accent) 35%, var(--card)), color-mix(in srgb, var(--accent-2) 30%, var(--card)));display:flex;align-items:center;justify-content:center;font-size:32px;color:color-mix(in srgb,var(--fg) 35%,transparent)}
    .card .img img{width:100%;height:100%;object-fit:cover;display:block}
    .card .body{padding:14px 14px 16px;display:flex;flex-direction:column;gap:6px;flex:1}
    .cat{font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:var(--accent);font-weight:600}
    .name{font-weight:600;letter-spacing:-.01em}
    .where{font-size:13px;color:var(--muted)}
    .desc{font-size:13px;color:color-mix(in srgb,var(--fg) 80%,transparent);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
    .row{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-top:auto;padding-top:6px}
    .badge{font-size:11px;padding:2px 7px;border-radius:999px;background:color-mix(in srgb,var(--fg) 6%,transparent);color:var(--muted)}
    .badge.verified{background:color-mix(in srgb,var(--accent) 18%,transparent);color:var(--accent)}
    footer{max-width:1320px;margin:0 auto;padding:30px 22px 60px;color:var(--muted);font-size:13px;border-top:1px solid var(--line);margin-top:30px}
    footer a{color:var(--accent)}
    .empty{padding:60px 0;text-align:center;color:var(--muted)}
  </style>
</head>
<body>
  <header class="nav">
    <div class="nav-in">
      <div class="brand"><span class="pin">●</span>&nbsp;Costa&nbsp;Rica&nbsp;Directory</div>
      <nav class="nav-links">
        <a href="#" data-cat="" class="active">All</a>
        <a href="#" data-cat="tourism">Tourism</a>
        <a href="#" data-cat="rentals">Rentals</a>
        <a href="#" data-cat="service">Local services</a>
      </nav>
      <div class="ml-auto" style="display:flex;gap:8px;align-items:center">
        <span id="stat-total" class="badge">—</span>
        <button class="icon-btn" id="theme-toggle" title="Toggle theme" aria-label="Toggle theme">
          <svg id="i-sun" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg>
          <svg id="i-moon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:none"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
        </button>
      </div>
    </div>
  </header>

  <section class="hero">
    <h1>Every business in Costa Rica, in one searchable directory.</h1>
    <p class="lede">Tourism · rentals · local services across all 84 cantones. Aggregated from Hacienda, MEIC, ICT, municipal patente registries, and local web directories.</p>

    <div class="filter-bar" id="region-bar"></div>

    <div class="controls">
      <div class="search">
        <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
        <input id="q" placeholder="Search by name, address, town, keyword…" autocomplete="off" />
      </div>
      <div class="ctrl">
        <label for="sort">Sort</label>
        <select id="sort">
          <option value="newest">Newest</option>
          <option value="name">Name A→Z</option>
          <option value="name-desc">Name Z→A</option>
          <option value="rating">Rating</option>
          <option value="verified">Verified first</option>
          <option value="oldest">Oldest</option>
        </select>
      </div>
      <div class="ctrl">
        <label for="cols">Density</label>
        <input id="cols" type="range" min="2" max="6" step="1" value="4" />
      </div>
    </div>

    <div class="meta" id="meta">Loading…</div>
  </section>

  <main>
    <div class="grid" id="grid"></div>
    <div class="empty" id="empty" style="display:none">No matches yet — try clearing filters or pulling more data.</div>
  </main>

  <footer>
    <div>© Costa Rica Directory. Aggregated public-record data; we do not host owner-operated content without permission. Owner of a listing? <a href="mailto:info@agentabrams.com">info@agentabrams.com</a>.</div>
  </footer>

  <script>
    const $ = (s,r=document)=>r.querySelector(s);
    const $$ = (s,r=document)=>[...r.querySelectorAll(s)];
    const grid = $('#grid'); const meta = $('#meta'); const empty = $('#empty');

    const state = {
      q: localStorage.getItem('cr-q') || '',
      cat: '',
      region: '',
      sort: localStorage.getItem('cr-sort') || 'newest',
      cols: parseInt(localStorage.getItem('cr-cols') || '4', 10),
    };

    function applyTheme(){
      const cur = document.documentElement.getAttribute('data-theme') || 'light';
      $('#i-sun').style.display  = cur === 'dark' ? '' : 'none';
      $('#i-moon').style.display = cur === 'dark' ? 'none' : '';
    }
    $('#theme-toggle').addEventListener('click', () => {
      const cur = document.documentElement.getAttribute('data-theme') || 'light';
      const nxt = cur === 'dark' ? 'light' : 'dark';
      document.documentElement.setAttribute('data-theme', nxt);
      try { localStorage.setItem('cr-theme', nxt); } catch(e){}
      applyTheme();
    });
    applyTheme();

    document.documentElement.style.setProperty('--cols', state.cols);
    $('#cols').value = state.cols;
    $('#cols').addEventListener('input', e => {
      state.cols = parseInt(e.target.value, 10);
      document.documentElement.style.setProperty('--cols', state.cols);
      try { localStorage.setItem('cr-cols', String(state.cols)); } catch(e){}
    });

    $('#sort').value = state.sort;
    $('#sort').addEventListener('change', e => { state.sort = e.target.value; localStorage.setItem('cr-sort', state.sort); load(); });

    $('#q').value = state.q;
    let qDebounce;
    $('#q').addEventListener('input', e => {
      clearTimeout(qDebounce);
      qDebounce = setTimeout(() => { state.q = e.target.value.trim(); localStorage.setItem('cr-q', state.q); load(); }, 220);
    });

    $$('.nav-links a').forEach(a => a.addEventListener('click', e => {
      e.preventDefault();
      $$('.nav-links a').forEach(x => x.classList.remove('active'));
      a.classList.add('active');
      state.cat = a.dataset.cat || '';
      load();
    }));

    function regionPill(slug, name, n, active){
      const a = document.createElement('button');
      a.className = 'pill' + (active ? ' active' : '');
      a.innerHTML = `${name}<span class="n">${n||0}</span>`;
      a.addEventListener('click', () => { state.region = active ? '' : slug; renderRegions(window.__regions__); load(); });
      return a;
    }

    function renderRegions(regs){
      const bar = $('#region-bar'); bar.innerHTML = '';
      bar.appendChild(regionPill('', 'All regions', regs.reduce((s,r)=>s+(r.place_count||0),0), state.region===''));
      regs.filter(r => (r.place_count||0) > 0 || ['nosara','tamarindo','manuel-antonio','la-fortuna','san-jose','santa-teresa','jaco','monteverde','puerto-viejo'].includes(r.slug))
          .slice(0, 18)
          .forEach(r => bar.appendChild(regionPill(r.slug, r.name, r.place_count, state.region===r.slug)));
    }

    async function loadRegions(){
      try {
        const j = await (await fetch('/api/regions')).json();
        window.__regions__ = j.regions || [];
        renderRegions(window.__regions__);
      } catch(e){ console.error(e); }
    }

    function card(p){
      const wrap = document.createElement('a');
      wrap.className = 'card';
      wrap.href = '/p/' + encodeURIComponent(p.slug);
      const verticalLabel = (p.vertical||'').replace(/_/g,' ');
      const heroSrc = p.effective_image_url || p.image_url || p.region_image_url;
      const img = heroSrc
        ? `<div class="img"><img loading="lazy" src="${heroSrc}" alt=""></div>`
        : `<div class="img">⛰</div>`;
      const region = p.region_name ? `${p.region_name}${p.province?', '+p.province:''}` : (p.address || '');
      wrap.innerHTML = `
        ${img}
        <div class="body">
          <div class="cat">${verticalLabel}</div>
          <div class="name">${p.name}</div>
          <div class="where">${region}</div>
          <div class="desc">${p.description||''}</div>
          <div class="row">
            <span class="badge ${p.verified?'verified':''}">${p.verified?'verified':p.source||'pending'}</span>
            ${p.rating?`<span class="badge">★ ${(+p.rating).toFixed(1)}</span>`:''}
            ${p.website ? (/facebook\.com|instagram\.com/i.test(p.website) ? '<span class="badge" title="social profile on file">ƒ social</span>' : '<span class="badge" title="website on file">🌐 site</span>') : ''}
            ${p.phone ? '<span class="badge" title="phone on file">📞</span>' : ''}
          </div>
        </div>`;
      return wrap;
    }

    async function load(){
      const params = new URLSearchParams();
      if (state.q)      params.set('q', state.q);
      if (state.cat)    params.set('category', state.cat);
      if (state.region) params.set('region', state.region);
      if (state.sort)   params.set('sort', state.sort);
      params.set('limit','120');
      meta.textContent = 'Loading…';
      try {
        const j = await (await fetch('/api/places?'+params)).json();
        $('#stat-total').textContent = (j.total||0).toLocaleString() + ' listings';
        meta.textContent = `${(j.total||0).toLocaleString()} matches`
          + (state.region ? ` · ${state.region}` : '')
          + (state.cat ? ` · ${state.cat}` : '');
        grid.innerHTML = '';
        if (!j.places || !j.places.length){ empty.style.display=''; return; }
        empty.style.display='none';
        j.places.forEach(p => grid.appendChild(card(p)));
      } catch(e){
        meta.textContent = 'error: ' + e.message;
      }
    }

    loadRegions().then(load);
  </script>
</body>
</html>