← back to Lawyer Directory Builder
src/server/directory.ts
532 lines
/**
* Directory + EZ Upgrade pitch.
*
* For lawyers (the primary audience): find yourself in the CalBar registry,
* see your current site's audit score, see a preview of what an EZ Upgrade
* would look like, claim & purchase the upgrade.
*
* Routes:
* GET /directory — search + audit-pull + upgrade preview
* GET /api/firms.geo — JSON of geocoded firms (map)
* GET /api/firms/:id/audit — latest audit for one firm (score + suggestions)
* POST /directory/inquire — fallback for "I can't find myself" cases
*/
import express from 'express';
import { query } from '../db/pool.ts';
const router = express.Router();
router.use(express.urlencoded({ extended: false }));
function esc(s: any): string {
if (s === null || s === undefined) return '';
return String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]!));
}
router.get('/api/firms.geo', async (_req, res) => {
const r = await query(`
SELECT id, name, city, zip, lat, lng,
website IS NOT NULL AS has_site,
attorney_count
FROM organizations
WHERE type='law_firm'
AND lat BETWEEN 32.5 AND 42.05
AND lng BETWEEN -124.5 AND -114.0
LIMIT 8000
`);
res.json({ rows: r.rows });
});
router.get('/api/firms/:id/audit', async (req, res) => {
const id = parseInt(req.params.id, 10);
if (!id) return res.json({ audit: null, firm: null });
const f = await query<any>(`
SELECT id, name, address, city, zip, phone, website
FROM organizations WHERE id = $1 LIMIT 1
`, [id]);
const a = await query<any>(`
SELECT marketing_score, primary_color, palette, screenshot_path, suggestions,
has_https, has_favicon, has_og_image, has_meta_viewport, has_analytics, has_h1,
has_schema_org, has_phone_visible, page_size_bytes, load_time_ms, audited_at
FROM site_audits
WHERE organization_id = $1 AND status_code BETWEEN 200 AND 399
ORDER BY audited_at DESC LIMIT 1
`, [id]);
res.json({ firm: f.rows[0] || null, audit: a.rows[0] || null });
});
router.get('/directory', (_req, res) => {
res.send(`<!doctype html>
<html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Claim & Upgrade · Counsel & Bar</title>
<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=Cormorant+Garamond:ital,wght@0,300;0,400;0,500;1,300;1,400&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="">
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
<style>
:root{
--noir:#0a0a0c;--noir-rise:#131316;--noir-deep:#050507;
--rule:#2a2724;--rule-faint:#1a1815;
--ink:#f4f1ea;--ink-soft:#d8d2c5;--ink-mute:#8b857a;
--metal:#b89968;--metal-glow:#d4b683;--metal-deep:#8a7044;
--good:#34d399;--warn:#d4a04a;--bad:#d87a7a;
--serif:"Cormorant Garamond","Cormorant","Georgia",serif;
--sans:"Inter",-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;
}
*{box-sizing:border-box}
html{-webkit-font-smoothing:antialiased;scroll-behavior:smooth}
body{margin:0;background:var(--noir);color:var(--ink);font-family:var(--sans);font-weight:300;font-size:15px;line-height:1.55}
::selection{background:var(--metal);color:var(--noir)}
a{color:var(--metal);text-decoration:none}
a:hover{color:var(--metal-glow)}
header.brand{padding:22px 48px;display:flex;align-items:center;gap:18px;justify-content:space-between;border-bottom:1px solid var(--rule);background:var(--noir-deep)}
header.brand h1{margin:0;font-family:var(--serif);font-size:22px;font-weight:400}
header.brand h1 a{color:var(--ink)}
header.brand h1 .amp{color:var(--metal);font-style:italic;padding:0 4px}
.topnav{font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--ink-mute);display:flex;gap:24px;align-items:center}
.topnav a{color:var(--ink-mute)}
.topnav a:hover{color:var(--ink)}
.topnav a.cta-mini{color:var(--metal);border:1px solid var(--rule);padding:9px 16px;transition:all 220ms ease}
.topnav a.cta-mini:hover{color:var(--noir);background:var(--metal);border-color:var(--metal)}
.hero{padding:96px 48px 72px;border-bottom:1px solid var(--rule);max-width:1200px;margin:0 auto;width:100%}
.eyebrow{font-size:10px;letter-spacing:.3em;text-transform:uppercase;color:var(--metal);font-weight:500;margin:0 0 18px}
.hero h1{font-family:var(--serif);font-weight:300;font-size:clamp(40px,6vw,76px);line-height:1.02;letter-spacing:-.02em;margin:0 0 18px;color:var(--ink);max-width:18ch}
.hero h1 em{font-style:italic;color:var(--metal);font-weight:400}
.hero p.lede{font-size:18px;color:var(--ink-soft);max-width:60ch;margin:0 0 40px;font-weight:300}
.search-wrap{position:relative;max-width:680px}
.search-input{width:100%;padding:22px 24px;background:var(--noir-rise);border:1px solid var(--rule);color:var(--ink);font-family:var(--sans);font-weight:300;font-size:18px}
.search-input:focus{outline:none;border-color:var(--metal);box-shadow:0 0 0 3px rgba(184,153,104,.12)}
.search-hint{margin-top:10px;font-size:11px;color:var(--ink-mute);letter-spacing:.06em}
#ac-list{position:absolute;left:0;right:0;top:100%;z-index:20;background:var(--noir-rise);border:1px solid var(--rule);margin-top:2px;max-height:360px;overflow:auto;display:none}
.ac-row{padding:14px 20px;cursor:pointer;border-bottom:1px solid var(--rule);font-size:14px;color:var(--ink);font-weight:300}
.ac-row:last-child{border-bottom:none}
.ac-row:hover,.ac-row.sel{background:var(--rule-faint)}
.ac-row .nm{font-weight:500}
.ac-row .meta{color:var(--ink-mute);font-size:12px;margin-top:4px;font-style:italic}
.ac-row .pill{font-size:9px;padding:2px 7px;border:1px solid var(--rule);color:var(--ink-mute);margin-left:8px;letter-spacing:.14em;text-transform:uppercase}
/* ── Result panel ── */
.result{max-width:1200px;margin:0 auto;padding:64px 48px;border-bottom:1px solid var(--rule);display:none}
.result.visible{display:block}
.result-head{margin-bottom:48px}
.result-head .name{font-family:var(--serif);font-weight:300;font-size:clamp(36px,5vw,56px);line-height:1.05;letter-spacing:-.015em;margin:0 0 8px;color:var(--ink)}
.result-head .name em{font-style:italic;color:var(--metal);font-weight:400}
.result-head .sub{font-size:14px;color:var(--ink-soft);letter-spacing:.04em}
.result-grid{display:grid;grid-template-columns:1fr 1fr;gap:32px;margin-bottom:48px}
@media (max-width:880px){.result-grid{grid-template-columns:1fr}}
.panel{background:var(--noir-rise);border:1px solid var(--rule);padding:32px}
.panel h3{font-family:var(--serif);font-weight:400;font-size:22px;margin:0 0 18px;color:var(--ink);letter-spacing:-.005em}
.panel .field{display:flex;justify-content:space-between;padding:10px 0;border-bottom:1px solid var(--rule-faint);font-size:14px;gap:16px}
.panel .field:last-child{border-bottom:none}
.panel .field label{font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--ink-mute);font-weight:500}
.panel .field span{color:var(--ink);text-align:right;word-break:break-word}
.score-display{display:flex;align-items:baseline;gap:18px;margin-bottom:24px}
.score-display .num{font-family:var(--serif);font-weight:300;font-size:96px;line-height:1;color:var(--metal);letter-spacing:-.02em;font-variant-numeric:tabular-nums}
.score-display .num.tier-a{color:var(--metal-glow)}
.score-display .num.tier-b{color:var(--metal)}
.score-display .num.tier-c{color:#a07a40}
.score-display .num.tier-d{color:#5a4220;font-weight:400}
.score-display .num.tier-x{color:var(--ink-mute);font-style:italic;font-size:42px}
.score-display .of{font-size:20px;color:var(--ink-mute);font-weight:300}
.dir-vol{display:flex;align-items:center;gap:18px;margin:0 0 26px;color:var(--metal)}
.dir-vol .v-rule{flex:0 0 56px;height:1px;background:linear-gradient(90deg,transparent,var(--metal) 50%,transparent)}
.dir-vol .v-text{font-family:var(--serif);font-style:italic;font-weight:400;font-size:14px;letter-spacing:.02em}
.dir-vol .v-meta{font-size:10px;letter-spacing:.28em;text-transform:uppercase;color:var(--ink-mute);font-weight:500}
.brand-mono{display:inline-flex;align-items:center;gap:14px;text-decoration:none}
.score-label{font-size:11px;letter-spacing:.22em;text-transform:uppercase;color:var(--ink-mute);margin-bottom:18px}
.suggestions{list-style:none;padding:0;margin:18px 0 0}
.suggestions li{padding:10px 0;border-bottom:1px solid var(--rule-faint);font-size:14px;color:var(--ink-soft);display:flex;gap:12px;align-items:flex-start}
.suggestions li:before{content:'→';color:var(--metal);flex-shrink:0;font-family:var(--serif);font-size:18px;line-height:1.2}
.suggestions li:last-child{border-bottom:none}
.no-audit{padding:24px 0;color:var(--ink-mute);font-size:14px;font-style:italic}
/* ── Upgrade pitch ── */
.upgrade{margin-top:24px;background:linear-gradient(135deg,var(--noir-rise) 0%,var(--noir) 100%);border:1px solid var(--metal-deep);padding:48px;position:relative}
.upgrade::before{content:'EZ UPGRADE';position:absolute;top:-1px;left:24px;background:var(--metal);color:var(--noir);padding:8px 16px;font-size:10px;letter-spacing:.3em;font-weight:600}
.upgrade-grid{display:grid;grid-template-columns:1.2fr 1fr;gap:48px;align-items:center;margin-top:14px}
@media (max-width:880px){.upgrade-grid{grid-template-columns:1fr;gap:32px}}
.upgrade h2{font-family:var(--serif);font-weight:300;font-size:clamp(34px,4vw,48px);line-height:1.05;letter-spacing:-.015em;margin:0 0 16px;color:var(--ink)}
.upgrade h2 em{font-style:italic;color:var(--metal);font-weight:400}
.upgrade p{color:var(--ink-soft);font-size:15px;line-height:1.6;margin:0 0 14px;max-width:48ch}
.upgrade ul{list-style:none;padding:0;margin:18px 0 28px}
.upgrade ul li{padding:6px 0;font-size:13px;color:var(--ink-soft)}
.upgrade ul li:before{content:'✓';color:var(--metal);margin-right:10px;font-weight:600}
.upgrade .price-card{background:var(--noir-deep);border:1px solid var(--rule);padding:32px;text-align:center}
.upgrade .price-card .amount{font-family:var(--serif);font-weight:300;font-size:48px;line-height:1;color:var(--ink-soft);letter-spacing:-.01em;margin:0}
.upgrade .price-card .one-time{font-size:11px;color:var(--ink-mute);letter-spacing:.22em;text-transform:uppercase;margin:10px 0 0}
.upgrade .price-card .turnaround{font-size:13px;color:var(--ink-soft);margin:18px 0 24px;font-style:italic}
.cta-claim{display:block;width:100%;padding:18px 24px;background:var(--metal);color:var(--noir);border:1px solid var(--metal);font-size:12px;letter-spacing:.18em;text-transform:uppercase;font-weight:500;text-align:center;text-decoration:none;cursor:pointer;transition:background 220ms ease}
.cta-claim:hover{background:var(--metal-glow);color:var(--noir)}
/* Score-vs-upgrade decoupling — Rule 7.1 / §6155 surface */
.score-disclaimer{max-width:1200px;margin:32px auto 0;padding:24px 32px;border:1px solid var(--rule);background:linear-gradient(180deg,rgba(184,153,104,.018),transparent)}
.score-disclaimer .sd-eyebrow{font-size:9px;letter-spacing:.32em;text-transform:uppercase;color:var(--metal);font-weight:500;margin:0 0 12px}
.score-disclaimer p{font-size:13px;line-height:1.7;color:var(--ink-soft);margin:0;max-width:none}
.score-disclaimer p strong{color:var(--ink);font-weight:500}
.upgrade-disclosure{max-width:1200px;margin:32px auto 0}
.upgrade-disclosure[open] .up-chev{transform:rotate(90deg)}
.upgrade-summary{cursor:pointer;list-style:none;padding:18px 24px;border:1px solid var(--rule);background:transparent;color:var(--metal);font-size:11px;letter-spacing:.22em;text-transform:uppercase;font-weight:500;display:flex;justify-content:space-between;align-items:center;transition:border-color 200ms ease,color 200ms ease}
.upgrade-summary::-webkit-details-marker{display:none}
.upgrade-summary:hover{border-color:var(--metal);color:var(--metal-glow)}
.up-chev{font-family:var(--serif);font-style:italic;font-size:20px;line-height:1;transition:transform 280ms ease;color:var(--metal)}
.upgrade-disclosure .upgrade{margin-top:0;border-top:none}
/* ── Map (browse the registry) ── */
.browse{padding:80px 48px;border-bottom:1px solid var(--rule)}
.browse-head{max-width:1200px;margin:0 auto 32px}
.browse-grid{max-width:1200px;margin:0 auto;display:grid;grid-template-columns:280px 1fr;gap:32px;height:520px}
@media (max-width:880px){.browse-grid{grid-template-columns:1fr;height:auto}}
.browse aside{padding:0;background:transparent;border:none}
.browse aside .filter-row{margin-bottom:20px}
.browse aside label{display:block;font-size:10px;letter-spacing:.22em;text-transform:uppercase;color:var(--ink-mute);font-weight:500;margin-bottom:8px}
.browse aside input{width:100%;padding:12px 14px;background:var(--noir-rise);border:1px solid var(--rule);color:var(--ink);font-family:var(--sans);font-size:14px;font-weight:300}
.browse .ledger-row{display:flex;justify-content:space-between;padding:8px 0;border-top:1px solid var(--rule-faint);font-size:12px;color:var(--ink-mute)}
.browse .ledger-row b{color:var(--metal);font-family:var(--serif);font-size:18px;font-weight:300;font-variant-numeric:tabular-nums}
#map{height:100%;background:var(--noir-deep);min-height:480px;border:1px solid var(--rule)}
.leaflet-container{background:var(--noir-deep)!important}
.leaflet-popup-content-wrapper{background:var(--noir-rise)!important;color:var(--ink)!important;border:1px solid var(--rule);border-radius:0!important;box-shadow:0 12px 32px rgba(0,0,0,0.5)}
.leaflet-popup-tip{background:var(--noir-rise)!important;border:1px solid var(--rule)}
.leaflet-popup-content{margin:14px 16px!important;font-family:var(--sans);font-size:13px;color:var(--ink)}
.leaflet-popup-content b{font-family:var(--serif);font-weight:400;font-size:15px;display:block;margin-bottom:4px;color:var(--ink)}
.leaflet-popup-content .pop-meta{color:var(--ink-mute);font-size:11px;letter-spacing:.04em}
.leaflet-control-attribution{background:rgba(10,10,12,0.85)!important;color:var(--ink-mute)!important;font-size:10px}
.leaflet-control-attribution a{color:var(--metal)!important}
.leaflet-control-zoom a{background:var(--noir-rise)!important;color:var(--ink)!important;border:1px solid var(--rule)!important;border-radius:0!important;width:30px!important;height:30px!important;line-height:30px!important;font-weight:300!important}
.leaflet-control-zoom a:hover{background:var(--rule)!important;color:var(--metal)!important}
/* ── Fallback inquiry (can't find self) ── */
.inquiry{padding:64px 48px;background:var(--noir-deep);border-top:1px solid var(--rule)}
.inquiry-grid{max-width:1000px;margin:0 auto;display:grid;grid-template-columns:1fr 1fr;gap:48px;align-items:start}
@media (max-width:760px){.inquiry-grid{grid-template-columns:1fr;gap:24px}}
.inquiry h3{font-family:var(--serif);font-weight:400;font-size:24px;margin:0 0 12px;color:var(--ink)}
.inquiry p{color:var(--ink-soft);font-size:14px;margin:0 0 12px;line-height:1.6}
.inquiry .stack{display:flex;flex-direction:column;gap:12px}
.inquiry label{font-size:10px;letter-spacing:.22em;text-transform:uppercase;color:var(--ink-mute);font-weight:500}
.inquiry input,.inquiry textarea{width:100%;padding:12px 14px;background:var(--noir-rise);border:1px solid var(--rule);color:var(--ink);font-family:var(--sans);font-weight:300;font-size:14px;margin-top:6px}
.inquiry textarea{min-height:100px;resize:vertical}
.inquiry button{padding:14px 22px;background:transparent;color:var(--metal);border:1px solid var(--metal);font-size:11px;letter-spacing:.18em;text-transform:uppercase;font-weight:500;cursor:pointer;align-self:flex-start;transition:all 220ms ease}
.inquiry button:hover{background:var(--metal);color:var(--noir)}
footer{padding:48px;border-top:1px solid var(--rule);background:var(--noir);color:var(--ink-mute);font-size:12px;text-align:center}
</style>
</head><body>
<header class="brand">
<a class="brand-mono" href="/"><svg viewBox="0 0 28 28" width="28" height="28" aria-hidden="true" style="flex:0 0 28px"><defs><linearGradient id="cb-dir" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#d4b683"/><stop offset="50%" stop-color="#b89968"/><stop offset="100%" stop-color="#8a7044"/></linearGradient></defs><circle cx="14" cy="14" r="13" fill="none" stroke="url(#cb-dir)" stroke-width="1"/><path fill="url(#cb-dir)" d="M9.6 9.5c-1.6 0-2.7 1.2-2.7 3v3c0 1.8 1.1 3 2.7 3 1.4 0 2.4-.8 2.6-2.1h-1.3c-.1.6-.6 1-1.3 1-.9 0-1.4-.6-1.4-1.7v-2.4c0-1.1.5-1.7 1.4-1.7.7 0 1.2.4 1.3 1h1.3c-.2-1.3-1.2-2.1-2.6-2.1zm5.5.1v8.7h2.7c1.6 0 2.6-.9 2.6-2.4 0-1-.5-1.7-1.3-2 .7-.3 1.1-1 1.1-1.8 0-1.5-1-2.5-2.5-2.5h-2.6zm1.3 1.1h1.2c.8 0 1.4.5 1.4 1.4 0 .9-.5 1.4-1.4 1.4h-1.2v-2.8zm0 3.9h1.4c.9 0 1.4.6 1.4 1.5s-.5 1.5-1.4 1.5h-1.4V14.6z"/></svg><h1 style="margin:0"><span style="color:var(--ink)">Counsel</span> <span class="amp">&</span> <span style="color:var(--ink)">Bar</span></h1></a>
<nav class="topnav">
<a href="/find-a-lawyer">Find counsel</a>
<a href="/directory">Directory</a>
<a href="/login">Sign in</a>
<a class="cta-mini" href="/signup">Claim your firm</a>
</nav>
</header>
<section class="hero">
<div class="dir-vol">
<span class="v-rule"></span>
<span class="v-text">Volume I</span>
<span class="v-meta">Edition 2026 · Attorney Self-Service</span>
</div>
<h1>Find your firm.<br>Audit your site.<br>Reserve an <em>EZ Upgrade.</em></h1>
<p class="lede">Type your name. We pull your State Bar listing, audit your current site against twelve published signals, and show you what the EZ Upgrade — same domain, dramatically better presentation — would look like. Typical go-live: seven business days from content intake.</p>
<div class="search-wrap">
<input class="search-input" id="search" placeholder="Type your name to find your firm in the registry" autocomplete="off">
<p class="search-hint">All 89,390 California-licensed attorneys searchable. Start with a last name.</p>
<div id="ac-list"></div>
</div>
</section>
<section class="result" id="result">
<div class="result-head">
<div class="name" id="r-name">—</div>
<div class="sub" id="r-sub">—</div>
</div>
<div class="result-grid">
<div class="panel">
<h3>Your current listing</h3>
<div class="field"><label>Bar #</label><span id="r-bar">—</span></div>
<div class="field"><label>Firm</label><span id="r-firm">—</span></div>
<div class="field"><label>Address</label><span id="r-addr">—</span></div>
<div class="field"><label>Phone</label><span id="r-phone">—</span></div>
<div class="field"><label>Website</label><span id="r-site">—</span></div>
<div class="field"><label>License</label><span id="r-status">—</span></div>
</div>
<div class="panel">
<h3>Site audit</h3>
<div id="r-audit-loading" class="no-audit">Loading audit…</div>
<div id="r-audit" style="display:none">
<div class="score-label">Site signals · <a href="/methodology.html" style="color:var(--ink-mute);border-bottom:1px dotted var(--rule);padding-bottom:1px">methodology v1.0 →</a></div>
<div class="score-display"><div class="num" id="r-score">—</div><div class="of">/ 100</div></div>
<div class="score-label" style="margin-top:18px">Top fixes the EZ Upgrade addresses</div>
<ul class="suggestions" id="r-suggestions"></ul>
</div>
<div id="r-no-audit" class="no-audit" style="display:none">
Site not yet audited. Methodology v1.0 covers twelve published signals (HTTPS, mobile viewport, schema markup, social card image, etc.) — we'll add this firm to the audit queue if you claim the listing.
</div>
</div>
</div>
<aside class="score-disclaimer">
<p class="sd-eyebrow">— What this score is and isn't —</p>
<p>The <strong>site signals</strong> figure measures twelve technical and presentation attributes of the firm's website (HTTPS, mobile viewport, schema markup, page weight, etc.) against published methodology v1.0. <strong>It is not a measure of legal skill, client outcomes, or professional reputation</strong> — those are not within the scope of any independent web audit. The audit is published independently of any commercial offering; <strong>purchasing the EZ Upgrade is in no way required to receive, view, or correct your score</strong>. To request removal or correction, email the address in the footer.</p>
</aside>
<details class="upgrade-disclosure">
<summary class="upgrade-summary">See what an EZ Upgrade looks like for this firm <span class="up-chev">›</span></summary>
<div class="upgrade">
<div class="upgrade-grid">
<div>
<h2>The <em>EZ Upgrade.</em></h2>
<p>An independent site-build service offered by Counsel & Bar. Same domain, dramatically better presentation. Available to any California-licensed attorney whether or not they're audited; the directory listing remains free either way.</p>
<ul>
<li>Editorial homepage rewrite, mobile-first</li>
<li>30-min Zoom-consult booking page (Stripe payouts go to you)</li>
<li>HTTPS, social card, schema markup, analytics — current standards</li>
<li>Typical go-live: seven business days from content intake</li>
<li>14-day revision window from first proof · no subscription · pay once</li>
</ul>
</div>
<div class="price-card">
<p class="amount">$499</p>
<p class="one-time">USD · one-time</p>
<p class="turnaround">Site-build service. <strong>Not</strong> a directory placement fee.</p>
<a class="cta-claim" id="cta-claim" href="/signup">Claim listing & reserve →</a>
</div>
</div>
</div>
</details>
</section>
<section class="browse">
<div class="browse-head">
<p class="eyebrow">Browse the registry</p>
<h2 style="font-family:var(--serif);font-weight:300;font-size:clamp(28px,3.5vw,40px);margin:0;letter-spacing:-.015em">42,636 firms, plotted.</h2>
</div>
<div class="browse-grid">
<aside>
<div class="filter-row">
<label for="f-city">City</label>
<input id="f-city" placeholder="Los Angeles · all">
</div>
<div class="ledger-row"><span>Showing</span><b id="stat-shown">—</b></div>
<div class="ledger-row"><span>With websites</span><b id="stat-sites">—</b></div>
<div class="ledger-row"><span>Cities represented</span><b id="stat-cities">—</b></div>
</aside>
<div id="map"></div>
</div>
</section>
<section class="inquiry">
<div class="inquiry-grid">
<div>
<h3>Can't find yourself?</h3>
<p>If you're recently licensed, recently changed firms, or your CalBar listing is otherwise stale, send us a note and we'll add you manually.</p>
</div>
<form class="stack" method="post" action="/directory/inquire">
<div>
<label for="iq-name">Your name</label>
<input id="iq-name" name="full_name" required>
</div>
<div>
<label for="iq-email">Email</label>
<input id="iq-email" name="email" type="email" required>
</div>
<div>
<label for="iq-msg">Tell us about your situation</label>
<textarea id="iq-msg" name="case_description" required maxlength="2000"></textarea>
</div>
<button type="submit">Send →</button>
</form>
</div>
</section>
<footer>© 2026 · Counsel <span style="color:var(--metal);font-style:italic;">&</span> Bar · Built from the public California State Bar registry</footer>
<script>
(function(){
function esc(s){ return String(s == null ? '' : s).replace(/[&<>"']/g, function(c){ return ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[c]; }); }
function scoreClass(s){
if (s == null) return 'tier-x';
if (s >= 90) return 'tier-a';
if (s >= 70) return 'tier-b';
if (s >= 50) return 'tier-c';
return 'tier-d';
}
// ── Autocomplete + selection
var inp = document.getElementById('search');
var list = document.getElementById('ac-list');
var t, sel = -1, items = [];
function render(rows){
items = rows;
if (!rows.length) { list.style.display = 'none'; list.innerHTML = ''; return; }
list.innerHTML = rows.map(function(r,i){
var meta = r.firm_name ? '<div class="meta">'+esc(r.firm_name)+(r.city?' · '+esc(r.city):'')+'</div>' : '';
var st = r.license_status ? '<span class="pill">'+esc(r.license_status)+'</span>' : '';
return '<div class="ac-row" data-i="'+i+'"><div class="nm">'+esc(r.full_name)+' #'+esc(r.bar_number)+st+'</div>'+meta+'</div>';
}).join('');
list.style.display = 'block';
sel = -1;
Array.prototype.forEach.call(list.querySelectorAll('.ac-row'), function(el){
el.addEventListener('mousedown', function(e){ e.preventDefault(); pick(parseInt(el.dataset.i, 10)); });
});
}
inp.addEventListener('input', function(){
clearTimeout(t);
var q = inp.value.trim();
if (q.length < 3) { list.style.display = 'none'; return; }
t = setTimeout(function(){
fetch('/api/professionals/search?q=' + encodeURIComponent(q))
.then(function(r){ return r.json(); })
.then(function(j){ render(j.rows || []); })
.catch(function(){ list.style.display = 'none'; });
}, 180);
});
inp.addEventListener('keydown', function(e){
if (list.style.display === 'none') return;
var rows = list.querySelectorAll('.ac-row');
if (e.key === 'ArrowDown'){ e.preventDefault(); sel = Math.min(rows.length-1, sel+1); }
else if (e.key === 'ArrowUp'){ e.preventDefault(); sel = Math.max(0, sel-1); }
else if (e.key === 'Enter' && sel >= 0){ e.preventDefault(); pick(sel); return; }
else if (e.key === 'Escape'){ list.style.display = 'none'; return; }
else return;
Array.prototype.forEach.call(rows, function(el, i){ el.classList.toggle('sel', i === sel); });
});
document.addEventListener('click', function(e){ if (!list.contains(e.target) && e.target !== inp) list.style.display = 'none'; });
function pick(i){
var r = items[i];
if (!r) return;
inp.value = r.full_name;
list.style.display = 'none';
showResult(r);
}
function showResult(r){
document.getElementById('r-name').innerHTML = esc(r.full_name) + ', <em>Esq.</em>';
document.getElementById('r-sub').textContent = 'CA Bar #' + r.bar_number + (r.license_status ? ' · ' + r.license_status : '');
document.getElementById('r-bar').textContent = r.bar_number || '—';
document.getElementById('r-firm').textContent = r.firm_name || 'Solo / unaffiliated';
document.getElementById('r-addr').textContent = (r.city ? esc(r.city) : '—') + (r.zip ? ' ' + esc(r.zip) : '');
document.getElementById('r-status').textContent = r.license_status || '—';
var siteEl = document.getElementById('r-site');
var phoneEl = document.getElementById('r-phone');
siteEl.textContent = '…'; phoneEl.textContent = '…';
document.getElementById('r-audit').style.display = 'none';
document.getElementById('r-no-audit').style.display = 'none';
document.getElementById('r-audit-loading').style.display = 'block';
document.getElementById('result').classList.add('visible');
setTimeout(function(){ document.getElementById('result').scrollIntoView({ behavior:'smooth', block:'start' }); }, 80);
var firmId = r.firm_id;
if (!firmId) {
siteEl.textContent = r.firm_website || '—';
phoneEl.textContent = r.firm_phone || '—';
document.getElementById('r-audit-loading').style.display = 'none';
document.getElementById('r-no-audit').style.display = 'block';
document.getElementById('cta-claim').href = '/signup';
return;
}
fetch('/api/firms/' + firmId + '/audit').then(function(res){ return res.json(); }).then(function(j){
var f = j.firm || {};
siteEl.innerHTML = f.website ? '<a href="'+esc(f.website)+'" target="_blank" rel="noopener">'+esc(f.website)+'</a>' : '—';
phoneEl.textContent = f.phone || '—';
document.getElementById('r-audit-loading').style.display = 'none';
var a = j.audit;
if (!a) {
document.getElementById('r-no-audit').style.display = 'block';
} else {
var scoreEl = document.getElementById('r-score');
scoreEl.textContent = (a.marketing_score == null ? '—' : a.marketing_score);
scoreEl.className = 'num ' + scoreClass(a.marketing_score);
var sugs = (a.suggestions || []).slice(0, 5);
document.getElementById('r-suggestions').innerHTML = sugs.length === 0
? '<li>Your site already passes our 12-signal audit. We can still rebuild for the booking flow + faster mobile.</li>'
: sugs.map(function(s){ return '<li>'+esc(s)+'</li>'; }).join('');
document.getElementById('r-audit').style.display = 'block';
}
// CTA carries the firm + name forward to /upgrade (prefill via query)
document.getElementById('cta-claim').href = '/upgrade?prefill=' + encodeURIComponent(JSON.stringify({
full_name: r.full_name, bar_number: r.bar_number,
firm_name: r.firm_name || '', website: f.website || '', phone: f.phone || ''
}));
});
}
// ── Map (browse the registry)
var map = L.map('map', { zoomControl: true }).setView([36.7783, -119.4179], 6);
L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap © CARTO', subdomains: 'abcd', maxZoom: 18
}).addTo(map);
var layer = L.layerGroup().addTo(map);
var FIRMS = [];
function pin(){
return L.divIcon({
className: 'firm-pin',
html: '<div style="width:7px;height:7px;background:#b89968;border:1px solid #050507;border-radius:50%;box-shadow:0 0 5px rgba(184,153,104,0.55)"></div>',
iconSize: [7,7], iconAnchor: [3.5,3.5]
});
}
function renderMap(){
layer.clearLayers();
var city = (document.getElementById('f-city').value || '').toLowerCase().trim();
var shown = 0, withSites = 0, cities = {};
FIRMS.forEach(function(f){
if (city && (f.city || '').toLowerCase().indexOf(city) === -1) return;
shown++; if (f.has_site) withSites++; if (f.city) cities[f.city] = 1;
var m = L.marker([f.lat, f.lng], { icon: pin() });
m.bindPopup('<b>'+esc(f.name)+'</b><div class="pop-meta">'+esc(f.city || '')+(f.zip ? ' · '+esc(f.zip) : '')+'</div>');
layer.addLayer(m);
});
document.getElementById('stat-shown').textContent = shown.toLocaleString();
document.getElementById('stat-sites').textContent = withSites.toLocaleString();
document.getElementById('stat-cities').textContent = Object.keys(cities).length.toLocaleString();
}
fetch('/api/firms.geo').then(function(r){ return r.json(); }).then(function(j){ FIRMS = j.rows || []; renderMap(); });
document.getElementById('f-city').addEventListener('input', renderMap);
})();
</script>
</body></html>`);
});
router.post('/directory/inquire', async (req, res) => {
if (!req.body || typeof req.body !== 'object') {
return res.status(400).send('Invalid request body');
}
const b = req.body as any;
try {
if (!b.full_name || !b.email || !b.case_description) {
throw new Error('Please fill all fields');
}
await query(`
INSERT INTO leads (full_name, email, case_description, practice_area, urgency, zip, consent_to_contact, ip, user_agent, source)
VALUES ($1, LOWER($2), $3, 'other', 'just_researching', NULL, true, $4, $5, 'general_inquiry')
`, [
String(b.full_name).trim().slice(0, 120),
String(b.email).trim().slice(0, 200),
String(b.case_description).trim().slice(0, 2000),
req.ip || null,
(req.headers['user-agent'] || '').toString().slice(0, 200),
]);
res.redirect('/directory?msg=' + encodeURIComponent('Got it. We’ll respond within two business days.'));
} catch (e) {
res.redirect('/directory?err=' + encodeURIComponent((e as Error).message));
}
});
export default router;