← 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, '&quot;') + '"></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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
</script>
</body>
</html>`;
}