← 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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>