← back to Ventura Corridor

public/walk-route.html

425 lines

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Walk Route · DW · Ventura Corridor</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<script>(function(){try{var t=localStorage.getItem('vc-theme');document.documentElement.dataset.theme=(t==='light'||t==='dark')?t:'dark';}catch(e){document.documentElement.dataset.theme='dark';}})();</script>
<link rel="stylesheet" href="/theme.css">
<script src="/theme.js" defer></script>
<style>
  :root {
    --noir: #0a0a0c; --noir-rise: #131316; --ink: #f0ece2;
    --ink-mute: #888475; --metal: #b89968; --metal-glow: #d4b683;
    --rule: #2a2622;
    --serif: 'Cormorant Garamond', 'Times New Roman', serif;
    --sans: 'Inter', -apple-system, system-ui, sans-serif;
    --mono: 'JetBrains Mono', ui-monospace, monospace;
  }
  @import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;1,300;1,400&family=Inter:wght@100;200;300;400;600&family=JetBrains+Mono:wght@200;300;400&display=swap');
  html, body { margin: 0; background: var(--noir); color: var(--ink); font-family: var(--sans); font-weight: 300; }
  * { box-sizing: border-box; }
  header { position: sticky; top: 0; z-index: 10;
    background: linear-gradient(180deg, rgba(10,10,12,.97) 70%, rgba(10,10,12,0));
    padding: 22px 32px 16px; border-bottom: 1px solid var(--rule);
    display: flex; justify-content: space-between; align-items: baseline; gap: 24px; flex-wrap: wrap; }
  header h1 { font-family: var(--serif); font-weight: 400; font-size: 28px; margin: 0; letter-spacing: -0.01em; }
  header h1 em { font-style: italic; color: var(--metal); }
  .sub { font-size: 11px; letter-spacing: .26em; text-transform: uppercase; color: var(--ink-mute); margin-top: 2px; }
  nav.tabs { display: flex; gap: 4px; flex-wrap: wrap; }
  nav.tabs a { font-size: 9px; letter-spacing: .26em; text-transform: uppercase;
    color: var(--ink-mute); text-decoration: none; padding: 5px 10px; border: 1px solid var(--rule); }
  nav.tabs a.active, nav.tabs a:hover { color: var(--metal); border-color: var(--metal); }

  .controls {
    padding: 24px 32px; border-bottom: 1px solid var(--rule);
    background: var(--noir-rise);
    display: flex; gap: 24px; flex-wrap: wrap; align-items: end;
  }
  .ctrl-group { display: flex; flex-direction: column; gap: 6px; }
  .ctrl-group label { font-size: 9px; letter-spacing: .26em; text-transform: uppercase; color: var(--ink-mute); }
  .ctrl-group select, .ctrl-group input {
    background: var(--noir); border: 1px solid var(--rule); color: var(--ink);
    padding: 8px 12px; font-family: var(--sans); font-size: 13px; min-width: 160px;
    border-radius: 0;
  }
  .ctrl-group select:focus, .ctrl-group input:focus { outline: none; border-color: var(--metal); }
  .btn {
    background: transparent; border: 1px solid var(--metal); color: var(--metal);
    padding: 9px 18px; font-size: 10px; letter-spacing: .26em; text-transform: uppercase;
    cursor: pointer; font-family: var(--sans); transition: all 0.15s;
  }
  .btn:hover { background: rgba(184,153,104,0.1); }
  .btn-print { border-color: var(--metal-glow); color: var(--metal-glow); }

  .summary {
    padding: 24px 32px; border-bottom: 1px solid var(--rule);
    display: flex; gap: 48px; flex-wrap: wrap; align-items: baseline;
  }
  .summary .stat { display: flex; flex-direction: column; gap: 4px; }
  .summary .num { font-family: var(--serif); font-style: italic; font-size: 36px; color: var(--metal-glow); line-height: 1; }
  .summary .lbl { font-size: 9px; letter-spacing: .26em; text-transform: uppercase; color: var(--ink-mute); }

  main { padding: 32px; max-width: 1100px; margin: 0 auto; }

  .stop {
    margin-bottom: 28px; padding: 24px;
    background: var(--noir-rise); border: 1px solid var(--rule);
    page-break-inside: avoid;
    position: relative;
  }
  .stop.done { opacity: 0.45; }
  .stop-head {
    display: flex; align-items: baseline; gap: 18px; margin-bottom: 16px;
    padding-bottom: 14px; border-bottom: 1px solid var(--rule);
  }
  .stop-num {
    font-family: var(--serif); font-style: italic; font-size: 42px; color: var(--metal);
    line-height: 1; min-width: 60px;
  }
  .stop-addr { flex: 1; }
  .stop-addr .street {
    font-family: var(--serif); font-size: 22px; font-weight: 400; color: var(--ink);
    margin: 0 0 4px;
  }
  .stop-addr .meta { font-size: 11px; color: var(--ink-mute); letter-spacing: .12em; }
  .stop-addr .meta .gmaps { color: var(--metal); margin-left: 12px; text-decoration: none; }
  .stop-addr .meta .gmaps:hover { text-decoration: underline; }

  .tenants { display: grid; gap: 12px; }
  .tenant {
    display: grid; grid-template-columns: auto 1fr auto; gap: 16px; align-items: center;
    padding: 12px 14px;
    background: var(--noir); border: 1px solid var(--rule);
  }
  .tenant.sent { border-color: var(--metal); background: rgba(184,153,104,0.04); }
  .tenant input[type="checkbox"] { accent-color: var(--metal); width: 18px; height: 18px; cursor: pointer; }
  .tenant-name { font-family: var(--serif); font-size: 17px; }
  .tenant-name .suite { color: var(--ink-mute); font-style: italic; font-size: 13px; margin-left: 8px; }
  .tenant-pitch { font-size: 9px; letter-spacing: .2em; text-transform: uppercase; color: var(--metal); }
  .tenant-prox { font-size: 8px; letter-spacing: .2em; text-transform: uppercase; color: var(--ink-mute); margin-top: 2px; }

  .script {
    margin-top: 16px; padding: 14px 18px;
    background: var(--noir); border-left: 2px solid var(--metal);
    font-family: var(--serif); font-style: italic; font-size: 14px; line-height: 1.55;
    color: var(--ink-mute);
  }
  .script .label { display: block; font-family: var(--sans); font-style: normal; font-size: 9px; letter-spacing: .26em; color: var(--metal); margin-bottom: 6px; text-transform: uppercase; }

  .hangtag {
    margin-top: 16px; padding: 14px 18px;
    background: rgba(184,153,104,0.04); border: 1px dashed var(--metal);
    font-size: 12px; line-height: 1.55; color: var(--ink);
  }
  .hangtag .label { display: block; font-size: 9px; letter-spacing: .26em; color: var(--metal); margin-bottom: 6px; text-transform: uppercase; }

  .stop-actions { display: flex; gap: 8px; margin-top: 16px; }
  .stop-actions .btn { padding: 7px 14px; font-size: 9px; }

  .empty { text-align: center; padding: 80px 32px; color: var(--ink-mute); }
  .empty .big { font-family: var(--serif); font-style: italic; font-size: 32px; color: var(--metal); margin-bottom: 12px; }

  .loading { text-align: center; padding: 60px; color: var(--ink-mute); font-size: 11px; letter-spacing: .26em; text-transform: uppercase; }

  /* PRINT STYLES */
  @media print {
    html, body { background: white !important; color: black !important; }
    header, .controls, .stop-actions, nav.tabs { display: none !important; }
    .summary { border: none; padding: 12px 0; gap: 24px; }
    .summary .num { color: #555 !important; }
    main { padding: 0; max-width: 100%; }
    .stop { background: white !important; border: 1px solid #999 !important; box-shadow: none !important; margin-bottom: 18px; padding: 16px; }
    .stop-head { border-bottom: 1px solid #999; }
    .stop-num, .stop-addr .street, .tenant-name { color: #000 !important; }
    .stop-addr .meta, .tenant-prox, .tenant-pitch { color: #555 !important; }
    .script { background: #f4f4f4 !important; border-left: 2px solid #555 !important; color: #333 !important; }
    .script .label, .hangtag .label { color: #555 !important; }
    .hangtag { background: #fafafa !important; border-color: #555 !important; color: #000 !important; }
    .tenant { background: #fff !important; border: 1px solid #ccc !important; }
    .tenant.sent { background: #f0eee0 !important; }
    .tenant input[type="checkbox"] { display: inline-block; }
  }
</style>
</head>
<body>

<header>
  <div>
    <h1>Walk <em>route</em> · door-by-door</h1>
    <div class="sub">Printable · DW + neighbors · check off as you go</div>
  </div>
  <nav class="tabs">
    <a href="/">map</a>
    <a href="/today.html">today</a>
    <a href="/pitches.html">pitches</a>
    <a href="/pitches-map.html">map</a>
    <a href="/walk-route.html" class="active">walk route</a>
    <a href="/responses.html">responses</a>
    <a href="/postcards.html">postcards</a>
    <a href="/buildings.html">buildings</a>
    <a href="/crawl-derby.html">🏇 derby</a>
    <a href="/linkedin.html">linkedin</a>
    <span class="theme-toggle-host" style="display:inline-flex;align-items:center;margin-left:6px"></span>
  </nav>
</header>

<div class="controls">
  <div class="ctrl-group">
    <label>route order</label>
    <select id="sort">
      <option value="geographic" selected>Geographic (E↔W lap)</option>
      <option value="priority">⚡ Priority (highest-leverage bldgs first)</option>
    </select>
  </div>
  <div class="ctrl-group" id="direction-group">
    <label>direction</label>
    <select id="direction">
      <option value="east">East (Sherman Oaks → Encino)</option>
      <option value="west">West (Encino → Sherman Oaks)</option>
    </select>
  </div>
  <div class="ctrl-group">
    <label>stops (max)</label>
    <select id="limit">
      <option value="10">10 — quick lap (~30 min)</option>
      <option value="20">20 — half day</option>
      <option value="30" selected>30 — full afternoon</option>
      <option value="50">50 — all day</option>
      <option value="80">80 — every walking-distance pitch</option>
    </select>
  </div>
  <div class="ctrl-group">
    <label>filter</label>
    <select id="filter-status">
      <option value="open">Not sent yet</option>
      <option value="all">All (incl. already sent)</option>
    </select>
  </div>
  <div class="ctrl-group" style="justify-content:flex-end">
    <button class="btn" onclick="reload()">↻ Refresh route</button>
  </div>
  <div class="ctrl-group" style="justify-content:flex-end">
    <button class="btn btn-print" onclick="window.print()">🖨 Print door hangers</button>
  </div>
</div>

<div class="summary">
  <div class="stat"><div class="num" id="sum-stops">—</div><div class="lbl">Stops on route</div></div>
  <div class="stat"><div class="num" id="sum-tenants">—</div><div class="lbl">Doors to knock</div></div>
  <div class="stat"><div class="num" id="sum-buildings">—</div><div class="lbl">Multi-tenant buildings</div></div>
  <div class="stat"><div class="num" id="sum-eta">—</div><div class="lbl">Est. minutes (3/door)</div></div>
</div>

<main id="route">
  <div class="loading">Loading route…</div>
</main>

<script>
function escapeHtml(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])); }

const PROX_LABEL = {
  same_building: '⚡ same building',
  same_block: '★ same block',
  walk_2min: '2 min walk',
  walk_5min: '5 min walk',
  walk_10min: '10 min walk',
  walk_15min: '15 min walk'
};

const PITCH_HANGTAG = {
  'law-office': 'For the partner with an eye: Designer Wallcoverings (suite 201, just down the block) carries Phillip Jeffries, Cole & Son, Schumacher — the lines that lift a firm lobby. Free trade-account setup + samples to your office.',
  'medical-clinic': 'Patients spend 15-30 min staring at your waiting-room walls. Designer Wallcoverings (just up Ventura) carries calming, wipeable, commercial-grade wallcoverings at trade pricing. Walk-in welcome — suite 201.',
  'dental-office': 'Patient-experience hardware: Designer Wallcoverings carries the calming, wipeable wallcovering lines that turn a dental waiting room into a calm space. Trade pricing. Suite 201, two doors down.',
  'restaurant': 'Restaurant interiors: we did the walls for [discreet]. Designer Wallcoverings is right up the block — suite 201. Stop in for a sample drop and a coffee.',
  'salon-spa': 'Your interior is your brand. Designer Wallcoverings (suite 201) carries the lines that show in shelter mags — Cole & Son, Phillip Jeffries, custom murals. Free trade pricing.',
  'hospitality': 'Designer Wallcoverings (suite 201) supplies wallcovering for boutique hotels along the corridor. Free samples + trade pricing. Stop in.',
  'building-neighbor': 'Hi — we share 15442. Designer Wallcoverings is in suite 201. Walk-in welcome any time. Free coffee, free samples, trade pricing for any commercial project.',
  'retail-storefront': 'Retail story = retail sales. Designer Wallcoverings (suite 201) helps boutique retailers turn their interior into a destination. Sample drop = free.',
  'office-tenant': 'Designer Wallcoverings (suite 201, just up the block) carries the wallcovering lines that turn a generic office into something memorable. Trade pricing for any LLC.',
  'real-estate': 'Real estate offices use Designer Wallcoverings (suite 201) for both their own offices and their staging projects. Trade pricing on every line.',
  'fitness-studio': 'Designer Wallcoverings (suite 201) carries durable, photogenic wallcovering lines used by boutique studios across LA. Trade pricing.',
  'beauty-spa': 'Beauty interiors are content. Designer Wallcoverings (suite 201) carries the wallcovering lines that ship in shelter mags. Trade pricing.',
  'default': 'Designer Wallcoverings · 15442 Ventura Blvd #201 · trade samples free · 818-XXX-XXXX'
};

const PITCH_SCRIPT = {
  'law-office': 'Hi — Steve from Designer Wallcoverings, suite 201 here at 15442. We supply the wallcovering lines (Phillip Jeffries, Cole & Son, Schumacher) that lift a firm lobby. Could I drop samples for whoever runs the build-out?',
  'medical-clinic': 'Hi — Steve from Designer Wallcoverings, just up Ventura at suite 201. We carry wipeable, calming wallcovering lines used by medical offices in LA. Could I leave samples for the office manager?',
  'dental-office': 'Hi — Steve from Designer Wallcoverings, suite 201 just up the block. We carry the calming wallcovering lines for dental offices — wipeable, commercial-grade. Samples for the practice manager?',
  'restaurant': 'Hi — Steve from Designer Wallcoverings, suite 201. We did walls for [discreet]. Quick sample drop for the chef/owner?',
  'salon-spa': 'Hi — Steve from Designer Wallcoverings, suite 201. Boutique salons book partly on interior. Could I drop samples for the owner?',
  'hospitality': 'Hi — Steve from Designer Wallcoverings, suite 201. Hospitality samples for whoever does FF&E?',
  'building-neighbor': 'Hi — Steve, suite 201, just down the hall. Wanted to introduce myself. Designer Wallcoverings — drop in any time, free coffee.',
  'retail-storefront': 'Hi — Steve from Designer Wallcoverings, suite 201. We help retail interiors. Quick sample drop?',
  'office-tenant': 'Hi — Steve from Designer Wallcoverings, suite 201. Trade pricing for office build-outs. Samples?',
  'real-estate': 'Hi — Steve from Designer Wallcoverings, suite 201. Trade pricing for staging + your own offices.',
  'fitness-studio': 'Hi — Steve from Designer Wallcoverings, suite 201. Boutique studios use us for that photogenic interior look.',
  'beauty-spa': 'Hi — Steve from Designer Wallcoverings, suite 201. Spa interiors are everything.',
  'default': 'Hi — Steve from Designer Wallcoverings. We are at suite 201 right here on Ventura. Quick sample drop?'
};

let allRows = [];

async function load() {
  const sort = document.getElementById('sort').value;
  const direction = document.getElementById('direction').value;
  const limit = document.getElementById('limit').value;
  const filterStatus = document.getElementById('filter-status').value;
  // Hide direction selector when sorting by priority — it's irrelevant
  document.getElementById('direction-group').style.display = sort === 'priority' ? 'none' : '';
  const url = `/api/pitches/walk-route?sort=${sort}&direction=${direction}&limit=${limit}`;
  const res = await fetch(url);
  if (!res.ok) {
    document.getElementById('route').innerHTML = `<div class="empty"><div class="big">Could not load route.</div><div>${res.status} ${res.statusText}</div></div>`;
    return;
  }
  const data = await res.json();
  let rows = data.rows || [];
  if (filterStatus === 'open') {
    rows = rows.filter(r => !['sent','replied','won','lost','skip'].includes(r.status));
  }
  allRows = rows;
  render(rows);
}

function render(rows) {
  // Group by exact address
  const byAddr = {};
  for (const r of rows) {
    const key = `${r.address}|${r.city}|${r.zip}`;
    if (!byAddr[key]) byAddr[key] = { addr: r.address, city: r.city, zip: r.zip, lat: r.lat, lng: r.lng, gmaps: r.gmaps_url, tenants: [] };
    byAddr[key].tenants.push(r);
  }
  const stops = Object.values(byAddr);

  document.getElementById('sum-stops').textContent = stops.length;
  document.getElementById('sum-tenants').textContent = rows.length;
  document.getElementById('sum-buildings').textContent = stops.filter(s => s.tenants.length > 1).length;
  document.getElementById('sum-eta').textContent = (rows.length * 3) + 'm';

  if (stops.length === 0) {
    document.getElementById('route').innerHTML = `<div class="empty"><div class="big">No stops match.</div><div>Try widening the filter.</div></div>`;
    return;
  }

  document.getElementById('route').innerHTML = stops.map((s, i) => {
    const order = i + 1;
    const cleanAddr = s.addr.replace(/\s+/g, ' ').trim();
    const proxOnStop = s.tenants[0].dw_proximity;
    const headPitch = s.tenants[0].pitch_type || 'default';
    return `
    <div class="stop" data-stop="${order}">
      <div class="stop-head">
        <div class="stop-num">${String(order).padStart(2,'0')}</div>
        <div class="stop-addr">
          <h3 class="street">${escapeHtml(cleanAddr)}</h3>
          <div class="meta">
            ${escapeHtml(s.city)} ${escapeHtml(s.zip)} · ${PROX_LABEL[proxOnStop] || ''} · ${s.tenants.length} ${s.tenants.length === 1 ? 'door' : 'doors'}
            <a class="gmaps" href="${s.gmaps}" target="_blank">↗ Maps</a>
          </div>
        </div>
      </div>

      <div class="tenants">
        ${s.tenants.map(t => `
          <div class="tenant ${['sent','replied','won'].includes(t.status) ? 'sent' : ''}" data-pitch-id="${t.id}">
            <input type="checkbox" ${['sent','replied','won'].includes(t.status) ? 'checked' : ''}
              onchange="toggleSent(${t.id}, this.checked)" title="Mark walked">
            <div>
              <div class="tenant-name">${escapeHtml(t.name)}${extractSuite(t.address) ? `<span class="suite">${escapeHtml(extractSuite(t.address))}</span>` : ''}</div>
              <div class="tenant-pitch">${escapeHtml(t.pitch_type || '')}</div>
              <div class="tenant-prox">priority ${t.priority} · ${t.status}</div>
            </div>
            <div></div>
          </div>
        `).join('')}
      </div>

      <div class="script">
        <span class="label">If someone is at the front desk, say:</span>
        ${escapeHtml(PITCH_SCRIPT[headPitch] || PITCH_SCRIPT['default'])}
      </div>

      <div class="hangtag">
        <span class="label">If no one's there, leave this card / hangtag:</span>
        ${escapeHtml(PITCH_HANGTAG[headPitch] || PITCH_HANGTAG['default'])}
      </div>

      <div class="stop-actions">
        <button class="btn" onclick="markAllSent(${order})">✓ Mark all walked</button>
        <a class="btn" href="${s.gmaps}" target="_blank">Open in Maps</a>
      </div>
    </div>`;
  }).join('');
}

function extractSuite(addr) {
  const m = String(addr || '').match(/(SUITE|STE|UNIT|#)\s*[#]?\s*([A-Z0-9-]+)/i);
  return m ? `${m[1].toUpperCase()} ${m[2]}` : '';
}

async function toggleSent(pitchId, checked) {
  if (!checked) return; // we don't un-walk
  try {
    const res = await fetch(`/api/pitches/${pitchId}`, {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ status: 'sent', outreach_channel: 'walk-in' })
    });
    if (!res.ok) {
      const t = await res.text();
      alert('Failed to mark walked: ' + t);
      return;
    }
    // Update local state
    const row = allRows.find(r => String(r.id) === String(pitchId));
    if (row) row.status = 'sent';
  } catch (e) {
    alert('Network error: ' + e.message);
  }
}

async function markAllSent(stopOrder) {
  const stopEl = document.querySelector(`.stop[data-stop="${stopOrder}"]`);
  if (!stopEl) return;
  const checkboxes = stopEl.querySelectorAll('input[type="checkbox"]:not(:checked)');
  if (checkboxes.length === 0) return;
  if (!confirm(`Mark all ${checkboxes.length} doors at this stop as walked?`)) return;
  for (const cb of checkboxes) {
    cb.checked = true;
    const id = cb.closest('.tenant').dataset.pitchId;
    await toggleSent(parseInt(id, 10), true);
  }
  stopEl.classList.add('done');
}

function reload() { load(); }

document.getElementById('sort').addEventListener('change', load);
document.getElementById('direction').addEventListener('change', load);
document.getElementById('limit').addEventListener('change', load);
document.getElementById('filter-status').addEventListener('change', load);

(async () => {
  await load();
  const urlFocus = new URLSearchParams(location.search).get('focus');
  if (!urlFocus) return;
  const flash = (el) => {
    el.scrollIntoView({ behavior: 'smooth', block: 'center' });
    el.style.transition = 'box-shadow 0.4s';
    el.style.boxShadow = '0 0 0 2px var(--metal-glow)';
    setTimeout(() => { el.style.boxShadow = ''; }, 2500);
  };
  let el = document.querySelector(`.tenant[data-pitch-id="${urlFocus}"]`);
  if (el) return flash(el);
  document.getElementById('limit').value = '80';
  document.getElementById('filter-status').value = 'all';
  await load();
  el = document.querySelector(`.tenant[data-pitch-id="${urlFocus}"]`);
  if (el) flash(el);
})();
</script>
</body>
</html>