← back to Rentv 2026

admin/forsale.html

194 lines

<!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>