← back to Costa Rica
public/index.html
354 lines
<!doctype html>
<html lang="en" data-theme="light">
<head>
<script>
// Credential-safe fetch guard (fleet drop-in) — if this page is opened with
// credentials in the URL (a saved bookmark / Chrome-remembered basic-auth:
// https://user:pass@host/…), the browser poisons document.baseURI, so any
// relative or root-relative fetch('/api/…') throws "Request cannot be constructed
// from a URL that includes credentials" and callers silently fall to an empty
// state. Resolve every non-absolute request URL against the credential-free
// location instead of baseURI. Placed first so it wraps window.fetch before any
// app script runs. Ref: creds-in-url-fetch-guard-fleet-pattern.
(function () {
var _fetch = window.fetch.bind(window);
var cleanBase = function () { return location.origin + location.pathname; };
window.fetch = function (input, init) {
try {
if (typeof input === 'string' && !/^[a-z]+:\/\//i.test(input) && input.indexOf('//') !== 0) {
input = new URL(input, cleanBase()).href;
} else if (input instanceof Request && !/^[a-z]+:\/\//i.test(input.url)) {
input = new Request(new URL(input.url, cleanBase()).href, input);
}
} catch (_) { /* fall through to native */ }
return _fetch(input, init);
};
})();
</script>
<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)}
/* ── drill atom: every data point filters to its deeper view (card is an <a>, atoms are <span>) ── */
.drill{color:inherit;cursor:pointer;border-bottom:1px dotted transparent}
.drill:hover{border-bottom-color:currentColor;opacity:.85}
.cat .drill:hover,.where .drill:hover{color:var(--accent)}
</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 src="/drill.js"></script>
<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: '',
vertical: '',
sort: localStorage.getItem('cr-sort') || 'newest',
cols: parseInt(localStorage.getItem('cr-cols') || '4', 10),
};
// ── href-to-deeper-data adapter (client state model) ──────────────────────
// Filters live in a plain `state` object (q/category/region/vertical), not a
// FIELDS/FIL set — so override the drill primitives to map each facet field to
// state, keeping the <span class="drill" data-filter data-v> atom contract.
// The card is an <a>, so in-card atoms are <span> (delegated + stopPropagation),
// never nested <a>. Only EXISTING on-card values are linkified.
function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}
const DFIELDS=['q','category','region','vertical']; // drill field -> state key (cat maps to category)
function stateVal(k){ return k==='category' ? state.cat : state[k]; }
function drillState(over){
const s={q:state.q,category:state.cat,region:state.region,vertical:state.vertical};
if(over)Object.assign(s,over); return s;
}
function drillURL(s){
const u=new URLSearchParams();
for(const k of DFIELDS)if(s[k])u.set(k,s[k]);
if(state.sort&&state.sort!=='newest')u.set('sort',state.sort);
const q=u.toString(); return q?('?'+q):location.pathname;
}
// drill(field,value,label,cls) → in-card <span> whose data IS the filtered view.
// Not an <a> (card is already an anchor); delegated click applies it in place.
function drill(field,val,label,cls){
if(val==null||val==='')return esc(label!=null?label:val);
return `<span class="drill ${cls||''}" role="link" tabindex="0" data-filter="${field}" data-v="${esc(String(val))}" title="Filter to ${esc(String(label!=null?label:val))}">${esc(String(label!=null?label:val))}</span>`;
}
function setF(field,val){
if(field==='category'){ state.cat=String(val); syncCatNav(); }
else state[field]=String(val);
writeURL(true); load();
}
function syncCatNav(){
$$('.nav-links a').forEach(x=>x.classList.toggle('active',(x.dataset.cat||'')===state.cat));
}
function readURL(){
const u=new URLSearchParams(location.search);
state.q=u.get('q')||''; state.cat=u.get('category')||'';
state.region=u.get('region')||''; state.vertical=u.get('vertical')||'';
if(u.get('sort'))state.sort=u.get('sort');
}
function writeURL(push){ history[push?'pushState':'replaceState']({},'',drillURL(drillState())); }
// delegated: plain click / Enter on a drill atom filters in place; the card
// <a> navigation is suppressed via preventDefault + stopPropagation.
grid.addEventListener('click',e=>{
const d=e.target.closest('span.drill'); if(!d)return;
if(e.metaKey||e.ctrlKey||e.shiftKey||e.altKey||e.button===1)return;
e.preventDefault(); e.stopPropagation();
setF(d.dataset.filter,d.dataset.v);
});
grid.addEventListener('keydown',e=>{
if(e.key!=='Enter'&&e.key!==' ')return;
const d=e.target.closest('span.drill'); if(!d)return;
e.preventDefault(); e.stopPropagation();
setF(d.dataset.filter,d.dataset.v);
});
addEventListener('popstate',()=>{ readURL(); $('#q').value=state.q; $('#sort').value=state.sort; renderRegions(window.__regions__||[]); syncCatNav(); load(); });
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); writeURL(true); 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); writeURL(false); 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 || '';
writeURL(true); 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__); writeURL(true); 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>`;
// region drills on the region SLUG (server filters r.slug); province appended as plain text
const regionDrill = p.region_name && p.region_slug
? drill('region', p.region_slug, p.region_name) + (p.province ? ', ' + esc(p.province) : '')
: esc(p.region_name ? `${p.region_name}${p.province?', '+p.province:''}` : (p.address || ''));
// vertical (the .cat label) drills on the raw p.vertical value; label is the spaced form
const catDrill = p.vertical ? drill('vertical', p.vertical, verticalLabel) : esc(verticalLabel);
wrap.innerHTML = `
${img}
<div class="body">
<div class="cat">${catDrill}</div>
<div class="name">${esc(p.name)}</div>
<div class="where">${regionDrill}</div>
<div class="desc">${esc(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.vertical) params.set('vertical', state.vertical);
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;
}
}
// deep-link: hydrate state from the URL before first load (URL wins over localStorage)
readURL(); $('#q').value=state.q; $('#sort').value=state.sort; syncCatNav();
loadRegions().then(load);
</script>
</body>
</html>