← back to Lawyer Directory Builder

src/server/portal.ts

851 lines

/**
 * User + admin portals.
 *
 *   /signup, /login, /logout, /dashboard       — user
 *   /admin                                      — admin (list, edit plan, suspend, delete)
 *
 * All HTML is server-rendered inline (no React/SSR framework — keep it simple
 * and self-contained). Forms post to action routes; route handlers redirect
 * back to a page with a flash query param.
 */
import express from 'express';
import {
  hashPassword, verifyPassword, createUser, findUserByEmail, findUserAndVerifyPassword,
  createSession, destroySession,
  setSessionCookie, clearSessionCookie, requireUser, requireAdmin, PLANS, STATUSES,
} from './auth.ts';
import { query } from '../db/pool.ts';
import cookie from 'cookie';

const router = express.Router();
router.use(express.urlencoded({ extended: false }));

// ─── shared layout helpers ─────────────────────────────────────────────────

function layout(title: string, body: string, opts: { user?: any } = {}) {
  const userBar = opts.user
    ? `<div class="topnav-user">Signed in as <b>${esc(opts.user.email)}</b> · <span class="pill ${opts.user.plan}">${opts.user.plan}</span> · <a href="/dashboard">Dashboard</a>${opts.user.role === 'admin' ? ' · <a href="/admin">Admin</a> · <a href="/admin/leads">Leads</a>' : ''} · <form action="/logout" method="post" style="display:inline"><button class="link" type="submit">Sign out</button></form></div>`
    : `<div class="topnav-user"><a href="/login">Sign in</a> · <a href="/signup">Sign up</a></div>`;
  const monogram = `<svg viewBox="0 0 28 28" width="28" height="28" aria-hidden="true" style="flex:0 0 28px"><defs><linearGradient id="cb-portal" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#d4b683"/><stop offset="50%" stop-color="#b89968"/><stop offset="100%" stop-color="#8a7044"/></linearGradient></defs><circle cx="14" cy="14" r="13" fill="none" stroke="url(#cb-portal)" stroke-width="1"/><path fill="url(#cb-portal)" d="M9.6 9.5c-1.6 0-2.7 1.2-2.7 3v3c0 1.8 1.1 3 2.7 3 1.4 0 2.4-.8 2.6-2.1h-1.3c-.1.6-.6 1-1.3 1-.9 0-1.4-.6-1.4-1.7v-2.4c0-1.1.5-1.7 1.4-1.7.7 0 1.2.4 1.3 1h1.3c-.2-1.3-1.2-2.1-2.6-2.1zm5.5.1v8.7h2.7c1.6 0 2.6-.9 2.6-2.4 0-1-.5-1.7-1.3-2 .7-.3 1.1-1 1.1-1.8 0-1.5-1-2.5-2.5-2.5h-2.6zm1.3 1.1h1.2c.8 0 1.4.5 1.4 1.4 0 .9-.5 1.4-1.4 1.4h-1.2v-2.8zm0 3.9h1.4c.9 0 1.4.6 1.4 1.5s-.5 1.5-1.4 1.5h-1.4V14.6z"/></svg>`;
  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 &amp; 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;-moz-osx-font-smoothing:grayscale}
  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;transition:color 180ms ease}
  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;letter-spacing:.01em}
  header.brand h1 a{color:var(--ink)}
  header.brand h1 .amp{color:var(--metal);font-style:italic;padding:0 4px}
  .topnav-user{font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--ink-mute)}
  .topnav-user b{color:var(--ink);font-weight:500}
  .topnav-user a{color:var(--ink-mute);text-transform:uppercase}
  .topnav-user a:hover{color:var(--ink)}
  main{max-width:980px;margin:0 auto;padding:64px 48px}
  main.wide{max-width:1280px}
  h2{margin:0 0 28px;font-family:var(--serif);font-weight:300;font-size:clamp(34px,5vw,52px);line-height:1.1;letter-spacing:-.015em;color:var(--ink)}
  h2 em{font-style:italic;color:var(--metal);font-weight:400}
  h3{margin:32px 0 12px;font-family:var(--serif);font-weight:400;font-size:22px;color:var(--ink);letter-spacing:-.005em}
  p.lede{font-size:17px;color:var(--ink-soft);margin:-16px 0 32px;max-width:60ch;font-weight:300}
  form.stack{display:flex;flex-direction:column;gap:14px;max-width:480px}
  input,select,textarea,button{font:inherit;font-family:var(--sans);color:var(--ink);background:var(--noir-rise);border:1px solid var(--rule);border-radius:0;padding:13px 16px;transition:border-color 180ms ease, box-shadow 180ms ease}
  input:focus,select:focus,textarea:focus{outline:none;border-color:var(--metal);box-shadow:0 0 0 3px rgba(184,153,104,.12)}
  button{background:var(--metal);border-color:var(--metal);color:var(--noir);font-weight:500;font-size:12px;letter-spacing:.18em;text-transform:uppercase;padding:14px 24px;cursor:pointer;transition:background 220ms ease,transform 220ms ease}
  button:hover{background:var(--metal-glow);border-color:var(--metal-glow);transform:translateY(-1px)}
  button.danger{background:transparent;border-color:#5a2222;color:var(--bad)}
  button.danger:hover{background:#2a0e0e;color:var(--bad);transform:none}
  button.warn{background:transparent;border-color:#5a4520;color:var(--warn)}
  button.warn:hover{background:#2a2010;color:var(--warn);transform:none}
  button.link{background:none;border:none;padding:0;color:var(--metal);cursor:pointer;text-decoration:none;font:inherit;font-size:11px;letter-spacing:.14em;text-transform:uppercase}
  button.link:hover{color:var(--metal-glow);transform:none}
  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:middle}
  th{color:var(--ink-mute);font-weight:500;font-size:10px;text-transform:uppercase;letter-spacing:.18em}
  tr:hover td{background:var(--rule-faint)}
  .pill{display:inline-block;font-size:10px;padding:3px 9px;border-radius:0;border:1px solid var(--rule);letter-spacing:.14em;text-transform:uppercase;font-weight:500}
  .pill.active{background:#0e2a1a;color:var(--good);border-color:#1a4a30}
  .pill.suspended{background:#2a2010;color:var(--warn);border-color:#4a3a1a}
  .pill.deleted{background:#2a0e0e;color:var(--bad);border-color:#4a1a1a}
  .pill.admin{background:#1a1a2a;color:#a5a0d4;border-color:#2a2a4a}
  .pill.pro{background:#0e2a26;color:#7fd1c4;border-color:#1a4a44}
  .pill.premium{background:#2a2010;color:var(--metal-glow);border-color:#4a3a1a}
  .pill.free{background:var(--noir-rise);color:var(--ink-mute);border-color:var(--rule)}
  .flash{padding:14px 18px;margin-bottom:24px;border:1px solid var(--rule);font-size:13px;letter-spacing:.06em}
  .flash.success{background:#0e2a1a;color:var(--good);border-color:#1a4a30}
  .flash.error{background:#2a0e0e;color:var(--bad);border-color:#4a1a1a}
  .muted{color:var(--ink-mute);font-size:13px}
  .actions{display:flex;gap:8px;flex-wrap:wrap}
  .actions form{display:inline}
  .actions button{padding:7px 12px;font-size:10px;letter-spacing:.12em}
  .stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:0;margin:24px 0;border-top:1px solid var(--rule);border-bottom:1px solid var(--rule)}
  .stat{padding:24px 20px;border-right:1px solid var(--rule)}
  .stat:last-child{border-right:0}
  .stat .num{font-family:var(--serif);font-weight:300;font-size:32px;line-height:1;color:var(--metal);font-variant-numeric:tabular-nums;margin-bottom:8px}
  .stat .lbl{font-size:10px;letter-spacing:.2em;text-transform:uppercase;color:var(--ink-mute)}
  @media (max-width:720px){header.brand{padding:16px 20px;gap:10px;flex-wrap:wrap}main{padding:36px 20px}.stat{border-right:0;border-bottom:1px solid var(--rule)}}
  @media (prefers-reduced-motion: no-preference){
    .scroll-reveal{opacity:0;transform:translateY(22px);transition:opacity 900ms cubic-bezier(.16,.84,.3,1),transform 900ms cubic-bezier(.16,.84,.3,1)}
    .scroll-reveal.in-view{opacity:1;transform:none}
    .scroll-reveal.s1{transition-delay:80ms}
    .scroll-reveal.s2{transition-delay:160ms}
    .scroll-reveal.s3{transition-delay:240ms}
  }

  /* ── Shared portal display elements (Volume strip, italic-metal display, receipt, paths) ── */
  .signup-vol{display:flex;align-items:center;gap:18px;margin:0 0 26px;color:var(--metal)}
  .signup-vol .v-rule{flex:0 0 56px;height:1px;background:linear-gradient(90deg,transparent,var(--metal) 50%,transparent)}
  .signup-vol .v-text{font-family:var(--serif);font-style:italic;font-weight:400;font-size:14px;letter-spacing:.02em}
  .signup-vol .v-meta{font-size:10px;letter-spacing:.28em;text-transform:uppercase;color:var(--ink-mute);font-weight:500}
  .signup-display{font-family:var(--serif);font-weight:300;font-size:clamp(40px,6.2vw,72px);line-height:1.04;letter-spacing:-.018em;margin:0 0 22px;max-width:18ch;color:var(--ink)}
  .signup-display em{font-style:italic;color:var(--metal);font-weight:400}
  .signup-lede{color:var(--ink-soft);font-size:17px;line-height:1.6;margin:0 0 56px;max-width:62ch;font-weight:300}
  .receipt{margin:32px 0 12px;padding:22px 28px;border:1px solid var(--rule);background:linear-gradient(180deg,rgba(184,153,104,.025),transparent);display:grid;grid-template-columns:repeat(3,1fr);gap:24px}
  @media (max-width:720px){.receipt{grid-template-columns:1fr;gap:14px;padding:18px 22px}}
  .receipt-row{display:flex;flex-direction:column;gap:4px}
  .r-label{font-size:9px;letter-spacing:.3em;text-transform:uppercase;color:var(--ink-mute);font-weight:500}
  .r-val{font-family:var(--serif);font-style:italic;color:var(--metal);font-size:16px;font-variant-numeric:tabular-nums;letter-spacing:.02em}
  .thanks-next{margin:64px 0 0;padding:48px 0 0;border-top:1px solid var(--rule)}
  .thanks-step{font-family:var(--serif);font-weight:400;font-size:24px;color:var(--ink);margin:0 0 28px;letter-spacing:-.005em}
  .thanks-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:0;border-top:1px solid var(--rule);border-bottom:1px solid var(--rule)}
  @media (max-width:720px){.thanks-grid{grid-template-columns:1fr}}
  .thanks-cell{padding:32px 28px;border-right:1px solid var(--rule);display:flex;flex-direction:column;gap:12px}
  .thanks-cell:last-child{border-right:none}
  @media (max-width:720px){.thanks-cell{border-right:none;border-bottom:1px solid var(--rule);padding:28px 20px}.thanks-cell:last-child{border-bottom:none}}
  .thanks-num{font-family:var(--serif);font-style:italic;font-weight:300;color:transparent;-webkit-text-stroke:1px var(--metal);font-size:28px;line-height:1;letter-spacing:-.02em}
  .thanks-cell h4{font-family:var(--serif);font-weight:400;font-size:18px;line-height:1.25;color:var(--ink);margin:0;letter-spacing:-.005em}
  .thanks-cell p{font-size:13px;line-height:1.6;color:var(--ink-mute);margin:0;flex:1}
  .thanks-link{font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--metal);font-weight:500;border-bottom:1px solid var(--rule);padding-bottom:3px;align-self:flex-start;transition:border-color 200ms ease,color 200ms ease}
  .thanks-link:hover{color:var(--metal-glow);border-bottom-color:var(--metal)}
  .public-trust{padding:24px 28px;border:1px solid var(--rule);background:linear-gradient(180deg,rgba(184,153,104,.018),transparent)}
  .public-trust .t-eyebrow{font-size:9px;letter-spacing:.3em;text-transform:uppercase;color:var(--metal);font-weight:500;margin:0 0 12px}
  .public-trust p{font-size:13px;line-height:1.7;color:var(--ink-mute);margin:0;max-width:none}
  .cta-metal{padding:20px 36px;font-size:12px;letter-spacing:.18em;text-transform:uppercase;background:linear-gradient(180deg,#d4b683 0%,#b89968 38%,#9c7e4f 100%);color:var(--noir);border:1px solid var(--metal);font-weight:500;box-shadow:inset 0 1px 0 rgba(255,235,200,.35),0 1px 0 rgba(0,0,0,.4);transition:transform 220ms ease,box-shadow 220ms ease,background 220ms ease}
  .cta-metal:hover{background:linear-gradient(180deg,#e3c89a 0%,#c8a674 50%,#a98559 100%);transform:translateY(-1px);box-shadow:inset 0 1px 0 rgba(255,240,210,.5),0 4px 18px rgba(184,153,104,.18);color:var(--noir)}

  /* Submit-button loading state (shared by /signup and /login forms) */
  button.big-btn:disabled{cursor:not-allowed;opacity:.7;transform:none;background:linear-gradient(180deg,#9c8758 0%,#80684a 50%,#5e4a30 100%);box-shadow:inset 0 1px 0 rgba(255,235,200,.15)}
  button.big-btn:disabled:hover{background:linear-gradient(180deg,#9c8758 0%,#80684a 50%,#5e4a30 100%);transform:none}
  button.big-btn .cta-spinner{display:none;width:13px;height:13px;border:1.5px solid rgba(10,10,12,.25);border-top-color:var(--noir);border-radius:50%;animation:portal-spin 720ms linear infinite;margin-left:10px;vertical-align:middle}
  button.big-btn.is-loading .cta-arrow{display:none}
  button.big-btn.is-loading .cta-spinner{display:inline-block}
  @keyframes portal-spin{to{transform:rotate(360deg)}}
  @media (prefers-reduced-motion: reduce){
    button.big-btn .cta-spinner{animation:none}
  }

*:focus-visible{outline:none;box-shadow:0 0 0 3px rgba(184,153,104,.45);border-radius:1px}
*:focus:not(:focus-visible){outline:none}

</style>
</head><body>
<header class="brand">
  <a href="/" style="display:inline-flex;align-items:center;gap:14px;text-decoration:none">${monogram}<h1 style="margin:0"><span style="color:var(--ink)">Counsel</span> <span class="amp">&amp;</span> <span style="color:var(--ink)">Bar</span></h1></a>
  ${userBar}
</header>
<main>${body}</main>
<script>
(function(){
  if(!('IntersectionObserver' in window))return;
  if(window.matchMedia('(prefers-reduced-motion: reduce)').matches){
    document.querySelectorAll('.scroll-reveal').forEach(function(el){el.classList.add('in-view');});return;
  }
  var io=new IntersectionObserver(function(es){es.forEach(function(e){if(e.isIntersecting){e.target.classList.add('in-view');io.unobserve(e.target);}});},{threshold:0.12,rootMargin:'0px 0px -10% 0px'});
  document.querySelectorAll('.scroll-reveal').forEach(function(el){io.observe(el);});
})();
</script>
</body></html>`;
}

function esc(s: any): string {
  if (s === null || s === undefined) return '';
  return String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]!));
}

// Same-origin path allowlist for post-auth redirects: must start with a single
// '/' and not '//' (which the browser interprets as protocol-relative).
function safeNext(raw: any, fallback = '/dashboard'): string {
  if (typeof raw !== 'string' || raw.length === 0) return fallback;
  if (!raw.startsWith('/') || raw.startsWith('//') || raw.startsWith('/\\')) return fallback;
  return raw;
}

function flash(req: express.Request): string {
  const msg = (req.query.msg as string) || '';
  const err = (req.query.err as string) || '';
  if (err) return `<div class="flash error">${esc(err)}</div>`;
  if (msg) return `<div class="flash success">${esc(msg)}</div>`;
  return '';
}

// ─── public auth pages ──────────────────────────────────────────────────────

router.get('/signup', (req, res) => {
  if (req.user) return res.redirect('/dashboard');
  const next = safeNext(req.query.next);
  res.send(layout('Claim your listing', `
    <div class="signup-vol">
      <span class="v-rule"></span>
      <span class="v-text">Volume I</span>
      <span class="v-meta">Edition 2026 · Attorney Listing</span>
    </div>
    <h2 class="signup-display">Claim your <em>listing.</em></h2>
    <p class="signup-lede">Type your name. We check it against the active California State Bar roll — pick yourself, and your firm's profile is pre-filled. Verify what's accurate, edit what isn't, see your public booking page before you publish.</p>

    <div class="claim-pact" role="presentation" aria-label="What claiming your listing means">
      <div class="claim-pact-cell">
        <span class="claim-pact-num">i</span>
        <h3 class="claim-pact-h">Always <em>free.</em></h3>
        <p class="claim-pact-p">The directory listing costs nothing — for you, for your clients, for anyone. We do not charge for placement, do not boost paying firms, do not accept fees for routing cases. The free listing is the entire offer.</p>
      </div>
      <div class="claim-pact-cell">
        <span class="claim-pact-num">ii</span>
        <h3 class="claim-pact-h">Yours to <em>edit.</em></h3>
        <p class="claim-pact-p">Once you claim, the controls are yours: hours, photos, narrative, response settings. We never edit a claimed listing without you. The only fields that stay locked are the ones drawn directly from the State Bar public roll.</p>
      </div>
      <div class="claim-pact-cell">
        <span class="claim-pact-num">iii</span>
        <h3 class="claim-pact-h">Reversible <em>any time.</em></h3>
        <p class="claim-pact-p">Unclaim from your dashboard whenever you choose. Your listing reverts to the State Bar baseline within one business day; your account, edits, and uploaded photos are deleted on request.</p>
      </div>
    </div>

    <p class="step-eyebrow"><em>i</em> &nbsp;·&nbsp; Find yourself in the registry</p>

    ${flash(req)}

    <form class="stack big" method="post" action="/signup" autocomplete="off" id="claim-form">
      <input type="hidden" name="next" value="${esc(next)}">

      <!-- Step 1: name -->
      <label class="big-lbl" style="position:relative">
        Your Name
        <input class="big-in" id="full_name" name="full_name" placeholder="Start typing your name — we'll match the CalBar registry" required autocomplete="off">
        <div id="ac-list"></div>
      </label>
      <input type="hidden" id="bar_number" name="bar_number">

      <!-- Step 2 + 3: details + sample booking — hidden until name is picked -->
      <div id="step-confirm" class="step-hidden">
        <hr class="step-rule">
        <p class="step-eyebrow"><em>ii</em> &nbsp;·&nbsp; Confirm your firm</p>
        <h3 class="step-h3">Is this still right?</h3>
        <p class="muted" style="margin:0 0 24px">We pulled this from the State Bar listing. Edit anything that's stale.</p>

        <label class="big-lbl">
          Firm Name
          <input class="big-in" id="firm_name" name="firm_name" placeholder="Law Office of Jane Doe" autocomplete="organization">
        </label>

        <label class="big-lbl" style="margin-top:18px">
          Website <span style="text-transform:none;letter-spacing:0;color:var(--ink-mute);font-size:11px;font-weight:400">(visitors will tap-through to this)</span>
          <input class="big-in" id="website" name="website" type="url" placeholder="https://janedoelaw.com" autocomplete="url">
        </label>

        <label class="big-lbl" style="margin-top:18px">
          Phone
          <input class="big-in" id="phone" name="phone" type="tel" placeholder="(415) 555-0100" autocomplete="tel">
        </label>

        <hr class="step-rule">
        <p class="step-eyebrow"><em>iii</em> &nbsp;·&nbsp; Your public booking page (preview)</p>
        <h3 class="step-h3">This is what visitors will see.</h3>
        <p class="muted" style="margin:0 0 24px">A polished Zoom-consult page on <code>lawyers.agentabrams.com/<span id="prv-slug">jane-doe</span></code> — included with every paid listing. We provision the calendar, the video link, and Stripe payouts.</p>

        <div class="zoom-preview scroll-reveal">
          <div class="zp-header">
            <div class="zp-avatar"></div>
            <div>
              <div class="zp-name" id="prv-name">Jane Doe, Esq.</div>
              <div class="zp-firm" id="prv-firm">Law Office of Jane Doe</div>
              <div class="zp-loc" id="prv-loc">California State Bar #000000 · Verified</div>
            </div>
          </div>
          <div class="zp-divider"></div>
          <div class="zp-body">
            <div class="zp-col">
              <div class="zp-eyebrow">30-minute consult</div>
              <div class="zp-price">$150</div>
              <div class="zp-detail">By Zoom · No-show refund · No signup required for client</div>
              <button type="button" class="zp-cta" disabled>Book — Tue, 2:30 PM</button>
              <div class="zp-disclaimer">Sample. Real booking goes live on first paid listing.</div>
            </div>
            <div class="zp-col zp-cal">
              <div class="zp-eyebrow">Available this week</div>
              <div class="zp-cal-grid">
                <div class="zp-day"><div class="zp-d-name">Mon</div><div class="zp-d-num">5</div><div class="zp-slots">3 open</div></div>
                <div class="zp-day zp-d-active"><div class="zp-d-name">Tue</div><div class="zp-d-num">6</div><div class="zp-slots">2 open</div></div>
                <div class="zp-day"><div class="zp-d-name">Wed</div><div class="zp-d-num">7</div><div class="zp-slots">5 open</div></div>
                <div class="zp-day"><div class="zp-d-name">Thu</div><div class="zp-d-num">8</div><div class="zp-slots">1 open</div></div>
                <div class="zp-day zp-d-empty"><div class="zp-d-name">Fri</div><div class="zp-d-num">9</div><div class="zp-slots">—</div></div>
              </div>
            </div>
          </div>
        </div>

        <hr class="step-rule">
        <p class="step-eyebrow"><em>iv</em> &nbsp;·&nbsp; Create your account</p>

        <label class="big-lbl" style="margin-top:8px">
          Email
          <input class="big-in" name="email" type="email" placeholder="you@firm.com" required autocomplete="email">
        </label>

        <label class="big-lbl" style="margin-top:18px">
          Password
          <input class="big-in" name="password" type="password" placeholder="Min 8 characters" required minlength="8" autocomplete="new-password">
        </label>

        <button class="big-btn" type="submit" style="margin-top:28px" data-loading-text="Claiming your listing…">
          <span class="cta-text">Claim your listing</span>
          <span class="cta-arrow" style="font-family:var(--serif);font-style:italic;margin-left:8px">→</span>
          <span class="cta-spinner" aria-hidden="true"></span>
        </button>
      </div>
    </form>

    <p class="muted" style="margin-top:24px">Already claimed? <a href="/login">Sign in</a></p>

    <script>
      (function(){
        var f = document.getElementById('claim-form');
        if (!f) return;
        f.addEventListener('submit', function(){
          var btn = f.querySelector('button[type="submit"]');
          if (!btn) return;
          btn.disabled = true;
          btn.classList.add('is-loading');
          var t = btn.querySelector('.cta-text');
          if (t) t.textContent = btn.dataset.loadingText || 'Submitting…';
        });
      })();
    </script>

    <style>
      form.stack.big{gap:22px;max-width:720px}
      .signup-vol{display:flex;align-items:center;gap:18px;margin:0 0 26px;color:var(--metal)}
      .signup-vol .v-rule{flex:0 0 56px;height:1px;background:linear-gradient(90deg,transparent,var(--metal) 50%,transparent)}
      .signup-vol .v-text{font-family:var(--serif);font-style:italic;font-weight:400;font-size:14px;letter-spacing:.02em}
      .signup-vol .v-meta{font-size:10px;letter-spacing:.28em;text-transform:uppercase;color:var(--ink-mute);font-weight:500}
      .signup-display{font-family:var(--serif);font-weight:300;font-size:clamp(40px,6.2vw,72px);line-height:1.04;letter-spacing:-.018em;margin:0 0 22px;max-width:18ch;color:var(--ink)}
      .signup-display em{font-style:italic;color:var(--metal);font-weight:400}
      .signup-lede{color:var(--ink-soft);font-size:17px;line-height:1.6;margin:0 0 32px;max-width:62ch;font-weight:300}
      .claim-pact{display:grid;grid-template-columns:repeat(3,1fr);gap:0;margin:0 0 56px;border-top:1px solid var(--rule);border-bottom:1px solid var(--rule)}
      .claim-pact-cell{padding:32px 28px 28px;border-right:1px solid var(--rule);display:flex;flex-direction:column;gap:10px}
      .claim-pact-cell:last-child{border-right:0}
      .claim-pact-num{font-family:var(--serif);font-style:italic;font-weight:300;font-size:44px;line-height:.9;color:transparent;-webkit-text-stroke:1px var(--metal);margin:0 0 6px;display:block;letter-spacing:-.02em}
      .claim-pact-h{font-family:var(--serif);font-weight:400;font-size:20px;line-height:1.2;color:var(--ink);margin:0;letter-spacing:-.005em}
      .claim-pact-h em{font-style:italic;color:var(--metal);font-weight:400}
      .claim-pact-p{font-size:13px;line-height:1.65;color:var(--ink-mute);margin:0;font-weight:300}
      @media (max-width:780px){.claim-pact{grid-template-columns:1fr}.claim-pact-cell{border-right:0;border-bottom:1px solid var(--rule)}.claim-pact-cell:last-child{border-bottom:0}}
      .step-eyebrow{font-size:10px;letter-spacing:.28em;text-transform:uppercase;color:var(--metal);font-weight:500;margin:0 0 16px;display:flex;align-items:center;gap:0}
      .step-eyebrow em{font-family:var(--serif);font-style:italic;font-weight:400;color:transparent;-webkit-text-stroke:1px var(--metal);font-size:32px;line-height:1;letter-spacing:-.02em;text-transform:none;margin-right:14px}
      .step-h3{font-family:var(--serif);font-weight:400;font-size:24px;margin:0 0 8px;color:var(--ink);letter-spacing:-.005em}
      .step-rule{border:none;border-top:1px solid var(--rule);margin:48px 0 32px}
      .step-hidden{opacity:.35;filter:blur(2px);pointer-events:none;transition:opacity 600ms ease,filter 600ms ease;max-height:60px;overflow:hidden}
      .step-hidden.revealed{opacity:1;filter:none;pointer-events:auto;max-height:none;overflow:visible}
      .big-lbl{display:flex;flex-direction:column;gap:8px;font-size:10px;color:var(--ink-mute);font-weight:500;text-transform:uppercase;letter-spacing:.22em}
      .big-in{padding:16px 18px!important;font-size:16px!important;background:var(--noir-rise);color:var(--ink);border:1px solid var(--rule);font-family:var(--sans);font-weight:300}
      .big-in:focus{outline:none;border-color:var(--metal);box-shadow:0 0 0 3px rgba(184,153,104,.12)}
      .big-btn{padding:20px 36px!important;font-size:12px!important;letter-spacing:.18em;text-transform:uppercase;width:auto;align-self:flex-start;background:linear-gradient(180deg,#d4b683 0%,#b89968 38%,#9c7e4f 100%);color:var(--noir);border:1px solid var(--metal);box-shadow:inset 0 1px 0 rgba(255,235,200,.35),0 1px 0 rgba(0,0,0,.4)}
      .big-btn:hover{background:linear-gradient(180deg,#e3c89a 0%,#c8a674 50%,#a98559 100%);border-color:var(--metal-glow);transform:translateY(-1px);box-shadow:inset 0 1px 0 rgba(255,240,210,.5),0 4px 18px rgba(184,153,104,.18)}
      #ac-list{position:absolute;left:0;right:0;top:100%;z-index:10;background:var(--noir-rise);border:1px solid var(--rule);margin-top:2px;max-height:320px;overflow:auto;display:none;text-transform:none;letter-spacing:0}
      .ac-row{padding:12px 16px;cursor:pointer;border-bottom:1px solid var(--rule);font-size:13px;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:3px;font-style:italic}
      .ac-row .pill{font-size:9px;padding:2px 7px;border-radius:0;background:transparent;border:1px solid var(--rule);color:var(--ink-mute);margin-left:8px;letter-spacing:.14em}

      /* ── Zoom-consult preview (step 03) ── */
      .zoom-preview{background:var(--noir-rise);border:1px solid var(--rule);padding:32px;margin:0 0 8px;position:relative}
      .zoom-preview::before{content:'PREVIEW';position:absolute;top:-1px;right:24px;background:var(--noir);color:var(--metal);padding:6px 14px;font-size:9px;letter-spacing:.3em;border:1px solid var(--rule);border-top:none}
      .zp-header{display:flex;gap:20px;align-items:center}
      .zp-avatar{width:64px;height:64px;background:linear-gradient(135deg,var(--metal-deep),var(--metal-glow));border-radius:50%;flex-shrink:0;border:1px solid var(--metal)}
      .zp-name{font-family:var(--serif);font-size:24px;font-weight:400;color:var(--ink);letter-spacing:-.005em;line-height:1.1}
      .zp-firm{font-size:13px;color:var(--ink-soft);margin-top:4px}
      .zp-loc{font-size:11px;color:var(--ink-mute);margin-top:4px;letter-spacing:.06em}
      .zp-divider{height:1px;background:var(--rule);margin:24px 0}
      .zp-body{display:grid;grid-template-columns:1fr 1.2fr;gap:32px}
      @media (max-width:640px){.zp-body{grid-template-columns:1fr}}
      .zp-eyebrow{font-size:10px;letter-spacing:.22em;text-transform:uppercase;color:var(--ink-mute);margin-bottom:10px}
      .zp-price{font-family:var(--serif);font-size:48px;font-weight:300;color:var(--metal);line-height:1;margin-bottom:8px;letter-spacing:-.02em}
      .zp-detail{font-size:12px;color:var(--ink-soft);margin-bottom:18px;line-height:1.5}
      .zp-cta{padding:14px 24px;background:var(--metal);color:var(--noir);border:1px solid var(--metal);font-size:11px;letter-spacing:.18em;text-transform:uppercase;font-weight:500;width:100%;cursor:not-allowed;opacity:.85}
      .zp-disclaimer{font-size:10px;color:var(--ink-mute);font-style:italic;margin-top:10px;letter-spacing:.06em}
      .zp-cal-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:6px}
      .zp-day{padding:14px 8px;border:1px solid var(--rule);text-align:center;background:var(--noir-deep);transition:border-color 180ms ease}
      .zp-day:hover{border-color:var(--metal-deep)}
      .zp-d-name{font-size:9px;letter-spacing:.18em;text-transform:uppercase;color:var(--ink-mute)}
      .zp-d-num{font-family:var(--serif);font-size:22px;color:var(--ink);font-weight:300;margin:6px 0 4px}
      .zp-slots{font-size:10px;color:var(--metal);letter-spacing:.04em}
      .zp-d-active{border-color:var(--metal);background:rgba(184,153,104,0.08)}
      .zp-d-active .zp-d-num{color:var(--metal-glow)}
      .zp-d-empty .zp-slots{color:var(--ink-mute);opacity:.5}
    </style>
    <script>
    (function(){
      var inp = document.getElementById('full_name');
      var bar = document.getElementById('bar_number');
      var firm = document.getElementById('firm_name');
      var web = document.getElementById('website');
      var phone = document.getElementById('phone');
      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 ? '<span class="meta">' + esc(r.firm_name) + (r.city ? ' · ' + esc(r.city) : '') + '</span>' : '';
          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)); });
        });
      }
      function pick(i){
        var r = items[i];
        if (!r) return;
        inp.value = r.full_name;
        if (r.bar_number) bar.value = r.bar_number;
        if (r.firm_name && !firm.value) firm.value = r.firm_name;
        if (r.firm_website && !web.value) web.value = r.firm_website;
        if (r.firm_phone && !phone.value) phone.value = r.firm_phone;
        list.style.display = 'none';
        // Reveal Step 2/3/4 + populate the booking-page preview
        var step = document.getElementById('step-confirm');
        if (step) step.classList.add('revealed');
        var prvName = document.getElementById('prv-name');
        var prvFirm = document.getElementById('prv-firm');
        var prvLoc  = document.getElementById('prv-loc');
        var prvSlug = document.getElementById('prv-slug');
        if (prvName) prvName.textContent = r.full_name + ', Esq.';
        if (prvFirm) prvFirm.textContent = r.firm_name || '';
        if (prvLoc)  prvLoc.textContent  = 'California State Bar #' + r.bar_number + ' · Verified';
        if (prvSlug) prvSlug.textContent = r.full_name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
        // Smooth-scroll a little so the new section is visible
        setTimeout(function(){ step.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, 320);
      }
      function esc(s){ return String(s == null ? '' : s).replace(/[&<>"']/g, function(c){ return ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[c]; }); }
      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'; });
    })();
    </script>
  `, { user: req.user }));
});

router.post('/signup', async (req, res) => {
  if (!req.body || typeof req.body !== 'object') {
    return res.status(400).send('Invalid request body');
  }
  const { email, password, full_name, bar_number, firm_name, website, phone, next } = req.body as any;
  try {
    if (!email || !password || password.length < 8) throw new Error('Email and 8+ char password required');
    const existing = await findUserByEmail(email);
    if (existing) return res.redirect('/signup?err=' + encodeURIComponent('Account already exists — try logging in'));
    const userId = await createUser({
      email, password, full_name, bar_number,
      firm_name: firm_name || null,
      website: website ? String(website).trim().slice(0, 250) : null,
      phone: phone ? String(phone).trim().slice(0, 40) : null,
    });
    const sid = await createSession(userId, req.ip || null, req.headers['user-agent'] || null);
    setSessionCookie(res, sid);
    // Fresh signup: route through the editorial welcome surface unless the user
    // came from a deep-link with explicit ?next= (which we honor).
    if (typeof next === 'string' && next.length > 0) {
      res.redirect(safeNext(next));
    } else {
      res.redirect('/signup/welcome');
    }
  } catch (e) {
    res.redirect('/signup?err=' + encodeURIComponent((e as Error).message));
  }
});

router.get('/signup/welcome', requireUser, async (req, res) => {
  const u = req.user!;
  const now = new Date();
  const pad = (n: number) => String(n).padStart(2, '0');
  const rand = Math.random().toString(36).slice(2, 6).toUpperCase();
  const listingNo = `LIST-${now.getFullYear()}-${pad(now.getMonth() + 1)}${pad(now.getDate())}-${rand}`;
  const tstamp = `${pad(now.getHours())}:${pad(now.getMinutes())} PT · ${now.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}`;
  const firstName = u.full_name ? u.full_name.split(' ')[0] : '';
  let firmName = '';
  if (u.organization_id) {
    const r = await query<{ name: string }>(`SELECT name FROM organizations WHERE id = $1`, [u.organization_id]);
    if (r.rows[0]) firmName = r.rows[0].name;
  }

  res.send(layout('Listing claimed', `
    <div class="signup-vol">
      <span class="v-rule"></span>
      <span class="v-text">Volume I</span>
      <span class="v-meta">Edition 2026 · Listing on File</span>
    </div>
    <p style="font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--ink-mute);margin:0 0 18px;font-weight:500">Counsel &amp; Bar is a directory, <span style="color:var(--metal)">not</span> a lawyer referral service.</p>
    <h2 class="signup-display">Welcome to the registry${firstName ? `, <em>${esc(firstName)}.</em>` : ', <em>counsel.</em>'}</h2>
    <p class="signup-lede">Your listing is on file${firmName ? ` under <strong>${esc(firmName)}</strong>` : ''}. This isn't a referral channel; we don't route inquiries to specific firms. You're <strong>listed alongside other California-licensed attorneys</strong> in proximity-ranked search results, drawing on the public State Bar information already filed with the registry. Three steps polish the entry.</p>

    <div class="receipt scroll-reveal">
      <div class="receipt-row">
        <span class="r-label">Listing №.</span>
        <span class="r-val">${listingNo}</span>
      </div>
      <div class="receipt-row">
        <span class="r-label">Logged</span>
        <span class="r-val">${tstamp}</span>
      </div>
      ${u.bar_number ? `<div class="receipt-row"><span class="r-label">CA Bar №.</span><span class="r-val">${esc(u.bar_number)}</span></div>` : ''}
    </div>

    <section class="thanks-next">
      <h3 class="thanks-step">What happens next</h3>
      <div class="thanks-grid">
        <div class="thanks-cell scroll-reveal s1">
          <span class="thanks-num">i.</span>
          <h4>Add your CA Bar №</h4>
          <p>${u.bar_number ? `We've recorded № <strong>${esc(u.bar_number)}</strong> on your account. The State Bar's public roll at calbar.ca.gov remains the authoritative source — consumers can verify against it directly from your listing.` : `Add your bar number from the dashboard so it appears on your listing. The State Bar's public roll at calbar.ca.gov remains the authoritative source for license status.`}</p>
          <a class="thanks-link" href="/dashboard#bar">Open dashboard →</a>
        </div>
        <div class="thanks-cell scroll-reveal s2">
          <span class="thanks-num">ii.</span>
          <h4>Polish your profile</h4>
          <p>Add practice areas, languages spoken, fee structure, and a short bio. Every field is optional — and every field is rendered as you wrote it. We don't edit, embellish, or rank by paid tier.</p>
          <a class="thanks-link" href="/dashboard#profile">Edit profile →</a>
        </div>
        <div class="thanks-cell scroll-reveal s3">
          <span class="thanks-num">iii.</span>
          <h4>Preview your listing</h4>
          <p>See your public-facing card exactly as a consumer will. City, distance label, audit grade, contact details — rendered straight from the State Bar record and your dashboard fields.</p>
          <a class="thanks-link" href="/dashboard#preview">Preview public listing →</a>
        </div>
      </div>
    </section>

    <div style="margin:48px 0 0;padding-top:32px;border-top:1px solid var(--rule);display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:16px">
      <p style="margin:0;font-size:13px;color:var(--ink-mute);max-width:48ch">No data was sold, shared, or syndicated. We're a directory; what's on file is what consumers see, and you control every editable field from the dashboard.</p>
      <a href="/dashboard" class="cta-metal" style="text-decoration:none;display:inline-flex;align-items:center;gap:10px">Continue to dashboard <span style="font-style:italic;font-family:var(--serif)">→</span></a>
    </div>

    <aside class="public-trust scroll-reveal" style="margin-top:48px">
      <p class="t-eyebrow">For the record</p>
      <p>Counsel &amp; Bar is a directory, not a lawyer referral service under California Business &amp; Professions Code §6155. We do not route consumer inquiries to specific firms, do not accept fees for placement or ranking, and do not split fees with attorneys. Listing claim ≠ endorsement; consumer verification at <a href="https://www.calbar.ca.gov" rel="noopener">calbar.ca.gov</a> remains authoritative.</p>
    </aside>
  `, { variant: 'public' }));
});

router.get('/login', (req, res) => {
  if (req.user) return res.redirect('/dashboard');
  const next = safeNext(req.query.next);
  res.send(layout('Login', `
    <div class="signup-vol">
      <span class="v-rule"></span>
      <span class="v-text">Volume I</span>
      <span class="v-meta">Edition 2026 · Sign In</span>
    </div>
    <h2 class="signup-display" style="font-size:clamp(36px,5vw,56px);margin:0 0 36px;max-width:14ch">Welcome <em>back.</em></h2>
    ${flash(req)}
    <form class="stack" method="post" action="/login" style="max-width:480px" id="login-form">
      <input type="hidden" name="next" value="${esc(next)}">
      <input name="email" type="email" placeholder="Email" required autocomplete="email">
      <input name="password" type="password" placeholder="Password" required autocomplete="current-password">
      <button class="big-btn" type="submit" style="margin-top:8px;width:auto;align-self:flex-start" data-loading-text="Signing in…">
        <span class="cta-text">Sign in</span>
        <span class="cta-arrow" style="font-family:var(--serif);font-style:italic;margin-left:8px">→</span>
        <span class="cta-spinner" aria-hidden="true"></span>
      </button>
    </form>
    <p class="muted" style="margin-top:24px">No account yet? <a href="/signup">Claim your listing</a></p>
    <script>
      (function(){
        var f = document.getElementById('login-form');
        if (!f) return;
        f.addEventListener('submit', function(){
          var btn = f.querySelector('button[type="submit"]');
          if (!btn) return;
          btn.disabled = true;
          btn.classList.add('is-loading');
          var t = btn.querySelector('.cta-text');
          if (t) t.textContent = btn.dataset.loadingText || 'Signing in…';
        });
      })();
    </script>
  `, { user: req.user }));
});

router.post('/login', async (req, res) => {
  if (!req.body || typeof req.body !== 'object') {
    return res.status(400).send('Invalid request body');
  }
  const { email, password, next } = req.body as any;
  try {
    // Audit 2026-05-04 tick 25: use the constant-time verify (DUMMY_HASH defense)
    // instead of findUserByEmail + verifyPassword separately — that pattern leaks
    // user existence via the ~80ms bcrypt-vs-immediate-fail timing oracle.
    const u = await findUserAndVerifyPassword(email, password);
    if (!u || u.status !== 'active') throw new Error('Invalid email or password');
    const sid = await createSession(u.id, req.ip || null, req.headers['user-agent'] || null);
    setSessionCookie(res, sid);
    res.redirect(safeNext(next));
  } catch (e) {
    res.redirect('/login?err=' + encodeURIComponent((e as Error).message));
  }
});

router.post('/logout', async (req, res) => {
  const sid = cookie.parse(req.headers.cookie || '').sid;
  if (sid) await destroySession(sid);
  clearSessionCookie(res);
  res.redirect('/login?msg=' + encodeURIComponent('Logged out'));
});

// ─── user dashboard ─────────────────────────────────────────────────────────

router.get('/dashboard', requireUser, async (req, res) => {
  const u = req.user!;
  let firmInfo = '';
  if (u.organization_id) {
    const r = await query<{ name: string; address: string; city: string; website: string | null }>(
      `SELECT name, address, city, website FROM organizations WHERE id = $1`, [u.organization_id]);
    if (r.rows[0]) {
      const f = r.rows[0];
      firmInfo = `<div class="stat" style="grid-column:1/-1">
        <div class="lbl">Linked firm</div>
        <div style="font-size:16px;font-weight:600;margin:4px 0">${esc(f.name)}</div>
        <div class="muted">${esc(f.address)}, ${esc(f.city)}${f.website ? ` · <a href="${esc(f.website)}" target="_blank">${esc(f.website)}</a>` : ''}</div>
      </div>`;
    }
  }
  const planBadge = `<span class="pill ${u.plan}">${u.plan}</span>`;
  res.send(layout('Dashboard', `
    <div class="signup-vol">
      <span class="v-rule"></span>
      <span class="v-text">Volume I</span>
      <span class="v-meta">Edition 2026 · Practice Dashboard</span>
    </div>
    <h2 class="signup-display" style="font-size:clamp(36px,5vw,56px);max-width:18ch;margin:0 0 32px">Welcome ${u.full_name ? `<em>${esc(u.full_name.split(' ')[0])}.</em>` : '<em>back.</em>'}</h2>
    ${flash(req)}
    <section id="bar" class="dash-section">
      <div class="stats scroll-reveal">
        <div class="stat"><div class="lbl">Plan</div><div class="num" style="font-size:18px">${planBadge}</div></div>
        <div class="stat"><div class="lbl">Status</div><div class="num" style="font-size:18px"><span class="pill ${u.status}">${u.status}</span></div></div>
        ${u.bar_number ? `<div class="stat"><div class="lbl">CA Bar #</div><div class="num">${esc(u.bar_number)}</div></div>` : '<div class="stat"><div class="lbl">CA Bar #</div><div class="num" style="font-size:13px;color:var(--ink-mute);font-style:italic;font-family:var(--serif);font-weight:400">not added</div></div>'}
        ${firmInfo}
      </div>
    </section>
    <section id="profile" class="dash-section" style="margin-top:32px">
      <h3 style="font-family:var(--serif);font-weight:400;font-size:22px;color:var(--ink);margin:0 0 8px;letter-spacing:-.005em">Your profile</h3>
      <p class="muted" style="margin:0 0 16px">Practice areas, languages, fee structure, and bio appear here. Profile editing is rolling out to all listings — for now, profile content syncs from the State Bar registry.</p>
    </section>
    <section id="preview" class="dash-section" style="margin-top:32px">
      <h3 style="font-family:var(--serif);font-weight:400;font-size:22px;color:var(--ink);margin:0 0 8px;letter-spacing:-.005em">Public listing preview</h3>
      <p class="muted" style="margin:0 0 16px">${u.organization_id ? `View your card as a consumer would: <a href="/find-a-lawyer/results?zip=90013" style="font-size:13px">/find-a-lawyer/results →</a>` : 'Once your account is linked to a firm, your card will appear in /find-a-lawyer/results when consumers search nearby ZIPs.'}</p>
    </section>
    <script>
      // Smooth-scroll to dashboard anchor when arriving from /signup/welcome links.
      if (location.hash) {
        var t = document.querySelector(location.hash);
        if (t) setTimeout(function(){ t.scrollIntoView({behavior:'smooth', block:'start'}); }, 120);
      }
    </script>

    ${(u as any).tier === 'lawyer' || (u as any).tier === 'admin' ? `
    <h3>Lawyer Pro · <em style="color:var(--good);font-style:italic">active</em></h3>
    <p class="muted">You have Lawyer Pro. Respond to reviews, send messages, see directory-inquiry alerts.</p>
    <p><a href="/api/billing/portal" style="font-size:13px">Manage billing in Stripe →</a></p>
    ` : `
    <h3>Upgrade to Lawyer <em>Pro</em></h3>
    <p class="lede" style="font-size:15px;margin:0 0 18px;max-width:54ch">Respond to client reviews. Message visitors who reached out to you directly. See an alert when a directory inquiry mentions your practice area + ZIP — and you decide whether to reach out.</p>
    <div style="border:1px solid var(--rule);background:var(--noir-rise);padding:24px 28px;max-width:480px;margin:0 0 24px">
      <div style="display:flex;align-items:baseline;gap:8px;margin-bottom:14px">
        <div style="font-family:var(--serif);font-size:42px;color:var(--metal);font-weight:300;line-height:1">$29</div>
        <div class="muted" style="font-size:13px">/ month · cancel anytime</div>
      </div>
      <ul style="list-style:none;padding:0;margin:0 0 20px;font-size:13px;color:var(--ink-soft)">
        <li style="padding:6px 0;border-bottom:1px solid var(--rule-faint)"><span style="color:var(--metal)">✓</span> Reply publicly to client reviews on your profile</li>
        <li style="padding:6px 0;border-bottom:1px solid var(--rule-faint)"><span style="color:var(--metal)">✓</span> Direct messages with visitors who initiate contact</li>
        <li style="padding:6px 0;border-bottom:1px solid var(--rule-faint)"><span style="color:var(--metal)">✓</span> Directory-inquiry alerts (practice area + ZIP) — for your awareness</li>
        <li style="padding:6px 0;border-bottom:1px solid var(--rule-faint)"><span style="color:var(--metal)">✓</span> Edit firm profile, photos, hours</li>
        <li style="padding:6px 0"><span style="color:var(--metal)">✓</span> Stripe-managed · cancel from this dashboard</li>
      </ul>
      <button id="upgradeProBtn" class="big-btn" type="button" style="width:100%;align-self:auto">Start Lawyer Pro →</button>
      <p id="upgradeProErr" style="display:none;color:var(--bad);font-size:12px;margin-top:10px"></p>
    </div>
    <script>
      document.getElementById('upgradeProBtn').addEventListener('click', async () => {
        const btn = document.getElementById('upgradeProBtn');
        const err = document.getElementById('upgradeProErr');
        btn.disabled = true; btn.textContent = 'Redirecting to Stripe…'; err.style.display='none';
        try {
          const r = await fetch('/api/billing/upgrade', { method:'POST', headers:{'Content-Type':'application/json'}, body:'{}' });
          const j = await r.json();
          if (j.url) { window.location = j.url; return; }
          throw new Error(j.error || 'unknown error');
        } catch (e) {
          err.textContent = 'Could not start checkout: ' + (e && e.message ? e.message : e);
          err.style.display='block';
          btn.disabled = false; btn.textContent = 'Start Lawyer Pro →';
        }
      });
    </script>
    `}

    <h3>One-time site rebuild · <em>EZ Upgrade $499</em></h3>
    <p class="muted" style="max-width:54ch">A complete site rebuild on the Counsel &amp; Bar template — typical go-live: seven business days from content intake. <a href="/upgrade">View details →</a></p>

    ${u.role === 'admin' || (u as any).tier === 'admin' ? '<h3>Admin</h3><p>You have admin access. <a href="/admin">Open admin panel →</a></p>' : ''}
  `, { user: u }));
});

// ─── admin panel ────────────────────────────────────────────────────────────

router.get('/admin', requireAdmin, async (req, res) => {
  const search = ((req.query.q as string) || '').trim();
  const where = search
    ? `WHERE (LOWER(email) LIKE $1 OR LOWER(COALESCE(full_name,'')) LIKE $1 OR bar_number = $2)`
    : '';
  const params = search ? [`%${search.toLowerCase()}%`, search] : [];
  const r = await query(`
    SELECT id, email, full_name, role, plan, status, bar_number, organization_id, professional_id,
           created_at, last_login_at
    FROM app_users
    ${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 app_users
    UNION ALL SELECT 'active', COUNT(*)::text FROM app_users WHERE status='active'
    UNION ALL SELECT 'paying', COUNT(*)::text FROM app_users WHERE plan IN ('premium','pro') AND status='active'
    UNION ALL SELECT 'admins', COUNT(*)::text FROM app_users WHERE role='admin'
  `);
  const cm: Record<string, string> = {};
  for (const row of counts.rows) cm[row.k] = row.v;

  const rows = r.rows.map((u: any) => {
    const plans = PLANS.map(p => `<option value="${p}"${u.plan === p ? ' selected' : ''}>${p}</option>`).join('');
    const actions = `
      <form method="post" action="/admin/users/${u.id}/plan">
        <select name="plan" onchange="this.form.submit()">${plans}</select>
      </form>
      <form method="post" action="/admin/users/${u.id}/${u.status === 'suspended' ? 'unsuspend' : 'suspend'}" onsubmit="return confirm('${u.status === 'suspended' ? 'Reactivate' : 'Suspend'} this user?')">
        <button class="warn" type="submit">${u.status === 'suspended' ? 'Activate' : 'Suspend'}</button>
      </form>
      <form method="post" action="/admin/users/${u.id}/delete" onsubmit="return confirm('PERMANENTLY DELETE this user? This cannot be undone.')">
        <button class="danger" type="submit">Delete</button>
      </form>
    `;
    const last = u.last_login_at ? new Date(u.last_login_at).toISOString().slice(0, 10) : '—';
    const created = new Date(u.created_at).toISOString().slice(0, 10);
    return `<tr>
      <td>${u.id}</td>
      <td><b>${esc(u.email)}</b><br><span class="muted">${esc(u.full_name || '')}</span></td>
      <td><span class="pill ${u.role === 'admin' ? 'admin' : ''}">${u.role}</span></td>
      <td><span class="pill ${u.status}">${u.status}</span></td>
      <td>${esc(u.bar_number || '—')}</td>
      <td>${esc(u.organization_id || '—')}</td>
      <td class="muted">${created}</td>
      <td class="muted">${last}</td>
      <td><div class="actions">${actions}</div></td>
    </tr>`;
  }).join('');

  res.send(layout('Admin', `
    <h2>Admin · Users</h2>
    ${flash(req)}
    <div class="stats scroll-reveal">
      <div class="stat"><div class="num">${cm.total || 0}</div><div class="lbl">Total users</div></div>
      <div class="stat"><div class="num">${cm.active || 0}</div><div class="lbl">Active</div></div>
      <div class="stat"><div class="num">${cm.paying || 0}</div><div class="lbl">Premium + Pro</div></div>
      <div class="stat"><div class="num">${cm.admins || 0}</div><div class="lbl">Admins</div></div>
    </div>

    <form method="get" action="/admin" style="display:flex;gap:8px;margin:18px 0">
      <input name="q" placeholder="Search email, name, or bar #" value="${esc(search)}" style="flex:1">
      <button type="submit">Search</button>
      ${search ? `<a href="/admin" style="padding:9px 11px;border:1px solid var(--line);border-radius:6px">Clear</a>` : ''}
    </form>

    <table>
      <thead><tr><th>ID</th><th>User</th><th>Role</th><th>Status</th><th>Bar #</th><th>Firm ID</th><th>Created</th><th>Last login</th><th>Actions</th></tr></thead>
      <tbody>${rows || '<tr><td colspan="9" class="muted" style="padding:30px;text-align:center">No users yet</td></tr>'}</tbody>
    </table>
  `, { user: req.user }));
});

router.post('/admin/users/:id/plan', 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 plan = (req.body.plan as string)?.trim();
  if (!PLANS.includes(plan as any)) return res.redirect('/admin?err=' + encodeURIComponent('Invalid plan'));
  await query(`UPDATE app_users SET plan = $2 WHERE id = $1`, [id, plan]);
  res.redirect('/admin?msg=' + encodeURIComponent(`Plan updated to ${plan}`));
});

router.post('/admin/users/:id/suspend', requireAdmin, async (req, res) => {
  const id = parseInt(req.params.id, 10);
  if (id === req.user!.id) return res.redirect('/admin?err=' + encodeURIComponent("Can't suspend yourself"));
  await query(`UPDATE app_users SET status = 'suspended' WHERE id = $1`, [id]);
  await query(`DELETE FROM app_sessions WHERE user_id = $1`, [id]);
  res.redirect('/admin?msg=' + encodeURIComponent('User suspended'));
});

router.post('/admin/users/:id/unsuspend', requireAdmin, async (req, res) => {
  const id = parseInt(req.params.id, 10);
  await query(`UPDATE app_users SET status = 'active' WHERE id = $1`, [id]);
  res.redirect('/admin?msg=' + encodeURIComponent('User reactivated'));
});

router.post('/admin/users/:id/delete', requireAdmin, async (req, res) => {
  const id = parseInt(req.params.id, 10);
  if (id === req.user!.id) return res.redirect('/admin?err=' + encodeURIComponent("Can't delete yourself"));
  // Hard delete — sessions + user. Cascades via FK. Records linking to organizations stay (set null on user side).
  await query(`DELETE FROM app_users WHERE id = $1`, [id]);
  res.redirect('/admin?msg=' + encodeURIComponent('User deleted'));
});

export default router;