← back to NationalPaperHangers

views/public/find.ejs

200 lines

<%- include('../partials/head', { title }) %>
<%- include('../partials/header') %>

<section class="find-page">
  <div class="find-head">
    <h1 class="display-sm">Find a verified installer</h1>
    <p class="lede">Filter by city, ZIP, segment, material, or brand experience. Every active listing is reviewed and re-validated by NPH staff.</p>
  </div>

  <form action="/find" method="get" class="find-filters" role="search">
    <div class="filter-row">
      <label>
        <span>Search</span>
        <input type="text" name="q" value="<%= q %>" placeholder="City, brand, specialty">
      </label>
      <label>
        <span>ZIP</span>
        <input type="text" name="zip" value="<%= zip %>" placeholder="90210" maxlength="10">
      </label>
      <label>
        <span>State</span>
        <input type="text" name="state" value="<%= state %>" placeholder="CA" maxlength="2">
      </label>
      <label>
        <span>Segment</span>
        <select name="segment">
          <option value="">All</option>
          <% ['luxury_residential','hospitality','retail','museum','yacht'].forEach(function(s){ %>
            <option value="<%= s %>" <%= segment === s ? 'selected' : '' %>><%= s.replace(/_/g,' ').replace(/\b\w/g, c => c.toUpperCase()) %></option>
          <% }); %>
        </select>
      </label>
      <label>
        <span>Material</span>
        <select name="material">
          <option value="">All</option>
          <% ['hand_painted','silk','grasscloth','vinyl','mural','metallic_leaf','hand_screened','digital_print'].forEach(function(m){ %>
            <option value="<%= m %>" <%= material === m ? 'selected' : '' %>><%= m.replace(/_/g,' ') %></option>
          <% }); %>
        </select>
      </label>
      <button type="submit" class="btn btn-primary">Search</button>
    </div>
  </form>

  <div class="result-bar" style="display:flex;align-items:baseline;justify-content:space-between;flex-wrap:wrap;gap:12px;margin:0 0 16px">
    <p class="result-count" style="margin:0">
      <%= installers.length %> <%= show === 'all' ? '' : 'verified ' %>installer<%= installers.length === 1 ? '' : 's' %>
      <% if (show !== 'all') { %><span class="muted" style="font-size:13px;margin-left:8px">· directory listings hidden</span><% } %>
    </p>
    <div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center">
      <!-- Sort + density per CLAUDE.md global rule: every directory grid has both -->
      <label style="display:inline-flex;align-items:center;gap:6px;font-size:13px;color:var(--muted)">
        Sort
        <select id="sort-select" style="padding:6px 10px;border:1px solid var(--border);background:var(--bg);font-size:13px;border-radius:4px">
          <option value="default">Default</option>
          <option value="newest_claimed">Newest claimed</option>
          <option value="az">A → Z</option>
          <option value="za">Z → A</option>
          <option value="tier">Tier (Signature first)</option>
          <option value="verified_first">Verified first</option>
          <option value="creds">Most brand-trained creds</option>
        </select>
      </label>
      <label style="display:inline-flex;align-items:center;gap:6px;font-size:13px;color:var(--muted)">
        Density
        <input id="density-slider" type="range" min="240" max="420" step="20" value="300" style="width:90px" aria-label="Card density">
      </label>
      <a href="<%= mapHref %>" class="btn btn-ghost btn-sm" style="font-size:13px" aria-label="View these results on the map">View on map</a>
      <a href="<%= toggleHref %>" class="btn btn-ghost btn-sm" style="font-size:13px">
        <%= show === 'all' ? 'Hide directory listings' : 'Show directory listings' %>
      </a>
    </div>
  </div>

  <div class="installer-grid" id="installerGrid" style="grid-template-columns: repeat(auto-fill, minmax(var(--card-min, 300px), 1fr))">
    <% installers.forEach(function(i){
      // Tier rank for client-side sort: signature/enterprise > pro > basic > unclaimed/null
      var _tierRank = (i.tier === 'signature' || i.tier === 'enterprise') ? 0
                    : (i.tier === 'pro') ? 1
                    : (i.tier === 'basic') ? 2 : 9;
      var _verifiedRank = (i.claim_status === 'unclaimed') ? 9 : (i.verified ? 0 : 1);
      var _claimedAt = i.claimed_at ? new Date(i.claimed_at).getTime() : 0;
      var _credsCount = i.verified_brands_count || 0;
    %>
      <a class="installer-card" href="/installer/<%= i.slug %>"
         data-name="<%= (i.business_name || '').toLowerCase() %>"
         data-tier-rank="<%= _tierRank %>"
         data-verified-rank="<%= _verifiedRank %>"
         data-claimed-at="<%= _claimedAt %>"
         data-creds="<%= _credsCount %>">
        <% var _pdImg = pickSegmentImage(i); %>
        <div class="card-image">
          <% if (_pdImg) { %>
            <img loading="lazy" decoding="async" src="<%= _pdImg.file %>" alt="<%= i.business_name %> — wallcovering installer in <%= [i.city,i.state].filter(Boolean).join(', ') %>">
          <% } else { %>
            <span class="card-image-placeholder"><%= (i.business_name || '').slice(0,2).toUpperCase() %></span>
          <% } %>
        </div>
        <div class="card-body">
          <h3 class="card-title"><%= i.business_name %></h3>
          <p class="card-meta"><%= i.city %>, <%= i.state %><% if (i.zip) { %> · <%= i.zip %><% } %></p>
          <% if (i.headline) { %><p class="card-headline"><%= i.headline %></p><% } %>
          <ul class="card-tags">
            <% (i.materials || []).slice(0,4).forEach(function(m){ %>
              <li class="tag"><%= m.replace(/_/g,' ') %></li>
            <% }); %>
          </ul>
          <% if (i.verified_brands_count > 0) { %>
            <p class="card-creds" title="Brand-trained credentials verified by NPH ops">
              <span class="cred-pip" aria-hidden="true">✦</span>
              Brand-trained:
              <%= (i.verified_brands || []).slice(0,2).join(', ') %><% if (i.verified_brands_count > 2) { %> +<%= i.verified_brands_count - 2 %><% } %>
            </p>
          <% } %>
          <div class="card-foot">
            <% if (i.claim_status === 'unclaimed') { %>
              <span class="directory-badge" title="Public directory listing seeded from WIA. Studio has not yet claimed it.">Directory listing</span>
            <% } else { %>
              <% if (i.verified) { %><span class="verified-badge">Verified</span><% } %>
              <% if (i.tier === 'signature' || i.tier === 'enterprise') { %><span class="signature-badge">Signature</span><% } %>
            <% } %>
          </div>
        </div>
      </a>
    <% }); %>
    <% if (installers.length === 0) { %>
      <div class="empty-state">
        <h3>No installers match those filters yet.</h3>
        <p>Loosen a filter, or <a href="/find">view all active studios</a>. We're actively onboarding installers nationwide.</p>
      </div>
    <% } %>
  </div>
</section>

<%- include('../partials/footer') %>
<script src="/js/find-filter.js"></script>
<script>
  // Sort + density per CLAUDE.md standing rule. Both persist via localStorage.
  (function(){
    var grid = document.getElementById('installerGrid');
    if (!grid) return;
    var sortSel = document.getElementById('sort-select');
    var densSlider = document.getElementById('density-slider');

    // Restore saved values
    try {
      var savedSort = localStorage.getItem('nph-find-sort');
      if (savedSort && sortSel) sortSel.value = savedSort;
      var savedDens = localStorage.getItem('nph-find-density');
      if (savedDens && densSlider) { densSlider.value = savedDens; grid.style.setProperty('--card-min', savedDens + 'px'); }
    } catch (e) {}

    function num(el, attr) { return parseInt(el.getAttribute(attr) || '0', 10) || 0; }

    function applySort() {
      var mode = sortSel.value;
      var cards = Array.from(grid.querySelectorAll('.installer-card'));
      if (mode === 'default') {
        // Server's natural order — re-sort by original DOM index, which we
        // store the first time we touch them.
        cards.forEach(function(c, i){ if (!c.dataset.origIdx) c.dataset.origIdx = i; });
        cards.sort(function(a,b){ return num(a,'data-orig-idx') - num(b,'data-orig-idx'); });
      } else if (mode === 'az') {
        cards.sort(function(a,b){ return (a.dataset.name||'').localeCompare(b.dataset.name||''); });
      } else if (mode === 'za') {
        cards.sort(function(a,b){ return (b.dataset.name||'').localeCompare(a.dataset.name||''); });
      } else if (mode === 'tier') {
        cards.sort(function(a,b){ return num(a,'data-tier-rank') - num(b,'data-tier-rank'); });
      } else if (mode === 'verified_first') {
        cards.sort(function(a,b){ return num(a,'data-verified-rank') - num(b,'data-verified-rank'); });
      } else if (mode === 'newest_claimed') {
        cards.sort(function(a,b){ return num(b,'data-claimed-at') - num(a,'data-claimed-at'); });
      } else if (mode === 'creds') {
        cards.sort(function(a,b){ return num(b,'data-creds') - num(a,'data-creds'); });
      }
      // Stamp original index before reordering
      cards.forEach(function(c){ if (!c.dataset.origIdx) c.dataset.origIdx = Array.from(grid.children).indexOf(c); });
      cards.forEach(function(c){ grid.appendChild(c); });
    }

    if (sortSel) {
      sortSel.addEventListener('change', function(){
        try { localStorage.setItem('nph-find-sort', sortSel.value); } catch (e) {}
        applySort();
      });
      // Stamp original DOM index on first paint, BEFORE first sort
      Array.from(grid.children).forEach(function(c, i){ c.dataset.origIdx = i; });
      if (sortSel.value !== 'default') applySort();
    }

    if (densSlider) {
      densSlider.addEventListener('input', function(){
        grid.style.setProperty('--card-min', densSlider.value + 'px');
        try { localStorage.setItem('nph-find-density', densSlider.value); } catch (e) {}
      });
    }
  })();
</script>