← back to Small Business Builder
src/render/near.js
288 lines
// /near — universal drive-mode map. Same UX as /corridor/:slug/map but
// unbounded: pulls all geocoded shops (capped to 5,000 nearest the viewport)
// and recomputes "5 nearest" as the user drives. Designed for ongoing daily
// use; the /corridor/:slug/map pages are editorial drive-throughs whereas
// this one is the app surface.
//
// Server hands the client a tiny payload (just slug+name+lat+lng+lic) for
// performance — full panel data is fetched via /api/biz/:slug/panel on
// pin-click.
import { esc, escJsonLd } from '../lib/escape.js';
export function renderNear({ businesses = [], totalGeocoded = 0 } = {}) {
// The payload ships ONLY the fields the map + nearby-list need. Detail
// panel fetches the rest async on pin-click.
const payload = businesses.map(b => ({
slug: b.slug,
name: b.name,
cat: b.category || '',
hood: b.neighborhood || '',
lat: Number(b.latitude),
lng: Number(b.longitude),
lic: b.source_data_json?.source === 'dca_bbcb' ? 1 : 0,
dba: b.source_data_json?.dba_name || '',
}));
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<title>Near Me · Drive Map · LA Salon Directory</title>
<meta name="description" content="Live map of every state-licensed salon, barber, and shop near you across LA + Orange + Riverside + San Bernardino + Ventura counties. Updates as you drive.">
<link rel="preconnect" href="https://unpkg.com" crossorigin>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600;700&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="">
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css" crossorigin="">
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css" crossorigin="">
<style>
:root{
--ink:#1a1a1a;--paper:#f5f5f5;--rule:#e6e3dc;--mute:#7a766f;
--accent:#5a6e3a;--gold:#b8945a;
--serif:"Cormorant Garamond",Georgia,serif;
--sans:"Inter",-apple-system,BlinkMacSystemFont,system-ui,sans-serif;
--mono:ui-monospace,"SF Mono",Menlo,monospace;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;background:var(--ink);color:var(--ink);font-family:var(--sans);overflow:hidden}
a{color:inherit;text-decoration:none}
.topbar{position:absolute;top:0;left:0;right:0;z-index:1000;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 14px;background:rgba(245,245,245,.96);backdrop-filter:blur(10px);border-bottom:1px solid var(--rule)}
.brand{font-family:var(--serif);font-size:16px;font-weight:500;letter-spacing:-.005em}
.brand em{color:var(--accent);font-style:italic}
.brand a{color:var(--ink)}
.title{font-family:var(--serif);font-size:16px;font-style:italic;text-align:center;flex:1}
.actions{display:flex;gap:8px;align-items:center}
.btn{font-family:var(--mono);font-size:10px;letter-spacing:.18em;text-transform:uppercase;padding:8px 12px;border:1px solid var(--rule);background:#fff;color:var(--ink);cursor:pointer;transition:all 150ms ease;border-radius:0}
.btn.is-on{background:var(--accent);color:var(--paper);border-color:var(--accent)}
.btn:hover{border-color:var(--accent);color:var(--accent)}
.btn.is-on:hover{color:var(--paper)}
#map{position:absolute;top:50px;left:0;right:0;bottom:0;background:#222}
.me-dot{width:18px;height:18px;border-radius:50%;background:#1a73e8;border:3px solid #fff;box-shadow:0 0 0 4px rgba(26,115,232,.25);animation:mePulse 2.4s ease-out infinite}
@keyframes mePulse{0%,100%{box-shadow:0 0 0 4px rgba(26,115,232,.18)}50%{box-shadow:0 0 0 14px rgba(26,115,232,.04)}}
.pin{width:14px;height:14px;border-radius:50%;border:2px solid #fff;box-shadow:0 1px 4px rgba(0,0,0,.35)}
.pin-licensed{background:var(--gold)}
.pin-salon{background:var(--accent)}
.pin-other{background:#3a3a3a}
.nearby{position:absolute;left:14px;bottom:14px;z-index:1000;width:min(360px,calc(100vw - 28px));max-height:42vh;overflow-y:auto;background:rgba(245,245,245,.96);backdrop-filter:blur(10px);border:1px solid var(--rule);box-shadow:0 12px 32px rgba(0,0,0,.18)}
.nearby-head{padding:14px 18px;border-bottom:1px solid var(--rule);display:flex;justify-content:space-between;align-items:baseline;background:rgba(0,0,0,.02)}
.nearby-head h2{font-family:var(--serif);font-size:18px;font-weight:500;letter-spacing:-.005em}
.nearby-head .meta{font-family:var(--mono);font-size:9px;letter-spacing:.18em;text-transform:uppercase;color:var(--mute)}
.nearby-row{display:flex;justify-content:space-between;align-items:center;gap:10px;padding:10px 18px;border-bottom:1px dotted var(--rule);cursor:pointer;transition:background 150ms ease}
.nearby-row:hover{background:#fff}
.nearby-row:last-child{border-bottom:none}
.n-info{flex:1;min-width:0}
.n-name{font-family:var(--serif);font-size:15px;font-weight:500;line-height:1.2;display:flex;align-items:center;gap:6px}
.n-name .lic-mark{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--gold)}
.n-where{font-family:var(--mono);font-size:9px;color:var(--mute);text-transform:uppercase;letter-spacing:.04em;line-height:1.4;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.n-dist{font-family:var(--mono);font-size:11px;font-weight:600;color:var(--accent);white-space:nowrap}
.detail{position:absolute;top:50px;right:0;bottom:0;z-index:1000;width:min(380px,90vw);background:var(--paper);border-left:1px solid var(--rule);box-shadow:-8px 0 24px rgba(0,0,0,.18);overflow-y:auto;transform:translateX(100%);transition:transform 280ms cubic-bezier(.2,.7,.3,1)}
.detail.open{transform:translateX(0)}
.d-close{position:absolute;top:14px;right:14px;width:32px;height:32px;border:1px solid var(--rule);background:#fff;cursor:pointer;font-size:18px;display:flex;align-items:center;justify-content:center;font-family:var(--mono)}
.d-close:hover{border-color:var(--accent);color:var(--accent)}
.d-body{padding:32px 24px}
.d-eyebrow{font-family:var(--mono);font-size:10px;letter-spacing:.2em;text-transform:uppercase;color:var(--accent);margin-bottom:8px}
.d-title{font-family:var(--serif);font-size:32px;font-weight:500;line-height:1.05;letter-spacing:-.01em;margin-bottom:16px}
.d-meta{font-family:var(--mono);font-size:10px;letter-spacing:.06em;color:var(--mute);text-transform:uppercase;line-height:1.7;margin-bottom:18px}
.d-meta .row{display:flex;justify-content:space-between;gap:8px;padding:6px 0;border-bottom:1px dotted var(--rule)}
.d-meta .row strong{color:var(--ink);font-weight:500}
.d-actions{display:flex;flex-direction:column;gap:8px;margin-top:18px}
.d-btn{display:block;padding:12px 16px;text-align:center;border:1px solid var(--rule);background:#fff;font-family:var(--mono);font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--ink);transition:all 150ms ease}
.d-btn.primary{background:var(--accent);color:var(--paper);border-color:var(--accent)}
.d-btn:hover{border-color:var(--accent);color:var(--accent)}
.d-btn.primary:hover{background:var(--ink);border-color:var(--ink);color:var(--paper)}
.empty{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center;color:#888;font-family:var(--serif);font-size:24px;font-style:italic;pointer-events:none}
@media (max-width:640px){
.title{display:none}
.nearby{max-height:35vh;width:calc(100vw - 28px)}
}
</style>
</head>
<body>
<header class="topbar">
<div class="brand"><a href="/">LA <em>Salon</em></a></div>
<div class="title"><em>Near</em> me · drive map</div>
<div class="actions">
<button class="btn" id="follow-toggle" type="button">📍 Follow</button>
<a class="btn" href="/">Home</a>
</div>
</header>
<div id="map"></div>
<aside class="nearby" id="nearby" aria-live="polite">
<div class="nearby-head">
<h2>Up ahead</h2>
<span class="meta" id="nearby-status">${totalGeocoded.toLocaleString()} shops · tap follow</span>
</div>
<div id="nearby-list">
<div style="padding:14px 18px;font-family:var(--mono);font-size:11px;color:var(--mute);text-transform:uppercase;letter-spacing:.06em">Tap "Follow" to share your location and see what's around you.</div>
</div>
</aside>
<aside class="detail" id="detail" aria-hidden="true">
<button class="d-close" id="detail-close" type="button">×</button>
<div class="d-body" id="detail-body"></div>
</aside>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
<script src="https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js" crossorigin=""></script>
<script>
const SHOPS = ${escJsonLd(JSON.stringify(payload))};
const center = [34.05, -118.25]; // Downtown LA — fallback when geolocation off
const map = L.map('map', { zoomControl: true, attributionControl: true })
.setView(center, 12);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '© OpenStreetMap'
}).addTo(map);
function pinFor(s) {
const cls = s.lic ? 'pin-licensed' : (['salon','barbershop','beard','nail','lash','brow','spa'].includes(s.cat) ? 'pin-salon' : 'pin-other');
return L.divIcon({
className: '',
html: '<div class="pin ' + cls + '" aria-label="' + (s.name || '').replace(/"/g, '"') + '"></div>',
iconSize: [14, 14],
iconAnchor: [7, 7],
});
}
const cluster = L.markerClusterGroup({
showCoverageOnHover: false,
spiderfyOnMaxZoom: true,
maxClusterRadius: 60,
chunkedLoading: true, // perf — incremental marker insertion
});
for (const s of SHOPS) {
const m = L.marker([s.lat, s.lng], { icon: pinFor(s) });
m.shop = s;
m.on('click', () => openDetail(s));
cluster.addLayer(m);
}
map.addLayer(cluster);
const detailEl = document.getElementById('detail');
const detailBody = document.getElementById('detail-body');
function openDetail(s) {
const apple = 'https://maps.apple.com/?daddr=' + s.lat + ',' + s.lng;
const google = 'https://www.google.com/maps/dir/?api=1&destination=' + s.lat + ',' + s.lng;
detailBody.innerHTML = ''
+ '<div class="d-eyebrow">' + escapeHtml(s.cat || 'business') + (s.lic ? ' · ◆ State-Licensed' : '') + '</div>'
+ '<h1 class="d-title">' + escapeHtml(s.name) + '</h1>'
+ '<div class="d-meta">'
+ (s.hood ? '<div class="row"><span>Neighborhood</span><strong>' + escapeHtml(s.hood) + '</strong></div>' : '')
+ '<div class="row"><span>Distance</span><strong id="d-distance">—</strong></div>'
+ '</div>'
+ '<div id="d-sources" style="font-family:var(--mono);font-size:10px;color:var(--mute);text-transform:uppercase;letter-spacing:.06em">loading…</div>'
+ '<div class="d-actions">'
+ '<a class="d-btn primary" href="/biz/' + encodeURIComponent(s.slug) + '">View Profile</a>'
+ '<a class="d-btn" href="' + apple + '" target="_blank" rel="noopener">Apple Maps · Drive Here</a>'
+ '<a class="d-btn" href="' + google + '" target="_blank" rel="noopener">Google Maps · Drive Here</a>'
+ '<a class="d-btn" href="/biz/' + encodeURIComponent(s.slug) + '/links" style="margin-top:14px">+ Add Reddit / Discord / Yelp</a>'
+ '</div>';
detailEl.classList.add('open');
detailEl.setAttribute('aria-hidden', 'false');
if (currentPos) {
document.getElementById('d-distance').textContent = formatDist(haversineMi(currentPos.lat, currentPos.lng, s.lat, s.lng));
}
map.setView([s.lat, s.lng], Math.max(map.getZoom(), 16));
// Async-fetch the rich panel data (websites, socials, articles, sources).
fetch('/api/biz/' + encodeURIComponent(s.slug) + '/panel')
.then(r => r.json())
.then(j => {
const el = document.getElementById('d-sources');
if (!el || !j?.ok) { if (el) el.textContent = ''; return; }
const blocks = [];
if (j.website) blocks.push('<div style="margin:14px 0 6px;color:var(--accent);font-weight:600;font-size:11px;letter-spacing:.18em;text-transform:uppercase">Website</div><a href="' + escapeHtml(j.website) + '" target="_blank" rel="noopener" style="display:block;padding:8px 12px;border:1px solid var(--rule);background:#fff;color:var(--ink);text-transform:none;letter-spacing:0;font-size:13px;font-family:var(--sans)">' + escapeHtml(j.website) + ' ↗</a>');
if (j.socials?.length) blocks.push('<div style="margin:14px 0 6px;color:var(--accent);font-weight:600;font-size:11px;letter-spacing:.18em;text-transform:uppercase">Socials</div><div style="display:flex;flex-wrap:wrap;gap:6px">' + j.socials.map(s => '<a href="' + escapeHtml(s.url) + '" target="_blank" rel="noopener" style="padding:6px 10px;border:1px solid var(--rule);background:#fff;color:var(--ink);text-transform:none;letter-spacing:0;font-size:11px;font-family:var(--mono)">' + escapeHtml(s.platform) + (s.handle ? ' · @' + escapeHtml(s.handle) : '') + ' ↗</a>').join('') + '</div>');
if (j.articles?.length) blocks.push('<div style="margin:14px 0 6px;color:var(--accent);font-weight:600;font-size:11px;letter-spacing:.18em;text-transform:uppercase">Press · ' + j.articles.length + '</div>' + j.articles.map(a => '<a href="' + escapeHtml(a.url) + '" target="_blank" rel="noopener" style="display:block;padding:8px 12px;border-bottom:1px dotted var(--rule);color:var(--ink);text-transform:none;letter-spacing:0;font-size:12px;font-family:var(--sans)"><div style="font-family:var(--mono);font-size:9px;letter-spacing:.16em;text-transform:uppercase;color:var(--accent);margin-bottom:2px">' + escapeHtml(a.publisher || 'press') + '</div><div style="font-family:var(--serif);font-size:14px;font-weight:500">' + escapeHtml(a.title || a.url) + '</div></a>').join(''));
el.innerHTML = blocks.join('');
})
.catch(() => { const el = document.getElementById('d-sources'); if (el) el.textContent = ''; });
}
document.getElementById('detail-close').addEventListener('click', () => {
detailEl.classList.remove('open');
detailEl.setAttribute('aria-hidden', 'true');
});
let currentPos = null, meMarker = null, follow = false, watchId = null;
const followBtn = document.getElementById('follow-toggle');
followBtn.addEventListener('click', () => {
if (!navigator.geolocation) { setStatus('geolocation unsupported'); return; }
if (!follow) startFollow(); else stopFollow();
});
function startFollow() {
follow = true;
followBtn.classList.add('is-on');
followBtn.textContent = '📍 Following';
setStatus('locating…');
watchId = navigator.geolocation.watchPosition(onPos, onPosErr, { enableHighAccuracy:true, maximumAge:5000, timeout:15000 });
}
function stopFollow() {
follow = false;
followBtn.classList.remove('is-on');
followBtn.textContent = '📍 Follow';
setStatus('paused');
if (watchId != null) navigator.geolocation.clearWatch(watchId);
watchId = null;
}
function onPos(p) {
const lat = p.coords.latitude, lng = p.coords.longitude;
currentPos = { lat, lng };
if (!meMarker) {
meMarker = L.marker([lat, lng], { icon:L.divIcon({ className:'', html:'<div class="me-dot"></div>', iconSize:[18,18], iconAnchor:[9,9] }), zIndexOffset:1000, interactive:false }).addTo(map);
} else { meMarker.setLatLng([lat, lng]); }
if (follow) map.setView([lat, lng], Math.max(map.getZoom(), 14));
refreshNearby(lat, lng);
}
function onPosErr(e) { setStatus('location ' + (e.code === 1 ? 'denied' : 'unavailable')); stopFollow(); }
function haversineMi(lat1, lng1, lat2, lng2) {
const R = 3958.8, toRad = d => d * Math.PI / 180;
const dLat = toRad(lat2 - lat1), 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 2 * R * Math.asin(Math.sqrt(a));
}
function formatDist(mi) {
if (mi < 0.1) return Math.round(mi * 5280) + ' ft';
if (mi < 10) return mi.toFixed(2) + ' mi';
return mi.toFixed(1) + ' mi';
}
function setStatus(t) { document.getElementById('nearby-status').textContent = t; }
function refreshNearby(lat, lng) {
const ranked = SHOPS.map(s => ({ ...s, _d: haversineMi(lat, lng, s.lat, s.lng) }))
.sort((a, b) => a._d - b._d).slice(0, 8);
const list = document.getElementById('nearby-list');
list.innerHTML = ranked.map(s =>
'<div class="nearby-row" data-slug="' + escapeHtml(s.slug) + '">'
+ '<div class="n-info"><div class="n-name">' + (s.lic ? '<span class="lic-mark"></span>' : '') + escapeHtml(s.name) + '</div>'
+ '<div class="n-where">' + escapeHtml(s.cat) + (s.hood ? ' · ' + escapeHtml(s.hood) : '') + '</div></div>'
+ '<div class="n-dist">' + formatDist(s._d) + '</div></div>'
).join('');
setStatus(ranked[0] ? ('nearest ' + formatDist(ranked[0]._d)) : 'no shops nearby');
Array.from(list.querySelectorAll('.nearby-row')).forEach(row => {
row.addEventListener('click', () => {
const slug = row.getAttribute('data-slug');
const s = SHOPS.find(x => x.slug === slug);
if (s) openDetail(s);
});
});
}
function escapeHtml(s) {
return String(s == null ? '' : s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
}
</script>
</body>
</html>`;
}