← back to Ventura Corridor

public/postcards.html

272 lines

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Postcards · 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@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); }

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

  .control-bar {
    display: flex; gap: 16px; flex-wrap: wrap; align-items: end; margin-bottom: 28px;
    padding: 18px 22px; background: var(--noir-rise); border: 1px solid var(--rule);
  }
  .control-bar .ctrl { display: flex; flex-direction: column; gap: 6px; }
  .control-bar label { font-size: 9px; letter-spacing: .26em; text-transform: uppercase; color: var(--ink-mute); }
  .control-bar input, .control-bar select {
    background: var(--noir); border: 1px solid var(--rule); color: var(--ink);
    padding: 8px 12px; font-family: var(--sans); font-size: 13px; min-width: 180px; border-radius: 0;
  }
  .control-bar .pick {
    background: var(--noir); border: 1px solid var(--rule); padding: 8px 12px;
    font-family: var(--mono); font-size: 11px; color: var(--metal-glow); min-width: 280px;
  }
  .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);
  }
  .btn:hover { background: rgba(184,153,104,0.06); }
  .btn-glow { border-color: var(--metal-glow); color: var(--metal-glow); }

  .preview-row {
    display: grid; grid-template-columns: 1fr 1fr; gap: 28px; margin-bottom: 32px;
  }
  @media (max-width: 1100px) { .preview-row { grid-template-columns: 1fr; } }

  .card-frame {
    background: var(--noir-rise); border: 1px solid var(--rule); padding: 14px;
  }
  .card-frame h3 {
    font-family: var(--serif); font-style: italic; font-weight: 400; font-size: 18px;
    color: var(--metal); margin: 0 0 10px;
  }
  .card-frame iframe {
    width: 100%; aspect-ratio: 6.25 / 4.25;
    border: 1px solid var(--rule); background: white;
    box-shadow: 0 6px 18px rgba(0,0,0,0.45);
  }

  .lob-instructions {
    margin-top: 36px; padding: 24px 26px;
    background: var(--noir-rise); border: 1px solid var(--rule);
  }
  .lob-instructions h2 {
    font-family: var(--serif); font-style: italic; font-weight: 400; font-size: 22px;
    color: var(--metal); margin: 0 0 14px;
  }
  .lob-instructions ol { padding-left: 20px; line-height: 1.8; font-size: 13px; }
  .lob-instructions code {
    font-family: var(--mono); font-size: 11px;
    background: var(--noir); border: 1px solid var(--rule); padding: 2px 8px;
    color: var(--metal-glow);
  }
  .lob-instructions a { color: var(--metal); }
  .lob-instructions .cost-strip {
    display: flex; gap: 24px; margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--rule); flex-wrap: wrap;
  }
  .lob-instructions .cost-strip .stat { display: flex; flex-direction: column; gap: 4px; }
  .lob-instructions .cost-strip .num { font-family: var(--serif); font-style: italic; font-size: 28px; color: var(--metal-glow); }
  .lob-instructions .cost-strip .lbl { font-size: 9px; letter-spacing: .26em; text-transform: uppercase; color: var(--ink-mute); }

  .pitch-info {
    margin-top: 16px; padding: 12px 16px; background: var(--noir-rise); border-left: 2px solid var(--metal);
    font-size: 12px; line-height: 1.6;
  }
  .pitch-info b { font-family: var(--serif); color: var(--metal-glow); font-weight: 400; font-size: 16px; }
  .pitch-info .meta { color: var(--ink-mute); font-size: 10px; letter-spacing: .12em; text-transform: uppercase; }
</style>
</head>
<body>

<header>
  <div>
    <h1>Postcard <em>preview</em> · Lob template</h1>
    <div class="sub">live merge with real pitch data · 4×6 finished · ready for upload</div>
  </div>
  <nav class="tabs">
    <a href="/">map</a>
    <a href="/today.html">today</a>
    <a href="/pitches.html">pitches</a>
    <a href="/walk-route.html">walk route</a>
    <a href="/responses.html">responses</a>
    <a href="/postcards.html" class="active">postcards</a>
    <a href="/buildings.html">buildings</a>
    <a href="/crawl-derby.html">🏇 derby</a>
    <span class="theme-toggle-host" style="display:inline-flex;align-items:center;margin-left:6px"></span>
  </nav>
</header>

<main>

<div class="control-bar">
  <div class="ctrl">
    <label>Pick a pitch to preview</label>
    <select id="pitch-select"><option>Loading…</option></select>
  </div>
  <div class="ctrl">
    <label>Pitch ID (manual)</label>
    <input type="number" id="pitch-id-input" placeholder="e.g. 1234">
  </div>
  <div class="ctrl">
    <label>&nbsp;</label>
    <button class="btn" onclick="loadFromInput()">Preview ↗</button>
  </div>
  <div class="ctrl">
    <label>&nbsp;</label>
    <button class="btn" onclick="randomPick()">🎲 Random</button>
  </div>
  <div class="ctrl">
    <label>&nbsp;</label>
    <a class="btn btn-glow" href="/api/pitches/postcard-batch.csv?min_proximity=walk_15min" download>⬇ Lob CSV</a>
  </div>
</div>

<div class="pitch-info" id="pitch-info" style="display:none"></div>

<div class="preview-row">
  <div class="card-frame">
    <h3>Front</h3>
    <iframe id="iframe-front" sandbox="allow-same-origin"></iframe>
  </div>
  <div class="card-frame">
    <h3>Back · address + indicia + message</h3>
    <iframe id="iframe-back" sandbox="allow-same-origin"></iframe>
  </div>
</div>

<div class="lob-instructions">
  <h2>How to upload to Lob</h2>
  <ol>
    <li>Sign in at <a href="https://dashboard.lob.com" target="_blank">dashboard.lob.com</a> → <strong>Templates</strong> → <strong>Create Template</strong>.</li>
    <li>Paste the contents of <code>public/postcard-template-front.html</code> into the front-template HTML editor. Repeat with <code>postcard-template-back.html</code> for the back.</li>
    <li>Save both. Note the front-template ID (looks like <code>tmpl_XXX</code>) and the back-template ID — Lob shows them in the template list.</li>
    <li>Go to <strong>Postcards → Bulk Send</strong> and upload <a href="/api/pitches/postcard-batch.csv?min_proximity=walk_15min">this CSV</a>.</li>
    <li>For "Front Template" pick the front you just saved. Same for back. Lob auto-merges <code>{{to.name}}</code>, <code>{{to.address_line1}}</code>, <code>{{merge.body}}</code> etc. from each row.</li>
    <li>Lob applies first-class postage automatically. Scheduled mail-date appears in your Lob dashboard. Tracking IDs are returned in the API response — you can paste those back into <code>pitches.notes</code> later.</li>
  </ol>
  <div class="cost-strip">
    <div class="stat"><div class="num">$0.82</div><div class="lbl">Per postcard (4×6)</div></div>
    <div class="stat"><div class="num">$<span id="cost-walking">—</span></div><div class="lbl">Total · ≤15 min walk</div></div>
    <div class="stat"><div class="num"><span id="cost-walking-count">—</span></div><div class="lbl">Postcards in CSV</div></div>
    <div class="stat"><div class="num">2-4 days</div><div class="lbl">Lob print + mail SLA</div></div>
  </div>
</div>

</main>

<script>
let allPitches = [];
let currentId = null;

async function loadPitchList() {
  try {
    const data = await fetch('/api/pitches?channel=postcard&limit=500').then(r => r.json());
    allPitches = (data.rows || []).filter(p => p.outreach_channel === 'postcard');
    // Sort: same_building, same_block, walking, then rest
    const proxOrder = { same_building:0, same_block:1, walk_2min:2, walk_5min:3, walk_10min:4, walk_15min:5 };
    allPitches.sort((a, b) => (proxOrder[a.dw_proximity] ?? 9) - (proxOrder[b.dw_proximity] ?? 9));
    const sel = document.getElementById('pitch-select');
    sel.innerHTML = allPitches.slice(0, 250).map(p =>
      `<option value="${p.id}">${(p.name || '').slice(0, 50)} · ${p.dw_proximity || ''} · pri ${p.priority}</option>`
    ).join('');
    sel.addEventListener('change', () => loadPreview(parseInt(sel.value, 10)));
    // Deep-link via ?id= param (from /pitches.html cross-action)
    const urlId = parseInt(new URLSearchParams(location.search).get('id') || '', 10);
    if (Number.isFinite(urlId)) {
      sel.value = String(urlId);
      loadPreview(urlId);
      // If the pitch wasn't in the dropdown (only top 250 sorted by proximity), still fetch by ID
      if (sel.value !== String(urlId)) {
        document.getElementById('pitch-id-input').value = String(urlId);
        loadPreview(urlId);
      }
    } else if (allPitches.length) {
      loadPreview(parseInt(sel.value, 10));
    }
  } catch (e) {
    document.getElementById('pitch-select').innerHTML = '<option>error loading: ' + e.message + '</option>';
  }
}

async function loadPreview(id) {
  if (!id) return;
  currentId = id;
  document.getElementById('iframe-front').src = `/api/pitches/${id}/postcard-preview/front.html`;
  document.getElementById('iframe-back').src  = `/api/pitches/${id}/postcard-preview/back.html`;
  // Pull pitch metadata for the info strip — fallback to fetch if not in current cohort
  let p = allPitches.find(x => String(x.id) === String(id));
  if (!p) {
    try {
      const d = await fetch('/api/pitches?id=' + id).then(r => r.json());
      p = (d.rows || [])[0];
    } catch {}
  }
  if (p) {
    const info = document.getElementById('pitch-info');
    info.style.display = 'block';
    info.innerHTML = `
      <b>${escapeHtml(p.name)}</b>
      <div class="meta">${escapeHtml(p.address || '')} · ${escapeHtml(p.dw_proximity || '')} · ${escapeHtml(p.pitch_type || '')} · pri ${p.priority} · status ${p.status} · channel ${escapeHtml(p.outreach_channel || '—')}</div>
    `;
  }
}

function loadFromInput() {
  const v = parseInt(document.getElementById('pitch-id-input').value, 10);
  if (Number.isFinite(v)) loadPreview(v);
}

function randomPick() {
  if (!allPitches.length) return;
  const p = allPitches[Math.floor(Math.random() * Math.min(allPitches.length, 250))];
  document.getElementById('pitch-select').value = p.id;
  loadPreview(p.id);
}

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

async function loadCostStrip() {
  try {
    const csv = await fetch('/api/pitches/postcard-batch.csv?min_proximity=walk_15min').then(r => r.text());
    const lines = csv.trim().split('\n');
    const count = Math.max(lines.length - 1, 0);
    document.getElementById('cost-walking-count').textContent = count.toLocaleString('en-US');
    document.getElementById('cost-walking').textContent = (count * 0.82).toFixed(0).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
  } catch (e) {
    document.getElementById('cost-walking').textContent = '—';
  }
}

loadPitchList();
loadCostStrip();
</script>
</body>
</html>