← 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> Costa Rica 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>