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