← back to Rentv 2026

public/ad-admin.html

198 lines

<!doctype html><html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>RENTV Ad-Slots — Admin</title>
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700;800&family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
  :root{--red:#c8102e;--ink:#14171a;--sub:#5b636b;--line:#e4e7ea;--wash:#f6f7f9;--gold:#c6a24a}
  *{box-sizing:border-box;margin:0}
  body{font-family:Inter,system-ui,sans-serif;color:var(--ink);background:var(--wash);line-height:1.45;font-size:14px}
  a{color:var(--red)}
  .top{position:sticky;top:0;z-index:20;display:flex;align-items:center;gap:16px;flex-wrap:wrap;background:#0b0e12;color:#e7ecf1;padding:12px 20px;border-bottom:2px solid var(--red)}
  .top .brand{font-family:'Playfair Display',serif;font-weight:800;font-size:19px}.top .brand span{color:var(--red)}
  .top .k{font-size:11px;font-weight:800;letter-spacing:.16em;text-transform:uppercase;color:#7b848d}
  .top .who{margin-left:auto;font-size:12px;color:#8b949e}
  .tabs{display:flex;gap:4px;background:#fff;border-bottom:1px solid var(--line);padding:0 16px;position:sticky;top:47px;z-index:19}
  .tabs button{border:0;background:none;font:inherit;font-weight:800;color:var(--sub);padding:13px 14px;border-bottom:3px solid transparent;cursor:pointer}
  .tabs button.on{color:var(--red);border-color:var(--red)}
  .wrap{max-width:1300px;margin:0 auto;padding:20px}
  .panel{display:none}.panel.on{display:block}
  .bar{display:flex;flex-wrap:wrap;align-items:center;gap:12px;margin-bottom:14px}
  .bar h2{font-family:'Playfair Display',serif;font-size:22px}
  .bar input.search{flex:1;min-width:200px;max-width:360px;padding:9px 12px;border:1px solid var(--line);border-radius:8px;font:inherit}
  .coltoggle{display:flex;gap:6px;flex-wrap:wrap}
  .coltoggle button{font:inherit;font-size:11px;font-weight:700;border:1px solid var(--line);background:#fff;border-radius:16px;padding:4px 10px;cursor:pointer;color:var(--sub)}
  .coltoggle button.on{background:var(--ink);color:#fff;border-color:var(--ink)}
  table{width:100%;border-collapse:collapse;background:#fff;border:1px solid var(--line);border-radius:10px;overflow:hidden;font-size:13px}
  th,td{text-align:left;padding:9px 11px;border-bottom:1px solid var(--line);vertical-align:middle}
  th{background:#f2f4f6;font-size:10.5px;letter-spacing:.05em;text-transform:uppercase;color:var(--sub);cursor:pointer;white-space:nowrap;position:relative;resize:horizontal;overflow:hidden;min-width:60px}
  th:hover{color:var(--ink)}
  tr:hover td{background:#fafbfc}
  td.hide,th.hide{display:none}
  .st{font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:.04em;padding:2px 8px;border-radius:20px}
  .st.available{background:#eafaf0;color:#0f8a4d}.st.reserved{background:#fff6e6;color:#a06a00}.st.sold{background:#fdeaec;color:var(--red)}
  input.cell,select.cell{font:inherit;font-size:12.5px;padding:6px 8px;border:1px solid var(--line);border-radius:6px;width:100%;max-width:170px;background:#fff}
  .save{background:var(--ink);color:#fff;border:0;border-radius:6px;padding:7px 12px;font-weight:800;font-size:12px;cursor:pointer}
  .save:hover{background:var(--red)}
  .save.done{background:#0f8a4d}
  .price{font-weight:800;font-variant-numeric:tabular-nums}
  .thumb{width:70px;height:40px;object-fit:cover;border-radius:5px;background:linear-gradient(135deg,#1f2a37,#c8102e);display:block}
  /* reservations */
  .resgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:14px}
  .rescard{background:#fff;border:1px solid var(--line);border-radius:11px;padding:15px 16px;display:flex;flex-direction:column;gap:6px}
  .rescard .when{display:inline-flex;align-items:center;gap:6px;font-size:11px;font-weight:700;color:#8a929b;background:var(--wash);border:1px solid var(--line);border-radius:16px;padding:3px 9px;align-self:flex-start}
  .rescard h4{font-size:15px;font-weight:800}
  .rescard .kind{font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:.05em;color:var(--red)}
  .rescard .kv{font-size:13px;color:var(--sub)}.rescard .kv b{color:var(--ink)}
  .rescard .amt{font-family:'Playfair Display',serif;font-size:20px;margin-top:2px}
  .empty{padding:40px;text-align:center;color:var(--sub);background:#fff;border:1px dashed var(--line);border-radius:10px}
  .flash{margin-bottom:12px;padding:10px 14px;border-radius:8px;font-weight:600;font-size:13px;display:none}
  .flash.warn{display:block;background:#fff6e6;border:1px solid #ffe1a8;color:#8a5a00}
  .chosenbox{background:#fff;border:1px solid var(--line);border-left:4px solid var(--gold);border-radius:10px;padding:14px 16px;margin-bottom:14px;font-size:13px}
  .chosenbox b{color:var(--gold)}
</style></head><body>
<header class="top">
  <span class="brand">REN<span>TV</span> · Ad-Slots Admin</span>
  <span class="k">rate card · video sponsorships · reservations</span>
  <span class="who" id="who">checking session…</span>
</header>
<div class="tabs">
  <button data-tab="placements" class="on">Banner Slots</button>
  <button data-tab="videos">Video Sponsorships</button>
  <button data-tab="reservations">Reservations</button>
</div>
<div class="wrap">
  <div class="flash warn" id="flash"></div>
  <div class="chosenbox" id="chosenbox" style="display:none"></div>

  <!-- PLACEMENTS -->
  <section class="panel on" id="p-placements">
    <div class="bar"><h2>Banner &amp; placement rate card</h2></div>
    <table id="tPlace"><thead><tr>
      <th>Slot</th><th>Surface</th><th>Format</th><th>Price/mo</th><th>Status</th><th>Advertiser</th><th>Creative URL</th><th>Click URL</th><th>Created</th><th></th>
    </tr></thead><tbody></tbody></table>
  </section>

  <!-- VIDEOS -->
  <section class="panel" id="p-videos">
    <div class="bar"><h2>Video sponsorship inventory</h2>
      <input class="search" id="vSearch" placeholder="Search title / category / source (space = AND)…">
      <div class="coltoggle" id="vCols"></div>
    </div>
    <div style="font-size:12px;color:var(--sub);margin-bottom:8px" id="vCount"></div>
    <table id="tVid"><thead><tr>
      <th data-c="thumb">Video</th><th data-c="title">Title</th><th data-c="source">Source</th><th data-c="cat">Category</th>
      <th data-c="plays">Plays</th><th data-c="price">Price/mo</th><th data-c="status">Status</th><th data-c="advertiser">Advertiser</th><th data-c="save"></th>
    </tr></thead><tbody></tbody></table>
    <button class="save" id="vMore" style="margin:14px auto 0;display:none">Show more</button>
  </section>

  <!-- RESERVATIONS -->
  <section class="panel" id="p-reservations">
    <div class="bar"><h2>Reservations / buy inquiries</h2><span style="font-size:12px;color:var(--sub)" id="rCount"></span></div>
    <div class="resgrid" id="resgrid"></div>
  </section>
</div>

<script>
const $=s=>document.querySelector(s),$$=s=>Array.from(document.querySelectorAll(s));
const esc=s=>(s==null?'':String(s)).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
const money=n=>'$'+Number(n||0).toLocaleString('en-US');
// Steve rule: admin cards show created DATE + TIME in admin's local tz
const fmtDT=iso=>{try{return new Date(iso).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});}catch(e){return iso||'';}};
async function jget(u){try{const r=await fetch(u,{credentials:'same-origin'});if(r.status===401||r.status===403){adminGate();return null;}return r.ok?await r.json():null;}catch(e){return null;}}
async function jpost(u,b){try{const r=await fetch(u,{method:'POST',headers:{'Content-Type':'application/json'},credentials:'same-origin',body:JSON.stringify(b)});if(r.status===401||r.status===403){adminGate();return null;}return await r.json();}catch(e){return null;}}
let GATED=false;
function adminGate(){ if(GATED)return; GATED=true; $('#who').textContent='not signed in as admin';
  $('#flash').className='flash warn'; $('#flash').innerHTML='🔒 You are not signed in as an admin, so writes and the reservations queue are locked. <a href="/login">Sign in</a> then reload. (The public rate card + inventory still load read-only where available.)'; }

/* tabs */
$$('.tabs button').forEach(b=>b.onclick=()=>{ $$('.tabs button').forEach(x=>x.classList.toggle('on',x===b)); $$('.panel').forEach(p=>p.classList.toggle('on',p.id==='p-'+b.dataset.tab)); });

/* chosen template banner */
(async()=>{ const j=await jget('/api/ad/site-template'); if(j&&j.chosen){ $('#chosenbox').style.display='block'; $('#chosenbox').innerHTML='Chosen 2026 site: <b>Template '+esc(j.chosen.toUpperCase())+'</b> · set '+esc(fmtDT(j.updated_at))+' · <a href="/2026/">change in the selector →</a>'; } })();

/* ---------- placements ---------- */
async function loadPlace(){
  const d=await jget('/api/ad/rate-card'); if(!d)return; $('#who').textContent='admin';
  const tb=$('#tPlace tbody');
  tb.innerHTML=(d.placements||[]).map(p=>{
    const st=p.status||'available';
    return `<tr data-id="${esc(p.id)}">
      <td><b>${esc(p.name)}</b><div style="font-size:11px;color:var(--sub)">${esc(p.id)}</div></td>
      <td>${esc(p.surface)}</td><td style="font-size:12px">${esc(p.format)}</td>
      <td><input class="cell" data-f="price_monthly" type="number" value="${p.price_monthly||0}" style="max-width:100px"></td>
      <td><select class="cell" data-f="status"><option ${st==='available'?'selected':''}>available</option><option ${st==='reserved'?'selected':''}>reserved</option><option ${st==='sold'?'selected':''}>sold</option></select></td>
      <td><input class="cell" data-f="advertiser" value="${esc(p.advertiser||'')}" placeholder="—"></td>
      <td><input class="cell" data-f="creative_url" value="${esc(p.creative_url||'')}" placeholder="https://…/banner.png"></td>
      <td><input class="cell" data-f="click_url" value="${esc(p.click_url||'')}" placeholder="https://advertiser…"></td>
      <td style="font-size:11px;color:var(--sub);white-space:nowrap">🕓 ${esc(fmtDT(p.created_at))}</td>
      <td><button class="save" data-save-place>Save</button></td>
    </tr>`;
  }).join('');
}
$('#tPlace').addEventListener('click',async e=>{ const b=e.target.closest('[data-save-place]'); if(!b)return;
  const tr=b.closest('tr'); const body={id:tr.dataset.id}; tr.querySelectorAll('.cell').forEach(c=>body[c.dataset.f]=c.value);
  b.textContent='…'; const j=await jpost('/api/ad/admin/placement',body);
  if(j&&j.ok){ b.textContent='✓ Saved'; b.classList.add('done'); setTimeout(()=>{b.textContent='Save';b.classList.remove('done');},1400); } else b.textContent='Save';
});

/* ---------- videos ---------- */
let VID=[],vPage=80;
const VCOLS=['thumb','title','source','cat','plays','price','status','advertiser'];
function initVCols(){ $('#vCols').innerHTML=VCOLS.map(c=>`<button data-col="${c}" class="on">${c}</button>`).join('');
  let hidden={}; try{hidden=JSON.parse(localStorage.getItem('rentv_admin_vcols')||'{}');}catch(e){}
  VCOLS.forEach(c=>{ if(hidden[c]) toggleCol(c,false); });
  $('#vCols').querySelectorAll('button').forEach(b=>{ if(hidden[b.dataset.col])b.classList.remove('on'); b.onclick=()=>{const on=!b.classList.contains('on');b.classList.toggle('on',on);toggleCol(b.dataset.col,on);persistCols();}; });
}
function toggleCol(c,show){ document.querySelectorAll(`#tVid [data-c="${c}"]`).forEach(el=>el.classList.toggle('hide',!show)); document.querySelectorAll(`#tVid td.col-${c}`).forEach(el=>el.classList.toggle('hide',!show)); }
function persistCols(){ const hidden={}; $('#vCols').querySelectorAll('button').forEach(b=>{if(!b.classList.contains('on'))hidden[b.dataset.col]=1;}); try{localStorage.setItem('rentv_admin_vcols',JSON.stringify(hidden));}catch(e){} }
async function loadVid(){ const d=await jget('/api/ad/inventory'); if(!d)return; VID=d.items||[]; renderVid(); }
function vFilter(){ const q=($('#vSearch').value||'').toLowerCase().split(/\s+/).filter(Boolean);
  return VID.filter(x=>{ const hay=(x.title+' '+x.cat+' '+x.source+' '+(x.sponsorship.advertiser||'')).toLowerCase(); return q.every(t=>hay.includes(t)); }); }
function renderVid(){ const v=vFilter(); $('#vCount').textContent=v.length+' videos'; const slice=v.slice(0,vPage);
  const hiddenBtns=$('#vCols').querySelectorAll('button');
  $('#tVid tbody').innerHTML=slice.map(x=>{ const sp=x.sponsorship||{}; const st=sp.status||'available';
    return `<tr data-uid="${esc(x.uid)}">
      <td class="col-thumb" data-c="thumb">${x.thumb?`<img class="thumb" src="${esc(x.thumb)}" onerror="this.style.visibility='hidden'">`:'<div class="thumb"></div>'}</td>
      <td class="col-title" data-c="title"><b style="font-weight:600">${esc(x.title||x.uid)}</b></td>
      <td class="col-source" data-c="source">${esc(x.source)}</td>
      <td class="col-cat" data-c="cat">${esc(x.cat||'')}</td>
      <td class="col-plays" data-c="plays" style="font-variant-numeric:tabular-nums">${x.plays||''}</td>
      <td class="col-price" data-c="price"><input class="cell" data-f="price_monthly" type="number" value="${sp.price_monthly||0}" style="max-width:90px"></td>
      <td class="col-status" data-c="status"><select class="cell" data-f="status"><option ${st==='available'?'selected':''}>available</option><option ${st==='reserved'?'selected':''}>reserved</option><option ${st==='sold'?'selected':''}>sold</option></select></td>
      <td class="col-advertiser" data-c="advertiser"><input class="cell" data-f="advertiser" value="${esc(sp.advertiser||'')}" placeholder="—"></td>
      <td class="col-save" data-c="save"><button class="save" data-save-vid>Save</button></td>
    </tr>`; }).join('');
  // re-apply hidden columns
  hiddenBtns.forEach(b=>{ if(!b.classList.contains('on')) toggleCol(b.dataset.col,false); });
  $('#vMore').style.display=v.length>vPage?'block':'none';
}
$('#vSearch').addEventListener('input',()=>{vPage=80;renderVid();});
$('#vMore').onclick=()=>{vPage+=80;renderVid();};
$('#tVid').addEventListener('click',async e=>{ const b=e.target.closest('[data-save-vid]'); if(!b)return;
  const tr=b.closest('tr'); const body={uid:tr.dataset.uid}; tr.querySelectorAll('.cell').forEach(c=>body[c.dataset.f]=c.value);
  b.textContent='…'; const j=await jpost('/api/ad/admin/video',body);
  if(j&&j.ok){ b.textContent='✓'; b.classList.add('done'); const idx=VID.findIndex(x=>x.uid===tr.dataset.uid); if(idx>=0)VID[idx].sponsorship=Object.assign(VID[idx].sponsorship,j.state); setTimeout(()=>{b.textContent='Save';b.classList.remove('done');},1400); } else b.textContent='Save';
});

/* ---------- reservations ---------- */
async function loadRes(){ const d=await jget('/api/ad/admin/reservations'); if(!d)return;
  $('#rCount').textContent=(d.count||0)+' inquiries'; const g=$('#resgrid');
  if(!d.items||!d.items.length){ g.innerHTML='<div class="empty">No reservations yet. When someone reserves a slot or sponsors a video on <a href="/advertise">/advertise</a>, it lands here.</div>'; return; }
  g.innerHTML=d.items.map(r=>{ const tot=(r.quoted_monthly||0)*(r.months||1);
    return `<div class="rescard">
      <span class="when" title="${esc(r.at)}">🕓 ${esc(fmtDT(r.at))}</span>
      <span class="kind">${esc(r.kind)} · ${esc(r.months)} mo${r.start?' · from '+esc(r.start):''}</span>
      <h4>${esc(r.target_name||r.target_id)}</h4>
      <div class="kv"><b>${esc(r.advertiser||'—')}</b></div>
      <div class="kv">${esc(r.contact_name||'')} · <a href="mailto:${esc(r.email)}">${esc(r.email)}</a>${r.phone?' · '+esc(r.phone):''}</div>
      ${r.note?`<div class="kv" style="font-style:italic">“${esc(r.note)}”</div>`:''}
      <div class="amt">${money(tot)} <span style="font-size:12px;color:var(--sub);font-family:Inter">(${money(r.quoted_monthly)}/mo)</span></div>
    </div>`; }).join('');
}

initVCols(); loadPlace(); loadVid(); loadRes();
</script>
<script src="/2026/elements.js" defer></script>
</body></html>