← back to Rentv 2026
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
M public/services.htmlM server.js
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=>({'&':'&','<':'<','>':'>','"':'"'}[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 2026
·
fix(location): sanitizeLocation maps a full state NAME to it 3b60464d →