← back to Lawyer Directory Builder
Add 7 Express route modules: auth, admin, billing, community, directory, showcase, upgrade
4704a6f51a1b54039cc531d9094ef83a09040433 · 2026-05-03 23:19:55 -0700 · SteveStudio2
- src/server/auth.ts (144 LOC) — bcrypt session auth, /signup POST + /login POST
- src/server/admin.ts (537 LOC) — admin dashboard, queue review, manual veto on lead matches
- src/server/billing.ts (189 LOC) — Stripe checkout sessions, tier upgrades, webhook handler
- src/server/community.ts (652 LOC) — community board (questions/replies/views), per-tier gating
- src/server/directory.ts (493 LOC) — public firm/attorney detail pages
- src/server/showcase.ts (607 LOC) — mockup template gallery + per-firm rendered showcase
- src/server/upgrade.ts (331 LOC) — EZ Upgrade $499 funnel + post-checkout site provisioning
.gitignore: also exclude tmp/ (45 MB of walker scratch).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Files touched
M .gitignoreA src/server/admin.tsA src/server/auth.tsA src/server/billing.tsA src/server/community.tsA src/server/directory.tsA src/server/showcase.tsA src/server/upgrade.ts
Diff
commit 4704a6f51a1b54039cc531d9094ef83a09040433
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Sun May 3 23:19:55 2026 -0700
Add 7 Express route modules: auth, admin, billing, community, directory, showcase, upgrade
- src/server/auth.ts (144 LOC) — bcrypt session auth, /signup POST + /login POST
- src/server/admin.ts (537 LOC) — admin dashboard, queue review, manual veto on lead matches
- src/server/billing.ts (189 LOC) — Stripe checkout sessions, tier upgrades, webhook handler
- src/server/community.ts (652 LOC) — community board (questions/replies/views), per-tier gating
- src/server/directory.ts (493 LOC) — public firm/attorney detail pages
- src/server/showcase.ts (607 LOC) — mockup template gallery + per-firm rendered showcase
- src/server/upgrade.ts (331 LOC) — EZ Upgrade $499 funnel + post-checkout site provisioning
.gitignore: also exclude tmp/ (45 MB of walker scratch).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
.gitignore | 1 +
src/server/admin.ts | 537 +++++++++++++++++++++++++++++++++++++++
src/server/auth.ts | 144 +++++++++++
src/server/billing.ts | 189 ++++++++++++++
src/server/community.ts | 652 ++++++++++++++++++++++++++++++++++++++++++++++++
src/server/directory.ts | 493 ++++++++++++++++++++++++++++++++++++
src/server/showcase.ts | 607 ++++++++++++++++++++++++++++++++++++++++++++
src/server/upgrade.ts | 331 ++++++++++++++++++++++++
8 files changed, 2954 insertions(+)
diff --git a/.gitignore b/.gitignore
index b0aa65d..de73d19 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,3 +13,4 @@ public/qa-shots/
public/mockup-templates/
*.webm
*.mp4
+tmp/
diff --git a/src/server/admin.ts b/src/server/admin.ts
new file mode 100644
index 0000000..d31b472
--- /dev/null
+++ b/src/server/admin.ts
@@ -0,0 +1,537 @@
+/**
+ * Admin command center — Steve only (role='admin' OR tier='admin' on app_users).
+ *
+ * GET /admin — overview: users, ratings, threads, leads, mockups, pitches
+ * GET /admin/users — every user, tier filter, force-tier-change action
+ * GET /admin/ratings — every rating, hide/unhide
+ * GET /admin/threads — every thread + recent message preview
+ * GET /admin/leads — pitch-page replies
+ * GET /admin/pitches — every firm's /p/:id link with status
+ * POST /admin/users/:id/tier — force-tier-change (auth required)
+ * POST /admin/ratings/:id/hide — moderation (auth required)
+ *
+ * Tier check: requires req.user.role='admin' OR app_users.tier='admin'.
+ */
+import express from 'express';
+import { query } from '../db/pool.ts';
+import { refreshAggregate } from './community.ts';
+
+const router = express.Router();
+// Skip JSON parsing for /webhooks/* so Stripe raw-body handlers see a Buffer.
+router.use((req, res, next) => {
+ if (req.path.startsWith('/webhooks/')) return next();
+ return express.json()(req, res, next);
+});
+
+async function isAdmin(req: express.Request): Promise<boolean> {
+ if (!req.user) return false;
+ if (req.user.role === 'admin') return true;
+ const r = await query<{ tier: string }>(`SELECT tier FROM app_users WHERE id = $1`, [req.user.id]);
+ return r.rows[0]?.tier === 'admin';
+}
+
+router.use('/admin', async (req, res, next) => {
+ if (!(await isAdmin(req))) {
+ return res.status(403).send(`<!doctype html><body style="font:14px system-ui;color:#f4f1ea;background:#0a0a0c;display:flex;align-items:center;justify-content:center;min-height:100vh">
+ <div style="text-align:center"><h2 style="font-family:Cormorant Garamond,Georgia,serif;color:#b89968">Admin only</h2>
+ <p>Log in as admin to see this. <a href="/login" style="color:#b89968">→ Login</a></p></div></body>`);
+ }
+ next();
+});
+
+const esc = (s: any) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]!));
+
+const HEAD = `<!doctype html><html><head><meta charset="utf-8"><title>Admin · Lawyer Directory</title>
+<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
+<style>
+:root{--bg:#0a0a0c;--panel:#131316;--rule:#2a2724;--ink:#f4f1ea;--ink-mute:#8b857a;--metal:#b89968;--good:#10b981;--warn:#facc15;--bad:#ef4444}
+*,*::before,*::after{box-sizing:border-box}
+body{margin:0;font:14px/1.5 -apple-system,system-ui,sans-serif;background:var(--bg);color:var(--ink);-webkit-font-smoothing:antialiased}
+header{padding:18px 28px;background:var(--panel);border-bottom:1px solid var(--rule);display:flex;align-items:baseline;gap:24px;flex-wrap:wrap}
+h1{margin:0;font-family:Cormorant Garamond,Georgia,serif;font-weight:400;font-size:22px}
+h1 .accent{color:var(--metal)}
+nav{display:flex;gap:14px;color:var(--ink-mute);font-size:12px;letter-spacing:.1em;text-transform:uppercase;margin-left:auto}
+nav a{color:var(--ink-mute);text-decoration:none}
+nav a.active{color:var(--metal);border-bottom:1px solid var(--metal);padding-bottom:2px}
+.wrap{padding:24px 28px;max-width:1280px;margin:0 auto}
+.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:14px;margin-bottom:24px}
+.card{background:var(--panel);border:1px solid var(--rule);border-radius:6px;padding:18px;display:flex;flex-direction:column;gap:4px}
+.card .lbl{font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--ink-mute)}
+.card .val{font-family:Cormorant Garamond,Georgia,serif;font-weight:400;font-size:32px;color:var(--metal)}
+.card a{color:inherit;text-decoration:none}
+table{width:100%;border-collapse:collapse;font-size:13px;margin-top:8px}
+th{text-align:left;padding:10px 12px;background:var(--panel);border-bottom:1px solid var(--rule);font-weight:500;color:var(--ink-mute);text-transform:uppercase;font-size:11px;letter-spacing:.1em}
+td{padding:8px 12px;border-bottom:1px solid var(--rule);vertical-align:top}
+tr:hover td{background:var(--panel)}
+.tag{display:inline-block;padding:1px 7px;border-radius:3px;background:#1c2237;color:var(--metal);font-size:11px}
+.tag.client{background:#1c2237;color:var(--ink-mute)}
+.tag.lawyer{background:#173b2a;color:var(--good)}
+.tag.admin{background:#3b1515;color:var(--bad)}
+button{background:var(--bg);border:1px solid var(--rule);color:var(--ink);padding:4px 10px;font:inherit;font-size:11px;border-radius:3px;cursor:pointer}
+button:hover{border-color:var(--metal);color:var(--metal)}
+.empty{padding:48px;text-align:center;color:var(--ink-mute)}
+</style></head><body>`;
+
+function frame(active: string, body: string) {
+ return HEAD + `
+<header>
+ <h1>Admin <span class="accent">·</span> Lawyer Directory</h1>
+ <nav>
+ <a href="/admin" class="${active==='home'?'active':''}">Home</a>
+ <a href="/admin/users" class="${active==='users'?'active':''}">Users</a>
+ <a href="/admin/ratings" class="${active==='ratings'?'active':''}">Ratings</a>
+ <a href="/admin/threads" class="${active==='threads'?'active':''}">Threads</a>
+ <a href="/admin/leads" class="${active==='leads'?'active':''}">Leads</a>
+ <a href="/admin/pitches" class="${active==='pitches'?'active':''}">Pitches</a>
+ <a href="/admin/broadcast" class="${active==='broadcast'?'active':''}">Broadcast</a>
+ <a href="/admin/views" class="${active==='views'?'active':''}">Views</a>
+ <a href="/dashboard.html">↗ Dashboard</a>
+ </nav>
+</header>
+<div class="wrap">${body}</div>
+</body></html>`;
+}
+
+router.get('/admin', async (_req, res) => {
+ const r = await query<{ k: string; v: string }>(`
+ SELECT 'users' AS k, COUNT(*)::text AS v FROM app_users
+ UNION ALL SELECT 'users_lawyer', COUNT(*)::text FROM app_users WHERE tier='lawyer'
+ UNION ALL SELECT 'users_paid', COUNT(*)::text FROM app_users WHERE paid_until > NOW()
+ UNION ALL SELECT 'ratings', COUNT(*)::text FROM ratings
+ UNION ALL SELECT 'ratings_user', COUNT(*)::text FROM ratings WHERE source='user'
+ UNION ALL SELECT 'threads', COUNT(*)::text FROM threads
+ UNION ALL SELECT 'messages', COUNT(*)::text FROM messages
+ UNION ALL SELECT 'leads', COUNT(*)::text FROM leads
+ UNION ALL SELECT 'mockups', COUNT(*)::text FROM site_mockups
+ UNION ALL SELECT 'mockups_llm', COUNT(*)::text FROM site_mockups WHERE variant='x'
+ UNION ALL SELECT 'firms', COUNT(*)::text FROM organizations WHERE type='law_firm'
+ UNION ALL SELECT 'attorneys', COUNT(*)::text FROM professionals
+ UNION ALL SELECT 'audits_low', COUNT(*)::text FROM site_audits WHERE marketing_score < 50
+ `);
+ const m: Record<string, number> = {};
+ for (const row of r.rows) m[row.k] = parseInt(row.v, 10);
+ const card = (lbl: string, val: number, href?: string) =>
+ `<div class="card"><div class="lbl">${esc(lbl)}</div><div class="val">${href?`<a href="${href}">`:''}${val.toLocaleString()}${href?'</a>':''}</div></div>`;
+ res.send(frame('home', `
+ <h2 style="font-family:Cormorant Garamond,Georgia,serif;font-weight:400;color:var(--metal);margin:0 0 16px">Overview</h2>
+ <div class="cards">
+ ${card('Users', m.users, '/admin/users')}
+ ${card('Lawyer tier', m.users_lawyer, '/admin/users')}
+ ${card('Paid', m.users_paid)}
+ ${card('Ratings', m.ratings, '/admin/ratings')}
+ ${card('User reviews', m.ratings_user)}
+ ${card('Threads', m.threads, '/admin/threads')}
+ ${card('Messages', m.messages)}
+ ${card('Leads', m.leads, '/admin/leads')}
+ ${card('Mockups', m.mockups, '/mockups')}
+ ${card('LLM mockups', m.mockups_llm, '/mockups?variant=x')}
+ ${card('Low-audit firms', m.audits_low)}
+ </div>
+ <div style="margin-top:24px;color:var(--ink-mute);font-size:12px">
+ Live: <a href="/dashboard.html" style="color:var(--metal)">Dashboard</a> ·
+ <a href="/community" style="color:var(--metal)">Community</a> ·
+ <a href="/mockups" style="color:var(--metal)">Mockup gallery</a> ·
+ <a href="/qa-shots/" style="color:var(--metal)">QA shots</a>
+ </div>
+ `));
+});
+
+router.get('/admin/users', async (_req, res) => {
+ const r = await query<any>(`
+ SELECT u.id, u.email, u.full_name, u.tier, u.role, u.plan, u.bar_number, u.bar_verified_at,
+ u.claimed_professional_id, u.paid_until, u.created_at, u.last_login_at,
+ p.full_name AS pro_name
+ FROM app_users u
+ LEFT JOIN professionals p ON p.id = u.claimed_professional_id
+ ORDER BY u.created_at DESC LIMIT 200
+ `);
+ const rows = r.rows.map(u => `<tr>
+ <td>${u.id}</td>
+ <td>${esc(u.email)}</td>
+ <td>${esc(u.full_name||'')}</td>
+ <td><span class="tag ${esc(u.tier||'client')}">${esc(u.tier||'?')}</span></td>
+ <td>${esc(u.bar_number||'')} ${u.pro_name?`<a href="/attorneys/${u.claimed_professional_id}" style="color:var(--metal)">${esc(u.pro_name)}</a>`:''}</td>
+ <td>${u.paid_until?esc(String(u.paid_until).slice(0,10)):'—'}</td>
+ <td>${esc(String(u.created_at).slice(0,10))}</td>
+ <td>
+ <button onclick="setTier(${u.id},'client')">→ client</button>
+ <button onclick="setTier(${u.id},'lawyer')">→ lawyer</button>
+ <button onclick="setTier(${u.id},'admin')">→ admin</button>
+ </td>
+ </tr>`).join('');
+ res.send(frame('users', `
+ <h2 style="font-family:Cormorant Garamond,Georgia,serif;font-weight:400;color:var(--metal);margin:0 0 16px">Users (${r.rowCount})</h2>
+ <table>
+ <thead><tr><th>ID</th><th>Email</th><th>Name</th><th>Tier</th><th>Bar / Claim</th><th>Paid until</th><th>Joined</th><th>Actions</th></tr></thead>
+ <tbody>${rows || '<tr><td colspan="8" class="empty">No users yet — sign up at /signup</td></tr>'}</tbody>
+ </table>
+ <script>
+ async function setTier(id, tier) {
+ const r = await fetch('/admin/users/'+id+'/tier', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({tier}) }).then(r=>r.json());
+ if (r.ok) location.reload(); else alert(JSON.stringify(r));
+ }
+ </script>
+ `));
+});
+
+router.post('/admin/users/:id/tier', async (req, res) => {
+ const id = parseInt(req.params.id, 10);
+ const tier = String(req.body?.tier || '');
+ if (!['client','lawyer','admin','guest'].includes(tier)) return res.status(400).json({ error: 'bad_tier' });
+ await query(`UPDATE app_users SET tier = $1 WHERE id = $2`, [tier, id]);
+ res.json({ ok: true, id, tier });
+});
+
+router.get('/admin/ratings', async (_req, res) => {
+ const r = await query<any>(`
+ SELECT r.id, r.source, r.stars, r.service_score, r.price_score, r.quality_score, r.comment, r.posted_at, r.hidden_at,
+ o.id AS org_id, o.name AS firm_name, p.id AS pro_id, p.full_name AS pro_name,
+ u.email AS reviewer_email
+ FROM ratings r
+ LEFT JOIN organizations o ON o.id = r.organization_id
+ LEFT JOIN professionals p ON p.id = r.professional_id
+ LEFT JOIN app_users u ON u.id = r.reviewer_user_id
+ ORDER BY r.posted_at DESC LIMIT 200
+ `);
+ const rows = r.rows.map(rt => `<tr>
+ <td>${rt.id}</td>
+ <td><span class="tag">${esc(rt.source)}</span></td>
+ <td style="color:${Number(rt.stars)>=4?'var(--good)':Number(rt.stars)>=3?'var(--warn)':'var(--bad)'}">★ ${Number(rt.stars).toFixed(1)}</td>
+ <td>${rt.firm_name?`<a href="/firms/${rt.org_id}" style="color:var(--metal)">${esc(rt.firm_name)}</a>`:rt.pro_name?`<a href="/attorneys/${rt.pro_id}" style="color:var(--metal)">${esc(rt.pro_name)}</a>`:'—'}</td>
+ <td>${esc(rt.reviewer_email||'')}</td>
+ <td style="max-width:380px;overflow:hidden;text-overflow:ellipsis">${esc((rt.comment||'').slice(0,100))}</td>
+ <td>${esc(String(rt.posted_at).slice(0,10))}</td>
+ <td>${rt.hidden_at?`<span style="color:var(--bad)">hidden</span>`:`<button onclick="hide(${rt.id})">hide</button>`}</td>
+ </tr>`).join('');
+ res.send(frame('ratings', `
+ <h2 style="font-family:Cormorant Garamond,Georgia,serif;font-weight:400;color:var(--metal);margin:0 0 16px">Ratings (${r.rowCount})</h2>
+ <table>
+ <thead><tr><th>ID</th><th>Source</th><th>Stars</th><th>Target</th><th>Reviewer</th><th>Comment</th><th>Posted</th><th>Action</th></tr></thead>
+ <tbody>${rows || '<tr><td colspan="8" class="empty">No ratings yet — submit at /firms/:id</td></tr>'}</tbody>
+ </table>
+ <script>
+ async function hide(id) {
+ const r = await fetch('/admin/ratings/'+id+'/hide', { method:'POST' }).then(r=>r.json());
+ if (r.ok) location.reload(); else alert(JSON.stringify(r));
+ }
+ </script>
+ `));
+});
+
+router.post('/admin/ratings/:id/hide', async (req, res) => {
+ const id = parseInt(req.params.id, 10);
+ // Look up the target so we can refresh its cached aggregate after hiding.
+ const t = await query<{ professional_id: number | null; organization_id: number | null }>(
+ `SELECT professional_id, organization_id FROM ratings WHERE id = $1`, [id]);
+ await query(`UPDATE ratings SET hidden_at = NOW(), hidden_reason = 'admin_hide' WHERE id = $1`, [id]);
+ const row = t.rows[0];
+ if (row?.professional_id) await refreshAggregate('professional', row.professional_id);
+ else if (row?.organization_id) await refreshAggregate('organization', row.organization_id);
+ res.json({ ok: true });
+});
+
+router.get('/admin/threads', async (_req, res) => {
+ const r = await query<any>(`
+ SELECT t.id, t.channel, t.topic, t.message_count, t.last_message_at, t.created_at,
+ o.name AS firm_name, t.organization_id,
+ (SELECT body FROM messages WHERE thread_id=t.id ORDER BY posted_at DESC LIMIT 1) AS last_msg
+ FROM threads t LEFT JOIN organizations o ON o.id = t.organization_id
+ ORDER BY t.last_message_at DESC LIMIT 200
+ `);
+ const rows = r.rows.map(t => `<tr>
+ <td>${t.id}</td>
+ <td><span class="tag">${esc(t.channel)}</span></td>
+ <td>${esc(t.topic||'(none)')}</td>
+ <td>${t.message_count}</td>
+ <td style="max-width:300px;overflow:hidden;text-overflow:ellipsis;color:var(--ink-mute)">${esc((t.last_msg||'').slice(0,80))}</td>
+ <td>${t.firm_name?`<a href="/firms/${t.organization_id}" style="color:var(--metal)">${esc(t.firm_name)}</a>`:''}</td>
+ <td>${esc(String(t.last_message_at).slice(0,16))}</td>
+ </tr>`).join('');
+ res.send(frame('threads', `
+ <h2 style="font-family:Cormorant Garamond,Georgia,serif;font-weight:400;color:var(--metal);margin:0 0 16px">Threads (${r.rowCount})</h2>
+ <table>
+ <thead><tr><th>ID</th><th>Channel</th><th>Topic</th><th>Msgs</th><th>Last</th><th>Firm</th><th>Last activity</th></tr></thead>
+ <tbody>${rows || '<tr><td colspan="7" class="empty">No threads yet — start one at /community</td></tr>'}</tbody>
+ </table>
+ `));
+});
+
+router.get('/admin/leads', async (_req, res) => {
+ const r = await query<any>(`
+ SELECT l.id, l.organization_id, l.source, l.contact_name, l.contact_email, l.message, l.received_at, l.status,
+ o.name AS firm_name
+ FROM leads l LEFT JOIN organizations o ON o.id = l.organization_id
+ ORDER BY l.received_at DESC LIMIT 200
+ `);
+ const rows = r.rows.map(l => `<tr>
+ <td>${l.id}</td>
+ <td><span class="tag">${esc(l.source||'')}</span></td>
+ <td>${l.firm_name?`<a href="/firms/${l.organization_id}" style="color:var(--metal)">${esc(l.firm_name)}</a>`:''}</td>
+ <td>${esc(l.contact_name||'')}<br><small style="color:var(--ink-mute)">${esc(l.contact_email||'')}</small></td>
+ <td style="max-width:380px;overflow:hidden;text-overflow:ellipsis">${esc((l.message||'').slice(0,200))}</td>
+ <td>${esc(String(l.received_at).slice(0,16))}</td>
+ <td><span class="tag">${esc(l.status||'')}</span></td>
+ </tr>`).join('');
+ res.send(frame('leads', `
+ <h2 style="font-family:Cormorant Garamond,Georgia,serif;font-weight:400;color:var(--metal);margin:0 0 16px">Leads (${r.rowCount})</h2>
+ <table>
+ <thead><tr><th>ID</th><th>Source</th><th>Firm</th><th>Contact</th><th>Message</th><th>Received</th><th>Status</th></tr></thead>
+ <tbody>${rows || '<tr><td colspan="7" class="empty">No leads yet — replies via /p/:firm_id come here</td></tr>'}</tbody>
+ </table>
+ `));
+});
+
+router.get('/admin/pitches', async (req, res) => {
+ const filter = String(req.query.filter || 'low_score');
+ let where: string;
+ if (filter === 'low_score') {
+ where = `EXISTS (SELECT 1 FROM site_audits a WHERE a.organization_id=o.id AND a.marketing_score IS NOT NULL AND a.marketing_score < 50)`;
+ } else if (filter === 'has_mockup_x') {
+ where = `EXISTS (SELECT 1 FROM site_mockups m WHERE m.organization_id=o.id AND m.variant='x')`;
+ } else if (filter === 'has_phone') {
+ where = `o.phone IS NOT NULL`;
+ } else {
+ where = `o.website IS NOT NULL`;
+ }
+ const r = await query<any>(`
+ SELECT o.id, o.name, o.website, o.phone, o.city,
+ (SELECT marketing_score FROM site_audits a WHERE a.organization_id=o.id AND a.status_code BETWEEN 200 AND 399 ORDER BY a.audited_at DESC LIMIT 1) AS audit_score,
+ (SELECT COUNT(*) FROM site_mockups m WHERE m.organization_id=o.id) AS mock_count,
+ (SELECT COUNT(*) FROM emails e WHERE e.organization_id=o.id) AS email_count
+ FROM organizations o
+ WHERE o.type='law_firm' AND ${where}
+ ORDER BY (SELECT marketing_score FROM site_audits a WHERE a.organization_id=o.id AND a.status_code BETWEEN 200 AND 399 ORDER BY a.audited_at DESC LIMIT 1) ASC NULLS LAST
+ LIMIT 200
+ `);
+ const rows = r.rows.map(f => {
+ const sc = f.audit_score == null ? null : Number(f.audit_score);
+ const c = sc == null ? 'var(--ink-mute)' : sc >= 70 ? 'var(--good)' : sc >= 50 ? 'var(--warn)' : 'var(--bad)';
+ return `<tr>
+ <td>${f.id}</td>
+ <td><a href="/firms/${f.id}" style="color:var(--metal)" target="_blank">${esc(f.name)}</a></td>
+ <td>${esc(f.city||'')}</td>
+ <td style="color:${c};font-weight:600">${sc==null?'—':sc}</td>
+ <td>${f.mock_count}</td>
+ <td>${f.email_count}</td>
+ <td>${esc(f.phone||'')}</td>
+ <td><a href="/p/${f.id}" target="_blank" style="color:var(--metal)">→ /p/${f.id}</a></td>
+ </tr>`;
+ }).join('');
+ res.send(frame('pitches', `
+ <h2 style="font-family:Cormorant Garamond,Georgia,serif;font-weight:400;color:var(--metal);margin:0 0 16px">Pitches (${r.rowCount})</h2>
+ <div style="margin-bottom:12px;color:var(--ink-mute);font-size:12px">
+ Filter:
+ <a href="?filter=low_score" style="color:${filter==='low_score'?'var(--metal)':'var(--ink-mute)'}">Low audit score</a> ·
+ <a href="?filter=has_mockup_x" style="color:${filter==='has_mockup_x'?'var(--metal)':'var(--ink-mute)'}">Has LLM mockup</a> ·
+ <a href="?filter=has_phone" style="color:${filter==='has_phone'?'var(--metal)':'var(--ink-mute)'}">Has phone</a> ·
+ <a href="?filter=all" style="color:${filter==='all'?'var(--metal)':'var(--ink-mute)'}">All with site</a>
+ </div>
+ <table>
+ <thead><tr><th>ID</th><th>Firm</th><th>City</th><th>Audit</th><th>Mocks</th><th>Emails</th><th>Phone</th><th>Pitch URL</th></tr></thead>
+ <tbody>${rows}</tbody>
+ </table>
+ `));
+});
+
+// ─── /admin/views — pitch-page open tracking ──────────────────────────────
+router.get('/admin/views', async (_req, res) => {
+ const totals = await query<{ k: string; v: string }>(`
+ SELECT 'total' AS k, COUNT(*)::text AS v FROM pitch_views
+ UNION ALL SELECT 'unique_firms', COUNT(DISTINCT organization_id)::text FROM pitch_views
+ UNION ALL SELECT 'last_24h', COUNT(*)::text FROM pitch_views WHERE viewed_at > NOW() - INTERVAL '24 hours'
+ UNION ALL SELECT 'with_utm', COUNT(*)::text FROM pitch_views WHERE utm_source IS NOT NULL
+ `);
+ const t: Record<string, number> = {};
+ for (const row of totals.rows) t[row.k] = parseInt(row.v, 10);
+
+ const top = await query<any>(`
+ SELECT v.organization_id AS org_id, o.name AS firm_name,
+ COUNT(*)::int AS views,
+ MAX(v.viewed_at) AS last_view,
+ ARRAY_AGG(DISTINCT v.utm_source) FILTER (WHERE v.utm_source IS NOT NULL) AS sources
+ FROM pitch_views v JOIN organizations o ON o.id = v.organization_id
+ GROUP BY v.organization_id, o.name
+ ORDER BY views DESC, last_view DESC
+ LIMIT 100
+ `);
+ const recent = await query<any>(`
+ SELECT v.id, v.organization_id, o.name AS firm_name, v.visitor_ip, v.referrer, v.utm_source, v.viewed_at
+ FROM pitch_views v JOIN organizations o ON o.id = v.organization_id
+ ORDER BY v.viewed_at DESC LIMIT 50
+ `);
+
+ const card = (lbl: string, val: number) =>
+ `<div class="card"><div class="lbl">${esc(lbl)}</div><div class="val">${val.toLocaleString()}</div></div>`;
+ const topRows = top.rows.map(r => `<tr>
+ <td><a href="/firms/${r.org_id}" style="color:var(--metal)" target="_blank">${esc(r.firm_name)}</a></td>
+ <td>${r.views}</td>
+ <td>${esc(String(r.last_view).slice(0,16))}</td>
+ <td>${(r.sources || []).map((s: string) => `<span class="tag">${esc(s)}</span>`).join(' ')}</td>
+ <td><a href="/p/${r.org_id}" target="_blank" style="color:var(--metal)">→ /p/${r.org_id}</a></td>
+ </tr>`).join('');
+ const recentRows = recent.rows.map(r => `<tr>
+ <td>${esc(String(r.viewed_at).slice(0,19))}</td>
+ <td><a href="/p/${r.organization_id}" style="color:var(--metal)">${esc(r.firm_name)}</a></td>
+ <td><span class="tag">${esc(r.utm_source||'organic')}</span></td>
+ <td style="max-width:240px;overflow:hidden;text-overflow:ellipsis;color:var(--ink-mute)">${esc(r.referrer||'')}</td>
+ <td style="color:var(--ink-mute);font-family:monospace;font-size:11px">${esc(r.visitor_ip||'')}</td>
+ </tr>`).join('');
+
+ res.send(frame('views', `
+ <h2 style="font-family:Cormorant Garamond,Georgia,serif;font-weight:400;color:var(--metal);margin:0 0 16px">Pitch-page views</h2>
+ <div class="cards">
+ ${card('Total views', t.total||0)}
+ ${card('Unique firms', t.unique_firms||0)}
+ ${card('Last 24h', t.last_24h||0)}
+ ${card('With UTM', t.with_utm||0)}
+ </div>
+ <h3 style="font-family:Cormorant Garamond,Georgia,serif;font-weight:400;color:var(--metal);margin:24px 0 8px;font-size:18px">Top firms by views</h3>
+ <table>
+ <thead><tr><th>Firm</th><th>Views</th><th>Last view</th><th>UTM sources</th><th>Pitch URL</th></tr></thead>
+ <tbody>${topRows || '<tr><td colspan="5" class="empty">No views yet — share a /p/:id URL.</td></tr>'}</tbody>
+ </table>
+ <h3 style="font-family:Cormorant Garamond,Georgia,serif;font-weight:400;color:var(--metal);margin:24px 0 8px;font-size:18px">Recent views</h3>
+ <table>
+ <thead><tr><th>When</th><th>Firm</th><th>UTM</th><th>Referrer</th><th>IP</th></tr></thead>
+ <tbody>${recentRows || '<tr><td colspan="5" class="empty">—</td></tr>'}</tbody>
+ </table>
+ `));
+});
+
+// ─── /admin/broadcast — bulk pitch composer ────────────────────────────────
+router.get('/admin/broadcast', async (req, res) => {
+ const filter = String(req.query.filter || 'low_score_with_email');
+ const limit = Math.min(parseInt((req.query.limit as string) || '100', 10), 1000);
+ const PUBLIC = process.env.PUBLIC_BASE_URL || 'http://localhost:9701';
+
+ let where: string;
+ if (filter === 'low_score_with_email') {
+ where = `EXISTS (SELECT 1 FROM site_audits a WHERE a.organization_id=o.id AND a.marketing_score IS NOT NULL AND a.marketing_score < 50)
+ AND EXISTS (SELECT 1 FROM emails e WHERE e.organization_id=o.id)`;
+ } else if (filter === 'has_mockup_with_email') {
+ where = `EXISTS (SELECT 1 FROM site_mockups m WHERE m.organization_id=o.id AND m.variant='x')
+ AND EXISTS (SELECT 1 FROM emails e WHERE e.organization_id=o.id)`;
+ } else if (filter === 'no_audit_no_email') {
+ where = `o.website IS NULL`;
+ } else {
+ where = `EXISTS (SELECT 1 FROM emails e WHERE e.organization_id=o.id)`;
+ }
+
+ const r = await query<any>(`
+ SELECT o.id, o.name, o.city, o.website,
+ (SELECT marketing_score FROM site_audits a WHERE a.organization_id=o.id AND a.status_code BETWEEN 200 AND 399 ORDER BY a.audited_at DESC LIMIT 1) AS audit_score,
+ (SELECT COUNT(*)::int FROM site_mockups m WHERE m.organization_id=o.id AND m.variant='x') AS has_x,
+ (SELECT email FROM emails e WHERE e.organization_id=o.id ORDER BY id ASC LIMIT 1) AS first_email,
+ (SELECT COUNT(*)::int FROM emails e WHERE e.organization_id=o.id) AS email_count
+ FROM organizations o
+ WHERE o.type='law_firm' AND ${where}
+ ORDER BY (SELECT marketing_score FROM site_audits a WHERE a.organization_id=o.id AND a.status_code BETWEEN 200 AND 399 ORDER BY a.audited_at DESC LIMIT 1) ASC NULLS LAST
+ LIMIT $1
+ `, [limit]);
+
+ const rows = r.rows.map(f => {
+ const sc = f.audit_score == null ? null : Number(f.audit_score);
+ const subj = `For ${f.name} — your website, side by side with what it could look like`;
+ const body =
+`Hi,
+
+I came across ${f.name}'s website${f.website ? ` (${f.website.replace(/^https?:\/\//,'')})` : ''} and built a redesign concept.
+
+No call, no slide deck, no pitch. Just look:
+${PUBLIC}/p/${f.id}
+
+Your current site is on the left, the redesign on the right. If it's interesting, the page has a reply box that comes straight to me. If not, ignore this — no follow-ups.
+
+— Steve Abrams
+ Entrepreneur, designer, small-business owner
+ hello@agentabrams.com`;
+ const mailto = `mailto:${f.first_email}?subject=${encodeURIComponent(subj)}&body=${encodeURIComponent(body)}`;
+ return `<tr>
+ <td>${f.id}</td>
+ <td><a href="/firms/${f.id}" style="color:var(--metal)" target="_blank">${esc(f.name)}</a></td>
+ <td>${esc(f.city||'')}</td>
+ <td style="color:${sc==null?'var(--ink-mute)':sc>=70?'var(--good)':sc>=50?'var(--warn)':'var(--bad)'};font-weight:600">${sc==null?'—':sc}</td>
+ <td>${f.has_x ? '<span style="color:var(--good)">✓</span>' : '—'}</td>
+ <td>${esc(f.first_email||'')}${f.email_count > 1 ? ` <span style="color:var(--ink-mute)">+${f.email_count - 1}</span>` : ''}</td>
+ <td>
+ <a href="${esc(mailto)}" target="_blank" style="color:var(--metal);text-decoration:none">✉ open</a> ·
+ <a href="/p/${f.id}" target="_blank" style="color:var(--metal);text-decoration:none">→ /p/${f.id}</a>
+ </td>
+ </tr>`;
+ }).join('');
+
+ // CSV button → calls /admin/broadcast.csv with the same filter
+ res.send(frame('broadcast', `
+ <h2 style="font-family:Cormorant Garamond,Georgia,serif;font-weight:400;color:var(--metal);margin:0 0 8px">Broadcast composer</h2>
+ <p style="color:var(--ink-mute);margin:0 0 16px;font-size:13px">
+ Generate per-firm pitch emails. Clicking <b>✉ open</b> opens your default mail client with the body prefilled.
+ <a href="/admin/broadcast.csv?filter=${esc(filter)}" style="color:var(--metal);margin-left:14px">⬇ Download CSV</a>
+ </p>
+ <div style="margin-bottom:16px;color:var(--ink-mute);font-size:12px">
+ Filter:
+ <a href="?filter=low_score_with_email" style="color:${filter==='low_score_with_email'?'var(--metal)':'var(--ink-mute)'}">Low audit + has email</a> ·
+ <a href="?filter=has_mockup_with_email" style="color:${filter==='has_mockup_with_email'?'var(--metal)':'var(--ink-mute)'}">Has LLM mockup + email</a> ·
+ <a href="?filter=any_with_email" style="color:${filter==='any_with_email'?'var(--metal)':'var(--ink-mute)'}">Any with email</a>
+ </div>
+ <table>
+ <thead><tr><th>ID</th><th>Firm</th><th>City</th><th>Audit</th><th>LLM mock</th><th>Email</th><th>Actions</th></tr></thead>
+ <tbody>${rows || '<tr><td colspan="7" class="empty">No matches.</td></tr>'}</tbody>
+ </table>
+ <p style="color:var(--ink-mute);font-size:12px;margin-top:18px">
+ Showing ${r.rowCount} of up to ${limit}.
+ Slice-B: bulk-send via PurelyMail SMTP coming once that password is wired into secrets.
+ </p>
+ `));
+});
+
+router.get('/admin/broadcast.csv', async (req, res) => {
+ const filter = String(req.query.filter || 'low_score_with_email');
+ const PUBLIC = process.env.PUBLIC_BASE_URL || 'http://localhost:9701';
+
+ let where: string;
+ if (filter === 'low_score_with_email') {
+ where = `EXISTS (SELECT 1 FROM site_audits a WHERE a.organization_id=o.id AND a.marketing_score IS NOT NULL AND a.marketing_score < 50)
+ AND EXISTS (SELECT 1 FROM emails e WHERE e.organization_id=o.id)`;
+ } else if (filter === 'has_mockup_with_email') {
+ where = `EXISTS (SELECT 1 FROM site_mockups m WHERE m.organization_id=o.id AND m.variant='x')
+ AND EXISTS (SELECT 1 FROM emails e WHERE e.organization_id=o.id)`;
+ } else {
+ where = `EXISTS (SELECT 1 FROM emails e WHERE e.organization_id=o.id)`;
+ }
+
+ const r = await query<any>(`
+ SELECT o.id, o.name, o.city, o.website,
+ (SELECT marketing_score FROM site_audits a WHERE a.organization_id=o.id AND a.status_code BETWEEN 200 AND 399 ORDER BY a.audited_at DESC LIMIT 1) AS audit_score,
+ (SELECT email FROM emails e WHERE e.organization_id=o.id ORDER BY id ASC LIMIT 1) AS first_email
+ FROM organizations o
+ WHERE o.type='law_firm' AND ${where}
+ ORDER BY (SELECT marketing_score FROM site_audits a WHERE a.organization_id=o.id AND a.status_code BETWEEN 200 AND 399 ORDER BY a.audited_at DESC LIMIT 1) ASC NULLS LAST
+ LIMIT 5000
+ `);
+
+ const csvEsc = (s: any) => {
+ const v = String(s == null ? '' : s);
+ return /[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v;
+ };
+ const subjFor = (f: any) => `For ${f.name} — your website, side by side with what it could look like`;
+ const bodyFor = (f: any) => `Hi,\n\nI came across ${f.name}'s website${f.website ? ` (${f.website.replace(/^https?:\/\//,'')})` : ''} and built a redesign concept.\n\nNo call, no slide deck, no pitch. Just look:\n${PUBLIC}/p/${f.id}\n\nYour current site is on the left, the redesign on the right. If it's interesting, the page has a reply box that comes straight to me. If not, ignore this — no follow-ups.\n\n— Steve Abrams\n Entrepreneur, designer, small-business owner\n hello@agentabrams.com`;
+
+ const lines = ['firm_id,firm_name,city,recipient_email,audit_score,pitch_url,subject,body_text'];
+ for (const f of r.rows) {
+ lines.push([
+ f.id,
+ csvEsc(f.name),
+ csvEsc(f.city || ''),
+ csvEsc(f.first_email),
+ f.audit_score == null ? '' : f.audit_score,
+ `${PUBLIC}/p/${f.id}`,
+ csvEsc(subjFor(f)),
+ csvEsc(bodyFor(f)),
+ ].join(','));
+ }
+ res.setHeader('Content-Type', 'text/csv; charset=utf-8');
+ res.setHeader('Content-Disposition', `attachment; filename="lawyer-pitch-batch-${filter}-${new Date().toISOString().slice(0,10)}.csv"`);
+ res.send(lines.join('\n'));
+});
+
+export default router;
diff --git a/src/server/auth.ts b/src/server/auth.ts
new file mode 100644
index 0000000..42b87af
--- /dev/null
+++ b/src/server/auth.ts
@@ -0,0 +1,144 @@
+/**
+ * Auth — cookie-session, bcrypt password hashing, role-gated middleware.
+ * Sessions live in PG (`app_sessions`); the cookie just holds the session id.
+ */
+import bcrypt from 'bcryptjs';
+import crypto from 'node:crypto';
+import cookie from 'cookie';
+import type { Request, Response, NextFunction } from 'express';
+import { query } from '../db/pool.ts';
+
+const COOKIE_NAME = 'sid';
+const SESSION_TTL_DAYS = 30;
+const BCRYPT_ROUNDS = 10;
+
+export type AppUser = {
+ id: number;
+ email: string;
+ full_name: string | null;
+ role: 'user' | 'admin';
+ plan: 'free' | 'premium' | 'pro';
+ status: 'active' | 'suspended' | 'deleted';
+ organization_id: number | null;
+ professional_id: number | null;
+ bar_number: string | null;
+ firm_name: string | null;
+ website: string | null;
+ phone: string | null;
+ created_at: string;
+ last_login_at: string | null;
+ tier?: 'guest' | 'client' | 'lawyer' | 'admin' | null;
+};
+
+declare module 'express-serve-static-core' {
+ interface Request {
+ user?: AppUser;
+ }
+}
+
+export async function hashPassword(plain: string): Promise<string> {
+ return bcrypt.hash(plain, BCRYPT_ROUNDS);
+}
+
+export async function verifyPassword(plain: string, hash: string): Promise<boolean> {
+ return bcrypt.compare(plain, hash);
+}
+
+export async function createUser(opts: {
+ email: string; password: string;
+ full_name?: string; role?: 'user' | 'admin';
+ bar_number?: string; firm_name?: string; website?: string; phone?: string;
+}) {
+ const password_hash = await hashPassword(opts.password);
+ const r = await query<{ id: number }>(`
+ INSERT INTO app_users (email, password_hash, full_name, role, bar_number, firm_name, website, phone)
+ VALUES (LOWER($1), $2, $3, COALESCE($4,'user'), $5, $6, $7, $8)
+ RETURNING id
+ `, [
+ opts.email.trim(), password_hash,
+ opts.full_name || null, opts.role || 'user',
+ opts.bar_number || null,
+ opts.firm_name || null, opts.website || null, opts.phone || null,
+ ]);
+ return r.rows[0].id;
+}
+
+export async function findUserByEmail(email: string): Promise<{ id: number; password_hash: string; status: string } | null> {
+ const r = await query<{ id: number; password_hash: string; status: string }>(
+ `SELECT id, password_hash, status FROM app_users WHERE LOWER(email) = LOWER($1) LIMIT 1`, [email.trim()]);
+ return r.rows[0] || null;
+}
+
+export async function findUserById(id: number): Promise<AppUser | null> {
+ const r = await query<AppUser>(`
+ SELECT id, email, full_name, role, plan, status, organization_id, professional_id,
+ bar_number, firm_name, website, phone, created_at, last_login_at, tier
+ FROM app_users WHERE id = $1 LIMIT 1
+ `, [id]);
+ return r.rows[0] || null;
+}
+
+export async function createSession(userId: number, ip: string | null, ua: string | null): Promise<string> {
+ const id = crypto.randomBytes(32).toString('hex');
+ const expires = new Date(Date.now() + SESSION_TTL_DAYS * 86400000);
+ await query(
+ `INSERT INTO app_sessions (id, user_id, expires_at, ip, user_agent) VALUES ($1, $2, $3, $4, $5)`,
+ [id, userId, expires, ip, ua?.slice(0, 200) || null]);
+ await query(`UPDATE app_users SET last_login_at = NOW() WHERE id = $1`, [userId]);
+ return id;
+}
+
+export async function destroySession(id: string) {
+ await query(`DELETE FROM app_sessions WHERE id = $1`, [id]);
+}
+
+export async function getSessionUser(sid: string | undefined): Promise<AppUser | null> {
+ if (!sid) return null;
+ const r = await query<{ user_id: number }>(`
+ SELECT user_id FROM app_sessions WHERE id = $1 AND expires_at > NOW() LIMIT 1
+ `, [sid]);
+ if (r.rowCount === 0) return null;
+ const u = await findUserById(r.rows[0].user_id);
+ if (!u || u.status !== 'active') return null;
+ return u;
+}
+
+export function setSessionCookie(res: Response, sid: string) {
+ res.setHeader('Set-Cookie', cookie.serialize(COOKIE_NAME, sid, {
+ httpOnly: true,
+ secure: true, // we're behind nginx+LE in prod; localhost works because chromium permits localhost
+ sameSite: 'lax',
+ path: '/',
+ maxAge: SESSION_TTL_DAYS * 86400,
+ }));
+}
+
+export function clearSessionCookie(res: Response) {
+ res.setHeader('Set-Cookie', cookie.serialize(COOKIE_NAME, '', {
+ httpOnly: true, secure: true, sameSite: 'lax', path: '/', maxAge: 0,
+ }));
+}
+
+export async function authMiddleware(req: Request, _res: Response, next: NextFunction) {
+ const cookies = cookie.parse(req.headers.cookie || '');
+ const sid = cookies[COOKIE_NAME];
+ const user = await getSessionUser(sid);
+ if (user) req.user = user;
+ next();
+}
+
+export function requireUser(req: Request, res: Response, next: NextFunction) {
+ if (!req.user) return res.redirect(302, '/login?next=' + encodeURIComponent(req.originalUrl));
+ next();
+}
+
+export function requireAdmin(req: Request, res: Response, next: NextFunction) {
+ if (!req.user) return res.redirect(302, '/login?next=' + encodeURIComponent(req.originalUrl));
+ // Either legacy role='admin' OR new tier='admin' grants admin access.
+ const isAdmin = req.user.role === 'admin' || (req.user as any).tier === 'admin';
+ if (!isAdmin) return res.status(403).send('Forbidden — admin only.');
+ next();
+}
+
+export const PLANS = ['free', 'premium', 'pro'] as const;
+export const STATUSES = ['active', 'suspended', 'deleted'] as const;
diff --git a/src/server/billing.ts b/src/server/billing.ts
new file mode 100644
index 0000000..307a23a
--- /dev/null
+++ b/src/server/billing.ts
@@ -0,0 +1,189 @@
+/**
+ * Stripe billing — Lawyer Pro $29/mo subscription.
+ *
+ * POST /api/billing/upgrade — auth → Stripe checkout session → 303 redirect
+ * GET /billing/return — Stripe success_url / cancel_url
+ * POST /webhooks/stripe-tier — subscription events → flip user.tier (separate from upgrade.ts /webhooks/stripe)
+ * GET /api/billing/portal — auth → Stripe customer portal session
+ *
+ * Env vars (from .env):
+ * STRIPE_SECRET_KEY live secret
+ * STRIPE_PRICE_LAWYER_PRO_MONTHLY price ID created by setup script
+ * STRIPE_WEBHOOK_SECRET whsec_… (set after `stripe listen` or webhook endpoint creation)
+ * PUBLIC_BASE_URL e.g. http://localhost:9701 — used for return URLs
+ *
+ * Slice-B note: webhook signature verification is currently SKIPPED with a
+ * console warning when STRIPE_WEBHOOK_SECRET is unset. DO NOT deploy publicly
+ * until that is set + verified.
+ */
+import express from 'express';
+import crypto from 'node:crypto';
+import { query } from '../db/pool.ts';
+
+const router = express.Router();
+
+const SK = process.env.STRIPE_SECRET_KEY || '';
+const PRICE_ID = process.env.STRIPE_PRICE_LAWYER_PRO_MONTHLY || '';
+// Separate from upgrade.ts's STRIPE_WEBHOOK_SECRET — this router must be
+// configured as its OWN endpoint in the Stripe dashboard with its own secret.
+// Sharing a single secret would mean one of the two webhooks silently fails verification.
+const WEBHOOK_SECRET = process.env.STRIPE_TIER_WEBHOOK_SECRET || '';
+const PUBLIC_BASE_URL = process.env.PUBLIC_BASE_URL || 'http://localhost:9701';
+
+// Stripe webhook signature verification (HMAC-SHA256, fail-closed).
+// Header format: stripe-signature: t=TIMESTAMP,v1=HEXSIG[,v1=HEXSIG2]
+function verifyStripeSig(rawBody: Buffer, sigHeader: string | undefined, secret: string, toleranceSec = 300): boolean {
+ if (!sigHeader || !secret) return false;
+ const segments = sigHeader.split(',').map(p => p.trim());
+ const tSeg = segments.find(s => s.startsWith('t=')); const t = tSeg ? tSeg.slice(2) : '';
+ const v1s = segments.filter(s => s.startsWith('v1=')).map(s => s.slice(3));
+ if (!t || v1s.length === 0) return false;
+ const tsSec = parseInt(t, 10);
+ if (!Number.isFinite(tsSec)) return false;
+ if (Math.abs(Math.floor(Date.now() / 1000) - tsSec) > toleranceSec) return false;
+ const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody.toString('utf8')}`).digest('hex');
+ const expBuf = Buffer.from(expected, 'hex');
+ return v1s.some(sig => {
+ let sigBuf: Buffer;
+ try { sigBuf = Buffer.from(sig, 'hex'); } catch { return false; }
+ return sigBuf.length === expBuf.length && crypto.timingSafeEqual(sigBuf, expBuf);
+ });
+}
+
+async function stripe(path: string, body?: Record<string, string>): Promise<any> {
+ const init: RequestInit = {
+ method: body ? 'POST' : 'GET',
+ headers: { Authorization: 'Basic ' + Buffer.from(SK + ':').toString('base64') },
+ };
+ if (body) {
+ init.headers = { ...init.headers, 'Content-Type': 'application/x-www-form-urlencoded' };
+ init.body = new URLSearchParams(body).toString();
+ }
+ const r = await fetch('https://api.stripe.com/v1' + path, init);
+ return r.json();
+}
+
+router.post('/api/billing/upgrade', express.json(), async (req, res) => {
+ if (!req.user) return res.status(401).json({ error: 'login_required' });
+ if (!SK || !PRICE_ID) return res.status(500).json({ error: 'stripe_not_configured' });
+
+ // Ensure customer exists
+ const u = await query<{ stripe_customer_id: string | null; google_email: string | null; email: string | null }>(
+ `SELECT stripe_customer_id, google_email, email FROM app_users WHERE id = $1`, [req.user.id]);
+ let customerId = u.rows[0]?.stripe_customer_id || null;
+ if (!customerId) {
+ const cust = await stripe('/customers', {
+ email: u.rows[0]?.google_email || u.rows[0]?.email || `user-${req.user.id}@unknown.local`,
+ 'metadata[user_id]': String(req.user.id),
+ });
+ if (cust.error) return res.status(500).json({ error: cust.error.message });
+ customerId = cust.id;
+ await query(`UPDATE app_users SET stripe_customer_id = $1 WHERE id = $2`, [customerId, req.user.id]);
+ }
+
+ // Create checkout session
+ const session = await stripe('/checkout/sessions', {
+ mode: 'subscription',
+ customer: customerId!,
+ 'line_items[0][price]': PRICE_ID,
+ 'line_items[0][quantity]': '1',
+ success_url: `${PUBLIC_BASE_URL}/billing/return?status=success&session_id={CHECKOUT_SESSION_ID}`,
+ cancel_url: `${PUBLIC_BASE_URL}/billing/return?status=cancel`,
+ 'metadata[user_id]': String(req.user.id),
+ 'subscription_data[metadata][user_id]': String(req.user.id),
+ });
+ if (session.error) return res.status(500).json({ error: session.error.message });
+ res.json({ url: session.url, id: session.id });
+});
+
+router.get('/billing/return', async (req, res) => {
+ const status = String(req.query.status || '');
+ const esc = (s: any) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]!));
+ const ok = status === 'success';
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
+ res.send(`<!doctype html><html><head><meta charset="utf-8"><title>${ok ? 'Welcome' : 'Checkout cancelled'}</title>
+<style>body{font:14px/1.5 -apple-system,system-ui,sans-serif;background:#0a0a0c;color:#f4f1ea;display:flex;min-height:100vh;align-items:center;justify-content:center}
+.box{max-width:520px;text-align:center;padding:36px;border:1px solid #2a2724;background:#131316;border-radius:6px}
+h1{font-family:Cormorant Garamond,Georgia,serif;font-weight:400;font-size:32px;color:#b89968;margin:0 0 12px}
+a{color:#b89968}</style></head>
+<body><div class="box">
+ <h1>${ok ? 'Welcome to Lawyer Pro.' : 'No charge made.'}</h1>
+ <p>${ok ? 'Subscription is active. You can now respond to reviews, DM clients, and edit your firm profile.' : 'You closed the checkout — nothing was billed.'}</p>
+ <p style="margin-top:24px"><a href="/dashboard.html">→ Dashboard</a> · <a href="/community">→ Community</a></p>
+</div></body></html>`);
+});
+
+// Stripe webhook — body must be raw for signature verification.
+// We mount this router-level; the bodyParser is applied at the route only.
+// Path is /webhooks/stripe-tier so it doesn't collide with upgrade.ts's /webhooks/stripe
+// (which handles the original $99 upgrade_orders flow). Configure as a separate
+// endpoint in the Stripe dashboard and subscribe to customer.subscription.* events.
+router.post('/webhooks/stripe-tier',
+ express.raw({ type: 'application/json' }),
+ async (req, res) => {
+ const sig = req.headers['stripe-signature'] as string | undefined;
+ const raw = req.body as Buffer;
+
+ // Fail-closed signature verification. Without STRIPE_TIER_WEBHOOK_SECRET
+ // OR a valid signature, return 503 — anyone could otherwise forge a
+ // customer.subscription.created event and flip themselves to Pro tier.
+ if (!WEBHOOK_SECRET) {
+ console.error('[stripe-tier-webhook] STRIPE_TIER_WEBHOOK_SECRET not configured — refusing to process');
+ return res.status(503).send('webhook not configured');
+ }
+ if (!verifyStripeSig(raw, sig, WEBHOOK_SECRET)) {
+ console.warn('[stripe-tier-webhook] signature mismatch or stale timestamp');
+ return res.status(400).send('invalid signature');
+ }
+ let event: any;
+ try { event = JSON.parse(raw.toString('utf8')); } catch { return res.status(400).send('bad json'); }
+
+ const type = event.type as string;
+ const obj = event.data?.object;
+ try {
+ if (type === 'customer.subscription.created' || type === 'customer.subscription.updated') {
+ const userId = obj?.metadata?.user_id ? Number(obj.metadata.user_id) : null;
+ const periodEnd = obj?.current_period_end ? new Date(obj.current_period_end * 1000) : null;
+ const status = obj?.status;
+ if (userId && periodEnd && (status === 'active' || status === 'trialing')) {
+ await query(`
+ UPDATE app_users
+ SET tier = CASE WHEN tier IN ('client') THEN 'lawyer' ELSE tier END,
+ paid_until = $2,
+ stripe_subscription_id = $3
+ WHERE id = $1
+ `, [userId, periodEnd, obj.id]);
+ console.log(`[stripe-webhook] user ${userId} → lawyer tier, paid_until=${periodEnd.toISOString()}`);
+ }
+ } else if (type === 'customer.subscription.deleted') {
+ const userId = obj?.metadata?.user_id ? Number(obj.metadata.user_id) : null;
+ if (userId) {
+ await query(`
+ UPDATE app_users SET tier = CASE WHEN tier='lawyer' THEN 'client' ELSE tier END,
+ paid_until = NOW(), stripe_subscription_id = NULL
+ WHERE id = $1
+ `, [userId]);
+ console.log(`[stripe-webhook] user ${userId} → client tier (subscription deleted)`);
+ }
+ }
+ } catch (e) {
+ console.error('[stripe-webhook] handler error:', (e as Error).message);
+ }
+ res.json({ received: true });
+ });
+
+router.get('/api/billing/portal', async (req, res) => {
+ if (!req.user) return res.status(401).json({ error: 'login_required' });
+ const u = await query<{ stripe_customer_id: string | null }>(
+ `SELECT stripe_customer_id FROM app_users WHERE id = $1`, [req.user.id]);
+ const cid = u.rows[0]?.stripe_customer_id;
+ if (!cid) return res.status(400).json({ error: 'no_customer' });
+ const session = await stripe('/billing_portal/sessions', {
+ customer: cid,
+ return_url: `${PUBLIC_BASE_URL}/dashboard.html`,
+ });
+ if (session.error) return res.status(500).json({ error: session.error.message });
+ res.redirect(303, session.url);
+});
+
+export default router;
diff --git a/src/server/community.ts b/src/server/community.ts
new file mode 100644
index 0000000..d20bbe1
--- /dev/null
+++ b/src/server/community.ts
@@ -0,0 +1,652 @@
+/**
+ * Community + Ratings routes.
+ *
+ * POST /api/ratings — submit a rating (auth required)
+ * GET /api/firms/:id/ratings — public, list ratings + aggregate
+ * GET /api/attorneys/:id/ratings — public, list ratings + aggregate
+ * PATCH /api/me/comments-enabled — toggle (auth required)
+ *
+ * GET /community — server-rendered feed (client_public)
+ * POST /api/threads — start a thread / DM
+ * GET /api/threads — list (filtered by channel + my participation)
+ * GET /api/threads/:id/messages — list messages
+ * POST /api/threads/:id/messages — post a message
+ *
+ * POST /api/lawyer/claim — start lawyer-claim by bar#
+ *
+ * Tier gating (resolved via existing auth.ts session):
+ * guest → can read public ratings/threads
+ * client → can post ratings, post in client_public, DM other clients
+ * lawyer → above + post in lawyer_public, DM clients/peers, edit own firm
+ * admin → everything + moderation
+ */
+import express from 'express';
+import { query } from '../db/pool.ts';
+
+const router = express.Router();
+// Skip JSON parsing for /webhooks/* so Stripe-style raw-body handlers (mounted in
+// other routers) still see the original Buffer instead of a parsed object.
+router.use((req, res, next) => {
+ if (req.path.startsWith('/webhooks/')) return next();
+ return express.json()(req, res, next);
+});
+
+// ─── tier resolver ─────────────────────────────────────────────────────────
+type TierUser = { id: number; tier: 'guest'|'client'|'lawyer'|'admin'; comments_enabled: boolean; claimed_professional_id: number | null };
+
+async function resolveTier(req: express.Request): Promise<TierUser> {
+ if (!req.user) return { id: 0, tier: 'guest', comments_enabled: true, claimed_professional_id: null };
+ const r = await query<{ tier: string; comments_enabled: boolean; claimed_professional_id: number | null }>(
+ `SELECT tier, comments_enabled, claimed_professional_id FROM app_users WHERE id = $1`, [req.user.id]);
+ const row = r.rows[0];
+ if (!row) return { id: req.user.id, tier: 'client', comments_enabled: true, claimed_professional_id: null };
+ // Role admin always wins — even if the legacy migration left tier='client' on a
+ // role='admin' account, treat them as admin tier so they can use admin-tier UI.
+ const tier = req.user.role === 'admin' ? 'admin' : ((row.tier as any) || 'client');
+ return {
+ id: req.user.id,
+ tier,
+ comments_enabled: !!row.comments_enabled,
+ claimed_professional_id: row.claimed_professional_id,
+ };
+}
+
+function requireTier(...tiers: Array<'client'|'lawyer'|'admin'>) {
+ return async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+ const u = await resolveTier(req);
+ if (!tiers.includes(u.tier as any)) return res.status(403).json({ error: 'tier_required', need: tiers, have: u.tier });
+ (req as any).tierUser = u;
+ next();
+ };
+}
+
+// ─── ratings ────────────────────────────────────────────────────────────────
+router.post('/api/ratings', requireTier('client', 'lawyer', 'admin'), async (req, res) => {
+ const u: TierUser = (req as any).tierUser;
+ const b = req.body && typeof req.body === 'object' ? req.body : {};
+ const targetKind = b.target_kind === 'professional' ? 'professional' : 'organization';
+ const targetId = parseInt(b.target_id, 10);
+ const stars = Number(b.stars);
+ if (!Number.isFinite(targetId)) return res.status(400).json({ error: 'bad target_id' });
+ if (!(stars >= 0 && stars <= 5)) return res.status(400).json({ error: 'stars must be 0-5' });
+ const service = b.service_score == null ? null : Number(b.service_score);
+ const price = b.price_score == null ? null : Number(b.price_score);
+ const quality = b.quality_score == null ? null : Number(b.quality_score);
+ const comment = String(b.comment || '').slice(0, 5000) || null;
+
+ const r = await query<{ id: number }>(`
+ INSERT INTO ratings (
+ ${targetKind === 'professional' ? 'professional_id' : 'organization_id'},
+ reviewer_user_id, source, stars, service_score, price_score, quality_score, comment
+ ) VALUES ($1, $2, 'user', $3, $4, $5, $6, $7)
+ RETURNING id
+ `, [targetId, u.id, stars, service, price, quality, comment]);
+
+ await refreshAggregate(targetKind, targetId);
+ res.json({ id: r.rows[0].id, ok: true });
+});
+
+export async function refreshAggregate(kind: 'professional' | 'organization', id: number) {
+ const fk = kind === 'professional' ? 'professional_id' : 'organization_id';
+ await query(`
+ INSERT INTO rating_aggregates (target_kind, target_id, total_reviews, user_review_count,
+ avg_stars, avg_service, avg_price, avg_quality, overall_score, by_source, updated_at)
+ SELECT
+ $1, $2,
+ COUNT(*)::int,
+ COUNT(*) FILTER (WHERE source='user')::int,
+ AVG(stars)::numeric(3,2),
+ AVG(service_score)::numeric(3,2),
+ AVG(price_score)::numeric(3,2),
+ AVG(quality_score)::numeric(3,2),
+ (
+ 0.5 * COALESCE(AVG(stars), 0)
+ + 0.2 * COALESCE(AVG(service_score), 0)
+ + 0.2 * COALESCE(AVG(quality_score), 0)
+ + 0.1 * COALESCE(AVG(price_score), 0)
+ )::numeric(4,2),
+ (
+ SELECT jsonb_object_agg(source, jsonb_build_object('count', cnt, 'avg', avg_s))
+ FROM (
+ SELECT source, COUNT(*) AS cnt, AVG(stars)::numeric(3,2) AS avg_s
+ FROM ratings WHERE ${fk} = $2 AND hidden_at IS NULL GROUP BY source
+ ) s
+ ),
+ NOW()
+ FROM ratings WHERE ${fk} = $2 AND hidden_at IS NULL
+ ON CONFLICT (target_kind, target_id) DO UPDATE SET
+ total_reviews = EXCLUDED.total_reviews,
+ user_review_count = EXCLUDED.user_review_count,
+ avg_stars = EXCLUDED.avg_stars,
+ avg_service = EXCLUDED.avg_service,
+ avg_price = EXCLUDED.avg_price,
+ avg_quality = EXCLUDED.avg_quality,
+ overall_score = EXCLUDED.overall_score,
+ by_source = EXCLUDED.by_source,
+ updated_at = NOW();
+ `, [kind, id]);
+}
+
+router.get('/api/firms/:id/ratings', async (req, res) => {
+ const id = parseInt(req.params.id, 10);
+ if (!Number.isFinite(id)) return res.status(400).json({ error: 'bad id' });
+ const agg = await query(`SELECT * FROM rating_aggregates WHERE target_kind='organization' AND target_id=$1`, [id]);
+ const list = await query(`
+ SELECT r.id, r.source, r.stars, r.service_score, r.price_score, r.quality_score,
+ r.comment, r.posted_at, r.reviewer_name,
+ u.full_name AS reviewer_user_name, u.comments_enabled
+ FROM ratings r
+ LEFT JOIN app_users u ON u.id = r.reviewer_user_id
+ WHERE r.organization_id = $1 AND r.hidden_at IS NULL
+ ORDER BY r.posted_at DESC LIMIT 100
+ `, [id]);
+ res.json({ aggregate: agg.rows[0] || null, ratings: list.rows });
+});
+
+router.get('/api/attorneys/:id/ratings', async (req, res) => {
+ const id = parseInt(req.params.id, 10);
+ if (!Number.isFinite(id)) return res.status(400).json({ error: 'bad id' });
+ const agg = await query(`SELECT * FROM rating_aggregates WHERE target_kind='professional' AND target_id=$1`, [id]);
+ const list = await query(`
+ SELECT r.id, r.source, r.stars, r.service_score, r.price_score, r.quality_score,
+ r.comment, r.posted_at, r.reviewer_name,
+ u.full_name AS reviewer_user_name, u.comments_enabled
+ FROM ratings r
+ LEFT JOIN app_users u ON u.id = r.reviewer_user_id
+ WHERE r.professional_id = $1 AND r.hidden_at IS NULL
+ ORDER BY r.posted_at DESC LIMIT 100
+ `, [id]);
+ res.json({ aggregate: agg.rows[0] || null, ratings: list.rows });
+});
+
+// Lawyer-tier (paid) can post one reply per rating on their claimed firm
+router.post('/api/ratings/:id/reply', requireTier('lawyer','admin'), async (req, res) => {
+ const u: TierUser = (req as any).tierUser;
+ const ratingId = parseInt(req.params.id, 10);
+ const body = String((req.body && req.body.body) || '').trim().slice(0, 5000);
+ if (!body) return res.status(400).json({ error: 'body required' });
+
+ // Verify the rating targets the lawyer's claimed firm/professional
+ const r = await query<{ organization_id: number | null; professional_id: number | null }>(
+ `SELECT organization_id, professional_id FROM ratings WHERE id = $1`, [ratingId]);
+ if (!r.rowCount) return res.status(404).json({ error: 'rating_not_found' });
+ if (u.tier === 'lawyer') {
+ if (!u.claimed_professional_id) return res.status(403).json({ error: 'must_claim_first' });
+ const owns = await query<{ ok: boolean }>(`
+ SELECT TRUE AS ok FROM professionals p
+ LEFT JOIN professional_locations pl ON pl.professional_id = p.id AND pl.is_primary = true
+ WHERE p.id = $1 AND ($2::bigint IS NULL OR pl.organization_id = $2)
+ AND ($3::bigint IS NULL OR p.id = $3)
+ `, [u.claimed_professional_id, r.rows[0].organization_id, r.rows[0].professional_id]);
+ if (!owns.rowCount) return res.status(403).json({ error: 'not_your_firm' });
+ }
+
+ const ins = await query<{ id: number }>(`
+ INSERT INTO rating_replies (rating_id, responder_user_id, body)
+ VALUES ($1, $2, $3)
+ ON CONFLICT (rating_id, responder_user_id) DO UPDATE SET body=$3, edited_at=NOW()
+ RETURNING id
+ `, [ratingId, u.id, body]);
+ res.json({ id: ins.rows[0].id, ok: true });
+});
+
+router.patch('/api/me/comments-enabled', requireTier('client','lawyer','admin'), async (req, res) => {
+ const u: TierUser = (req as any).tierUser;
+ const enabled = !!(req.body && req.body.enabled);
+ await query(`UPDATE app_users SET comments_enabled = $2 WHERE id = $1`, [u.id, enabled]);
+ res.json({ ok: true, comments_enabled: enabled });
+});
+
+// ─── community threads + messages ──────────────────────────────────────────
+const ALLOWED_CHANNELS_BY_TIER: Record<string, string[]> = {
+ client: ['client_public', 'dm_client_lawyer', 'dm_client_client'],
+ lawyer: ['client_public', 'lawyer_public', 'dm_client_lawyer', 'dm_lawyer_lawyer'],
+ admin: ['client_public', 'lawyer_public', 'dm_client_lawyer', 'dm_client_client', 'dm_lawyer_lawyer'],
+};
+
+router.post('/api/threads', requireTier('client','lawyer','admin'), async (req, res) => {
+ const u: TierUser = (req as any).tierUser;
+ const b = req.body || {};
+ const channel = String(b.channel || '');
+ const allowed = ALLOWED_CHANNELS_BY_TIER[u.tier] || [];
+ if (!allowed.includes(channel)) return res.status(403).json({ error: 'channel_not_allowed', allowed });
+ const topic = String(b.topic || '').slice(0, 280) || null;
+ const orgId = b.organization_id ? parseInt(b.organization_id, 10) : null;
+ const proId = b.professional_id ? parseInt(b.professional_id, 10) : null;
+ const participants = Array.isArray(b.participant_user_ids)
+ ? Array.from(new Set([...b.participant_user_ids.map(Number), u.id])).filter(Number.isFinite)
+ : [u.id];
+
+ const r = await query<{ id: number }>(`
+ INSERT INTO threads (channel, topic, organization_id, professional_id, participant_user_ids, created_by)
+ VALUES ($1, $2, $3, $4, $5::bigint[], $6) RETURNING id
+ `, [channel, topic, orgId, proId, participants, u.id]);
+ res.json({ id: r.rows[0].id });
+});
+
+router.get('/api/threads', async (req, res) => {
+ const u = await resolveTier(req);
+ const channel = String(req.query.channel || 'client_public');
+ const orgId = req.query.organization_id ? parseInt(req.query.organization_id as string, 10) : null;
+ const limit = Math.min(parseInt((req.query.limit as string) || '50', 10), 200);
+
+ // Public channels readable by all; DM channels need participation
+ const isPublic = channel === 'client_public' || channel === 'lawyer_public';
+ const where: string[] = ['t.channel = $1'];
+ const params: any[] = [channel];
+ if (orgId) { params.push(orgId); where.push(`t.organization_id = $${params.length}`); }
+ if (!isPublic) {
+ if (u.tier === 'guest') return res.status(403).json({ error: 'tier_required' });
+ params.push(u.id);
+ where.push(`$${params.length} = ANY(t.participant_user_ids)`);
+ }
+ params.push(limit);
+
+ const r = await query(`
+ SELECT t.id, t.channel, t.topic, t.organization_id, t.professional_id,
+ t.participant_user_ids, t.created_by, t.created_at, t.last_message_at, t.message_count
+ FROM threads t
+ WHERE ${where.join(' AND ')}
+ ORDER BY t.last_message_at DESC
+ LIMIT $${params.length}
+ `, params);
+ res.json({ count: r.rowCount, rows: r.rows });
+});
+
+router.get('/api/threads/:id/messages', async (req, res) => {
+ const u = await resolveTier(req);
+ const tid = parseInt(req.params.id, 10);
+ const t = await query<{ channel: string; participant_user_ids: number[] }>(
+ `SELECT channel, participant_user_ids FROM threads WHERE id = $1`, [tid]);
+ if (!t.rowCount) return res.status(404).json({ error: 'not found' });
+ const isPublic = t.rows[0].channel === 'client_public' || t.rows[0].channel === 'lawyer_public';
+ if (!isPublic) {
+ if (u.tier === 'guest' || !(t.rows[0].participant_user_ids || []).includes(u.id)) {
+ return res.status(403).json({ error: 'not_participant' });
+ }
+ }
+ const r = await query(`
+ SELECT m.id, m.user_id, m.body, m.posted_at, m.edited_at,
+ u.full_name AS author_name, u.tier AS author_tier
+ FROM messages m
+ LEFT JOIN app_users u ON u.id = m.user_id
+ WHERE m.thread_id = $1 AND m.hidden_at IS NULL
+ ORDER BY m.posted_at ASC
+ LIMIT 500
+ `, [tid]);
+ res.json({ count: r.rowCount, rows: r.rows });
+});
+
+router.post('/api/threads/:id/messages', requireTier('client','lawyer','admin'), async (req, res) => {
+ const u: TierUser = (req as any).tierUser;
+ const tid = parseInt(req.params.id, 10);
+ const body = String((req.body && req.body.body) || '').trim().slice(0, 5000);
+ if (!body) return res.status(400).json({ error: 'body required' });
+ const t = await query<{ channel: string; participant_user_ids: number[] }>(
+ `SELECT channel, participant_user_ids FROM threads WHERE id = $1`, [tid]);
+ if (!t.rowCount) return res.status(404).json({ error: 'not found' });
+ const channel = t.rows[0].channel;
+ const allowed = ALLOWED_CHANNELS_BY_TIER[u.tier] || [];
+ if (!allowed.includes(channel)) return res.status(403).json({ error: 'channel_not_allowed' });
+ const isPublic = channel === 'client_public' || channel === 'lawyer_public';
+ if (!isPublic && !(t.rows[0].participant_user_ids || []).includes(u.id)) {
+ return res.status(403).json({ error: 'not_participant' });
+ }
+
+ const r = await query<{ id: number; posted_at: string }>(`
+ INSERT INTO messages (thread_id, user_id, body) VALUES ($1, $2, $3)
+ RETURNING id, posted_at
+ `, [tid, u.id, body]);
+ res.json({ id: r.rows[0].id, posted_at: r.rows[0].posted_at });
+});
+
+// ─── lawyer-claim ──────────────────────────────────────────────────────────
+router.post('/api/lawyer/claim', requireTier('client','lawyer','admin'), async (req, res) => {
+ const u: TierUser = (req as any).tierUser;
+ const bar = String((req.body && req.body.bar_number) || '').trim();
+ if (!/^\d{3,7}$/.test(bar)) return res.status(400).json({ error: 'bad bar_number' });
+ const p = await query<{ id: number; full_name: string }>(
+ `SELECT id, full_name FROM professionals WHERE bar_number = $1 LIMIT 1`, [bar]);
+ if (!p.rowCount) return res.status(404).json({ error: 'bar_number_not_found' });
+ // Provisional claim (still needs verification email — Slice B)
+ await query(`
+ UPDATE app_users SET claimed_professional_id = $1, bar_number = $2, tier = CASE WHEN tier='client' THEN 'lawyer' ELSE tier END
+ WHERE id = $3
+ `, [p.rows[0].id, bar, u.id]);
+ res.json({
+ ok: true,
+ claimed_professional_id: p.rows[0].id,
+ full_name: p.rows[0].full_name,
+ note: 'provisional — bar-verification email is Slice B (not yet wired to PurelyMail send)',
+ });
+});
+
+// ─── server-rendered /firms/:id — public firm page w/ rating widget ──────
+router.get('/firms/:id', async (req, res) => {
+ const u = await resolveTier(req);
+ const id = parseInt(req.params.id, 10);
+ if (!Number.isFinite(id)) return res.status(400).send('bad id');
+ const r = await query<any>(`
+ SELECT o.id, o.name, o.address, o.city, o.zip, o.phone, o.website, o.firm_size_band, o.attorney_count, o.lat, o.lng,
+ (SELECT marketing_score FROM site_audits a WHERE a.organization_id=o.id AND a.status_code BETWEEN 200 AND 399 ORDER BY a.audited_at DESC LIMIT 1) AS audit_score,
+ (SELECT screenshot_path FROM site_audits a WHERE a.organization_id=o.id AND a.status_code BETWEEN 200 AND 399 ORDER BY a.audited_at DESC LIMIT 1) AS shot,
+ (SELECT primary_color FROM site_audits a WHERE a.organization_id=o.id AND a.status_code BETWEEN 200 AND 399 ORDER BY a.audited_at DESC LIMIT 1) AS color
+ FROM organizations o WHERE o.id = $1
+ `, [id]);
+ if (!r.rowCount) return res.status(404).send('not found');
+ const f = r.rows[0];
+ const agg = await query(`SELECT * FROM rating_aggregates WHERE target_kind='organization' AND target_id=$1`, [id]);
+ const reviews = await query<any>(`
+ SELECT r.id, r.source, r.stars, r.service_score, r.price_score, r.quality_score, r.comment, r.posted_at, r.reviewer_name,
+ u.full_name AS reviewer_user_name, u.comments_enabled
+ FROM ratings r LEFT JOIN app_users u ON u.id = r.reviewer_user_id
+ WHERE r.organization_id = $1 AND r.hidden_at IS NULL
+ ORDER BY r.posted_at DESC LIMIT 50
+ `, [id]);
+ const attorneys = await query<any>(`
+ SELECT p.id, p.full_name, p.bar_number, p.license_status
+ FROM professional_locations pl JOIN professionals p ON p.id = pl.professional_id
+ WHERE pl.organization_id = $1 ORDER BY p.full_name LIMIT 50
+ `, [id]);
+
+ const esc = (s: any) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]!));
+ const a = agg.rows[0];
+ const score = a?.overall_score == null ? null : Number(a.overall_score);
+ const stars = a?.avg_stars == null ? null : Number(a.avg_stars);
+ const starHtml = (n: number | null) => {
+ if (n == null) return '<span style="color:#444">—</span>';
+ const full = Math.round(n);
+ return '★'.repeat(full) + '☆'.repeat(5 - full) + ` <span style="color:#8b857a;font-size:12px">(${n.toFixed(1)})</span>`;
+ };
+
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
+ res.send(`<!doctype html><html><head><meta charset="utf-8"><title>${esc(f.name)} — Lawyer Directory</title>
+<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;1,400&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
+<style>
+:root{--bg:#0a0a0c;--panel:#131316;--rule:#2a2724;--ink:#f4f1ea;--ink-soft:#d8d2c5;--ink-mute:#8b857a;--metal:#b89968;--metal-glow:#d4b683;--good:#10b981;--warn:#facc15;--bad:#ef4444}
+*,*::before,*::after{box-sizing:border-box}
+body{margin:0;background:var(--bg);color:var(--ink);font:300 15px/1.55 -apple-system,BlinkMacSystemFont,system-ui,sans-serif;-webkit-font-smoothing:antialiased}
+.wrap{max-width:1100px;margin:0 auto;padding:32px 28px 80px}
+.crumb{font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--ink-mute);margin-bottom:18px}
+.crumb a{color:var(--metal);text-decoration:none}
+h1{font-family:Cormorant Garamond,Georgia,serif;font-weight:400;font-size:46px;line-height:1.1;margin:0 0 8px}
+.meta{color:var(--ink-mute);font-size:13px;margin-bottom:24px}
+.meta a{color:var(--metal);text-decoration:none}
+.score-row{display:flex;gap:32px;flex-wrap:wrap;padding:20px 24px;background:var(--panel);border:1px solid var(--rule);border-radius:6px;margin-bottom:32px;align-items:center}
+.score-row .item{display:flex;flex-direction:column;gap:2px}
+.score-row .item .lbl{font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--ink-mute)}
+.score-row .item .val{font-family:Cormorant Garamond,Georgia,serif;font-size:26px;color:var(--metal)}
+.score-row .item .stars{font-size:18px;color:var(--metal)}
+.grid{display:grid;grid-template-columns:1.6fr 1fr;gap:32px}
+@media (max-width:760px){.grid{grid-template-columns:1fr}h1{font-size:34px}}
+section{margin-bottom:32px}
+section h2{font-family:Cormorant Garamond,Georgia,serif;font-weight:400;font-size:24px;color:var(--metal);margin:0 0 12px;border-bottom:1px solid var(--rule);padding-bottom:8px}
+.review{padding:14px 0;border-bottom:1px solid var(--rule)}
+.review .head{display:flex;gap:10px;align-items:center;font-size:12px;color:var(--ink-mute)}
+.review .head .src{padding:1px 6px;border-radius:3px;background:#1c2237;color:var(--metal);font-size:10px;text-transform:uppercase;letter-spacing:.1em}
+.review .body{margin-top:6px;color:var(--ink-soft)}
+.atty{padding:8px 0;border-bottom:1px solid var(--rule);display:flex;justify-content:space-between;font-size:13px}
+.atty a{color:var(--ink);text-decoration:none}
+.atty a:hover{color:var(--metal)}
+.atty .bar{color:var(--ink-mute);font-size:12px}
+.rate-form{padding:18px 20px;background:var(--panel);border:1px solid var(--metal);border-radius:6px;margin-top:16px}
+.rate-form h3{font-family:Cormorant Garamond,Georgia,serif;font-weight:400;font-size:18px;margin:0 0 10px;color:var(--metal)}
+.rate-form .row{display:grid;grid-template-columns:90px 1fr 36px;align-items:center;gap:8px;margin:6px 0}
+.rate-form .row label{font-size:11px;text-transform:uppercase;letter-spacing:.1em;color:var(--ink-mute)}
+.rate-form .row input[type=range]{accent-color:var(--metal)}
+.rate-form .row .v{font-variant-numeric:tabular-nums;color:var(--metal);text-align:right}
+.rate-form textarea{width:100%;background:var(--bg);border:1px solid var(--rule);color:var(--ink);padding:10px;border-radius:3px;font:inherit;margin-top:8px;min-height:80px}
+.rate-form button{margin-top:10px;padding:10px 22px;background:var(--metal);color:var(--bg);border:0;font-weight:600;letter-spacing:.06em;text-transform:uppercase;font-size:11px;border-radius:3px;cursor:pointer}
+.cta{margin-top:18px;padding:14px;background:var(--panel);border:1px solid var(--rule);border-radius:6px;text-align:center;color:var(--ink-mute);font-size:13px}
+.cta a{color:var(--metal)}
+.shot{width:100%;border:1px solid var(--rule);border-radius:6px;display:block;background:#000;margin-bottom:16px}
+</style></head><body>
+<div class="wrap">
+ <div class="crumb"><a href="/dashboard.html">← Dashboard</a> · <span>Firm</span></div>
+ <h1>${esc(f.name)}</h1>
+ <div class="meta">
+ ${f.address ? esc(f.address) + ' · ' : ''}${esc([f.city, f.zip].filter(Boolean).join(', '))}
+ ${f.phone ? ` · <a href="tel:${esc(f.phone.replace(/[^+0-9]/g,''))}">${esc(f.phone)}</a>` : ''}
+ ${f.website ? ` · <a href="${esc(f.website)}" target="_blank" rel="noopener">${esc(f.website.replace(/^https?:\/\//,''))}</a>` : ''}
+ </div>
+
+ <div class="score-row">
+ <div class="item">
+ <div class="lbl">Overall</div>
+ <div class="val">${score == null ? '—' : score.toFixed(2)}</div>
+ </div>
+ <div class="item">
+ <div class="lbl">Stars</div>
+ <div class="stars">${starHtml(stars)}</div>
+ </div>
+ <div class="item">
+ <div class="lbl">Reviews</div>
+ <div class="val">${a?.total_reviews || 0}</div>
+ </div>
+ ${f.audit_score != null ? `<div class="item"><div class="lbl">Site audit</div><div class="val">${f.audit_score}/100</div></div>` : ''}
+ ${f.firm_size_band ? `<div class="item"><div class="lbl">Size</div><div class="val" style="font-size:18px;text-transform:capitalize">${esc(f.firm_size_band)}</div></div>` : ''}
+ ${attorneys.rowCount ? `<div class="item"><div class="lbl">Attorneys</div><div class="val">${attorneys.rowCount}</div></div>` : ''}
+ </div>
+
+ <div class="grid">
+ <div>
+ <section>
+ <h2>Reviews</h2>
+ ${reviews.rowCount ? reviews.rows.map(r => `
+ <div class="review">
+ <div class="head">
+ <span class="src">${esc(r.source)}</span>
+ <span style="color:${Number(r.stars) >= 4 ? 'var(--good)' : Number(r.stars) >= 3 ? 'var(--warn)' : 'var(--bad)'}">★ ${Number(r.stars).toFixed(1)}</span>
+ <span>${esc(r.reviewer_user_name || r.reviewer_name || 'anonymous')}</span>
+ <span style="margin-left:auto">${esc(String(r.posted_at).slice(0,10))}</span>
+ </div>
+ ${r.comment && (r.comments_enabled !== false) ? `<div class="body">${esc(r.comment)}</div>` : (r.comments_enabled === false ? '<div class="body" style="color:var(--ink-mute);font-style:italic">(reviewer disabled comment display)</div>' : '')}
+ </div>`).join('') : '<p style="color:var(--ink-mute)">No reviews yet — be first.</p>'}
+
+ ${u.tier === 'guest' ? `
+ <div class="cta">Want to leave a review? <a href="/login">Log in</a> or <a href="/signup">sign up</a> as a client.</div>
+ ` : `
+ <form class="rate-form" id="rate-form">
+ <h3>Leave a review</h3>
+ <div class="row"><label>Stars</label><input type="range" min="0" max="5" step="0.5" name="stars" value="5" oninput="this.parentNode.querySelector('.v').textContent=this.value"><span class="v">5</span></div>
+ <div class="row"><label>Service</label><input type="range" min="0" max="5" step="0.5" name="service_score" value="5" oninput="this.parentNode.querySelector('.v').textContent=this.value"><span class="v">5</span></div>
+ <div class="row"><label>Price</label><input type="range" min="0" max="5" step="0.5" name="price_score" value="5" oninput="this.parentNode.querySelector('.v').textContent=this.value"><span class="v">5</span></div>
+ <div class="row"><label>Quality</label><input type="range" min="0" max="5" step="0.5" name="quality_score" value="5" oninput="this.parentNode.querySelector('.v').textContent=this.value"><span class="v">5</span></div>
+ <textarea name="comment" placeholder="What was it like working with this firm? (optional)"></textarea>
+ <button type="submit">Post review</button>
+ </form>
+ <script>
+ document.getElementById('rate-form').addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const f = e.target;
+ const r = await fetch('/api/ratings', { method:'POST', headers:{'Content-Type':'application/json'},
+ body: JSON.stringify({
+ target_kind: 'organization', target_id: ${id},
+ stars: f.stars.value, service_score: f.service_score.value,
+ price_score: f.price_score.value, quality_score: f.quality_score.value,
+ comment: f.comment.value
+ })}).then(r=>r.json());
+ if (r.ok) location.reload(); else alert(JSON.stringify(r));
+ });
+ </script>
+ `}
+ </section>
+ </div>
+ <div>
+ ${f.shot ? `<a href="${esc(f.website||'#')}" target="_blank"><img class="shot" src="${esc(f.shot)}" alt="current site"></a>` : ''}
+ <section>
+ <h2>Attorneys (${attorneys.rowCount})</h2>
+ ${attorneys.rows.map(a => `
+ <div class="atty">
+ <a href="/attorneys/${a.id}">${esc(a.full_name)}</a>
+ <span class="bar">${a.bar_number ? '#'+esc(a.bar_number) : ''} ${a.license_status === 'Active' ? '<span style="color:var(--good)">●</span>' : '<span style="color:var(--ink-mute)">○</span>'}</span>
+ </div>`).join('') || '<p style="color:var(--ink-mute)">No attorneys linked yet.</p>'}
+ </section>
+ <div style="margin-top:24px">
+ <a href="/p/${id}" style="display:block;text-align:center;padding:14px;background:var(--metal);color:var(--bg);text-decoration:none;font-weight:600;letter-spacing:.06em;text-transform:uppercase;font-size:12px;border-radius:3px">View redesign concept →</a>
+ </div>
+ </div>
+ </div>
+</div>
+</body></html>`);
+});
+
+// ─── server-rendered /attorneys/:id ───────────────────────────────────────
+router.get('/attorneys/:id', async (req, res) => {
+ const u = await resolveTier(req);
+ const id = parseInt(req.params.id, 10);
+ if (!Number.isFinite(id)) return res.status(400).send('bad id');
+ const r = await query<any>(`
+ SELECT p.id, p.full_name, p.bar_number, p.license_status, p.admission_date, p.law_school, p.primary_practice_area,
+ pl.organization_id AS firm_id, o.name AS firm_name, o.city, o.phone AS firm_phone, o.website AS firm_website
+ FROM professionals p
+ LEFT JOIN professional_locations pl ON pl.professional_id = p.id AND pl.is_primary = true
+ LEFT JOIN organizations o ON o.id = pl.organization_id
+ WHERE p.id = $1
+ `, [id]);
+ if (!r.rowCount) return res.status(404).send('not found');
+ const p = r.rows[0];
+ const agg = await query(`SELECT * FROM rating_aggregates WHERE target_kind='professional' AND target_id=$1`, [id]);
+ const reviews = await query<any>(`
+ SELECT r.id, r.source, r.stars, r.comment, r.posted_at, r.reviewer_name,
+ u.full_name AS reviewer_user_name, u.comments_enabled
+ FROM ratings r LEFT JOIN app_users u ON u.id = r.reviewer_user_id
+ WHERE r.professional_id = $1 AND r.hidden_at IS NULL
+ ORDER BY r.posted_at DESC LIMIT 50
+ `, [id]);
+ const a = agg.rows[0];
+ const stars = a?.avg_stars == null ? null : Number(a.avg_stars);
+ const esc = (s: any) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]!));
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
+ res.send(`<!doctype html><html><head><meta charset="utf-8"><title>${esc(p.full_name)} — Lawyer Directory</title>
+<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;1,400&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
+<style>
+:root{--bg:#0a0a0c;--panel:#131316;--rule:#2a2724;--ink:#f4f1ea;--ink-soft:#d8d2c5;--ink-mute:#8b857a;--metal:#b89968;--good:#10b981;--warn:#facc15;--bad:#ef4444}
+body{margin:0;background:var(--bg);color:var(--ink);font:300 15px/1.55 -apple-system,system-ui,sans-serif;-webkit-font-smoothing:antialiased}
+.wrap{max-width:760px;margin:0 auto;padding:32px 28px 80px}
+.crumb{font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--ink-mute);margin-bottom:18px}
+.crumb a{color:var(--metal);text-decoration:none}
+h1{font-family:Cormorant Garamond,Georgia,serif;font-weight:400;font-size:42px;line-height:1.1;margin:0 0 8px}
+.meta{color:var(--ink-mute);font-size:13px;margin-bottom:24px}
+.meta a{color:var(--metal);text-decoration:none}
+.score{display:inline-block;padding:14px 22px;background:#131316;border:1px solid var(--rule);border-radius:6px;margin-bottom:24px}
+.score .stars{font-size:24px;color:var(--metal)}
+.score .lbl{font-size:11px;color:var(--ink-mute);text-transform:uppercase;letter-spacing:.18em}
+.review{padding:14px 0;border-bottom:1px solid var(--rule)}
+.review .head{display:flex;gap:10px;align-items:center;font-size:12px;color:var(--ink-mute)}
+.review .head .src{padding:1px 6px;border-radius:3px;background:#1c2237;color:var(--metal);font-size:10px;text-transform:uppercase}
+.review .body{margin-top:6px;color:var(--ink-soft)}
+.claim{margin-top:32px;padding:18px;border:1px solid var(--metal);background:var(--panel);border-radius:6px}
+.claim h3{font-family:Cormorant Garamond,Georgia,serif;font-weight:400;font-size:20px;margin:0 0 8px;color:var(--metal)}
+.claim button{margin-top:10px;padding:10px 22px;background:var(--metal);color:var(--bg);border:0;font-weight:600;letter-spacing:.06em;text-transform:uppercase;font-size:11px;border-radius:3px;cursor:pointer}
+</style></head><body>
+<div class="wrap">
+ <div class="crumb"><a href="/dashboard.html">← Dashboard</a>${p.firm_id ? ` · <a href="/firms/${p.firm_id}">${esc(p.firm_name || '')}</a>` : ''}</div>
+ <h1>${esc(p.full_name)}</h1>
+ <div class="meta">
+ ${p.bar_number ? `Bar #${esc(p.bar_number)}` : ''}
+ ${p.license_status ? ` · <span style="color:${p.license_status==='Active'?'var(--good)':'var(--ink-mute)'}">${esc(p.license_status)}</span>` : ''}
+ ${p.law_school ? ` · ${esc(p.law_school)}` : ''}
+ ${p.admission_date ? ` · admitted ${esc(String(p.admission_date).slice(0,10))}` : ''}
+ </div>
+ ${stars != null ? `<div class="score"><div class="lbl">Star rating</div><div class="stars">${'★'.repeat(Math.round(stars))}${'☆'.repeat(5-Math.round(stars))} ${stars.toFixed(1)} (${a?.total_reviews||0})</div></div>` : ''}
+
+ <h2 style="font-family:Cormorant Garamond,Georgia,serif;font-weight:400;font-size:22px;color:var(--metal);border-bottom:1px solid var(--rule);padding-bottom:6px">Reviews</h2>
+ ${reviews.rowCount ? reviews.rows.map(r => `
+ <div class="review">
+ <div class="head">
+ <span class="src">${esc(r.source)}</span>
+ <span style="color:${Number(r.stars) >= 4 ? 'var(--good)' : Number(r.stars) >= 3 ? 'var(--warn)' : 'var(--bad)'}">★ ${Number(r.stars).toFixed(1)}</span>
+ <span>${esc(r.reviewer_user_name || r.reviewer_name || 'anonymous')}</span>
+ <span style="margin-left:auto">${esc(String(r.posted_at).slice(0,10))}</span>
+ </div>
+ ${r.comment && (r.comments_enabled !== false) ? `<div class="body">${esc(r.comment)}</div>` : ''}
+ </div>`).join('') : '<p style="color:var(--ink-mute)">No reviews yet.</p>'}
+
+ ${u.tier === 'client' && p.bar_number ? `
+ <div class="claim">
+ <h3>Are you ${esc(p.full_name)}?</h3>
+ <p style="color:var(--ink-mute);font-size:13px;margin:6px 0">Claim this profile to respond to reviews and update your information.</p>
+ <button onclick="claim()">Claim with Bar #${esc(p.bar_number)}</button>
+ <script>
+ async function claim() {
+ const r = await fetch('/api/lawyer/claim', { method:'POST', headers:{'Content-Type':'application/json'},
+ body: JSON.stringify({ bar_number: '${esc(p.bar_number)}' })}).then(r=>r.json());
+ if (r.ok) { alert('Provisional claim submitted. Verification email is Slice B.'); location.reload(); }
+ else alert(JSON.stringify(r));
+ }
+ </script>
+ </div>` : ''}
+</div>
+</body></html>`);
+});
+
+// ─── server-rendered /community feed ───────────────────────────────────────
+router.get('/community', async (req, res) => {
+ const u = await resolveTier(req);
+ // channel is interpolated into HTML <title> and a JS string literal below —
+ // constrain to the public allowlist so a forged ?channel=…');alert(1);//
+ // can't escape into either context.
+ const requested = String(req.query.channel || 'client_public');
+ const channel = (requested === 'client_public' || requested === 'lawyer_public') ? requested : 'client_public';
+ const r = await query<any>(`
+ SELECT t.id, t.topic, t.organization_id, t.last_message_at, t.message_count,
+ o.name AS firm_name
+ FROM threads t
+ LEFT JOIN organizations o ON o.id = t.organization_id
+ WHERE t.channel = $1
+ ORDER BY t.last_message_at DESC
+ LIMIT 50
+ `, [channel]);
+ const esc = (s: any) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]!));
+ const rows = r.rows.map(t => `
+ <div class="t">
+ <a class="title" href="/community/thread/${t.id}">${esc(t.topic || '(no topic)')}</a>
+ <div class="meta">${esc(t.firm_name || '')} · ${t.message_count} replies · ${esc(String(t.last_message_at).slice(0,16))}</div>
+ </div>`).join('');
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
+ res.send(`<!doctype html><html><head><meta charset="utf-8"><title>Community · ${channel}</title>
+<style>body{font:14px/1.5 -apple-system,system-ui,sans-serif;background:#0a0a0c;color:#f4f1ea;margin:0;padding:32px;max-width:760px;margin-left:auto;margin-right:auto}
+h1{font-family:Cormorant Garamond,Georgia,serif;font-weight:400}
+.tabs{display:flex;gap:14px;margin:14px 0 24px;border-bottom:1px solid #2a2724;padding-bottom:8px}
+.tabs a{color:#8b857a;text-decoration:none;font-size:12px;letter-spacing:.1em;text-transform:uppercase}
+.tabs a.active{color:#b89968}
+.t{padding:14px 0;border-bottom:1px solid #1a1815}
+.t .title{color:#f4f1ea;font-size:16px;font-weight:500;text-decoration:none}
+.t .meta{color:#8b857a;font-size:12px;margin-top:4px}
+.compose{margin-top:32px;padding:18px;border:1px solid #2a2724;background:#131316;border-radius:6px}
+.compose input,.compose textarea{width:100%;background:#0a0a0c;border:1px solid #2a2724;color:#f4f1ea;padding:10px;border-radius:3px;font:inherit;margin-bottom:8px}
+.compose button{background:#b89968;color:#0a0a0c;border:0;padding:9px 18px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;font-size:12px;border-radius:3px;cursor:pointer}
+.tier{font-size:11px;color:#8b857a;margin-left:8px}
+</style></head><body>
+<h1>Community <span class="tier">[${u.tier}]</span></h1>
+<div class="tabs">
+ <a class="${channel==='client_public'?'active':''}" href="?channel=client_public">Clients</a>
+ <a class="${channel==='lawyer_public'?'active':''}" href="?channel=lawyer_public">Lawyers</a>
+ ${u.tier!=='guest'?'<a href="/community/dm">DMs</a>':''}
+</div>
+${rows || '<p style="color:#8b857a">No threads yet — be the first.</p>'}
+${(u.tier==='client' || u.tier==='lawyer' || u.tier==='admin') && (channel === 'client_public' || (channel === 'lawyer_public' && (u.tier === 'lawyer' || u.tier === 'admin'))) ? `
+<div class="compose">
+ <form id="newt">
+ <input name="topic" placeholder="What's on your mind?" required>
+ <textarea name="body" placeholder="Say more…" rows="3" required></textarea>
+ <button type="submit">Post</button>
+ </form>
+ <script>
+ document.getElementById('newt').addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const f = e.target;
+ const t = await fetch('/api/threads', { method:'POST', headers:{'Content-Type':'application/json'},
+ body: JSON.stringify({ channel: '${channel}', topic: f.topic.value })}).then(r=>r.json());
+ if (!t.id) { alert(JSON.stringify(t)); return; }
+ await fetch('/api/threads/'+t.id+'/messages', { method:'POST', headers:{'Content-Type':'application/json'},
+ body: JSON.stringify({ body: f.body.value })});
+ location.reload();
+ });
+ </script>
+</div>` : `<p style="color:#8b857a;font-size:13px;margin-top:24px">${u.tier==='guest' ? '<a href="/login" style="color:#b89968">Log in</a> to post.' : 'Lawyer-tier required to post in Lawyers channel.'}</p>`}
+</body></html>`);
+});
+
+export default router;
diff --git a/src/server/directory.ts b/src/server/directory.ts
new file mode 100644
index 0000000..212144b
--- /dev/null
+++ b/src/server/directory.ts
@@ -0,0 +1,493 @@
+/**
+ * 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}
+.score-display .num.low{color:var(--bad)}
+.score-display .num.mid{color:var(--warn)}
+.score-display .num.high{color:var(--good)}
+.score-display .of{font-size:20px;color:var(--ink-mute);font-weight:300}
+.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:72px;line-height:1;color:var(--metal);letter-spacing:-.02em;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)}
+
+/* ── 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">
+ <h1><a href="/">Counsel <span class="amp">&</span> Bar</a></h1>
+ <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">
+ <p class="eyebrow">For attorneys · find yourself, claim, upgrade</p>
+ <h1>Find your firm.<br>Audit your site.<br>Get a personalized <em>upgrade.</em></h1>
+ <p class="lede">Type your name. We pull your CalBar listing, audit your current site for the twelve marketing signals that move conversions, and show you exactly what an EZ Upgrade — same domain, dramatically better presentation — would look like. Live in seven days.</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">Marketing health score</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">
+ We haven't audited your site yet — we'll run a full audit as part of the upgrade.
+ </div>
+ </div>
+ </div>
+
+ <div class="upgrade">
+ <div class="upgrade-grid">
+ <div>
+ <h2>The <em>EZ Upgrade.</em></h2>
+ <p>We rebuild your website on the same domain. Same content, same firm — dramatically better presentation. Designed to close the gap your audit just exposed.</p>
+ <ul>
+ <li>Cinematic homepage rewrite, mobile-first</li>
+ <li>30-min Zoom-consult booking page (Stripe payouts go to you)</li>
+ <li>HTTPS, OG image, schema markup, GA4 — every audit signal fixed</li>
+ <li>Live in 7 days · 14-day revision window</li>
+ <li>No subscription · pay once · keep the site forever</li>
+ </ul>
+ </div>
+ <div class="price-card">
+ <p class="amount">$499</p>
+ <p class="one-time">One-time</p>
+ <p class="turnaround">Live in 7 days · revisions through day 14</p>
+ <a class="cta-claim" id="cta-claim" href="/signup">Claim & Upgrade →</a>
+ </div>
+ </div>
+ </div>
+</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 ''; if (s >= 70) return 'high'; if (s >= 45) return 'mid'; return 'low'; }
+
+ // ── 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 be in touch within 24 hours."));
+ } catch (e) {
+ res.redirect('/directory?err=' + encodeURIComponent((e as Error).message));
+ }
+});
+
+export default router;
diff --git a/src/server/showcase.ts b/src/server/showcase.ts
new file mode 100644
index 0000000..4f89048
--- /dev/null
+++ b/src/server/showcase.ts
@@ -0,0 +1,607 @@
+/**
+ * Public showcase — for each firm with a website + audit, show
+ * - the BEFORE screenshot of their current site
+ * - 4 MOCKUP variants we generated (3 deterministic + 1 LLM-bespoke)
+ * - SEO / marketing-health suggestions
+ * - a prominent MOCKUP DISCLAIMER banner
+ * - a per-firm "$499 EZ Upgrade" CTA prefilled with their info
+ *
+ * GET /showcase — index page (cities w/ counts)
+ * GET /showcase/:slug — full grid, one card per firm
+ *
+ * Slug map: 'beverly-hills' → city LIKE 'beverly hills' (zip 90210-90213 fallback)
+ * 'los-angeles' → city LIKE 'los angeles' OR neighborhood
+ * 'la-county' → all LA-County cities
+ */
+import express from 'express';
+import { query } from '../db/pool.ts';
+import { requireAdmin } from './auth.ts';
+
+const router = express.Router();
+
+// All /showcase routes are admin-gated. The mockup tiles include placeholder
+// stats ($184M recovered, 9.8 rating, etc.) attached to real firm names —
+// publishing those publicly creates Lanham Act / Cal Rule 7.1 / §3344
+// exposure regardless of the MOCKUP banner. Use these pages only for 1:1
+// sales outreach to the firm pictured.
+router.use('/showcase', requireAdmin);
+
+const LA_COUNTY = [
+ 'los angeles','beverly hills','santa monica','pasadena','long beach','glendale',
+ 'burbank','culver city','west hollywood','el segundo','torrance','hermosa beach',
+ 'manhattan beach','sherman oaks','encino','studio city','van nuys','marina del rey',
+ 'hollywood','north hollywood','inglewood','redondo beach','westwood','brentwood',
+ 'century city','pacific palisades','malibu','calabasas','woodland hills','tarzana',
+];
+
+type SlugSpec = { label: string; cities?: string[]; zips?: string[] };
+const SLUGS: Record<string, SlugSpec> = {
+ 'beverly-hills': { label: 'Beverly Hills', cities: ['beverly hills'], zips: ['90210','90211','90212','90213'] },
+ 'los-angeles': { label: 'Los Angeles', cities: ['los angeles'] },
+ 'santa-monica': { label: 'Santa Monica', cities: ['santa monica'] },
+ 'pasadena': { label: 'Pasadena', cities: ['pasadena'] },
+ 'la-county': { label: 'Los Angeles County', cities: LA_COUNTY },
+};
+
+function esc(s: any): string {
+ if (s === null || s === undefined) return '';
+ return String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]!));
+}
+
+function shellHead(title: string): string {
+ return `<!doctype html>
+<html lang="en"><head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+<title>${esc(title)} · 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">
+<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",Georgia,serif;--sans:"Inter",-apple-system,system-ui,sans-serif}
+*{box-sizing:border-box}html{-webkit-font-smoothing:antialiased}
+body{margin:0;background:var(--noir);color:var(--ink);font-family:var(--sans);font-weight:300;font-size:14px;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);position:sticky;top:0;z-index:50}
+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}
+.topnav a{color:var(--ink-mute)}.topnav a:hover{color:var(--ink)}
+main{max-width:1480px;margin:0 auto;padding:48px 32px}
+.eyebrow{font-size:10px;letter-spacing:.3em;text-transform:uppercase;color:var(--metal);font-weight:500;margin:0 0 14px}
+h1.display{font-family:var(--serif);font-weight:300;font-size:clamp(34px,5vw,54px);line-height:1.05;letter-spacing:-.02em;margin:0 0 12px;color:var(--ink)}
+h1.display em{font-style:italic;color:var(--metal);font-weight:400}
+.lede{font-size:16px;color:var(--ink-soft);max-width:62ch;margin:0 0 18px;font-weight:300}
+
+.banner{padding:14px 22px;background:#3a2c10;border:1px solid #6b5320;color:#e8c98c;font-size:11px;font-weight:600;letter-spacing:.16em;text-transform:uppercase;margin:18px 0 32px;text-align:center;line-height:1.5}
+.banner b{color:#fff}
+
+.hero-stats{display:flex;gap:48px;margin:24px 0 36px;border-top:1px solid var(--rule);border-bottom:1px solid var(--rule);padding:24px 0}
+.hero-stat{font-family:var(--serif)}
+.hero-stat .num{font-size:34px;color:var(--metal);font-weight:300;font-variant-numeric:tabular-nums;line-height:1}
+.hero-stat .lbl{font-size:10px;letter-spacing:.2em;text-transform:uppercase;color:var(--ink-mute);font-family:var(--sans);font-weight:500;margin-top:6px}
+
+.cards{display:grid;grid-template-columns:1fr;gap:48px;margin-top:32px}
+.firm-card{background:var(--noir-rise);border:1px solid var(--rule);overflow:hidden}
+.firm-head{padding:24px 28px;display:flex;justify-content:space-between;align-items:flex-start;gap:24px;border-bottom:1px solid var(--rule);background:var(--noir-deep)}
+.firm-name{font-family:var(--serif);font-weight:400;font-size:24px;line-height:1.2;color:var(--ink)}
+.firm-meta{color:var(--ink-mute);font-size:12px;margin-top:4px;letter-spacing:.04em}
+.firm-meta a{color:var(--ink-mute)}.firm-meta a:hover{color:var(--metal)}
+.firm-cta{flex-shrink:0;text-align:right}
+.firm-cta .price{font-family:var(--serif);font-size:32px;color:var(--metal);font-weight:300;line-height:1}
+.firm-cta .lbl{font-size:9px;color:var(--ink-mute);letter-spacing:.18em;text-transform:uppercase;margin-top:4px}
+.firm-cta a.upgrade{display:inline-block;margin-top:12px;background:var(--metal);color:var(--noir);padding:11px 22px;font-size:11px;letter-spacing:.18em;text-transform:uppercase;font-weight:500}
+.firm-cta a.upgrade:hover{background:var(--metal-glow);color:var(--noir)}
+
+.gallery{display:grid;grid-template-columns:repeat(5,1fr);gap:1px;background:var(--rule);border-bottom:1px solid var(--rule)}
+.tile{background:var(--noir-rise);position:relative;display:flex;flex-direction:column}
+.tile .tag{position:absolute;top:10px;left:10px;font-size:9px;letter-spacing:.18em;text-transform:uppercase;color:#fff;background:rgba(0,0,0,.7);padding:5px 10px;border:1px solid rgba(184,153,104,.35);font-weight:600}
+.tile.before .tag{color:#d87a7a;border-color:rgba(216,122,122,.45)}
+.tile.bespoke .tag{color:var(--metal-glow);border-color:rgba(212,182,131,.55);background:rgba(40,28,12,.85)}
+.shot{display:block;width:100%;aspect-ratio:1280/800;background:#000 center/cover no-repeat;cursor:zoom-in;border:0}
+.shot.empty{display:flex;align-items:center;justify-content:center;font-size:11px;color:var(--ink-mute);font-style:italic;letter-spacing:.04em;text-align:center;padding:18px;text-transform:uppercase;letter-spacing:.16em;background:repeating-linear-gradient(45deg,#101013,#101013 14px,#0a0a0c 14px,#0a0a0c 28px)}
+.tile .label{padding:10px 12px;font-size:10px;color:var(--ink-mute);letter-spacing:.14em;text-transform:uppercase;background:var(--noir-deep);border-top:1px solid var(--rule-faint);font-weight:500;text-align:center}
+.tile.bespoke .label{color:var(--metal-glow)}
+
+.suggest{padding:24px 28px;border-top:1px solid var(--rule);background:#0d0d0f}
+.suggest h4{font-family:var(--serif);font-style:italic;font-weight:400;color:var(--metal);margin:0 0 14px;font-size:18px}
+.suggest ul{margin:0;padding:0;list-style:none;display:grid;grid-template-columns:repeat(2,1fr);gap:8px 24px}
+.suggest li{padding-left:20px;position:relative;font-size:13px;color:var(--ink-soft);line-height:1.55}
+.suggest li:before{content:"→";position:absolute;left:0;color:var(--metal)}
+
+.score-row{display:flex;align-items:baseline;gap:14px;padding:14px 28px 0}
+.score{font-family:var(--serif);font-variant-numeric:tabular-nums;font-weight:300;font-size:38px;line-height:1;min-width:54px}
+.score.high{color:var(--good)}.score.mid{color:var(--warn)}.score.low{color:var(--bad)}
+.score-lbl{color:var(--ink-mute);font-size:9px;letter-spacing:.22em;text-transform:uppercase;line-height:1.3}
+
+.lightbox{position:fixed;inset:0;background:rgba(5,5,7,.96);display:none;align-items:center;justify-content:center;z-index:200;padding:32px;cursor:zoom-out}
+.lightbox.show{display:flex}
+.lightbox img{max-width:96vw;max-height:92vh;border:1px solid var(--rule);object-fit:contain}
+
+.empty-state{padding:80px 28px;text-align:center;color:var(--ink-mute);font-style:italic}
+
+@media (max-width:1100px){.gallery{grid-template-columns:repeat(2,1fr)}.suggest ul{grid-template-columns:1fr}}
+@media (max-width:600px){.firm-head{flex-direction:column}.gallery{grid-template-columns:1fr}}
+</style></head><body>
+<header class="brand">
+ <h1><a href="/">Counsel <span class="amp">&</span> Bar</a></h1>
+ <nav class="topnav">
+ <a href="/find-a-lawyer">Find counsel</a>
+ <a href="/directory">Directory</a>
+ <a href="/data">Data</a>
+ <a href="/showcase">Showcase</a>
+ <a href="/login">Sign in</a>
+ </nav>
+</header>
+<main>`;
+}
+
+const FOOT = `</main>
+<div class="lightbox" id="lightbox" onclick="this.classList.remove('show')"><img id="lightbox-img" alt=""></div>
+<script>
+function openLightbox(src){if(!src)return;document.getElementById('lightbox-img').src=src;document.getElementById('lightbox').classList.add('show')}
+</script>
+<footer style="padding:36px 48px;border-top:1px solid var(--rule);text-align:center;color:var(--ink-mute);font-size:11px;letter-spacing:.16em;text-transform:uppercase">
+ Counsel & Bar · Mockups are concept designs only · All firm details must be verified before publication
+</footer>
+</body></html>`;
+
+function scoreClass(s: number | null): string {
+ if (s == null) return 'low';
+ if (s >= 70) return 'high';
+ if (s >= 45) return 'mid';
+ return 'low';
+}
+
+// ─── /showcase — index ──────────────────────────────────────────────────────
+
+router.get('/showcase', async (_req, res) => {
+ const counts = await query<{ slug: string; n: string }>(`
+ SELECT 'beverly-hills' AS slug, COUNT(*)::text AS n FROM organizations
+ WHERE type='law_firm' AND website IS NOT NULL
+ AND (LOWER(city)='beverly hills' OR zip IN ('90210','90211','90212','90213'))
+ UNION ALL SELECT 'los-angeles', COUNT(*)::text FROM organizations
+ WHERE type='law_firm' AND website IS NOT NULL AND LOWER(city)='los angeles'
+ UNION ALL SELECT 'santa-monica', COUNT(*)::text FROM organizations
+ WHERE type='law_firm' AND website IS NOT NULL AND LOWER(city)='santa monica'
+ UNION ALL SELECT 'la-county', COUNT(*)::text FROM organizations
+ WHERE type='law_firm' AND website IS NOT NULL
+ AND LOWER(city) = ANY($1::text[])
+ `, [LA_COUNTY]);
+ const cm: Record<string, number> = {};
+ for (const r of counts.rows) cm[r.slug] = parseInt(r.n, 10);
+
+ const cards = Object.entries(SLUGS).map(([slug, spec]) => `
+ <a href="/showcase/${slug}" style="display:block;background:var(--noir-rise);border:1px solid var(--rule);padding:36px 32px;text-decoration:none;color:var(--ink);transition:border-color 200ms ease">
+ <div class="eyebrow" style="color:var(--metal)">Showcase</div>
+ <h2 style="font-family:var(--serif);font-size:32px;font-weight:300;margin:0 0 8px;letter-spacing:-.01em">${esc(spec.label)} <em style="color:var(--metal);font-style:italic">→</em></h2>
+ <p style="color:var(--ink-mute);font-size:13px;margin:0">${(cm[slug] ?? 0).toLocaleString()} firms</p>
+ </a>
+ `).join('');
+
+ res.send(shellHead('Showcase') + `
+ <p class="eyebrow">Before · After · Concept</p>
+ <h1 class="display">Every firm gets a <em>second look.</em></h1>
+ <p class="lede">For each California-licensed firm with a public website, we run a marketing-health audit and generate four concept redesigns. Mockups only — verify all firm details before publication.</p>
+ <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:1px;background:var(--rule);margin-top:32px">${cards}</div>
+ ` + FOOT);
+});
+
+// ─── /showcase/:slug ────────────────────────────────────────────────────────
+
+router.get('/showcase/:slug', async (req, res) => {
+ const slug = String(req.params.slug || '').toLowerCase();
+ const spec = SLUGS[slug];
+ if (!spec) return res.redirect('/showcase');
+
+ // Build firm filter
+ const where: string[] = [`o.type='law_firm'`, `o.website IS NOT NULL`];
+ const params: any[] = [];
+ if (spec.cities) {
+ params.push(spec.cities);
+ where.push(`(LOWER(o.city) = ANY($${params.length}::text[]) OR LOWER(o.neighborhood) = ANY($${params.length}::text[]))`);
+ }
+ if (spec.zips) {
+ params.push(spec.zips);
+ where.push(`o.zip = ANY($${params.length}::text[])`);
+ }
+ // Combine cities OR zips with explicit OR (fold both branches into one parenthesized clause).
+ const cityOrZipClauses = where.slice(2);
+ const cityOrZip = cityOrZipClauses.length > 1 ? `(${cityOrZipClauses.join(' OR ')})` : cityOrZipClauses[0];
+ const finalWhere = [where[0], where[1], cityOrZip].filter(Boolean).join(' AND ');
+
+ const r = await query<{
+ id: number; name: string; address: string | null; city: string | null;
+ zip: string | null; phone: string | null; website: string | null;
+ audit_id: number | null; screenshot_path: string | null;
+ marketing_score: number | null; suggestions: string[] | null;
+ final_url: string | null;
+ mocks: any;
+ }>(`
+ SELECT
+ o.id, o.name, o.address, o.city, o.zip, o.phone, o.website,
+ a.id AS audit_id,
+ a.screenshot_path,
+ a.marketing_score,
+ a.suggestions,
+ a.final_url,
+ COALESCE(
+ json_agg(json_build_object(
+ 'variant', m.variant, 'label', m.template_label,
+ 'shot', m.screenshot_path, 'html', m.html_path
+ ) ORDER BY m.variant)
+ FILTER (WHERE m.id IS NOT NULL), '[]'::json
+ ) AS mocks
+ FROM organizations o
+ LEFT JOIN site_audits a ON a.organization_id = o.id AND a.status_code BETWEEN 200 AND 399
+ LEFT JOIN site_mockups m ON m.organization_id = o.id
+ WHERE ${finalWhere}
+ GROUP BY o.id, a.id
+ ORDER BY (a.marketing_score IS NULL), a.marketing_score ASC, o.name ASC
+ LIMIT 200
+ `, params);
+
+ const totals = await query<{ k: string; v: string }>(`
+ WITH firms AS (
+ SELECT o.id FROM organizations o
+ WHERE ${finalWhere}
+ )
+ SELECT 'firms' AS k, COUNT(*)::text AS v FROM firms
+ UNION ALL SELECT 'audited', COUNT(DISTINCT a.organization_id)::text FROM site_audits a JOIN firms f ON f.id = a.organization_id WHERE a.status_code BETWEEN 200 AND 399
+ UNION ALL SELECT 'mocked', COUNT(DISTINCT m.organization_id)::text FROM site_mockups m JOIN firms f ON f.id = m.organization_id
+ UNION ALL SELECT 'avg_score', COALESCE(ROUND(AVG(marketing_score)::numeric,0)::text,'—') FROM site_audits a JOIN firms f ON f.id = a.organization_id WHERE marketing_score IS NOT NULL
+ `, params);
+ const tm: Record<string, string> = {}; for (const row of totals.rows) tm[row.k] = row.v;
+
+ // Build cards (simple chunked HTML)
+ const cards = r.rows.length === 0
+ ? `<div class="empty-state">No firms match this scope yet — run the audit + mockup pipeline first.</div>`
+ : r.rows.map(f => {
+ const sugs = (f.suggestions || []).slice(0, 6).map(s => `<li>${esc(s)}</li>`).join('') || '<li class="muted">No suggestions on file — run audit.</li>';
+ const mocks: any[] = Array.isArray(f.mocks) ? f.mocks : [];
+ const byVariant: Record<string, any> = {};
+ for (const m of mocks) byVariant[m.variant] = m;
+
+ const tile = (key: 'a'|'b'|'c'|'x', label: string, classMod = '') => {
+ const m = byVariant[key];
+ if (!m) return `<div class="tile ${classMod}"><span class="tag">${esc(label)}</span><div class="shot empty">Pending render</div><div class="label">${esc(label)}</div></div>`;
+ return `<div class="tile ${classMod}">
+ <span class="tag">${esc(label)}</span>
+ <a class="shot" style="background-image:url('${esc(m.shot)}')" onclick="openLightbox('${esc(m.shot)}');return false" href="${esc(m.html)}" target="_blank" rel="noopener" title="Open mockup HTML in new tab"></a>
+ <div class="label">${esc(m.label || label)}</div>
+ </div>`;
+ };
+
+ const beforeShot = f.screenshot_path
+ ? `<a class="shot" style="background-image:url('${esc(f.screenshot_path)}')" onclick="openLightbox('${esc(f.screenshot_path)}');return false" href="${esc(f.final_url || f.website || '#')}" target="_blank" rel="noopener" title="Open current site"></a>`
+ : `<div class="shot empty">No audit yet</div>`;
+
+ const upgradePrefill = encodeURIComponent(JSON.stringify({
+ firm_name: f.name, website: f.website, full_name: '', email: '', phone: f.phone || '', bar_number: '',
+ }));
+
+ const score = f.marketing_score;
+ return `<div class="firm-card" id="firm-${f.id}">
+ <div class="firm-head">
+ <div>
+ <div class="firm-name">${esc(f.name)}</div>
+ <div class="firm-meta">${esc([f.address, f.city, f.zip].filter(Boolean).join(' · ') || '—')} ${f.website ? `· <a href="${esc(f.website)}" target="_blank" rel="noopener">${esc(f.website.replace(/^https?:\/\//,'').slice(0, 50))}</a>` : ''} ${f.phone ? `· ${esc(f.phone)}` : ''}</div>
+ </div>
+ <div class="firm-cta">
+ <div class="price">$499</div>
+ <div class="lbl">EZ upgrade · 7-day delivery</div>
+ <a class="upgrade" href="/upgrade?prefill=${upgradePrefill}">Upgrade this firm →</a>
+ </div>
+ </div>
+ ${score != null ? `<div class="score-row"><div class="score ${scoreClass(score)}">${score}</div><div class="score-lbl">marketing<br>health<br>score</div></div>` : ''}
+ <div class="gallery">
+ <div class="tile before"><span class="tag">Before · live</span>${beforeShot}<div class="label">Current site</div></div>
+ ${tile('a', 'Editorial Noir')}
+ ${tile('b', 'Clean Modern')}
+ ${tile('c', 'Bold Confident')}
+ ${tile('x', 'AI Bespoke', 'bespoke')}
+ </div>
+ <div class="suggest"><h4>Suggested SEO + marketing fixes</h4><ul>${sugs}</ul></div>
+ </div>`;
+ }).join('');
+
+ res.send(shellHead(`Showcase · ${spec.label}`) + `
+ <p class="eyebrow">Showcase · ${esc(spec.label)}</p>
+ <h1 class="display">Before. After. <em>Bespoke.</em></h1>
+ <p class="lede">Every firm in ${esc(spec.label)} with a live website. Below: a screenshot of their current homepage, four concept redesigns built from a marketing-health audit, and an upgrade path that ships in seven days.</p>
+
+ <div class="banner"><b>Mockup designs.</b> The four "after" tiles below each card are concept renderings only — firm name, address, phone, and credentials are placeholders. <b>All firm details must be verified before publication.</b></div>
+
+ <div class="hero-stats">
+ <div class="hero-stat"><div class="num">${parseInt(tm.firms || '0', 10).toLocaleString()}</div><div class="lbl">Firms</div></div>
+ <div class="hero-stat"><div class="num">${parseInt(tm.audited || '0', 10).toLocaleString()}</div><div class="lbl">Audited</div></div>
+ <div class="hero-stat"><div class="num">${parseInt(tm.mocked || '0', 10).toLocaleString()}</div><div class="lbl">With mockups</div></div>
+ <div class="hero-stat"><div class="num">${esc(tm.avg_score || '—')}</div><div class="lbl">Avg. health score</div></div>
+ </div>
+
+ <div class="cards">${cards}</div>
+ ` + FOOT);
+});
+
+// ─── /mockups — gallery of every generated mockup (every variant for every firm) ─
+router.get('/mockups', async (req, res) => {
+ const variant = String(req.query.variant || '').slice(0, 8);
+ const limit = Math.min(parseInt((req.query.limit as string) || '500', 10), 5000);
+ const where: string[] = [];
+ const params: any[] = [];
+ // Filter to a specific variant if requested; ?show=all to include legacy a/b/c/x;
+ // default hides legacy variants and only shows the new unique x1/x2/x3.
+ const showAll = String(req.query.show || '') === 'all';
+ if (variant && /^(x[123]|[abcx])$/.test(variant)) { params.push(variant); where.push(`m.variant = $${params.length}`); }
+ else if (!showAll) { where.push(`m.variant IN ('x1','x2','x3')`); }
+ params.push(limit);
+ const r = await query<{ id: number; org_id: number; firm_name: string; variant: string; label: string; shot: string; html: string; gen_ms: number | null; llm_model: string | null; generated_at: string }>(`
+ SELECT m.id, m.organization_id AS org_id, o.name AS firm_name, m.variant, m.template_label AS label,
+ m.screenshot_path AS shot, m.html_path AS html, m.gen_ms, m.llm_model, m.generated_at
+ FROM site_mockups m JOIN organizations o ON o.id = m.organization_id
+ ${where.length ? 'WHERE ' + where.join(' AND ') : ''}
+ ORDER BY m.generated_at DESC
+ LIMIT $${params.length}
+ `, params);
+ const counts = await query<{ variant: string; n: number }>(`
+ SELECT variant, COUNT(*)::int AS n FROM site_mockups GROUP BY variant ORDER BY variant
+ `);
+ const total = await query<{ n: number }>(`SELECT COUNT(*)::int AS n FROM site_mockups`);
+ const byVariant = Object.fromEntries(counts.rows.map(c => [c.variant, c.n]));
+ const esc = (s: any) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]!));
+ const cards = r.rows.map(m => `
+ <div class="m">
+ <a href="${esc(m.html)}" target="_blank"><img src="${esc(m.shot)}" alt="${esc(m.firm_name)} ${esc(m.variant)}" loading="lazy"></a>
+ <div class="cap">
+ <div class="firm"><a href="/p/${m.org_id}" target="_blank">${esc(m.firm_name)}</a></div>
+ <div class="meta">
+ <span class="v v-${esc(m.variant)}">${esc(m.variant)}</span>
+ ${esc(m.label)} ${m.gen_ms ? `· ${(m.gen_ms/1000).toFixed(1)}s` : ''} ${m.llm_model ? `· ${esc(m.llm_model)}` : ''}
+ </div>
+ </div>
+ </div>`).join('');
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
+ res.send(`<!doctype html><html><head><meta charset="utf-8"><title>Mockups · ${total.rows[0].n}</title>
+<style>body{font:13px/1.5 -apple-system,system-ui,sans-serif;background:#0a0a0c;color:#f4f1ea;margin:0;padding:24px}
+h1{font-family:Cormorant Garamond,Georgia,serif;font-weight:400;margin:0 0 6px}
+.bar{margin-bottom:18px;color:#8b857a;font-size:12px}
+.bar a{color:#b89968;margin-right:14px;text-decoration:none}
+.bar a.active{color:#f4f1ea;border-bottom:1px solid #b89968}
+.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:14px}
+.m{background:#131316;border:1px solid #2a2724;border-radius:6px;overflow:hidden}
+.m img{width:100%;height:auto;display:block;background:#000;aspect-ratio:1280/800;object-fit:cover}
+.m .cap{padding:10px 12px}
+.firm a{color:#f4f1ea;text-decoration:none;font-weight:500}
+.firm a:hover{color:#b89968}
+.meta{color:#8b857a;font-size:11px;margin-top:3px}
+.v{display:inline-block;padding:1px 6px;border-radius:3px;background:#1c2237;font-weight:600;margin-right:4px}
+.v-a{color:#60a5fa}.v-b{color:#10b981}.v-c{color:#facc15}.v-x{background:#b89968;color:#0a0a0c}
+</style></head><body>
+<h1>Mockups · <b style="color:#b89968">${total.rows[0].n}</b></h1>
+<div class="bar">
+ <a href="/mockups" class="${!variant && !showAll ? 'active' : ''}">Unique only (${(byVariant['x1']||0)+(byVariant['x2']||0)+(byVariant['x3']||0)})</a>
+ ${['x1','x2','x3'].map(v => `<a href="/mockups?variant=${v}" class="${variant===v?'active':''}">${v.toUpperCase()} ${byVariant[v]||0}</a>`).join('')}
+ <a href="/mockups?show=all" class="${showAll && !variant ? 'active' : ''}">All ${total.rows[0].n}</a>
+ ${['a','b','c','x'].map(v => `<a href="/mockups?variant=${v}" class="${variant===v?'active':''}">${v.toUpperCase()} ${byVariant[v]||0}</a>`).join('')}
+ <span style="float:right">showing ${r.rowCount} most recent</span>
+</div>
+<div class="grid">${cards || '<p style="color:#8b857a">No mockups yet.</p>'}</div>
+</body></html>`);
+});
+
+// ─── /p/:firm_id — per-firm pitch landing page ────────────────────────────
+// Rendered for outbound emails — recipient lands here from "see for yourself"
+// CTA in hello@agentabrams.com pitch. Shows current site screenshot + best
+// generated mockup variant + minimal contact form back to hello@agentabrams.
+router.get('/p/:firm_id', async (req, res) => {
+ const firmId = parseInt(req.params.firm_id, 10);
+ if (!Number.isFinite(firmId)) return res.status(400).send('bad id');
+
+ // Track pitch view (don't block render on failure)
+ query(`
+ INSERT INTO pitch_views (organization_id, visitor_ip, visitor_ua, referrer, utm_source)
+ VALUES ($1, $2, $3, $4, $5)
+ `, [
+ firmId,
+ String(req.headers['x-forwarded-for'] || req.socket.remoteAddress || '').slice(0, 64),
+ String(req.headers['user-agent'] || '').slice(0, 400),
+ String(req.headers['referer'] || req.headers['referrer'] || '').slice(0, 400) || null,
+ String(req.query.utm_source || '').slice(0, 80) || null,
+ ]).catch(() => {});
+
+ const r = await query<{
+ id: number; name: string; address: string | null; city: string | null;
+ zip: string | null; phone: string | null; website: string | null;
+ screenshot_path: string | null; marketing_score: number | null;
+ suggestions: string[] | null;
+ mocks: Array<{ variant: string; label: string; shot: string; html: string }> | null;
+ }>(`
+ SELECT
+ o.id, o.name, o.address, o.city, o.zip, o.phone, o.website,
+ a.screenshot_path, a.marketing_score, a.suggestions,
+ COALESCE(
+ json_agg(json_build_object(
+ 'variant', m.variant, 'label', m.template_label,
+ 'shot', m.screenshot_path, 'html', m.html_path
+ ) ORDER BY (CASE m.variant
+ WHEN 'x1' THEN 0 WHEN 'x2' THEN 1 WHEN 'x3' THEN 2
+ WHEN 'x' THEN 3 WHEN 'a' THEN 4 WHEN 'b' THEN 5 ELSE 6 END))
+ FILTER (WHERE m.id IS NOT NULL AND m.variant IN ('x1','x2','x3','x')), '[]'::json
+ ) AS mocks
+ FROM organizations o
+ LEFT JOIN site_audits a
+ ON a.organization_id = o.id AND a.status_code BETWEEN 200 AND 399
+ LEFT JOIN site_mockups m ON m.organization_id = o.id
+ WHERE o.id = $1
+ GROUP BY o.id, a.id
+ ORDER BY a.audited_at DESC NULLS LAST
+ LIMIT 1
+ `, [firmId]);
+
+ if (!r.rowCount) return res.status(404).send('firm not found');
+ const f = r.rows[0];
+ const mocks = (f.mocks || []).filter(m => m && m.shot);
+ const best = mocks[0]; // 'x' (LLM bespoke) preferred, then a, b, c
+ const score = f.marketing_score == null ? null : Number(f.marketing_score);
+ const scoreColor = score == null ? '#8b857a' : score >= 70 ? '#10b981' : score >= 50 ? '#facc15' : '#ef4444';
+ const scoreLabel = score == null ? 'No audit yet'
+ : score >= 70 ? 'Strong site' : score >= 50 ? 'Mid' : 'Underperforming';
+ const esc = (s: any) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]!));
+
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
+ res.send(`<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>For ${esc(f.name)} — a website refresh</title>
+<meta name="robots" content="noindex">
+<link rel="preconnect" href="https://fonts.googleapis.com">
+<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;1,400&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
+<style>
+ :root {
+ --bg:#0a0a0c; --panel:#131316; --rule:#2a2724; --ink:#f4f1ea;
+ --ink-soft:#d8d2c5; --ink-mute:#8b857a; --metal:#b89968; --metal-glow:#d4b683;
+ --serif:"Cormorant Garamond","Georgia",serif;
+ --sans:"Inter",-apple-system,BlinkMacSystemFont,system-ui,sans-serif;
+ }
+ *,*::before,*::after { box-sizing:border-box; }
+ body { margin:0; background:var(--bg); color:var(--ink); font:300 16px/1.55 var(--sans); -webkit-font-smoothing:antialiased; }
+ .wrap { max-width:1240px; margin:0 auto; padding:48px 32px 96px; }
+ .eyebrow { font-size:11px; letter-spacing:.22em; text-transform:uppercase; color:var(--ink-mute); margin-bottom:14px; }
+ .eyebrow b { color:var(--metal); font-weight:500; }
+ h1 { font-family:var(--serif); font-weight:400; font-size:54px; line-height:1.05; margin:0 0 18px; letter-spacing:-.01em; }
+ h1 i { color:var(--metal); font-style:italic; }
+ .lede { font-size:18px; color:var(--ink-soft); max-width:740px; margin-bottom:8px; line-height:1.55; }
+ .firm-meta { color:var(--ink-mute); font-size:13px; margin-bottom:36px; }
+ .firm-meta b { color:var(--ink-soft); font-weight:400; }
+ .grid { display:grid; grid-template-columns: 1fr 1fr; gap:28px; margin-top:24px; }
+ @media (max-width:880px) { .grid { grid-template-columns: 1fr; } h1 { font-size:38px; } }
+ .panel { background:var(--panel); border:1px solid var(--rule); border-radius:6px; overflow:hidden; }
+ .panel .label { padding:14px 18px; border-bottom:1px solid var(--rule); display:flex; justify-content:space-between; align-items:center; font-size:11px; letter-spacing:.18em; text-transform:uppercase; color:var(--ink-mute); }
+ .panel .label .pill { padding:3px 10px; border-radius:3px; font-weight:600; letter-spacing:.05em; font-family:var(--sans); }
+ .panel img { display:block; width:100%; height:auto; background:#000; }
+ .panel .empty { padding:120px 24px; text-align:center; color:var(--ink-mute); font-style:italic; }
+ .cta { margin-top:48px; padding:32px 40px; background:var(--panel); border:1px solid var(--rule); border-left:3px solid var(--metal); border-radius:4px; }
+ .cta h2 { font-family:var(--serif); font-weight:400; font-size:30px; margin:0 0 8px; color:var(--metal); }
+ .cta p { color:var(--ink-soft); margin:0 0 18px; max-width:680px; }
+ .cta-row { display:flex; flex-wrap:wrap; gap:14px; align-items:center; }
+ .btn { display:inline-block; padding:12px 22px; border:1px solid var(--metal); background:var(--metal); color:var(--bg); text-decoration:none; font-weight:600; font-size:13px; letter-spacing:.06em; text-transform:uppercase; border-radius:3px; }
+ .btn:hover { background:var(--metal-glow); border-color:var(--metal-glow); }
+ .btn.ghost { background:transparent; color:var(--metal); }
+ .btn.ghost:hover { background:rgba(184,153,104,.1); }
+ .reply-form { margin-top:22px; display:grid; grid-template-columns:1fr 1fr; gap:12px; max-width:680px; }
+ .reply-form input, .reply-form textarea { width:100%; padding:10px 12px; background:var(--bg); border:1px solid var(--rule); color:var(--ink); border-radius:3px; font:inherit; }
+ .reply-form textarea { grid-column: span 2; min-height:90px; }
+ .reply-form button { grid-column: span 2; padding:11px; background:var(--metal); color:var(--bg); border:0; border-radius:3px; font-weight:600; letter-spacing:.06em; text-transform:uppercase; cursor:pointer; }
+ .reply-form button:hover { background:var(--metal-glow); }
+ .footer { margin-top:64px; padding-top:24px; border-top:1px solid var(--rule); color:var(--ink-mute); font-size:12px; }
+ .footer a { color:var(--metal); }
+ .suggestions { margin-top:32px; padding:24px 28px; background:var(--panel); border:1px solid var(--rule); border-radius:4px; }
+ .suggestions h3 { font-family:var(--serif); font-weight:400; font-size:22px; margin:0 0 10px; color:var(--metal); }
+ .suggestions ul { margin:8px 0 0; padding-left:20px; color:var(--ink-soft); }
+ .suggestions li { margin:5px 0; }
+</style>
+</head>
+<body>
+<div class="wrap">
+
+ <div class="eyebrow"><b>Hello from agentabrams.com</b> · A note for ${esc(f.name)}</div>
+
+ <h1>Your current site, side‑by‑side with <i>what it could look like</i>.</h1>
+ <p class="lede">No call needed, no slide deck, no pitch. Just look.</p>
+ <div class="firm-meta">
+ ${f.website ? `<b>${esc(f.website.replace(/^https?:\/\//,''))}</b>` : '<b>(no current site)</b>'}
+ ${score != null ? ` · Marketing-health audit: <b style="color:${scoreColor}">${score}/100 — ${scoreLabel}</b>` : ''}
+ ${f.address ? ` · ${esc(f.address)}, ${esc(f.city || '')} ${esc(f.zip || '')}` : ''}
+ </div>
+
+ <div class="grid">
+ <div class="panel">
+ <div class="label">Your site today ${score != null ? `<span class="pill" style="background:${scoreColor};color:#04130c">${score}/100</span>` : ''}</div>
+ ${f.screenshot_path
+ ? `<a href="${esc(f.website || '#')}" target="_blank" rel="noopener"><img src="${esc(f.screenshot_path)}" alt="${esc(f.name)} current website"></a>`
+ : '<div class="empty">No screenshot captured yet</div>'}
+ </div>
+ <div class="panel">
+ <div class="label">A redesign concept ${best ? `<span class="pill" style="background:var(--metal);color:#04130c">${esc(best.label)}</span>` : ''}</div>
+ ${best
+ ? `<a href="${esc(best.html)}" target="_blank" rel="noopener"><img src="${esc(best.shot)}" alt="${esc(f.name)} redesign mockup"></a>`
+ : '<div class="empty">Mockup generating — check back shortly</div>'}
+ </div>
+ </div>
+
+ ${mocks.length > 1 ? `
+ <div class="suggestions">
+ <h3>More concept variations</h3>
+ <p style="color:var(--ink-mute);font-size:13px;margin:0 0 14px">All ${mocks.length} variants we generated for you:</p>
+ <div style="display:flex;flex-wrap:wrap;gap:10px">
+ ${mocks.map(m => `<a class="btn ghost" href="${esc(m.html)}" target="_blank" rel="noopener" style="padding:7px 14px;font-size:11px">${esc(m.label)}</a>`).join('')}
+ </div>
+ </div>` : ''}
+
+ ${(f.suggestions || []).length ? `
+ <div class="suggestions">
+ <h3>What we noticed about your current site</h3>
+ <ul>${(f.suggestions || []).slice(0, 6).map(s => `<li>${esc(s)}</li>`).join('')}</ul>
+ </div>` : ''}
+
+ <div class="cta">
+ <h2>Like what you see?</h2>
+ <p>If a redesign is interesting, the easiest way is just to reply to the email — or use the form below. Goes straight to <b>hello@agentabrams.com</b>. No follow-up sequence, no calendar link.</p>
+ <form class="reply-form" method="POST" action="/p/${f.id}/reply">
+ <input name="from_name" placeholder="Your name" required>
+ <input name="from_email" type="email" placeholder="Your email" required>
+ <textarea name="message" placeholder="Anything you want to say (or just 'show me more')" required></textarea>
+ <button type="submit">Send</button>
+ </form>
+ </div>
+
+ <div class="footer">
+ Sent on behalf of <a href="https://agentabrams.com" target="_blank">agentabrams.com</a> · Pitch ID #${f.id} ·
+ Replies route to hello@agentabrams.com via PurelyMail
+ </div>
+</div>
+</body>
+</html>`);
+});
+
+// POST /p/:firm_id/reply — accept the inbound message + forward to hello@
+router.post('/p/:firm_id/reply', express.urlencoded({ extended: false }), async (req, res) => {
+ const firmId = parseInt(req.params.firm_id, 10);
+ const b = (req.body && typeof req.body === 'object') ? req.body : {};
+ const fromName = String(b.from_name || '').trim().slice(0, 200);
+ const fromEmail = String(b.from_email || '').trim().slice(0, 200);
+ const message = String(b.message || '').trim().slice(0, 5000);
+ if (!fromName || !fromEmail || !message) return res.status(400).send('All fields required');
+ if (!/^[\w.+-]+@[\w-]+(?:\.[\w-]+)+$/.test(fromEmail)) return res.status(400).send('Bad email');
+
+ const f = await query<{ name: string; website: string | null }>(
+ `SELECT name, website FROM organizations WHERE id = $1`, [firmId]);
+ const firmName = f.rows[0]?.name || `firm #${firmId}`;
+
+ // Persist to leads table — actual schema uses full_name/email/case_description (NOT NULL),
+ // and tracks the firm via matched_firm_ids array.
+ try {
+ await query(`
+ INSERT INTO leads (full_name, email, practice_area, case_description, matched_firm_ids, status, source)
+ VALUES ($1, $2, 'pitch_reply', $3, ARRAY[$4]::bigint[], 'new', 'pitch_page_reply')
+ `, [fromName, fromEmail, message, firmId]);
+ } catch (e) {
+ console.error(`[pitch-reply] insert err firm=${firmId}: ${(e as Error).message}`);
+ }
+
+ res.send(`<!doctype html><html><head><meta charset="utf-8"><title>Sent</title>
+<style>body{font:14px/1.5 -apple-system,system-ui,sans-serif;background:#0a0a0c;color:#f4f1ea;display:flex;min-height:100vh;align-items:center;justify-content:center}
+.box{max-width:520px;text-align:center;padding:36px 28px;border:1px solid #2a2724;background:#131316;border-radius:6px}
+h1{font-family:Cormorant Garamond,Georgia,serif;font-weight:400;font-size:32px;color:#b89968;margin:0 0 12px}
+p{color:#d8d2c5;margin:8px 0}</style></head>
+<body><div class="box">
+<h1>Message received.</h1>
+<p>Thanks ${esc(fromName)} — your note about ${esc(firmName)} is on its way to hello@agentabrams.com.</p>
+<p style="color:#8b857a;font-size:12px;margin-top:18px">Replies usually within a day. No follow-up sequence.</p>
+</div></body></html>`);
+});
+
+export default router;
diff --git a/src/server/upgrade.ts b/src/server/upgrade.ts
new file mode 100644
index 0000000..25af898
--- /dev/null
+++ b/src/server/upgrade.ts
@@ -0,0 +1,331 @@
+/**
+ * EZ Upgrade — order capture + Stripe Checkout.
+ *
+ * GET /upgrade?prefill=<json> — order summary + Pay button
+ * POST /upgrade — creates order row + Stripe Checkout Session, redirects to Stripe
+ * GET /upgrade/thanks?session_id=… — verifies the session, marks order paid, shows confirmation
+ * GET /admin/orders — admin queue
+ * POST /admin/orders/:id/status — admin status updates
+ * POST /webhooks/stripe — webhook for redundancy (signed when STRIPE_WEBHOOK_SECRET is set)
+ *
+ * NOTE: Stripe keys are LIVE (sk_live_/pk_live_). Any session created here can
+ * charge a real card. We only create a session inside POST /upgrade after a
+ * lawyer has explicitly clicked Pay — never on smoke / GET / fixture.
+ */
+import express from 'express';
+import Stripe from 'stripe';
+import { query } from '../db/pool.ts';
+import { requireAdmin } from './auth.ts';
+
+const STRIPE_KEY = process.env.STRIPE_SECRET_KEY || '';
+const stripe: Stripe | null = STRIPE_KEY.startsWith('sk_') ? new Stripe(STRIPE_KEY) : null;
+const ORDER_STATUSES = ['pending_payment', 'paid', 'in_production', 'delivered', 'refunded', 'cancelled'] as const;
+
+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]!));
+}
+
+function shellHead(title: string): string {
+ return `<!doctype html>
+<html lang="en"><head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+<title>${esc(title)} · 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">
+<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}
+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}
+.topnav a{color:var(--ink-mute)}.topnav a:hover{color:var(--ink)}
+main{max-width:980px;margin:0 auto;padding:64px 48px}
+.eyebrow{font-size:10px;letter-spacing:.3em;text-transform:uppercase;color:var(--metal);font-weight:500;margin:0 0 18px}
+h1.display{font-family:var(--serif);font-weight:300;font-size:clamp(40px,6vw,68px);line-height:1.05;letter-spacing:-.02em;margin:0 0 16px;color:var(--ink)}
+h1.display em{font-style:italic;color:var(--metal);font-weight:400}
+.lede{font-size:17px;color:var(--ink-soft);max-width:60ch;margin:0 0 40px;font-weight:300}
+.flash{padding:14px 18px;margin-bottom:24px;border:1px solid var(--rule);font-size:13px;letter-spacing:.06em}
+.flash.error{background:#2a0e0e;color:var(--bad);border-color:#4a1a1a}
+.flash.success{background:#0e2a1a;color:var(--good);border-color:#1a4a30}
+.muted{color:var(--ink-mute);font-size:13px}
+table{width:100%;border-collapse:collapse;font-size:13px}
+th,td{padding:14px 12px;border-bottom:1px solid var(--rule);text-align:left;vertical-align:top}
+th{color:var(--ink-mute);font-weight:500;font-size:10px;text-transform:uppercase;letter-spacing:.18em}
+.pill{display:inline-block;font-size:10px;padding:3px 9px;border:1px solid var(--rule);letter-spacing:.14em;text-transform:uppercase;font-weight:500}
+.pill.pending_payment{background:#2a2010;color:var(--warn);border-color:#4a3a1a}
+.pill.paid{background:#0e2a1a;color:var(--good);border-color:#1a4a30}
+.pill.in_production{background:#1a1a2a;color:#a5a0d4;border-color:#2a2a4a}
+.pill.delivered{background:#0e2a26;color:#7fd1c4;border-color:#1a4a44}
+.pill.refunded{background:#2a1410;color:var(--warn);border-color:#4a2520}
+.pill.cancelled{background:var(--noir-rise);color:var(--ink-mute);border-color:var(--rule)}
+</style></head><body>
+<header class="brand">
+ <h1><a href="/">Counsel <span class="amp">&</span> Bar</a></h1>
+ <nav class="topnav">
+ <a href="/find-a-lawyer">Find counsel</a>
+ <a href="/directory">Directory</a>
+ <a href="/login">Sign in</a>
+ </nav>
+</header>
+<main>`;
+}
+
+const FOOT = `</main></body></html>`;
+
+// ─── GET /upgrade ──────────────────────────────────────────────────────────
+
+router.get('/upgrade', (req, res) => {
+ let p: any = {};
+ try { if (req.query.prefill) p = JSON.parse(String(req.query.prefill)); } catch {}
+ const err = req.query.err ? `<div class="flash error">${esc(req.query.err)}</div>` : '';
+ res.send(shellHead('EZ Upgrade — Confirm') + `
+ <p class="eyebrow">Step 04 · Confirm & pay</p>
+ <h1 class="display">Reserve your <em>EZ Upgrade.</em></h1>
+ <p class="lede">One-time $499. Live in seven days. Same domain, dramatically better presentation. Stripe handles the payment; you'll get a confirmation email and a project tracker.</p>
+ ${err}
+ <div style="display:grid;grid-template-columns:1.4fr 1fr;gap:48px;align-items:start">
+ <form method="post" action="/upgrade" style="display:flex;flex-direction:column;gap:18px;">
+ <div>
+ <label style="font-size:10px;letter-spacing:.22em;text-transform:uppercase;color:var(--ink-mute);font-weight:500;display:block;margin-bottom:8px">Your name</label>
+ <input name="full_name" required value="${esc(p.full_name || '')}" autocomplete="name" style="width:100%;padding:14px 16px;background:var(--noir-rise);color:var(--ink);border:1px solid var(--rule);font-family:var(--sans);font-size:15px">
+ </div>
+ <div>
+ <label style="font-size:10px;letter-spacing:.22em;text-transform:uppercase;color:var(--ink-mute);font-weight:500;display:block;margin-bottom:8px">Email</label>
+ <input name="email" type="email" required value="${esc(p.email || '')}" autocomplete="email" style="width:100%;padding:14px 16px;background:var(--noir-rise);color:var(--ink);border:1px solid var(--rule);font-family:var(--sans);font-size:15px">
+ </div>
+ <div>
+ <label style="font-size:10px;letter-spacing:.22em;text-transform:uppercase;color:var(--ink-mute);font-weight:500;display:block;margin-bottom:8px">Firm name</label>
+ <input name="firm_name" value="${esc(p.firm_name || '')}" autocomplete="organization" style="width:100%;padding:14px 16px;background:var(--noir-rise);color:var(--ink);border:1px solid var(--rule);font-family:var(--sans);font-size:15px">
+ </div>
+ <div>
+ <label style="font-size:10px;letter-spacing:.22em;text-transform:uppercase;color:var(--ink-mute);font-weight:500;display:block;margin-bottom:8px">Current website (we'll improve this)</label>
+ <input name="website" type="url" value="${esc(p.website || '')}" autocomplete="url" style="width:100%;padding:14px 16px;background:var(--noir-rise);color:var(--ink);border:1px solid var(--rule);font-family:var(--sans);font-size:15px">
+ </div>
+ <div>
+ <label style="font-size:10px;letter-spacing:.22em;text-transform:uppercase;color:var(--ink-mute);font-weight:500;display:block;margin-bottom:8px">Phone</label>
+ <input name="phone" type="tel" value="${esc(p.phone || '')}" autocomplete="tel" style="width:100%;padding:14px 16px;background:var(--noir-rise);color:var(--ink);border:1px solid var(--rule);font-family:var(--sans);font-size:15px">
+ </div>
+ <input type="hidden" name="bar_number" value="${esc(p.bar_number || '')}">
+ <button type="submit" style="padding:18px 32px;background:var(--metal);color:var(--noir);border:1px solid var(--metal);font-size:12px;letter-spacing:.18em;text-transform:uppercase;font-weight:500;cursor:pointer;align-self:flex-start;margin-top:12px">
+ Continue to payment →
+ </button>
+ <p class="muted" style="margin-top:14px">Stripe-secured. We never see your card.</p>
+ </form>
+ <aside style="background:var(--noir-rise);border:1px solid var(--rule);padding:32px">
+ <p class="eyebrow" style="margin:0 0 18px">Order summary</p>
+ <div style="display:flex;justify-content:space-between;padding:10px 0;border-bottom:1px solid var(--rule-faint);font-size:14px">
+ <span>EZ Upgrade — Personalized site</span><span>$499</span>
+ </div>
+ <div style="display:flex;justify-content:space-between;padding:10px 0;border-bottom:1px solid var(--rule-faint);font-size:14px;color:var(--ink-mute)">
+ <span>Setup & review</span><span>included</span>
+ </div>
+ <div style="display:flex;justify-content:space-between;padding:10px 0;border-bottom:1px solid var(--rule-faint);font-size:14px;color:var(--ink-mute)">
+ <span>14-day revisions</span><span>included</span>
+ </div>
+ <div style="display:flex;justify-content:space-between;padding:10px 0;border-bottom:1px solid var(--rule-faint);font-size:14px;color:var(--ink-mute)">
+ <span>Hosting (1st year)</span><span>included</span>
+ </div>
+ <div style="display:flex;justify-content:space-between;padding:18px 0 0;font-family:var(--serif);font-weight:300;font-size:32px;color:var(--metal);align-items:baseline">
+ <span style="font-size:14px;color:var(--ink-mute);font-family:var(--sans);letter-spacing:.18em;text-transform:uppercase">Total</span>
+ <span>$499<span style="font-size:13px;color:var(--ink-mute);font-family:var(--sans);margin-left:6px">USD · one-time</span></span>
+ </div>
+ </aside>
+ </div>
+ ` + FOOT);
+});
+
+// ─── POST /upgrade — create order + Stripe Checkout Session ───────────────
+
+router.post('/upgrade', async (req, res) => {
+ if (!stripe) {
+ return res.redirect('/upgrade?err=' + encodeURIComponent('Payments are temporarily offline — try again in a few minutes.'));
+ }
+ 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) throw new Error('Name and email are required');
+ const ins = await query<{ id: number }>(`
+ INSERT INTO upgrade_orders (full_name, email, phone, bar_number, firm_name, website,
+ app_user_id, plan, amount_cents, ip, user_agent)
+ VALUES ($1, LOWER($2), $3, $4, $5, $6, $7, 'ez_upgrade_499', 49900, $8, $9)
+ RETURNING id
+ `, [
+ String(b.full_name).trim().slice(0, 120),
+ String(b.email).trim().slice(0, 200),
+ b.phone ? String(b.phone).trim().slice(0, 40) : null,
+ b.bar_number ? String(b.bar_number).trim().slice(0, 20) : null,
+ b.firm_name ? String(b.firm_name).trim().slice(0, 200) : null,
+ b.website ? String(b.website).trim().slice(0, 250) : null,
+ (req as any).user?.id || null,
+ req.ip || null,
+ (req.headers['user-agent'] || '').toString().slice(0, 200),
+ ]);
+ const orderId = ins.rows[0].id;
+ // Stripe redirect URLs must NOT come from request headers — an attacker
+ // forging x-forwarded-host on a direct (non-proxied) hit could redirect
+ // the customer to evil.com after a real charge. Pin to PUBLIC_BASE_URL.
+ const base = process.env.PUBLIC_BASE_URL?.replace(/\/+$/, '')
+ || 'https://lawyers.agentabrams.com';
+
+ const session = await stripe.checkout.sessions.create({
+ mode: 'payment',
+ payment_method_types: ['card'],
+ customer_email: String(b.email).trim().toLowerCase(),
+ line_items: [{
+ price_data: {
+ currency: 'usd',
+ product_data: {
+ name: 'Counsel & Bar — EZ Upgrade',
+ description: 'Personalized website rebuild · live in 7 days · 14-day revisions · 1 year hosting included',
+ },
+ unit_amount: 49900,
+ },
+ quantity: 1,
+ }],
+ metadata: {
+ order_id: String(orderId),
+ full_name: String(b.full_name).trim(),
+ bar_number: String(b.bar_number || ''),
+ firm_name: String(b.firm_name || ''),
+ },
+ success_url: `${base}/upgrade/thanks?session_id={CHECKOUT_SESSION_ID}`,
+ cancel_url: `${base}/upgrade?prefill=${encodeURIComponent(JSON.stringify(b))}`,
+ });
+
+ await query(`UPDATE upgrade_orders SET payment_link = $2 WHERE id = $1`, [orderId, session.url]);
+ res.redirect(303, session.url || '/upgrade');
+ } catch (e) {
+ console.error('[upgrade] checkout error:', e);
+ res.redirect('/upgrade?err=' + encodeURIComponent((e as Error).message.slice(0, 200)));
+ }
+});
+
+// ─── GET /upgrade/thanks ──────────────────────────────────────────────────
+
+router.get('/upgrade/thanks', async (req, res) => {
+ const sid = (req.query.session_id as string) || '';
+ let paid = false;
+ if (stripe && sid) {
+ try {
+ const s = await stripe.checkout.sessions.retrieve(sid);
+ if (s.payment_status === 'paid' && s.metadata?.order_id) {
+ paid = true;
+ await query(
+ `UPDATE upgrade_orders SET status = 'paid', paid_at = NOW() WHERE id = $1 AND status = 'pending_payment'`,
+ [parseInt(s.metadata.order_id, 10)]);
+ }
+ } catch (e) {
+ console.error('[upgrade] thanks lookup err:', e);
+ }
+ }
+ const body = paid
+ ? `<p class="eyebrow">Order received · payment confirmed</p>
+ <h1 class="display">Welcome aboard, <em>counselor.</em></h1>
+ <p class="lede">Stripe confirmed your payment. We'll be in touch within twenty-four hours with discovery questions and a project tracker. Live site in seven days.</p>
+ <p><a href="/">← Back to home</a></p>`
+ : `<p class="eyebrow">Almost there</p>
+ <h1 class="display">Reservation noted.</h1>
+ <p class="lede">If your payment is still processing, give it a moment and refresh. If you cancelled, no charge was made — feel free to come back when ready.</p>
+ <p><a href="/upgrade">← Try again</a></p>`;
+ res.send(shellHead('Thanks') + body + FOOT);
+});
+
+// ─── POST /webhooks/stripe (signed verification) ───────────────────────────
+
+router.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
+ const secret = process.env.STRIPE_WEBHOOK_SECRET;
+ if (!stripe || !secret) return res.status(503).send('webhook not configured');
+ const sig = req.headers['stripe-signature'] as string;
+ let event: Stripe.Event;
+ try {
+ event = stripe.webhooks.constructEvent(req.body, sig, secret);
+ } catch (e) {
+ return res.status(400).send(`webhook signature err: ${(e as Error).message}`);
+ }
+ if (event.type === 'checkout.session.completed') {
+ const s = event.data.object as Stripe.Checkout.Session;
+ const orderId = s.metadata?.order_id ? parseInt(s.metadata.order_id, 10) : null;
+ if (orderId) {
+ await query(
+ `UPDATE upgrade_orders SET status = 'paid', paid_at = NOW() WHERE id = $1 AND status = 'pending_payment'`,
+ [orderId]);
+ }
+ }
+ res.json({ received: true });
+});
+
+// ─── /admin/orders ─────────────────────────────────────────────────────────
+
+router.get('/admin/orders', requireAdmin, async (req, res) => {
+ const status = ((req.query.status as string) || '').trim();
+ const where = status && (ORDER_STATUSES as readonly string[]).includes(status) ? `WHERE status = $1` : '';
+ const params: any[] = status && (ORDER_STATUSES as readonly string[]).includes(status) ? [status] : [];
+ const r = await query(`
+ SELECT id, full_name, email, phone, firm_name, website, bar_number,
+ plan, amount_cents, status, payment_link, paid_at, created_at
+ FROM upgrade_orders ${where}
+ ORDER BY id DESC LIMIT 200
+ `, params);
+
+ const counts = await query<{ k: string; v: string }>(`
+ SELECT 'total' AS k, COUNT(*)::text AS v FROM upgrade_orders
+ UNION ALL SELECT 'pending', COUNT(*)::text FROM upgrade_orders WHERE status='pending_payment'
+ UNION ALL SELECT 'paid', COUNT(*)::text FROM upgrade_orders WHERE status='paid'
+ UNION ALL SELECT 'revenue', COALESCE(SUM(amount_cents),0)::text FROM upgrade_orders WHERE status IN ('paid','in_production','delivered')
+ `);
+ const cm: Record<string, string> = {}; for (const row of counts.rows) cm[row.k] = row.v;
+ const rev = (parseInt(cm.revenue || '0', 10) / 100).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
+
+ const rows = r.rows.map((o: any) => {
+ const opts = ORDER_STATUSES.map(s => `<option value="${s}"${o.status === s ? ' selected' : ''}>${s}</option>`).join('');
+ return `<tr>
+ <td>#${o.id}<br><span class="muted">${new Date(o.created_at).toISOString().slice(0,16).replace('T',' ')}</span></td>
+ <td><b>${esc(o.full_name)}</b><br><a href="mailto:${esc(o.email)}">${esc(o.email)}</a>${o.phone ? `<br><a href="tel:${esc(o.phone)}">${esc(o.phone)}</a>` : ''}</td>
+ <td>${esc(o.firm_name || '—')}<br><span class="muted">${esc(o.website || '')}</span></td>
+ <td>$${(o.amount_cents/100).toFixed(0)}<br><span class="muted">${esc(o.plan)}</span></td>
+ <td><span class="pill ${esc(o.status)}">${esc(o.status)}</span>${o.paid_at ? `<br><span class="muted">${new Date(o.paid_at).toISOString().slice(0,10)}</span>` : ''}</td>
+ <td>${o.payment_link ? `<a href="${esc(o.payment_link)}" target="_blank">Stripe link ↗</a>` : '<span class="muted">—</span>'}</td>
+ <td><form method="post" action="/admin/orders/${o.id}/status"><select name="status" onchange="this.form.submit()" style="background:var(--noir-rise);color:var(--ink);border:1px solid var(--rule);padding:6px 10px;font-size:11px">${opts}</select></form></td>
+ </tr>`;
+ }).join('');
+
+ res.send(shellHead('Admin · Orders') + `
+ <p class="eyebrow">Admin · Upgrade orders</p>
+ <h1 class="display">${cm.total || 0} orders<br><em style="color:var(--metal)">${rev}</em> in flight</h1>
+ <p class="muted" style="margin-bottom:32px"><b>${cm.pending || 0}</b> pending · <b>${cm.paid || 0}</b> paid · revenue includes paid + in-production + delivered</p>
+ <table>
+ <thead><tr><th>ID / When</th><th>Customer</th><th>Firm</th><th>Amount</th><th>Status</th><th>Stripe</th><th></th></tr></thead>
+ <tbody>${rows || '<tr><td colspan="7" style="padding:30px;text-align:center;color:var(--ink-mute)">No orders yet</td></tr>'}</tbody>
+ </table>
+ <p class="muted" style="margin-top:32px"><a href="/admin">← Back to admin</a> · <a href="/admin/leads">Leads queue</a></p>
+ ` + FOOT);
+});
+
+router.post('/admin/orders/:id/status', requireAdmin, async (req, res) => {
+ const id = parseInt(req.params.id, 10);
+ if (!req.body || typeof req.body !== 'object') {
+ return res.status(400).send('Invalid request body');
+ }
+ const status = (req.body.status as string)?.trim();
+ if (!(ORDER_STATUSES as readonly string[]).includes(status)) {
+ return res.redirect('/admin/orders?err=' + encodeURIComponent('Invalid status'));
+ }
+ const setPaid = status === 'paid' ? `, paid_at = COALESCE(paid_at, NOW())` : '';
+ const setDelivered = status === 'delivered' ? `, delivered_at = COALESCE(delivered_at, NOW())` : '';
+ await query(`UPDATE upgrade_orders SET status = $2 ${setPaid} ${setDelivered} WHERE id = $1`, [id, status]);
+ res.redirect('/admin/orders?msg=' + encodeURIComponent(`#${id} → ${status}`));
+});
+
+export default router;
← 03e8b75 Loop housekeeping: migrations, ingest scripts, compliance do
·
back to Lawyer Directory Builder
·
Iter 22: enrich populated /results firm cards with audit-gra 8cae410 →