[object Object]

← back to NationalPaperHangers

/map → address-driven near-me view + service_counties/towns/zips

b3b4e140fd70825c7a550b72404f2648846306d3 · 2026-05-10 21:39:40 -0700 · SteveStudio2

Steve's brief: 'make sure the map feature showing current location and
installers servicing which area … allow user to select their address …
no map of entire country needed.'

Schema (migration 021):
- installers.service_counties TEXT[]
- installers.service_towns    TEXT[]
- installers.service_zips     TEXT[]
- GIN indexes on each.
Default ARRAY[]::TEXT[] so the 524 seeded studios keep radius-only
behavior until they fill in counties/towns.

Route /map (rewritten):
- No-address visit: just the address-input form + 'Use my location' button.
  No country pin map.
- Address visit: server-side geocode via OSM Nominatim (lib/geocode.js,
  no API key, 10-min in-memory cache). Returns lat/lng + county + town +
  zip + state.
- Filter: installer matches if (within service_radius_miles) OR
  (county in service_counties) OR (town in service_towns) OR
  (zip in service_zips). Match reasons surface per result.
- Sort: tier-paid first within 5-mi bucket, then distance ascending.
- Renders zoomed Leaflet map (auto-fits to results, max-zoom 12) +
  result cards with distance, response time, brand-trained count, tier.

Route /api/near-me — JSON API for downstream widgets. Takes lat/lng +
optional county/town/zip, returns filtered installers + distance.
60s edge cache.

Admin /admin/profile:
- New form fields for service_counties / service_towns / service_zips
  (comma-separated, capped per-entry + total count).
- POST handler arrays them safely, caps total list size.

CSP:
- connectSrc adds nominatim.openstreetmap.org for the client-side
  'Use my location' reverse-geocode.

Cleanup:
- map.ejs no longer pulls markercluster (unused — single-city result
  sets don't need clustering).

Smoke-tested:
- /map → 200, renders form + Use-my-location button.
- /map?address=90210 → 200, geocodes to 34.09 / -118.41 (Beverly Hills),
  16 studios service the area, map markers + cards render.

Files touched

Diff

commit b3b4e140fd70825c7a550b72404f2648846306d3
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Sun May 10 21:39:40 2026 -0700

    /map → address-driven near-me view + service_counties/towns/zips
    
    Steve's brief: 'make sure the map feature showing current location and
    installers servicing which area … allow user to select their address …
    no map of entire country needed.'
    
    Schema (migration 021):
    - installers.service_counties TEXT[]
    - installers.service_towns    TEXT[]
    - installers.service_zips     TEXT[]
    - GIN indexes on each.
    Default ARRAY[]::TEXT[] so the 524 seeded studios keep radius-only
    behavior until they fill in counties/towns.
    
    Route /map (rewritten):
    - No-address visit: just the address-input form + 'Use my location' button.
      No country pin map.
    - Address visit: server-side geocode via OSM Nominatim (lib/geocode.js,
      no API key, 10-min in-memory cache). Returns lat/lng + county + town +
      zip + state.
    - Filter: installer matches if (within service_radius_miles) OR
      (county in service_counties) OR (town in service_towns) OR
      (zip in service_zips). Match reasons surface per result.
    - Sort: tier-paid first within 5-mi bucket, then distance ascending.
    - Renders zoomed Leaflet map (auto-fits to results, max-zoom 12) +
      result cards with distance, response time, brand-trained count, tier.
    
    Route /api/near-me — JSON API for downstream widgets. Takes lat/lng +
    optional county/town/zip, returns filtered installers + distance.
    60s edge cache.
    
    Admin /admin/profile:
    - New form fields for service_counties / service_towns / service_zips
      (comma-separated, capped per-entry + total count).
    - POST handler arrays them safely, caps total list size.
    
    CSP:
    - connectSrc adds nominatim.openstreetmap.org for the client-side
      'Use my location' reverse-geocode.
    
    Cleanup:
    - map.ejs no longer pulls markercluster (unused — single-city result
      sets don't need clustering).
    
    Smoke-tested:
    - /map → 200, renders form + Use-my-location button.
    - /map?address=90210 → 200, geocodes to 34.09 / -118.41 (Beverly Hills),
      16 studios service the area, map markers + cards render.
---
 db/migrations/021_installer_service_areas.sql |  33 +++
 lib/geocode.js                                | 113 ++++++++
 routes/admin.js                               |  11 +-
 routes/public.js                              | 151 ++++++++++-
 server.js                                     |   5 +-
 views/admin/profile.ejs                       |  17 ++
 views/public/map.ejs                          | 377 +++++++++++---------------
 7 files changed, 490 insertions(+), 217 deletions(-)

diff --git a/db/migrations/021_installer_service_areas.sql b/db/migrations/021_installer_service_areas.sql
new file mode 100644
index 0000000..a72b0c7
--- /dev/null
+++ b/db/migrations/021_installer_service_areas.sql
@@ -0,0 +1,33 @@
+-- 021 · Structured service-area coverage per installer
+--
+-- Today: every installer has `service_radius_miles` (single number, default 50)
+-- and lat/lng. That works for "radius around the studio" but breaks for the
+-- real-world case where a studio in Manhattan services all 5 boroughs +
+-- specific Hudson Valley towns, OR explicitly excludes far-out areas.
+--
+-- These columns let the studio list (a) counties they service, (b) specific
+-- towns/cities they service, (c) zips they service. Additive to the existing
+-- radius: a match on ANY surface (radius OR county OR town OR zip) counts as
+-- "this studio services your location." Empty arrays = radius-only behavior,
+-- which preserves the current default for the 524 seeded studios.
+--
+-- Reversible:
+--   ALTER TABLE installers
+--     DROP COLUMN service_counties,
+--     DROP COLUMN service_towns,
+--     DROP COLUMN service_zips;
+
+BEGIN;
+
+ALTER TABLE installers
+  ADD COLUMN IF NOT EXISTS service_counties TEXT[] DEFAULT ARRAY[]::TEXT[],
+  ADD COLUMN IF NOT EXISTS service_towns    TEXT[] DEFAULT ARRAY[]::TEXT[],
+  ADD COLUMN IF NOT EXISTS service_zips     TEXT[] DEFAULT ARRAY[]::TEXT[];
+
+-- GIN indexes so the @> / && operators stay cheap when /near-me filters
+-- "which installers list this county/town/zip" on every search.
+CREATE INDEX IF NOT EXISTS idx_installers_service_counties ON installers USING gin (service_counties);
+CREATE INDEX IF NOT EXISTS idx_installers_service_towns    ON installers USING gin (service_towns);
+CREATE INDEX IF NOT EXISTS idx_installers_service_zips     ON installers USING gin (service_zips);
+
+COMMIT;
diff --git a/lib/geocode.js b/lib/geocode.js
new file mode 100644
index 0000000..e7f7579
--- /dev/null
+++ b/lib/geocode.js
@@ -0,0 +1,113 @@
+// Address → {lat, lng, county, town, zip, state, display_name} via OSM
+// Nominatim. Free, no API key, but rate-limited to ~1 req/sec by their
+// usage policy. We add a 10s in-memory cache keyed on the normalised
+// address string to avoid hammering them on retries/refreshes.
+//
+// User-Agent is mandatory per Nominatim policy — generic UAs get blocked.
+//
+// Returned fields:
+//   lat, lng         numbers
+//   county           "Los Angeles County" (drop the trailing word when
+//                    matching against service_counties)
+//   town             city / town / village / hamlet / municipality
+//   zip              postal_code or null
+//   state            short state code (2-letter)
+//   display_name     human-readable address echo
+//
+// On failure → null. Callers should branch on null and surface a friendly
+// "we couldn't find that address" rather than 500.
+
+const https = require('https');
+
+const UA = 'NationalPaperHangers/1.0 (info@nationalpaperhangers.com)';
+const _cache = new Map();
+const TTL_MS = 10 * 60 * 1000;
+
+function _normalise(q) {
+  return String(q || '').trim().toLowerCase().replace(/\s+/g, ' ');
+}
+
+function geocode(query) {
+  return new Promise((resolve) => {
+    const norm = _normalise(query);
+    if (!norm) return resolve(null);
+
+    const cached = _cache.get(norm);
+    if (cached && Date.now() - cached.at < TTL_MS) return resolve(cached.value);
+
+    const params = new URLSearchParams({
+      q: query,
+      format: 'jsonv2',
+      addressdetails: '1',
+      countrycodes: 'us',     // wallcovering market is US-only for now
+      limit: '1'
+    });
+    const opts = {
+      hostname: 'nominatim.openstreetmap.org',
+      path: '/search?' + params.toString(),
+      method: 'GET',
+      headers: { 'User-Agent': UA, 'Accept': 'application/json' }
+    };
+    const req = https.request(opts, (res) => {
+      let buf = '';
+      res.on('data', c => buf += c);
+      res.on('end', () => {
+        try {
+          const arr = JSON.parse(buf);
+          if (!Array.isArray(arr) || arr.length === 0) {
+            _cache.set(norm, { at: Date.now(), value: null });
+            return resolve(null);
+          }
+          const r = arr[0];
+          const a = r.address || {};
+          const stateLong = a.state || '';
+          // ISO_3166_2 is sometimes set ("US-CA"); fall back to the long-name map.
+          const stateMap = {
+            'alabama':'AL','alaska':'AK','arizona':'AZ','arkansas':'AR','california':'CA','colorado':'CO',
+            'connecticut':'CT','delaware':'DE','district of columbia':'DC','florida':'FL','georgia':'GA',
+            'hawaii':'HI','idaho':'ID','illinois':'IL','indiana':'IN','iowa':'IA','kansas':'KS',
+            'kentucky':'KY','louisiana':'LA','maine':'ME','maryland':'MD','massachusetts':'MA','michigan':'MI',
+            'minnesota':'MN','mississippi':'MS','missouri':'MO','montana':'MT','nebraska':'NE','nevada':'NV',
+            'new hampshire':'NH','new jersey':'NJ','new mexico':'NM','new york':'NY','north carolina':'NC',
+            'north dakota':'ND','ohio':'OH','oklahoma':'OK','oregon':'OR','pennsylvania':'PA','rhode island':'RI',
+            'south carolina':'SC','south dakota':'SD','tennessee':'TN','texas':'TX','utah':'UT','vermont':'VT',
+            'virginia':'VA','washington':'WA','west virginia':'WV','wisconsin':'WI','wyoming':'WY'
+          };
+          const state = (a['ISO3166-2-lvl4'] || '').replace(/^US-/, '')
+                     || stateMap[stateLong.toLowerCase()]
+                     || stateLong;
+          const value = {
+            lat: parseFloat(r.lat),
+            lng: parseFloat(r.lon),
+            county: a.county || null,       // includes "County" suffix; caller may strip
+            town: a.city || a.town || a.village || a.hamlet || a.municipality || a.suburb || null,
+            zip: a.postcode || null,
+            state,
+            display_name: r.display_name
+          };
+          _cache.set(norm, { at: Date.now(), value });
+          resolve(value);
+        } catch {
+          _cache.set(norm, { at: Date.now(), value: null });
+          resolve(null);
+        }
+      });
+    });
+    req.on('error', () => resolve(null));
+    req.setTimeout(8000, () => { req.destroy(); resolve(null); });
+    req.end();
+  });
+}
+
+// Haversine distance between two lat/lng pairs, in miles.
+function distanceMiles(lat1, lng1, lat2, lng2) {
+  const toRad = d => (d * Math.PI) / 180;
+  const R = 3958.8; // earth radius in miles
+  const dLat = toRad(lat2 - lat1);
+  const dLng = toRad(lng2 - lng1);
+  const a = Math.sin(dLat/2) ** 2 +
+            Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng/2) ** 2;
+  return R * 2 * Math.asin(Math.min(1, Math.sqrt(a)));
+}
+
+module.exports = { geocode, distanceMiles };
diff --git a/routes/admin.js b/routes/admin.js
index 1006ded..88dda75 100644
--- a/routes/admin.js
+++ b/routes/admin.js
@@ -369,6 +369,11 @@ router.post('/profile', async (req, res, next) => {
     if (f.eq_notes) equipment.notes = clampLen(f.eq_notes, 400);
     const equipmentJson = Object.keys(equipment).length ? JSON.stringify(equipment) : null;
 
+    // Service-area lists: counties/towns/zips comma-separated; cap each entry
+    // length so a runaway paste doesn't bloat the row. Cap total count too.
+    const serviceArr = (s, max, capLen) =>
+      String(s || '').split(',').map(x => clampLen(x.trim(), capLen)).filter(Boolean).slice(0, max);
+
     await db.query(
       `UPDATE installers SET
         business_name = $2, contact_name = $3, phone = $4, headline = $5, bio = $6,
@@ -376,6 +381,7 @@ router.post('/profile', async (req, res, next) => {
         team_size = $12, founded_year = $13, website = $14,
         market_segments = $15, materials = $16, brands_handled = $17, accreditations = $18,
         response_time_hours = $19, equipment = $20,
+        service_counties = $21, service_towns = $22, service_zips = $23,
         profile_complete = (LENGTH(COALESCE($6,'')) > 40 AND LENGTH(COALESCE($5,'')) > 0)
        WHERE id = $1`,
       [
@@ -395,7 +401,10 @@ router.post('/profile', async (req, res, next) => {
         website,
         arr(f.market_segments), arr(f.materials), arr(f.brands_handled), arr(f.accreditations),
         parseInt(f.response_time_hours || '24', 10),
-        equipmentJson
+        equipmentJson,
+        serviceArr(f.service_counties, 60, 80),
+        serviceArr(f.service_towns, 80, 80),
+        serviceArr(f.service_zips, 100, 10)
       ]
     );
     req.session.flash = { ok: 'Profile saved' };
diff --git a/routes/public.js b/routes/public.js
index cc8eb44..b848c7a 100644
--- a/routes/public.js
+++ b/routes/public.js
@@ -737,22 +737,167 @@ router.get('/sitemap.xml', async (req, res, next) => {
 // at low zoom; click → popup with mini-card + links to profile and book/visit.
 router.get('/map', async (req, res, next) => {
   try {
+    // Address-driven map: the user enters where they are; we show only the
+    // installers servicing THAT location, sorted by distance. No
+    // country-wide pin map — that gave noise instead of answers.
+    const { geocode, distanceMiles } = require('../lib/geocode');
+    const address = String(req.query.address || '').trim();
+
     const stats = await db.one(
       `SELECT
          COUNT(*) FILTER (WHERE latitude IS NOT NULL AND longitude IS NOT NULL) AS pinned,
          COUNT(*) FILTER (WHERE status='active' OR claim_status='unclaimed') AS total
          FROM installers`
     );
+
+    if (!address) {
+      // First visit: just show the address-input form. No map yet.
+      return res.render('public/map', {
+        title: 'Who services my area? · National Paper Hangers',
+        metaDescription: `Enter your address and we'll show every wallcovering installer who services your area — sorted by distance.`,
+        canonicalPath: '/map',
+        path: '/map',
+        stats,
+        address: '',
+        location: null,
+        results: [],
+        geocodeFailed: false
+      });
+    }
+
+    const loc = await geocode(address);
+    if (!loc) {
+      return res.render('public/map', {
+        title: 'Address not found · National Paper Hangers',
+        metaDescription: '',
+        canonicalPath: '/map',
+        path: '/map',
+        stats,
+        address,
+        location: null,
+        results: [],
+        geocodeFailed: true
+      });
+    }
+
+    // Strip "County" suffix from Nominatim's "Los Angeles County" before
+    // matching against installer.service_counties — installers tend to enter
+    // bare county names in admin.
+    const userCounty = (loc.county || '').replace(/\s+County$/i, '').trim();
+    const userTown = (loc.town || '').trim();
+    const userZip = (loc.zip || '').trim();
+
+    // Pull every geocoded installer and decide service-area match in JS.
+    // 524 rows is cheap; once we cross 5k we'll move this filter to SQL using
+    // ST_DWithin via earthdistance/cube. Don't pre-optimise.
+    const rows = await db.many(
+      `SELECT i.id, i.slug, i.business_name, i.headline, i.city, i.state, i.zip,
+              i.latitude::float8 AS lat, i.longitude::float8 AS lng,
+              i.service_radius_miles, i.service_counties, i.service_towns, i.service_zips,
+              i.tier, i.verified, i.claim_status, i.response_time_hours,
+              i.market_segments, i.materials,
+              COALESCE((
+                SELECT COUNT(*) FROM installer_credentials c
+                 WHERE c.installer_id = i.id AND c.ops_verified = true
+              ), 0)::int AS verified_brands_count
+         FROM installers i
+        WHERE i.latitude IS NOT NULL AND i.longitude IS NOT NULL
+          AND (i.status='active' OR i.claim_status='unclaimed')`
+    );
+
+    const matches = [];
+    for (const r of rows) {
+      const d = distanceMiles(loc.lat, loc.lng, r.lat, r.lng);
+      const radiusHit = d <= (r.service_radius_miles || 50);
+      const countyHit = userCounty && (r.service_counties || []).some(c =>
+        c.toLowerCase().replace(/\s+county$/i, '').trim() === userCounty.toLowerCase());
+      const townHit = userTown && (r.service_towns || []).some(t =>
+        t.toLowerCase().trim() === userTown.toLowerCase());
+      const zipHit = userZip && (r.service_zips || []).some(z => z.trim() === userZip);
+      if (radiusHit || countyHit || townHit || zipHit) {
+        matches.push({
+          ...r,
+          distance_miles: Math.round(d * 10) / 10,
+          match_reasons: [
+            radiusHit && `within ${r.service_radius_miles} mi`,
+            countyHit && `services ${userCounty}`,
+            townHit && `services ${userTown}`,
+            zipHit && `services ${userZip}`,
+          ].filter(Boolean)
+        });
+      }
+    }
+    matches.sort((a, b) => {
+      // Tier-paid first within same distance bucket
+      const tierOrder = { enterprise: 0, signature: 1, pro: 2 };
+      const at = tierOrder[a.tier] ?? 3;
+      const bt = tierOrder[b.tier] ?? 3;
+      if (Math.abs(a.distance_miles - b.distance_miles) > 5) return a.distance_miles - b.distance_miles;
+      return at - bt || a.distance_miles - b.distance_miles;
+    });
+
     res.render('public/map', {
-      title: 'Map of installers · National Paper Hangers',
-      metaDescription: `Browse ${stats.pinned || 0}+ verified luxury wallcovering installers on the map. Click any pin to view the studio profile, request a quote, or book an on-site visit.`,
+      title: `Installers near ${loc.display_name.split(',').slice(0,2).join(',')} · National Paper Hangers`,
+      metaDescription: `${matches.length} wallcovering installer${matches.length === 1 ? '' : 's'} service ${loc.display_name.split(',').slice(0,2).join(',')}. Sorted by distance, with response time + tier signals.`,
       canonicalPath: '/map',
       path: '/map',
-      stats
+      stats,
+      address,
+      location: loc,
+      results: matches,
+      geocodeFailed: false
     });
   } catch (err) { next(err); }
 });
 
+// JSON API for the on-page map widget. Caller passes lat+lng (already
+// geocoded by the form post), and we return the filtered + sorted servicing
+// installers. Cached 60s per coord pair.
+router.get('/api/near-me', async (req, res, next) => {
+  try {
+    const { distanceMiles } = require('../lib/geocode');
+    const lat = parseFloat(req.query.lat);
+    const lng = parseFloat(req.query.lng);
+    const county = String(req.query.county || '').replace(/\s+County$/i, '').trim().toLowerCase();
+    const town = String(req.query.town || '').trim().toLowerCase();
+    const zip = String(req.query.zip || '').trim();
+    if (!Number.isFinite(lat) || !Number.isFinite(lng)) {
+      return res.status(400).json({ error: 'lat/lng required' });
+    }
+    const rows = await db.many(
+      `SELECT i.id, i.slug, i.business_name, i.city, i.state,
+              i.latitude::float8 AS lat, i.longitude::float8 AS lng,
+              i.service_radius_miles, i.service_counties, i.service_towns, i.service_zips,
+              i.tier, i.verified, i.claim_status, i.response_time_hours
+         FROM installers i
+        WHERE i.latitude IS NOT NULL AND i.longitude IS NOT NULL
+          AND (i.status='active' OR i.claim_status='unclaimed')`
+    );
+    const matches = [];
+    for (const r of rows) {
+      const d = distanceMiles(lat, lng, r.lat, r.lng);
+      const radiusHit = d <= (r.service_radius_miles || 50);
+      const countyHit = county && (r.service_counties || []).some(c =>
+        c.toLowerCase().replace(/\s+county$/i, '').trim() === county);
+      const townHit = town && (r.service_towns || []).some(t => t.toLowerCase().trim() === town);
+      const zipHit = zip && (r.service_zips || []).some(z => z.trim() === zip);
+      if (radiusHit || countyHit || townHit || zipHit) {
+        matches.push({
+          id: r.id, slug: r.slug, business_name: r.business_name,
+          city: r.city, state: r.state,
+          lat: r.lat, lng: r.lng,
+          tier: r.tier, verified: r.verified, claim_status: r.claim_status,
+          response_time_hours: r.response_time_hours,
+          distance_miles: Math.round(d * 10) / 10
+        });
+      }
+    }
+    matches.sort((a, b) => a.distance_miles - b.distance_miles);
+    res.set('cache-control', 'public, max-age=60');
+    res.json({ count: matches.length, user: { lat, lng }, installers: matches });
+  } catch (err) { next(err); }
+});
+
 // Lightweight JSON for the map. Only public, non-PII fields. Cached 60s
 // at the edge — pins barely change between requests.
 router.get('/api/installers.geo', async (req, res, next) => {
diff --git a/server.js b/server.js
index a4f6e88..6477800 100644
--- a/server.js
+++ b/server.js
@@ -63,7 +63,10 @@ app.use(helmet({
         'https://www.google-analytics.com',
         'https://*.analytics.google.com',
         // Stripe API + Elements telemetry
-        'https://api.stripe.com', 'https://m.stripe.network', 'https://m.stripe.com'
+        'https://api.stripe.com', 'https://m.stripe.network', 'https://m.stripe.com',
+        // OpenStreetMap Nominatim — client-side reverse-geocode on /map
+        // when the user clicks "📍 Use my location"
+        'https://nominatim.openstreetmap.org'
       ],
       // Stripe iframes (Elements card field, 3DS challenge, hCaptcha for radar)
       // + social-video embeds on installer profiles (YouTube nocookie / Instagram / TikTok).
diff --git a/views/admin/profile.ejs b/views/admin/profile.ejs
index 4ae68f1..a0e03e4 100644
--- a/views/admin/profile.ejs
+++ b/views/admin/profile.ejs
@@ -28,6 +28,23 @@
       </div>
       <label>Service radius (miles) <input type="number" name="service_radius_miles" value="<%= installer.service_radius_miles %>"></label>
       <label class="check"><input type="checkbox" name="travel_available" <%= installer.travel_available ? 'checked' : '' %>> Available to travel for projects</label>
+
+      <p class="muted" style="margin:18px 0 6px;font-size:12px;text-transform:uppercase;letter-spacing:0.08em">Service area (additive to radius — match any line)</p>
+      <label>Counties you service (comma-separated, no "County" suffix)
+        <input type="text" name="service_counties"
+               value="<%= (installer.service_counties || []).join(', ') %>"
+               placeholder="e.g. Los Angeles, Ventura, Orange">
+      </label>
+      <label>Towns/cities you service (comma-separated)
+        <input type="text" name="service_towns"
+               value="<%= (installer.service_towns || []).join(', ') %>"
+               placeholder="e.g. Beverly Hills, Malibu, Santa Monica">
+      </label>
+      <label>ZIPs you explicitly service (comma-separated)
+        <input type="text" name="service_zips"
+               value="<%= (installer.service_zips || []).join(', ') %>"
+               placeholder="e.g. 90210, 90402, 90272">
+      </label>
     </fieldset>
     <fieldset>
       <legend>Specialty</legend>
diff --git a/views/public/map.ejs b/views/public/map.ejs
index 168fb06..9b31cb0 100644
--- a/views/public/map.ejs
+++ b/views/public/map.ejs
@@ -1,239 +1,192 @@
 <%- include('../partials/head', { title }) %>
 <link rel="stylesheet" href="/vendor/leaflet/leaflet.css">
-<link rel="stylesheet" href="/vendor/markercluster/MarkerCluster.css">
-<link rel="stylesheet" href="/vendor/markercluster/MarkerCluster.Default.css">
 <style>
-  .map-page { display: grid; grid-template-rows: auto 1fr; min-height: calc(100vh - 64px); }
-  .map-head {
-    display: flex; align-items: center; gap: 1rem;
-    padding: 1rem 1.5rem;
-    border-bottom: 1px solid var(--border, #2a2a2a);
-    background: var(--surface, #111);
-    flex-wrap: wrap;
+  .near-me-page { max-width: 1200px; margin: 0 auto; padding: 32px 24px; }
+  .near-me-head h1 { font-size: 32px; letter-spacing: 0.005em; margin: 0 0 8px; }
+  .near-me-head p.lede { margin: 0 0 20px; max-width: 560px; line-height: 1.55; font-size: 15px; }
+  .near-me-form {
+    display: flex; gap: 10px; flex-wrap: wrap; margin: 0 0 24px;
+    padding: 16px; border: 1px solid var(--border, #ddd); border-radius: 8px;
+    background: var(--bg-subtle, #faf9f7);
   }
-  .map-head h1 { margin: 0; font-family: 'Cormorant Garamond', serif; font-weight: 500; font-size: 1.5rem; }
-  .map-head .map-stats { color: var(--muted, #999); font-size: 0.85rem; }
-  .map-head .map-controls { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-left: auto; }
-  .map-head input, .map-head select {
-    padding: 0.45rem 0.75rem; border-radius: 4px;
-    border: 1px solid var(--border, #2a2a2a);
-    background: var(--bg, #0e0e0e); color: var(--text, #eee);
-    font-family: inherit; font-size: 0.9rem;
+  .near-me-form label { flex: 1; min-width: 280px; font-size: 13px; color: #666; }
+  .near-me-form input[type="text"] {
+    width: 100%; padding: 12px 14px; font-size: 15px;
+    border: 1px solid var(--border, #ddd); border-radius: 6px;
+    margin-top: 4px;
   }
-  #map { width: 100%; height: 100%; min-height: 540px; background: #0e0e0e; }
-  .pin-popup { font-family: 'Inter', sans-serif; min-width: 220px; }
-  .pin-popup h3 {
-    margin: 0 0 0.25rem; font-family: 'Cormorant Garamond', serif;
-    font-weight: 500; font-size: 1.1rem; color: #0e0e0e;
+  .near-me-form button { padding: 12px 22px; font-size: 15px; }
+  .near-me-geo-btn {
+    background: transparent; border: 1px solid var(--border, #ddd);
+    color: inherit; cursor: pointer; padding: 12px 18px;
+    border-radius: 6px; font-size: 14px;
+    margin-top: 19px;
   }
-  .pin-popup .pin-loc { color: #555; font-size: 0.82rem; margin: 0 0 0.5rem; }
-  .pin-popup .pin-badges { display: flex; gap: 0.3rem; flex-wrap: wrap; margin: 0.5rem 0; }
-  .pin-popup .badge {
-    display: inline-block; padding: 0.15rem 0.45rem;
-    font-size: 0.7rem; letter-spacing: 0.04em; text-transform: uppercase;
-    border-radius: 3px; background: #f3f3f3; color: #333;
-  }
-  .pin-popup .badge.tier-signature { background: #1a1a1a; color: #fff; }
-  .pin-popup .badge.tier-pro       { background: #6b4f2c; color: #fff; }
-  .pin-popup .badge.verified       { background: #1f5e3a; color: #fff; }
-  .pin-popup .badge.unclaimed      { background: #b8860b; color: #fff; }
-  .pin-popup .badge.brand-trained  { background: #2a3550; color: #fff; }
-  .pin-popup .pin-actions { display: flex; gap: 0.4rem; margin-top: 0.6rem; }
-  .pin-popup .pin-actions a {
-    flex: 1; text-align: center;
-    padding: 0.4rem 0.5rem; font-size: 0.8rem; font-weight: 500;
-    border-radius: 4px; text-decoration: none;
-  }
-  .pin-popup .pin-actions a.primary { background: #0e0e0e; color: #fff; }
-  .pin-popup .pin-actions a.ghost   { background: transparent; color: #0e0e0e; border: 1px solid #ccc; }
-  .pin-popup .pin-thumb { width: 100%; aspect-ratio: 4/3; overflow: hidden; margin: -0.75rem -0.9rem 0.6rem; }
-  .pin-popup .pin-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
-  .leaflet-popup-content { margin: 0.75rem 0.9rem; }
-  .pin-marker { width: 14px; height: 14px; border-radius: 50%; border: 2px solid #fff; box-shadow: 0 1px 4px rgba(0,0,0,0.4); }
-  .pin-marker.signature { background: #d4a847; }
-  .pin-marker.pro       { background: #6b4f2c; }
-  .pin-marker.basic     { background: #555; }
-  .pin-marker.unclaimed { background: #999; opacity: 0.85; }
+  .near-me-layout { display: grid; grid-template-columns: 1fr 1.2fr; gap: 24px; align-items: start; }
+  @media (max-width: 800px) { .near-me-layout { grid-template-columns: 1fr; } }
+  #near-me-map { height: 520px; border-radius: 8px; overflow: hidden; border: 1px solid var(--border, #ddd); }
+  .results-pane h2 { font-size: 14px; text-transform: uppercase; letter-spacing: 0.08em; color: #999; margin: 0 0 12px; }
+  .results-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 12px; max-height: 520px; overflow-y: auto; }
+  .result-card { padding: 14px 16px; border: 1px solid var(--border, #ddd); border-radius: 6px; transition: border-color 0.15s; }
+  .result-card:hover { border-color: #0e0e0e; }
+  .result-card .name { font-weight: 600; font-size: 15px; }
+  .result-card .meta { font-size: 13px; color: #666; margin-top: 4px; }
+  .result-card .reasons { font-size: 12px; color: #15803d; margin-top: 6px; }
+  .tier-badge { display: inline-block; font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; padding: 2px 8px; border-radius: 10px; vertical-align: middle; margin-left: 6px; }
+  .tier-signature, .tier-enterprise { background: #0e0e0e; color: #fff; }
+  .tier-pro { background: #f3f4f6; color: #374151; }
+  .empty-state { padding: 32px; border: 1px dashed var(--border, #ddd); border-radius: 8px; text-align: center; color: #666; }
 </style>
-
 <%- include('../partials/header') %>
 
-<section class="map-page">
-  <div class="map-head">
-    <div>
-      <h1>Find an installer on the map</h1>
-      <p class="map-stats">
-        <%= stats.pinned %> of <%= stats.total %> studios pinned · Click a pin to view the profile, request a quote, or book a visit
-      </p>
+<section class="near-me-page">
+  <header class="near-me-head">
+    <h1>Who services my area?</h1>
+    <p class="lede">Enter your address and we'll show every wallcovering installer who services your location — sorted by distance. <%= stats.pinned %>+ studios are mapped.</p>
+  </header>
+
+  <form method="get" action="/map" class="near-me-form" autocomplete="off">
+    <label>
+      Your address, city, or ZIP
+      <input type="text" name="address" value="<%= address || '' %>" required
+             placeholder="e.g. 90210, or 123 Main St, Brooklyn NY"
+             autofocus>
+    </label>
+    <button type="submit" class="btn btn-primary">Show installers near me →</button>
+    <button type="button" class="near-me-geo-btn" id="useLocBtn" title="Use my browser-detected location">📍 Use my location</button>
+  </form>
+
+  <% if (geocodeFailed) { %>
+    <div class="callout" style="padding:14px 18px;border:1px solid #fca5a5;background:#fef2f2;border-radius:6px;margin-bottom:24px">
+      <strong>Couldn't find that address.</strong> Try adding the city + state (e.g. "Brooklyn, NY") or a ZIP code.
     </div>
-    <div class="map-controls">
-      <input type="text" id="map-search" placeholder="City, ZIP, brand, business name" autocomplete="off">
-      <select id="map-segment">
-        <option value="">All segments</option>
-        <option value="luxury_residential">Luxury residential</option>
-        <option value="hospitality">Hospitality</option>
-        <option value="retail">Retail</option>
-        <option value="museum">Museum</option>
-        <option value="yacht">Yacht</option>
-      </select>
-      <select id="map-material">
-        <option value="">All materials</option>
-        <option value="hand_painted">Hand-painted</option>
-        <option value="silk">Silk</option>
-        <option value="grasscloth">Grasscloth</option>
-        <option value="mural">Mural</option>
-        <option value="vinyl">Vinyl</option>
-        <option value="metallic_leaf">Metallic leaf</option>
-      </select>
-      <a id="map-list-link" href="/find" class="btn btn-ghost btn-sm" style="font-size:13px;align-self:center;text-decoration:none" aria-label="View these results as a list">View as list</a>
+  <% } %>
+
+  <% if (location) { %>
+    <div class="near-me-layout">
+      <div>
+        <h2 style="margin:0 0 8px;font-size:14px;text-transform:uppercase;letter-spacing:0.08em;color:#999">Your area</h2>
+        <p style="margin:0 0 12px;font-size:14px"><%= location.display_name %></p>
+        <div id="near-me-map" data-lat="<%= location.lat %>" data-lng="<%= location.lng %>"
+             data-installers='<%- JSON.stringify(results.map(r => ({ id:r.id, slug:r.slug, name:r.business_name, lat:r.lat, lng:r.lng, distance:r.distance_miles, tier:r.tier }))) %>'></div>
+      </div>
+
+      <div class="results-pane">
+        <h2><%= results.length %> studio<%= results.length === 1 ? '' : 's' %> service this area</h2>
+        <% if (!results.length) { %>
+          <div class="empty-state">
+            <p style="margin:0 0 6px"><strong>No installer-marked service coverage for this location.</strong></p>
+            <p style="margin:0;font-size:13px">Try a nearby ZIP, or <a href="/find">browse all studios</a> and reach out directly — many will travel for the right project.</p>
+          </div>
+        <% } else { %>
+          <ul class="results-list">
+            <% results.forEach(function(r){ %>
+              <li class="result-card">
+                <a href="/installer/<%= r.slug %>" style="text-decoration:none;color:inherit;display:block">
+                  <div>
+                    <span class="name"><%= r.business_name %></span>
+                    <% if (r.tier === 'signature' || r.tier === 'enterprise') { %>
+                      <span class="tier-badge tier-<%= r.tier %>"><%= r.tier %></span>
+                    <% } else if (r.tier === 'pro') { %>
+                      <span class="tier-badge tier-pro">pro</span>
+                    <% } %>
+                    <% if (r.verified) { %><span style="font-size:12px;color:#888"> · verified</span><% } %>
+                  </div>
+                  <div class="meta">
+                    <%= r.distance_miles %> mi · <%= r.city %><% if (r.state) { %>, <%= r.state %><% } %>
+                    <% if (r.response_time_hours && r.claim_status !== 'unclaimed') { %>
+                      · replies in &lt; <%= r.response_time_hours %>h
+                    <% } %>
+                    <% if (r.verified_brands_count) { %>
+                      · <%= r.verified_brands_count %> brand-trained
+                    <% } %>
+                  </div>
+                  <% if (r.match_reasons && r.match_reasons.length) { %>
+                    <div class="reasons">✓ <%= r.match_reasons.join(' · ') %></div>
+                  <% } %>
+                </a>
+              </li>
+            <% }); %>
+          </ul>
+        <% } %>
+      </div>
     </div>
-  </div>
-  <div id="map" role="application" aria-label="Map of installer studios"></div>
-</section>
+  <% } %>
 
-<%- include('../partials/footer') %>
+</section>
 
+<% if (location) { %>
 <script src="/vendor/leaflet/leaflet.js"></script>
-<script src="/vendor/markercluster/leaflet.markercluster.js"></script>
 <script>
 (function(){
-  var US_CENTER = [39.5, -98.35];
-  var map = L.map('map', { worldCopyJump: true }).setView(US_CENTER, 4);
+  var mapEl = document.getElementById('near-me-map');
+  if (!mapEl || !window.L) return;
+  var ulat = parseFloat(mapEl.dataset.lat);
+  var ulng = parseFloat(mapEl.dataset.lng);
+  var insts = [];
+  try { insts = JSON.parse(mapEl.dataset.installers || '[]'); } catch(e) {}
+
+  var maxDist = insts.length ? Math.max.apply(null, insts.map(function(i){return i.distance;})) : 5;
+  var zoom = maxDist < 5 ? 12 : maxDist < 20 ? 10 : maxDist < 60 ? 9 : 8;
 
+  var map = L.map(mapEl).setView([ulat, ulng], zoom);
   L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
     maxZoom: 18,
-    attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
+    attribution: '© OpenStreetMap'
   }).addTo(map);
 
-  var cluster = L.markerClusterGroup({
-    showCoverageOnHover: false,
-    spiderfyOnMaxZoom: true,
-    chunkedLoading: true,
-    maxClusterRadius: 60
+  L.circleMarker([ulat, ulng], {
+    radius: 9, color: '#dc2626', weight: 3, fillColor: '#fff', fillOpacity: 1
+  }).addTo(map).bindPopup('You');
+
+  insts.forEach(function(i){
+    var tierColor = i.tier === 'signature' || i.tier === 'enterprise' ? '#0e0e0e'
+                  : i.tier === 'pro' ? '#374151'
+                  : '#9ca3af';
+    L.circleMarker([i.lat, i.lng], {
+      radius: 7, color: tierColor, weight: 2, fillColor: '#fff', fillOpacity: 1
+    }).addTo(map).bindPopup(
+      '<a href="/installer/' + i.slug + '" style="color:inherit;font-weight:600">' + i.name + '</a><br>' +
+      '<span style="font-size:12px;color:#666">' + i.distance + ' mi away</span>'
+    );
   });
-  map.addLayer(cluster);
-
-  var allMarkers = [];
-
-  function pinClass(i){
-    if (i.claim_status === 'unclaimed') return 'unclaimed';
-    if (i.tier === 'signature' || i.tier === 'enterprise') return 'signature';
-    if (i.tier === 'pro') return 'pro';
-    return 'basic';
-  }
-
-  function pinIcon(i){
-    var cls = pinClass(i);
-    return L.divIcon({
-      className: '',
-      html: '<div class="pin-marker '+cls+'"></div>',
-      iconSize: [14,14], iconAnchor: [7,7]
-    });
-  }
-
-  function popupHtml(i){
-    var loc = [i.city, i.state].filter(Boolean).join(', ');
-    var badges = '';
-    if (i.tier === 'signature' || i.tier === 'enterprise')
-      badges += '<span class="badge tier-signature">Signature</span>';
-    else if (i.tier === 'pro')
-      badges += '<span class="badge tier-pro">Pro</span>';
-    if (i.verified) badges += '<span class="badge verified">Verified</span>';
-    if (i.claim_status === 'unclaimed') badges += '<span class="badge unclaimed">Unclaimed</span>';
-    if (i.verified_brands_count > 0) {
-      var bn = (i.verified_brands || []).slice(0,2).join(', ');
-      var more = i.verified_brands_count > 2 ? ' +' + (i.verified_brands_count - 2) : '';
-      badges += '<span class="badge brand-trained" title="Brand-trained credentials verified by NPH">✦ ' + escapeHtml(bn + more) + '</span>';
-    }
-    var thumb = i.thumb
-      ? '<div class="pin-thumb"><img src="'+escapeHtml(i.thumb)+'" alt="" loading="lazy" decoding="async"></div>'
-      : '';
-    return '<div class="pin-popup">' +
-      thumb +
-      '<h3>'+escapeHtml(i.business_name)+'</h3>' +
-      '<p class="pin-loc">'+escapeHtml(loc)+'</p>' +
-      (badges ? '<div class="pin-badges">'+badges+'</div>' : '') +
-      '<div class="pin-actions">' +
-        '<a class="primary" href="/installer/'+encodeURIComponent(i.slug)+'">View profile</a>' +
-        (i.claim_status === 'unclaimed'
-          ? '<a class="ghost" href="/installer/'+encodeURIComponent(i.slug)+'#claim">Claim</a>'
-          : '<a class="ghost" href="/installer/'+encodeURIComponent(i.slug)+'/book">Book visit</a>') +
-      '</div>' +
-    '</div>';
-  }
-
-  function escapeHtml(s){
-    return String(s||'').replace(/[&<>"']/g, function(c){
-      return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];
-    });
-  }
 
-  function applyFilters(){
-    var q = (document.getElementById('map-search').value || '').toLowerCase().trim();
-    var seg = document.getElementById('map-segment').value;
-    var mat = document.getElementById('map-material').value;
-    cluster.clearLayers();
-    var n = 0;
-    allMarkers.forEach(function(rec){
-      var i = rec.data;
-      if (q) {
-        var hay = (i.business_name + ' ' + i.city + ' ' + i.state + ' ' + (i.materials||[]).join(' ') + ' ' + (i.market_segments||[]).join(' ')).toLowerCase();
-        if (hay.indexOf(q) === -1) return;
-      }
-      if (seg && (!i.market_segments || i.market_segments.indexOf(seg) === -1)) return;
-      if (mat && (!i.materials || i.materials.indexOf(mat) === -1)) return;
-      cluster.addLayer(rec.marker);
-      n++;
-    });
-    var statsEl = document.querySelector('.map-stats');
-    if (statsEl) statsEl.textContent = n + ' studios match · click a pin for details';
+  if (insts.length) {
+    var pts = insts.map(function(i){ return [i.lat, i.lng]; }).concat([[ulat, ulng]]);
+    map.fitBounds(L.latLngBounds(pts), { padding: [40, 40], maxZoom: 12 });
   }
+})();
+</script>
+<% } %>
 
-  fetch('/api/installers.geo')
-    .then(function(r){ return r.json(); })
-    .then(function(data){
-      (data.installers || []).forEach(function(i){
-        if (typeof i.lat !== 'number' || typeof i.lng !== 'number') return;
-        var m = L.marker([i.lat, i.lng], { icon: pinIcon(i) });
-        m.bindPopup(popupHtml(i));
-        cluster.addLayer(m);
-        allMarkers.push({ data: i, marker: m });
-      });
-      if (allMarkers.length) {
-        var bounds = L.latLngBounds(allMarkers.map(function(r){ return r.marker.getLatLng(); }));
-        map.fitBounds(bounds, { padding: [40,40], maxZoom: 7 });
-      }
-      // Pre-fill controls from URL query string so /find→/map hand-off keeps state.
-      var urlParams = new URLSearchParams(window.location.search);
-      var qParam = urlParams.get('q'); if (qParam) document.getElementById('map-search').value = qParam;
-      var segParam = urlParams.get('segment'); if (segParam) document.getElementById('map-segment').value = segParam;
-      var matParam = urlParams.get('material'); if (matParam) document.getElementById('map-material').value = matParam;
-
-      function syncListLink(){
-        var p = new URLSearchParams();
-        var q = document.getElementById('map-search').value.trim();
-        var s = document.getElementById('map-segment').value;
-        var m = document.getElementById('map-material').value;
-        if (q) p.set('q', q);
-        if (s) p.set('segment', s);
-        if (m) p.set('material', m);
-        var link = document.getElementById('map-list-link');
-        if (link) link.href = '/find' + (p.toString() ? '?' + p.toString() : '');
-      }
-
-      ['map-search','map-segment','map-material'].forEach(function(id){
-        var el = document.getElementById(id);
-        if (!el) return;
-        el.addEventListener('input', applyFilters);
-        el.addEventListener('input', syncListLink);
-        el.addEventListener('change', applyFilters);
-        el.addEventListener('change', syncListLink);
-      });
-
-      // Apply pre-filled filters + initial link sync.
-      if (qParam || segParam || matParam) applyFilters();
-      syncListLink();
-    })
-    .catch(function(e){ console.error('[map] load failed', e); });
+<script>
+(function(){
+  var btn = document.getElementById('useLocBtn');
+  var input = document.querySelector('input[name="address"]');
+  if (!btn || !navigator.geolocation) { if (btn) btn.style.display = 'none'; return; }
+  btn.addEventListener('click', function(){
+    btn.textContent = '📍 Finding…';
+    btn.disabled = true;
+    navigator.geolocation.getCurrentPosition(function(pos){
+      var url = 'https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=' +
+                pos.coords.latitude + '&lon=' + pos.coords.longitude;
+      fetch(url, { headers: { 'Accept': 'application/json' } })
+        .then(function(r){ return r.json(); })
+        .then(function(d){
+          input.value = (d && d.display_name) || (pos.coords.latitude.toFixed(5) + ', ' + pos.coords.longitude.toFixed(5));
+          input.form.submit();
+        })
+        .catch(function(){
+          input.value = pos.coords.latitude.toFixed(5) + ', ' + pos.coords.longitude.toFixed(5);
+          input.form.submit();
+        });
+    }, function(err){
+      btn.textContent = '📍 Use my location';
+      btn.disabled = false;
+      alert('Could not get your location: ' + err.message);
+    }, { timeout: 8000 });
+  });
 })();
 </script>
+
+<%- include('../partials/footer') %>

← 58309cd loginLimiter: widen to 100/15min in non-prod (CI flakiness f  ·  back to NationalPaperHangers  ·  wia-bb: log session-minutes to cost-tracker via shared helpe 6346a38 →