← back to Ventura Corridor
public/pitches.html
557 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Ventura Boulevard · DW pitch pipeline</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;
--green: #6a9b73; --red: #b66565; --amber: #c4a05c;
}
@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;1,300;1,400&family=Inter:wght@200;300;400;500;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: 18px 32px 14px; 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: 24px; margin: 0; }
header h1 em { font-style: italic; color: var(--metal); }
.sub { font-size: 10px; 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: 14px 32px; border-bottom: 1px solid var(--rule); display: flex; gap: 16px; flex-wrap: wrap; align-items: center; font-size: 11px; }
.controls label { display: flex; align-items: center; gap: 6px; font-size: 9px; letter-spacing: .22em; text-transform: uppercase; color: var(--ink-mute); }
.controls select, .controls input { background: var(--noir-rise); color: var(--ink); border: 1px solid var(--rule); padding: 5px 8px; font-family: var(--sans); font-size: 11px; }
.controls input[type=search] { min-width: 220px; }
.controls .ct { margin-left: auto; font-size: 10px; letter-spacing: .22em; text-transform: uppercase; color: var(--ink-mute); }
.controls .ct b { color: var(--metal); font-family: var(--serif); font-style: italic; font-size: 16px; font-weight: 400; margin-right: 6px; font-variant-numeric: tabular-nums; }
/* Quick stats by status */
.stats { padding: 12px 32px; border-bottom: 1px solid var(--rule); display: flex; gap: 24px; flex-wrap: wrap; font-size: 10px; letter-spacing: .15em; color: var(--ink-mute); text-transform: uppercase; }
.stats span b { color: var(--metal); font-family: var(--serif); font-style: italic; font-size: 14px; margin-right: 6px; font-variant-numeric: tabular-nums; }
table { width: 100%; border-collapse: collapse; font-size: 12px; }
th, td { padding: 8px 10px; text-align: left; border-bottom: 1px solid var(--rule); vertical-align: top; }
th { font-size: 9px; letter-spacing: .26em; text-transform: uppercase; color: var(--ink-mute); font-weight: 400; background: var(--noir); position: sticky; top: 60px; z-index: 1; }
tr:hover { background: var(--noir-rise); cursor: pointer; }
tr.expanded { background: var(--noir-rise); }
tr.sel { background: rgba(212,182,131,0.06); }
tr.sel.expanded { background: rgba(212,182,131,0.1); }
.nm { font-weight: 400; color: var(--ink); }
.addr { font-family: var(--mono); font-size: 9px; color: var(--ink-mute); letter-spacing: .08em; }
.ptag { font-size: 8px; letter-spacing: .22em; text-transform: uppercase; padding: 2px 5px; border: 1px solid var(--rule); color: var(--ink-mute); display: inline-block; }
.ptag.t-trade-partner, .ptag.t-trade-client { color: var(--metal-glow); border-color: var(--metal-glow); }
.ptag.t-cross-sell, .ptag.t-showroom-partner { color: var(--metal); border-color: var(--metal); }
.ptag.t-law-office { color: #c4b07b; border-color: #c4b07b; }
.ptag.t-medical-clinic { color: #7ab8a3; border-color: #7ab8a3; }
.ptag.t-beauty-salon { color: #c47ba6; border-color: #c47ba6; }
.ptag.t-real-estate { color: #7b9bc4; border-color: #7b9bc4; }
.ptag.t-hospitality { color: #c47b7b; border-color: #c47b7b; }
.ptag.t-fitness { color: #a3c47b; border-color: #a3c47b; }
.stag { font-size: 8px; letter-spacing: .22em; text-transform: uppercase; padding: 2px 5px; display: inline-block; }
.stag.s-draft { color: var(--ink-mute); }
.stag.s-researched { color: var(--metal); }
.stag.s-scrubbed { color: var(--amber); }
.stag.s-approved { color: var(--green); }
.stag.s-sent { color: var(--metal-glow); }
.stag.s-replied { color: var(--green); font-weight: 600; }
.stag.s-won { color: var(--green); font-weight: 600; }
.stag.s-lost { color: var(--red); }
.stag.s-skip { color: var(--red); opacity: 0.6; }
/* Expanded detail panel */
.detail { padding: 18px 22px; background: var(--noir); border-left: 2px solid var(--metal); }
.detail h4 { font-family: var(--serif); font-style: italic; font-weight: 400; font-size: 16px; color: var(--metal); margin: 0 0 10px; }
.detail .why { font-style: italic; color: var(--ink-mute); margin-bottom: 14px; }
.detail pre { background: rgba(0,0,0,0.3); padding: 14px 16px; border: 1px solid var(--rule); white-space: pre-wrap; font-family: var(--mono); font-size: 11px; line-height: 1.55; color: var(--ink); }
.detail textarea { width: 100%; background: var(--noir-rise); border: 1px solid var(--rule); color: var(--ink); padding: 8px 10px; font-family: var(--mono); font-size: 11px; min-height: 60px; }
.detail .links { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; }
.detail .links a { font-size: 9px; letter-spacing: .22em; text-transform: uppercase; padding: 4px 8px; border: 1px solid var(--metal); color: var(--metal); text-decoration: none; }
.detail .links a:hover { background: rgba(184,153,104,0.08); }
.detail .actions { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 14px; }
.detail .actions button {
background: transparent; border: 1px solid var(--rule); color: var(--ink-mute);
font-size: 9px; letter-spacing: .22em; text-transform: uppercase;
padding: 6px 12px; cursor: pointer; font-family: var(--sans);
}
.detail .actions button.primary { color: var(--metal); border-color: var(--metal); }
.detail .actions button.danger { color: var(--red); border-color: var(--red); }
.detail .actions button:hover { background: rgba(184,153,104,0.06); }
.empty { padding: 80px 32px; text-align: center; font-style: italic; color: var(--ink-mute); }
</style>
</head>
<body>
<header>
<div>
<h1>DW <em>pitch pipeline</em> · 5,353 corridor targets</h1>
<div class="sub">admin only · status workflow · per-vertical pre-drafted bodies</div>
</div>
<nav class="tabs">
<a href="/">map</a>
<a href="/atlas.html">atlas</a>
<a href="/dw-radar.html">dw-radar</a>
<a href="/signals.html">signals</a>
<a href="/pitches.html" class="active">pitches</a>
<a href="/today.html">today</a>
<a href="/walk-route.html">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="stats" id="stats">—</div>
<div class="controls">
<label>vertical
<select id="f-type">
<option value="">All</option>
<option>trade-partner</option>
<option>trade-client</option>
<option>showroom-partner</option>
<option>cross-sell</option>
<option>trade-supplier</option>
<option>law-office</option>
<option>medical-clinic</option>
<option>beauty-salon</option>
<option>real-estate</option>
<option>hospitality</option>
<option>fitness</option>
</select>
</label>
<label>status
<select id="f-status">
<option value="">All</option>
<option>draft</option>
<option>researched</option>
<option>scrubbed</option>
<option>approved</option>
<option>sent</option>
<option>replied</option>
<option>won</option>
<option>lost</option>
<option>skip</option>
</select>
</label>
<label>priority
<select id="f-priority">
<option value="">All</option>
<option value="1">1 — DW peer (paint/wallpaper)</option>
<option value="2">2 — Interior design</option>
<option value="3">3 — Specialized design</option>
<option value="4">4 — Home furnishings</option>
<option value="5">5 — Furniture stores</option>
<option value="10">10 — Law office</option>
<option value="11">11 — Medical/clinic</option>
<option value="12">12 — Beauty/salon</option>
<option value="13">13 — Real estate</option>
<option value="14">14 — Hospitality</option>
<option value="15">15 — Fitness</option>
</select>
</label>
<label>channel
<select id="f-channel">
<option value="">All</option>
<option value="walk-in">🚶 walk-in</option>
<option value="postcard">✉️ postcard</option>
<option value="email">📧 email</option>
<option value="linkedin">💼 linkedin</option>
<option value="phone">📞 phone</option>
</select>
</label>
<label>DW proximity
<select id="f-prox">
<option value="">All</option>
<option value="same_building">⚡ Same building (15442)</option>
<option value="same_block">★ Same block (15400-15500)</option>
<option value="walk_5min">5-min walk (≤0.25 mi)</option>
<option value="walk_10min">10-min walk (≤0.5 mi)</option>
<option value="walk_15min">15-min walk (≤1 mi)</option>
<option value="walking">All walking distance (≤1 mi)</option>
<option value="any">Any proximity tag</option>
</select>
</label>
<input type="search" id="q" placeholder="search name, city, address…" autocomplete="off">
<a href="/api/pitches/hot-list.csv" download style="font-size:9px;letter-spacing:.26em;text-transform:uppercase;padding:5px 10px;border:1px solid var(--metal);color:var(--metal);text-decoration:none">Hot list CSV ↓</a>
<a href="/api/pitches/postcard-batch.csv?min_proximity=walk_5min" download style="font-size:9px;letter-spacing:.26em;text-transform:uppercase;padding:5px 10px;border:1px solid var(--metal);color:var(--metal);text-decoration:none" title="Lob-format CSV for postcards within 0.25 mi of DW">Postcard 5min ↓</a>
<a href="/api/pitches/postcard-batch.csv?min_proximity=walk_10min" download style="font-size:9px;letter-spacing:.26em;text-transform:uppercase;padding:5px 10px;border:1px solid var(--metal);color:var(--metal);text-decoration:none" title="Lob-format CSV for postcards within 0.5 mi of DW">Postcard 10min ↓</a>
<span class="ct"><b id="ct">—</b> targets</span>
</div>
<div id="mass-toolbar" style="padding:14px 24px;border-bottom:1px solid var(--rule);background:rgba(184,153,104,0.04);display:flex;gap:12px;flex-wrap:wrap;align-items:center;font-size:11px">
<span style="font-family:var(--serif);font-style:italic;font-size:16px;color:var(--metal-glow)">Mass actions</span>
<span style="color:var(--ink-mute);letter-spacing:.18em;text-transform:uppercase;font-size:9px" id="mass-mode-label">applies to all <b id="mass-count" style="color:var(--metal)">—</b> visible</span>
<button onclick="selectAllVisible()" style="background:transparent;border:1px solid var(--rule);color:var(--ink-mute);padding:4px 9px;font-size:9px;letter-spacing:.18em;text-transform:uppercase;cursor:pointer;font-family:var(--sans)">Select all visible</button>
<button onclick="clearSelection()" style="background:transparent;border:1px solid var(--rule);color:var(--ink-mute);padding:4px 9px;font-size:9px;letter-spacing:.18em;text-transform:uppercase;cursor:pointer;font-family:var(--sans)">Clear</button>
<span style="flex:1"></span>
<select id="mass-field" style="background:var(--noir);border:1px solid var(--rule);color:var(--ink);padding:6px 10px;font-size:11px">
<option value="status">Set status</option>
<option value="outreach_channel">Set channel</option>
<option value="priority">Set priority</option>
<option value="pitch_type">Set pitch_type</option>
<option value="next_followup_at">⏰ Schedule follow-up</option>
</select>
<select id="mass-value" style="background:var(--noir);border:1px solid var(--rule);color:var(--ink);padding:6px 10px;font-size:11px"></select>
<input id="mass-date" type="date" style="background:var(--noir);border:1px solid var(--rule);color:var(--ink);padding:6px 10px;font-size:11px;display:none">
<span id="mass-date-presets" style="display:none;gap:4px">
<button onclick="setFollowupDate(3)" style="background:transparent;border:1px solid var(--rule);color:var(--ink-mute);padding:4px 8px;font-size:9px;letter-spacing:.15em;text-transform:uppercase;cursor:pointer;font-family:var(--sans)">+3d</button>
<button onclick="setFollowupDate(7)" style="background:transparent;border:1px solid var(--rule);color:var(--ink-mute);padding:4px 8px;font-size:9px;letter-spacing:.15em;text-transform:uppercase;cursor:pointer;font-family:var(--sans)">+1w</button>
<button onclick="setFollowupDate(14)" style="background:transparent;border:1px solid var(--rule);color:var(--ink-mute);padding:4px 8px;font-size:9px;letter-spacing:.15em;text-transform:uppercase;cursor:pointer;font-family:var(--sans)">+2w</button>
<button onclick="setFollowupDate(30)" style="background:transparent;border:1px solid var(--rule);color:var(--ink-mute);padding:4px 8px;font-size:9px;letter-spacing:.15em;text-transform:uppercase;cursor:pointer;font-family:var(--sans)">+1mo</button>
</span>
<button onclick="bulkApply(true)" style="background:transparent;border:1px solid var(--metal);color:var(--metal);padding:6px 12px;font-size:9px;letter-spacing:.18em;text-transform:uppercase;cursor:pointer">Preview</button>
<button onclick="bulkApply(false)" style="background:transparent;border:1px solid var(--metal-glow);color:var(--metal-glow);padding:6px 12px;font-size:9px;letter-spacing:.18em;text-transform:uppercase;cursor:pointer">⚡ Apply</button>
</div>
<table id="grid">
<thead>
<tr>
<th><input type="checkbox" id="cb-all" onchange="toggleAllVisible(this.checked)" style="accent-color:var(--metal);cursor:pointer"></th>
<th>★</th><th>Status</th><th>Vertical</th><th>Name</th><th>Address</th><th>City</th><th>Ready</th>
</tr>
</thead>
<tbody id="rows"></tbody>
</table>
<div class="empty" id="empty" style="display:none">No matches.</div>
<script>
let DATA = [];
const SELECTED = new Set(); // pitch ids selected for surgical bulk action
let EXPANDED = null;
function escapeHtml(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); }
function statusActions(p) {
const id = p.id;
const s = p.status;
// Allowed transitions
const next = {
draft: [['researched','primary','✓ researched'], ['skip','danger','skip']],
researched: [['scrubbed','primary','✓ DNC scrubbed'], ['draft','','← back to draft'], ['skip','danger','skip']],
scrubbed: [['approved','primary','✓ approved by Steve'], ['researched','','← back']],
approved: [['sent','primary','✓ sent via George'], ['scrubbed','','← back']],
sent: [['replied','primary','✓ got reply'], ['won','primary','✓ closed won'], ['lost','danger','closed lost']],
replied: [['won','primary','✓ closed won'], ['lost','danger','closed lost']],
won: [['replied','','← back']],
lost: [['replied','','← back']],
skip: [['draft','','← reactivate']],
};
return (next[s] || []).map(([nextStatus, cls, label]) =>
`<button class="${cls}" data-id="${id}" data-status="${nextStatus}">${label}</button>`
).join('');
}
function render() {
const ftype = document.getElementById('f-type').value;
const fstatus = document.getElementById('f-status').value;
const fpri = document.getElementById('f-priority').value;
const q = (document.getElementById('q').value || '').trim().toLowerCase();
let rows = DATA;
if (ftype) rows = rows.filter(r => r.pitch_type === ftype);
if (fstatus) rows = rows.filter(r => r.status === fstatus);
if (fpri) rows = rows.filter(r => String(r.priority) === fpri);
const fprox = document.getElementById('f-prox').value;
const WALK = ['same_building','same_block','walk_2min','walk_5min','walk_10min','walk_15min'];
if (fprox === 'same_building') rows = rows.filter(r => r.dw_proximity === 'same_building');
else if (fprox === 'same_block') rows = rows.filter(r => r.dw_proximity === 'same_block');
else if (fprox === 'walk_5min') rows = rows.filter(r => ['same_building','same_block','walk_2min','walk_5min'].includes(r.dw_proximity));
else if (fprox === 'walk_10min') rows = rows.filter(r => ['same_building','same_block','walk_2min','walk_5min','walk_10min'].includes(r.dw_proximity));
else if (fprox === 'walk_15min' || fprox === 'walking') rows = rows.filter(r => WALK.includes(r.dw_proximity));
else if (fprox === 'any') rows = rows.filter(r => r.dw_proximity);
const fchannel = document.getElementById('f-channel').value;
if (fchannel) rows = rows.filter(r => r.outreach_channel === fchannel);
if (q) rows = rows.filter(r => ((r.name || '') + ' ' + (r.city || '') + ' ' + (r.address || '') + ' ' + (r.naics || '')).toLowerCase().includes(q));
document.getElementById('ct').textContent = rows.length.toLocaleString();
const body = document.getElementById('rows');
if (!rows.length) { body.innerHTML = ''; document.getElementById('empty').style.display = 'block'; return; }
document.getElementById('empty').style.display = 'none';
body.innerHTML = rows.slice(0, 400).map(r => {
const isExp = EXPANDED === r.id;
const proxMap = {
same_building: ['⚡', 'var(--metal-glow)'], same_block: ['★', 'var(--metal)'],
walk_2min: ['◉', 'var(--metal)'], walk_5min: ['◉', 'var(--metal)'],
walk_10min: ['◎', 'var(--ink-mute)'], walk_15min: ['·', 'var(--ink-mute)']
};
const proxIcon = (proxMap[r.dw_proximity] || ['',''])[0];
const proxColor = (proxMap[r.dw_proximity] || ['',''])[1];
// Research-completeness ladder: contact_name → email → phone → observation → body
const dots = [
r.contact_name ? '●' : '○',
r.email ? '●' : '○',
r.phone ? '●' : '○',
r.observation ? '●' : '○',
r.body ? '●' : '○',
].join('');
const filled = [r.contact_name, r.email, r.phone, r.observation, r.body].filter(Boolean).length;
const dotColor = filled === 5 ? 'var(--metal-glow)' : filled >= 3 ? 'var(--metal)' : 'var(--ink-mute)';
const checked = SELECTED.has(r.id) ? 'checked' : '';
const main = `
<tr class="${isExp ? 'expanded' : ''} ${SELECTED.has(r.id) ? 'sel' : ''}" data-id="${r.id}">
<td style="text-align:center" data-cb="1"><input type="checkbox" ${checked} data-cb-id="${r.id}" onclick="event.stopPropagation()" style="accent-color:var(--metal);cursor:pointer"></td>
<td style="color:${proxColor};font-size:14px;text-align:center" title="${r.dw_proximity || ''}">${proxIcon}</td>
<td><span class="stag s-${r.status}">${r.status}</span></td>
<td><span class="ptag t-${r.pitch_type}">${r.pitch_type}</span></td>
<td class="nm">${escapeHtml(r.name)}${(() => {
const fresh = Number(r.news_fresh_7d || 0);
const total = Number(r.news_count || 0);
if (fresh >= 3) return ` <a href="/business/${r.business_id}/news" style="display:inline-block;padding:1px 5px;font-size:8px;letter-spacing:.16em;text-transform:uppercase;background:var(--metal-glow);color:#0a0a0c;text-decoration:none;border-radius:2px;vertical-align:middle" title="${fresh} articles in last 7 days — Hot publisher">🔥${fresh}</a>`;
if (fresh >= 1) return ` <a href="/business/${r.business_id}/news" style="display:inline-block;padding:1px 5px;font-size:8px;letter-spacing:.16em;text-transform:uppercase;color:var(--metal);border:1px solid var(--metal);text-decoration:none;vertical-align:middle" title="${fresh} articles in last 7 days">📰${fresh}</a>`;
if (total >= 1) return ` <a href="/business/${r.business_id}/news" style="display:inline-block;padding:1px 5px;font-size:8px;letter-spacing:.16em;text-transform:uppercase;color:var(--ink-mute);border:1px solid var(--rule);text-decoration:none;vertical-align:middle" title="${total} articles total">📰${total}</a>`;
return '';
})()}</td>
<td class="addr">${escapeHtml(r.address || '')}</td>
<td class="addr">${escapeHtml(r.city || '')}</td>
<td style="color:${dotColor};font-family:var(--mono);font-size:13px;letter-spacing:0.1em" title="contact·email·phone·observation·body — ${filled}/5">${dots}</td>
</tr>`;
const detail = isExp ? `
<tr><td colspan="8">
<div class="detail">
<div class="why">${escapeHtml(r.why_dw_fits || '')}</div>
<div class="links">
${Object.entries(r.research_links || {}).map(([k,v]) => `<a href="${v}" target="_blank" rel="noopener">${k} ↗</a>`).join('')}
${r.pitch_md_path ? `<a href="/${r.pitch_md_path}" target="_blank">📄 markdown ↗</a>` : ''}
</div>
<h4>Contact info <span style="font-family:var(--mono);font-size:9px;letter-spacing:.15em;color:var(--ink-mute);text-transform:uppercase;margin-left:8px">filled during research · saves on blur</span></h4>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:8px;margin-bottom:14px">
<input data-field="contact_name" data-id="${r.id}" placeholder="Contact name" value="${escapeHtml(r.contact_name||'')}" style="background:var(--noir-rise);border:1px solid var(--rule);color:var(--ink);padding:6px 8px;font-family:var(--sans);font-size:11px"/>
<input data-field="email" data-id="${r.id}" placeholder="email@example.com" value="${escapeHtml(r.email||'')}" style="background:var(--noir-rise);border:1px solid var(--rule);color:var(--ink);padding:6px 8px;font-family:var(--mono);font-size:11px"/>
<input data-field="phone" data-id="${r.id}" placeholder="(818) 555-0123" value="${escapeHtml(r.phone||'')}" style="background:var(--noir-rise);border:1px solid var(--rule);color:var(--ink);padding:6px 8px;font-family:var(--mono);font-size:11px"/>
<input data-field="website" data-id="${r.id}" placeholder="https://…" value="${escapeHtml(r.website||'')}" style="background:var(--noir-rise);border:1px solid var(--rule);color:var(--ink);padding:6px 8px;font-family:var(--mono);font-size:11px"/>
<input data-field="instagram" data-id="${r.id}" placeholder="@handle or URL" value="${escapeHtml(r.instagram||'')}" style="background:var(--noir-rise);border:1px solid var(--rule);color:var(--ink);padding:6px 8px;font-family:var(--mono);font-size:11px"/>
<input data-field="linkedin" data-id="${r.id}" placeholder="LinkedIn URL" value="${escapeHtml(r.linkedin||'')}" style="background:var(--noir-rise);border:1px solid var(--rule);color:var(--ink);padding:6px 8px;font-family:var(--mono);font-size:11px"/>
</div>
<h4>Subject</h4>
<pre>${escapeHtml(r.subject || '(no subject yet)')}</pre>
<h4>Body</h4>
<pre>${escapeHtml(r.body || '(no body yet)')}</pre>
<h4>Observation (what you saw)</h4>
<textarea data-field="observation" data-id="${r.id}" placeholder="What did Google Maps / Yelp / IG show? Style, budget tier, current walls…">${escapeHtml(r.observation || '')}</textarea>
<h4>Notes</h4>
<textarea data-field="notes" data-id="${r.id}" placeholder="Internal notes, follow-ups, context…">${escapeHtml(r.notes || '')}</textarea>
<div class="actions">${statusActions(r)}</div>
<div class="cross-actions" style="margin-top:14px;padding-top:12px;border-top:1px dashed var(--rule);display:flex;gap:8px;flex-wrap:wrap;font-size:10px;letter-spacing:.18em;text-transform:uppercase">
<a href="/postcards.html?id=${r.id}" target="_blank" style="color:var(--metal);text-decoration:none;padding:5px 10px;border:1px solid var(--rule)">📮 Postcard preview</a>
<a href="/buildings.html?bldg=${encodeURIComponent((r.address||'').replace(/\s*(SUITE|STE|UNIT|#).*$/i,'').trim())}" target="_blank" style="color:var(--metal);text-decoration:none;padding:5px 10px;border:1px solid var(--rule)">🏢 Building roster</a>
<a href="/responses.html?id=${r.id}" target="_blank" style="color:var(--metal);text-decoration:none;padding:5px 10px;border:1px solid var(--rule)">📝 Log response</a>
<a href="/walk-route.html?focus=${r.id}" target="_blank" style="color:var(--metal);text-decoration:none;padding:5px 10px;border:1px solid var(--rule)">🚶 Walk route</a>
<a href="/pitch/${r.id}" target="_blank" style="color:var(--metal-glow);text-decoration:none;padding:5px 10px;border:1px solid var(--metal-glow)">📄 1-pager</a>
<a href="/api/pitches/${r.id}/sheet.md" target="_blank" style="color:var(--ink-mute);text-decoration:none;padding:5px 10px;border:1px solid var(--rule);font-family:var(--mono);font-size:9px">⬇ .md</a>
<a href="/api/pitches/${r.id}/timeline" target="_blank" style="color:var(--ink-mute);text-decoration:none;padding:5px 10px;border:1px solid var(--rule);font-family:var(--mono);font-size:9px">📜 Timeline JSON</a>
</div>
<div style="font-family:var(--mono);font-size:9px;color:var(--ink-mute);margin-top:14px">
id ${r.id} · biz #${r.business_id} · created ${new Date(r.created_at).toLocaleDateString()} · last update ${new Date(r.updated_at).toLocaleString()}
${r.sent_at ? `· sent ${new Date(r.sent_at).toLocaleString()}` : ''}
${r.replied_at ? `· replied ${new Date(r.replied_at).toLocaleString()}` : ''}
</div>
</div>
</td></tr>` : '';
return main + detail;
}).join('');
}
function updateStats() {
const counts = {};
for (const r of DATA) counts[r.status] = (counts[r.status] || 0) + 1;
const order = ['draft','researched','scrubbed','approved','sent','replied','won','lost','skip'];
document.getElementById('stats').innerHTML = order
.filter(s => counts[s])
.map(s => `<span><b>${counts[s]}</b>${s}</span>`)
.join('');
}
async function patchPitch(id, fields) {
await fetch('/api/pitches/' + id, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(fields),
});
// Refresh local row
const i = DATA.findIndex(r => r.id == id);
if (i >= 0) Object.assign(DATA[i], fields);
updateStats();
render();
}
document.getElementById('rows').addEventListener('click', (e) => {
const t = e.target;
if (t.tagName === 'BUTTON' && t.dataset.status) {
e.stopPropagation();
patchPitch(t.dataset.id, { status: t.dataset.status });
return;
}
// Checkbox handles its own click (stopPropagation) but defend anyway
if (t.tagName === 'INPUT' && t.type === 'checkbox' && t.dataset.cbId) return;
if (t.tagName === 'TEXTAREA' || t.tagName === 'A') return;
const tr = t.closest('tr[data-id]');
if (!tr) return;
const id = parseInt(tr.dataset.id, 10);
EXPANDED = (EXPANDED === id ? null : id);
render();
});
document.getElementById('rows').addEventListener('change', (e) => {
// Selection checkbox
if (e.target.tagName === 'INPUT' && e.target.type === 'checkbox' && e.target.dataset.cbId) {
const id = parseInt(e.target.dataset.cbId, 10);
if (e.target.checked) SELECTED.add(id); else SELECTED.delete(id);
e.target.closest('tr').classList.toggle('sel', e.target.checked);
updateMassCount();
return;
}
// Field edit
if ((e.target.tagName === 'TEXTAREA' || e.target.tagName === 'INPUT') && e.target.dataset.id) {
const f = e.target.dataset.field;
const v = e.target.value;
patchPitch(e.target.dataset.id, { [f]: v });
}
});
['f-type','f-status','f-priority','f-prox','f-channel','q'].forEach(id => {
const el = document.getElementById(id);
el.addEventListener('input', () => { clearTimeout(window._t); window._t = setTimeout(render, 100); });
el.addEventListener('change', render);
});
fetch('/api/pitches').then(r => r.json()).then(d => {
DATA = d.rows || [];
updateStats();
render();
refreshMassValueOptions();
});
// ─── Mass-action toolbar (iter 58) ───────────────────────────────────
const MASS_OPTIONS = {
status: ['draft','researched','scrubbed','approved','sent','replied','won','lost','skip'],
outreach_channel: ['walk-in','linkedin-dm','inmail','email','postcard','phone','text'],
priority: ['1','2','3','4','5','6','7','8','9','10','11','12'],
pitch_type: ['law-office','medical-clinic','dental-office','restaurant','salon-spa','retail-storefront','office-tenant','real-estate','fitness-studio','beauty-spa','hospitality','building-neighbor']
};
function refreshMassValueOptions() {
const f = document.getElementById('mass-field').value;
const v = document.getElementById('mass-value');
const d = document.getElementById('mass-date');
const p = document.getElementById('mass-date-presets');
if (f === 'next_followup_at') {
v.style.display = 'none';
d.style.display = 'inline-block';
p.style.display = 'inline-flex';
if (!d.value) setFollowupDate(7);
} else {
v.style.display = 'inline-block';
d.style.display = 'none';
p.style.display = 'none';
v.innerHTML = (MASS_OPTIONS[f] || []).map(o => `<option value="${o}">${o}</option>`).join('');
}
}
function setFollowupDate(daysFromNow) {
const dt = new Date(Date.now() + daysFromNow * 86400000);
document.getElementById('mass-date').value = dt.toISOString().slice(0, 10);
}
document.getElementById('mass-field').addEventListener('change', refreshMassValueOptions);
// Keep "applies to N visible" in sync with current filter
const _origRender = render;
window.render = function() { _origRender(); updateMassCount(); };
function updateMassCount() {
const visibleIds = Array.from(document.querySelectorAll('#rows tr[data-id]')).map(tr => parseInt(tr.dataset.id, 10));
const selN = SELECTED.size;
const lbl = document.getElementById('mass-mode-label');
if (selN > 0) {
lbl.innerHTML = `applies to <b style="color:var(--metal-glow)">${selN}</b> selected · <span style="color:var(--ink-mute);text-transform:none;letter-spacing:0">click checkbox to deselect</span>`;
} else {
lbl.innerHTML = `applies to all <b id="mass-count" style="color:var(--metal)">${visibleIds.length.toLocaleString()}</b> visible · <span style="color:var(--ink-mute);text-transform:none;letter-spacing:0">tick rows for surgical mode</span>`;
}
// Update header "select all" checkbox state
const cbAll = document.getElementById('cb-all');
if (cbAll) {
const allChecked = visibleIds.length > 0 && visibleIds.every(id => SELECTED.has(id));
const someChecked = visibleIds.some(id => SELECTED.has(id));
cbAll.checked = allChecked;
cbAll.indeterminate = !allChecked && someChecked;
}
}
setInterval(updateMassCount, 500);
function selectAllVisible() {
const visibleIds = Array.from(document.querySelectorAll('#rows tr[data-id]')).map(tr => parseInt(tr.dataset.id, 10));
visibleIds.forEach(id => SELECTED.add(id));
render();
updateMassCount();
}
function clearSelection() {
SELECTED.clear();
render();
updateMassCount();
}
function toggleAllVisible(check) {
if (check) selectAllVisible();
else clearSelection();
}
async function bulkApply(dryRun) {
// Use SELECTED if non-empty, else fall back to all visible
let ids = Array.from(SELECTED);
let scope = 'selected';
if (ids.length === 0) {
ids = Array.from(document.querySelectorAll('#rows tr[data-id]')).map(tr => parseInt(tr.dataset.id, 10));
scope = 'visible';
}
if (!ids.length) { alert('No rows selected and none visible.'); return; }
const field = document.getElementById('mass-field').value;
let value;
if (field === 'next_followup_at') {
value = document.getElementById('mass-date').value;
if (!value) { alert('Pick a follow-up date first (or use a preset).'); return; }
} else {
value = document.getElementById('mass-value').value;
if (field === 'priority') value = parseInt(value, 10);
}
if (dryRun) {
alert(`Preview: would set ${field}=${value} on ${ids.length} ${scope} pitch${ids.length === 1 ? '' : 'es'}. No changes made.`);
return;
}
if (!confirm(`Set ${field}=${value} on ${ids.length} ${scope} pitch${ids.length === 1 ? '' : 'es'}?`)) return;
try {
const r = await fetch('/api/pitches/bulk', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids, fields: { [field]: value } })
});
if (!r.ok) throw new Error(await r.text());
const d = await r.json();
alert(`Updated ${d.updated} of ${d.ids} pitches.`);
SELECTED.clear();
const fresh = await fetch('/api/pitches').then(r => r.json());
DATA = fresh.rows || [];
updateStats();
render();
updateMassCount();
} catch (e) {
alert('Bulk update failed: ' + e.message);
}
}
</script>
</body>
</html>