← 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
M public/linkedin.htmlM public/pitches.htmlA public/postcard-template-back.htmlA public/postcard-template-front.htmlA public/postcards.htmlM public/responses.htmlM public/sales-nav-signup.htmlM public/today.htmlM public/walk-route.htmlM src/server/index.ts
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 & 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> </label>
+ <button class="btn" onclick="loadFromInput()">Preview ↗</button>
+ </div>
+ <div class="ctrl">
+ <label> </label>
+ <button class="btn" onclick="randomPick()">🎲 Random</button>
+ </div>
+ <div class="ctrl">
+ <label> </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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 →