← back to Rentv
feat(services): CSV export + per-view SEO/JSON-LD + provider detail pages
8e0541c9f2680470c51bed7e03bacf04254aca81 · 2026-08-06 16:01:19 -0700 · Steve
Three sales/discoverability features:
1. CSV export — /api/services/export.csv streams the current category/metro/filter as a
deduped one-row-per-firm prospect list (biggest-first, featured included), admin
'⤓ Export CSV' button; Content-Disposition attachment. ~1,088 lender rows/click.
2. SEO — /services now server-injects per-metro/category <title>, meta description,
canonical, OG tags, and JSON-LD (CollectionPage + BreadcrumbList + ItemList of
featured firms) via a sendPage(transform) hook; /services + 6 category URLs added
to sitemap.xml. Crawlable for 'LA commercial title companies' etc.
3. Provider detail pages — /services/:id + /api/services/provider/:id: full profile
with the firm's complete branch-license roster (the value-add over the card),
humanized specialties, official public-record links, 'Pairs well with' deal-team
cross-links, and admin Solicit+note. Card firm names now link to the detail page
(honors the all-data-points-href rule). Handles both registry (numeric) and
featured (f-*) ids.
Verified: export headers+attachment, per-cat <title>/JSON-LD types, detail page renders
37-branch roster + pairs, card names deep-link.
Files touched
A public/services-detail.htmlM public/services.htmlM server.js
Diff
commit 8e0541c9f2680470c51bed7e03bacf04254aca81
Author: Steve <steve@designerwallcoverings.com>
Date: Thu Aug 6 16:01:19 2026 -0700
feat(services): CSV export + per-view SEO/JSON-LD + provider detail pages
Three sales/discoverability features:
1. CSV export — /api/services/export.csv streams the current category/metro/filter as a
deduped one-row-per-firm prospect list (biggest-first, featured included), admin
'⤓ Export CSV' button; Content-Disposition attachment. ~1,088 lender rows/click.
2. SEO — /services now server-injects per-metro/category <title>, meta description,
canonical, OG tags, and JSON-LD (CollectionPage + BreadcrumbList + ItemList of
featured firms) via a sendPage(transform) hook; /services + 6 category URLs added
to sitemap.xml. Crawlable for 'LA commercial title companies' etc.
3. Provider detail pages — /services/:id + /api/services/provider/:id: full profile
with the firm's complete branch-license roster (the value-add over the card),
humanized specialties, official public-record links, 'Pairs well with' deal-team
cross-links, and admin Solicit+note. Card firm names now link to the detail page
(honors the all-data-points-href rule). Handles both registry (numeric) and
featured (f-*) ids.
Verified: export headers+attachment, per-cat <title>/JSON-LD types, detail page renders
37-branch roster + pairs, card names deep-link.
---
public/services-detail.html | 165 ++++++++++++++++++++++++++++++++++++++++++++
public/services.html | 8 ++-
server.js | 117 ++++++++++++++++++++++++++++++-
3 files changed, 285 insertions(+), 5 deletions(-)
diff --git a/public/services-detail.html b/public/services-detail.html
new file mode 100644
index 00000000..d4553040
--- /dev/null
+++ b/public/services-detail.html
@@ -0,0 +1,165 @@
+<!doctype html><html lang="en"><head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Provider — CRE Services · RENTV.com</title>
+<meta name="description" content="Commercial real estate service-provider profile on RENTV.com — licensed firm details, branch locations, and public license record.">
+<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,700;0,800;1,600&family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
+<style>
+ :root{--red:#c8102e;--ink:#14171a;--sub:#5b636b;--line:#e4e7ea;--wash:#f5f6f8;--up:#0f8a4d}
+ *{box-sizing:border-box;margin:0}
+ body{font-family:'Inter',system-ui,sans-serif;color:var(--ink);background:#fbfbfc;line-height:1.45}
+ a{color:inherit;text-decoration:none}
+ .top{position:sticky;top:0;z-index:900;display:flex;align-items:center;justify-content:space-between;height:54px;padding:0 16px;background:#fff;border-bottom:1px solid var(--line)}
+ .back{display:inline-flex;align-items:center;gap:8px;font-size:13px;font-weight:800;color:var(--sub);border:0;background:none;cursor:pointer}
+ .back:hover{color:var(--red)}
+ .brand{position:absolute;left:50%;transform:translateX(-50%);font-family:'Playfair Display',Georgia,serif;font-weight:800;font-size:25px;letter-spacing:.02em}.brand span{color:var(--red)}
+ .wrap{max-width:900px;margin:0 auto;padding:26px 22px 60px;width:100%}
+ .crumb{font-size:12px;color:var(--sub);font-weight:600;margin-bottom:12px}
+ .crumb a:hover{color:var(--red);text-decoration:underline}
+ .prof{border:1px solid var(--line);border-radius:14px;background:#fff;padding:22px 22px}
+ .prof h1{font-family:'Playfair Display',serif;font-size:clamp(26px,3.2vw,38px);font-weight:700;letter-spacing:-.01em}
+ .row1{display:flex;align-items:center;gap:9px;flex-wrap:wrap;margin-top:10px}
+ .badge{display:inline-block;font-size:11px;font-weight:800;color:#fff;background:var(--red);border-radius:20px;padding:3px 10px}
+ .st{display:inline-block;font-size:10.5px;font-weight:800;letter-spacing:.05em;text-transform:uppercase;padding:2px 9px;border-radius:20px;background:var(--wash);color:#4a4f55}
+ .st.ok{background:rgba(15,138,77,.12);color:var(--up)}
+ .st.feat{background:#fbf1d8;color:#8a6d1a;border:1px solid #ecdca4}
+ .st.spon{background:#eef1f4;color:#4a4f55;border:1px solid #d5dae0}
+ .locs{display:inline-flex;align-items:center;gap:4px;font-size:11px;font-weight:800;padding:2px 9px;border-radius:20px;background:#eaf2ff;color:#1857b8;border:1px solid #cadcf7}
+ .facts{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:14px;margin-top:20px}
+ .fact .k{font-size:10.5px;font-weight:800;letter-spacing:.1em;text-transform:uppercase;color:var(--sub)}
+ .fact .v{font-size:15px;font-weight:600;margin-top:3px;overflow-wrap:anywhere}
+ .fact .v a{color:#1857b8}.fact .v a:hover{text-decoration:underline}
+ .acts{display:flex;gap:9px;flex-wrap:wrap;margin-top:20px}
+ .act{font:800 13px 'Inter';border:1.5px solid var(--line);border-radius:9px;padding:9px 13px;color:var(--ink);cursor:pointer;display:inline-flex;align-items:center;gap:6px}
+ .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}
+ .act.sol.added{border-color:var(--up);color:var(--up);background:rgba(15,138,77,.08);pointer-events:none}
+ .notein{margin-top:12px;width:100%;max-width:520px;font:500 13px 'Inter';padding:9px 11px;border:1px dashed #b9c6da;border-radius:9px;background:#fafcff;outline:0}
+ .notein:focus{border-color:#1857b8;border-style:solid}
+ .sec{margin-top:26px}
+ .sec h2{font-family:'Playfair Display',serif;font-size:20px;font-weight:700;margin-bottom:4px}
+ .sec .sub{color:var(--sub);font-size:12.5px;margin-bottom:12px}
+ .btable{width:100%;border-collapse:collapse;font-size:13.5px}
+ .btable th{text-align:left;font-size:10.5px;letter-spacing:.08em;text-transform:uppercase;color:var(--sub);padding:8px 10px;border-bottom:1px solid var(--line)}
+ .btable td{padding:9px 10px;border-bottom:1px solid #eef0f2;overflow-wrap:anywhere}
+ .btable tr:hover td{background:#fafbfc}
+ .btable a{color:#1857b8}.btable a:hover{text-decoration:underline}
+ .pairs{display:flex;gap:8px;flex-wrap:wrap}
+ .pill{font:800 13px 'Inter';border:1.5px solid var(--line);border-radius:22px;padding:8px 14px;background:#fff}
+ .pill:hover{border-color:var(--red);color:var(--red)}
+ .fdisc{background:#fffaf0;border:1px solid #ecdca4;border-radius:10px;padding:10px 13px;margin-top:16px;color:#7a611a;font-size:12px;line-height:1.5}
+ .note{color:var(--sub);font-size:11.5px;margin-top:22px;line-height:1.5}
+ .spin{display:inline-block;width:15px;height:15px;border:2px solid var(--line);border-top-color:var(--red);border-radius:50%;animation:sp .7s linear infinite;vertical-align:-2px;margin-right:7px}@keyframes sp{to{transform:rotate(360deg)}}
+</style></head><body>
+
+<header class="top">
+ <button class="back" onclick="history.length>1?history.back():location.href='/services'">← Back</button>
+ <a class="brand" href="/">REN<span>TV</span></a>
+ <a class="back" href="/services">All services</a>
+</header>
+
+<main class="wrap" id="main">
+ <div style="padding:60px 0;color:var(--sub)"><span class="spin"></span>Loading provider…</div>
+</main>
+
+<script>
+const $=id=>document.getElementById(id);
+const esc=s=>String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
+const cap=s=>String(s||'').toLowerCase().replace(/\b([a-z])/g,c=>c.toUpperCase()).replace(/\bLlc\b/g,'LLC').replace(/\bInc\b/g,'Inc');
+const id=decodeURIComponent(location.pathname.split('/').pop()||'');
+
+// Admin-only Solicit handoff (same shared localStorage bucket + category mapping as /services)
+let ADMIN=false, PROV=null, CATKEY='', MARKET='';
+const GKEY='rentv.'+'growthLI.v1';
+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 []; } }
+
+async function checkAdmin(){ try{ const w=await (await fetch('/api/whoami')).json(); ADMIN=(w&&w.role==='admin'); }catch(e){ ADMIN=false; } }
+
+function solicit(btn){
+ if(!PROV)return; const scat=SOCIAL_CAT[CATKEY]||'Vendors / Service Providers';
+ const note=($('notein')?$('notein').value.trim():'');
+ const l=gload();
+ const ex=l.find(x=>x&&x.kind==='contact'&&x.cat===scat&&(x.name||'').toLowerCase()===String(PROV.name).toLowerCase());
+ if(ex){ if(note){ ex.note=note; localStorage.setItem(GKEY,JSON.stringify(l)); } }
+ else { l.push({ id:'sv'+Date.now().toString(36)+Math.floor(Math.random()*1e6).toString(36), kind:'contact', cat:scat,
+ name:cap(PROV.name), sub:[PROV.city?cap(PROV.city):'',PROV.loc_count>1?(PROV.loc_count+' locations'):'',PROV.featured?'Featured':''].filter(Boolean).join(' · '), note:note,
+ terms:PROV.name+' '+(PROV.city||'')+' commercial real estate', market:MARKET,
+ phone:PROV.phone||'', website:PROV.website||'', license_no:PROV.license_no||'', status:0, added:Date.now() });
+ localStorage.setItem(GKEY,JSON.stringify(l)); }
+ btn.classList.add('added'); btn.textContent='✓ In outreach';
+}
+
+function render(d){
+ const p=d.provider; PROV=p; CATKEY=d.cat; MARKET=d.market;
+ document.title=cap(p.name)+' — CRE Services · '+MARKET+' | RENTV.com';
+ const ok=/active|clear/i.test(p.license_status||'');
+ const catUrl='/services?cat='+esc(d.cat)+'&market='+encodeURIComponent(MARKET);
+ const badges=[
+ `<a class="badge" href="${catUrl}">${esc(p.role)}</a>`,
+ p.featured?(p.paid?'<span class="st spon">Sponsored</span>':'<span class="st feat">★ Featured</span>'):'',
+ (p.loc_count>1)?`<span class="locs">📍 ${p.loc_count} locations · ${p.city_count} cities</span>`:'',
+ p.license_status?`<span class="st${ok?' ok':''}">${esc(cap(p.license_status))}</span>`:''
+ ].filter(Boolean).join('');
+ const facts=[
+ p.specialty&&['Specialty',esc(p.specialty)],
+ p.license_no&&['License',esc(p.source_label||'License')+' #'+esc(p.license_no)],
+ (p.city||p.county||p.zip)&&['Location',[p.city&&esc(cap(p.city)),p.county&&esc(cap(p.county))+' County',p.zip&&esc(p.zip)].filter(Boolean).join(' · ')],
+ p.phone&&['Phone',`<a href="tel:${esc(String(p.phone).replace(/[^0-9+]/g,''))}">${esc(p.phone)}</a>`],
+ p.website&&['Website',`<a href="${esc(/^https?:/.test(p.website)?p.website:'https://'+p.website)}" target="_blank" rel="noopener noreferrer ${p.paid?'sponsored':'nofollow'}">${esc(p.website.replace(/^https?:\/\//,''))} ↗</a>`],
+ p.source_url&&['Public record',`<a href="${esc(p.source_url)}" target="_blank" rel="noopener noreferrer nofollow">Official license record ↗</a>`]
+ ].filter(Boolean).map(f=>`<div class="fact"><div class="k">${f[0]}</div><div class="v">${f[1]}</div></div>`).join('');
+
+ const branches=(d.branches||[]).filter(b=>b.city||b.license_no);
+ const branchSec=(p.loc_count>1&&branches.length>1)?`
+ <div class="sec">
+ <h2>Branch locations</h2>
+ <div class="sub">${branches.length} licensed location${branches.length>1?'s':''} for this firm in ${esc(MARKET)}.</div>
+ <table class="btable"><thead><tr><th>City</th><th>ZIP</th><th>License #</th><th>Status</th><th></th></tr></thead><tbody>
+ ${branches.map(b=>`<tr><td>${esc(cap(b.city||''))}</td><td>${esc(b.zip||'')}</td><td>${esc(b.license_no||'')}</td><td>${esc(cap(b.license_status||''))}</td><td>${b.source_url?`<a href="${esc(b.source_url)}" target="_blank" rel="noopener noreferrer nofollow">record ↗</a>`:''}</td></tr>`).join('')}
+ </tbody></table>
+ </div>`:'';
+
+ const pairs=(d.pairs||[]).map(x=>`<a class="pill" href="${esc(x.url)}">${esc(x.label)} ↗</a>`).join('');
+ const pairsSec=pairs?`<div class="sec"><h2>Pairs well with</h2><div class="sub">The other providers a deal in ${esc(MARKET)} typically needs.</div><div class="pairs">${pairs}</div></div>`:'';
+
+ const adminSec=ADMIN?`
+ <input class="notein" id="notein" maxlength="140" placeholder="Note for the pitch (optional)…">
+ <div class="acts">
+ ${p.phone?`<a class="act p" href="tel:${esc(String(p.phone).replace(/[^0-9+]/g,''))}">☎ Call</a>`:''}
+ <button class="act sol" id="solbtn">✉ Solicit</button>
+ <a class="act" href="/social">Open outreach ↗</a>
+ </div>`:`
+ <div class="acts">
+ ${p.phone?`<a class="act p" href="tel:${esc(String(p.phone).replace(/[^0-9+]/g,''))}">☎ Call</a>`:''}
+ ${p.website?`<a class="act" href="${esc(/^https?:/.test(p.website)?p.website:'https://'+p.website)}" target="_blank" rel="noopener noreferrer nofollow">Visit website ↗</a>`:''}
+ </div>`;
+
+ const featDisc=p.featured?`<div class="fdisc"><b>★ Featured</b> = unpaid editorial selection — no firm has paid for placement. Verify licensure & engagement terms directly.</div>`:'';
+
+ $('main').innerHTML=`
+ <div class="crumb"><a href="/">RENTV</a> › <a href="/services">CRE Services</a> › <a href="${catUrl}">${esc(catLabelOf(d.cat))} · ${esc(MARKET)}</a> › ${esc(cap(p.name))}</div>
+ <div class="prof">
+ <h1>${esc(cap(p.name))}</h1>
+ <div class="row1">${badges}</div>
+ <div class="facts">${facts}</div>
+ ${adminSec}
+ ${featDisc}
+ </div>
+ ${branchSec}
+ ${pairsSec}
+ <div class="note">Data from public California license registries (CSLB · DFPI · CA DRE · State Bar). RENTV surfaces public registry facts only — verify licensure and engagement terms directly before engaging a provider.</div>`;
+ if(ADMIN&&$('solbtn')) $('solbtn').onclick=()=>solicit($('solbtn'));
+}
+function catLabelOf(k){ return ({construction:'Construction & GCs',lenders:'Lenders & Capital',escrow:'Escrow',developers:'Developers',legal:'Legal',title:'Title',property:'Property Management'})[k]||'Services'; }
+
+async function boot(){
+ await checkAdmin();
+ let d; try{ d=await (await fetch('/api/services/provider/'+encodeURIComponent(id))).json(); }catch(e){ d=null; }
+ if(!d||d.error||!d.provider){ $('main').innerHTML='<div style="padding:60px 0;text-align:center;color:var(--sub)"><h2 style="font-family:Playfair Display,serif;color:var(--ink)">Provider not found</h2><div style="margin-top:8px">This record may have been removed. <a href="/services" style="color:var(--red);font-weight:800">Back to CRE Services →</a></div></div>'; return; }
+ render(d);
+}
+boot();
+</script>
+</body></html>
diff --git a/public/services.html b/public/services.html
index 504a6f61..29be49ae 100644
--- a/public/services.html
+++ b/public/services.html
@@ -74,6 +74,7 @@
.card{border:1px solid var(--line);border-radius:12px;background:#fff;padding:14px 15px;display:flex;flex-direction:column;gap:9px;min-width:0}
.card:hover{border-color:#cfd4d9;box-shadow:0 2px 12px rgba(6,9,12,.05)}
.card .nm{font:700 15px 'Inter';line-height:1.3;overflow-wrap:anywhere}
+ .card .nm a:hover{color:var(--red);text-decoration:underline}
.card .rowline{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.badge{display:inline-block;font-size:10.5px;font-weight:800;color:#fff;background:var(--red);border-radius:20px;padding:3px 9px;cursor:pointer;letter-spacing:.02em}
.badge:hover{filter:brightness(1.08)}
@@ -175,6 +176,7 @@
<div class="meta" id="meta"></div>
<div class="ctls">
<button class="solall" id="solall" style="display:none" title="Add every firm currently loaded on screen to the outreach list">✉ Solicit all visible</button>
+ <button class="solall" id="solcsv" style="display:none" title="Download the full current category/metro list as CSV">⤓ Export CSV</button>
<label class="ctl">Sort
<select id="sort" aria-label="Sort providers">
<option value="locations">Most locations</option>
@@ -232,7 +234,8 @@ function gsave(l){ localStorage.setItem(GKEY,JSON.stringify(l)); }
// LOCALHOST-ONLY reset: ?reset=outreach clears the outreach basket. Host-gated so a shared/bookmarked
// link can never wipe a real outreach list on the live site — it only works on the local preview.
if(new URLSearchParams(location.search).get('reset')==='outreach' && /^(127\.0\.0\.1|localhost)$/.test(location.hostname)){ try{ localStorage.removeItem(GKEY); }catch(e){} }
-function outreachRefresh(){ const n=gload().filter(x=>x&&x.kind==='contact').length; $('ocount').textContent=n; $('obasket').classList.toggle('show',ADMIN); $('solall').style.display=ADMIN?'inline-flex':'none'; }
+function outreachRefresh(){ const n=gload().filter(x=>x&&x.kind==='contact').length; $('ocount').textContent=n; $('obasket').classList.toggle('show',ADMIN); $('solall').style.display=ADMIN?'inline-flex':'none'; $('solcsv').style.display=ADMIN?'inline-flex':'none'; }
+function exportCSV(){ const u=new URLSearchParams(); u.set('cat',cat); u.set('market',market); if(q)u.set('q',q); if(city)u.set('city',city); location.href='/api/services/export.csv?'+u.toString(); }
function inOutreach(name,scat){ return gload().some(x=>x&&x.kind==='contact'&&x.cat===scat&&(x.name||'').toLowerCase()===String(name).toLowerCase()); }
function scatOf(){ return SOCIAL_CAT[cat]||'Vendors / Service Providers'; }
function outreachSub(r,note){ return [r.city?cap(r.city):'',r.loc_count>1?(r.loc_count+' locations'):'',r.featured?'Featured':'',note?('“'+note+'”'):''].filter(Boolean).join(' · '); }
@@ -267,6 +270,7 @@ async function checkAdmin(){
outreachRefresh();
}
$('solall').onclick=solicitAll;
+$('solcsv').onclick=exportCSV;
// URL state (deep-linkable): /services?cat=lenders&city=GLENDALE&q=capital
const p=new URLSearchParams(location.search);
@@ -335,7 +339,7 @@ function card(r){
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="nm"><a href="/services/${esc(r.id)}">${esc(cap(r.name))}</a></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>
${spec}
${loc?`<div class="loc">${loc}</div>`:''}
diff --git a/server.js b/server.js
index 7c3f7903..14b300ae 100644
--- a/server.js
+++ b/server.js
@@ -199,10 +199,11 @@ footer:not(#rentv-footer){display:none!important}
</footer>`;
// Internal/admin shells never get the customer footer.
const INTERNAL_PAGE = /(^|\/)(admin|versions|consulting|press|desk|desk-admin|audience|social|backend|deals-log|closings|deals|news-playbook|re-search|summit-leads)(\.html)?$|\/(admin|versions|consulting|press)\//i;
-function sendPage(res, absFile) {
+function sendPage(res, absFile, transform) {
let html;
try { html = fs.readFileSync(absFile, 'utf8'); }
catch { return res.status(404).send('not found'); }
+ if (typeof transform === 'function') { try { html = transform(html); } catch (e) { /* keep base html */ } }
const closeBody = () => html.indexOf('</body>') !== -1;
if (html.indexOf('/toggle.js') === -1) {
html = closeBody() ? html.replace('</body>', TOGGLE_TAG + '</body>') : html + TOGGLE_TAG;
@@ -1431,7 +1432,8 @@ app.get('/sitemap.xml', (req, r) => {
const base = (req.protocol + '://' + req.get('host')).replace(/\/$/, '');
const items = newsItems();
// Public, crawlable pages only — /deals is admin-gated now (was leaking a login redirect to crawlers).
- const urls = ['/', '/map', '/markets', '/review', '/topics', '/blog'];
+ const urls = ['/', '/map', '/markets', '/review', '/topics', '/blog', '/directory', '/services',
+ '/services?cat=construction', '/services?cat=lenders', '/services?cat=escrow', '/services?cat=title', '/services?cat=property', '/services?cat=legal'];
(readHubs().hubs || []).forEach(h => { if (hubMatches(h, items).length > 0) urls.push('/topic/' + h.slug); });
(readRegions().regions || []).forEach(reg => { if (regionMatches(reg, items).length > 0) urls.push('/region/' + reg.slug); });
// Article-level entries for EVERY logged RENTV story — the full corpus plus any live-feed
@@ -1461,7 +1463,41 @@ app.get('/deals', adminOnly, (_q, r) => sendPage(r, path.join(PUB, 'deals.html')
app.get('/news/:id', (_q, r) => sendPage(r, path.join(PUB, 'article.html')));
// CRE Services directory (Greater LA) — public. Licensed contractors, lenders, escrow, developers,
// attorneys from public registries (CSLB / DFPI). Served by /api/services + /api/services/facets.
-app.get('/services', (_q, r) => sendPage(r, path.join(PUB, 'services.html')));
+app.get('/services', (req, r) => {
+ // Per-metro/category SEO: server-inject <title>, meta description, canonical, OG + JSON-LD
+ // (CollectionPage + BreadcrumbList + ItemList of the featured firms) so the client-rendered
+ // directory is crawlable/indexable for queries like "LA commercial title companies".
+ const catKey = SERVICE_CATS[String(req.query.cat)] ? String(req.query.cat) : '';
+ const market = SERVICE_MARKETS.has(String(req.query.market)) ? String(req.query.market) : 'Greater LA';
+ const catLabel = catKey ? SERVICE_CATS[catKey].label : '';
+ const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
+ const base = (req.protocol + '://' + req.get('host')).replace(/\/$/, '');
+ const canon = base + '/services' + (catKey ? '?cat=' + catKey + (market !== 'Greater LA' ? '&market=' + encodeURIComponent(market) : '') : '');
+ const title = (catLabel ? catLabel + ' — ' : '') + 'CRE Services · ' + market + ' | RENTV.com';
+ const desc = 'Directory of ' + (catLabel ? catLabel.toLowerCase() + ' ' : '') + 'commercial real estate service providers in ' + market +
+ ' — licensed firms from public California registries (CSLB, DFPI, CA DRE, State Bar), ranked by scale. RENTV.com.';
+ const crumbs = [{ name: 'RENTV', url: base + '/' }, { name: 'CRE Services', url: base + '/services' }];
+ if (catLabel) crumbs.push({ name: catLabel + ' · ' + market, url: canon });
+ const ld = [
+ { '@context': 'https://schema.org', '@type': 'CollectionPage', name: title.replace(' | RENTV.com', ''), description: desc, url: canon, isPartOf: { '@type': 'WebSite', name: 'RENTV.com', url: base + '/' } },
+ { '@context': 'https://schema.org', '@type': 'BreadcrumbList', itemListElement: crumbs.map((c, i) => ({ '@type': 'ListItem', position: i + 1, name: c.name, item: c.url })) },
+ ];
+ if (catKey) {
+ const feat = featuredFor(catKey, market, '');
+ if (feat.length) ld.push({ '@context': 'https://schema.org', '@type': 'ItemList', name: catLabel + ' — Featured providers, ' + market,
+ itemListElement: feat.map((v, i) => ({ '@type': 'ListItem', position: i + 1, item: { '@type': 'Organization', name: v.name, url: v.website || undefined } })) });
+ }
+ const headTags = '<link rel="canonical" href="' + esc(canon) + '">' +
+ '<meta property="og:title" content="' + esc(title) + '"><meta property="og:description" content="' + esc(desc) + '">' +
+ '<meta property="og:type" content="website"><meta property="og:url" content="' + esc(canon) + '">' +
+ '<script type="application/ld+json">' + JSON.stringify(ld).replace(/</g, '\\u003c') + '</script>';
+ sendPage(r, path.join(PUB, 'services.html'), html => html
+ .replace(/<title>[^<]*<\/title>/, '<title>' + esc(title) + '</title>')
+ .replace(/<meta name="description" content="[^"]*">/, '<meta name="description" content="' + esc(desc) + '">')
+ .replace('</head>', headTags + '</head>'));
+});
+// Provider detail page — client fetches /api/services/provider/:id and renders the full profile.
+app.get('/services/:id', (_q, r) => sendPage(r, path.join(PUB, 'services-detail.html')));
// News MAP — public geolocated map of every article; green<3mo / red older (TK-10284).
app.get('/map', (_q, r) => sendPage(r, path.join(PUB, 'map.html')));
// The REview — the folded-in video platform, served natively in the RENTV shell
@@ -1753,6 +1789,81 @@ app.get('/api/services/facets', async (req, res) => {
res.json({ market, intro: SERVICE_CONTENT.intro || {}, cats, cities: cityR.rows, featured_meta: SERVICE_FEATURED_META });
} catch (e) { res.status(502).json({ cats: [], cities: [], error: 'services registry unavailable' }); }
});
+// Provider detail — one firm's full profile: all branch locations (the real value-add over the
+// card), humanized specialties, public-record link, and deal-team "pairs well with" cross-links.
+app.get('/api/services/provider/:id', async (req, res) => {
+ try {
+ const id = String(req.params.id);
+ // Featured firm (id like 'f-<cat>-<slug>') — reconstruct from the seed across all its markets.
+ if (/^f-/.test(id)) {
+ for (const key of Object.keys(SERVICE_CATS)) {
+ for (const mk of SERVICE_MARKETS) {
+ const hit = featuredFor(key, mk, '').find(v => v.id === id);
+ if (hit) return res.json({ provider: hit, branches: [], pairs: pairsWith(key, mk), cat: key, market: mk });
+ }
+ }
+ return res.status(404).json({ error: 'not found' });
+ }
+ if (!/^\d+$/.test(id)) return res.status(400).json({ error: 'bad id' });
+ const r = await rePool.query('SELECT id, role, entity_name, license_no, license_type, license_status, city, county, zip, market, phone, website, source, source_url FROM rentv_licensed_targets WHERE id = $1', [id]);
+ if (!r.rowCount) return res.status(404).json({ error: 'not found' });
+ const row = r.rows[0];
+ const provider = serviceRow(row);
+ // All branch licenses of this firm in the same market (the multi-location roster).
+ const NKEY = "regexp_replace(lower(coalesce(entity_name,'')),'[^a-z0-9]+','','g')";
+ const br = await rePool.query(
+ 'SELECT city, zip, license_no, license_type, license_status, source_url FROM rentv_licensed_targets ' +
+ 'WHERE within_300mi AND role=$1 AND market=$2 AND ' + NKEY + ' = ' + NKEY.replace('entity_name', '$3') +
+ ' ORDER BY city ASC NULLS LAST, license_no', [row.role, row.market, row.entity_name]);
+ provider.loc_count = br.rowCount; // total branch licenses
+ provider.city_count = new Set(br.rows.map(b => (b.city || '').toUpperCase()).filter(Boolean)).size;
+ const catKey = Object.keys(SERVICE_CATS).find(k => SERVICE_CATS[k].role === row.role) || '';
+ res.json({ provider, branches: br.rows, pairs: pairsWith(catKey, row.market), cat: catKey, market: row.market });
+ } catch (e) { res.status(502).json({ error: 'provider unavailable' }); }
+});
+// "Pairs well with" — the other service categories a deal team needs in the same metro.
+function pairsWith(catKey, market) {
+ const order = ['lenders', 'escrow', 'title', 'legal', 'developers', 'construction', 'property'];
+ return order.filter(k => k !== catKey && SERVICE_CATS[k]).slice(0, 4)
+ .map(k => ({ cat: k, label: SERVICE_CATS[k].label, url: '/services?cat=' + k + '&market=' + encodeURIComponent(market) }));
+}
+// CSV export of the current filtered category/metro view — a prospect list for the sales team
+// (one row per firm, biggest-first, featured firms included). Public data; download as attachment.
+function csvCell(v) { const s = String(v == null ? '' : v); return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; }
+app.get('/api/services/export.csv', async (req, res) => {
+ try {
+ const catKey = SERVICE_CATS[String(req.query.cat)] ? String(req.query.cat) : 'construction';
+ const cat = SERVICE_CATS[catKey];
+ const market = SERVICE_MARKETS.has(String(req.query.market)) ? String(req.query.market) : 'Greater LA';
+ const q = String(req.query.q || '').trim().slice(0, 60);
+ const city = String(req.query.city || '').trim().slice(0, 40);
+ const cap = Math.min(5000, Math.max(1, parseInt(req.query.limit, 10) || 2000));
+ const where = ['within_300mi', 'role = $1', 'market = $2'], params = [cat.role, market];
+ if (q) { params.push('%' + q + '%'); where.push('(entity_name ILIKE $' + params.length + ' OR city ILIKE $' + params.length + ')'); }
+ if (city) { params.push(city); where.push('city ILIKE $' + params.length); }
+ const whereSql = where.join(' AND ');
+ const NKEY = "regexp_replace(lower(coalesce(entity_name,'')),'[^a-z0-9]+','','g')";
+ const COLS = 'id, role, entity_name, license_no, license_type, license_status, city, county, zip, market, phone, website, source, source_url';
+ const CTE = 'WITH b AS (SELECT ' + COLS + ', ' + NKEY + ' AS nkey FROM rentv_licensed_targets WHERE ' + whereSql + '), ' +
+ 'stats AS (SELECT nkey, count(*)::int AS loc_count, count(DISTINCT city)::int AS city_count FROM b GROUP BY nkey), ' +
+ 'agg AS (SELECT b.*, s.loc_count, s.city_count FROM b JOIN stats s USING (nkey)) ';
+ const rowsR = await rePool.query(
+ CTE + 'SELECT * FROM (SELECT DISTINCT ON (nkey) ' + COLS + ', loc_count, city_count FROM agg ' +
+ "ORDER BY nkey, (city ILIKE 'los angeles') DESC NULLS LAST, id) t ORDER BY loc_count DESC, entity_name ASC LIMIT " + cap, params);
+ const featured = (!city) ? featuredFor(catKey, market, q) : [];
+ const header = ['firm', 'category', 'role', 'featured', 'market', 'city', 'county', 'zip', 'locations', 'cities', 'license_no', 'license_type', 'license_status', 'specialty', 'phone', 'website', 'source', 'source_url'];
+ const lines = [header.join(',')];
+ for (const v of featured) lines.push([v.name, cat.label, v.role, (v.paid ? 'Sponsored' : 'Featured'), market, v.city || '', '', '', '', '', '', '', '', v.specialty || '', '', v.website || '', v.source, ''].map(csvCell).join(','));
+ for (const r of rowsR.rows) {
+ const sr = serviceRow(r);
+ lines.push([sr.name, cat.label, sr.role, '', sr.market, sr.city || '', sr.county || '', sr.zip || '', r.loc_count || 1, r.city_count || 1, sr.license_no || '', sr.license_type || '', sr.license_status || '', sr.specialty || '', sr.phone || '', sr.website || '', sr.source, sr.source_url || ''].map(csvCell).join(','));
+ }
+ const fname = 'rentv-services-' + catKey + '-' + market.replace(/[^a-z0-9]+/gi, '-').toLowerCase() + '.csv';
+ res.set('Content-Type', 'text/csv; charset=utf-8');
+ res.set('Content-Disposition', 'attachment; filename="' + fname + '"');
+ res.send(lines.join('\n'));
+ } catch (e) { res.status(502).send('export unavailable'); }
+});
// Admin write-forwarder → usre backend (JSON body). Same-origin so /desk admin can add/withdraw
// sublease listings; the upstream is basic-auth-gated and loopback-only, so we inject the auth.
← 6190cac5 feat(deals): parseAcquirer recall 19→23/30 — handle developm
·
back to Rentv
·
auto-data-snapshot: 2026-08-06T16:03:07 (7 data files) — dat 21845887 →