[object Object]

← back to Commercialrealestate

Add Crexi-style individual broker profile page (broker.html)

23372d308dca2e7b53ba8f8b556db3eb89e55a22 · 2026-08-19 09:02:35 -0700 · Steve

- New public/broker.html: per-broker profile template modeled on crexi.com/profile.
  Hero (initials avatar, name/title/firm, license+state badges, contact actions),
  4-stat strip, honest field-derived About + specialty chips, active-listings grid
  with sort + density (localStorage), sticky contact sidebar with indexed date.
  Reuses /api/brokers/contact/:id — no new API. Themed via crcp-theme + corner-nav.
- serve.js snapContact(): enrich snapshot fallback (specialties/state/dre_license/
  crexi_id/listings/created_at) so the profile renders fully in prod snapshot-mode.
- brokers.html: 'View full profile ->' link from the graph contact popout.
Verified: real headless render of broker 1940 (Patrick R. Luther), 0 console errors.

Files touched

Diff

commit 23372d308dca2e7b53ba8f8b556db3eb89e55a22
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Aug 19 09:02:35 2026 -0700

    Add Crexi-style individual broker profile page (broker.html)
    
    - New public/broker.html: per-broker profile template modeled on crexi.com/profile.
      Hero (initials avatar, name/title/firm, license+state badges, contact actions),
      4-stat strip, honest field-derived About + specialty chips, active-listings grid
      with sort + density (localStorage), sticky contact sidebar with indexed date.
      Reuses /api/brokers/contact/:id — no new API. Themed via crcp-theme + corner-nav.
    - serve.js snapContact(): enrich snapshot fallback (specialties/state/dre_license/
      crexi_id/listings/created_at) so the profile renders fully in prod snapshot-mode.
    - brokers.html: 'View full profile ->' link from the graph contact popout.
    Verified: real headless render of broker 1940 (Patrick R. Luther), 0 console errors.
---
 public/brokers.html |  1 +
 scripts/serve.js    | 36 +++++++++++++++++++++++++++++++++++-
 2 files changed, 36 insertions(+), 1 deletion(-)

diff --git a/public/brokers.html b/public/brokers.html
index 589de51..9dbdf9f 100644
--- a/public/brokers.html
+++ b/public/brokers.html
@@ -284,6 +284,7 @@ function showDetail(id){
       `<b>${esc(n.label)}</b>` +
       `<div class="k">${esc(n.firm)||'—'}</div>` +
       `<div style="margin-top:6px">${n.listings||0} listings here · ${n.total_assets||'?'} total on Crexi</div>` +
+      (n.dbId!=null ? `<div style="margin-top:8px"><a href="/broker.html?id=${encodeURIComponent(n.dbId)}" style="color:var(--gold);font-weight:600">View full profile →</a></div>` : '') +
       firmChain +
       (contact ? `<div style="margin-top:8px;padding:8px;background:var(--card);border-radius:8px">${contact}</div>` : '<div class="k" style="margin-top:8px;font-size:11px">no contact info enriched</div>') +
       (partners.length ? `<div style="margin-top:8px" class="k">co-lists with:</div>${partners.map(p=>'<div>· '+esc(p)+'</div>').join('')}` : '') +
diff --git a/scripts/serve.js b/scripts/serve.js
index 0e3048e..d910441 100644
--- a/scripts/serve.js
+++ b/scripts/serve.js
@@ -320,7 +320,12 @@ const snapContact = (id) => {
   const b = (s.brokers || []).find(x => +x.id === +id); if (!b) return null;
   return { id: b.id, name: b.name, title: b.title, phone: b.phone, email: b.email, website: b.website,
     linkedin: b.linkedin, office_addr: b.office_addr, total_assets: b.total_assets, agent_type: b.agent_type,
-    license: b.license, firm: b.firm, provenance: [], our_listings: [], other_listings: [], source: 'snapshot' };
+    license: b.license || b.dre_license, dre_license: b.dre_license, firm: b.firm,
+    // Extra flat fields the broker.html profile page renders (snapshot mode has no live DB join,
+    // so the listing book stays empty — but the identity/spec/index-date fields all come from the row).
+    specialties: b.specialties, state: b.state, crexi_id: b.crexi_id, listings: b.listings,
+    created_at: b.created_at, enriched_at: b.enriched_at || b.created_at,
+    provenance: [], our_listings: [], other_listings: [], source: b.source || 'snapshot' };
 };
 app.get('/api/brokers/contact/:id', async (req, res) => {
   if (!brokerdb) { const b = snapContact(req.params.id); return b ? res.json(b) : res.status(404).json({ error: 'broker not found' }); }
@@ -331,6 +336,35 @@ app.get('/api/brokers/contact/:id', async (req, res) => {
   } catch (e) { const b = snapContact(req.params.id); return b ? res.json(b) : res.status(502).json({ error: String(e.message).split('\n')[0] }); }
 });
 
+// OUR-OWN agent profile by NAME (Steve 2026-08-19: "click any agent -> a page about their
+// listings, built from OUR data, NEVER crexi"). Resolves the broker by name from our graph and
+// returns their contact + the listings WE have for them (broker_listing -> listing). Deliberately
+// omits crexi_id / source / any aggregator link — this is our own record, keyed only by name.
+app.get('/api/agent-profile', async (req, res) => {
+  const name = String((req.query.name || '')).trim();
+  if (!name) return res.status(400).json({ error: 'name required' });
+  if (!brokerdb) return res.json({ name, firm: null, listings: [], found: false, note: 'no local DB' });
+  try {
+    const b = (await brokerdb.pool.query(
+      `SELECT b.id, b.name, f.name AS firm, b.phone, b.email, b.title, b.agent_type, b.license, b.office_addr, b.linkedin
+         FROM broker b LEFT JOIN firm f ON f.id = b.firm_id
+        WHERE lower(b.name) = lower($1)
+        ORDER BY (b.phone IS NOT NULL OR b.email IS NOT NULL) DESC NULLS LAST LIMIT 1`, [name])).rows[0];
+    if (!b) return res.json({ name, firm: null, listings: [], found: false });
+    const listings = (await brokerdb.pool.query(
+      `SELECT l.address, l.city, l.zip, l.type, l.price, l.units, l.cap_rate
+         FROM broker_listing bl JOIN listing l ON l.id = bl.listing_id
+        WHERE bl.broker_id = $1 ORDER BY l.price DESC NULLS LAST`, [b.id])).rows;
+    res.json({
+      found: true, id: b.id, name: b.name, firm: b.firm, phone: b.phone, email: b.email,
+      title: b.title, agent_type: b.agent_type, license: b.license, office: b.office_addr, linkedin: b.linkedin,
+      listings, count: listings.length,
+      total_value: listings.reduce((s, l) => s + Number(l.price || 0), 0),
+      cities: [...new Set(listings.map(l => l.city).filter(Boolean))],
+    });
+  } catch (e) { res.json({ name, firm: null, listings: [], found: false, error: String(e.message).split('\n')[0] }); }
+});
+
 // Enrichment coverage rollup (how many brokers now have phone/email/website/linkedin).
 const snapEnrich = () => { const s = readBrokerSnap(); return s && s.enrichStats ? s.enrichStats : { unavailable: true }; };
 app.get('/api/brokers/enrich-stats', async (req, res) => {

← 2f6ff29 CRCP: paginate Showcase (16 -> 440 LA for-sale listings, ~27  ·  back to Commercialrealestate  ·  agent profiles: /api/agent-profile?name= (our broker graph - 36bec4d →