[object Object]

← back to Rentv

rentv: add /forsale admin board — assets + known-advertiser prospect pipeline + sublease.com sale (DTD time-boxed LATER)

eb15d0a95fe7cecfb0763702c6ce545550bab704 · 2026-07-30 14:18:07 -0700 · Steve

Files touched

Diff

commit eb15d0a95fe7cecfb0763702c6ce545550bab704
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Jul 30 14:18:07 2026 -0700

    rentv: add /forsale admin board — assets + known-advertiser prospect pipeline + sublease.com sale (DTD time-boxed LATER)
---
 .deploy.conf              |   4 +-
 admin/forsale.html        | 193 ++++++++++++++++++++++++++++++++++++++++++++++
 data/forsale.json         | 162 ++++++++++++++++++++++++++++++++++++++
 docs/FORSALE-SCENARIOS.md |  80 +++++++++++++++++++
 server.js                 |  72 +++++++++++++++++
 5 files changed, 509 insertions(+), 2 deletions(-)

diff --git a/.deploy.conf b/.deploy.conf
index acf4d60d..874d943c 100644
--- a/.deploy.conf
+++ b/.deploy.conf
@@ -4,8 +4,8 @@ DEPLOY_PATH=/root/public-projects/rentv
 HEALTH_URL=http://localhost:9704/api/health
 INSTALL_CMD="npm ci --production"
 BUILD_CMD=""
-# Never overwrite prod's accumulated real newsletter signups with the local file.
-RSYNC_EXTRA_EXCLUDES="data/subscribers.jsonl data/posts.json"
+# Never overwrite prod's accumulated real newsletter signups / live pipeline data with the local file.
+RSYNC_EXTRA_EXCLUDES="data/subscribers.jsonl data/posts.json data/forsale.json"
 # 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/admin/forsale.html b/admin/forsale.html
new file mode 100644
index 00000000..eb096da4
--- /dev/null
+++ b/admin/forsale.html
@@ -0,0 +1,193 @@
+<!doctype html><html lang="en"><head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+<title>RENTV · For Sale — Admin</title>
+<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@800&family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
+<style>
+  :root{--red:#c8102e;--line:#232a32;--cols:2}*{box-sizing:border-box;margin:0}
+  body{background:#0d1014;color:#e7ecf1;font-family:'Inter',system-ui,sans-serif;font-size:14px}
+  a{color:#7cc0ff;text-decoration:none}
+  .top{position:sticky;top:0;z-index:9;display:flex;align-items:center;justify-content:space-between;height:52px;padding:0 16px;background:#0b0e12;border-bottom:1px solid var(--line)}
+  .brand{font-family:'Playfair Display',serif;font-weight:800;font-size:19px}.brand span{color:var(--red)}
+  .brand small{font-family:'Inter';font-weight:700;font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:#8b939b;margin-left:10px}
+  .wrap{max-width:1240px;margin:0 auto;padding:24px 18px 90px;display:grid;grid-template-columns:minmax(0,380px) 1fr;gap:26px}
+  @media(max-width:900px){.wrap{grid-template-columns:1fr}}
+  h2{font-size:13px;letter-spacing:.1em;text-transform:uppercase;color:#8b939b;margin:0 0 12px}
+  h3{font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:#6b747d;margin:22px 0 10px}
+  form{background:#11161c;border:1px solid var(--line);border-radius:14px;padding:18px;margin-bottom:18px}
+  .fld{margin-bottom:12px}.fld label{display:block;font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:#8b939b;margin-bottom:5px}
+  .fld label .req{color:var(--red)}
+  input,select,textarea{width:100%;background:#0d1116;border:1px solid var(--line);border-radius:9px;color:#e7ecf1;padding:10px 12px;font:inherit}
+  input:focus,select:focus,textarea:focus{outline:none;border-color:var(--red)}
+  .row2{display:grid;grid-template-columns:1fr 1fr;gap:10px}
+  .btn{background:var(--red);border:0;color:#fff;font-weight:800;padding:11px 18px;border-radius:10px;cursor:pointer;font-size:14px;width:100%;margin-top:6px}.btn:hover{filter:brightness(1.1)}
+  .note{font-size:12px;color:#6b747d;margin-top:12px;line-height:1.6}
+  .msg{margin-top:10px;font-size:13px;font-weight:700;display:none}.msg.ok{display:block;color:#37d67a}.msg.err{display:block;color:#ff6b6b}
+  .list{min-width:0}
+  .asset{background:#11161c;border:1px solid var(--line);border-radius:14px;padding:16px 18px;margin-bottom:20px}
+  .asset.domain{border-color:#4a3a12;background:linear-gradient(180deg,#15130c,#11161c)}
+  .asset .hd{display:flex;justify-content:space-between;align-items:flex-start;gap:12px}
+  .asset .nm{font-weight:800;font-size:19px}.asset .dek{color:#9aa4ad;font-size:13px;margin-top:3px}
+  .asset .facts{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px;align-items:center}
+  .chip{background:#0d1116;border:1px solid var(--line);border-radius:7px;padding:5px 9px;font-size:12px;color:#c3cbd3}.chip b{color:#e7ecf1}
+  .kind{background:#0d1116;border:1px solid var(--line);border-radius:7px;padding:5px 9px;font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:#8b939b}
+  .when{background:#0d1116;border:1px solid var(--line);border-radius:7px;padding:5px 9px;font-size:11.5px;color:#8b939b}
+  .pill{border-radius:999px;padding:4px 11px;font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.05em;border:1px solid}
+  .st-available,.st-target{color:#8b939b;border-color:#2f3945;background:#141a21}
+  .st-prospecting,.st-contacted{color:#7cc0ff;border-color:#244055;background:#0f1a24}
+  .st-in_talks,.st-interested{color:#f0b64a;border-color:#4a3a12;background:#1a1509}
+  .st-under_offer,.st-offer{color:#c78bff;border-color:#3c2a55;background:#160f1f}
+  .st-sold,.st-won{color:#37d67a;border-color:#1c4a30;background:#0c1a12}
+  .st-held,.st-withdrawn,.st-passed{color:#ff8b8b;border-color:#3a2a2e;background:#1a0f12}
+  .ctrls{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin:10px 0 14px}
+  .ctrls label{font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:#8b939b}
+  .ctrls select,.ctrls input[type=range]{width:auto;padding:7px 10px}
+  .pg{display:grid;grid-template-columns:repeat(var(--cols),minmax(0,1fr));gap:10px}
+  .pc{background:#0e1319;border:1px solid var(--line);border-radius:11px;padding:12px 13px;display:flex;flex-direction:column;gap:7px}
+  .pc .pnm{font-weight:700;font-size:14px}
+  .pc .cat{font-size:11.5px;color:#8b939b}
+  .pc .fit{font-size:12px;color:#aeb7c0;line-height:1.5}
+  .pc .foot{display:flex;justify-content:space-between;align-items:center;gap:8px;margin-top:auto}
+  .pc select{font-size:11px;padding:5px 7px}
+  .del{background:none;border:1px solid #3a2a2e;color:#ff8b8b;border-radius:8px;padding:5px 9px;font-weight:700;font-size:11px;cursor:pointer}.del:hover{background:#2a1418;border-color:var(--red)}
+  .empty{color:#6b747d;font-size:13px;padding:16px 4px;line-height:1.6}
+  .countdown{color:#f0b64a;font-weight:700}
+</style></head>
+<body>
+<header class="top">
+  <a class="brand" href="/desk">REN<span>TV</span><small>For Sale · Admin</small></a>
+  <a href="/desk" style="font-weight:700;font-size:13px">← Back to Desk</a>
+</header>
+<div class="wrap">
+  <div>
+    <h2>Add an asset for sale</h2>
+    <form id="fa" autocomplete="off">
+      <div class="fld"><label>Name <span class="req">*</span></label><input name="name" required placeholder="Homepage takeover, or sublease.com"></div>
+      <div class="row2">
+        <div class="fld"><label>Kind</label><select name="kind"><option value="ad_inventory">Ad inventory</option><option value="sponsorship">Sponsorship</option><option value="domain">Domain</option><option value="other">Other</option></select></div>
+        <div class="fld"><label>Status</label><select name="status"><option value="available">Available</option><option value="prospecting">Prospecting</option><option value="in_talks">In talks</option><option value="under_offer">Under offer</option><option value="sold">Sold</option><option value="held">Held</option></select></div>
+      </div>
+      <div class="fld"><label>Headline</label><input name="headline" placeholder="Top-of-fold masthead, Western-US CRE audience"></div>
+      <div class="row2">
+        <div class="fld"><label>Ask ($)</label><input name="ask" type="number" min="0" placeholder="200000"></div>
+        <div class="fld"><label>Floor ($)</label><input name="floor" type="number" min="0" placeholder="200000"></div>
+      </div>
+      <div class="row2">
+        <div class="fld"><label>Close by</label><input name="close_by" type="date"></div>
+        <div class="fld"><label>Ask note</label><input name="ask_note" placeholder="Rate on request"></div>
+      </div>
+      <div class="fld"><label>Notes</label><textarea name="notes" rows="2" placeholder="Strategy, context…"></textarea></div>
+      <button class="btn" type="submit">Add asset</button>
+      <div class="msg" id="msgA"></div>
+    </form>
+
+    <h2>Add a prospect (known advertiser / buyer)</h2>
+    <form id="fp" autocomplete="off">
+      <div class="fld"><label>For asset <span class="req">*</span></label><select name="asset_id" id="paid" required></select></div>
+      <div class="fld"><label>Company <span class="req">*</span></label><input name="name" required placeholder="CBRE"></div>
+      <div class="row2">
+        <div class="fld"><label>Category</label><input name="category" placeholder="Brokerage"></div>
+        <div class="fld"><label>Status</label><select name="status"><option value="target">Target</option><option value="contacted">Contacted</option><option value="interested">Interested</option><option value="offer">Offer</option><option value="won">Won</option><option value="passed">Passed</option></select></div>
+      </div>
+      <div class="fld"><label>Fit / why</label><textarea name="fit" rows="2" placeholder="Runs a large sublease practice; category domain has defensive value"></textarea></div>
+      <div class="fld"><label>Contact</label><input name="contact" placeholder="name / email — optional"></div>
+      <button class="btn" type="submit">Add prospect</button>
+      <div class="msg" id="msgP"></div>
+      <div class="note">Empty-honest: prospects are <b>target accounts</b> until you mark them Contacted. Never record interest that isn't real.</div>
+    </form>
+  </div>
+
+  <div class="list">
+    <h2>Assets &amp; pipeline (<span id="cnt">0</span>)</h2>
+    <div class="ctrls">
+      <label>Sort <select id="sort">
+        <option value="newest">Newest prospect</option>
+        <option value="name">Company A→Z</option>
+        <option value="status">Pipeline status</option>
+      </select></label>
+      <label>Density <input id="dens" type="range" min="1" max="4" value="2"></label>
+    </div>
+    <div id="assets"></div>
+  </div>
+</div>
+<script>
+const $=s=>document.getElementById(s);
+const esc=s=>String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
+const fmtWhen=d=>{try{return new Date(d).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});}catch(e){return d;}};
+const money=n=>(n==null||n==='')?null:('$'+Number(n).toLocaleString());
+const ASSET_ST=['available','prospecting','in_talks','under_offer','sold','held','withdrawn'];
+const PROS_ST=['target','contacted','interested','offer','won','passed'];
+const ST_RANK={target:0,contacted:1,interested:2,offer:3,won:4,passed:5};
+let DATA={assets:[],prospects:[]};
+
+// persisted view prefs (house rule: sort + density persist)
+const dens=$('dens'); dens.value=localStorage.getItem('fs-dens')||2; document.documentElement.style.setProperty('--cols',dens.value);
+dens.oninput=()=>{document.documentElement.style.setProperty('--cols',dens.value);localStorage.setItem('fs-dens',dens.value);};
+const sortSel=$('sort'); sortSel.value=localStorage.getItem('fs-sort')||'newest';
+sortSel.onchange=()=>{localStorage.setItem('fs-sort',sortSel.value);render();};
+
+function countdown(d){ if(!d) return ''; const days=Math.ceil((new Date(d)-Date.now())/864e5);
+  if(isNaN(days)) return ''; return days<0?`closed ${-days}d ago`:`${days}d to close`; }
+
+function sortProspects(list){
+  const s=sortSel.value, a=[...list];
+  if(s==='name') a.sort((x,y)=>(x.name||'').localeCompare(y.name||''));
+  else if(s==='status') a.sort((x,y)=>(ST_RANK[x.status]??9)-(ST_RANK[y.status]??9));
+  else a.sort((x,y)=>String(y.created_at).localeCompare(String(x.created_at)));
+  return a;
+}
+
+function render(){
+  const A=DATA.assets||[], P=DATA.prospects||[];
+  $('cnt').textContent=A.length;
+  // asset selector for the prospect form
+  $('paid').innerHTML=A.map(a=>`<option value="${a.id}">${esc(a.name)}</option>`).join('')||'<option value="">(add an asset first)</option>';
+  if(!A.length){ $('assets').innerHTML='<div class="empty">No assets yet. Add one on the left — the sublease.com domain or an ad-inventory placement.</div>'; return; }
+  $('assets').innerHTML=A.map(a=>{
+    const pr=sortProspects(P.filter(p=>p.asset_id===a.id));
+    const cd=a.kind==='domain'?countdown(a.close_by):'';
+    return `<div class="asset ${a.kind==='domain'?'domain':''}">
+      <div class="hd">
+        <div><div class="nm">${esc(a.name)}</div>${a.headline?`<div class="dek">${esc(a.headline)}</div>`:''}</div>
+        <button class="del" data-akill="${a.id}">Remove</button>
+      </div>
+      <div class="facts">
+        <span class="kind">${esc(a.kind||'')}</span>
+        <select class="astatus" data-aid="${a.id}">${ASSET_ST.map(s=>`<option value="${s}" ${s===a.status?'selected':''}>${s.replace(/_/g,' ')}</option>`).join('')}</select>
+        ${money(a.ask)?`<span class="chip">Ask <b>${money(a.ask)}</b></span>`:(a.ask_note?`<span class="chip">${esc(a.ask_note)}</span>`:'')}
+        ${money(a.floor)?`<span class="chip">Floor <b>${money(a.floor)}</b></span>`:''}
+        ${cd?`<span class="chip countdown">⏳ ${esc(cd)}</span>`:''}
+        <span class="when" title="${esc(a.created_at)}">🕓 ${esc(fmtWhen(a.created_at))}</span>
+      </div>
+      ${a.notes?`<div class="dek" style="margin-top:10px;font-size:12px;color:#8b939b;line-height:1.55">${esc(a.notes)}</div>`:''}
+      <h3>Prospects (${pr.length})</h3>
+      ${pr.length?`<div class="pg">${pr.map(p=>`<div class="pc">
+        <div class="pnm">${esc(p.name)}</div>
+        ${p.category?`<div class="cat">${esc(p.category)}</div>`:''}
+        ${p.fit?`<div class="fit">${esc(p.fit)}</div>`:''}
+        ${p.contact?`<div class="cat">✉ ${esc(p.contact)}</div>`:''}
+        <div class="foot">
+          <select class="pstatus" data-pid="${p.id}">${PROS_ST.map(s=>`<option value="${s}" ${s===p.status?'selected':''}>${s}</option>`).join('')}</select>
+          <span class="when" title="${esc(p.created_at)}">🕓 ${esc(fmtWhen(p.created_at))}</span>
+          <button class="del" data-pkill="${p.id}">✕</button>
+        </div></div>`).join('')}</div>`:'<div class="empty">No prospects yet for this asset.</div>'}
+    </div>`;
+  }).join('');
+  wire();
+}
+
+function wire(){
+  document.querySelectorAll('.astatus').forEach(sel=>sel.onchange=()=>post('/api/admin/forsale/asset',{id:sel.dataset.aid,status:sel.value}));
+  document.querySelectorAll('.pstatus').forEach(sel=>sel.onchange=()=>post('/api/admin/forsale/prospect',{id:sel.dataset.pid,status:sel.value}));
+  document.querySelectorAll('[data-akill]').forEach(b=>b.onclick=async()=>{if(confirm('Remove this asset and its prospects?')){await del('/api/admin/forsale/asset/'+b.dataset.akill);load();}});
+  document.querySelectorAll('[data-pkill]').forEach(b=>b.onclick=async()=>{await del('/api/admin/forsale/prospect/'+b.dataset.pkill);load();});
+}
+async function post(url,body){const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},credentials:'same-origin',body:JSON.stringify(body)});const j=await r.json().catch(()=>({}));if(r.ok&&j.ok)load();return j;}
+async function del(url){await fetch(url,{method:'DELETE',credentials:'same-origin'});}
+async function load(){DATA=await fetch('/api/forsale',{credentials:'same-origin'}).then(r=>r.json()).catch(()=>({assets:[],prospects:[]}));render();}
+
+function formBody(form){const NUMK=['ask','floor'];const b={};new FormData(form).forEach((v,k)=>{if(String(v).trim()!=='')b[k]=NUMK.includes(k)?Number(v):v;});return b;}
+$('fa').onsubmit=async e=>{e.preventDefault();const m=$('msgA');m.className='msg';const j=await post('/api/admin/forsale/asset',formBody(e.target));if(j.ok){m.className='msg ok';m.textContent='✓ Asset added.';e.target.reset();}else{m.className='msg err';m.textContent='✕ '+(j.error||'error');}};
+$('fp').onsubmit=async e=>{e.preventDefault();const m=$('msgP');m.className='msg';const j=await post('/api/admin/forsale/prospect',formBody(e.target));if(j.ok){m.className='msg ok';m.textContent='✓ Prospect added.';e.target.reset();}else{m.className='msg err';m.textContent='✕ '+(j.error||'error');}};
+load();
+</script>
+</body></html>
diff --git a/data/forsale.json b/data/forsale.json
new file mode 100644
index 00000000..bc9f1ecc
--- /dev/null
+++ b/data/forsale.json
@@ -0,0 +1,162 @@
+{
+  "assets": [
+    {
+      "id": "dom-sublease",
+      "kind": "domain",
+      "name": "sublease.com",
+      "headline": "Premium one-word .com — the commercial-sublease category name",
+      "ask": 200000,
+      "floor": 200000,
+      "ask_note": "Estimate. Competitive process among strategic buyers preferred over a single quote.",
+      "status": "prospecting",
+      "close_by": "2026-10-28",
+      "notes": "DTD verdict 2026-07-30 = time-boxed LATER. Plan: (1) route real sublease deal-flow through RENTV's /desk so the name shows demonstrated demand, (2) run a competitive process among the known CRE strategics/advertisers below, (3) hard fallback — if no demonstrated traction or bidding interest by close_by, revert to selling the bare name at the floor. Do NOT half-build: an unproven micro-marketplace can be worth LESS than the bare name (the dissent's strongest point).",
+      "created_at": "2026-07-30T00:00:00.000Z"
+    },
+    {
+      "id": "ad-home-takeover",
+      "kind": "ad_inventory",
+      "name": "Homepage takeover / masthead",
+      "headline": "Top-of-fold masthead + billboard on rentv.com, Western-US CRE audience since 1998",
+      "ask": null,
+      "ask_note": "Rate on request — set a rate card before outreach.",
+      "status": "available",
+      "notes": "Highest-visibility placement. Natural fit for a brokerage brand or a CRE lender.",
+      "created_at": "2026-07-30T00:00:00.000Z"
+    },
+    {
+      "id": "ad-newsletter",
+      "kind": "ad_inventory",
+      "name": "Newsletter sponsorship",
+      "headline": "Sole sponsor slot in the RENTV email to the subscriber list",
+      "ask": null,
+      "ask_note": "Rate on request — price per send or monthly.",
+      "status": "available",
+      "notes": "Direct-to-inbox reach of the opted-in CRE subscriber base.",
+      "created_at": "2026-07-30T00:00:00.000Z"
+    },
+    {
+      "id": "ad-sublease-desk",
+      "kind": "sponsorship",
+      "name": "Sublease Desk presenting sponsor",
+      "headline": "“Sublease Desk, presented by — ” on /desk + /deals",
+      "ask": null,
+      "ask_note": "Rate on request. Strongest tie-in for the sublease.com buyer conversation.",
+      "status": "available",
+      "notes": "Sponsoring the deal-flow surface is the on-ramp to the sublease.com sale — a sponsor that values the audience is a warm domain buyer.",
+      "created_at": "2026-07-30T00:00:00.000Z"
+    }
+  ],
+  "prospects": [
+    {
+      "id": "pr-costar",
+      "asset_id": "dom-sublease",
+      "name": "CoStar Group",
+      "category": "CRE data / listings (owns LoopNet)",
+      "fit": "Category-defining buyer; owns LoopNet, buys category domains defensively. Highest strategic value for sublease.com.",
+      "status": "target",
+      "contact": "",
+      "notes": "",
+      "created_at": "2026-07-30T00:00:00.000Z"
+    },
+    {
+      "id": "pr-yardi",
+      "asset_id": "dom-sublease",
+      "name": "Yardi (CommercialEdge / CommercialCafe)",
+      "category": "CRE data / listings",
+      "fit": "Direct LoopNet competitor building a listings brand; sublease.com is a clean vertical front door.",
+      "status": "target",
+      "contact": "",
+      "notes": "",
+      "created_at": "2026-07-30T00:00:00.000Z"
+    },
+    {
+      "id": "pr-crexi",
+      "asset_id": "dom-sublease",
+      "name": "Crexi",
+      "category": "CRE marketplace",
+      "fit": "Growth-stage marketplace that spends on category SEO; a one-word vertical domain is on-brand.",
+      "status": "target",
+      "contact": "",
+      "notes": "",
+      "created_at": "2026-07-30T00:00:00.000Z"
+    },
+    {
+      "id": "pr-cbre",
+      "asset_id": "dom-sublease",
+      "name": "CBRE",
+      "category": "Brokerage",
+      "fit": "Largest brokerage; runs a major sublease practice. Would buy the name for its tenant-rep / sublease group.",
+      "status": "target",
+      "contact": "",
+      "notes": "",
+      "created_at": "2026-07-30T00:00:00.000Z"
+    },
+    {
+      "id": "pr-jll",
+      "asset_id": "dom-sublease",
+      "name": "JLL",
+      "category": "Brokerage",
+      "fit": "Global brokerage with a large sublease/tenant-rep book; category domain has defensive + marketing value.",
+      "status": "target",
+      "contact": "",
+      "notes": "",
+      "created_at": "2026-07-30T00:00:00.000Z"
+    },
+    {
+      "id": "pr-cushman",
+      "asset_id": "dom-sublease",
+      "name": "Cushman & Wakefield",
+      "category": "Brokerage",
+      "fit": "Top-3 brokerage; active sublease inventory nationally.",
+      "status": "target",
+      "contact": "",
+      "notes": "",
+      "created_at": "2026-07-30T00:00:00.000Z"
+    },
+    {
+      "id": "pr-colliers",
+      "asset_id": "dom-sublease",
+      "name": "Colliers",
+      "category": "Brokerage",
+      "fit": "Aggressive, acquisitive brokerage; buys brands and platforms.",
+      "status": "target",
+      "contact": "",
+      "notes": "",
+      "created_at": "2026-07-30T00:00:00.000Z"
+    },
+    {
+      "id": "pr-iwg",
+      "asset_id": "dom-sublease",
+      "name": "IWG / Regus (flex space)",
+      "category": "Flex-space operator",
+      "fit": "Flex operators are the natural home for short-term sublease demand; adjacent category buyer.",
+      "status": "target",
+      "contact": "",
+      "notes": "",
+      "created_at": "2026-07-30T00:00:00.000Z"
+    },
+    {
+      "id": "pr-marcus",
+      "asset_id": "ad-newsletter",
+      "name": "Marcus & Millichap",
+      "category": "Brokerage / advertiser",
+      "fit": "Heavy CRE advertiser; natural masthead / newsletter sponsor for a Western-US CRE audience.",
+      "status": "target",
+      "contact": "",
+      "notes": "",
+      "created_at": "2026-07-30T00:00:00.000Z"
+    },
+    {
+      "id": "pr-kidder",
+      "asset_id": "ad-home-takeover",
+      "name": "Kidder Mathews",
+      "category": "Western-US brokerage / advertiser",
+      "fit": "West-Coast-focused brokerage; RENTV's Western-US beat is a bullseye audience match for their ad spend.",
+      "status": "target",
+      "contact": "",
+      "notes": "",
+      "created_at": "2026-07-30T00:00:00.000Z"
+    }
+  ]
+}
\ No newline at end of file
diff --git a/docs/FORSALE-SCENARIOS.md b/docs/FORSALE-SCENARIOS.md
new file mode 100644
index 00000000..f91cbdba
--- /dev/null
+++ b/docs/FORSALE-SCENARIOS.md
@@ -0,0 +1,80 @@
+# RENTV For-Sale — Scenarios, Buyer Shortlist & Outreach Drafts
+
+**Status:** DRAFT for Steve's review. Nothing has been sent. No buyer or advertiser has been contacted.
+**Decision of record:** DTD verdict 2026-07-30 — **time-boxed LATER** (hold sublease.com, build demonstrated demand + run a competitive process, hard fallback to a bare-name floor sale by **2026-10-28** if no traction). Vote 6/8. The strongest dissent (respect it): *there is no named buyer yet, and a half-built marketplace can be worth **less** than the bare name.*
+
+Admin surface: **`/forsale`** on rentv.com (admin-gated) — the live board for the assets + prospect pipeline below.
+
+---
+
+## 1 · The sublease.com domain (~$200k)
+
+### Valuation logic
+- A one-word `.com` in an active CRE category is a **non-perishable, blue-chip asset** — negligible downside to holding (~$10–40/yr renewal).
+- A **bare** category domain typically sells on comps/brandability (low-hundreds-of-K).
+- A domain that **demonstrably routes real demand** (traffic + leads + a captive advertiser list) reprices from "domain" to "domain + working funnel," which both **widens the buyer pool** (operators/financial buyers, not just 3–4 strategics) and enables a **competitive process** — the mechanism that produces a multiple over a single quote.
+- ⚠️ The half-built trap: a *lightly* monetized micro-marketplace can be valued at 3–5× near-zero revenue, i.e. **below** the bare-name strategic premium. So either demonstrate *real* demand or sell the bare name at the floor — never sell the in-between.
+
+### Buyer shortlist (target accounts — NOT yet contacted)
+| Buyer | Why they'd buy sublease.com |
+|---|---|
+| **CoStar Group** | Owns LoopNet; category-defining; buys category domains defensively. Highest strategic value. |
+| **Yardi** (CommercialEdge / CommercialCafe) | Direct LoopNet competitor building a listings brand; clean vertical front door. |
+| **Crexi** | Growth marketplace that spends on category SEO; one-word vertical domain is on-brand. |
+| **CBRE / JLL / Cushman & Wakefield / Colliers** | Each runs a large sublease/tenant-rep practice; defensive + marketing value. |
+| **IWG / Regus** (flex space) | Flex operators are the natural home for short-term sublease demand. |
+
+### The three scenarios
+- **A — Sell NOW (bare name).** Go find/negotiate one strategic buyer, target ~$200k. Fast, certain-ish, but forecloses the auction and hands leverage to whoever you approach first. This is the **fallback**, not the plan.
+- **B — Build then sell (the verdict).** Route real sublease deal-flow through `/desk`, prove demand, then sell as a monetized asset **or** run a competitive process among the buyers above. Upside: a multiple. Risk: execution slip.
+- **C — Competitive process now.** Even without heavy monetization, quietly float the name to all buyers above **as a sealed process with a floor** — let them bid against each other. Cheaper than B, captures much of the auction upside. **Recommended first move**, run in parallel with a light B.
+
+---
+
+## 2 · RENTV → known advertisers (ad/sponsorship sale)
+
+RENTV's real sellable inventory (rate cards TBD — set before outreach):
+- **Homepage takeover / masthead** — highest-visibility; brokerage or CRE lender.
+- **Newsletter sponsorship** — sole-sponsor slot to the opted-in subscriber list.
+- **Sublease Desk presenting sponsor** — "presented by —" on `/desk` + `/deals`. **Strategic tie-in:** a sponsor who values this audience is a *warm sublease.com buyer.* Sponsorship is the on-ramp to the domain sale.
+
+Advertiser targets: **Marcus & Millichap, Kidder Mathews** (Western-US bullseye), plus the brokerages above (they buy ads *and* could buy the domain — double-duty prospects).
+
+---
+
+## 3 · Timeline (90-day time-box → 2026-10-28)
+
+1. **Weeks 1–2:** Set rate cards. Open a quiet competitive process on sublease.com (Scenario C) — soft feelers to the shortlist, floor at/above $200k. Track every touch in `/forsale`.
+2. **Weeks 2–6:** Stand up the light monetization (route real sublease deal-flow through `/desk`, land 1–2 Sublease-Desk sponsors). Any traction is pure upside to the domain price.
+3. **Weeks 6–12:** Convert the strongest domain interest to offers; if a bidding dynamic forms, run it. 
+4. **Day 90 gate:** If no demonstrated traction AND no bidding interest → revert to Scenario A, sell the bare name at the floor. No open-ended holding.
+
+---
+
+## 4 · Outreach drafts (DRAFT — not sent; for Steve's approval)
+
+### 4a · Domain — competitive-process feeler (to a strategic buyer)
+> **Subject:** sublease.com — quiet process, [Company] on the shortlist
+>
+> Hi [Name],
+> We own **sublease.com** and are running a short, invitation-only process for it before it goes to open market. Given [Company]'s position in [commercial sublease / CRE listings], we wanted you on the shortlist.
+> It's the exact-match category name — direct type-in, brandable, and (via our RENTV commercial-real-estate audience) already adjacent to real sublease deal-flow. Happy to share traffic/interest context under NDA.
+> If it's a fit, reply with a range and we'll fold you into the process. We expect to close within [X] weeks.
+> Best, [Steve] · RENTV
+
+### 4b · Sublease Desk sponsorship (to an advertiser)
+> **Subject:** Present the RENTV Sublease Desk — [Company]
+>
+> Hi [Name],
+> RENTV has covered Western-US commercial real estate since 1998. We're opening a **presenting sponsorship** of our new Sublease Desk — the deal-flow surface our broker audience uses daily.
+> One brand, top placement on `/desk` + `/deals` + the newsletter. It puts [Company] in front of the exact tenant-rep / sublease audience you want, and it's a natural home given your [sublease practice / Western-US footprint].
+> Rate card attached. Open to a short call this week?
+> Best, [Steve] · RENTV
+
+*(Both are starting points — tune tone/specifics before any send. Sending is Steve-gated.)*
+
+---
+
+## 5 · What's built vs. what's gated
+- **Built (local, reversible):** the `/forsale` admin board + `/api/forsale` + `/api/admin/forsale/*`, seeded with the assets + target prospects above; this doc. Smoke-tested on a scratch port.
+- **Gated (Steve-only):** any outreach/send, listing sublease.com, accepting an offer, the prod deploy of `/forsale` to rentv.com, and setting real rate-card numbers.
diff --git a/server.js b/server.js
index e7acc7c7..22d33307 100644
--- a/server.js
+++ b/server.js
@@ -369,6 +369,78 @@ app.delete('/api/admin/sublease/:id', adminOnly, (req, res) => {
   return usreWrite('DELETE', '/api/admin/sublease/' + id, req, res);
 });
 
+// ── For-Sale board (/forsale): assets RENTV is selling to known advertisers + the ──
+// sublease.com domain sale, with a per-asset prospect pipeline. Local JSON (like posts),
+// admin-only, empty-honest — prospects seed as "target" (not-yet-contacted), never as bids.
+const FORSALE = path.join(DATA, 'forsale.json');
+const readForsale = () => { const d = readJSON('forsale.json', { assets: [], prospects: [] }); d.assets = asArr(d.assets); d.prospects = asArr(d.prospects); return d; };
+const writeForsale = (d) => fs.writeFileSync(FORSALE, JSON.stringify(d, null, 2));
+const clip = (s, n) => String(s == null ? '' : s).slice(0, n);
+const ASSET_STATUS = ['available', 'prospecting', 'in_talks', 'under_offer', 'sold', 'held', 'withdrawn'];
+const PROSPECT_STATUS = ['target', 'contacted', 'interested', 'offer', 'won', 'passed'];
+app.get('/forsale', adminOnly, (_req, res) => sendPage(res, path.join(__dirname, 'admin', 'forsale.html')));
+app.get('/api/forsale', adminOnly, (_req, res) => res.json(readForsale()));
+// Add or update an asset. New asset when no id; partial update (incl. status) when id matches.
+app.post('/api/admin/forsale/asset', adminOnly, (req, res) => {
+  const b = req.body || {}, d = readForsale(), now = new Date().toISOString();
+  if (b.id) {
+    const i = d.assets.findIndex(a => a.id === b.id);
+    if (i < 0) return res.status(404).json({ ok: false, error: 'asset not found' });
+    ['name', 'kind', 'headline', 'ask_note', 'notes', 'close_by'].forEach(k => { if (b[k] != null) d.assets[i][k] = clip(b[k], k === 'notes' ? 2000 : 300); });
+    if (b.ask != null) d.assets[i].ask = b.ask === '' ? null : Number(b.ask);
+    if (b.floor != null) d.assets[i].floor = b.floor === '' ? null : Number(b.floor);
+    if (b.status && ASSET_STATUS.includes(b.status)) d.assets[i].status = b.status;
+    d.assets[i].updated_at = now;
+    try { writeForsale(d); } catch { return res.status(500).json({ ok: false, error: 'save failed' }); }
+    return res.json({ ok: true, asset: d.assets[i] });
+  }
+  if (!clip(b.name, 200).trim()) return res.status(400).json({ ok: false, error: 'name required' });
+  const asset = {
+    id: 'as' + Date.now().toString(36), kind: clip(b.kind, 40) || 'ad_inventory',
+    name: clip(b.name, 200), headline: clip(b.headline, 300), ask: b.ask ? Number(b.ask) : null,
+    floor: b.floor ? Number(b.floor) : null, ask_note: clip(b.ask_note, 300),
+    status: ASSET_STATUS.includes(b.status) ? b.status : 'available',
+    close_by: clip(b.close_by, 30), notes: clip(b.notes, 2000), created_at: now,
+  };
+  d.assets.push(asset);
+  try { writeForsale(d); } catch { return res.status(500).json({ ok: false, error: 'save failed' }); }
+  res.json({ ok: true, asset });
+});
+app.delete('/api/admin/forsale/asset/:id', adminOnly, (req, res) => {
+  const d = readForsale(), n = d.assets.filter(a => a.id !== req.params.id);
+  d.assets = n; d.prospects = d.prospects.filter(p => p.asset_id !== req.params.id); // orphaned prospects go too
+  try { writeForsale(d); } catch { return res.status(500).json({ ok: false, error: 'save failed' }); }
+  res.json({ ok: true });
+});
+// Add or update a prospect (a known-advertiser target, or its pipeline status).
+app.post('/api/admin/forsale/prospect', adminOnly, (req, res) => {
+  const b = req.body || {}, d = readForsale(), now = new Date().toISOString();
+  if (b.id) {
+    const i = d.prospects.findIndex(p => p.id === b.id);
+    if (i < 0) return res.status(404).json({ ok: false, error: 'prospect not found' });
+    ['name', 'category', 'fit', 'contact', 'notes'].forEach(k => { if (b[k] != null) d.prospects[i][k] = clip(b[k], k === 'notes' ? 2000 : 300); });
+    if (b.status && PROSPECT_STATUS.includes(b.status)) d.prospects[i].status = b.status;
+    d.prospects[i].updated_at = now;
+    try { writeForsale(d); } catch { return res.status(500).json({ ok: false, error: 'save failed' }); }
+    return res.json({ ok: true, prospect: d.prospects[i] });
+  }
+  if (!clip(b.name, 200).trim()) return res.status(400).json({ ok: false, error: 'name required' });
+  if (!d.assets.some(a => a.id === b.asset_id)) return res.status(400).json({ ok: false, error: 'unknown asset_id' });
+  const prospect = {
+    id: 'pr' + Date.now().toString(36), asset_id: b.asset_id, name: clip(b.name, 200),
+    category: clip(b.category, 120), fit: clip(b.fit, 400), contact: clip(b.contact, 200),
+    status: PROSPECT_STATUS.includes(b.status) ? b.status : 'target', notes: clip(b.notes, 2000), created_at: now,
+  };
+  d.prospects.push(prospect);
+  try { writeForsale(d); } catch { return res.status(500).json({ ok: false, error: 'save failed' }); }
+  res.json({ ok: true, prospect });
+});
+app.delete('/api/admin/forsale/prospect/:id', adminOnly, (req, res) => {
+  const d = readForsale(); d.prospects = d.prospects.filter(p => p.id !== req.params.id);
+  try { writeForsale(d); } catch { return res.status(500).json({ ok: false, error: 'save failed' }); }
+  res.json({ ok: true });
+});
+
 // ── Also hook the Desk to two more loopback backends (same adminOnly gate): ──
 //   homesonspec-web (new-construction / spec-home inventory) — open on loopback.
 //   commercialrealestate / "crcp" (commercial-RE CRM) — basic-auth-gated; inject the cred.

← 69355a23 pr-intelligence: operator review pass (256 orgs verified on  ·  back to Rentv  ·  rentv/forsale: set suggested ad rate card + add sublease.com b0bc9e51 →