← 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
A db/migrations/021_installer_service_areas.sqlA lib/geocode.jsM routes/admin.jsM routes/public.jsM server.jsM views/admin/profile.ejsM views/public/map.ejs
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 < <%= 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: '© <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 {'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 →