← back to Re Flyer Aggregator

public/flyers/live.html

174 lines

<!doctype html><html lang="en"><head>
<script>
// Credential-safe fetch guard (fleet drop-in) — if this page is opened with
// credentials in the URL (a saved bookmark / Chrome-remembered basic-auth:
// https://user:pass@host/…), the browser poisons document.baseURI, so any
// relative or root-relative fetch('/api/…') throws "Request cannot be constructed
// from a URL that includes credentials" and callers silently fall to an empty
// state. Resolve every non-absolute request URL against the credential-free
// location instead of baseURI. Placed first so it wraps window.fetch before any
// app script runs. Ref: creds-in-url-fetch-guard-fleet-pattern.
(function () {
  var _fetch = window.fetch.bind(window);
  var cleanBase = function () { return location.origin + location.pathname; };
  window.fetch = function (input, init) {
    try {
      if (typeof input === 'string' && !/^[a-z]+:\/\//i.test(input) && input.indexOf('//') !== 0) {
        input = new URL(input, cleanBase()).href;
      } else if (input instanceof Request && !/^[a-z]+:\/\//i.test(input.url)) {
        input = new Request(new URL(input.url, cleanBase()).href, input);
      }
    } catch (_) { /* fall through to native */ }
    return _fetch(input, init);
  };
})();
</script>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Live deal import — CRE · Abrams</title>
<style>
 :root{--navy:#0E2350;--gold:#E8A81C;--tmpl:30px 96px 90px 84px 1fr 140px 150px} *{box-sizing:border-box}
 body{margin:0;font-family:Inter,-apple-system,Helvetica,Arial,sans-serif;background:#070a14;color:#eaf0ff}
 #nav{position:fixed;top:0;bottom:0;left:0;width:250px;z-index:30;background:#0b1120;border-right:1px solid #1c2540;color:#cfe0ff;overflow-y:auto;transition:width .18s}
 #nav.right{left:auto;right:0;border-right:none;border-left:1px solid #1c2540} #nav.collapsed{width:36px;overflow:hidden}
 #nav .nh{display:flex;justify-content:space-between;align-items:center;padding:16px 14px 4px;font-size:13px;letter-spacing:2px;color:#7fd7ff;font-weight:800}
 #nav .nh button{background:#152037;color:#9fb4e6;border:1px solid #24304f;border-radius:6px;cursor:pointer;font-size:12px;padding:2px 8px;margin-left:5px}
 #nav .hint{padding:0 14px 8px;font-size:10px;color:#5a6b92;letter-spacing:1px}
 #nav .field{padding:9px 14px 4px;font-size:11px;color:#8aa;text-transform:uppercase;letter-spacing:1px;border-top:1px solid #131c33;display:flex;justify-content:space-between}
 #nav .field .srt{cursor:pointer;color:#5a6b92} #nav .field .srt:hover{color:#7fd7ff}
 #nav .val{display:flex;justify-content:space-between;align-items:center;padding:6px 14px;cursor:pointer;font-size:13px}
 #nav .val:hover{background:#131c33} #nav .val.on{background:#16263f;color:#7fd7ff;font-weight:600}
 #nav .val .b{background:#0e2a3a;color:#7fd7ff;border-radius:10px;padding:1px 9px;font-size:11px}
 #nav .col{display:flex;justify-content:space-between;align-items:center;padding:6px 14px;cursor:pointer;font-size:13px;color:#9fb4e6}
 #nav .col:hover{background:#131c33} #nav .col .sw{width:30px;height:16px;border-radius:9px;background:#24304f;position:relative;transition:.15s}
 #nav .col.on .sw{background:#2ee6a0} #nav .col .sw::after{content:"";position:absolute;top:2px;left:2px;width:12px;height:12px;border-radius:50%;background:#fff;transition:.15s} #nav .col.on .sw::after{left:16px}
 #nav.collapsed .nh span:first-child,#nav.collapsed .hint,#nav.collapsed #navfields{display:none}
 body.navL .main{margin-left:250px} body.navR .main{margin-right:250px} body.navLc .main{margin-left:36px} body.navRc .main{margin-right:36px}
 .main{transition:margin .18s}
 header{background:#0E2350;padding:14px 22px;display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;position:sticky;top:0;z-index:10}
 header h1{margin:0;font-size:18px}header h1 small{display:block;color:#E8A81C;font-size:11px;letter-spacing:2px}
 #stat{text-align:right;font-variant-numeric:tabular-nums} #stat b{font-size:24px;color:#7fd7ff}
 .filt{padding:8px 22px;background:#0b1120;border-bottom:1px solid #1c2540;display:flex;gap:12px;align-items:center;font-size:12px;color:#9fb4e6;position:sticky;top:52px;z-index:9}
 .filt input{background:#111a30;border:1px solid #24304f;color:#cfe0ff;border-radius:6px;padding:5px 9px} .filt .clr{cursor:pointer;color:#7fd7ff} .filt .live{margin-left:auto;color:#2ee6a0}
 .cols{grid-template-columns:var(--tmpl)}
 .hd{display:grid;gap:10px;padding:8px 12px;color:#5a6b92;font-size:11px;text-transform:uppercase;letter-spacing:1px;border-bottom:1px solid #1c2540;position:sticky;top:86px;background:#070a14;z-index:8}
 .hd span[data-k]{cursor:pointer} .hd span[data-k]:hover{color:#7fd7ff} .hd span.act{color:#7fd7ff}
 .rows{padding:0 10px 70px}
 .row{display:grid;gap:10px;align-items:center;padding:9px 12px;border-bottom:1px solid #101830;font-size:13px;animation:in .3s ease;cursor:pointer}
 @keyframes in{from{opacity:0;transform:translateY(-6px)}to{opacity:1;transform:none}}
 .row:hover{background:#0d1424} .row.sel{background:#132a1f} .row.exp{background:#101a30}
 .cb{width:16px;height:16px;border:1px solid #3a4a78;border-radius:4px;display:inline-block;position:relative} .row.sel .cb{background:#2ee6a0;border-color:#2ee6a0}
 .car{color:#5a6b92;font-size:10px;transition:.15s} .row.exp .car{transform:rotate(90deg);color:#7fd7ff}
 .date{color:#8aa;font-size:12px} .price{font-weight:800;color:#7fd7ff} .chip{font-size:10px;font-weight:800;padding:2px 7px;border-radius:5px;color:#070a14;text-align:center}
 .ttl a{color:#eaf0ff;text-decoration:none} .ttl a:hover{color:#7fd7ff} .mk{color:#9fb4e6;font-size:12px} .src{color:#E8A81C;font-size:12px}
 .detail{padding:12px 46px 16px;border-bottom:1px solid #101830;background:#0a1120;animation:in .2s ease}
 .detail .dt{font-size:15px;color:#eaf0ff;font-weight:600;margin-bottom:8px;line-height:1.4}
 .detail .kv{display:flex;flex-wrap:wrap;gap:8px 22px;font-size:12px;color:#9fb4e6;margin-bottom:10px}
 .detail .kv b{color:#7fd7ff;font-weight:600} .detail .kv .g{color:#2ee6a0}
 .detail a.open{display:inline-block;background:#152a3f;color:#7fd7ff;border:1px solid #24304f;border-radius:6px;padding:6px 12px;text-decoration:none;font-size:12px;font-weight:600}
 #selbar{position:fixed;bottom:0;left:0;right:0;z-index:20;background:#0e2a1f;border-top:1px solid #1f6b45;padding:10px 22px;display:none;justify-content:space-between;align-items:center;font-size:13px}
 #selbar.on{display:flex} #selbar button{background:#2ee6a0;color:#04140c;border:none;border-radius:6px;padding:7px 12px;font-weight:700;cursor:pointer;margin-left:8px} #selbar .g{background:#1a3a2a;color:#9fe6c4}
</style></head><body>
<div id="nav"><div class="nh"><span>NAV AGENT</span><span><button id="navdock" title="dock left/right">⇄</button><button id="navcol" title="collapse">‹</button></span></div>
 <div class="hint">CLICK A VALUE TO FILTER · SORT ANY FIELD · TOGGLE COLUMNS</div><div id="navfields"></div></div>
<div class="main">
 <header><h1>LIVE DEAL IMPORT<small>ABRAMS · CRE CLOSED DEALS — DEEDS/DTT + NEWS</small></h1>
  <div id="stat"><b id="cnt">0</b> records<br><span style="color:#8aa;font-size:12px" id="vol">$0</span></div></header>
 <div class="filt"><input id="q" placeholder="search…" size="20"><span class="clr" id="clr">clear filters</span>
   <span class="clr" id="selall">select all in view</span><span id="fstat"></span><span class="live">● live</span></div>
 <div class="hd cols" id="hd"></div>
 <div class="rows" id="rows"></div>
</div>
<div id="selbar"><span><b id="seln">0</b> selected · <span id="selv" class="g" style="padding:2px 8px;border-radius:5px"></span></span>
 <span><button id="selcopy">Copy</button><button id="selcsv">Download CSV</button><button class="g" id="selclr">Clear</button></span></div>
<script>
const TC={Sale:'#2ee6a0',Listing:'#38bdf8',Loan:'#f0b429',Lease:'#5b8bff',Development:'#b061ff',News:'#94a3b8',Filing:'#8aa0c0',Post:'#E8A81C',CRE:'#8aa0c0'};
const money=n=>!n?'':n>=1e9?'$'+(n/1e9).toFixed(1)+'B':n>=1e6?'$'+Math.round(n/1e6)+'M':'$'+Math.round(n/1e3)+'K';
const esc=s=>(s||'').replace(/[<>&]/g,c=>({'<':'&lt;','>':'&gt;','&':'&amp;'}[c]));
const dt=s=>{if(!s)return'';const d=new Date(s);return isNaN(d)?(''+s).slice(0,11):d.toLocaleDateString('en-US',{month:'short',day:'numeric',year:'2-digit'});};
const dtFull=s=>{const d=new Date(s);return isNaN(d)?(s||'—'):d.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});};
const yr=s=>{const d=new Date(s);return isNaN(d)?'—':''+d.getFullYear();};
const band=x=>{const p=x.price||0;return !p?'No price':p<1e7?'< $10M':p<5e7?'$10–50M':p<1e8?'$50–100M':p<5e8?'$100–500M':'$500M+';};
const keyOf=x=>`${x.price}|${(x.title||'').slice(0,40)}`;
let ALL=[],VIEW=[],F={type:'',market:'',source:'',year:'',band:''},sortK='date',dir=-1,shown=0;const PAGE=60,CAPVIEW=1500;
const SEL=new Map();      // key -> record (selection persists across filter/scroll)
const EXP=new Set();      // keys of expanded rows
// FULL Nav Agent scope: every field is filterable + sortable
const FIELDS=[['type','TYPE'],['band','PRICE'],['market','MARKET'],['source','SOURCE'],['year','YEAR']];
const fval=(x,k)=>k==='year'?yr(x.date):k==='band'?band(x):(x[k]||'—');
// toggleable columns (checkbox is fixed). w = grid track width.
const COLS=[['date','Date','96px'],['price','Price','90px'],['type','Type','84px'],['title','Deal','1fr'],['market','Market','140px'],['source','Source','150px']];
let VIS=JSON.parse(localStorage.liveCols||'null')||COLS.reduce((o,c)=>(o[c[0]]=true,o),{});
function tmpl(){return '30px '+COLS.filter(c=>VIS[c[0]]).map(c=>c[2]).join(' ');}
function applyCols(){document.documentElement.style.setProperty('--tmpl',tmpl());
 document.getElementById('hd').innerHTML='<span></span>'+COLS.filter(c=>VIS[c[0]]).map(c=>`<span data-k="${c[0]}" class="${sortK===c[0]?'act':''}">${c[1]}</span>`).join('');
 document.querySelectorAll('.hd span[data-k]').forEach(s=>s.onclick=()=>{const k=s.dataset.k;if(sortK===k)dir*=-1;else{sortK=k;dir=(k==='title'||k==='market'||k==='source')?1:-1;}apply();});}
function buildNav(){let h='';for(const[k,lbl]of FIELDS){const c={};ALL.forEach(x=>{const v=fval(x,k);c[v]=(c[v]||0)+1;});
  h+=`<div class="field"><span>${lbl}</span><span class="srt" data-s="${k==='band'?'price':k}">sort ⇅</span></div>`;
  h+=Object.entries(c).sort((a,b)=>b[1]-a[1]).slice(0,14).map(([v,n])=>`<div class="val${F[k]===v?' on':''}" data-k="${k}" data-v="${esc(v)}"><span>${esc(v).slice(0,22)}</span><span class="b">${n}</span></div>`).join('');}
 // COLUMNS show/hide (full nav-agent scope)
 h+=`<div class="field"><span>COLUMNS</span><span class="srt" style="cursor:default;color:#5a6b92">show / hide</span></div>`;
 h+=COLS.map(c=>`<div class="col${VIS[c[0]]?' on':''}" data-col="${c[0]}"><span>${c[1]}</span><span class="sw"></span></div>`).join('');
 const nf=document.getElementById('navfields');nf.innerHTML=h;
 nf.querySelectorAll('.val').forEach(el=>el.onclick=()=>{const k=el.dataset.k;F[k]=F[k]===el.dataset.v?'':el.dataset.v;buildNav();apply();});
 nf.querySelectorAll('.srt').forEach(el=>el.onclick=()=>{const k=el.dataset.s;if(sortK===k)dir*=-1;else{sortK=k;dir=-1;}apply();});
 nf.querySelectorAll('.col').forEach(el=>el.onclick=()=>{const c=el.dataset.col;VIS[c]=!VIS[c];if(!COLS.some(x=>VIS[x[0]]))VIS[c]=true;localStorage.liveCols=JSON.stringify(VIS);buildNav();applyCols();apply();});}
function applyDock(){const nav=document.getElementById('nav'),side=localStorage.navSide||'L',col=localStorage.navCol==='1';
 nav.classList.toggle('right',side==='R');nav.classList.toggle('collapsed',col);
 document.body.className=(side==='L'?(col?'navLc':'navL'):(col?'navRc':'navR'));document.getElementById('navcol').textContent=col?'›':'‹';}
document.getElementById('navdock').onclick=()=>{localStorage.navSide=localStorage.navSide==='R'?'L':'R';applyDock();};
document.getElementById('navcol').onclick=()=>{localStorage.navCol=localStorage.navCol==='1'?'0':'1';applyDock();};
document.getElementById('clr').onclick=()=>{F={type:'',market:'',source:'',year:'',band:''};document.getElementById('q').value='';buildNav();apply();};
function cell(x,k){const c=TC[x.type]||'#8aa0c0';
 if(k==='date')return `<span class="date">${dt(x.date)}</span>`;
 if(k==='price')return `<span class="price">${x.price_label||money(x.price)||'—'}</span>`;
 if(k==='type')return `<span class="chip" style="background:${c}">${(x.type||'CRE').toUpperCase()}</span>`;
 if(k==='title'){const t=x.link?`<a href="${x.link}" target="_blank" rel="noopener" onclick="event.stopPropagation()">${esc(x.title)}</a>`:esc(x.title);return `<span class="ttl">${t}</span>`;}
 if(k==='market')return `<span class="mk">${esc(x.market||'')}</span>`;
 if(k==='source')return `<span class="src">${esc(x.source||'')}</span>`;return '';}
function rowHTML(x){const k=keyOf(x);const cells=COLS.filter(c=>VIS[c[0]]).map(c=>cell(x,c[0])).join('');
 let h=`<div class="row cols${SEL.has(k)?' sel':''}${EXP.has(k)?' exp':''}" data-k="${esc(k)}"><span class="cb"><span class="car">▸</span></span>${cells}</div>`;
 if(EXP.has(k))h+=detailHTML(x);return h;}
function detailHTML(x){const c=TC[x.type]||'#8aa0c0';
 return `<div class="detail" data-dk="${esc(keyOf(x))}"><div class="dt">${esc(x.title||'')}</div>
  <div class="kv"><span><b>${x.price_label||money(x.price)||'—'}</b></span>
   <span style="color:${c};font-weight:700">${(x.type||'CRE').toUpperCase()}</span>
   <span>📍 ${esc(x.market||'—')}</span><span>🗞 ${esc(x.source||'—')}</span><span>🕓 ${esc(dtFull(x.date))}</span>
   ${x.buyer?`<span>Buyer: <b class="g">${esc(x.buyer)}</b></span>`:''}${x.seller?`<span>Seller: <b class="g">${esc(x.seller)}</b></span>`:''}</div>
  ${x.link?`<a class="open" href="${x.link}" target="_blank" rel="noopener">Open source article ↗</a>`:'<span style="color:#5a6b92;font-size:12px">public-record entry — no article link</span>'}</div>`;}
function cmp(a,b){if(sortK==='date')return((new Date(a.date)||0)-(new Date(b.date)||0))*dir;
 if(sortK==='price')return((a.price||0)-(b.price||0))*dir; if(sortK==='year')return (yr(a.date)-yr(b.date))*dir||((b.price||0)-(a.price||0));
 return (''+(a[sortK]||'')).localeCompare(''+(b[sortK]||''))*dir;}
function apply(){const q=document.getElementById('q').value.toLowerCase();
 VIEW=ALL.filter(x=>(!F.type||x.type===F.type)&&(!F.market||(x.market||'—')===F.market)&&(!F.source||(x.source||'—')===F.source)&&(!F.year||yr(x.date)===F.year)&&(!F.band||band(x)===F.band)&&[x.title,x.source,x.market,x.type,x.price_label,x.buyer,x.seller,dt(x.date),yr(x.date),x.date].join(' ').toLowerCase().includes(q));
 VIEW.sort(cmp);applyCols();
 document.getElementById('vol').textContent=money(VIEW.reduce((s,x)=>s+(x.price||0),0))+' total';
 const fil=Object.values(F).filter(Boolean).join(' · ');document.getElementById('fstat').textContent=`${VIEW.length.toLocaleString()} of ${ALL.length.toLocaleString()}${fil?' · '+fil:''}`;
 shown=0;document.getElementById('rows').innerHTML='';stream();}
function stream(){const rows=document.getElementById('rows');const end=Math.min(shown+PAGE,Math.min(VIEW.length,CAPVIEW));
 let html='';for(let i=shown;i<end;i++)html+=rowHTML(VIEW[i]);rows.insertAdjacentHTML('beforeend',html);
 document.getElementById('cnt').textContent=end.toLocaleString();shown=end;
 if(shown<Math.min(VIEW.length,CAPVIEW))setTimeout(stream,45);
 else if(VIEW.length>CAPVIEW)rows.insertAdjacentHTML('beforeend',`<div style="padding:14px;color:#5a6b92">+ ${(VIEW.length-CAPVIEW).toLocaleString()} more — filter with the Nav Agent to narrow</div>`);}
// click: checkbox -> select; anywhere else on the row -> expand/collapse the chip
document.getElementById('rows').addEventListener('click',e=>{const row=e.target.closest('.row');if(!row)return;const k=row.dataset.k;
 const rec=VIEW.find(x=>keyOf(x)===k)||ALL.find(x=>keyOf(x)===k);
 if(e.target.closest('.cb')){if(SEL.has(k)){SEL.delete(k);row.classList.remove('sel');}else{SEL.set(k,rec);row.classList.add('sel');}selbar();return;}
 // expand toggle
 if(EXP.has(k)){EXP.delete(k);row.classList.remove('exp');const d=row.nextElementSibling;if(d&&d.classList.contains('detail'))d.remove();}
 else{EXP.add(k);row.classList.add('exp');row.insertAdjacentHTML('afterend',detailHTML(rec));}});
document.getElementById('selall').onclick=()=>{VIEW.slice(0,CAPVIEW).forEach(x=>SEL.set(keyOf(x),x));document.querySelectorAll('.row').forEach(r=>r.classList.add('sel'));selbar();};
document.getElementById('selclr').onclick=()=>{SEL.clear();document.querySelectorAll('.row.sel').forEach(r=>r.classList.remove('sel'));selbar();};
function selbar(){const n=SEL.size;document.getElementById('selbar').classList.toggle('on',n>0);document.getElementById('seln').textContent=n;
 document.getElementById('selv').textContent=money([...SEL.values()].reduce((s,x)=>s+(x.price||0),0))+' total';}
function selRows(){return [...SEL.values()];}
function toCSV(rows){const cols=['date','price','type','title','market','source','link'];
 const esc=v=>{v=v==null?'':''+v;return /[",\n]/.test(v)?'"'+v.replace(/"/g,'""')+'"':v;};
 return [cols.join(','),...rows.map(r=>cols.map(c=>esc(c==='price'?(r.price_label||money(r.price)):r[c])).join(','))].join('\n');}
document.getElementById('selcsv').onclick=()=>{const b=new Blob([toCSV(selRows())],{type:'text/csv'});const a=document.createElement('a');a.href=URL.createObjectURL(b);a.download='selected-deals.csv';a.click();};
document.getElementById('selcopy').onclick=()=>{navigator.clipboard.writeText(toCSV(selRows())).then(()=>{document.getElementById('selcopy').textContent='Copied!';setTimeout(()=>document.getElementById('selcopy').textContent='Copy',1200);});};
document.getElementById('q').oninput=apply;
function load(){fetch('flyers.json?t='+Date.now()).then(r=>r.json()).then(j=>{const grew=j.length!==ALL.length;ALL=j;if(grew){buildNav();apply();}}).catch(()=>document.getElementById('fstat').textContent='load error');}
applyDock();buildNav();load();setInterval(load,20000);
</script></body></html>