← back to Rentv
rentv(TK-10061): wire /advertise media-kit form to real capture (POST /api/advertise → append-only JSONL, admin read, deploy-exclude); was data-fakepost dropping every advertiser lead
9c747d58b57b5e5ca8e21da8fd64801b0b6d3006 · 2026-08-07 20:20:05 -0700 · Steve Abrams
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
M .deploy.confM public/advertise.htmlM server.js
Diff
commit 9c747d58b57b5e5ca8e21da8fd64801b0b6d3006
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Fri Aug 7 20:20:05 2026 -0700
rentv(TK-10061): wire /advertise media-kit form to real capture (POST /api/advertise → append-only JSONL, admin read, deploy-exclude); was data-fakepost dropping every advertiser lead
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
.deploy.conf | 4 +++-
public/advertise.html | 19 ++++++++++++++++---
server.js | 41 +++++++++++++++++++++++++++++++++++++++++
3 files changed, 60 insertions(+), 4 deletions(-)
diff --git a/.deploy.conf b/.deploy.conf
index 6d1cc417..eed9fa5c 100644
--- a/.deploy.conf
+++ b/.deploy.conf
@@ -13,7 +13,9 @@ BUILD_CMD=""
# public/img/news = prod-cron + live article-proxy runtime output (localize.cjs downloads each rentv.com photo here on prod). With rsync --delete, a deploy was WIPING prod's downloaded images (local dir is gitignored/stale) → every front-page image 404'd blank (TK-10284, 2026-08-06). Prod owns this dir — never clobber it.
# data/active-templates.json = the LIVE select-to-live choice (TK-10356), set by admins on prod via
# POST /api/admin/active-template — prod owns it; never clobber it with the local dev copy on deploy.
-RSYNC_EXTRA_EXCLUDES="data/subscribers.jsonl data/posts.json data/forsale.json data/vimeo-library.json data/social-drafts.json data/news-archive.json data/deals-registry.json data/deals.json data/news.json data/markets.json data/search-index-meta.json data/search-index.jsonl data/ticker.json data/active-templates.json public/img/news public/audio"
+# data/advertise-inquiries.jsonl = inbound advertiser leads captured on prod via POST /api/advertise
+# (TK-10061). PII + prod-accumulated — never clobber with the local dev copy on deploy.
+RSYNC_EXTRA_EXCLUDES="data/subscribers.jsonl data/advertise-inquiries.jsonl data/posts.json data/forsale.json data/vimeo-library.json data/social-drafts.json data/news-archive.json data/deals-registry.json data/deals.json data/news.json data/markets.json data/search-index-meta.json data/search-index.jsonl data/ticker.json data/active-templates.json public/img/news public/audio"
# NOTE (2026-07-28): renamed from rentv-v1 -> rentv (unified RENTV app). The prod
# dir /root/public-projects/rentv-v1 must be renamed to /root/public-projects/rentv
# and pm2 re-registered as `rentv` as part of the Steve-gated cutover — see
diff --git a/public/advertise.html b/public/advertise.html
index a99708f0..1828e386 100644
--- a/public/advertise.html
+++ b/public/advertise.html
@@ -114,9 +114,9 @@
<section class="blk"><div class="wrap">
<div class="eyebrow rv">Get started</div><h2 class="big rv">Request the 2026 media kit.</h2>
<p class="lead2 rv">Tell us a little about your firm and we'll send rates, audience data and available slots. No obligation.</p>
- <form class="form rv" data-fakepost>
- <label>Name</label><input required placeholder="Your name">
- <label>Company</label><input required placeholder="Firm / brand">
+ <form class="form rv" data-advertise>
+ <label>Name</label><input data-name required placeholder="Your name">
+ <label>Company</label><input data-company required placeholder="Firm / brand">
<label>Work email</label><input type="email" required placeholder="you@company.com">
<label>Interested in</label><select><option>Integrated package</option><option>Newsletter sponsorship</option><option>Site display</option><option>Sponsored content</option><option>Conference sponsorship</option><option>Video / CRE Talk</option></select>
<button class="cta" type="submit">Request media kit →</button>
@@ -142,6 +142,19 @@
var io=new IntersectionObserver(function(es){es.forEach(function(e){if(e.isIntersecting){e.target.classList.add('in');io.unobserve(e.target);}});},{threshold:.1});
document.querySelectorAll('.rv').forEach(function(el){io.observe(el);});
document.querySelectorAll('form[data-fakepost]').forEach(function(f){f.addEventListener('submit',function(e){e.preventDefault();var ok=f.querySelector('.ok');if(ok){ok.style.display='block';}f.querySelectorAll('input,select,button').forEach(function(x){if(x.type!=='reset')x.setAttribute('disabled','');});});});
+ // Real advertiser-inquiry capture → POST /api/advertise (append-only JSONL; inbound lead capture, no external send-to-list).
+ document.querySelectorAll('form[data-advertise]').forEach(function(f){f.addEventListener('submit',function(e){e.preventDefault();
+ var name=(f.querySelector('input[data-name]')||{}).value||'';
+ var company=(f.querySelector('input[data-company]')||{}).value||'';
+ var email=(f.querySelector('input[type=email]')||{}).value||'';
+ var interest=(f.querySelector('select')||{}).value||'';
+ var ok=f.querySelector('.ok');var btn=f.querySelector('button');if(btn)btn.setAttribute('disabled','');
+ fetch('/api/advertise',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:name,company:company,email:email,interest:interest,source:'advertise'})})
+ .then(function(r){return r.json();}).then(function(j){
+ if(j&&j.ok){if(ok){ok.style.display='block';}f.querySelectorAll('input,select').forEach(function(x){x.setAttribute('disabled','');});}
+ else{if(btn)btn.removeAttribute('disabled');alert((j&&j.error)?('Please check the form — '+j.error):'Could not submit, please try again.');}
+ }).catch(function(){if(btn)btn.removeAttribute('disabled');alert('Network error — please try again.');});
+ });});
// Real newsletter capture → POST /api/subscribe (append-only JSONL; no external send-to-list).
document.querySelectorAll('form[data-subscribe]').forEach(function(f){f.addEventListener('submit',function(e){e.preventDefault();
var email=(f.querySelector('input[type=email]')||{}).value||'';
diff --git a/server.js b/server.js
index 7d96fed0..6fc8c838 100644
--- a/server.js
+++ b/server.js
@@ -778,6 +778,47 @@ app.get('/api/subscribers', adminOnly, (_q, res) => {
res.json({ count: items.length, unique: uniq, items: items.slice(-500).reverse() });
});
+// ── ADVERTISER INQUIRIES (/advertise "Request the media kit" form) ──────────
+// Inbound lead capture only — appends to an append-only JSONL, exactly like
+// /api/subscribe. This is NOT an outbound send-to-list (nothing is emailed to
+// anyone), so it needs no send-gate. The captured inquiries are PII → the read
+// endpoint is admin-only, and the file is a deploy-exclude so a deploy never
+// clobbers prod's accumulated leads (see .deploy.conf RSYNC_EXTRA_EXCLUDES).
+const ADV_INQ = path.join(DATA, 'advertise-inquiries.jsonl');
+const MAX_ADV_INQ = 20000; // hard ceiling — guards against disk-fill / append abuse
+let advInqCount = 0;
+try { advInqCount = fs.readFileSync(ADV_INQ, 'utf8').split('\n').filter(Boolean).length; } catch { /* none yet */ }
+app.post('/api/advertise', (req, res) => {
+ const b = req.body || {};
+ const email = String(b.email || '').trim().toLowerCase();
+ const name = String(b.name || '').trim();
+ const company = String(b.company || '').trim();
+ if (!name || !company) return res.status(400).json({ ok: false, error: 'name and company required' });
+ if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) return res.status(400).json({ ok: false, error: 'valid work email required' });
+ if (advInqCount >= MAX_ADV_INQ) return res.status(503).json({ ok: false, error: 'inquiries temporarily closed' });
+ // x-forwarded-for: behind nginx the real client IP is the LAST segment (client-supplied values precede it).
+ const xff = String(req.headers['x-forwarded-for'] || '').split(',').map(s => s.trim()).filter(Boolean);
+ const rec = {
+ name: name.slice(0, 120),
+ company: company.slice(0, 160),
+ email,
+ interest: String(b.interest || '').trim().slice(0, 80),
+ source: String(b.source || 'advertise').trim().slice(0, 40),
+ at: new Date().toISOString(),
+ ip: xff.length ? xff[xff.length - 1] : (req.socket.remoteAddress || ''), // best-effort, untrusted
+ };
+ try { fs.appendFileSync(ADV_INQ, JSON.stringify(rec) + '\n'); advInqCount++; }
+ catch (e) { return res.status(500).json({ ok: false, error: 'could not save' }); }
+ res.json({ ok: true });
+});
+// Admin read — count + recent inquiries. Advertiser leads are PII → admin-only.
+app.get('/api/advertise-inquiries', adminOnly, (_q, res) => {
+ let lines = [];
+ try { lines = fs.readFileSync(ADV_INQ, 'utf8').split('\n').filter(Boolean); } catch { /* none yet */ }
+ const items = lines.map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
+ res.json({ count: items.length, items: items.slice(-500).reverse() });
+});
+
// ── BLOG BACKEND — original RENTV articles (file-backed, no external DB). ──
const POSTS = path.join(DATA, 'posts.json');
const readPosts = () => { try { return JSON.parse(fs.readFileSync(POSTS, 'utf8')); } catch { return []; } };
← 84cfb6ef auto-data-snapshot: 2026-08-07T20:04:36 (7 data files) — dat
·
back to Rentv
·
rentv(TK-10061): gitignore data/advertise-inquiries.jsonl (P 6e544afe →