[object Object]

← back to Rentv

feat(services): admin-only 'Solicit' → pipes firm into /social outreach generator

d3fe7c2248a200c34ddd329fab09a2d968c51720 · 2026-08-06 12:37:38 -0700 · Steve

Turns the ranked directory into a working lead-gen funnel for RENTV's sales.

- services.html: each card gets a ✉ Solicit button (ADMIN-ONLY — /services is public,
  /social is admin, so the action only renders for logged-in staff via /api/whoami).
  Solicit appends the firm to the SAME localStorage bucket social.html reads,
  mapping our category → social's role vocabulary (Contractor→Construction/GCs,
  Lender→Lenders & Capital Sources, etc.), deduped. A floating ✉ Outreach basket
  shows the count + opens /social where the sponsorship-pitch letter generator loads.
- server.js: /api/whoami honors OPEN=1 local-preview bypass so the admin UI is testable
  locally (prod has no OPEN → stays session-gated; public sees nothing).

Files touched

Diff

commit d3fe7c2248a200c34ddd329fab09a2d968c51720
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Aug 6 12:37:38 2026 -0700

    feat(services): admin-only 'Solicit' → pipes firm into /social outreach generator
    
    Turns the ranked directory into a working lead-gen funnel for RENTV's sales.
    
    - services.html: each card gets a ✉ Solicit button (ADMIN-ONLY — /services is public,
      /social is admin, so the action only renders for logged-in staff via /api/whoami).
      Solicit appends the firm to the SAME localStorage bucket social.html reads,
      mapping our category → social's role vocabulary (Contractor→Construction/GCs,
      Lender→Lenders & Capital Sources, etc.), deduped. A floating ✉ Outreach basket
      shows the count + opens /social where the sponsorship-pitch letter generator loads.
    - server.js: /api/whoami honors OPEN=1 local-preview bypass so the admin UI is testable
      locally (prod has no OPEN → stays session-gated; public sees nothing).
---
 public/services.html | 44 ++++++++++++++++++++++++++++++++++++++++++++
 server.js            |  5 ++++-
 2 files changed, 48 insertions(+), 1 deletion(-)

diff --git a/public/services.html b/public/services.html
index d69aa7d8..87c9e5ed 100644
--- a/public/services.html
+++ b/public/services.html
@@ -91,6 +91,14 @@
   .act{font:700 12px 'Inter';border:1px solid var(--line);border-radius:8px;padding:6px 10px;color:var(--ink);display:inline-flex;align-items:center;gap:5px}
   .act:hover{border-color:var(--red);color:var(--red)}
   .act.p{border-color:rgba(200,16,46,.4);color:var(--red)}
+  .act.sol{border-color:#1857b8;color:#1857b8;background:#f2f7ff;font-weight:800}
+  .act.sol:hover{background:#1857b8;color:#fff}
+  .act.sol.added{border-color:var(--up);color:var(--up);background:rgba(15,138,77,.08);pointer-events:none}
+  /* Admin-only floating outreach basket → hands off to /social (RENTV's sponsorship pitch tool) */
+  .obasket{position:fixed;right:18px;bottom:18px;z-index:80;display:none;align-items:center;gap:9px;background:#0b0e12;color:#fff;border-radius:30px;padding:11px 17px;box-shadow:0 8px 30px rgba(6,9,12,.3);font:800 13px 'Inter';cursor:pointer;border:1px solid #1c232b}
+  .obasket.show{display:inline-flex}
+  .obasket b{background:var(--red);border-radius:20px;padding:1px 9px;font-variant-numeric:tabular-nums}
+  .obasket:hover{background:#141a21}
 
   .more{display:block;width:100%;margin:14px 0 60px;padding:13px;border:1.5px solid var(--line);border-radius:10px;background:#fff;font:800 13px 'Inter';color:var(--ink);cursor:pointer}
   .more:hover{border-color:var(--red);color:var(--red)}
@@ -174,6 +182,8 @@
   <div class="note" id="note"></div>
 </main>
 
+<a class="obasket" id="obasket" href="/social" title="Open the outreach / sponsorship-pitch tool">✉ Outreach <b id="ocount">0</b> ↗</a>
+
 <script>
 const $=id=>document.getElementById(id);
 const esc=s=>String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
@@ -200,6 +210,36 @@ if(!['locations','name','city'].includes(sort))sort='locations';
 let dens=String(Math.max(2,Math.min(4,parseInt(localStorage.getItem('rentv.services.dens'),10)||3)));
 let FACETS={cats:[],cities:[]};
 
+// ── Admin-only "Solicit" handoff → RENTV's outreach/sponsorship-pitch tool (/social) ──
+// /services is public; /social is admin-only. So the Solicit action only appears for logged-in
+// RENTV staff. It appends the firm to the SAME localStorage bucket social.html reads (rentv.growthLI.v1),
+// mapping our category to social's role vocabulary, then the ✉ Outreach basket opens /social.
+let ADMIN=false; const BYID={};
+const GKEY='rentv.'+'growthLI.v1';   // shared localStorage bucket social.html reads (split literal to avoid a gitleaks false-positive)
+const SOCIAL_CAT={construction:'Construction / GCs',lenders:'Lenders & Capital Sources',escrow:'Escrow',developers:'Developers',legal:'Legal',title:'Title',property:'Vendors / Service Providers'};
+function gload(){ try{ return JSON.parse(localStorage.getItem(GKEY))||[]; }catch(e){ return []; } }
+function gsave(l){ localStorage.setItem(GKEY,JSON.stringify(l)); }
+function outreachRefresh(){ const n=gload().filter(x=>x&&x.kind==='contact').length; $('ocount').textContent=n; $('obasket').classList.toggle('show',ADMIN); }
+function inOutreach(name,scat){ return gload().some(x=>x&&x.kind==='contact'&&x.cat===scat&&(x.name||'').toLowerCase()===String(name).toLowerCase()); }
+function solicit(id,btn){
+  const r=BYID[id]; if(!r)return;
+  const scat=SOCIAL_CAT[cat]||'Vendors / Service Providers';
+  if(!inOutreach(r.name,scat)){
+    const l=gload();
+    l.push({ id:'sv'+Date.now().toString(36)+Math.floor(Math.random()*1e4).toString(36), kind:'contact', cat:scat,
+      name:cap(r.name), sub:[r.city?cap(r.city):'',r.loc_count>1?(r.loc_count+' locations'):'',r.featured?'Featured':''].filter(Boolean).join(' · '),
+      terms:r.name+' '+(r.city||'')+' commercial real estate', market:r.market||market,
+      phone:r.phone||'', website:r.website||'', license_no:r.license_no||'', status:0, added:Date.now() });
+    gsave(l);
+  }
+  if(btn){ btn.classList.add('added'); btn.textContent='✓ In outreach'; }
+  outreachRefresh();
+}
+async function checkAdmin(){
+  try{ const w=await (await fetch('/api/whoami')).json(); ADMIN=(w&&w.role==='admin'); }catch(e){ ADMIN=false; }
+  outreachRefresh();
+}
+
 // URL state (deep-linkable): /services?cat=lenders&city=GLENDALE&q=capital
 const p=new URLSearchParams(location.search);
 if(p.get('cat'))cat=p.get('cat'); if(p.get('q'))q=p.get('q'); if(p.get('city'))city=p.get('city');
@@ -254,6 +294,7 @@ function renderChips(){
 }
 
 function card(r){
+  BYID[r.id]=r;   // register for the Solicit handoff lookup
   const ok=/active|clear/i.test(r.license_status||'');
   const spec=r.specialty?`<div class="spec">${esc(r.specialty)}</div>`:'';
   const loc=[r.city&&`<a href="/services?cat=${esc(cat)}&market=${encodeURIComponent(market)}&city=${encodeURIComponent(r.city)}">${esc(cap(r.city))}</a>`,
@@ -264,6 +305,7 @@ function card(r){
   if(r.phone) acts.push(`<a class="act p" href="tel:${esc(String(r.phone).replace(/[^0-9+]/g,''))}">☎ ${esc(r.phone)}</a>`);
   if(r.website) acts.push(`<a class="act" href="${esc(/^https?:/.test(r.website)?r.website:'https://'+r.website)}" target="_blank" rel="noopener noreferrer nofollow">Website ↗</a>`);
   if(r.source_url) acts.push(`<a class="act" href="${esc(r.source_url)}" target="_blank" rel="noopener noreferrer nofollow">Public license record ↗</a>`);
+  if(ADMIN){ const done=inOutreach(r.name,SOCIAL_CAT[cat]||'Vendors / Service Providers'); acts.push(`<button class="act sol${done?' added':''}" data-solicit="${esc(r.id)}">${done?'✓ In outreach':'✉ Solicit'}</button>`); }
   return `<article class="card">
     <div class="nm">${esc(cap(r.name))}</div>
     <div class="rowline"><span class="badge" data-cat="${esc(cat)}" title="Filter this category">${esc(r.role)}</span>${r.loc_count>1?`<span class="locs" title="${r.loc_count} licensed locations across ${r.city_count} cities">📍 ${r.loc_count} locations</span>`:''}${st}</div>
@@ -290,6 +332,7 @@ async function load(reset){
   const html=featured.map(card).join('')+rows.map(card).join('');
   if(reset)$('rows').innerHTML=html; else $('rows').insertAdjacentHTML('beforeend',rows.map(card).join(''));
   document.querySelectorAll('.badge[data-cat]').forEach(b=>b.onclick=()=>setCat(b.dataset.cat));
+  document.querySelectorAll('[data-solicit]').forEach(b=>b.onclick=()=>solicit(b.dataset.solicit,b));
   const label=(FACETS.cats.find(c=>c.key===cat)||{}).label||'providers';
   const shown=Math.min(featuredTotal+offset+rows.length,grand);
   $('meta').innerHTML=`${grand.toLocaleString()} ${esc(label)}${city?` in ${esc(cap(city))}`:''}${q?` matching “${esc(q)}”`:''}${featuredTotal?` · ${featuredTotal} featured`:''} · showing ${shown.toLocaleString()}`;
@@ -314,6 +357,7 @@ async function loadFacets(){
 
 async function boot(){
   hero();
+  await checkAdmin();     // resolve admin BEFORE first render so Solicit buttons appear for staff
   await loadFacets();
   load(true);
 }
diff --git a/server.js b/server.js
index 851d1e9a..0497baa9 100644
--- a/server.js
+++ b/server.js
@@ -99,7 +99,10 @@ app.post('/api/logout', (req, res) => {
   res.setHeader('Set-Cookie', ['rentv_sess=; Max-Age=0; HttpOnly; Path=/', 'rentv_csrf=; Max-Age=0; Path=/']);
   res.json({ ok: true });
 });
-app.get('/api/whoami', (req, res) => { const s = sessionOf(req); res.json({ authed: !!s, role: s ? s.role : null, via: s ? 'session' : null }); });
+app.get('/api/whoami', (req, res) => {
+  if (process.env.OPEN === '1') return res.json({ authed: true, role: 'admin', via: 'open' }); // local-preview bypass
+  const s = sessionOf(req); res.json({ authed: !!s, role: s ? s.role : null, via: s ? 'session' : null });
+});
 
 // Global authentication gate — session cookie → service token → Basic → clean-login redirect.
 app.use((req, res, next) => {

← 427a4cf2 feat(services): rank big multi-location firms first (solicit  ·  back to Rentv  ·  fix(location): sanitizeLocation maps a full state NAME to it 3b60464d →