[object Object]

← back to Ventura Corridor

iter 52: postcard preview + Lob template generator — public/postcard-template-front.html (4×6 noir front w/ DW brand, hero serif, signature, mergefield footer) + public/postcard-template-back.html (left half message zone, right half indicia + Lob-spec address block at 1.125" from bottom + 0.5" from right) ready to upload to Lob; /api/pitches/:id/postcard-preview/(front|back).html injects real PG data; /postcards.html admin viewer with side-by-side iframe preview + pitch dropdown + manual ID input + random-pitch + Lob CSV download + per-postcard cost calc + 6-step Lob upload guide; /api/pitches accepts channel/id/limit query params; postcards link wired into all 6 admin nav bars; ADMIN_PATHS regex covers /postcards

dbf656bfa1d15b6fb50f287f724b89d78171f613 · 2026-05-06 12:22:14 -0700 · SteveStudio2

Files touched

Diff

commit dbf656bfa1d15b6fb50f287f724b89d78171f613
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date:   Wed May 6 12:22:14 2026 -0700

    iter 52: postcard preview + Lob template generator — public/postcard-template-front.html (4×6 noir front w/ DW brand, hero serif, signature, mergefield footer) + public/postcard-template-back.html (left half message zone, right half indicia + Lob-spec address block at 1.125" from bottom + 0.5" from right) ready to upload to Lob; /api/pitches/:id/postcard-preview/(front|back).html injects real PG data; /postcards.html admin viewer with side-by-side iframe preview + pitch dropdown + manual ID input + random-pitch + Lob CSV download + per-postcard cost calc + 6-step Lob upload guide; /api/pitches accepts channel/id/limit query params; postcards link wired into all 6 admin nav bars; ADMIN_PATHS regex covers /postcards
---
 public/linkedin.html                |   1 +
 public/pitches.html                 |   1 +
 public/postcard-template-back.html  | 129 ++++++++++++++++++
 public/postcard-template-front.html |  88 +++++++++++++
 public/postcards.html               | 251 ++++++++++++++++++++++++++++++++++++
 public/responses.html               |   1 +
 public/sales-nav-signup.html        |   1 +
 public/today.html                   |   1 +
 public/walk-route.html              |   1 +
 src/server/index.ts                 |  66 +++++++++-
 10 files changed, 536 insertions(+), 4 deletions(-)

diff --git a/public/linkedin.html b/public/linkedin.html
index 9d978bc..f4afdc2 100644
--- a/public/linkedin.html
+++ b/public/linkedin.html
@@ -76,6 +76,7 @@
     <a href="/pitches.html">pitches</a>
     <a href="/walk-route.html">walk route</a>
     <a href="/responses.html">responses</a>
+    <a href="/postcards.html">postcards</a>
     <a href="/linkedin.html" class="active">linkedin</a>
     <span class="theme-toggle-host" style="display:inline-flex;align-items:center;margin-left:6px"></span>
   </nav>
diff --git a/public/pitches.html b/public/pitches.html
index f1d4679..d1a4fc8 100644
--- a/public/pitches.html
+++ b/public/pitches.html
@@ -110,6 +110,7 @@
     <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="/linkedin.html">linkedin</a>
     <span class="theme-toggle-host" style="display:inline-flex;align-items:center;margin-left:6px"></span>
   </nav>
diff --git a/public/postcard-template-back.html b/public/postcard-template-back.html
new file mode 100644
index 0000000..5bea30a
--- /dev/null
+++ b/public/postcard-template-back.html
@@ -0,0 +1,129 @@
+<!doctype html>
+<html>
+<head>
+<meta charset="utf-8">
+<title>DW Postcard — Back</title>
+<!--
+  Lob postcard BACK template.
+  Lob requires the right half (3"×6") to be reserved for indicia + address block.
+  Left half (3"×6" minus margins) is the message area.
+  All address block coordinates match Lob's spec exactly.
+-->
+<style>
+  @page { size: 6.25in 4.25in; margin: 0; }
+  html, body { margin: 0; padding: 0; width: 6.25in; height: 4.25in; overflow: hidden; }
+  body {
+    font-family: 'Cormorant Garamond', 'Times New Roman', serif;
+    color: #1a1a1a;
+    background: #faf8f3;
+    position: relative;
+    box-sizing: border-box;
+  }
+  /* Left half = message zone */
+  .message {
+    position: absolute;
+    top: 0.3in; left: 0.3in;
+    width: 2.7in; height: 3.65in;
+    padding: 0;
+    font-size: 10pt;
+    line-height: 1.55;
+    color: #2a2a2a;
+    overflow: hidden;
+  }
+  .greeting {
+    font-style: italic;
+    font-size: 14pt;
+    margin: 0 0 0.08in;
+    color: #6a4f2a;
+  }
+  .body-copy {
+    font-family: 'Inter', system-ui, sans-serif;
+    font-weight: 300;
+    font-size: 9.5pt;
+    margin: 0.05in 0;
+  }
+  .signoff {
+    margin-top: 0.18in;
+    font-style: italic;
+    font-size: 12pt;
+    color: #6a4f2a;
+  }
+
+  /* Right half = address block + indicia */
+  .right-pane {
+    position: absolute;
+    top: 0; right: 0;
+    width: 3in; height: 4.25in;
+  }
+  /* Indicia box (Lob places permit/postage here automatically) */
+  .indicia {
+    position: absolute; top: 0.25in; right: 0.25in;
+    width: 1.2in; height: 0.7in;
+    border: 0.5px dashed #999;
+    font-family: monospace; font-size: 6pt; color: #999;
+    text-align: center; line-height: 0.7in;
+    letter-spacing: 0.1em;
+  }
+  /* Address block (Lob's required position: 1.125" from bottom, 0.5" from right edge) */
+  .address-block {
+    position: absolute;
+    bottom: 1.125in;
+    right: 0.5in;
+    font-family: 'Inter', system-ui, sans-serif;
+    font-weight: 400;
+    font-size: 11pt;
+    line-height: 1.32;
+    color: #1a1a1a;
+    text-align: left;
+    width: 3.75in;
+    box-sizing: border-box;
+  }
+  .address-block .name { font-weight: 500; }
+  /* QR placeholder zone (replace with merge.qr_url if you generate per-pitch) */
+  .qr {
+    position: absolute;
+    top: 0.25in; left: 0.25in;
+    width: 0.7in; height: 0.7in;
+    background: #1a1a1a; color: #faf8f3;
+    font-family: monospace; font-size: 5.5pt;
+    text-align: center; line-height: 0.7in;
+    letter-spacing: 0.05em;
+  }
+  /* Brand bar across the bottom-left */
+  .brand-bar {
+    position: absolute;
+    bottom: 0.25in; left: 0.3in;
+    font-family: 'Inter', system-ui, sans-serif; font-weight: 200;
+    font-size: 7pt; letter-spacing: 0.3em; text-transform: uppercase;
+    color: #6a4f2a;
+  }
+  .brand-bar .url { color: #1a1a1a; font-weight: 400; }
+</style>
+</head>
+<body>
+  <div class="message">
+    <p class="greeting">{{to.name}},</p>
+    <p class="body-copy">{{merge.body}}</p>
+    <p class="body-copy" style="margin-top:0.12in">
+      Stop in suite 201 — happy to show you samples, talk through your project, or just say hello.
+      We're at 15442 Ventura, the building right at the corner.
+    </p>
+    <div class="signoff">— Steve</div>
+  </div>
+
+  <div class="right-pane">
+    <div class="qr">DW.com<br>QR</div>
+    <div class="indicia">FIRST-CLASS<br>PERMIT VIA LOB</div>
+    <div class="address-block">
+      <div class="name">{{to.name}}</div>
+      <div>{{to.company}}</div>
+      <div>{{to.address_line1}}</div>
+      <div>{{to.address_city}}, {{to.address_state}} {{to.address_zip}}</div>
+    </div>
+  </div>
+
+  <div class="brand-bar">
+    <span class="url">designerwallcoverings.com</span> · 15442 Ventura Blvd #201
+  </div>
+</body>
+</html>
diff --git a/public/postcard-template-front.html b/public/postcard-template-front.html
new file mode 100644
index 0000000..8bd4c14
--- /dev/null
+++ b/public/postcard-template-front.html
@@ -0,0 +1,88 @@
+<!doctype html>
+<html>
+<head>
+<meta charset="utf-8">
+<title>DW Postcard — Front</title>
+<!--
+  Lob postcard FRONT template.
+  4×6 finished · 4.25"×6.25" with 0.125" bleed · 300dpi → 1275×1875px
+  Upload this file to Lob (Templates → Create → "Custom HTML") and use field
+  names matching the merge.* columns in /api/pitches/postcard-batch.csv.
+  Safe (live) zone is 0.25" from edge → text/logo stays inside 4.5"×6.5" minus 0.25".
+-->
+<style>
+  /* Lob expects 4.25"x6.25" page with 0.125" bleed. We design at that exact size. */
+  @page { size: 6.25in 4.25in; margin: 0; }
+  html, body { margin: 0; padding: 0; width: 6.25in; height: 4.25in; overflow: hidden; }
+  body {
+    font-family: 'Cormorant Garamond', 'Times New Roman', serif;
+    color: #f0ece2;
+    background: #0a0a0c;
+    position: relative;
+    box-sizing: border-box;
+    /* Safe zone: 0.25" from edge — keep text inside this */
+    padding: 0.45in 0.5in;
+  }
+  /* Bleed-edge gradient: warm metal glow at corners */
+  body::before {
+    content: '';
+    position: absolute; inset: 0;
+    background:
+      radial-gradient(ellipse at top right, rgba(212,182,131,0.18), transparent 50%),
+      radial-gradient(ellipse at bottom left, rgba(184,153,104,0.12), transparent 60%);
+    z-index: 0;
+  }
+  body::after {
+    content: '';
+    position: absolute; inset: 0.18in;
+    border: 0.5px solid rgba(184,153,104,0.4);
+    z-index: 1;
+  }
+  .content { position: relative; z-index: 2; height: 100%; display: flex; flex-direction: column; }
+  .brand-mark {
+    font-size: 9pt; letter-spacing: 0.45em; text-transform: uppercase;
+    color: #b89968; font-family: 'Inter', system-ui, sans-serif; font-weight: 200;
+  }
+  .headline {
+    font-size: 28pt; line-height: 1.05;
+    margin: 0.18in 0 0.1in;
+    font-style: italic; font-weight: 300;
+    letter-spacing: -0.01em;
+  }
+  .headline em { font-style: normal; color: #d4b683; }
+  .sub {
+    font-size: 11pt; line-height: 1.45;
+    color: #d8d2c0;
+    max-width: 4in;
+    font-family: 'Inter', system-ui, sans-serif; font-weight: 300;
+  }
+  .signature {
+    margin-top: auto;
+    font-size: 10pt; color: #d8d2c0;
+    font-family: 'Inter', system-ui, sans-serif; font-weight: 300;
+  }
+  .signature .who { font-family: 'Cormorant Garamond', serif; font-style: italic; font-size: 14pt; color: #d4b683; display: block; margin-bottom: 2px; }
+  .footer {
+    position: absolute; bottom: 0.32in; right: 0.5in;
+    font-size: 7pt; letter-spacing: 0.3em; text-transform: uppercase;
+    color: #888475; font-family: 'Inter', system-ui, sans-serif;
+  }
+</style>
+</head>
+<body>
+  <div class="content">
+    <div class="brand-mark">Designer Wallcoverings</div>
+    <h1 class="headline">A neighbor on Ventura with <em>walls worth seeing</em>.</h1>
+    <p class="sub">
+      Suite 201 · 15442 Ventura Blvd · Sherman Oaks. We carry the lines you see in shelter magazines —
+      Phillip Jeffries, Cole &amp; Son, Schumacher, Mind the Gap. Free trade samples,
+      free coffee, no minimum order. Walk in any time, or scan the QR on the back.
+    </p>
+    <div class="signature">
+      <span class="who">Steve Abrams</span>
+      Owner · Designer Wallcoverings
+    </div>
+  </div>
+  <div class="footer">{{merge.dw_proximity}} · {{merge.pitch_type}}</div>
+</body>
+</html>
diff --git a/public/postcards.html b/public/postcards.html
new file mode 100644
index 0000000..af5e6b2
--- /dev/null
+++ b/public/postcards.html
@@ -0,0 +1,251 @@
+<!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>
+    <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)));
+    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
+  const p = allPitches.find(x => String(x.id) === String(id));
+  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}</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>
diff --git a/public/responses.html b/public/responses.html
index e897d8f..d814bfd 100644
--- a/public/responses.html
+++ b/public/responses.html
@@ -184,6 +184,7 @@
     <a href="/walk-route.html">walk route</a>
     <a href="/linkedin.html">linkedin</a>
     <a href="/responses.html" class="active">responses</a>
+    <a href="/postcards.html">postcards</a>
     <span class="theme-toggle-host" style="display:inline-flex;align-items:center;margin-left:6px"></span>
   </nav>
 </header>
diff --git a/public/sales-nav-signup.html b/public/sales-nav-signup.html
index bea9487..1b4d2c5 100644
--- a/public/sales-nav-signup.html
+++ b/public/sales-nav-signup.html
@@ -94,6 +94,7 @@
     <a href="/pitches.html">pitches</a>
     <a href="/walk-route.html">walk route</a>
     <a href="/responses.html">responses</a>
+    <a href="/postcards.html">postcards</a>
     <a href="/linkedin.html">linkedin</a>
     <a href="/sales-nav-signup.html" class="active">signup</a>
     <span class="theme-toggle-host" style="display:inline-flex;align-items:center;margin-left:6px"></span>
diff --git a/public/today.html b/public/today.html
index 3fe9fdc..f0648c8 100644
--- a/public/today.html
+++ b/public/today.html
@@ -115,6 +115,7 @@
     <a href="/pitches-map.html">map</a>
     <a href="/walk-route.html">walk route</a>
     <a href="/responses.html">responses</a>
+    <a href="/postcards.html">postcards</a>
     <a href="/today.html" class="active">today</a>
     <span class="theme-toggle-host" style="display:inline-flex;align-items:center;margin-left:6px"></span>
   </nav>
diff --git a/public/walk-route.html b/public/walk-route.html
index fbc9377..7dab049 100644
--- a/public/walk-route.html
+++ b/public/walk-route.html
@@ -156,6 +156,7 @@
     <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="/linkedin.html">linkedin</a>
     <span class="theme-toggle-host" style="display:inline-flex;align-items:center;margin-left:6px"></span>
   </nav>
diff --git a/src/server/index.ts b/src/server/index.ts
index 51eb744..3218029 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -67,6 +67,7 @@ const ADMIN_PATHS = [
   /^\/api\/responses(\.csv|\/.*)?$/i,
   /^\/api\/followups\/?$/i,
   /^\/api\/snapshots\/?$/i,
+  /^\/postcards(\.html)?\/?$/i,
 ];
 app.use((req, res, next) => {
   if (!ADMIN_PATHS.some((re) => re.test(req.path))) return next();
@@ -476,12 +477,18 @@ app.get('/api/polyline', (_req, res) => {
 // ─── Pitches — DW outreach pipeline ──────────────────────────────────
 app.get('/api/pitches', async (req, res) => {
   try {
-    const status = String(req.query.status || '').trim();
-    const pri    = String(req.query.priority || '').trim();
+    const status  = String(req.query.status   || '').trim();
+    const pri     = String(req.query.priority || '').trim();
+    const channel = String(req.query.channel  || '').trim();
+    const id      = String(req.query.id       || '').trim();
+    const limit   = Math.min(parseInt(String(req.query.limit ?? '5500'), 10) || 5500, 6000);
     const where: string[] = [];
     const params: any[] = [];
-    if (status) { params.push(status); where.push(`p.status = $${params.length}`); }
-    if (pri)    { params.push(parseInt(pri, 10)); where.push(`p.priority = $${params.length}`); }
+    if (status)  { params.push(status); where.push(`p.status = $${params.length}`); }
+    if (pri)     { params.push(parseInt(pri, 10)); where.push(`p.priority = $${params.length}`); }
+    if (channel) { params.push(channel); where.push(`p.outreach_channel = $${params.length}`); }
+    if (id)      { params.push(parseInt(id, 10)); where.push(`p.id = $${params.length}`); }
+    params.push(limit);
     const r = await query(`
       SELECT p.id, p.business_id, p.pitch_type, p.priority, p.status, p.dw_proximity, p.outreach_channel,
              p.subject, p.body, p.observation, p.why_dw_fits,
@@ -490,12 +497,15 @@ app.get('/api/pitches', async (req, res) => {
              p.li_connection_status, p.li_dm_sent_at, p.li_invite_sent_at,
              p.created_at, p.updated_at, p.scrubbed_at, p.approved_at,
              p.sent_at, p.replied_at, p.outcome, p.notes,
+             p.reply_text, p.reply_channel, p.won_value_usd, p.lost_reason,
+             p.next_followup_at, p.followup_notes,
              b.name, b.address, b.city, b.zip,
              b.raw->>'primary_naics_description' AS naics
       FROM pitches p
       JOIN businesses b ON b.id = p.business_id
       ${where.length ? 'WHERE ' + where.join(' AND ') : ''}
       ORDER BY p.priority ASC, b.name ASC
+      LIMIT $${params.length}
     `, params);
     res.json({ count: r.rowCount, rows: r.rows });
   } catch (e: any) {
@@ -885,6 +895,54 @@ app.patch('/api/pitches/:id', async (req, res) => {
   }
 });
 
+// ─── Postcard preview: render the front/back HTML with a real pitch ──
+// The /postcards.html viewer iframes these endpoints so Steve sees the
+// final postcard rendered from real PG data before he uploads templates to Lob.
+import { readFileSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+
+function renderTemplate(tplName: 'postcard-template-front.html' | 'postcard-template-back.html', vars: Record<string, string>) {
+  const __dirname = dirname(fileURLToPath(import.meta.url));
+  const root = join(__dirname, '..', '..', 'public');
+  const tpl = readFileSync(join(root, tplName), 'utf-8');
+  return tpl.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_m, key) => vars[key] ?? '');
+}
+
+app.get('/api/pitches/:id/postcard-preview/:side(front|back).html', async (req, res) => {
+  try {
+    const id = parseInt(req.params.id, 10);
+    const side = req.params.side as 'front' | 'back';
+    if (!Number.isFinite(id)) return res.status(400).send('bad id');
+    const r = await query(
+      `SELECT p.id, b.name, p.contact_name, b.address, b.city, b.zip,
+              p.dw_proximity, p.pitch_type, p.subject, p.body
+       FROM pitches p JOIN businesses b ON b.id = p.business_id
+       WHERE p.id = $1`,
+      [id]
+    );
+    if (r.rowCount === 0) return res.status(404).send('not found');
+    const row = r.rows[0];
+    const vars: Record<string, string> = {
+      'to.name': row.contact_name || row.name || 'Neighbor',
+      'to.company': row.contact_name ? row.name : '',
+      'to.address_line1': row.address || '',
+      'to.address_city': row.city || '',
+      'to.address_state': 'CA',
+      'to.address_zip': row.zip || '',
+      'merge.dw_proximity': (row.dw_proximity || '').replace(/_/g, ' '),
+      'merge.pitch_type': row.pitch_type || '',
+      'merge.subject': row.subject || '',
+      'merge.body':
+        row.body
+        || `Hi — Steve from Designer Wallcoverings, suite 201 right here at 15442 Ventura. We carry the lines you see in shelter magazines (Phillip Jeffries, Cole & Son, Schumacher, Mind the Gap). Free trade samples, no minimum, no pressure — just a sample drop and a coffee.`,
+    };
+    res.setHeader('Content-Type', 'text/html; charset=utf-8');
+    res.send(renderTemplate(`postcard-template-${side}.html` as any, vars));
+  } catch (e: any) {
+    res.status(500).send('error: ' + e.message);
+  }
+});
+
 // ─── Timeline / audit log: every lifecycle event for one pitch ──────
 app.get('/api/pitches/:id/timeline', async (req, res) => {
   try {

← 69ba8fb iter 51: pitch_event_log audit trail + responses CSV export  ·  back to Ventura Corridor  ·  fix(ops): rewrite hawk to query pm2 status not http — only r c5a0c97 →