← back to Ventura Claw Leads
views/public/find.ejs
280 lines
<%- include('../partials/head', { title }) %>
<%- include('../partials/header') %>
<%
// JSON-LD ItemList — surfaces the result page as a structured list to
// search engines. Each business appears as a ListItem with rank + URL +
// name, so searchers see "1. Olive & Salt Trattoria · 2. Boulevard Bagels …"
// in rich-results carousels.
var _publicUrl = (typeof publicUrl !== 'undefined' && publicUrl) ? publicUrl.replace(/\/+$/, '') : 'https://leads.venturaclaw.com';
var _ldList = {
'@context': 'https://schema.org',
'@type': 'ItemList',
itemListOrder: 'https://schema.org/ItemListOrderAscending',
numberOfItems: businesses.length,
itemListElement: businesses.slice(0, 30).map(function(b, i){
return {
'@type': 'ListItem',
position: i + 1,
url: _publicUrl + '/business/' + b.slug,
name: b.business_name
};
})
};
%>
<script type="application/ld+json"><%- JSON.stringify(_ldList) %></script>
<section class="find-page">
<p class="kicker">Find a business</p>
<h1 class="display-sm"><%= customH1 %></h1>
<p class="muted" style="margin:-8px 0 16px;font-size:14px">
Prefer the map? <a href="/map<%= v ? '?vertical=' + v : '' %>" style="font-weight:600">View these on a map →</a>
</p>
<form action="/find" method="get" class="find-filters" role="search" autocomplete="off">
<div class="filter-row">
<label style="position:relative" for="find-q">Search
<input type="search" name="q" value="<%= q %>" placeholder="Name, neighborhood, or category" id="find-q" autocomplete="off"
role="combobox" aria-autocomplete="list" aria-controls="find-suggest" aria-expanded="false">
<ul id="find-suggest" class="find-suggest" role="listbox" aria-label="Business name suggestions" hidden></ul>
</label>
<label for="find-vertical">Category
<select name="vertical" id="find-vertical">
<option value="">All categories</option>
<% verticals.forEach(function(vv){ %>
<option value="<%= vv.key %>" <%= v === vv.key ? 'selected' : '' %>><%= vv.label %></option>
<% }); %>
</select>
</label>
<label for="find-city">City<input type="text" name="city" value="<%= city %>" placeholder="Sherman Oaks" id="find-city"></label>
<button type="submit" class="btn btn-primary">Search</button>
</div>
</form>
<div class="grid-controls" role="region" aria-label="Sort and density">
<p class="muted" style="margin: 0">
<% if (typeof totalCount !== 'undefined' && totalCount > businesses.length) { %>
Showing <%= businesses.length %> of <%= totalCount %> business<%= totalCount === 1 ? '' : 'es' %>
<% } else { %>
<%= businesses.length %> business<%= businesses.length === 1 ? '' : 'es' %> found
<% } %>
</p>
<div class="grid-controls-right">
<label class="grid-control" for="grid-sort"><span>Sort</span>
<select id="grid-sort" aria-label="Sort businesses by">
<option value="featured" <%= sort === 'featured' ? 'selected' : '' %>>Featured</option>
<option value="newest" <%= sort === 'newest' ? 'selected' : '' %>>Newest</option>
<option value="updated" <%= sort === 'updated' ? 'selected' : '' %>>Recently updated</option>
<option value="name_asc" <%= sort === 'name_asc' ? 'selected' : '' %>>Name A→Z</option>
<option value="name_desc"<%= sort === 'name_desc'? 'selected' : '' %>>Name Z→A</option>
<option value="vertical" <%= sort === 'vertical' ? 'selected' : '' %>>By category</option>
</select>
</label>
<label class="grid-control"><span>Density</span>
<input type="range" id="grid-density" min="220" max="480" step="20" value="300" aria-label="Card width">
</label>
</div>
</div>
<% if (businesses.length === 0) { %>
<div style="padding:40px 0">
<h3 style="text-align:center;margin:0 0 12px">No matches yet.</h3>
<p class="muted" style="text-align:center;margin:0 0 32px">Try a broader search, or browse by category:</p>
<div class="verticals-grid">
<% verticals.forEach(function(vv){ %>
<a class="vertical-chip" href="/find?vertical=<%= vv.key %>">
<span class="v-icon" aria-hidden="true"><%= vv.icon %></span>
<span class="v-meta">
<span class="v-label"><%= vv.label %></span>
<span class="v-count"><%= vv.blurb %></span>
</span>
</a>
<% }); %>
</div>
<p class="muted" style="text-align:center;margin-top:24px;font-size:13px"><a href="/find">View all businesses</a></p>
</div>
<% } else { %>
<div class="business-grid" id="grid">
<% businesses.forEach(function(b){
var vMeta = verticals.find(function(vv){return vv.key===b.vertical});
%>
<a class="business-card" href="/business/<%= b.slug %>">
<% if (b.photo_path) { %>
<div class="biz-card-hero biz-card-hero-photo">
<img src="<%= b.photo_path %>" alt="<%= b.business_name %>" loading="lazy" decoding="async">
</div>
<% } else { %>
<div class="biz-card-hero vertical-hero-<%= b.vertical %>" aria-hidden="true">
<%- include('../partials/vertical-glyph', { verticalKey: b.vertical }) %>
</div>
<% } %>
<p class="biz-vertical"><%= vMeta ? vMeta.label : b.vertical %></p>
<p class="biz-name"><%= b.business_name %></p>
<p class="biz-loc"><%= [b.neighborhood, b.city, b.state].filter(Boolean).join(' · ') %></p>
<% if (b.headline) { %><p class="biz-headline"><%= b.headline %></p><% } %>
<div class="biz-badges">
<% if (b.tier === 'premier') { %><span class="biz-badge is-premier">Featured</span>
<% } else if (b.tier === 'standard') { %><span class="biz-badge is-standard">Standard</span><% } %>
<% if (b.claim_status === 'self' || b.claim_status === 'claimed') { %><span class="biz-badge is-claimed">Claimed</span><% } %>
<% if (b.verified) { %><span class="biz-badge">Verified</span><% } %>
</div>
</a>
<% }); %>
</div>
<% if (typeof totalPages !== 'undefined' && totalPages > 1) {
// Build a query string preserving current filters but swapping page=
var qs = '';
if (q) qs += '&q=' + encodeURIComponent(q);
if (v) qs += '&vertical=' + encodeURIComponent(v);
if (city) qs += '&city=' + encodeURIComponent(city);
if (sort && sort !== 'featured') qs += '&sort=' + encodeURIComponent(sort);
function pageUrl(n){ return '/find?page=' + n + qs; }
%>
<nav class="pagination" role="navigation" aria-label="Pagination" style="display:flex;gap:8px;justify-content:center;align-items:center;margin:32px 0 16px;flex-wrap:wrap">
<% if (page > 1) { %>
<a href="<%= pageUrl(page - 1) %>" class="btn" rel="prev" aria-label="Previous page">← Prev</a>
<% } %>
<span class="muted" style="font-size:13px" aria-current="page">Page <%= page %> of <%= totalPages %></span>
<% if (page < totalPages) { %>
<a href="<%= pageUrl(page + 1) %>" class="btn btn-primary" rel="next" aria-label="Next page">Next →</a>
<% } %>
</nav>
<% } %>
<% } %>
</section>
<script>
// Auto-suggest on the search box. Debounced 150ms, fetches /api/businesses/suggest,
// arrow-key + enter navigation, click-outside dismiss.
(function(){
var input = document.getElementById('find-q');
var list = document.getElementById('find-suggest');
if (!input || !list) return;
var debounceTimer = null;
var currentQ = '';
var activeIdx = -1;
function escHtml(s){ return String(s||'').replace(/[&<>"']/g, function(c){ return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]; }); }
function close(){
list.hidden = true; activeIdx = -1; list.innerHTML = '';
input.setAttribute('aria-expanded', 'false');
input.removeAttribute('aria-activedescendant');
}
function open(){
list.hidden = false;
input.setAttribute('aria-expanded', 'true');
}
function setActive(idx) {
var items = list.querySelectorAll('li');
items.forEach(function(li, i){
var on = i === idx;
li.classList.toggle('is-active', on);
li.setAttribute('aria-selected', on ? 'true' : 'false');
});
activeIdx = idx;
// Track active option via aria-activedescendant so screen readers
// announce the active suggestion as the user arrows through.
if (idx >= 0 && items[idx]) {
input.setAttribute('aria-activedescendant', items[idx].id);
} else {
input.removeAttribute('aria-activedescendant');
}
}
async function fetchSuggest(q){
try {
var r = await fetch('/api/businesses/suggest?q=' + encodeURIComponent(q));
if (!r.ok) return [];
var d = await r.json();
return (d.q === currentQ) ? d.results : null;
} catch(e){ return []; }
}
function render(items){
if (!items.length) { close(); return; }
list.innerHTML = items.map(function(b, i){
var id = 'find-suggest-opt-' + i;
var aSel = i === 0 ? 'true' : 'false';
var aCls = i === 0 ? ' class="is-active"' : '';
return '<li role="option" id="' + id + '" aria-selected="' + aSel + '" data-href="/business/' + escHtml(b.slug) + '"' + aCls + '>' +
'<strong>' + escHtml(b.business_name) + '</strong>' +
'<span class="suggest-vert">' + escHtml((b.vertical||'').replace(/_/g,' ')) + '</span>' +
'<span class="suggest-loc">' + escHtml([b.neighborhood, b.city].filter(Boolean).join(' · ')) + '</span>' +
'</li>';
}).join('');
activeIdx = 0;
open();
var first = list.querySelector('li');
if (first) input.setAttribute('aria-activedescendant', first.id);
}
input.addEventListener('input', function(){
var v = input.value.trim();
currentQ = v;
clearTimeout(debounceTimer);
if (v.length < 2) { close(); return; }
debounceTimer = setTimeout(async function(){
var items = await fetchSuggest(v);
if (items === null) return;
render(items);
}, 150);
});
input.addEventListener('keydown', function(e){
var items = list.querySelectorAll('li');
if (e.key === 'ArrowDown') { if (items.length) { setActive(Math.min(activeIdx + 1, items.length - 1)); e.preventDefault(); } }
else if (e.key === 'ArrowUp') { if (items.length) { setActive(Math.max(activeIdx - 1, 0)); e.preventDefault(); } }
else if (e.key === 'Enter' && activeIdx >= 0 && items[activeIdx]) {
var href = items[activeIdx].getAttribute('data-href');
if (href) { window.location = href; e.preventDefault(); }
}
else if (e.key === 'Escape') { close(); }
});
list.addEventListener('mousedown', function(e){
var li = e.target.closest('li[data-href]');
if (li) { window.location = li.getAttribute('data-href'); }
});
document.addEventListener('click', function(e){
if (!input.contains(e.target) && !list.contains(e.target)) close();
});
})();
// Sort + density: persist to localStorage, sort change reloads with ?sort=,
// density updates a CSS var live (no reload).
(function(){
var grid = document.getElementById('grid');
var sortSel = document.getElementById('grid-sort');
var dens = document.getElementById('grid-density');
if (!grid) return;
function applyDensity(px){
grid.style.setProperty('--vcl-card-min', px + 'px');
try { localStorage.setItem('vcl-grid-density', String(px)); } catch(e){}
}
var savedDens = null;
try { savedDens = localStorage.getItem('vcl-grid-density'); } catch(e){}
if (savedDens && Number(savedDens) >= 220 && Number(savedDens) <= 480) {
dens.value = savedDens;
applyDensity(Number(savedDens));
} else {
applyDensity(Number(dens.value));
}
dens.addEventListener('input', function(){ applyDensity(Number(this.value)); });
var savedSort = null;
try { savedSort = localStorage.getItem('vcl-grid-sort'); } catch(e){}
var urlSort = new URLSearchParams(window.location.search).get('sort');
if (!urlSort && savedSort && savedSort !== 'featured') {
// User had a non-default saved — apply it via URL.
var p = new URLSearchParams(window.location.search);
p.set('sort', savedSort);
window.location.search = p.toString();
return;
}
sortSel.addEventListener('change', function(){
try { localStorage.setItem('vcl-grid-sort', this.value); } catch(e){}
var p = new URLSearchParams(window.location.search);
if (this.value === 'featured') p.delete('sort'); else p.set('sort', this.value);
window.location.search = p.toString();
});
})();
</script>
<%- include('../partials/footer') %>