← 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 {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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') %>