← back to Small Business Builder

src/render/corridor-map.js

417 lines

// /corridor/:slug/map — full-screen Leaflet map of one corridor with live
// geolocation. As the user drives the strip, the blue dot updates, the
// nearest-businesses panel re-ranks by haversine distance, and the next-up-
// ahead callouts swap in. Pins are color-coded:
//   gold dot   = state-licensed salon (BBCB)
//   olive dot  = unlicensed salon-category
//   charcoal   = non-salon business
// Brand-driven overlays (logo + name + address + neighborhood + license link)
// open in a side-panel on click. Reuses the same data-fetch the editorial
// /corridor/:slug page uses, so shipping a new corridor automatically gets
// a /corridor/:slug/map for free.

import { esc, escJsonLd } from '../lib/escape.js';

export function renderCorridorMap({ corridorTag = 'ventura-blvd-20mi', meta, businesses = [] } = {}) {
  // Build a slim, JSON-safe payload for the client. We only ship what the
  // map needs to render + the side-panel; full profiles live at /biz/:slug.
  const payload = businesses
    .filter(b => b.latitude != null && b.longitude != null)
    .map(b => ({
      slug: b.slug,
      name: b.name,
      cat:  b.category || '',
      addr: b.address || '',
      hood: b.neighborhood || '',
      city: b.city || '',
      lat:  Number(b.latitude),
      lng:  Number(b.longitude),
      lic:  b.source_data_json?.source === 'dca_bbcb' ? (b.source_data_json?.license_number || '') : '',
      sole: b.source_data_json?.shop_personality === 'sole_prop' ? 1 : 0,
    }));

  // Editorial naming + accent. Falls back if meta missing.
  const title = meta?.title || 'Corridor';
  const accent = meta?.accent || '#5a6e3a';
  const corridorPath = `/corridor/${corridorTag.replace(/-\d+mi$/, '')}`;

  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>${esc(title)} · Drive Map · LA Salon Directory</title>
<meta name="description" content="Live map of ${esc(title)} — every state-licensed salon, restaurant, and storefront on the strip. Tracks your location 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:${accent};--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}

/* TOP BAR ============================================================ */
.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)}
.topbar .title{font-family:var(--serif);font-size:16px;color:var(--ink);font-style:italic;text-align:center;flex:1}
.topbar .title em{color:var(--accent);font-style:italic}
.topbar .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 =============================================================== */
#map{position:absolute;top:50px;left:0;right:0;bottom:0;background:#222}

/* USER-LOCATION DOT (custom, pulses) ================================ */
.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 STYLES ======================================================== */
.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}

/* NEAREST-LIST PANEL ================================================ */
.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);transition:background 150ms ease;cursor:pointer}
.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;letter-spacing:-.005em;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}

/* SIDE PANEL ON PIN-CLICK =========================================== */
.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:24px}
.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)}

@media (max-width:640px){
  .topbar .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">${esc(title)} · <em>Drive Map</em></div>
  <div class="actions">
    <button class="btn" id="follow-toggle" type="button">📍 Follow</button>
    <a class="btn" href="${esc(corridorPath)}">Read</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">geolocate · 5 nearest</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.</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>
// =========================================================================
// Inline payload — all corridor businesses with lat/lng. ${payload.length} total.
// =========================================================================
const SHOPS = ${escJsonLd(JSON.stringify(payload))};
const ACCENT = ${JSON.stringify(accent)};
const CORRIDOR_TITLE = ${JSON.stringify(title)};

// Build a Leaflet map seeded on the median lat/lng of the corridor.
const lats = SHOPS.map(s => s.lat).sort((a,b) => a-b);
const lngs = SHOPS.map(s => s.lng).sort((a,b) => a-b);
const center = lats.length
  ? [lats[Math.floor(lats.length/2)], lngs[Math.floor(lngs.length/2)]]
  : [34.15, -118.45];

const map = L.map('map', { zoomControl: true, attributionControl: true })
  .setView(center, 14);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
  maxZoom: 19,
  attribution: '© OpenStreetMap'
}).addTo(map);

// Fit to all pins on first load — this is corridor-scoped so it's bounded.
if (SHOPS.length) {
  map.fitBounds(SHOPS.map(s => [s.lat, s.lng]), { padding: [60, 60] });
}

// =========================================================================
// Pins — divIcon dots, color by license/category. Cluster for performance.
// =========================================================================
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: 50,
});
const markers = [];
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);
  markers.push(m);
}
map.addLayer(cluster);

// =========================================================================
// Side-panel detail
// =========================================================================
const detailEl = document.getElementById('detail');
const detailBody = document.getElementById('detail-body');
function openDetail(s) {
  const dcaUrl = s.lic ? 'https://search.dca.ca.gov/results?lic=' + encodeURIComponent(s.lic) : '';
  // Apple Maps and Google Maps deep-links — both use ?daddr= or ?destination=
  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' : '') + (s.sole ? ' · Sole-Proprietor' : '') + '</div>'
    + '<h1 class="d-title">' + escapeHtml(s.name) + '</h1>'
    + '<div class="d-meta">'
      + (s.addr ? '<div class="row"><span>Address</span><strong>' + escapeHtml(s.addr) + '</strong></div>' : '')
      + (s.hood ? '<div class="row"><span>Neighborhood</span><strong>' + escapeHtml(s.hood) + '</strong></div>' : '')
      + (s.city ? '<div class="row"><span>City</span><strong>' + escapeHtml(s.city) + '</strong></div>' : '')
      + (s.lic  ? '<div class="row"><span>License</span><strong>' + escapeHtml(s.lic) + '</strong></div>' : '')
      + '<div class="row"><span>Distance</span><strong id="d-distance">—</strong></div>'
    + '</div>'
    + '<div id="d-sources" style="margin:8px 0 0;font-family:var(--mono);font-size:10px;color:var(--mute);text-transform:uppercase;letter-spacing:.06em">loading community links…</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>'
      + (dcaUrl ? '<a class="d-btn" href="' + dcaUrl + '" target="_blank" rel="noopener">◆ Verify License (DCA)</a>' : '')
      + '<a class="d-btn" href="/biz/' + encodeURIComponent(s.slug) + '/links" style="margin-top:14px">+ Add Reddit / Discord / Yelp</a>'
    + '</div>';
  // Async-fetch the combined panel payload (socials + articles + review-sources).
  // Single round-trip; renders inline below the meta block.
  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 socials = j.socials || [];
      const articles = j.articles || [];
      const sources = j.review_sources || [];
      const blocks = [];
      // Website (if known)
      if (j.website) {
        blocks.push('<div style="margin:18px 0 8px;color:var(--accent);font-weight:600;font-size:11px;letter-spacing:.18em;text-transform:uppercase;font-family:var(--mono)">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);margin-bottom:6px">' + escapeHtml(j.website) + ' ↗</a>');
      }
      // Socials row
      if (socials.length) {
        blocks.push('<div style="margin:18px 0 8px;color:var(--accent);font-weight:600;font-size:11px;letter-spacing:.18em;text-transform:uppercase;font-family:var(--mono)">Socials</div>'
          + '<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:6px">'
          + socials.map(soc =>
              '<a href="' + escapeHtml(soc.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(soc.platform) + (soc.handle ? ' · @' + escapeHtml(soc.handle) : '') + ' ↗</a>'
            ).join('')
          + '</div>');
      }
      // Articles list
      if (articles.length) {
        blocks.push('<div style="margin:18px 0 8px;color:var(--accent);font-weight:600;font-size:11px;letter-spacing:.18em;text-transform:uppercase;font-family:var(--mono)">Press · ' + articles.length + '</div>'
          + articles.map(a =>
              '<a href="' + escapeHtml(a.url) + '" target="_blank" rel="noopener" style="display:block;padding:9px 12px;border-bottom:1px dotted var(--rule);color:var(--ink);text-transform:none;letter-spacing:0;font-size:12px;font-family:var(--sans);line-height:1.35">'
              + '<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(''));
      }
      // Community sources
      if (sources.length) {
        blocks.push('<div style="margin:18px 0 8px;color:var(--accent);font-weight:600;font-size:11px;letter-spacing:.18em;text-transform:uppercase;font-family:var(--mono)">Community</div>'
          + sources.map(src =>
              '<a href="' + escapeHtml(src.url) + '" target="_blank" rel="noopener" style="display:block;padding:8px 0;border-bottom:1px dotted var(--rule);color:var(--ink);text-transform:none;letter-spacing:0;font-size:12px;font-family:var(--sans)">'
              + '<span style="font-family:var(--mono);font-size:9px;letter-spacing:.16em;text-transform:uppercase;color:var(--accent);margin-right:8px">' + escapeHtml(src.source_type) + '</span>'
              + escapeHtml(src.label || src.url) + '</a>'
            ).join(''));
      }
      el.innerHTML = blocks.join('');
    })
    .catch(() => { const el = document.getElementById('d-sources'); if (el) el.textContent = ''; });
  detailEl.classList.add('open');
  detailEl.setAttribute('aria-hidden', 'false');
  // If we have a location, fill in the distance now.
  if (currentPos) {
    document.getElementById('d-distance').textContent = formatDist(haversineMi(currentPos.lat, currentPos.lng, s.lat, s.lng));
  }
  // Pan map to the shop with a small offset so the panel doesn't cover it.
  map.setView([s.lat, s.lng], Math.max(map.getZoom(), 16));
}
document.getElementById('detail-close').addEventListener('click', () => {
  detailEl.classList.remove('open');
  detailEl.setAttribute('aria-hidden', 'true');
});

// =========================================================================
// Geolocation — watchPosition, blue-dot + auto-recenter when "Follow" is on.
// =========================================================================
let currentPos = null;
let meMarker = null;
let follow = false;
let 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 · 5 nearest');
  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(), 15));
  refreshNearby(lat, lng);
}
function onPosErr(e) {
  setStatus('location ' + (e.code === 1 ? 'denied' : 'unavailable'));
  stopFollow();
}

// =========================================================================
// Nearby panel — sort by haversine, render top 5.
// =========================================================================
function haversineMi(lat1, lng1, lat2, lng2) {
  const R = 3958.8;  // earth radius in miles
  const 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, 5);
  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" title="state-licensed"></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(CORRIDOR_TITLE.split(' ')[0] + ' · 5 nearest');
  // Wire each row to open the side-panel for that shop.
  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);
    });
  });
  // If detail panel is open, update its distance row live.
  const dEl = document.getElementById('d-distance');
  if (dEl) {
    // Find which shop is currently shown and recompute.
    const open = SHOPS.find(s => detailBody.querySelector('.d-title')?.textContent === s.name);
    if (open) dEl.textContent = formatDist(haversineMi(lat, lng, open.lat, open.lng));
  }
}

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>`;
}