← back to Small Business Builder

src/server/index.js

4491 lines

// small-business-builder — Express server on PORT 9760
//
// Routes:
//   GET  /                                  — grid of 3-12 mockups
//   GET  /healthz
//   GET  /biz/:slug                          — public profile (template 1 by default)
//   GET  /biz/:slug/edit                     — left-panel edit + 5-template preview
//   GET  /biz/:slug/template/:id             — full HTML render of one template
//   GET  /biz/:slug/template/:id/preview     — same, sandboxed for iframe use
//   POST /api/scrape-website                 — scrape OG/meta + create business
//   POST /api/scrape-instagram               — scrape public IG profile + attach
//   POST /api/biz/update                     — patch arbitrary business fields
//   POST /api/biz/socials                    — upsert socials by platform
//   GET  /admin                              — minimal admin (list + delete)
//   POST /api/admin/delete                   — admin-token gated
//
// All app HTML is rendered by `src/render/grid.js` and `src/render/edit.js`.
// All template HTML is rendered by `src/render/templates/index.js`.

import 'dotenv/config';
import express from 'express';
import helmet from 'helmet';
import compression from 'compression';
import cookieParser from 'cookie-parser';

import { many, one, query } from '../lib/db.js';
import { slugify } from '../lib/slug.js';
import { log } from '../lib/log.js';
import { esc, escJsonLd } from '../lib/escape.js';
import { renderGrid } from '../render/grid.js';
import { renderEdit } from '../render/edit.js';
import { renderHome } from '../render/home.js';
import { renderProfile, renderInterviewForm } from '../render/profile.js';
import { renderInterviewLive } from '../render/interview-live.js';
import { QUESTIONS } from '../lib/interview-questions.js';
import { draftInterview } from '../lib/ollama-draft.js';

const QUESTION_IDS = new Set(QUESTIONS.map(q => q.id));
import fs from 'node:fs';
import { promises as fsp } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const PUBLIC_DIR = path.resolve(__dirname, '..', '..', 'public');
const UPLOADS_DIR = path.join(PUBLIC_DIR, 'uploads');
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
import { renderTemplate } from '../render/templates/index.js';
import { scrapeWebsiteMeta } from '../scrape/website_meta.js';
import { scrapeInstagramPublic } from '../scrape/instagram_public.js';
import { renderEntry as renderWAEntry, renderResult as renderWAResult } from '../render/website-analysis.js';
import { renderWall } from '../render/wall.js';
import { detectVertical, pickThemesFor } from '../lib/vertical.js';
import { renderTheme } from '../render/website-analysis-themes.js';
import { listBuilds } from '../lib/builds-index.js';
import { renderBuildsIndex } from '../render/builds-index.js';
import { renderMap } from '../render/map.js';
import { computeGrade } from '../lib/audit-grade.js';

const app = express();
// Security headers via helmet (added 2026-05-04 overnight YOLO loop)
app.use(helmet({ contentSecurityPolicy: false }));
const PORT = parseInt(process.env.PORT || '9760', 10);
const ADMIN_TOKEN = process.env.ADMIN_TOKEN || '';

app.use(compression());
// Bumped to 8MB so webcam-frame data URLs can POST without 413.
app.use(express.json({ limit: '8mb' }));

// LAN/tailnet auth gate. BASIC_AUTH must come from env — fail to start if
// missing. Reviewer (HIGH-severity) flagged the prior hardcoded fallback as
// shipping a real credential into source + MEMORY.md.
const BASIC_AUTH = process.env.BASIC_AUTH;
if (!BASIC_AUTH || !/^[^:]+:[^:].+$/.test(BASIC_AUTH)) {
  console.error('FATAL: BASIC_AUTH env var is required (format user:pass)');
  process.exit(1);
}
const AUTH_HEADER = 'Basic ' + Buffer.from(BASIC_AUTH).toString('base64');

// Public consumer surface — these hosts get a stripped-down view of the app
// (no /builds, no admin APIs, no basic-auth). Anything else is admin and
// requires basic-auth. Trust proxy is set so req.hostname reflects nginx X-Forwarded-Host.
app.set('trust proxy', 1);
const PUBLIC_HOSTS = new Set([
  'livesiteaudit.agentabrams.com',
  'livesiteaudit.com',           // future, if registered
  'barber.agentabrams.com',      // renamed home of the LA Barber & Salon directory (2026-07-22)
  'fantasea.agentabrams.com',    // dedicated critique subdomain (FantaSea Yachts)
]);
const PUBLIC_PATH_ALLOW = [
  /^\/$/,                                 // landing → /website-analysis
  /^\/website-analysis(\/|$)/,            // entry form
  /^\/api\/website-analysis(\/|$)/,       // form POST endpoint
  /^\/wa\/[^/]+$/,                        // analysis result page (read-only)
  /^\/wa\/[^/]+\/m\/[^/]+$/,              // mockup preview (read-only)
  /^\/wa\/[^/]+\/screenshot\.png$/,       // hero screenshot (read-only)
  /^\/api\/wa\/[^/]+\/deep-dive$/,        // deep-dive synthesis (local, no scrape/LLM)
  /^\/wall$/,                             // brutalist gallery of all builds
  /^\/grades$/,                           // top graded + most-roasted (/leaderboard taken by LA Salon)
  /^\/roasts\.rss$/,                      // RSS feed of latest roasts
  /^\/api\/roast-me$/,                    // qwen3 roast-on-demand
  /^\/health$/, /^\/healthz$/,
  /^\/uploads\//,
];
function isPublicPathAllowed(p) {
  return PUBLIC_PATH_ALLOW.some(re => re.test(p));
}

app.use((req, res, next) => {
  const host = (req.hostname || '').toLowerCase();
  const isPublicHost = PUBLIC_HOSTS.has(host);
  if (req.path === '/health' || req.path === '/healthz') return next();

  if (isPublicHost) {
    if (!isPublicPathAllowed(req.path)) {
      // Hide admin surfaces from public host — no leak of route names via 401.
      return res.status(404).type('text/plain').send('not found');
    }
    return next();  // public path on public host: NO basic auth
  }

  // Admin host — require basic auth.
  if (req.get('authorization') === AUTH_HEADER) return next();
  res.set('WWW-Authenticate', 'Basic realm="mac2-pm2"');
  res.status(401).send('auth required');
});

app.use(express.urlencoded({ extended: true, limit: '512kb' }));
app.use(cookieParser());
// Serve uploaded webcam captures + any other public assets.
app.use('/uploads', express.static(UPLOADS_DIR, { maxAge: '7d' }));

// --- helpers ----------------------------------------------------------------

const ALLOWED_FIELDS = new Set([
  'name','category','address','city','state','zip','latitude','longitude',
  'phone','email','website','years_in_business','owner_name',
  'hero_image_url','color_primary','color_accent','about_text','hours_json',
  'tier','ranking_score',
]);

async function uniqueSlug(base) {
  let s = slugify(base) || 'biz';
  let i = 0;
  while (await one('SELECT 1 FROM businesses WHERE slug=$1', [s])) {
    i += 1;
    s = `${slugify(base) || 'biz'}-${i}`;
  }
  return s;
}

// JSON-API admin gate. Different from `requireAdmin` (HTML) so XHR clients get
// a JSON 401 instead of a login form.
function requireAdminJson(req, res) {
  const tok = req.cookies?.smb_admin || req.body?.admin_token || req.query?.t || '';
  if (!ADMIN_TOKEN || tok !== ADMIN_TOKEN) {
    res.status(401).json({ error: 'unauthorized' });
    return false;
  }
  return true;
}

// Generic 500 handler — log full err locally, return a vague message to the
// client so pg / undici / DNS errors don't leak hostnames or connection
// strings.
function fail(res, where, e) {
  log.err(`${where} failed`, { error: String(e?.stack || e?.message || e) });
  res.status(500).json({ error: 'internal error' });
}

// Postgres unique-violation code; surfaces as `e.code === '23505'`.
const PG_UNIQUE_VIOLATION = '23505';

async function loadBySlug(slug) {
  return one('SELECT * FROM businesses WHERE slug=$1', [slug]);
}

// Gate any handler behind the same ADMIN_TOKEN cookie (or ?t=) used by /admin.
// Used to block public visitors from reaching the interview admin UI.
function requireAdmin(req, res) {
  const tok = req.cookies?.smb_admin || req.query?.t || req.body?.admin_token || '';
  if (!ADMIN_TOKEN || tok !== ADMIN_TOKEN) {
    res.status(401).set('content-type', 'text/html; charset=utf-8')
       .send(`<form style="font-family:system-ui;padding:48px;max-width:420px;margin:0 auto"><h2 style="margin-bottom:12px">Admin only</h2><p style="color:#666;margin-bottom:18px">Interview editor is gated. Enter admin token.</p><input name="t" placeholder="admin token" style="width:100%;padding:10px;margin-bottom:10px"><button style="padding:10px 18px">Enter</button></form>`);
    return false;
  }
  // Refresh cookie so subsequent requests don't need ?t=.
  res.cookie('smb_admin', tok, { httpOnly: true, sameSite: 'lax' });
  return true;
}

// --- routes -----------------------------------------------------------------

app.get('/healthz', async (_req, res) => {
  try {
    await query('SELECT 1');
    res.json({ ok: true, port: PORT, ts: new Date().toISOString() });
  } catch (e) {
    res.status(500).json({ ok: false, error: String(e.message || e) });
  }
});

// Public editorial homepage — neighborhood-led, barber/beard featured first.
app.get('/', async (_req, res) => {
  const all = await many(
    `SELECT slug, COALESCE(NULLIF(source_data_json->>'dba_name',''), name) AS name,
            owner_name, category, neighborhood, city, state, tier, hero_image_url, ranking_score
     FROM businesses
     ORDER BY ranking_score DESC NULLS LAST, updated_at DESC
     LIMIT 200`
  );
  // Latest filled interview drives the "In this issue" featured block.
  const featured = await one(
    `SELECT i.qa_json, i.recorded_at, b.slug, b.name, b.owner_name, b.neighborhood
     FROM business_interviews i
     JOIN businesses b ON b.id = i.business_id
     WHERE jsonb_typeof(i.qa_json) = 'object' AND i.qa_json <> '{}'::jsonb
     ORDER BY i.recorded_at DESC LIMIT 1`
  );
  const featuredBlock = featured ? {
    business: { slug: featured.slug, name: featured.name, owner_name: featured.owner_name, neighborhood: featured.neighborhood },
    qa_json: featured.qa_json,
  } : null;
  const isBarber = (b) => b.category === 'barbershop' || b.category === 'beard';
  const featuredBarbers = all.filter(isBarber).slice(0, 8);
  const featuredIds = new Set(featuredBarbers.map(b => b.slug));
  const otherShops = all.filter(b => !featuredIds.has(b.slug)).slice(0, 24);

  // neighborhood counts
  const hoodMap = new Map();
  for (const b of all) {
    if (!b.neighborhood) continue;
    hoodMap.set(b.neighborhood, (hoodMap.get(b.neighborhood) || 0) + 1);
  }
  const neighborhoods = [...hoodMap.entries()].map(([neighborhood, count]) => ({ neighborhood, count }));

  // Latest 8 enrichment finds — articles + social handles — for the "just
  // discovered" rail. Hits the same tables /timeline does but shape's slim.
  const latestFinds = await many(
    `WITH recent_articles AS (
       SELECT a.url, a.title, a.publisher, a.created_at AS ts, b.slug,
              COALESCE(NULLIF(b.source_data_json->>'dba_name',''), b.name) AS name,
              b.neighborhood, 'article' AS kind
         FROM business_articles a JOIN businesses b ON b.id = a.business_id
        ORDER BY a.created_at DESC LIMIT 16
     ), recent_socials AS (
       SELECT s.url, s.handle AS title, s.platform AS publisher, s.scraped_at AS ts,
              b.slug, COALESCE(NULLIF(b.source_data_json->>'dba_name',''), b.name) AS name,
              b.neighborhood, 'social' AS kind
         FROM business_socials s JOIN businesses b ON b.id = s.business_id
        WHERE s.url IS NOT NULL
        ORDER BY s.scraped_at DESC LIMIT 16
     )
     SELECT * FROM recent_articles UNION ALL SELECT * FROM recent_socials
      ORDER BY ts DESC LIMIT 8`
  );

  res.set('content-type', 'text/html; charset=utf-8');
  res.send(renderHome({ featuredBarbers, otherShops, neighborhoods, featured: featuredBlock, totalCount: all.length, latestFinds }));
});

// /best — curated "data-complete" shops: website + Instagram + at least one
// press article. Intended as a top-of-funnel page that surfaces only the
// shops where we know enough to send a customer. Live-recomputed from
// the enrichment tables on every request (cheap; small set).
// /best — neighborhood × category hub. Lists every /best/:hood/:cat URL in a
// scannable grid so Google can crawl the long-tail entry points. The old
// "data-complete cohort" view moved to /best/curated (preserved verbatim).
app.get('/best', async (_req, res) => {
  try {
    const grid = await many(
      `SELECT neighborhood,
              COUNT(*) FILTER (WHERE category = 'salon')      AS salons,
              COUNT(*) FILTER (WHERE category = 'barbershop') AS barbershops,
              COUNT(*) FILTER (WHERE category = 'spa')        AS spas,
              COUNT(*) FILTER (WHERE category = 'nail')       AS nails,
              COUNT(*)::int AS total
         FROM businesses
        WHERE neighborhood IS NOT NULL AND neighborhood <> ''
        GROUP BY neighborhood
        HAVING COUNT(*) >= 5
        ORDER BY total DESC`
    );
    const slug = s => String(s).toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
    const totalShops = grid.reduce((a, r) => a + Number(r.total), 0);
    res.set('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>Best Salons & Barbers by Neighborhood · ${grid.length} LA neighborhoods · LA Salon Directory</title>
<meta name="description" content="Browse the best salons, barbershops, spas, and nail salons across ${grid.length} Los Angeles neighborhoods. ${totalShops.toLocaleString()} ranked shops.">
<link rel="canonical" href="${esc(process.env.SITE_URL || 'https://localhost')}/best">
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600;700&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>
:root{--ink:#1a1a1a;--paper:#f5f5f5;--rule:#e6e3dc;--mute:#7a766f;--accent:#5a6e3a;--gold:#b8945a}
*{box-sizing:border-box;margin:0;padding:0}body{background:var(--paper);color:var(--ink);font-family:Inter,system-ui;line-height:1.45;-webkit-font-smoothing:antialiased}
a{color:inherit;text-decoration:none}
.topbar{padding:18px 32px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--rule);position:sticky;top:0;background:var(--paper);z-index:60;flex-wrap:wrap;gap:12px}
.brand{font-family:Cormorant Garamond,serif;font-size:20px;font-weight:500}
.brand em{color:var(--accent);font-style:italic}
.topnav{display:flex;gap:18px;font-family:monospace;font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute)}
.topnav a:hover{color:var(--ink)}
.hero{padding:64px 32px 32px;border-bottom:1px solid var(--rule);max-width:1240px;margin:0 auto}
h1{font-family:Cormorant Garamond,serif;font-weight:500;font-size:clamp(40px,6vw,72px);line-height:1;letter-spacing:-.02em;margin-bottom:14px}
h1 em{font-style:italic;color:var(--accent)}
.hero-lede{font-size:17px;color:#3a3a3a;max-width:60ch;margin-bottom:20px}
.stats{display:flex;gap:32px;font-family:monospace;font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute);flex-wrap:wrap}
.stats b{display:block;font-family:Cormorant Garamond,serif;font-style:italic;font-size:30px;color:var(--ink);font-weight:500;text-transform:none}
.cta{padding:18px 32px;background:#fff;border-bottom:1px solid var(--rule);text-align:center;font-family:monospace;font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--mute)}
.cta a{color:var(--accent);border-bottom:1px solid var(--accent);padding-bottom:1px}
.grid{padding:48px 32px;max-width:1240px;margin:0 auto;display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:1px;background:var(--rule);border:1px solid var(--rule)}
.cell{background:var(--paper);padding:18px 22px;display:flex;flex-direction:column;gap:6px;transition:background 150ms ease}
.cell:hover{background:#fff}
.cell-name{font-family:Cormorant Garamond,serif;font-size:24px;font-weight:500;letter-spacing:-.005em;line-height:1.1}
.cell-name a{color:var(--ink)}
.cell-name a:hover{color:var(--accent)}
.cats{display:flex;flex-wrap:wrap;gap:5px;margin-top:6px}
.cat{display:inline-block;padding:3px 8px;border:1px solid var(--rule);background:#fff;font-family:monospace;font-size:9px;letter-spacing:.08em;text-transform:uppercase}
.cat:hover{background:var(--accent);color:var(--paper);border-color:var(--accent)}
.cat-tot{font-family:monospace;font-size:9px;color:var(--mute);letter-spacing:.06em;margin-top:4px}
.footer{padding:48px 32px;border-top:1px solid var(--rule);font-family:monospace;font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--mute);text-align:center}
</style></head>
<body>
<header class="topbar">
  <div class="brand"><a href="/">LA <em>Salon</em> Directory</a> <span style="color:var(--mute);font-style:italic">/ best by neighborhood</span></div>
  <nav class="topnav">
    <a href="/best/curated">Curated</a><a href="/proprietors">Proprietors</a><a href="/fresh">Just Opened</a><a href="/press">Press</a><a href="/">Home</a>
  </nav>
</header>
<section class="hero">
  <h1>The <em>best</em> by neighborhood.</h1>
  <p class="hero-lede">Ranked salons, barbershops, spas, and nail salons across ${grid.length} Los Angeles neighborhoods. Built from California state licensure data, press coverage, social presence, and website signals — ${totalShops.toLocaleString()} shops in total.</p>
  <div class="stats">
    <div>Neighborhoods<b>${grid.length}</b></div>
    <div>Total shops<b>${totalShops.toLocaleString()}</b></div>
    <div>Ranked pages<b>${(grid.length * 5).toLocaleString()}</b></div>
  </div>
</section>
<div class="cta">Looking for the <a href="/best/curated">data-complete cohort →</a> (website + IG + press)</div>
<section class="grid">
${grid.map(g => {
  const s = slug(g.neighborhood);
  return `<div class="cell">
    <div class="cell-name"><a href="/best/${esc(s)}">${esc(g.neighborhood)}</a></div>
    <div class="cat-tot">${Number(g.total).toLocaleString()} shop${Number(g.total)===1?'':'s'} ranked</div>
    <div class="cats">
      ${Number(g.salons) > 0 ? `<a class="cat" href="/best/${esc(s)}/salons">Salons · ${g.salons}</a>` : ''}
      ${Number(g.barbershops) > 0 ? `<a class="cat" href="/best/${esc(s)}/barbershops">Barbers · ${g.barbershops}</a>` : ''}
      ${Number(g.spas) > 0 ? `<a class="cat" href="/best/${esc(s)}/spas">Spas · ${g.spas}</a>` : ''}
      ${Number(g.nails) > 0 ? `<a class="cat" href="/best/${esc(s)}/nail-salons">Nails · ${g.nails}</a>` : ''}
    </div>
  </div>`;
}).join('')}
</section>
<footer class="footer">
  <a href="/">← Home</a> · ${grid.length} neighborhoods · sourced from CA BBCB
</footer>
</body></html>`);
  } catch (e) { fail(res, '/best', e); }
});

// /best/curated — original "data-complete cohort" view.
app.get('/best/curated', async (_req, res) => {
  const rows = await many(
    `SELECT b.slug,
            COALESCE(NULLIF(b.source_data_json->>'dba_name',''), b.name) AS name,
            b.category, b.neighborhood, b.city, b.address,
            b.website,
            (b.source_data_json->>'source' = 'dca_bbcb') AS licensed,
            ig.url AS ig_url, ig.handle AS ig_handle,
            ye.url AS yelp_url,
            COUNT(DISTINCT a.id)::int AS article_count,
            COUNT(DISTINCT s.id)::int AS social_count,
            ARRAY_AGG(DISTINCT a.publisher) FILTER (WHERE a.publisher IS NOT NULL) AS publishers
       FROM businesses b
       LEFT JOIN business_socials ig ON ig.business_id = b.id AND ig.platform = 'instagram'
       LEFT JOIN business_socials ye ON ye.business_id = b.id AND ye.platform = 'yelp'
       LEFT JOIN business_articles a ON a.business_id = b.id
       LEFT JOIN business_socials s  ON s.business_id  = b.id
      WHERE b.website IS NOT NULL AND b.website <> ''
      GROUP BY b.id, b.slug, b.name, b.source_data_json, b.category, b.neighborhood, b.city, b.address, b.website,
               ig.url, ig.handle, ye.url
     HAVING COUNT(DISTINCT a.id) >= 1 AND COUNT(DISTINCT s.id) >= 1
      ORDER BY article_count DESC, social_count DESC, name`
  );
  // Group by neighborhood for the editorial run.
  const byHood = new Map();
  for (const r of rows) {
    const k = r.neighborhood || r.city || 'Other';
    if (!byHood.has(k)) byHood.set(k, []);
    byHood.get(k).push(r);
  }
  const ordered = [...byHood.entries()].sort((a, b) => b[1].length - a[1].length);

  res.set('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>The Best · Data-Complete Shops · LA Salon Directory</title>
<meta name="description" content="${rows.length} LA salons we know enough about to send you to — website, Instagram, and at least one editorial press mention. The data-complete cohort.">
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600;700&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>:root{--ink:#1a1a1a;--paper:#f5f5f5;--rule:#e6e3dc;--mute:#7a766f;--accent:#5a6e3a;--gold:#b8945a}*{box-sizing:border-box;margin:0;padding:0}body{background:var(--paper);color:var(--ink);font-family:Inter,system-ui;line-height:1.45;-webkit-font-smoothing:antialiased}a{color:inherit;text-decoration:none}.topbar{padding:18px 32px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--rule);position:sticky;top:0;background:var(--paper);z-index:60;flex-wrap:wrap;gap:12px}.brand{font-family:Cormorant Garamond,serif;font-size:20px;font-weight:500}.brand em{color:var(--accent);font-style:italic}.topnav{display:flex;gap:18px;font-family:monospace;font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute)}.topnav a:hover{color:var(--ink)}.hero{padding:80px 32px 40px;border-bottom:1px solid var(--rule);max-width:1240px;margin:0 auto}.hero-eyebrow{font-family:monospace;font-size:11px;letter-spacing:.22em;text-transform:uppercase;color:var(--accent);margin-bottom:18px}h1{font-family:Cormorant Garamond,serif;font-weight:500;font-size:clamp(48px,7vw,84px);line-height:.96;letter-spacing:-.02em;margin-bottom:14px}h1 em{font-style:italic;color:var(--accent)}.hero-lede{font-size:17px;color:#3a3a3a;max-width:60ch;margin-bottom:24px}.stats-row{display:flex;gap:32px;font-family:monospace;font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute);flex-wrap:wrap}.stats-row b{display:block;font-family:Cormorant Garamond,serif;font-style:italic;font-size:32px;letter-spacing:-.01em;color:var(--ink);font-weight:500;margin-top:4px;text-transform:none}.hood-section{padding:64px 32px;max-width:1240px;margin:0 auto;border-top:1px solid var(--rule);scroll-margin-top:80px}.hood-head{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:24px;padding-bottom:12px;border-bottom:1px solid var(--rule)}.hood-head h2{font-family:Cormorant Garamond,serif;font-weight:500;font-size:clamp(32px,4vw,52px);letter-spacing:-.015em;line-height:1}.hood-head .meta{font-family:monospace;font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--mute)}.shops{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:0 1px;background:var(--rule);border:1px solid var(--rule)}.shop{background:var(--paper);padding:22px 24px;display:flex;flex-direction:column;gap:8px;transition:background 200ms ease;border-bottom:1px solid var(--rule)}.shop:hover{background:#fff}.s-cat{font-family:monospace;font-size:9px;letter-spacing:.18em;text-transform:uppercase;color:var(--accent);display:flex;justify-content:space-between;align-items:center}.s-cat .lic{background:var(--gold);color:var(--ink);padding:1px 6px;font-weight:700}.s-name{font-family:Cormorant Garamond,serif;font-size:24px;font-weight:500;line-height:1.1;letter-spacing:-.005em}.s-name a{color:var(--ink)}.s-where{font-family:monospace;font-size:10px;letter-spacing:.04em;color:var(--mute);text-transform:uppercase;line-height:1.4}.chips{display:flex;flex-wrap:wrap;gap:5px;margin-top:6px}.chip{display:inline-block;padding:4px 10px;border:1px solid var(--rule);background:#fff;font-family:monospace;font-size:10px;letter-spacing:.06em;text-transform:uppercase;color:var(--ink);line-height:1.3}.chip:hover{background:var(--accent);color:var(--paper);border-color:var(--accent)}.chip-press{background:var(--gold);color:var(--ink);border-color:var(--gold);font-weight:700}.pubs{font-family:monospace;font-size:10px;color:var(--accent);letter-spacing:.04em;margin-top:4px;line-height:1.5}.empty{text-align:center;padding:96px;font-family:Cormorant Garamond,serif;font-style:italic;font-size:24px;color:var(--mute)}.footer{padding:48px 32px;border-top:1px solid var(--rule);font-family:monospace;font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--mute)}</style></head>
<body>
<header class="topbar">
  <div class="brand"><a href="/">LA <em>Salon</em> Directory</a> <span style="color:var(--mute);font-style:italic">/ the best</span></div>
  <nav class="topnav">
    <a href="/press">Press</a>
    <a href="/proprietors">Proprietors</a>
    <a href="/fresh">Just Opened</a>
    <a href="/near">Near Me</a>
    <a href="/">Home</a>
  </nav>
</header>
<section class="hero">
  <div class="hero-eyebrow">Data-Complete · Website + Instagram + Press</div>
  <h1>The shops we know <em>enough</em> about.</h1>
  <p class="hero-lede">${rows.length} LA salons and barbershops where we've found a real website, an active Instagram presence, AND at least one editorial press mention. The cohort we're confident enough to send you to.</p>
  <div class="stats-row">
    <div>Curated<b>${rows.length}</b></div>
    <div>Neighborhoods<b>${ordered.length}</b></div>
    <div>State-licensed<b>${rows.filter(r => r.licensed).length}</b></div>
  </div>
</section>

${ordered.length === 0 ? `
<div class="empty">No fully-enriched shops yet. The agents are still working — refresh later.</div>
` : ordered.map(([hood, list]) => `
  <section class="hood-section">
    <div class="hood-head">
      <h2>${esc(hood)}</h2>
      <span class="meta">${list.length} shop${list.length === 1 ? '' : 's'}</span>
    </div>
    <div class="shops">
      ${list.map(s => `
        <article class="shop">
          <div class="s-cat">
            <span>${esc(s.category || 'business')}</span>
            ${s.licensed ? '<span class="lic">◆ Licensed</span>' : ''}
          </div>
          <div class="s-name"><a href="/biz/${esc(s.slug)}">${esc(s.name)}</a></div>
          ${s.address ? `<div class="s-where">${esc(s.address)}</div>` : ''}
          <div class="chips">
            <a class="chip" href="${esc(s.website)}" target="_blank" rel="noopener">🌐 Website</a>
            ${s.ig_url ? `<a class="chip" href="${esc(s.ig_url)}" target="_blank" rel="noopener">📷 ${s.ig_handle ? '@' + esc(s.ig_handle) : 'Instagram'}</a>` : ''}
            ${s.yelp_url ? `<a class="chip" href="${esc(s.yelp_url)}" target="_blank" rel="noopener">⭐ Yelp</a>` : ''}
            <a class="chip chip-press" href="/biz/${esc(s.slug)}#press">📰 ${s.article_count} Press</a>
          </div>
          ${s.publishers && s.publishers.length ? `<div class="pubs">${s.publishers.slice(0, 5).map(p => esc(p)).join(' · ')}${s.publishers.length > 5 ? ' +' + (s.publishers.length - 5) : ''}</div>` : ''}
        </article>
      `).join('')}
    </div>
  </section>
`).join('')}

<footer class="footer">
  <a href="/" style="color:var(--accent)">← Home</a>
  · ${rows.length.toLocaleString()} curated · live-computed from enrichment tables
</footer>
</body></html>`);
});

// /best/:hoodSlug/:catSlug? — Yelp-killer "Best [category] in [neighborhood]" pages.
// The URL slug IS the SEO target — H1 mirrors the verbatim Google query, and the URL
// stays stable forever even as rankings rotate. JSON-LD ItemList wraps the top 10.
const CAT_SLUG_TO_DB = {
  'salons': 'salon', 'salon': 'salon',
  'barbershops': 'barbershop', 'barbers': 'barbershop', 'barbershop': 'barbershop',
  'spas': 'spa', 'spa': 'spa',
  'nail-salons': 'nail', 'nail': 'nail',
};
const CAT_SLUG_TO_LABEL = {
  'salons': 'Hair Salons', 'salon': 'Hair Salons',
  'barbershops': 'Barbershops', 'barbers': 'Barbershops', 'barbershop': 'Barbershops',
  'spas': 'Spas', 'spa': 'Spas',
  'nail-salons': 'Nail Salons', 'nail': 'Nail Salons',
};
app.get('/best/:hoodSlug/:catSlug?', async (req, res) => {
  try {
    const hoodSlug = String(req.params.hoodSlug || '').toLowerCase();
    const catSlug = String(req.params.catSlug || '').toLowerCase();
    const dbCat = catSlug ? CAT_SLUG_TO_DB[catSlug] : null;
    const catLabel = catSlug ? (CAT_SLUG_TO_LABEL[catSlug] || 'Salons & Barbers') : 'Salons & Barbers';
    if (catSlug && !dbCat) return res.status(404).send('Unknown category');

    // Resolve hoodSlug → canonical neighborhood (case-insensitive, hyphen-aware).
    const hoodRow = await one(
      `SELECT neighborhood, COUNT(*)::int AS n
         FROM businesses
        WHERE neighborhood IS NOT NULL
          AND LOWER(REGEXP_REPLACE(neighborhood, '\\s+', '-', 'g')) = $1
        GROUP BY neighborhood
        ORDER BY n DESC LIMIT 1`,
      [hoodSlug]
    );
    if (!hoodRow) return res.status(404).send(`Neighborhood "${hoodSlug}" not found`);
    const hood = hoodRow.neighborhood;

    const rows = await many(
      `SELECT b.slug,
              COALESCE(NULLIF(b.source_data_json->>'dba_name',''), b.name) AS name,
              b.category, b.address, b.website, b.phone,
              (b.source_data_json->>'source' = 'dca_bbcb') AS licensed,
              ig.url AS ig_url, ig.handle AS ig_handle,
              ye.url AS yelp_url,
              COUNT(DISTINCT a.id)::int AS article_count,
              ARRAY_AGG(DISTINCT a.publisher) FILTER (WHERE a.publisher IS NOT NULL) AS publishers,
              (
                CASE WHEN b.website IS NOT NULL AND b.website <> '' THEN 15 ELSE 0 END
              + CASE WHEN ig.url IS NOT NULL THEN 10 ELSE 0 END
              + CASE WHEN ye.url IS NOT NULL THEN 9 ELSE 0 END
              + COUNT(DISTINCT a.id)::int * 14
              + CASE WHEN b.source_data_json->>'source' = 'dca_bbcb' THEN 8 ELSE 0 END
              + CASE WHEN b.address IS NOT NULL AND b.address <> '' THEN 6 ELSE 0 END
              + CASE WHEN b.phone IS NOT NULL AND b.phone <> '' THEN 5 ELSE 0 END
              + CASE WHEN b.latitude IS NOT NULL THEN 5 ELSE 0 END
              ) AS score
         FROM businesses b
         LEFT JOIN business_socials  ig ON ig.business_id = b.id AND ig.platform = 'instagram'
         LEFT JOIN business_socials  ye ON ye.business_id = b.id AND ye.platform = 'yelp'
         LEFT JOIN business_articles a  ON a.business_id  = b.id
        WHERE b.neighborhood = $1
          ${dbCat ? 'AND b.category = $2' : ''}
        GROUP BY b.id, ig.url, ig.handle, ye.url
        ORDER BY score DESC, name
        LIMIT 100`,
      dbCat ? [hood, dbCat] : [hood]
    );

    if (rows.length === 0) return res.status(404).send(`No ${catLabel} found in ${hood}`);

    const baseUrl = process.env.SITE_URL || 'https://localhost';
    const canonUrl = `${baseUrl}/best/${hoodSlug}${catSlug ? '/' + catSlug : ''}`;
    const top10 = rows.slice(0, 10);
    const itemList = {
      '@context': 'https://schema.org',
      '@type': 'ItemList',
      name: `Best ${catLabel} in ${hood}, Los Angeles`,
      description: `${rows.length} ${catLabel.toLowerCase()} ranked in ${hood}, Los Angeles — based on website presence, press coverage, social, and state licensure.`,
      url: canonUrl,
      numberOfItems: rows.length,
      itemListElement: top10.map((r, i) => ({
        '@type': 'ListItem',
        position: i + 1,
        item: {
          '@type': 'HairSalon',
          name: r.name,
          url: `${baseUrl}/biz/${r.slug}`,
          ...(r.address ? { address: r.address } : {}),
          ...(r.phone ? { telephone: r.phone } : {}),
          ...(r.website ? { sameAs: r.website } : {}),
        },
      })),
    };
    const breadcrumb = {
      '@context': 'https://schema.org',
      '@type': 'BreadcrumbList',
      itemListElement: [
        { '@type': 'ListItem', position: 1, name: 'Home', item: baseUrl },
        { '@type': 'ListItem', position: 2, name: 'Best', item: `${baseUrl}/best` },
        { '@type': 'ListItem', position: 3, name: hood, item: `${baseUrl}/best/${hoodSlug}` },
        ...(catSlug ? [{ '@type': 'ListItem', position: 4, name: catLabel, item: canonUrl }] : []),
      ],
    };

    res.set('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>Best ${esc(catLabel)} in ${esc(hood)}, Los Angeles · ${rows.length} ranked · LA Salon Directory</title>
<meta name="description" content="${rows.length} ${esc(catLabel.toLowerCase())} ranked in ${esc(hood)}, Los Angeles. Top picks based on website, press coverage, Instagram, Yelp, and state licensure.">
<link rel="canonical" href="${esc(canonUrl)}">
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600;700&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
<script type="application/ld+json">${escJsonLd(JSON.stringify(itemList))}</script>
<script type="application/ld+json">${escJsonLd(JSON.stringify(breadcrumb))}</script>
<style>
:root{--ink:#1a1a1a;--paper:#f5f5f5;--rule:#e6e3dc;--mute:#7a766f;--accent:#5a6e3a;--gold:#b8945a}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--paper);color:var(--ink);font-family:Inter,system-ui;line-height:1.45;-webkit-font-smoothing:antialiased}
a{color:inherit;text-decoration:none}
.topbar{padding:18px 32px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--rule);position:sticky;top:0;background:var(--paper);z-index:60;flex-wrap:wrap;gap:12px}
.brand{font-family:Cormorant Garamond,serif;font-size:20px;font-weight:500}
.brand em{color:var(--accent);font-style:italic}
.topnav{display:flex;gap:18px;font-family:monospace;font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute)}
.topnav a:hover{color:var(--ink)}
.crumb{padding:14px 32px;font-family:monospace;font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:var(--mute);max-width:1240px;margin:0 auto}
.crumb a:hover{color:var(--ink)}
.hero{padding:64px 32px 32px;border-bottom:1px solid var(--rule);max-width:1240px;margin:0 auto}
h1{font-family:Cormorant Garamond,serif;font-weight:500;font-size:clamp(40px,6vw,72px);line-height:1;letter-spacing:-.02em;margin-bottom:14px}
h1 em{font-style:italic;color:var(--accent)}
.hero-lede{font-size:17px;color:#3a3a3a;max-width:60ch;margin-bottom:20px}
.review-count{font-family:Cormorant Garamond,serif;font-size:21px;color:var(--accent);font-style:italic}
.list{padding:32px 32px 64px;max-width:1240px;margin:0 auto}
.row{display:grid;grid-template-columns:60px 1fr auto;gap:20px;align-items:center;padding:20px 0;border-bottom:1px solid var(--rule);transition:padding 150ms ease}
.row:hover{padding-left:8px;background:#fff}
.rank{font-family:Cormorant Garamond,serif;font-style:italic;font-size:36px;color:var(--accent);font-weight:500;line-height:1;text-align:center}
.r-name{font-family:Cormorant Garamond,serif;font-size:24px;font-weight:500;letter-spacing:-.005em;line-height:1.2}
.r-meta{font-family:monospace;font-size:10px;letter-spacing:.06em;color:var(--mute);text-transform:uppercase;margin-top:4px;line-height:1.5}
.r-chips{display:flex;gap:6px;flex-wrap:wrap;margin-top:6px}
.chip{display:inline-block;padding:3px 9px;border:1px solid var(--rule);background:#fff;font-family:monospace;font-size:9px;letter-spacing:.08em;text-transform:uppercase}
.chip-press{background:var(--gold);color:var(--ink);border-color:var(--gold);font-weight:700}
.chip-lic{background:var(--accent);color:var(--paper);border-color:var(--accent)}
.r-score{font-family:monospace;font-size:11px;color:var(--accent);text-align:right;letter-spacing:.06em}
.r-score b{display:block;font-family:Cormorant Garamond,serif;font-style:italic;font-size:24px;color:var(--ink);font-weight:500;letter-spacing:-.01em}
.cat-nav{padding:18px 32px;background:#fff;border-top:1px solid var(--rule);border-bottom:1px solid var(--rule);font-family:monospace;font-size:11px;letter-spacing:.12em;text-transform:uppercase;display:flex;gap:18px;flex-wrap:wrap;justify-content:center}
.cat-nav a{padding:6px 12px;border:1px solid var(--rule)}
.cat-nav a:hover,.cat-nav a.active{background:var(--accent);color:var(--paper);border-color:var(--accent)}
.footer{padding:48px 32px;border-top:1px solid var(--rule);font-family:monospace;font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--mute);text-align:center}
@media (max-width:880px){.row{grid-template-columns:40px 1fr;gap:14px}.r-score{grid-column:2}}
</style></head>
<body>
<header class="topbar">
  <div class="brand"><a href="/">LA <em>Salon</em> Directory</a> <span style="color:var(--mute);font-style:italic">/ best ${esc(hood)}</span></div>
  <nav class="topnav">
    <a href="/best">All Best</a>
    <a href="/press">Press</a>
    <a href="/proprietors">Proprietors</a>
    <a href="/fresh">Just Opened</a>
    <a href="/">Home</a>
  </nav>
</header>
<nav class="crumb"><a href="/">Home</a> / <a href="/best">Best</a> / <a href="/best/${esc(hoodSlug)}">${esc(hood)}</a>${catSlug ? ` / <span style="color:var(--ink)">${esc(catLabel)}</span>` : ''}</nav>
<section class="hero">
  <h1>Best <em>${esc(catLabel)}</em><br>in ${esc(hood)}, Los Angeles</h1>
  <p class="hero-lede">The ${rows.length} ${esc(catLabel.toLowerCase())} ranked in ${esc(hood)} based on website presence, press coverage, Instagram, Yelp listings, and California state licensure. Top ${Math.min(rows.length, 10)} below; full list of ${rows.length} continues underneath.</p>
  <p class="review-count">${rows.length} ${esc(catLabel.toLowerCase())} ranked in this neighborhood</p>
</section>
<nav class="cat-nav">
  <a href="/best/${esc(hoodSlug)}" class="${!catSlug ? 'active' : ''}">All</a>
  <a href="/best/${esc(hoodSlug)}/salons" class="${catSlug==='salons'?'active':''}">Salons</a>
  <a href="/best/${esc(hoodSlug)}/barbershops" class="${catSlug==='barbershops'?'active':''}">Barbershops</a>
  <a href="/best/${esc(hoodSlug)}/spas" class="${catSlug==='spas'?'active':''}">Spas</a>
  <a href="/best/${esc(hoodSlug)}/nail-salons" class="${catSlug==='nail-salons'?'active':''}">Nail Salons</a>
</nav>
<section class="list">
${rows.map((r, i) => `
  <a class="row" href="/biz/${esc(r.slug)}">
    <div class="rank">${i + 1}</div>
    <div>
      <div class="r-name">${esc(r.name)}</div>
      <div class="r-meta">${esc(r.address || hood)}${r.publishers && r.publishers.length ? ' · ' + r.publishers.slice(0,3).map(p=>esc(p)).join(' · ') : ''}</div>
      <div class="r-chips">
        ${r.website ? '<span class="chip">🌐 Website</span>' : ''}
        ${r.ig_url ? `<span class="chip">📷 ${r.ig_handle ? '@'+esc(r.ig_handle) : 'IG'}</span>` : ''}
        ${r.yelp_url ? '<span class="chip">⭐ Yelp</span>' : ''}
        ${r.article_count > 0 ? `<span class="chip chip-press">📰 ${r.article_count} Press</span>` : ''}
        ${r.licensed ? '<span class="chip chip-lic">◆ Licensed</span>' : ''}
      </div>
    </div>
    <div class="r-score">SCORE<b>${r.score}</b></div>
  </a>
`).join('')}
</section>
<footer class="footer">
  <a href="/best">← All Best</a> · ${rows.length} ranked in ${esc(hood)} · scoring: website 15 · press 14 · IG 10 · Yelp 9 · licensed 8 · address 6 · phone 5 · geocoded 5
</footer>
</body></html>`);
  } catch (e) { fail(res, '/best/:hood/:cat', e); }
});

// /press/shops — same press data sliced by SHOP rather than by publisher.
// The shops with the most article hits — useful for "who's getting written
// about this season". Sorts by article_count desc.
app.get('/press/shops', async (_req, res) => {
  const rows = await many(
    `SELECT b.slug,
            COALESCE(NULLIF(b.source_data_json->>'dba_name',''), b.name) AS name,
            b.neighborhood, b.category,
            COUNT(a.id)::int AS article_count,
            ARRAY_AGG(DISTINCT a.publisher) FILTER (WHERE a.publisher IS NOT NULL) AS publishers
       FROM businesses b
       JOIN business_articles a ON a.business_id = b.id
      GROUP BY b.id, b.slug, b.name, b.source_data_json, b.neighborhood, b.category
      ORDER BY article_count DESC, name
      LIMIT 200`
  );
  res.set('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>Most-Covered Shops · Press · LA Salon Directory</title>
<meta name="description" content="The LA salons and barbershops with the most editorial press coverage — ranked by article count.">
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600;700&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>:root{--ink:#1a1a1a;--paper:#f5f5f5;--rule:#e6e3dc;--mute:#7a766f;--accent:#5a6e3a;--gold:#b8945a}*{box-sizing:border-box;margin:0;padding:0}body{background:var(--paper);color:var(--ink);font-family:Inter,system-ui;line-height:1.45;-webkit-font-smoothing:antialiased}a{color:inherit;text-decoration:none}.topbar{padding:18px 32px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--rule);position:sticky;top:0;background:var(--paper);z-index:60;flex-wrap:wrap;gap:12px}.brand{font-family:Cormorant Garamond,serif;font-size:20px;font-weight:500}.brand em{color:var(--accent);font-style:italic}.topnav{display:flex;gap:18px;font-family:monospace;font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute)}.hero{padding:80px 32px 40px;border-bottom:1px solid var(--rule);max-width:1240px;margin:0 auto}.hero-eyebrow{font-family:monospace;font-size:11px;letter-spacing:.22em;text-transform:uppercase;color:var(--accent);margin-bottom:18px}h1{font-family:Cormorant Garamond,serif;font-weight:500;font-size:clamp(48px,7vw,84px);line-height:.96;letter-spacing:-.02em;margin-bottom:14px}h1 em{font-style:italic;color:var(--accent)}.hero-lede{font-size:17px;color:#3a3a3a;max-width:60ch}.list{padding:48px 32px;max-width:1240px;margin:0 auto}.row{display:grid;grid-template-columns:60px 1fr 80px;gap:24px;align-items:baseline;padding:18px 0;border-bottom:1px solid var(--rule);transition:padding 200ms ease}.row:hover{padding-left:8px;background:#fff}.rank{font-family:Cormorant Garamond,serif;font-style:italic;font-size:36px;color:var(--mute);font-weight:500;letter-spacing:-.02em}.r-info .name{font-family:Cormorant Garamond,serif;font-size:24px;font-weight:500;line-height:1.1}.r-info .meta{font-family:monospace;font-size:10px;letter-spacing:.06em;color:var(--mute);text-transform:uppercase;margin-top:4px}.r-info .pubs{font-family:monospace;font-size:11px;color:var(--accent);margin-top:6px;letter-spacing:.04em}.count{font-family:monospace;font-size:11px;color:var(--mute);text-align:right;letter-spacing:.06em;text-transform:uppercase}.count b{font-family:Cormorant Garamond,serif;font-style:italic;font-size:32px;color:var(--ink);font-weight:500;letter-spacing:-.02em;display:block;font-style:italic}.empty{text-align:center;padding:96px;font-family:Cormorant Garamond,serif;font-style:italic;font-size:24px;color:var(--mute)}.footer{padding:48px 32px;border-top:1px solid var(--rule);font-family:monospace;font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--mute)}</style></head>
<body>
<header class="topbar">
  <div class="brand"><a href="/">LA <em>Salon</em> Directory</a> <span style="color:var(--mute);font-style:italic">/ press / most-covered</span></div>
  <nav class="topnav">
    <a href="/press">By publisher</a>
    <a href="/proprietors">Proprietors</a>
    <a href="/fresh">Just Opened</a>
    <a href="/near">Near me</a>
    <a href="/">Home</a>
  </nav>
</header>
<section class="hero">
  <div class="hero-eyebrow">Most-Covered Shops · By Article Count</div>
  <h1>Who they're <em>writing about.</em></h1>
  <p class="hero-lede">The salons and barbershops with the most editorial press coverage in our index. Ranked by total article count across publishers like Modern Salon, Fashionista, Voyage LA, Hollywood Reporter, Spa &amp; Beauty Today, and others.</p>
</section>
<section class="list">
  ${rows.length === 0 ? '<div class="empty">No press articles indexed yet. Enrichment fleet still working.</div>' :
    rows.map((r, i) => `
      <a class="row" href="/biz/${esc(r.slug)}">
        <span class="rank">${i + 1}</span>
        <div class="r-info">
          <div class="name">${esc(r.name)}</div>
          <div class="meta">${esc(r.category || '')}${r.neighborhood ? ' · ' + esc(r.neighborhood) : ''}</div>
          ${r.publishers && r.publishers.length ? `<div class="pubs">${r.publishers.slice(0, 6).map(p => esc(p)).join(' · ')}${r.publishers.length > 6 ? ' +' + (r.publishers.length - 6) : ''}</div>` : ''}
        </div>
        <div class="count"><b>${r.article_count}</b>articles</div>
      </a>
    `).join('')
  }
</section>
<footer class="footer"><a href="/" style="color:var(--accent)">← Home</a></footer>
</body></html>`);
});

// /near — universal "near me" drive-mode map. Same UX as /corridor/:slug/map
// but unbounded — pulls all geocoded shops (capped 6,000) so the user can
// drive anywhere in LA county and see the directory live.
app.get('/near', async (_req, res) => {
  const businesses = await many(
    `SELECT slug,
            COALESCE(NULLIF(source_data_json->>'dba_name',''), name) AS name,
            category, neighborhood, latitude, longitude, source_data_json
       FROM businesses
      WHERE latitude IS NOT NULL AND longitude IS NOT NULL
      ORDER BY (source_data_json->>'source' = 'dca_bbcb') DESC,
               ranking_score DESC NULLS LAST
      LIMIT 6000`
  );
  const totalGeocoded = await one(
    `SELECT COUNT(*)::int AS n FROM businesses WHERE latitude IS NOT NULL`
  );
  const { renderNear } = await import('../render/near.js');
  res.set('content-type', 'text/html; charset=utf-8');
  res.send(renderNear({ businesses, totalGeocoded: totalGeocoded?.n || 0 }));
});

// /feed.xml — RSS 2.0 of the most-recent press articles. Subscribable in
// any reader. New shop coverage as it lands — auto-publishes when an
// agent persists a finding.
app.get('/feed.xml', async (req, res) => {
  const host = req.get('host') || `127.0.0.1:${PORT}`;
  const proto = req.get('x-forwarded-proto') || 'http';
  const base = `${proto}://${host}`;
  const articles = await many(
    `SELECT a.url, a.title, a.publisher, a.published_at, a.created_at, a.excerpt,
            b.slug, COALESCE(NULLIF(b.source_data_json->>'dba_name',''), b.name) AS biz_name,
            b.neighborhood
       FROM business_articles a JOIN businesses b ON b.id = a.business_id
      ORDER BY COALESCE(a.published_at, a.created_at) DESC LIMIT 50`
  );
  const escXml = (s) => String(s == null ? '' : s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
  const items = articles.map(a => {
    const pubDate = new Date(a.published_at || a.created_at).toUTCString();
    return `  <item>
    <title>${escXml(a.title || a.publisher || a.url)}</title>
    <link>${escXml(a.url)}</link>
    <guid isPermaLink="false">${escXml(base + '/biz/' + a.slug + '#' + a.url.slice(-32))}</guid>
    <pubDate>${pubDate}</pubDate>
    <category>${escXml(a.publisher || 'press')}</category>
    <description>${escXml(`${a.biz_name}${a.neighborhood ? ' · ' + a.neighborhood : ''} — ${a.publisher || 'press'}: ${(a.excerpt || a.title || '').slice(0, 240)}`)}</description>
    <source url="${escXml(base + '/biz/' + a.slug)}">${escXml(a.biz_name)}</source>
  </item>`;
  }).join('\n');
  res.set('content-type', 'application/rss+xml; charset=utf-8');
  res.send(`<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
  <title>LA Salon Directory · Press Feed</title>
  <link>${escXml(base + '/press')}</link>
  <atom:link href="${escXml(base + '/feed.xml')}" rel="self" type="application/rss+xml"/>
  <description>Editorial press, blog mentions, and Reddit threads about LA salons + barbers — newest first. ${articles.length} most-recent items.</description>
  <language>en-us</language>
  <lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
${items}
</channel>
</rss>`);
});

// /press — public feed of all article + Reddit + blog mentions surfaced by
// the Exa enrichment fleet. Grouped by publisher (LA Times, Eater, etc).
// Updates as the agents persist new finds.
app.get('/press', async (_req, res) => {
  const articles = await many(
    `SELECT a.url, a.title, a.publisher, a.published_at, a.excerpt,
            b.slug, b.name AS biz_name, b.neighborhood, b.category
       FROM business_articles a
       JOIN businesses b ON b.id = a.business_id
      ORDER BY a.published_at DESC NULLS LAST, a.created_at DESC
      LIMIT 500`
  );
  // Group by publisher for the masthead.
  const byPub = new Map();
  for (const a of articles) {
    const p = a.publisher || 'other';
    if (!byPub.has(p)) byPub.set(p, []);
    byPub.get(p).push(a);
  }
  const orderedPubs = [...byPub.entries()].sort((a, b) => b[1].length - a[1].length);
  const totalArticles = articles.length;
  const distinctShops = new Set(articles.map(a => a.slug)).size;
  res.set('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>Press · ${totalArticles} articles · LA Salon Directory</title>
<meta name="description" content="${totalArticles} press mentions across ${orderedPubs.length} publishers — LA Times, Eater, Hollywood Reporter, NAILS Magazine, and more.">
<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:wght@400;500;600;700&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>:root{--ink:#1a1a1a;--paper:#f5f5f5;--rule:#e6e3dc;--mute:#7a766f;--accent:#5a6e3a;--gold:#b8945a}*{box-sizing:border-box;margin:0;padding:0}body{background:var(--paper);color:var(--ink);font-family:Inter,system-ui;-webkit-font-smoothing:antialiased}a{color:inherit;text-decoration:none}.topbar{padding:18px 32px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--rule);position:sticky;top:0;background:var(--paper);z-index:60;flex-wrap:wrap;gap:12px}.brand{font-family:Cormorant Garamond,serif;font-size:20px;font-weight:500}.brand em{color:var(--accent);font-style:italic}.topnav{display:flex;gap:18px;font-family:monospace;font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute)}.topnav a:hover{color:var(--ink)}.hero{padding:80px 32px 48px;border-bottom:1px solid var(--rule);max-width:1240px;margin:0 auto}.hero-eyebrow{font-family:monospace;font-size:11px;letter-spacing:.22em;text-transform:uppercase;color:var(--accent);margin-bottom:18px}.hero h1{font-family:Cormorant Garamond,serif;font-weight:500;font-size:clamp(48px,7vw,96px);line-height:.96;letter-spacing:-.02em;margin-bottom:20px;max-width:14ch}.hero h1 em{font-style:italic;color:var(--accent)}.hero-lede{font-size:18px;line-height:1.55;color:#3a3a3a;max-width:62ch;margin-bottom:32px}.hero-stats{display:flex;gap:48px;font-family:monospace;font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute);flex-wrap:wrap}.hero-stats b{display:block;font-family:Cormorant Garamond,serif;font-style:italic;font-size:34px;letter-spacing:-.01em;color:var(--ink);font-weight:500;margin-top:4px;text-transform:none}.pub-section{padding:64px 32px;max-width:1240px;margin:0 auto;border-top:1px solid var(--rule);scroll-margin-top:80px}.pub-head{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:24px;padding-bottom:12px;border-bottom:1px solid var(--rule)}.pub-head h2{font-family:Cormorant Garamond,serif;font-weight:500;font-size:clamp(32px,4vw,52px);letter-spacing:-.015em;line-height:1}.pub-head .meta{font-family:monospace;font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--mute)}.articles{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:0 1px;background:var(--rule);border:1px solid var(--rule)}.article{background:var(--paper);padding:22px 24px;display:block;transition:background 200ms ease;border-bottom:1px solid var(--rule)}.article:hover{background:#fff}.a-header{display:flex;justify-content:space-between;gap:8px;margin-bottom:8px;align-items:baseline}.a-shop{font-family:monospace;font-size:9px;letter-spacing:.18em;text-transform:uppercase;color:var(--accent)}.a-date{font-family:monospace;font-size:9px;color:var(--mute);letter-spacing:.06em}.a-title{font-family:Cormorant Garamond,serif;font-size:21px;font-weight:500;line-height:1.2;letter-spacing:-.005em;color:var(--ink);margin-bottom:6px}.a-excerpt{font-size:13px;line-height:1.5;color:#4a4a4a;margin-bottom:8px}.a-bizlink{font-family:monospace;font-size:10px;color:var(--accent);letter-spacing:.04em;text-transform:uppercase}.empty{text-align:center;padding:96px 32px;color:var(--mute);font-family:Cormorant Garamond,serif;font-style:italic;font-size:24px}.pub-toc{padding:48px 32px;max-width:1240px;margin:0 auto;display:flex;flex-wrap:wrap;gap:6px}.pub-toc a{font-family:monospace;font-size:11px;letter-spacing:.06em;color:var(--mute);text-transform:uppercase;padding:6px 12px;border:1px solid var(--rule);transition:all 150ms ease}.pub-toc a:hover{background:var(--accent);color:var(--paper);border-color:var(--accent)}.pub-toc a b{color:var(--ink);margin-right:4px}.footer{padding:48px 32px;border-top:1px solid var(--rule);font-family:monospace;font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--mute);display:flex;justify-content:space-between;flex-wrap:wrap;gap:16px}@media(max-width:880px){.topbar{padding:14px 20px}.hero{padding:48px 20px 32px}.pub-section{padding:48px 20px}}</style></head>
<body>
<header class="topbar">
  <div class="brand"><a href="/">LA <em>Salon</em> Directory</a> <span style="color:var(--mute);font-style:italic">/ press</span></div>
  <nav class="topnav">
    <a href="/neighborhoods">Neighborhoods</a>
    <a href="/proprietors">Proprietors</a>
    <a href="/fresh">Just Opened</a>
    <a href="/corridor/ventura-blvd">Corridors</a>
    <a href="/map">Map</a>
    <a href="/">Home</a>
  </nav>
</header>

<section class="hero">
  <div class="hero-eyebrow">The Press File · Articles &amp; Mentions</div>
  <h1>What they're <em>writing.</em></h1>
  <p class="hero-lede">Every editorial mention, profile piece, review, and Reddit thread we've found across the LA salon corridor. From the Hollywood Reporter to NAILS Magazine to your local secret-LA blog.</p>
  <div class="hero-stats">
    <div>Articles<b>${totalArticles.toLocaleString()}</b></div>
    <div>Publishers<b>${orderedPubs.length}</b></div>
    <div>Shops covered<b>${distinctShops.toLocaleString()}</b></div>
  </div>
</section>

${orderedPubs.length === 0 ? `
<div class="empty">No press articles indexed yet. Enrichment fleet is still running.</div>
` : `
<nav class="pub-toc">
  ${orderedPubs.map(([pub, list]) => `<a href="#${esc((pub || '').replace(/[^a-z0-9]/gi, '-').toLowerCase())}"><b>${list.length}</b>${esc(pub)}</a>`).join('')}
</nav>

${orderedPubs.map(([pub, list]) => `
  <section class="pub-section" id="${esc((pub || '').replace(/[^a-z0-9]/gi, '-').toLowerCase())}">
    <div class="pub-head">
      <h2>${esc(pub)}</h2>
      <span class="meta">${list.length} article${list.length===1?'':'s'}</span>
    </div>
    <div class="articles">
      ${list.map(a => `
        <a class="article" href="${esc(a.url)}" target="_blank" rel="noopener">
          <div class="a-header">
            <span class="a-shop">${esc(a.biz_name)}${a.neighborhood ? ' · ' + esc(a.neighborhood) : ''}</span>
            ${a.published_at ? `<span class="a-date">${new Date(a.published_at).toLocaleDateString('en-US',{month:'short',year:'numeric'})}</span>` : ''}
          </div>
          <div class="a-title">${esc(a.title || a.url)}</div>
          ${a.excerpt ? `<p class="a-excerpt">${esc(a.excerpt)}</p>` : ''}
          <div class="a-bizlink">${esc(a.publisher)} → /biz/${esc(a.slug)}</div>
        </a>
      `).join('')}
    </div>
  </section>
`).join('')}
`}

<footer class="footer">
  <span>LA Salon Directory · Press File · ${new Date().getFullYear()}</span>
  <span>${totalArticles.toLocaleString()} articles · ${orderedPubs.length} publishers</span>
  <span><a href="/">← Home</a></span>
</footer>
</body>
</html>`);
});

// /api/timeline.json — programmatic feed of recent enrichment events.
// Same data shape /timeline renders into HTML; useful for embeds and bots.
app.get('/api/timeline.json', async (_req, res) => {
  const limit = 100;
  const [articles, socials] = await Promise.all([
    many(`SELECT a.url, a.title, a.publisher, a.created_at AS ts,
            b.slug, COALESCE(NULLIF(b.source_data_json->>'dba_name',''), b.name) AS name,
            b.neighborhood, b.category
       FROM business_articles a JOIN businesses b ON b.id = a.business_id
      ORDER BY a.created_at DESC LIMIT $1`, [limit]),
    many(`SELECT s.platform, s.url, s.handle, s.scraped_at AS ts,
            b.slug, COALESCE(NULLIF(b.source_data_json->>'dba_name',''), b.name) AS name,
            b.neighborhood, b.category
       FROM business_socials s JOIN businesses b ON b.id = s.business_id
       WHERE s.url IS NOT NULL ORDER BY s.scraped_at DESC LIMIT $1`, [limit]),
  ]);
  const events = [
    ...articles.map(a => ({ kind:'article', ts:a.ts, slug:a.slug, name:a.name, neighborhood:a.neighborhood, category:a.category, url:a.url, title:a.title, publisher:a.publisher })),
    ...socials.map(s => ({ kind:'social', ts:s.ts, slug:s.slug, name:s.name, neighborhood:s.neighborhood, category:s.category, url:s.url, platform:s.platform, handle:s.handle })),
  ].sort((a, b) => new Date(b.ts) - new Date(a.ts)).slice(0, limit);
  res.set('content-type', 'application/json; charset=utf-8');
  res.set('access-control-allow-origin', '*');
  res.json({ ok: true, count: events.length, events });
});

// /api/best.json — programmatic feed of the data-complete /best cohort.
app.get('/api/best.json', async (_req, res) => {
  const rows = await many(
    `SELECT b.slug,
            COALESCE(NULLIF(b.source_data_json->>'dba_name',''), b.name) AS name,
            b.category, b.neighborhood, b.city, b.address, b.website,
            (b.source_data_json->>'source' = 'dca_bbcb') AS licensed,
            COUNT(DISTINCT a.id)::int AS article_count,
            COUNT(DISTINCT s.id)::int AS social_count,
            ARRAY_AGG(DISTINCT a.publisher) FILTER (WHERE a.publisher IS NOT NULL) AS publishers,
            ARRAY_AGG(DISTINCT s.platform) FILTER (WHERE s.platform IS NOT NULL) AS platforms
       FROM businesses b
       LEFT JOIN business_articles a ON a.business_id = b.id
       LEFT JOIN business_socials s  ON s.business_id  = b.id
      WHERE b.website IS NOT NULL AND b.website <> ''
      GROUP BY b.id, b.slug, b.name, b.source_data_json, b.category, b.neighborhood, b.city, b.address, b.website
     HAVING COUNT(DISTINCT a.id) >= 1 AND COUNT(DISTINCT s.id) >= 1
      ORDER BY article_count DESC, social_count DESC, name`
  );
  res.set('content-type', 'application/json; charset=utf-8');
  res.set('access-control-allow-origin', '*');
  res.json({ ok: true, count: rows.length, shops: rows });
});

// /api/biz/:slug.json — clean public JSON API for one shop. Joins
// businesses + business_socials + business_articles + business_review_sources
// into a single response. Perfect for embeddable widgets and aggregator feeds.
app.get('/api/biz/:slug.json', async (req, res) => {
  const biz = await loadBySlug(req.params.slug);
  if (!biz) return res.status(404).json({ error: 'unknown business' });
  const [socials, articles, reviewSources] = await Promise.all([
    many(`SELECT platform, handle, url FROM business_socials WHERE business_id=$1 AND url IS NOT NULL ORDER BY platform`, [biz.id]),
    many(`SELECT url, title, publisher, published_at, excerpt FROM business_articles WHERE business_id=$1 ORDER BY published_at DESC NULLS LAST, created_at DESC`, [biz.id]),
    many(`SELECT source_type, url, label FROM business_review_sources WHERE business_id=$1 AND verified=true ORDER BY source_type`, [biz.id]),
  ]);
  const dba = biz.source_data_json?.dba_name || null;
  const licensed = biz.source_data_json?.source === 'dca_bbcb';
  const corridor = biz.source_data_json?.corridor || null;
  const sole_prop = biz.source_data_json?.shop_personality === 'sole_prop';
  res.set('content-type', 'application/json; charset=utf-8');
  res.set('access-control-allow-origin', '*');  // public read
  res.json({
    ok: true,
    slug: biz.slug,
    name: biz.name,
    dba_name: dba,
    display_name: dba || biz.name,
    category: biz.category,
    address: biz.address,
    city: biz.city,
    state: biz.state,
    zip: biz.zip,
    neighborhood: biz.neighborhood,
    latitude: biz.latitude ? Number(biz.latitude) : null,
    longitude: biz.longitude ? Number(biz.longitude) : null,
    phone: biz.phone,
    website: biz.website,
    licensed,
    license_number: biz.source_data_json?.license_number || null,
    sole_proprietor: sole_prop,
    corridor,
    socials,
    articles,
    review_sources: reviewSources,
    profile_url: `/biz/${biz.slug}`,
    og_image: `/api/biz/${biz.slug}/og.svg`,
    feed_url: '/feed.xml',
  });
});

// /api/biz/:slug/og.svg — 1200×630 social-share card. Pure SVG so we don't
// need imagemagick / canvas / sharp. Twitter + FB + iMessage all rasterize
// SVG embed well. Hand-tuned to match the editorial brand.
app.get('/api/biz/:slug/og.svg', async (req, res) => {
  const biz = await loadBySlug(req.params.slug);
  if (!biz) return res.status(404).send('Not found');
  const articles = await many(
    `SELECT publisher FROM business_articles WHERE business_id=$1 LIMIT 5`,
    [biz.id]
  );
  const socials = await many(
    `SELECT platform FROM business_socials WHERE business_id=$1`,
    [biz.id]
  );

  const dba = biz.source_data_json?.dba_name || biz.name;
  const cat = String(biz.category || 'salon').toUpperCase();
  const hood = biz.neighborhood || biz.city || 'Los Angeles';
  const licensed = biz.source_data_json?.source === 'dca_bbcb';
  const escSvg = (s) => String(s == null ? '' : s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&apos;');
  // Wrap long shop names. Approximate: 22 chars per line at the chosen size.
  const wrap = (s, max) => {
    if (!s || s.length <= max) return [s || ''];
    const words = s.split(/\s+/);
    const lines = []; let cur = '';
    for (const w of words) {
      if ((cur + ' ' + w).trim().length > max) { if (cur) lines.push(cur); cur = w; }
      else cur = (cur ? cur + ' ' : '') + w;
    }
    if (cur) lines.push(cur);
    return lines.slice(0, 3); // cap at 3 lines
  };
  const titleLines = wrap(dba, 22);
  const titleY = 280 - (titleLines.length - 1) * 50;
  const pubsLine = articles.map(a => a.publisher).filter(Boolean).slice(0, 4).join(' · ');
  const socsLine = socials.map(s => s.platform).filter(Boolean).join(' · ');

  res.set('content-type', 'image/svg+xml; charset=utf-8');
  res.set('cache-control', 'public, max-age=600');
  res.send(`<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
  <defs>
    <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
      <stop offset="0%" stop-color="#f5f5f5"/>
      <stop offset="100%" stop-color="#fafaf6"/>
    </linearGradient>
  </defs>
  <rect width="1200" height="630" fill="url(#bg)"/>
  <!-- accent bar -->
  <rect x="0" y="0" width="1200" height="14" fill="#5a6e3a"/>
  <!-- brand -->
  <text x="60" y="80" font-family="Georgia, serif" font-size="22" fill="#1a1a1a" font-weight="500">LA <tspan font-style="italic" fill="#5a6e3a">Salon</tspan> Directory</text>
  <!-- Eyebrow -->
  <text x="60" y="170" font-family="ui-monospace, Menlo, monospace" font-size="18" fill="#5a6e3a" letter-spacing="3" font-weight="700">${escSvg(cat)} · ${escSvg(hood.toUpperCase())}</text>
  <!-- Title -->
  ${titleLines.map((line, i) => `<text x="60" y="${titleY + i * 100}" font-family="Georgia, serif" font-size="96" fill="#1a1a1a" font-weight="500" letter-spacing="-3">${escSvg(line)}</text>`).join('')}
  <!-- chips bar -->
  <g transform="translate(60, 470)">
    ${licensed ? `<rect x="0" y="0" width="180" height="46" fill="#b8945a" stroke="#1a1a1a" stroke-width="2"/>
    <text x="90" y="31" text-anchor="middle" font-family="ui-monospace, monospace" font-size="14" font-weight="700" fill="#1a1a1a" letter-spacing="2">◆ LICENSED</text>` : ''}
    ${articles.length ? `<rect x="${licensed ? 200 : 0}" y="0" width="160" height="46" fill="#1a1a1a"/>
    <text x="${licensed ? 280 : 80}" y="31" text-anchor="middle" font-family="ui-monospace, monospace" font-size="14" font-weight="700" fill="#facc15" letter-spacing="2">📰 ${articles.length} PRESS</text>` : ''}
    ${socials.length ? `<rect x="${(licensed?200:0) + (articles.length?180:0)}" y="0" width="180" height="46" fill="#fff" stroke="#1a1a1a" stroke-width="2"/>
    <text x="${(licensed?290:90) + (articles.length?180:0)}" y="31" text-anchor="middle" font-family="ui-monospace, monospace" font-size="14" font-weight="700" fill="#1a1a1a" letter-spacing="2">📷 ${socials.length} SOCIAL${socials.length===1?'':'S'}</text>` : ''}
  </g>
  <!-- pubs -->
  ${pubsLine ? `<text x="60" y="568" font-family="ui-monospace, monospace" font-size="16" fill="#5a6e3a" letter-spacing="2">PRESS · ${escSvg(pubsLine)}</text>` : ''}
  <!-- footer URL -->
  <text x="60" y="600" font-family="ui-monospace, monospace" font-size="14" fill="#7a766f" letter-spacing="2">la-salon-directory · /biz/${escSvg(biz.slug)}</text>
  <!-- right-hand decorative seal (rotates "LICENSED" in a ring or just brand mark) -->
  <g transform="translate(1040, 100)">
    <circle r="80" fill="none" stroke="#5a6e3a" stroke-width="3" opacity="0.4"/>
    <text x="0" y="-20" text-anchor="middle" font-family="ui-monospace, monospace" font-size="11" letter-spacing="3" fill="#5a6e3a" font-weight="700">EST.</text>
    <text x="0" y="8" text-anchor="middle" font-family="Georgia, serif" font-size="34" font-style="italic" fill="#5a6e3a" font-weight="500">${new Date().getFullYear()}</text>
    <text x="0" y="28" text-anchor="middle" font-family="ui-monospace, monospace" font-size="9" letter-spacing="2" fill="#5a6e3a">DIRECTORY</text>
  </g>
</svg>`);
});

// /loop.log — serve the loop-tick log over HTTP so Steve can stream it from
// any machine (MacBookPro, phone, etc.) without ssh'ing in. Plain text.
//   curl -Ns http://localhost:9760/loop.log     — last 200 lines, then live tail
//   curl -s  http://localhost:9760/loop.log?n=50 — just last 50 lines
app.get('/loop.log', async (req, res) => {
  const n = Math.min(2000, Math.max(10, parseInt(req.query.n || '200', 10)));
  const live = req.query.live !== '0';
  const fsMod = await import('node:fs');
  const path = await import('node:path');
  const logPath = path.join(__dirname, '..', '..', 'logs', 'loop.log');
  res.set('content-type', 'text/plain; charset=utf-8');
  res.set('access-control-allow-origin', '*');
  // Initial dump of last N lines.
  let lastSize = 0;
  try {
    const data = fsMod.readFileSync(logPath, 'utf8');
    const lines = data.split('\n').filter(Boolean);
    res.write(lines.slice(-n).join('\n') + '\n');
    lastSize = Buffer.byteLength(data);
  } catch {
    res.write('# log not found yet — first tick will create it\n');
  }
  if (!live) return res.end();
  // Live tail: poll every 1.5s, push appended bytes only.
  res.write('# --- live tail ---\n');
  const interval = setInterval(() => {
    try {
      const stat = fsMod.statSync(logPath);
      if (stat.size > lastSize) {
        const fd = fsMod.openSync(logPath, 'r');
        const buf = Buffer.alloc(stat.size - lastSize);
        fsMod.readSync(fd, buf, 0, buf.length, lastSize);
        fsMod.closeSync(fd);
        res.write(buf.toString('utf8'));
        lastSize = stat.size;
      } else if (stat.size < lastSize) {
        // Log was rotated; reset.
        lastSize = 0;
      }
    } catch (_) {}
  }, 1500);
  req.on('close', () => clearInterval(interval));
});

// /leaderboard — ranked feed of every shop with at least one signal,
// ordered by SQL-approximated score. Same 14-signal rubric as /analyze
// but computed in SQL for speed (instead of Node-side per-row).
//   /leaderboard                        — top 200 by score
//   /leaderboard?hood=Beverly+Hills     — neighborhood-scoped
//   /leaderboard?cat=barbershop         — category-scoped
//   /leaderboard?from=200&to=400        — pagination
// /api/leaderboard.json — programmatic feed of the SQL-scored leaderboard.
// Same scoring as /leaderboard but JSON shape. Supports ?hood=X&cat=Y&limit=N.
app.get('/api/leaderboard.json', async (req, res) => {
  const hood = req.query.hood ? String(req.query.hood).slice(0, 80) : null;
  const cat = req.query.cat ? String(req.query.cat).slice(0, 40) : null;
  const limit = Math.min(500, Math.max(1, parseInt(req.query.limit || '100', 10)));
  const params = [];
  let where = '1=1';
  if (hood) { params.push(hood); where += ` AND b.neighborhood = $${params.length}`; }
  if (cat)  { params.push(cat);  where += ` AND b.category = $${params.length}`; }
  const rows = await many(
    `WITH agg AS (
       SELECT b.id, b.slug,
              COALESCE(NULLIF(b.source_data_json->>'dba_name',''), b.name) AS name,
              b.category, b.neighborhood, b.website,
              (b.source_data_json->>'source' = 'dca_bbcb')          AS licensed,
              (b.source_data_json->>'dba_name' IS NOT NULL)         AS has_dba,
              COUNT(DISTINCT a.id)                                   AS article_count,
              BOOL_OR(s.platform = 'instagram')                      AS has_ig,
              BOOL_OR(s.platform = 'yelp')                           AS has_yelp,
              BOOL_OR(s.platform = 'facebook')                       AS has_fb,
              BOOL_OR(s.platform = 'tiktok')                         AS has_tiktok,
              EXISTS (SELECT 1 FROM business_review_sources r WHERE r.business_id = b.id AND r.verified) AS has_review,
              ARRAY_AGG(DISTINCT a.publisher) FILTER (WHERE a.publisher IS NOT NULL) AS publishers
         FROM businesses b
         LEFT JOIN business_articles a ON a.business_id = b.id
         LEFT JOIN business_socials s  ON s.business_id  = b.id AND s.url IS NOT NULL
        WHERE ${where}
        GROUP BY b.id
     )
     SELECT *,
            (CASE WHEN website IS NOT NULL AND website <> ''  THEN 15 ELSE 0 END
           + CASE WHEN article_count >= 1                     THEN 14 ELSE 0 END
           + CASE WHEN has_ig                                 THEN 10 ELSE 0 END
           + CASE WHEN has_yelp                               THEN  9 ELSE 0 END
           + CASE WHEN licensed                               THEN  8 ELSE 0 END
           + CASE WHEN has_dba                                THEN  5 ELSE 0 END
           + CASE WHEN has_fb                                 THEN  5 ELSE 0 END
           + CASE WHEN has_tiktok                             THEN  5 ELSE 0 END
           + CASE WHEN has_review                             THEN  5 ELSE 0 END
           )::int AS score
       FROM agg
      ORDER BY score DESC, article_count DESC, name
      LIMIT ${limit}`,
    params
  );
  res.set('content-type', 'application/json; charset=utf-8');
  res.set('access-control-allow-origin', '*');
  res.json({ ok: true, count: rows.length, hood, category: cat, shops: rows });
});

// /leaderboard/by-category — at-a-glance view: top 5 by score in each
// of the major categories. Useful as a competitive scan without filtering
// the main leaderboard category-by-category.
app.get('/leaderboard/by-category', async (_req, res) => {
  // Compute scored top-5 per category in one query.
  const rows = await many(
    `WITH agg AS (
       SELECT b.id, b.slug,
              COALESCE(NULLIF(b.source_data_json->>'dba_name',''), b.name) AS name,
              b.category, b.neighborhood, b.address, b.website,
              (b.source_data_json->>'source' = 'dca_bbcb')          AS licensed,
              (b.source_data_json->>'dba_name' IS NOT NULL)         AS has_dba,
              COUNT(DISTINCT a.id)                                   AS article_count,
              BOOL_OR(s.platform = 'instagram')                      AS has_ig,
              BOOL_OR(s.platform = 'yelp')                           AS has_yelp,
              BOOL_OR(s.platform = 'facebook')                       AS has_fb,
              BOOL_OR(s.platform = 'tiktok')                         AS has_tiktok,
              EXISTS (SELECT 1 FROM business_review_sources r WHERE r.business_id = b.id AND r.verified) AS has_review,
              ARRAY_AGG(DISTINCT a.publisher) FILTER (WHERE a.publisher IS NOT NULL) AS publishers
         FROM businesses b
         LEFT JOIN business_articles a ON a.business_id = b.id
         LEFT JOIN business_socials s  ON s.business_id  = b.id AND s.url IS NOT NULL
        WHERE b.category IS NOT NULL
        GROUP BY b.id
     ),
     scored AS (
       SELECT *,
              (CASE WHEN website IS NOT NULL AND website <> ''  THEN 15 ELSE 0 END
             + CASE WHEN article_count >= 1                     THEN 14 ELSE 0 END
             + CASE WHEN has_ig                                 THEN 10 ELSE 0 END
             + CASE WHEN has_yelp                               THEN  9 ELSE 0 END
             + CASE WHEN licensed                               THEN  8 ELSE 0 END
             + CASE WHEN address IS NOT NULL AND address <> '' THEN  6 ELSE 0 END
             + CASE WHEN has_dba                                THEN  5 ELSE 0 END
             + CASE WHEN has_fb                                 THEN  5 ELSE 0 END
             + CASE WHEN has_tiktok                             THEN  5 ELSE 0 END
             + CASE WHEN has_review                             THEN  5 ELSE 0 END
             )::int AS score,
              ROW_NUMBER() OVER (PARTITION BY category ORDER BY
                CASE WHEN website IS NOT NULL AND website <> ''  THEN 15 ELSE 0 END
              + CASE WHEN article_count >= 1                     THEN 14 ELSE 0 END
              + CASE WHEN has_ig                                 THEN 10 ELSE 0 END
              + CASE WHEN has_yelp                               THEN  9 ELSE 0 END
              + CASE WHEN licensed                               THEN  8 ELSE 0 END
              + CASE WHEN address IS NOT NULL AND address <> '' THEN  6 ELSE 0 END DESC,
                article_count DESC, name) AS rnk
         FROM agg
     )
     SELECT * FROM scored WHERE rnk <= 5
      ORDER BY category, rnk`
  );
  // Group by category for the rendered layout.
  const byCat = new Map();
  for (const r of rows) {
    if (!byCat.has(r.category)) byCat.set(r.category, []);
    byCat.get(r.category).push(r);
  }
  // Sort categories by total shop count (more = featured first).
  const catSizes = await many(
    `SELECT category, COUNT(*)::int AS n FROM businesses
      WHERE category IS NOT NULL GROUP BY category ORDER BY n DESC`
  );
  const ordered = catSizes.filter(c => byCat.has(c.category)).map(c => [c.category, byCat.get(c.category), c.n]);

  const safe = (s) => String(s == null ? '' : s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
  const colorFor = (score) => score >= 70 ? '#22c55e' : score >= 50 ? '#84cc16' : score >= 30 ? '#facc15' : score >= 15 ? '#f97316' : '#dc2626';

  res.set('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>Leaderboard by Category · LA Salon Directory</title>
<meta name="description" content="Top 5 highest-scoring shops in each category — barbershops, salons, spas, nails, and more.">
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600;700&family=Inter:wght@300;400;500;600&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
<style>:root{--ink:#1a1a1a;--paper:#f5f5f5;--rule:#e6e3dc;--mute:#7a766f;--accent:#5a6e3a;--gold:#b8945a}*{box-sizing:border-box;margin:0;padding:0}body{background:var(--paper);color:var(--ink);font-family:Inter,system-ui;-webkit-font-smoothing:antialiased;line-height:1.45}a{color:inherit;text-decoration:none}.topbar{padding:18px 32px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--rule);position:sticky;top:0;background:var(--paper);z-index:60}.brand{font-family:Cormorant Garamond,serif;font-size:20px;font-weight:500}.brand em{color:var(--accent);font-style:italic}.topnav{display:flex;gap:18px;font-family:Space Mono,monospace;font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute)}.hero{padding:64px 32px 32px;border-bottom:1px solid var(--rule);max-width:1240px;margin:0 auto}.hero-eyebrow{font-family:Space Mono,monospace;font-size:11px;letter-spacing:.22em;text-transform:uppercase;color:var(--accent);margin-bottom:18px}h1{font-family:Cormorant Garamond,serif;font-weight:500;font-size:clamp(40px,6vw,72px);line-height:.96;letter-spacing:-.02em;margin-bottom:14px}h1 em{font-style:italic;color:var(--accent)}.cat-section{padding:48px 32px;max-width:1240px;margin:0 auto;border-top:1px solid var(--rule)}.cat-head{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:18px;padding-bottom:10px;border-bottom:1px solid var(--rule)}.cat-head h2{font-family:Cormorant Garamond,serif;font-weight:500;font-size:36px;text-transform:capitalize;letter-spacing:-.015em}.cat-head .meta{font-family:Space Mono,monospace;font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute)}.cat-head .more{color:var(--accent);font-family:Space Mono,monospace;font-size:11px;letter-spacing:.06em;text-transform:uppercase;text-decoration:underline}.top5{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:1px;background:var(--rule);border:1px solid var(--rule)}.spot{background:var(--paper);padding:18px 20px;display:flex;flex-direction:column;gap:6px;transition:background 200ms}.spot:hover{background:#fff}.spot-rank{font-family:Cormorant Garamond,serif;font-style:italic;font-size:36px;color:var(--accent);font-weight:500;line-height:1;letter-spacing:-.02em}.spot-name{font-family:Cormorant Garamond,serif;font-size:20px;font-weight:500;line-height:1.15;letter-spacing:-.005em}.spot-where{font-family:Space Mono,monospace;font-size:10px;color:var(--mute);text-transform:uppercase;letter-spacing:.06em}.spot-score{display:inline-flex;align-items:center;gap:6px;font-family:Space Mono,monospace;font-size:11px;letter-spacing:.04em;font-weight:700;color:#000;padding:2px 8px;width:fit-content}.spot-pubs{font-family:Space Mono,monospace;font-size:10px;color:var(--accent);letter-spacing:.04em;line-height:1.5;margin-top:2px}.empty{padding:48px;font-family:Cormorant Garamond,serif;font-style:italic;color:var(--mute);text-align:center}.footer{padding:48px 32px;font-family:Space Mono,monospace;font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--mute);text-align:center;border-top:1px solid var(--rule)}</style></head>
<body>
<header class="topbar">
  <div class="brand"><a href="/">LA <em>Salon</em> Directory</a> <span style="color:var(--mute);font-style:italic">/ leaderboard / by category</span></div>
  <nav class="topnav">
    <a href="/grades">Full leaderboard</a>
    <a href="/best">★ Best</a>
    <a href="/timeline">Live Feed</a>
    <a href="/">Home</a>
  </nav>
</header>
<section class="hero">
  <div class="hero-eyebrow">Top 5 per category · ranked by 14-signal score</div>
  <h1>The <em>category</em> leaders.</h1>
  <p style="font-size:16px;color:#3a3a3a;max-width:60ch">Pick your battle. The shops with the strongest SEO + press + social profile in each service vertical.</p>
</section>
${ordered.length === 0 ? '<div class="empty">No data yet.</div>' : ordered.map(([cat, list, total]) => `
  <section class="cat-section" id="${safe(cat)}">
    <div class="cat-head">
      <h2>${safe(cat)}</h2>
      <span class="meta">${list.length} top of ${total.toLocaleString()} ${safe(cat)}s · <a class="more" href="/leaderboard?cat=${encodeURIComponent(cat)}">full ranking →</a></span>
    </div>
    <div class="top5">
      ${list.map(s => `
        <a class="spot" href="/biz/${safe(s.slug)}/analyze">
          <div class="spot-rank">№ ${s.rnk}</div>
          <div class="spot-name">${safe(s.name)}</div>
          <div class="spot-where">${safe(s.neighborhood || s.address || '')}</div>
          <div class="spot-score" style="background:${colorFor(s.score)}">${s.score} · ${s.score >= 70 ? 'A' : s.score >= 50 ? 'B' : s.score >= 30 ? 'C' : s.score >= 15 ? 'D' : 'F'}</div>
          ${s.publishers && s.publishers.length ? `<div class="spot-pubs">📰 ${s.publishers.slice(0, 2).map(p => safe(p)).join(' · ')}${s.publishers.length > 2 ? ' +' + (s.publishers.length - 2) : ''}</div>` : ''}
        </a>
      `).join('')}
    </div>
  </section>
`).join('')}
<footer class="footer"><a href="/" style="color:var(--accent)">← Home</a></footer>
</body></html>`);
});

app.get('/leaderboard', async (req, res) => {
  const hood = req.query.hood ? String(req.query.hood).slice(0, 80) : null;
  const cat = req.query.cat ? String(req.query.cat).slice(0, 40) : null;
  const from = Math.max(0, parseInt(req.query.from || '0', 10));
  const to = Math.min(from + 500, parseInt(req.query.to || (from + 200).toString(), 10));
  const limit = Math.min(500, to - from);
  const params = [];
  let where = '1=1';
  if (hood) { params.push(hood); where += ` AND b.neighborhood = $${params.length}`; }
  if (cat)  { params.push(cat);  where += ` AND b.category = $${params.length}`; }
  const rows = await many(
    `WITH agg AS (
       SELECT b.id, b.slug,
              COALESCE(NULLIF(b.source_data_json->>'dba_name',''), b.name) AS name,
              b.category, b.neighborhood, b.city, b.address, b.website,
              b.latitude, b.longitude, b.phone, b.email, b.about_text,
              (b.source_data_json->>'source' = 'dca_bbcb')          AS licensed,
              (b.source_data_json->>'dba_name' IS NOT NULL)         AS has_dba,
              COUNT(DISTINCT a.id)                                   AS article_count,
              BOOL_OR(s.platform = 'instagram')                      AS has_ig,
              BOOL_OR(s.platform = 'yelp')                           AS has_yelp,
              BOOL_OR(s.platform = 'facebook')                       AS has_fb,
              BOOL_OR(s.platform = 'tiktok')                         AS has_tiktok,
              EXISTS (SELECT 1 FROM business_review_sources r WHERE r.business_id = b.id AND r.verified)
                                                                     AS has_review,
              COUNT(DISTINCT s.id)                                   AS social_count,
              ARRAY_AGG(DISTINCT a.publisher) FILTER (WHERE a.publisher IS NOT NULL) AS publishers
         FROM businesses b
         LEFT JOIN business_articles a ON a.business_id = b.id
         LEFT JOIN business_socials s  ON s.business_id  = b.id AND s.url IS NOT NULL
        WHERE ${where}
        GROUP BY b.id
     )
     SELECT *,
            (CASE WHEN website IS NOT NULL AND website <> ''  THEN 15 ELSE 0 END
           + CASE WHEN article_count >= 1                     THEN 14 ELSE 0 END
           + CASE WHEN has_ig                                 THEN 10 ELSE 0 END
           + CASE WHEN has_yelp                               THEN  9 ELSE 0 END
           + CASE WHEN licensed                               THEN  8 ELSE 0 END
           + CASE WHEN address IS NOT NULL AND address <> '' THEN  6 ELSE 0 END
           + CASE WHEN phone IS NOT NULL AND phone <> ''     THEN  5 ELSE 0 END
           + CASE WHEN latitude IS NOT NULL                  THEN  5 ELSE 0 END
           + CASE WHEN has_dba                                THEN  5 ELSE 0 END
           + CASE WHEN has_fb                                 THEN  5 ELSE 0 END
           + CASE WHEN has_tiktok                             THEN  5 ELSE 0 END
           + CASE WHEN has_review                             THEN  5 ELSE 0 END
           + CASE WHEN email IS NOT NULL AND email <> ''     THEN  4 ELSE 0 END
           + CASE WHEN about_text IS NOT NULL AND about_text <> '' THEN  4 ELSE 0 END
           )::int AS score
       FROM agg
      ORDER BY score DESC, article_count DESC, social_count DESC, name
      LIMIT ${limit} OFFSET ${from}`,
    params
  );
  const totalQ = await one(
    `SELECT COUNT(*)::int AS n FROM businesses b WHERE ${where}`,
    params
  );

  const safe = (s) => String(s == null ? '' : s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
  const colorFor = (score) => score >= 80 ? '#22c55e' : score >= 60 ? '#84cc16' : score >= 40 ? '#facc15' : score >= 20 ? '#f97316' : '#dc2626';
  const labelFor = (score) => score >= 80 ? 'A' : score >= 60 ? 'B' : score >= 40 ? 'C' : score >= 20 ? 'D' : 'F';

  res.set('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>Leaderboard · LA Salon Directory${hood ? ' · ' + safe(hood) : ''}${cat ? ' · ' + safe(cat) : ''}</title>
<meta name="description" content="${rows.length} top-scoring LA salons + barbers ranked by SEO + quality + press signals${hood ? ' in ' + safe(hood) : ''}.">
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600;700&family=Inter:wght@300;400;500;600&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
<style>:root{--ink:#1a1a1a;--paper:#f5f5f5;--rule:#e6e3dc;--mute:#7a766f;--accent:#5a6e3a;--gold:#b8945a}*{box-sizing:border-box;margin:0;padding:0}body{background:var(--paper);color:var(--ink);font-family:Inter,system-ui;-webkit-font-smoothing:antialiased;line-height:1.45}a{color:inherit;text-decoration:none}.topbar{padding:18px 32px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--rule);position:sticky;top:0;background:var(--paper);z-index:60;flex-wrap:wrap;gap:12px}.brand{font-family:Cormorant Garamond,serif;font-size:20px;font-weight:500}.brand em{color:var(--accent);font-style:italic}.topnav{display:flex;gap:18px;font-family:Space Mono,monospace;font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute)}.hero{padding:64px 32px 32px;border-bottom:1px solid var(--rule);max-width:1240px;margin:0 auto}.hero-eyebrow{font-family:Space Mono,monospace;font-size:11px;letter-spacing:.22em;text-transform:uppercase;color:var(--accent);margin-bottom:18px}h1{font-family:Cormorant Garamond,serif;font-weight:500;font-size:clamp(40px,6vw,72px);line-height:.96;letter-spacing:-.02em;margin-bottom:14px}h1 em{font-style:italic;color:var(--accent)}.lede{font-size:16px;color:#3a3a3a;max-width:60ch}.filter-row{padding:24px 32px;max-width:1240px;margin:0 auto;display:flex;gap:8px;flex-wrap:wrap;font-family:Space Mono,monospace;font-size:11px;letter-spacing:.06em;text-transform:uppercase;align-items:center}.filter-row form{display:flex;gap:6px;align-items:center;flex-wrap:wrap}.filter-row select,.filter-row input{padding:8px 12px;border:1px solid var(--rule);background:#fff;font-family:inherit;font-size:11px;font-weight:700;text-transform:uppercase}.filter-row button{background:#1a1a1a;color:#facc15;border:none;padding:8px 14px;font-family:inherit;font-size:11px;letter-spacing:.18em;text-transform:uppercase;font-weight:700;cursor:pointer}.filter-row .clear{color:var(--mute);text-decoration:underline}.list{max-width:1240px;margin:0 auto;padding:24px 32px}.row{display:grid;grid-template-columns:60px 80px 1fr 240px 110px;gap:16px;align-items:center;padding:14px;border-bottom:1px solid var(--rule);transition:background 200ms ease}.row:hover{background:#fff}.rank{font-family:Cormorant Garamond,serif;font-style:italic;font-size:24px;color:var(--mute);font-weight:500;letter-spacing:-.02em;text-align:center}.score-pill{display:flex;flex-direction:column;align-items:center;gap:0}.score-pill .v{font-family:Cormorant Garamond,serif;font-style:italic;font-size:32px;font-weight:500;letter-spacing:-.02em;line-height:1}.score-pill .grade{font-family:Space Mono,monospace;font-size:10px;font-weight:700;letter-spacing:.16em;color:#000;padding:1px 8px;border:1px solid #000;margin-top:4px}.shop-info{min-width:0}.shop-info .name{font-family:Cormorant Garamond,serif;font-size:22px;font-weight:500;line-height:1.15;letter-spacing:-.005em}.shop-info .meta{font-family:Space Mono,monospace;font-size:10px;color:var(--mute);text-transform:uppercase;letter-spacing:.06em;margin-top:4px}.shop-info .pubs{font-family:Space Mono,monospace;font-size:10px;color:var(--accent);margin-top:4px;letter-spacing:.04em;line-height:1.5}.signals{display:flex;flex-wrap:wrap;gap:4px;font-family:Space Mono,monospace;font-size:9px;letter-spacing:.04em;text-transform:uppercase}.signals span{padding:2px 6px;border:1px solid var(--rule);background:#fff;font-weight:600}.signals span.on{background:var(--accent);color:#fff;border-color:var(--accent)}.signals span.lic{background:var(--gold);color:#000;border-color:#000}.actions{display:flex;flex-direction:column;gap:4px;align-items:flex-end}.actions a{font-family:Space Mono,monospace;font-size:10px;letter-spacing:.04em;text-transform:uppercase;color:var(--accent);text-align:right}.empty{padding:96px 32px;text-align:center;color:var(--mute);font-family:Cormorant Garamond,serif;font-style:italic;font-size:24px}@media(max-width:880px){.row{grid-template-columns:auto auto 1fr;gap:8px}.signals,.actions{grid-column:1/-1}}.footer{padding:48px 32px;font-family:Space Mono,monospace;font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--mute);text-align:center;border-top:1px solid var(--rule)}.pager{padding:24px;text-align:center;display:flex;gap:12px;justify-content:center;font-family:Space Mono,monospace;font-size:11px}.pager a{padding:8px 16px;border:1px solid var(--rule);background:#fff;color:var(--ink);font-weight:700;letter-spacing:.06em;text-transform:uppercase}.pager a:hover{border-color:var(--accent);color:var(--accent)}</style></head>
<body>
<header class="topbar">
  <div class="brand"><a href="/">LA <em>Salon</em> Directory</a> <span style="color:var(--mute);font-style:italic">/ leaderboard${hood ? ' / ' + safe(hood) : ''}${cat ? ' / ' + safe(cat) : ''}</span></div>
  <nav class="topnav">
    <a href="/best">★ Best</a>
    <a href="/timeline">Live Feed</a>
    <a href="/stats">Stats</a>
    <a href="/">Home</a>
  </nav>
</header>
<section class="hero">
  <div class="hero-eyebrow">Ranked by 14-signal SEO + quality score · 0–100</div>
  <h1>The <em>leaderboard.</em></h1>
  <p class="lede">Every shop scored on the same 14 weighted signals as <a href="/biz/moraga-bernie-93010/analyze" style="color:var(--accent);text-decoration:underline">/analyze</a>. Higher = more press, more socials, more presence. Click any shop to see the breakdown + suggestions to climb the ranking.</p>
</section>
<section class="filter-row">
  <form method="get">
    <label style="color:var(--mute)">Hood:</label>
    <input type="text" name="hood" value="${safe(hood || '')}" placeholder="e.g. Beverly Hills" autocomplete="off">
    <label style="color:var(--mute);margin-left:8px">Cat:</label>
    <select name="cat">
      <option value="">all</option>
      ${['barbershop','salon','spa','nail','restaurant','retail','cafe','bar','health','office'].map(c => `<option value="${c}" ${cat === c ? 'selected' : ''}>${c}</option>`).join('')}
    </select>
    <button type="submit">Filter</button>
  </form>
  ${hood || cat ? '<a class="clear" href="/grades">Clear filter</a>' : ''}
  <span style="margin-left:auto;color:var(--mute)">${rows.length.toLocaleString()} of ${totalQ.n.toLocaleString()} shops${hood || cat ? ' · filtered' : ''}</span>
</section>
${rows.length === 0 ? `<div class="empty">No matches. Try clearing filters.</div>` : `
<section class="list">
  ${rows.map((r, i) => {
    const grade = labelFor(r.score);
    const color = colorFor(r.score);
    const signals = [
      { on: !!(r.website && r.website.trim()), label: '🌐 Web' },
      { on: r.article_count >= 1,              label: `📰 ${r.article_count}` },
      { on: !!r.has_ig,                        label: '📷 IG' },
      { on: !!r.has_yelp,                      label: '⭐ Yelp' },
      { on: !!r.licensed,                      label: '◆ Lic',     cls: 'lic' },
      { on: !!r.has_dba,                       label: 'DBA' },
      { on: !!r.has_fb,                        label: '👤 FB' },
      { on: r.latitude != null,                label: '📍 Geo' },
    ];
    return `<a class="row" href="/biz/${safe(r.slug)}/analyze">
      <span class="rank">${(from + i + 1).toLocaleString()}</span>
      <div class="score-pill">
        <span class="v" style="color:${color}">${r.score}</span>
        <span class="grade" style="background:${color}">${grade}</span>
      </div>
      <div class="shop-info">
        <div class="name">${safe(r.name)}</div>
        <div class="meta">${safe(r.category || 'business')}${r.neighborhood ? ' · ' + safe(r.neighborhood) : ''}${r.address ? ' · ' + safe(r.address) : ''}</div>
        ${r.publishers && r.publishers.length ? `<div class="pubs">📰 ${r.publishers.slice(0, 3).map(p => safe(p)).join(' · ')}${r.publishers.length > 3 ? ' +' + (r.publishers.length - 3) : ''}</div>` : ''}
      </div>
      <div class="signals">
        ${signals.filter(s => s.on).map(s => `<span class="on ${s.cls || ''}">${s.label}</span>`).join('')}
      </div>
      <div class="actions">
        <span style="color:var(--accent);font-weight:600">analyze →</span>
        ${r.website ? `<span style="font-size:9px;color:var(--mute)">${safe(r.website.replace(/^https?:\/\/(www\.)?/, '').slice(0, 26))}</span>` : ''}
      </div>
    </a>`;
  }).join('')}
</section>
<div class="pager">
  ${from > 0 ? `<a href="?${hood ? 'hood=' + encodeURIComponent(hood) + '&' : ''}${cat ? 'cat=' + encodeURIComponent(cat) + '&' : ''}from=${Math.max(0, from - 200)}&to=${from}">← Previous 200</a>` : ''}
  ${rows.length === limit ? `<a href="?${hood ? 'hood=' + encodeURIComponent(hood) + '&' : ''}${cat ? 'cat=' + encodeURIComponent(cat) + '&' : ''}from=${to}&to=${to + 200}">Next 200 →</a>` : ''}
</div>
`}
<footer class="footer">
  <a href="/" style="color:var(--accent)">← Home</a>
  · ${rows.length.toLocaleString()} shops scored · live data
</footer>
</body></html>`);
});

// =====================================================================
// /biz/:slug/analyze — heuristic SEO + quality + leader-comparison report
// =====================================================================
// Score out of 100 across 14 weighted signals. Compares each shop to top
// leaders in same neighborhood + category. Suggestions ranked by impact.

const SCORE_RUBRIC = [
  { key:'has_website',  weight:15, check:b => !!(b.website && b.website.trim()),                    label:'Website',         tip:"Get a Squarespace or Wix site (~$15/mo). Even a 1-page site beats a Yelp-only listing for trust + Google ranking." },
  { key:'has_press',    weight:14, check:(_, ctx) => ctx.articleCount >= 1,                         label:'Press mention',   tip:"Pitch one local writer (LA Times, Eater LA, LAmag, Voyage LA, Hoodline). One feature compounds — it drives all your other SEO." },
  { key:'has_ig',       weight:10, check:(_, ctx) => ctx.socialPlatforms.has('instagram'),          label:'Instagram',       tip:"Claim an IG handle today. Post 3 cuts/looks per week. Costs nothing, doubles discovery for under-30 customers." },
  { key:'has_yelp',     weight:9,  check:(_, ctx) => ctx.socialPlatforms.has('yelp'),               label:'Yelp page',       tip:"Claim your Yelp listing (free). Add 5 photos + business hours. Most LA salon discovery still happens here." },
  { key:'licensed',     weight:8,  check:b => b.source_data_json?.source === 'dca_bbcb',            label:'State-licensed',  tip:"Make sure your BBCB establishment license is current — public verify badge on your profile only shows when registered with the state board." },
  { key:'has_address',  weight:6,  check:b => !!(b.address && b.address.trim()),                    label:'Physical address',tip:"Address must be on the profile, on Google Maps, on Yelp, on IG bio. Customers verify before they book." },
  { key:'has_phone',    weight:5,  check:b => !!(b.phone && String(b.phone).trim()),                label:'Phone',           tip:"Add a tappable phone number. Mobile users won't fight a contact form for a haircut booking." },
  { key:'geocoded',     weight:5,  check:b => b.latitude != null && b.longitude != null,            label:'Map pin',         tip:"Address must geocode cleanly. Double-check the suite/unit number — Apple Maps + Google Maps need an exact match." },
  { key:'has_dba',      weight:5,  check:b => !!(b.source_data_json?.dba_name),                     label:'Public brand',    tip:"If license reads as 'LASTNAME FIRSTNAME', your public brand should match across Yelp + IG + Google Maps." },
  { key:'has_fb',       weight:5,  check:(_, ctx) => ctx.socialPlatforms.has('facebook'),           label:'Facebook page',   tip:"Older customers search FB first. Free, 10-min setup, shows up in Google rich-card results." },
  { key:'has_tiktok',   weight:5,  check:(_, ctx) => ctx.socialPlatforms.has('tiktok'),             label:'TikTok',          tip:"For under-30: TikTok > IG. Post a haircut transformation video weekly. Algo will hand you free local discovery." },
  { key:'has_review',   weight:5,  check:(_, ctx) => ctx.reviewCount >= 1,                          label:'Community link',  tip:"Submit a Reddit/Discord/community link via /biz/{slug}/links — opt-in, free, surfaces you in the local community feed." },
  { key:'has_email',    weight:4,  check:b => !!(b.email && String(b.email).trim()),                label:'Booking email',   tip:"Add a public email so press + influencers can reach you for collaborations." },
  { key:'has_about',    weight:4,  check:b => !!(b.about_text && String(b.about_text).trim()),      label:'About paragraph', tip:"Write 2-3 sentences about your story. AI tools (Google AI Overview, ChatGPT) only quote shops with a real 'about' passage." },
];

app.get('/biz/:slug/analyze', async (req, res) => {
  const biz = await loadBySlug(req.params.slug);
  if (!biz) return res.status(404).send('Not found');

  const [socials, articles, reviewSources, pricing, hoodLeaders, hoodMedians] = await Promise.all([
    many(`SELECT platform, handle, url FROM business_socials WHERE business_id=$1 AND url IS NOT NULL`, [biz.id]),
    many(`SELECT url, title, publisher FROM business_articles WHERE business_id=$1 ORDER BY published_at DESC NULLS LAST LIMIT 10`, [biz.id]),
    many(`SELECT source_type, url FROM business_review_sources WHERE business_id=$1 AND verified=true`, [biz.id]),
    many(`SELECT service_kind, price_low, price_high, notes FROM business_pricing WHERE business_id=$1`, [biz.id]),
    biz.neighborhood ? many(
      `SELECT b.slug,
              COALESCE(NULLIF(b.source_data_json->>'dba_name',''), b.name) AS name,
              b.category,
              COUNT(DISTINCT a.id)::int AS article_count,
              COUNT(DISTINCT s.id)::int AS social_count,
              ARRAY_AGG(DISTINCT a.publisher) FILTER (WHERE a.publisher IS NOT NULL) AS publishers
         FROM businesses b
         LEFT JOIN business_articles a ON a.business_id = b.id
         LEFT JOIN business_socials s ON s.business_id = b.id AND s.url IS NOT NULL
        WHERE b.neighborhood = $1 AND b.id <> $2
          AND (b.category = $3 OR $3 IS NULL)
        GROUP BY b.id, b.slug, b.name, b.source_data_json, b.category
       HAVING COUNT(DISTINCT a.id) > 0 OR COUNT(DISTINCT s.id) >= 2
        ORDER BY article_count DESC, social_count DESC
        LIMIT 5`,
      [biz.neighborhood, biz.id, biz.category]
    ) : Promise.resolve([]),
    biz.neighborhood ? many(
      `SELECT p.service_kind,
              percentile_cont(0.5) WITHIN GROUP (ORDER BY p.price_low) AS median_low,
              MIN(p.price_low) AS min_low, MAX(p.price_high) AS max_high,
              COUNT(*)::int AS sample_size
         FROM business_pricing p
         JOIN businesses b ON b.id = p.business_id
        WHERE b.neighborhood = $1 AND (b.category = $2 OR $2 IS NULL)
        GROUP BY p.service_kind`,
      [biz.neighborhood, biz.category]
    ) : Promise.resolve([]),
  ]);

  const ctx = {
    articleCount:  articles.length,
    socialPlatforms: new Set(socials.map(s => s.platform)),
    reviewCount:   reviewSources.length,
  };
  let totalScore = 0;
  const breakdown = SCORE_RUBRIC.map(r => {
    const passed = r.check(biz, ctx);
    if (passed) totalScore += r.weight;
    return { ...r, passed };
  });
  const suggestions = breakdown.filter(b => !b.passed).sort((a, b) => b.weight - a.weight);
  const leaderArticleAvg = hoodLeaders.length ? Math.round(hoodLeaders.reduce((s, l) => s + l.article_count, 0) / hoodLeaders.length) : 0;
  const leaderSocialAvg  = hoodLeaders.length ? Math.round(hoodLeaders.reduce((s, l) => s + l.social_count,  0) / hoodLeaders.length) : 0;
  const scoreColor = totalScore >= 80 ? '#22c55e' : totalScore >= 60 ? '#84cc16' : totalScore >= 40 ? '#facc15' : totalScore >= 20 ? '#f97316' : '#dc2626';
  const scoreLabel = totalScore >= 80 ? 'Excellent' : totalScore >= 60 ? 'Strong' : totalScore >= 40 ? 'Average' : totalScore >= 20 ? 'Thin' : 'Bare';
  const safe = (s) => String(s == null ? '' : s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
  const dba = biz.source_data_json?.dba_name || biz.name;
  const fmtPrice = (cents) => cents ? '$' + Math.round(cents / 100) : '?';
  const SERVICE_LABEL = {
    mens_haircut:   "Men's Haircut", womens_haircut: "Women's Haircut",
    color:          "Color (single process)", balayage: "Balayage / Highlights",
    manicure:       "Manicure", pedicure: "Pedicure",
    facial:         "Facial", wax: "Wax", brow: "Brow", lash: "Lash extensions",
    other:          "Other",
  };

  res.set('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>Analysis · ${safe(dba)} · LA Salon Directory</title>
<meta name="description" content="${safe(dba)} scored ${totalScore}/100 on 14 SEO + quality signals. Compared against top ${hoodLeaders.length} leaders in ${safe(biz.neighborhood || 'LA')}.">
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600;700&family=Inter:wght@300;400;500;600&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
<style>:root{--ink:#1a1a1a;--paper:#f5f5f5;--rule:#e6e3dc;--mute:#7a766f;--accent:#5a6e3a;--gold:#b8945a}*{box-sizing:border-box;margin:0;padding:0}body{background:var(--paper);color:var(--ink);font-family:Inter,system-ui;line-height:1.45;-webkit-font-smoothing:antialiased}a{color:inherit;text-decoration:none}.topbar{padding:18px 32px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--rule);position:sticky;top:0;background:var(--paper);z-index:60}.brand{font-family:Cormorant Garamond,serif;font-size:20px;font-weight:500}.brand em{color:var(--accent);font-style:italic}.topnav{display:flex;gap:18px;font-family:Space Mono,monospace;font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute)}.hero{padding:64px 32px 32px;border-bottom:1px solid var(--rule);max-width:1200px;margin:0 auto}.hero-eyebrow{font-family:Space Mono,monospace;font-size:11px;letter-spacing:.22em;text-transform:uppercase;color:var(--accent);margin-bottom:18px}h1{font-family:Cormorant Garamond,serif;font-weight:500;font-size:clamp(40px,6vw,72px);line-height:.96;letter-spacing:-.02em;margin-bottom:14px}h1 em{font-style:italic;color:var(--accent)}.score-row{display:grid;grid-template-columns:auto 1fr;gap:32px;align-items:center;margin-top:24px}.score-circle{width:160px;height:160px;border-radius:50%;background:${scoreColor};color:#000;display:flex;align-items:center;justify-content:center;flex-direction:column;border:8px solid #1a1a1a}.score-circle b{font-family:Cormorant Garamond,serif;font-size:64px;font-weight:500;line-height:1;font-style:italic;letter-spacing:-.02em}.score-circle span{font-family:Space Mono,monospace;font-size:11px;letter-spacing:.18em;text-transform:uppercase;font-weight:700;margin-top:2px}.score-meta strong{font-family:Cormorant Garamond,serif;font-size:36px;font-style:italic;letter-spacing:-.01em;display:block;margin-bottom:6px;color:var(--ink)}.score-meta p{color:#3a3a3a;font-size:15px;max-width:42ch}.section{padding:48px 32px;max-width:1200px;margin:0 auto;border-top:1px solid var(--rule)}.section h2{font-family:Cormorant Garamond,serif;font-size:32px;font-weight:500;margin-bottom:6px}.section .lede{color:var(--mute);font-size:13px;font-family:Space Mono,monospace;letter-spacing:.06em;text-transform:uppercase;margin-bottom:24px}.score-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:1px;background:var(--rule);border:1px solid var(--rule)}.signal{background:#fff;padding:14px 18px;display:flex;justify-content:space-between;align-items:center;gap:10px;font-size:13px}.signal.pass{background:#f0fdf4}.signal.fail{background:#fafaf6;color:#666}.signal-name{font-family:Cormorant Garamond,serif;font-size:18px;font-weight:500}.signal-state{font-family:Space Mono,monospace;font-size:10px;letter-spacing:.18em;text-transform:uppercase;font-weight:700;padding:3px 8px;border:2px solid #1a1a1a}.signal.pass .signal-state{background:#22c55e;color:#000}.signal.fail .signal-state{background:#fff;color:#000}.signal-weight{font-family:Space Mono,monospace;font-size:9px;color:var(--mute);letter-spacing:.04em}.suggest-list{display:flex;flex-direction:column;gap:0;background:var(--rule);border:1px solid var(--rule)}.suggest{background:var(--paper);padding:18px 22px;display:grid;grid-template-columns:auto 1fr auto;gap:18px;align-items:center}.suggest:hover{background:#fff}.suggest .priority{font-family:Cormorant Garamond,serif;font-style:italic;font-size:32px;color:var(--accent);font-weight:500;letter-spacing:-.02em;width:60px;text-align:center}.suggest-info .what{font-family:Cormorant Garamond,serif;font-size:22px;font-weight:500;line-height:1.15;margin-bottom:4px}.suggest-info .how{font-size:13px;color:#444;line-height:1.55}.suggest .pts{font-family:Space Mono,monospace;font-size:11px;letter-spacing:.06em;background:#1a1a1a;color:#facc15;padding:4px 9px;font-weight:700;text-align:center}.leaders-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:1px;background:var(--rule);border:1px solid var(--rule)}.leader{background:var(--paper);padding:20px 22px;display:flex;flex-direction:column;gap:6px}.leader:hover{background:#fff}.leader .rank{font-family:Cormorant Garamond,serif;font-style:italic;font-size:42px;color:var(--accent);font-weight:500;line-height:1}.leader .name{font-family:Cormorant Garamond,serif;font-size:22px;font-weight:500;letter-spacing:-.005em;line-height:1.2}.leader .meta{font-family:Space Mono,monospace;font-size:10px;color:var(--mute);text-transform:uppercase;letter-spacing:.06em}.leader .pubs{font-family:Space Mono,monospace;font-size:10px;color:var(--accent);letter-spacing:.04em;line-height:1.5;margin-top:2px}.gap-table{font-family:Space Mono,monospace;font-size:13px;width:100%;border-collapse:collapse;background:#fff;border:1px solid var(--rule);margin-top:16px}.gap-table td,.gap-table th{padding:12px 14px;border-bottom:1px solid var(--rule);text-align:left}.gap-table th{background:#fafaf6;font-size:10px;text-transform:uppercase;letter-spacing:.16em}.gap-table .you{color:var(--accent);font-weight:700}.gap-table .gap{color:#dc2626;font-weight:700}.empty{padding:32px;font-family:Cormorant Garamond,serif;font-style:italic;color:var(--mute);text-align:center}.price-form{background:var(--ink);color:var(--paper);padding:32px}.price-form h3{font-family:Cormorant Garamond,serif;font-size:24px;font-weight:500;margin-bottom:8px;color:#facc15}.price-form p{color:#aaa;font-size:13px;max-width:60ch;margin-bottom:16px}.price-form form{display:grid;grid-template-columns:1fr 1fr 1fr auto;gap:8px;max-width:720px;align-items:end}.price-form label{display:block;font-family:Space Mono,monospace;font-size:9px;letter-spacing:.18em;text-transform:uppercase;color:#666;margin-bottom:4px}.price-form select,.price-form input{padding:10px 12px;background:#fff;color:var(--ink);border:none;font-family:Space Mono,monospace;font-size:13px;font-weight:700;width:100%}.price-form button{background:#facc15;color:#000;border:none;padding:10px 20px;font-family:Space Mono,monospace;font-size:11px;letter-spacing:.18em;text-transform:uppercase;font-weight:700;cursor:pointer}@media(max-width:880px){.score-row{grid-template-columns:1fr;text-align:center}.price-form form{grid-template-columns:1fr}}.footer{padding:48px 32px;font-family:Space Mono,monospace;font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--mute);text-align:center;border-top:1px solid var(--rule)}</style></head>
<body>
<header class="topbar">
  <div class="brand"><a href="/biz/${safe(biz.slug)}">${safe(dba)}</a> <span style="color:var(--mute);font-style:italic">/ analysis</span></div>
  <nav class="topnav">
    <a href="/biz/${safe(biz.slug)}">← profile</a>
    <a href="/biz/${safe(biz.slug)}/links">+ Links</a>
    <a href="/best">★ The Best</a>
  </nav>
</header>
<section class="hero">
  <div class="hero-eyebrow">SEO + Quality Score · ${safe(biz.neighborhood || biz.city || 'LA')}${biz.category ? ' · ' + safe(biz.category) : ''}</div>
  <h1>${safe(dba)}<br><em style="font-size:.6em">at a glance.</em></h1>
  <div class="score-row">
    <div class="score-circle"><b>${totalScore}</b><span>${scoreLabel}</span></div>
    <div class="score-meta">
      <strong>${breakdown.filter(b => b.passed).length} of ${breakdown.length} signals · ${suggestions.length} suggestion${suggestions.length === 1 ? '' : 's'}</strong>
      <p>Heuristic score across 14 weighted signals — what's known about your shop. Higher = better discoverability + trust + press surface area. Below: leaders we benchmarked you against, then prioritized actions.</p>
    </div>
  </div>
</section>
${suggestions.length ? `
<section class="section">
  <h2>${suggestions.length} suggestion${suggestions.length === 1 ? '' : 's'} · ranked by impact</h2>
  <div class="lede">Top of the list = biggest score gain</div>
  <div class="suggest-list">
    ${suggestions.map((s, i) => `
      <div class="suggest">
        <div class="priority">${i + 1}.</div>
        <div class="suggest-info">
          <div class="what">${safe(s.label)}</div>
          <div class="how">${safe(s.tip)}</div>
        </div>
        <div class="pts">+${s.weight}</div>
      </div>
    `).join('')}
  </div>
</section>
` : '<section class="section"><h2>★ Excellent — every signal hit</h2></section>'}
<section class="section">
  <h2>Score breakdown · 14 signals</h2>
  <div class="lede">Pass/fail per signal · sum = ${totalScore}/100</div>
  <div class="score-grid">
    ${breakdown.map(b => `
      <div class="signal ${b.passed ? 'pass' : 'fail'}">
        <div>
          <div class="signal-name">${safe(b.label)}</div>
          <div class="signal-weight">${b.weight} pt${b.weight === 1 ? '' : 's'}</div>
        </div>
        <span class="signal-state">${b.passed ? '✓ HIT' : '— miss'}</span>
      </div>
    `).join('')}
  </div>
</section>
<section class="section">
  <h2>Top leaders in ${safe(biz.neighborhood || 'your hood')}</h2>
  <div class="lede">Same neighborhood${biz.category ? ' · same category (' + safe(biz.category) + ')' : ''} · ranked by press coverage</div>
  ${hoodLeaders.length ? `
  <div class="leaders-grid">
    ${hoodLeaders.map((l, i) => `
      <a class="leader" href="/biz/${safe(l.slug)}">
        <div class="rank">№ ${i + 1}</div>
        <div class="name">${safe(l.name)}</div>
        <div class="meta">${safe(l.category || 'business')}</div>
        <div class="meta" style="color:var(--accent)">${l.article_count} article${l.article_count === 1 ? '' : 's'} · ${l.social_count} social${l.social_count === 1 ? '' : 's'}</div>
        ${l.publishers && l.publishers.length ? `<div class="pubs">${l.publishers.slice(0, 4).map(p => safe(p)).join(' · ')}${l.publishers.length > 4 ? ' +' + (l.publishers.length - 4) : ''}</div>` : ''}
      </a>
    `).join('')}
  </div>
  <table class="gap-table">
    <thead><tr><th>Metric</th><th>You</th><th>Hood leader avg</th><th>Gap</th></tr></thead>
    <tbody>
      <tr><td>Press articles</td><td class="you">${ctx.articleCount}</td><td>${leaderArticleAvg}</td><td class="gap">${leaderArticleAvg - ctx.articleCount > 0 ? '−' + (leaderArticleAvg - ctx.articleCount) : '+' + (ctx.articleCount - leaderArticleAvg)}</td></tr>
      <tr><td>Social platforms</td><td class="you">${socials.length}</td><td>${leaderSocialAvg}</td><td class="gap">${leaderSocialAvg - socials.length > 0 ? '−' + (leaderSocialAvg - socials.length) : '+' + (socials.length - leaderSocialAvg)}</td></tr>
      <tr><td>Has website</td><td class="you">${biz.website ? '✓' : '—'}</td><td>—</td><td>—</td></tr>
    </tbody>
  </table>
  ` : '<div class="empty">No leaders identified in this neighborhood yet.</div>'}
</section>
<section class="section">
  <h2>Pricing · ${pricing.length ? pricing.length + ' service' + (pricing.length === 1 ? '' : 's') + ' on file' : 'no entries yet'}</h2>
  <div class="lede">Owner-submitted prices · used to compute hood/category medians${pricing.length ? '' : ' · be the first to enter one'}</div>
  ${pricing.length ? `
  <table class="gap-table">
    <thead><tr><th>Service</th><th>Your price</th><th>Hood median</th><th>Sample size</th></tr></thead>
    <tbody>
      ${pricing.map(p => {
        const median = hoodMedians.find(m => m.service_kind === p.service_kind);
        const yourPrice = p.price_high ? `${fmtPrice(p.price_low)}–${fmtPrice(p.price_high)}` : fmtPrice(p.price_low);
        return `<tr>
          <td>${safe(SERVICE_LABEL[p.service_kind] || p.service_kind)}</td>
          <td class="you">${yourPrice}</td>
          <td>${median ? fmtPrice(median.median_low) : '—'}</td>
          <td>${median ? median.sample_size : '0'}</td>
        </tr>`;
      }).join('')}
    </tbody>
  </table>
  ` : ''}
</section>
<section class="price-form">
  <h3>Add or update a price</h3>
  <p>Submit one representative service price. Used to compute neighborhood medians and feed the leader-comparison table above. Free, public, owner-curated. Keeps the directory honest about price.</p>
  <form id="price-form">
    <div>
      <label>Service kind</label>
      <select name="service_kind" required>
        <option value="">—</option>
        ${Object.entries(SERVICE_LABEL).map(([k, l]) => `<option value="${k}">${l}</option>`).join('')}
      </select>
    </div>
    <div>
      <label>Low ($)</label>
      <input name="price_low" type="number" min="0" step="1" placeholder="35" required>
    </div>
    <div>
      <label>High ($, optional)</label>
      <input name="price_high" type="number" min="0" step="1" placeholder="55">
    </div>
    <button type="submit">Save</button>
  </form>
  <div id="price-out" style="font-family:Space Mono,monospace;font-size:13px;color:#facc15;margin-top:14px;min-height:18px"></div>
</section>
<footer class="footer">
  <a href="/biz/${safe(biz.slug)}" style="color:var(--accent)">← back to profile</a> · <a href="/best" style="color:var(--accent)">★ /best</a>
</footer>
<script>
document.getElementById('price-form').addEventListener('submit', async (e) => {
  e.preventDefault();
  const fd = new FormData(e.target);
  const body = Object.fromEntries(fd.entries());
  body.slug = ${JSON.stringify(biz.slug)};
  const out = document.getElementById('price-out');
  out.textContent = 'submitting…';
  try {
    const r = await fetch('/api/biz/price', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify(body) });
    const j = await r.json();
    if (j.ok) { out.textContent = '✓ saved · refreshing'; setTimeout(() => location.reload(), 800); }
    else { out.textContent = '✗ ' + (j.error || 'error'); out.style.color = '#dc2626'; }
  } catch (err) { out.textContent = '✗ network error'; out.style.color = '#dc2626'; }
});
</script>
</body></html>`);
});

// POST /api/biz/price — owner-submits a price; upserts into business_pricing.
const ALLOWED_SERVICE_KINDS = new Set(['mens_haircut','womens_haircut','color','balayage','manicure','pedicure','facial','wax','brow','lash','other']);
app.post('/api/biz/price', async (req, res) => {
  try {
    const { slug, service_kind, price_low, price_high, notes, submitted_by } = req.body || {};
    if (!slug || !service_kind) return res.status(400).json({ error: 'slug + service_kind required' });
    const kind = String(service_kind).toLowerCase();
    if (!ALLOWED_SERVICE_KINDS.has(kind)) return res.status(400).json({ error: 'unknown service_kind' });
    const lowDollars = Number(price_low);
    const highDollars = price_high ? Number(price_high) : null;
    if (!Number.isFinite(lowDollars) || lowDollars <= 0 || lowDollars > 5000) return res.status(400).json({ error: 'price_low out of range ($1-$5000)' });
    if (highDollars != null && (!Number.isFinite(highDollars) || highDollars < lowDollars || highDollars > 10000)) return res.status(400).json({ error: 'price_high out of range or below low' });
    const biz = await loadBySlug(slug);
    if (!biz) return res.status(404).json({ error: 'unknown business' });
    const lowCents = Math.round(lowDollars * 100);
    const highCents = highDollars != null ? Math.round(highDollars * 100) : null;
    await query(
      `INSERT INTO business_pricing (business_id, service_kind, price_low, price_high, notes, submitted_by)
       VALUES ($1, $2, $3, $4, $5, $6)
       ON CONFLICT (business_id, service_kind) DO UPDATE SET
         price_low    = EXCLUDED.price_low,
         price_high   = EXCLUDED.price_high,
         notes        = COALESCE(EXCLUDED.notes, business_pricing.notes),
         submitted_by = COALESCE(EXCLUDED.submitted_by, business_pricing.submitted_by),
         updated_at   = now()`,
      [biz.id, kind, lowCents, highCents, notes ? String(notes).slice(0, 240) : null, submitted_by ? String(submitted_by).slice(0, 120) : null]
    );
    res.json({ ok: true });
  } catch (e) {
    fail(res, 'biz/price', e);
  }
});

// /addresses — phone-book index of every physical address in the catalog.
// Group by neighborhood, sort within group by street name + house number.
// Each row deep-links to Apple Maps + Google Maps + DCA verify (if licensed).
//   /addresses                  — all neighborhoods
//   /addresses?hood=Silver+Lake — single neighborhood
//   /addresses?city=Beverly+Hills — single city
app.get('/addresses', async (req, res) => {
  const hood = req.query.hood ? String(req.query.hood).slice(0, 80) : null;
  const cityFilter = req.query.city ? String(req.query.city).slice(0, 80) : null;
  const params = [];
  let where = `address IS NOT NULL AND address <> ''`;
  if (hood) { params.push(hood); where += ` AND neighborhood = $${params.length}`; }
  if (cityFilter) { params.push(cityFilter); where += ` AND LOWER(city) = LOWER($${params.length})`; }
  const rows = await many(
    `SELECT slug,
            COALESCE(NULLIF(source_data_json->>'dba_name',''), name) AS name,
            category, address, city, zip, neighborhood,
            latitude, longitude,
            (source_data_json->>'source' = 'dca_bbcb') AS licensed,
            source_data_json->>'license_number' AS license_number,
            COALESCE(NULLIF(REGEXP_REPLACE(SPLIT_PART(address, ' ', 1), '\\D', '', 'g'), '')::bigint, 0) AS house_num,
            REGEXP_REPLACE(address, '^\\d+\\s*', '') AS street_part
       FROM businesses
      WHERE ${where}
      ORDER BY neighborhood NULLS LAST, street_part, house_num, name
      LIMIT 30000`,
    params
  );
  const byHood = new Map();
  for (const r of rows) {
    const k = r.neighborhood || r.city || 'Other';
    if (!byHood.has(k)) byHood.set(k, []);
    byHood.get(k).push(r);
  }
  const ordered = [...byHood.entries()].sort((a, b) => b[1].length - a[1].length);
  const safe = (s) => String(s == null ? '' : s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
  const slugify = (s) => String(s || '').toLowerCase().replace(/[^\w]+/g, '-').replace(/^-|-$/g, '');
  const totals = await one(
    `SELECT COUNT(*)::int AS total, COUNT(*) FILTER (WHERE latitude IS NOT NULL)::int AS geo
       FROM businesses WHERE address IS NOT NULL AND address <> ''`
  );

  res.set('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>Every Address · LA Salon Directory${hood ? ' · ' + safe(hood) : ''}${cityFilter ? ' · ' + safe(cityFilter) : ''}</title>
<meta name="description" content="${rows.length.toLocaleString()} physical addresses${hood ? ' in ' + safe(hood) : ''} — every salon, barber, and shop with a known street address. Map deep-links per row.">
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600;700&family=Inter:wght@300;400;500;600&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
<style>:root{--ink:#1a1a1a;--paper:#f5f5f5;--rule:#e6e3dc;--mute:#7a766f;--accent:#5a6e3a;--gold:#b8945a}*{box-sizing:border-box;margin:0;padding:0}body{background:var(--paper);color:var(--ink);font-family:Inter,system-ui;-webkit-font-smoothing:antialiased;line-height:1.4}a{color:inherit;text-decoration:none}.topbar{padding:18px 32px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--rule);position:sticky;top:0;background:var(--paper);z-index:60;flex-wrap:wrap;gap:12px}.brand{font-family:Cormorant Garamond,serif;font-size:20px;font-weight:500}.brand em{color:var(--accent);font-style:italic}.topnav{display:flex;gap:18px;font-family:Space Mono,monospace;font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute)}.topnav a:hover{color:var(--ink)}.hero{padding:64px 32px 32px;border-bottom:1px solid var(--rule);max-width:1240px;margin:0 auto}.hero-eyebrow{font-family:Space Mono,monospace;font-size:11px;letter-spacing:.22em;text-transform:uppercase;color:var(--accent);margin-bottom:16px}h1{font-family:Cormorant Garamond,serif;font-weight:500;font-size:clamp(40px,6vw,72px);line-height:.96;letter-spacing:-.02em;margin-bottom:14px}h1 em{font-style:italic;color:var(--accent)}.hero-stats{display:flex;gap:32px;font-family:Space Mono,monospace;font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute);flex-wrap:wrap;margin-top:24px}.hero-stats b{display:block;font-family:Cormorant Garamond,serif;font-style:italic;font-size:28px;letter-spacing:-.01em;color:var(--ink);font-weight:500;margin-top:4px;text-transform:none}.hood-toc{padding:32px 32px 8px;max-width:1240px;margin:0 auto;display:flex;flex-wrap:wrap;gap:6px}.hood-toc a{font-family:Space Mono,monospace;font-size:11px;letter-spacing:.06em;color:var(--mute);text-transform:uppercase;padding:5px 10px;border:1px solid var(--rule);transition:all 150ms ease}.hood-toc a:hover{background:var(--accent);color:var(--paper);border-color:var(--accent)}.hood-toc a b{color:var(--ink);font-family:Cormorant Garamond,serif;font-style:italic;font-size:14px;margin-right:6px}.hood-section{padding:48px 32px;max-width:1240px;margin:0 auto;border-top:1px solid var(--rule);scroll-margin-top:80px}.hood-head{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:18px;padding-bottom:10px;border-bottom:1px solid var(--rule)}.hood-head h2{font-family:Cormorant Garamond,serif;font-weight:500;font-size:clamp(28px,4vw,42px);letter-spacing:-.015em;line-height:1}.hood-head .meta{font-family:Space Mono,monospace;font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--mute)}.addr-list{column-count:2;column-gap:36px;column-rule:1px solid var(--rule)}@media(max-width:880px){.addr-list{column-count:1}}.addr{break-inside:avoid;display:grid;grid-template-columns:1fr auto;gap:8px;align-items:baseline;padding:8px 0;border-bottom:1px dotted var(--rule);font-size:13px}.addr:hover{background:#fff;padding-left:6px}.addr-info{min-width:0}.addr-info .name{font-family:Cormorant Garamond,serif;font-size:17px;font-weight:500;line-height:1.15;letter-spacing:-.005em;display:inline}.addr-info .street{font-family:Space Mono,monospace;font-size:11px;color:#3a3a3a;display:block;margin-top:2px;letter-spacing:.02em}.addr-info .meta{font-family:Space Mono,monospace;font-size:9px;color:var(--mute);text-transform:uppercase;letter-spacing:.06em;margin-top:1px}.addr-actions{display:flex;flex-direction:column;gap:2px;align-items:flex-end;font-family:Space Mono,monospace;font-size:9px}.addr-actions a{padding:1px 6px;border:1px solid var(--rule);background:#fff;color:var(--ink);letter-spacing:.04em;text-transform:uppercase;white-space:nowrap;font-weight:600}.addr-actions a:hover{background:var(--accent);color:var(--paper);border-color:var(--accent)}.addr-actions a.lic{background:var(--gold);color:var(--ink);border-color:var(--gold)}.empty{text-align:center;padding:96px;font-family:Cormorant Garamond,serif;font-style:italic;font-size:24px;color:var(--mute)}.footer{padding:48px 32px;font-family:Space Mono,monospace;font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--mute);text-align:center;border-top:1px solid var(--rule)}</style></head>
<body>
<header class="topbar">
  <div class="brand"><a href="/">LA <em>Salon</em> Directory</a> <span style="color:var(--mute);font-style:italic">/ all addresses${hood ? ' / ' + safe(hood) : ''}${cityFilter ? ' / ' + safe(cityFilter) : ''}</span></div>
  <nav class="topnav">
    <a href="/best">★ Best</a>
    <a href="/timeline">Live Feed</a>
    <a href="/near">Near Me</a>
    <a href="/neighborhoods">Hoods</a>
    <a href="/">Home</a>
  </nav>
</header>
<section class="hero">
  <div class="hero-eyebrow">Phone-book index · ${rows.length.toLocaleString()} addresses${hood ? ' · ' + safe(hood) : ''}</div>
  <h1>Every <em>physical</em> address.</h1>
  <p style="font-size:16px;color:#3a3a3a;max-width:60ch;margin-bottom:8px">Every salon, barber, and shop with a known street address — sorted by neighborhood, then by street, then house number. Each row deep-links to Apple Maps and Google Maps for directions.</p>
  <div class="hero-stats">
    <div>${hood || cityFilter ? 'Filtered' : 'Total'} addresses<b>${rows.length.toLocaleString()}</b></div>
    <div>Of catalog<b>${totals.total.toLocaleString()}</b></div>
    <div>Geocoded<b>${totals.geo.toLocaleString()}</b></div>
    <div>Neighborhoods<b>${ordered.length}</b></div>
  </div>
</section>
${ordered.length === 0 ? `
<div class="empty">No addresses match this filter.</div>
` : `
<nav class="hood-toc">
  ${ordered.slice(0, 80).map(([h, list]) => `<a href="#${safe(slugify(h))}"><b>${list.length}</b>${safe(h)}</a>`).join('')}
  ${ordered.length > 80 ? `<span style="font-family:Space Mono,monospace;font-size:11px;color:var(--mute);align-self:center;padding:5px 10px">+${ordered.length - 80} more below</span>` : ''}
</nav>
${ordered.map(([h, list]) => `
  <section class="hood-section" id="${safe(slugify(h))}">
    <div class="hood-head">
      <h2>${safe(h)}</h2>
      <span class="meta">${list.length} address${list.length === 1 ? '' : 'es'}${hood !== h ? ' · <a href="/addresses?hood=' + encodeURIComponent(h) + '" style="color:var(--accent);text-decoration:underline">just this hood →</a>' : ''}</span>
    </div>
    <div class="addr-list">
      ${list.map(r => {
        const apple  = r.latitude ? `https://maps.apple.com/?daddr=${r.latitude},${r.longitude}` : `https://maps.apple.com/?q=${encodeURIComponent(r.address + ', ' + (r.city || 'Los Angeles') + ', CA')}`;
        const google = r.latitude ? `https://www.google.com/maps/dir/?api=1&destination=${r.latitude},${r.longitude}` : `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(r.address + ', ' + (r.city || 'Los Angeles') + ', CA')}`;
        const lic    = r.licensed && r.license_number ? `https://search.dca.ca.gov/results?lic=${encodeURIComponent(r.license_number)}` : '';
        return `<div class="addr">
          <div class="addr-info">
            <a class="name" href="/biz/${safe(r.slug)}">${safe(r.name)}</a>
            <span class="street">${safe(r.address)}${r.city ? ', ' + safe(r.city) : ''}${r.zip ? ' ' + safe((r.zip || '').slice(0,5)) : ''}</span>
            <span class="meta">${safe(r.category || 'business')}${r.licensed ? ' · ◆ licensed' : ''}${r.latitude ? ' · 📍' : ''}</span>
          </div>
          <div class="addr-actions">
            <a href="${safe(apple)}" target="_blank" rel="noopener" title="Apple Maps">A↗</a>
            <a href="${safe(google)}" target="_blank" rel="noopener" title="Google Maps">G↗</a>
            ${lic ? `<a class="lic" href="${safe(lic)}" target="_blank" rel="noopener" title="Verify license">◆</a>` : ''}
          </div>
        </div>`;
      }).join('')}
    </div>
  </section>
`).join('')}
`}
<footer class="footer">
  <a href="/" style="color:var(--accent)">← Home</a>
  · ${rows.length.toLocaleString()} addresses · ${ordered.length} neighborhoods · live data
</footer>
</body></html>`);
});

// /random — surface a random shop from the catalog. 302-redirects to the
// profile, so deep-link sharing makes sense. Uses TABLESAMPLE for cheap
// O(1) random picks at scale (vs ORDER BY random() which is O(N)).
//
// /random         — any shop with a name (the firehose)
// /random/best    — only fully-enriched shops (data-complete cohort)
// /random/licensed — only state-licensed BBCB shops
// /random/<hood>  — random shop in that neighborhood
app.get('/random', async (req, res) => {
  try {
    const r = await one(
      `SELECT slug FROM businesses TABLESAMPLE SYSTEM(1) WHERE name IS NOT NULL LIMIT 1`
    );
    if (!r?.slug) {
      // TABLESAMPLE may return nothing on small samples; fall back to ordered.
      const fb = await one(`SELECT slug FROM businesses ORDER BY random() LIMIT 1`);
      // Reviewer #7: don't redirect to /biz/ with empty slug when DB empty.
      if (!fb?.slug) return res.status(503).json({ error: 'no businesses available' });
      return res.redirect(302, `/biz/${encodeURIComponent(fb.slug)}`);
    }
    res.redirect(302, `/biz/${encodeURIComponent(r.slug)}`);
  } catch (e) {
    fail(res, '/random', e);
  }
});

app.get('/random/best', async (_req, res) => {
  // Pick from the data-complete cohort (website + ≥1 social + ≥1 article).
  const r = await one(
    `SELECT b.slug FROM businesses b
       JOIN business_socials s ON s.business_id = b.id
       JOIN business_articles a ON a.business_id = b.id
      WHERE b.website IS NOT NULL AND b.website <> ''
      GROUP BY b.id, b.slug
     HAVING COUNT(DISTINCT s.id) >= 1 AND COUNT(DISTINCT a.id) >= 1
      ORDER BY random() LIMIT 1`
  );
  if (!r?.slug) return res.redirect(302, '/best');
  res.redirect(302, `/biz/${r.slug}`);
});

app.get('/random/licensed', async (_req, res) => {
  const r = await one(
    `SELECT slug FROM businesses
      WHERE source_data_json->>'source' = 'dca_bbcb'
      ORDER BY random() LIMIT 1`
  );
  res.redirect(302, `/biz/${r?.slug || ''}`);
});

app.get('/random/:hood', async (req, res) => {
  const hood = String(req.params.hood || '').replace(/-/g, ' ');
  const r = await one(
    `SELECT slug FROM businesses
      WHERE LOWER(neighborhood) = LOWER($1)
      ORDER BY random() LIMIT 1`,
    [hood]
  );
  if (!r?.slug) return res.redirect(302, '/');
  res.redirect(302, `/biz/${r.slug}`);
});

// /stats — public growth dashboard. Hourly histograms of enrichment finds +
// geocode hits over the last 24h, plus aggregate totals. Auto-refresh 60s.
app.get('/stats', async (_req, res) => {
  const totals = await one(
    `SELECT
        (SELECT COUNT(*) FROM businesses)::int AS biz_total,
        (SELECT COUNT(*) FROM businesses WHERE source_data_json->>'source'='dca_bbcb')::int AS biz_licensed,
        (SELECT COUNT(*) FROM businesses WHERE latitude IS NOT NULL)::int AS biz_geo,
        (SELECT COUNT(*) FROM businesses WHERE website IS NOT NULL AND website <> '')::int AS biz_websites,
        (SELECT COUNT(*) FROM businesses WHERE source_data_json ? 'dba_name')::int AS biz_dba,
        (SELECT COUNT(DISTINCT neighborhood) FROM businesses WHERE neighborhood IS NOT NULL)::int AS hood_count,
        (SELECT COUNT(*) FROM enrichment_jobs)::int AS jobs_total,
        (SELECT COUNT(*) FROM enrichment_jobs WHERE status='found')::int AS jobs_found,
        (SELECT COUNT(*) FROM business_articles)::int AS articles_total,
        (SELECT COUNT(*) FROM business_socials WHERE url IS NOT NULL)::int AS socials_total,
        (SELECT COUNT(*) FROM business_review_sources WHERE verified=true)::int AS reviews_total`
  );
  // Hourly histogram for the last 24h.
  const histogram = await many(
    `WITH hours AS (
       SELECT generate_series(
         date_trunc('hour', now() - interval '24 hours'),
         date_trunc('hour', now()),
         '1 hour'::interval
       ) AS h
     )
     SELECT to_char(h, 'HH24:MI') AS bucket,
            EXTRACT(epoch FROM h)::int AS ts,
            (SELECT COUNT(*) FROM enrichment_jobs WHERE attempted_at >= h AND attempted_at < h + interval '1 hour' AND status='found')::int AS jobs_found,
            (SELECT COUNT(*) FROM business_articles WHERE created_at >= h AND created_at < h + interval '1 hour')::int AS articles,
            (SELECT COUNT(*) FROM business_socials WHERE scraped_at >= h AND scraped_at < h + interval '1 hour')::int AS socials
       FROM hours
      ORDER BY h`
  );
  const platform = await many(
    `SELECT platform, COUNT(*)::int AS n
       FROM business_socials WHERE url IS NOT NULL
      GROUP BY platform ORDER BY n DESC LIMIT 10`
  );
  const publishers = await many(
    `SELECT publisher, COUNT(*)::int AS n
       FROM business_articles WHERE publisher IS NOT NULL
      GROUP BY publisher ORDER BY n DESC LIMIT 10`
  );
  const safe = (s) => String(s == null ? '' : s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
  // Find the max value across all hourly metrics for chart scaling.
  const maxBar = Math.max(1, ...histogram.flatMap(h => [h.jobs_found, h.articles, h.socials]));

  res.set('content-type', 'text/html; charset=utf-8');
  res.send(`<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta http-equiv="refresh" content="60">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Stats · LA Salon Directory</title>
<meta name="description" content="Live growth dashboard: 27,740 LA salons indexed, ${totals.biz_geo.toLocaleString()} geocoded, ${totals.articles_total} press articles surfaced, ${totals.socials_total} socials linked. Updates every 60s.">
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500&family=Inter:wght@300;400;500;600&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
<style>:root{--ink:#1a1a1a;--paper:#f5f5f5;--rule:#e6e3dc;--mute:#7a766f;--accent:#5a6e3a;--gold:#b8945a}*{box-sizing:border-box;margin:0;padding:0}body{background:var(--paper);color:var(--ink);font-family:Inter,system-ui;-webkit-font-smoothing:antialiased}a{color:inherit;text-decoration:none}.topbar{padding:18px 32px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--rule);position:sticky;top:0;background:var(--paper);z-index:60}.brand{font-family:Cormorant Garamond,serif;font-size:20px;font-weight:500}.brand em{color:var(--accent);font-style:italic}.topnav{display:flex;gap:18px;font-family:Space Mono,monospace;font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute)}.refresh{position:fixed;top:14px;right:18px;font-family:Space Mono,monospace;font-size:9px;letter-spacing:.16em;color:var(--mute);text-transform:uppercase;background:rgba(255,255,255,.85);padding:4px 8px;border:1px solid var(--rule);z-index:100}.hero{padding:80px 32px 40px;border-bottom:1px solid var(--rule);max-width:1240px;margin:0 auto}.hero-eyebrow{font-family:Space Mono,monospace;font-size:11px;letter-spacing:.22em;text-transform:uppercase;color:var(--accent);margin-bottom:18px}h1{font-family:Cormorant Garamond,serif;font-weight:500;font-size:clamp(48px,7vw,84px);line-height:.96;letter-spacing:-.02em;margin-bottom:14px}h1 em{font-style:italic;color:var(--accent)}.section{max-width:1240px;margin:0 auto;padding:48px 32px;border-bottom:1px solid var(--rule)}.section h2{font-family:Cormorant Garamond,serif;font-size:32px;font-weight:500;margin-bottom:18px}.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:1px;background:var(--rule);border:1px solid var(--rule)}.card{background:var(--paper);padding:20px 22px;display:flex;flex-direction:column;gap:6px;transition:background 200ms ease}.card:hover{background:#fff}.card-label{font-family:Space Mono,monospace;font-size:9px;letter-spacing:.18em;text-transform:uppercase;color:var(--mute)}.card-val{font-family:Cormorant Garamond,serif;font-style:italic;font-size:42px;line-height:1;letter-spacing:-.02em}.card-val em{color:var(--accent)}.chart{margin-top:24px}.chart-row{display:flex;align-items:flex-end;gap:2px;height:120px;font-family:Space Mono,monospace;border-bottom:1px solid var(--rule);padding-bottom:4px}.bar{flex:1;background:var(--accent);min-height:2px;display:flex;align-items:flex-end;justify-content:center;color:#fff;font-size:9px;padding:2px;border-top:2px solid var(--accent)}.bar.articles{background:var(--gold);border-top-color:var(--gold);color:#1a1a1a}.bar.socials{background:#1a1a1a;border-top-color:#1a1a1a}.legend{display:flex;gap:18px;font-family:Space Mono,monospace;font-size:11px;color:var(--mute);text-transform:uppercase;letter-spacing:.06em;margin-top:14px}.legend span{display:inline-flex;align-items:center;gap:6px}.legend i{width:14px;height:14px;display:inline-block;border:1px solid #1a1a1a}.legend i.found{background:var(--accent)}.legend i.articles{background:var(--gold)}.legend i.socials{background:#1a1a1a}.x-axis{display:flex;justify-content:space-between;font-family:Space Mono,monospace;font-size:9px;color:var(--mute);margin-top:6px;letter-spacing:.04em}.list-grid{display:grid;grid-template-columns:1fr 1fr;gap:32px;margin-top:18px}@media (max-width:880px){.list-grid{grid-template-columns:1fr}}.list h3{font-family:Space Mono,monospace;font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--mute);margin-bottom:10px}.list-row{display:flex;justify-content:space-between;align-items:baseline;padding:6px 0;border-bottom:1px dotted var(--rule);font-size:13px}.list-row:last-child{border-bottom:none}.list-row .name{font-family:Cormorant Garamond,serif;font-size:16px}.list-row .n{font-family:Space Mono,monospace;font-weight:700;color:var(--accent);font-size:12px}.footer{padding:48px 32px;font-family:Space Mono,monospace;font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--mute);text-align:center}</style></head>
<body>
<div class="refresh">↻ refresh 60s</div>
<header class="topbar">
  <div class="brand"><a href="/">LA <em>Salon</em> Directory</a> <span style="color:var(--mute);font-style:italic">/ stats</span></div>
  <nav class="topnav">
    <a href="/best">★ Best</a>
    <a href="/timeline">Live Feed</a>
    <a href="/press">Press</a>
    <a href="/feed.xml">RSS</a>
    <a href="/">Home</a>
  </nav>
</header>
<section class="hero">
  <div class="hero-eyebrow">Live Growth Dashboard · Auto-refresh 60s</div>
  <h1>Where the <em>directory</em> is.</h1>
</section>
<section class="section">
  <h2>The Catalog</h2>
  <div class="cards">
    <div class="card"><span class="card-label">Total shops</span><span class="card-val">${totals.biz_total.toLocaleString()}</span></div>
    <div class="card"><span class="card-label">State-licensed</span><span class="card-val"><em>${totals.biz_licensed.toLocaleString()}</em></span></div>
    <div class="card"><span class="card-label">Geocoded</span><span class="card-val">${totals.biz_geo.toLocaleString()}</span></div>
    <div class="card"><span class="card-label">w/ Website</span><span class="card-val">${totals.biz_websites.toLocaleString()}</span></div>
    <div class="card"><span class="card-label">DBA recorded</span><span class="card-val"><em>${totals.biz_dba.toLocaleString()}</em></span></div>
    <div class="card"><span class="card-label">Neighborhoods</span><span class="card-val">${totals.hood_count.toLocaleString()}</span></div>
  </div>
</section>
<section class="section">
  <h2>The Enrichment Fleet</h2>
  <div class="cards">
    <div class="card"><span class="card-label">Jobs total</span><span class="card-val">${totals.jobs_total.toLocaleString()}</span></div>
    <div class="card"><span class="card-label">Found</span><span class="card-val"><em>${totals.jobs_found.toLocaleString()}</em></span></div>
    <div class="card"><span class="card-label">Hit rate</span><span class="card-val">${totals.jobs_total > 0 ? Math.round(totals.jobs_found / totals.jobs_total * 100) : 0}%</span></div>
    <div class="card"><span class="card-label">Articles</span><span class="card-val"><em>${totals.articles_total}</em></span></div>
    <div class="card"><span class="card-label">Socials</span><span class="card-val">${totals.socials_total}</span></div>
    <div class="card"><span class="card-label">Reviews opt-in</span><span class="card-val">${totals.reviews_total}</span></div>
  </div>
</section>
<section class="section">
  <h2>Last 24 hours</h2>
  <div class="legend">
    <span><i class="found"></i> jobs found</span>
    <span><i class="articles"></i> articles</span>
    <span><i class="socials"></i> socials</span>
  </div>
  <div class="chart">
    <div class="chart-row" title="hourly: jobs found / articles / socials">
      ${histogram.map(h => `
        <div style="flex:1;display:flex;flex-direction:column;justify-content:flex-end;gap:1px;height:120px">
          <div class="bar socials" style="height:${(h.socials / maxBar * 100).toFixed(1)}%" title="${safe(h.bucket)} · ${h.socials} socials">${h.socials > 0 ? h.socials : ''}</div>
          <div class="bar articles" style="height:${(h.articles / maxBar * 100).toFixed(1)}%" title="${safe(h.bucket)} · ${h.articles} articles">${h.articles > 0 ? h.articles : ''}</div>
          <div class="bar" style="height:${(h.jobs_found / maxBar * 100).toFixed(1)}%" title="${safe(h.bucket)} · ${h.jobs_found} jobs found">${h.jobs_found > 0 ? h.jobs_found : ''}</div>
        </div>
      `).join('')}
    </div>
    <div class="x-axis">
      ${histogram.filter((_, i) => i % 3 === 0).map(h => `<span>${safe(h.bucket)}</span>`).join('')}
    </div>
  </div>
</section>
<section class="section">
  <div class="list-grid">
    <div class="list">
      <h3>★ Top Platforms</h3>
      ${platform.map(p => `<div class="list-row"><span class="name">${safe(p.platform)}</span><span class="n">${p.n}</span></div>`).join('')}
    </div>
    <div class="list">
      <h3>★ Top Publishers</h3>
      ${publishers.map(p => `<div class="list-row"><span class="name">${safe(p.publisher)}</span><span class="n">${p.n}</span></div>`).join('')}
    </div>
  </div>
</section>
<footer class="footer">
  <a href="/" style="color:var(--accent)">← Home</a> · live data · refresh 60s
</footer>
</body></html>`);
});

// /timeline — public activity stream of the most-recent enrichments. Reads
// from enrichment_jobs + business_articles + business_socials, merges chronologically.
// Reverse-cron feed — newest first. Auto-refresh 30s so the stream feels alive.
app.get('/timeline', async (_req, res) => {
  const limit = 100;
  // Pull from each source, merge by timestamp.
  const [jobs, articles, socials] = await Promise.all([
    many(
      `SELECT j.kind, j.status, j.found_url, j.found_handle, j.attempted_at AS ts,
              b.slug, COALESCE(NULLIF(b.source_data_json->>'dba_name',''), b.name) AS name,
              b.neighborhood, b.category
         FROM enrichment_jobs j
         JOIN businesses b ON b.id = j.business_id
        WHERE j.status = 'found' AND j.attempted_at IS NOT NULL
        ORDER BY j.attempted_at DESC LIMIT $1`,
      [limit]
    ),
    many(
      `SELECT a.title, a.publisher, a.url, a.created_at AS ts,
              b.slug, COALESCE(NULLIF(b.source_data_json->>'dba_name',''), b.name) AS name,
              b.neighborhood
         FROM business_articles a
         JOIN businesses b ON b.id = a.business_id
        ORDER BY a.created_at DESC LIMIT $1`,
      [limit]
    ),
    many(
      `SELECT s.platform, s.url, s.handle, s.scraped_at AS ts,
              b.slug, COALESCE(NULLIF(b.source_data_json->>'dba_name',''), b.name) AS name,
              b.neighborhood
         FROM business_socials s
         JOIN businesses b ON b.id = s.business_id
        WHERE s.url IS NOT NULL
        ORDER BY s.scraped_at DESC LIMIT $1`,
      [limit]
    ),
  ]);

  // Tag each event with a type and merge.
  const events = [
    ...jobs.map(j => ({ ...j, _type: 'enrich', when: j.ts })),
    ...articles.map(a => ({ ...a, _type: 'article', when: a.ts })),
    ...socials.map(s => ({ ...s, _type: 'social', when: s.ts })),
  ].filter(e => e.when).sort((a, b) => new Date(b.when) - new Date(a.when)).slice(0, 200);

  const safe = (s) => String(s == null ? '' : s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
  const fmtAgo = (d) => {
    const ms = Date.now() - new Date(d).getTime();
    if (ms < 60_000) return 'just now';
    if (ms < 3600_000) return Math.floor(ms / 60_000) + 'm ago';
    if (ms < 86400_000) return Math.floor(ms / 3600_000) + 'h ago';
    return Math.floor(ms / 86400_000) + 'd ago';
  };
  const eventLine = (e) => {
    if (e._type === 'article') {
      return `<div class="ev ev-article">
        <span class="ev-time">${fmtAgo(e.when)}</span>
        <span class="ev-tag tag-article">📰 article</span>
        <a class="ev-shop" href="/biz/${safe(e.slug)}">${safe(e.name)}</a>
        <span class="ev-where">${safe(e.neighborhood || '')}</span>
        <a class="ev-link" href="${safe(e.url)}" target="_blank" rel="noopener" title="${safe(e.title || '')}">${safe(e.publisher || 'press')} · ${safe((e.title || '').slice(0, 80))}↗</a>
      </div>`;
    }
    if (e._type === 'social') {
      return `<div class="ev ev-social">
        <span class="ev-time">${fmtAgo(e.when)}</span>
        <span class="ev-tag tag-social">📷 ${safe(e.platform)}</span>
        <a class="ev-shop" href="/biz/${safe(e.slug)}">${safe(e.name)}</a>
        <span class="ev-where">${safe(e.neighborhood || '')}</span>
        <a class="ev-link" href="${safe(e.url)}" target="_blank" rel="noopener">${e.handle ? '@' + safe(e.handle) : safe(e.url.replace(/^https?:\/\//, '').slice(0, 40))}↗</a>
      </div>`;
    }
    // enrich (catch-all from enrichment_jobs)
    if (e.kind === 'website') {
      return `<div class="ev ev-website">
        <span class="ev-time">${fmtAgo(e.when)}</span>
        <span class="ev-tag tag-website">🌐 website</span>
        <a class="ev-shop" href="/biz/${safe(e.slug)}">${safe(e.name)}</a>
        <span class="ev-where">${safe(e.neighborhood || '')}</span>
        <a class="ev-link" href="${safe(e.found_url || '#')}" target="_blank" rel="noopener">${safe((e.found_url || '').replace(/^https?:\/\//, '').slice(0, 40))}↗</a>
      </div>`;
    }
    if (e.kind === 'dba_name') {
      return `<div class="ev ev-dba">
        <span class="ev-time">${fmtAgo(e.when)}</span>
        <span class="ev-tag tag-dba">★ DBA</span>
        <a class="ev-shop" href="/biz/${safe(e.slug)}">${safe(e.name)}</a>
        <span class="ev-where">${safe(e.neighborhood || '')}</span>
        <span class="ev-link" style="color:#000">→ ${safe(e.found_handle || '?')}</span>
      </div>`;
    }
    return ''; // skip enrich events for kinds covered by article/social
  };

  res.set('content-type', 'text/html; charset=utf-8');
  res.send(`<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta http-equiv="refresh" content="30">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Timeline · LA Salon Directory</title>
<meta name="description" content="Live activity stream of every enrichment the directory finds — websites, Instagram handles, press articles, DBA names — newest first.">
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600;700&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>:root{--ink:#1a1a1a;--paper:#f5f5f5;--rule:#e6e3dc;--mute:#7a766f;--accent:#5a6e3a;--gold:#b8945a}*{box-sizing:border-box;margin:0;padding:0}body{background:var(--paper);color:var(--ink);font-family:Inter,system-ui;-webkit-font-smoothing:antialiased}a{color:inherit;text-decoration:none}.topbar{padding:18px 32px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--rule);position:sticky;top:0;background:var(--paper);z-index:60;flex-wrap:wrap;gap:12px}.brand{font-family:Cormorant Garamond,serif;font-size:20px;font-weight:500}.brand em{color:var(--accent);font-style:italic}.topnav{display:flex;gap:18px;font-family:monospace;font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute)}.hero{padding:80px 32px 32px;border-bottom:1px solid var(--rule);max-width:1080px;margin:0 auto}.hero-eyebrow{font-family:monospace;font-size:11px;letter-spacing:.22em;text-transform:uppercase;color:var(--accent);margin-bottom:18px}h1{font-family:Cormorant Garamond,serif;font-weight:500;font-size:clamp(48px,7vw,84px);line-height:.96;letter-spacing:-.02em;margin-bottom:14px}h1 em{font-style:italic;color:var(--accent)}.hero-lede{font-size:16px;color:#3a3a3a;max-width:60ch}.refresh-tag{position:fixed;top:14px;right:18px;font-family:monospace;font-size:9px;letter-spacing:.16em;color:var(--mute);text-transform:uppercase;background:rgba(255,255,255,.85);padding:4px 8px;border:1px solid var(--rule);z-index:100}.feed{max-width:1080px;margin:0 auto;padding:32px;display:flex;flex-direction:column;gap:0}.ev{display:grid;grid-template-columns:80px 110px minmax(180px,1fr) 140px minmax(140px,2fr);gap:14px;align-items:baseline;padding:10px 14px;border-bottom:1px dotted var(--rule);font-size:13px;line-height:1.4;transition:padding 150ms ease}.ev:hover{background:#fff;padding-left:20px}.ev-time{font-family:monospace;font-size:10px;color:var(--mute);letter-spacing:.04em;text-transform:uppercase;white-space:nowrap}.ev-tag{font-family:monospace;font-size:10px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;padding:2px 8px;text-align:center;white-space:nowrap}.tag-article{background:var(--gold);color:#000}.tag-social{background:var(--accent);color:#fff}.tag-website{background:#000;color:#facc15}.tag-dba{background:#000;color:#fff}.ev-shop{font-family:Cormorant Garamond,serif;font-size:18px;font-weight:500;color:var(--ink);letter-spacing:-.005em;line-height:1.2;text-decoration:none}.ev-shop:hover{color:var(--accent)}.ev-where{font-family:monospace;font-size:10px;color:var(--mute);letter-spacing:.04em;text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ev-link{font-family:monospace;font-size:11px;color:var(--accent);letter-spacing:.04em;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.ev-link:hover{color:var(--ink);text-decoration:underline}.empty{text-align:center;padding:96px;font-family:Cormorant Garamond,serif;font-style:italic;font-size:24px;color:var(--mute)}.footer{padding:48px 32px;border-top:1px solid var(--rule);font-family:monospace;font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--mute);text-align:center}@media (max-width:880px){.ev{grid-template-columns:1fr;gap:4px;padding:16px 14px}.ev-time,.ev-where{font-size:9px}.ev-tag{justify-self:start}}</style></head>
<body>
<div class="refresh-tag">↻ live · 30s</div>
<header class="topbar">
  <div class="brand"><a href="/">LA <em>Salon</em> Directory</a> <span style="color:var(--mute);font-style:italic">/ timeline</span></div>
  <nav class="topnav">
    <a href="/best">★ Best</a>
    <a href="/press">Press</a>
    <a href="/proprietors">Proprietors</a>
    <a href="/near">Near</a>
    <a href="/">Home</a>
  </nav>
</header>
<section class="hero">
  <div class="hero-eyebrow">Live · Enrichment Activity Stream</div>
  <h1>What the agents <em>just found.</em></h1>
  <p class="hero-lede">Every enrichment the directory pulls in — a website discovered, an Instagram handle confirmed, a press article surfaced, a DBA brand uncovered — streams here newest first. The fleet runs continuously.</p>
</section>
<div class="feed">
  ${events.length === 0 ? '<div class="empty">No activity yet. Check back in a moment.</div>' : events.map(eventLine).filter(Boolean).join('')}
</div>
<footer class="footer">
  <span>${events.length} recent events · ${jobs.length + articles.length + socials.length} total stream sources · auto-refresh 30s</span>
</footer>
</body></html>`);
});

// /admin/data-status — tier × kind matrix showing enrichment coverage.
// One row per tier (sole_prop, corridor, geocoded, licensed, press_eligible),
// one column per kind (website, instagram, articles, yelp, dba_name, etc.).
// Each cell shows X / Y (= attempted-with-find / attempted total).
app.get('/admin/data-status', async (req, res) => {
  if (!requireAdmin(req, res)) return;
  const tiers = [
    ['sole_prop',      `b.source_data_json->>'shop_personality' = 'sole_prop'`],
    ['corridor',       `b.source_data_json->>'corridor' IS NOT NULL`],
    ['geocoded',       `b.latitude IS NOT NULL`],
    ['licensed',       `b.source_data_json->>'source' = 'dca_bbcb'`],
    ['press_eligible', `b.neighborhood = ANY(ARRAY['Hollywood','East Hollywood','Hollywood Hills','Silver Lake','Echo Park','Highland Park','Koreatown','Mid-Wilshire','Downtown LA','Venice','Mar Vista','Beverly Hills','West Hollywood','Westwood','Brentwood','Pacific Palisades','Santa Monica','Culver City','Los Feliz','Atwater Village','Eagle Rock','Pasadena','South Pasadena','Bel-Air','Boyle Heights','Lincoln Heights','Studio City','Sherman Oaks','Beverly Grove'])`],
  ];
  const kinds = ['website', 'instagram', 'articles', 'yelp', 'facebook', 'tiktok', 'dba_name'];

  // Build the matrix — one query per tier × kind cell. Quick on small sets.
  const matrix = {};
  const totals = {};
  for (const [tierName, tierWhere] of tiers) {
    matrix[tierName] = {};
    const totalRow = await one(`SELECT COUNT(*)::int AS n FROM businesses b WHERE ${tierWhere}`);
    totals[tierName] = totalRow?.n || 0;
    for (const kind of kinds) {
      const r = await one(
        `SELECT
            COUNT(*) FILTER (WHERE j.status='found')::int AS hit,
            COUNT(*) FILTER (WHERE j.status='not_found')::int AS miss,
            COUNT(*)::int AS tried
           FROM businesses b LEFT JOIN enrichment_jobs j ON j.business_id = b.id AND j.kind = $1
          WHERE ${tierWhere} AND j.id IS NOT NULL`,
        [kind]
      );
      matrix[tierName][kind] = r || { hit: 0, miss: 0, tried: 0 };
    }
  }

  const safe = (s) => String(s == null ? '' : s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
  const cellColor = (hit, tried, total) => {
    if (tried === 0) return '#fafaf6';
    const triedPct = tried / total;
    const hitPct = hit / tried;
    if (triedPct < 0.05) return '#fef3c7';                    // barely touched
    if (hitPct >= 0.5)   return '#22c55e';                    // strong yield
    if (hitPct >= 0.2)   return '#84cc16';                    // moderate
    if (hitPct > 0)      return '#facc15';                    // weak
    return '#f3f4f6';                                          // tried but 0 hits
  };
  res.set('content-type', 'text/html; charset=utf-8');
  res.send(`<!doctype html><html><head><meta charset="utf-8">
<meta http-equiv="refresh" content="60">
<title>Data Status · Admin · LA Salon Directory</title>
<meta name="robots" content="noindex">
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500&family=Inter:wght@300;400;500;600&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
<style>:root{--ink:#1a1a1a;--paper:#f5f5f5;--rule:#e6e3dc;--mute:#7a766f;--accent:#5a6e3a}*{box-sizing:border-box;margin:0;padding:0}body{background:var(--paper);color:var(--ink);font-family:Inter,system-ui;padding:32px;line-height:1.4}h1{font-family:Cormorant Garamond,serif;font-size:48px;font-weight:500;margin-bottom:6px}h1 em{color:var(--accent);font-style:italic}.crumb{font-family:Space Mono,monospace;font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute);margin-bottom:24px}p{color:var(--mute);font-size:14px;margin-bottom:32px}table{width:100%;border-collapse:collapse;background:#fff;border:2px solid var(--ink);font-family:Space Mono,monospace}th,td{padding:12px;text-align:center;border:1px solid var(--rule);font-size:12px;vertical-align:middle}th{background:var(--ink);color:var(--paper);font-size:10px;letter-spacing:.18em;text-transform:uppercase;font-weight:700}tr:first-child th{background:var(--ink)}td.tier{background:#fafaf6;text-align:left;font-weight:700;text-transform:uppercase;letter-spacing:.06em;font-size:11px}td.tier b{display:block;font-family:Cormorant Garamond,serif;font-size:18px;font-weight:500;letter-spacing:0;text-transform:none;margin-top:2px}.cell{padding:10px;font-size:11px;font-weight:700;line-height:1.3}.cell .hit{font-family:Cormorant Garamond,serif;font-size:24px;font-style:italic;font-weight:500;letter-spacing:-.01em;display:block;line-height:1}.cell .frac{display:block;color:#222;font-size:10px;margin-top:2px}.cell .pct{display:block;font-size:9px;color:#444;margin-top:2px}a{color:var(--accent);text-decoration:underline}.legend{display:flex;flex-wrap:wrap;gap:8px;margin-top:24px;font-family:Space Mono,monospace;font-size:11px}.legend span{padding:6px 10px;border:1px solid #000}</style></head>
<body>
<div class="crumb">★ Admin · Live Data Coverage Matrix · refresh 60s</div>
<h1>Data <em>status.</em></h1>
<p>Each cell = (found / attempted) for that (tier, kind). Greener = higher hit rate among shops we've tried. Yellow = under 5% attempted (most untouched). Recompute every refresh. <a href="/admin/enrichment">Live enrichment feed →</a></p>
<table>
<thead><tr><th style="text-align:left">Tier</th>${kinds.map(k => `<th>${safe(k)}</th>`).join('')}</tr></thead>
<tbody>
${tiers.map(([tier, _]) => `<tr>
  <td class="tier">${safe(tier)}<b>${(totals[tier] || 0).toLocaleString()} shops</b></td>
  ${kinds.map(k => {
    const c = matrix[tier][k];
    const color = cellColor(c.hit, c.tried, totals[tier] || 1);
    const triedPct = totals[tier] ? (c.tried / totals[tier] * 100).toFixed(1) : '0';
    const hitPct   = c.tried   ? (c.hit / c.tried * 100).toFixed(0) : '–';
    return `<td class="cell" style="background:${color}">
      <span class="hit">${c.hit}</span>
      <span class="frac">/ ${c.tried} tried</span>
      <span class="pct">${triedPct}% touched · ${hitPct}% yield</span>
    </td>`;
  }).join('')}
</tr>`).join('')}
</tbody>
</table>
<div class="legend">
  <span style="background:#22c55e">≥50% yield (green)</span>
  <span style="background:#84cc16">≥20% yield</span>
  <span style="background:#facc15">>0% yield</span>
  <span style="background:#f3f4f6">tried 0 found</span>
  <span style="background:#fef3c7">< 5% touched</span>
  <span style="background:#fafaf6">untouched</span>
</div>
</body></html>`);
});

// /admin/enrichment — live status of the Exa enrichment fleet. Shows
// per-kind hit/miss/pending counts, the freshest 25 found websites, top
// 12 article publishers, and the most recent 25 articles. Auto-refresh.
app.get('/admin/enrichment', async (req, res) => {
  if (!requireAdmin(req, res)) return;
  const byKind = await many(
    `SELECT kind,
            COUNT(*) FILTER (WHERE status='found')      AS found,
            COUNT(*) FILTER (WHERE status='not_found')  AS missed,
            COUNT(*) FILTER (WHERE status='pending')    AS pending,
            COUNT(*) FILTER (WHERE status='error')      AS errored,
            COUNT(*)                                    AS total
       FROM enrichment_jobs
      GROUP BY kind
      ORDER BY total DESC`
  );
  const recentWebsites = await many(
    `SELECT b.slug, b.name, b.neighborhood, j.found_url, j.attempted_at
       FROM enrichment_jobs j JOIN businesses b ON b.id = j.business_id
      WHERE j.kind='website' AND j.status='found' AND j.found_url IS NOT NULL
      ORDER BY j.attempted_at DESC LIMIT 25`
  );
  const topPublishers = await many(
    `SELECT publisher, COUNT(*)::int AS count
       FROM business_articles
      WHERE publisher IS NOT NULL
      GROUP BY publisher ORDER BY count DESC LIMIT 12`
  );
  const recentArticles = await many(
    `SELECT b.slug, b.name, a.title, a.publisher, a.url, a.created_at
       FROM business_articles a JOIN businesses b ON b.id = a.business_id
      ORDER BY a.created_at DESC LIMIT 25`
  );
  const recentSocials = await many(
    `SELECT b.slug, b.name, s.platform, s.url, s.handle
       FROM business_socials s JOIN businesses b ON b.id = s.business_id
      WHERE s.url IS NOT NULL
      ORDER BY s.scraped_at DESC LIMIT 25`
  );
  const totals = await one(
    `SELECT COUNT(*)::int AS total_biz,
            COUNT(*) FILTER (WHERE website IS NOT NULL AND website <> '')::int AS with_website
       FROM businesses`
  );
  res.set('content-type', 'text/html; charset=utf-8');
  res.send(`<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta http-equiv="refresh" content="30">
<title>Enrichment · Admin · LA Salon Directory</title>
<meta name="robots" content="noindex">
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500&family=Inter:wght@300;400;500&display=swap" rel="stylesheet">
<style>:root{--ink:#1a1a1a;--paper:#f5f5f5;--rule:#e6e3dc;--mute:#7a766f;--accent:#5a6e3a;--gold:#b8945a;--err:#c2410c}*{box-sizing:border-box;margin:0;padding:0}body{background:var(--paper);color:var(--ink);font-family:Inter,system-ui;line-height:1.45;-webkit-font-smoothing:antialiased;padding:32px}h1{font-family:Cormorant Garamond,serif;font-size:42px;font-weight:500;margin-bottom:6px}h1 em{color:var(--accent);font-style:italic}.crumb{font-family:monospace;font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute);margin-bottom:24px}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:1px;background:var(--rule);border:1px solid var(--rule);margin-bottom:36px}.card{background:#fff;padding:18px 20px}.card h3{font-family:monospace;font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--mute);margin-bottom:6px}.card .v{font-family:Cormorant Garamond,serif;font-size:42px;line-height:1;font-weight:500;letter-spacing:-.02em}.card .v em{color:var(--accent);font-style:italic;font-size:24px;margin-left:6px}h2{font-family:Cormorant Garamond,serif;font-size:28px;font-weight:500;margin:32px 0 12px}table{width:100%;border-collapse:collapse;background:#fff;border:1px solid var(--rule);font-size:13px}th,td{padding:9px 12px;text-align:left;border-bottom:1px solid var(--rule);font-family:monospace}th{background:rgba(0,0,0,.03);font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute);font-weight:500}td.name{font-family:Cormorant Garamond,serif;font-size:16px}td a{color:var(--accent)}.tag{font-size:9px;letter-spacing:.16em;color:var(--mute);text-transform:uppercase;background:rgba(0,0,0,.04);padding:2px 6px}.tag.found{background:var(--accent);color:#fff}.tag.miss{background:var(--err);color:#fff}.refresh{position:fixed;top:12px;right:14px;font-family:monospace;font-size:9px;letter-spacing:.16em;color:var(--mute);text-transform:uppercase}</style></head>
<body>
<div class="refresh">Auto-refresh · 30s</div>
<div class="crumb">Admin · Exa Enrichment Fleet</div>
<h1>Enrichment <em>flow.</em></h1>
<p style="color:var(--mute);margin-bottom:32px">${totals.total_biz.toLocaleString()} businesses total · ${totals.with_website.toLocaleString()} with website (${(totals.with_website/totals.total_biz*100).toFixed(1)}%)</p>

<h2>Per-kind progress</h2>
<table>
  <thead><tr><th>Kind</th><th>Found</th><th>Not found</th><th>Pending</th><th>Errored</th><th>Total tried</th></tr></thead>
  <tbody>
  ${byKind.length === 0 ? '<tr><td colspan="6" style="text-align:center;padding:32px;color:var(--mute)">No jobs yet — agents starting up.</td></tr>' :
    byKind.map(r => `<tr><td class="name">${esc(r.kind)}</td><td><span class="tag found">${r.found || 0}</span></td><td><span class="tag miss">${r.missed || 0}</span></td><td>${r.pending || 0}</td><td>${r.errored || 0}</td><td><b>${r.total}</b></td></tr>`).join('')
  }
  </tbody>
</table>

<h2>Recently-found websites · ${recentWebsites.length}</h2>
<table>
  <thead><tr><th>Shop</th><th>Neighborhood</th><th>Website</th><th>When</th></tr></thead>
  <tbody>
  ${recentWebsites.length === 0 ? '<tr><td colspan="4" style="text-align:center;padding:24px;color:var(--mute)">no website hits yet</td></tr>' :
    recentWebsites.map(r => `<tr><td class="name"><a href="/biz/${esc(r.slug)}">${esc(r.name)}</a></td><td>${esc(r.neighborhood || '')}</td><td><a href="${esc(r.found_url)}" target="_blank" rel="noopener">${esc(r.found_url)}</a></td><td>${new Date(r.attempted_at).toLocaleString('en-US',{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'})}</td></tr>`).join('')
  }
  </tbody>
</table>

<h2>Top article publishers</h2>
<div class="grid">
  ${topPublishers.length === 0 ? '<div class="card" style="grid-column:1/-1;color:var(--mute)">no articles yet</div>' :
    topPublishers.map(r => `<div class="card"><h3>${esc(r.publisher)}</h3><div class="v">${r.count}<em>articles</em></div></div>`).join('')
  }
</div>

<h2>Recent articles · ${recentArticles.length}</h2>
<table>
  <thead><tr><th>Shop</th><th>Title</th><th>Publisher</th></tr></thead>
  <tbody>
  ${recentArticles.length === 0 ? '<tr><td colspan="3" style="text-align:center;padding:24px;color:var(--mute)">no articles yet</td></tr>' :
    recentArticles.map(r => `<tr><td class="name"><a href="/biz/${esc(r.slug)}">${esc(r.name)}</a></td><td><a href="${esc(r.url)}" target="_blank" rel="noopener">${esc(r.title || r.url)}</a></td><td>${esc(r.publisher || '?')}</td></tr>`).join('')
  }
  </tbody>
</table>

<h2>Recent socials · ${recentSocials.length}</h2>
<table>
  <thead><tr><th>Shop</th><th>Platform</th><th>Handle / URL</th></tr></thead>
  <tbody>
  ${recentSocials.length === 0 ? '<tr><td colspan="3" style="text-align:center;padding:24px;color:var(--mute)">no socials yet</td></tr>' :
    recentSocials.map(r => `<tr><td class="name"><a href="/biz/${esc(r.slug)}">${esc(r.name)}</a></td><td><span class="tag">${esc(r.platform)}</span></td><td><a href="${esc(r.url)}" target="_blank" rel="noopener">${esc(r.handle || r.url)}</a></td></tr>`).join('')
  }
  </tbody>
</table>
</body></html>`);
});

// POST /api/admin/enrich — bulk-persist Exa-agent findings. ADMIN-token
// gated (otherwise an attacker could inject arbitrary URLs into
// businesses.website + business_socials).
//
// Body shape (one batch from one exa-agent):
//   {
//     findings: [
//       { slug, kind:'website',  url, source?:'exa', raw_json? }
//       { slug, kind:'instagram', url, handle?, follower_count?, source?, raw_json? }
//       { slug, kind:'articles', items:[{url,title,publisher?,published_at?,excerpt?}] }
//       { slug, kind:'website',  status:'not_found' }    // negative result
//     ]
//   }
const KIND_TO_PLATFORM = {
  instagram:'instagram', tiktok:'tiktok', facebook:'facebook', x:'x', twitter:'x',
  bluesky:'bluesky', pinterest:'pinterest', yelp:'yelp', threads:'threads', youtube:'youtube',
};
app.post('/api/admin/enrich', async (req, res) => {
  if (!requireAdminJson(req, res)) return;
  try {
    const findings = Array.isArray(req.body?.findings) ? req.body.findings : [];
    if (!findings.length) return res.status(400).json({ error: 'findings array required' });

    let persisted = { website:0, social:0, article:0, job:0, skipped:0 };
    for (const f of findings) {
      const slug = String(f.slug || '').slice(0, 200);
      const kind = String(f.kind || '').toLowerCase();
      if (!slug || !kind) { persisted.skipped++; continue; }
      const biz = await loadBySlug(slug);
      if (!biz) { persisted.skipped++; continue; }

      // Mark a job entry regardless of outcome (so we know we tried).
      // Operator-precedence fix per reviewer: previous expression
      // `f.status || (f.url || (f.items?.length) ? 'found' : 'not_found')`
      // bound the `?:` to (f.url || items.length), incorrectly evaluating
      // `'not_found'` for the articles-only case. Use explicit parens.
      const hasFinding = !!(f.url || (f.items && f.items.length > 0) || f.handle);
      const status = f.status || (hasFinding ? 'found' : 'not_found');
      const url    = String(f.url || '').slice(0, 600) || null;
      const handle = f.handle ? String(f.handle).slice(0, 120) : null;
      await query(
        `INSERT INTO enrichment_jobs (business_id, kind, status, found_url, found_handle, hit_count, raw_json, attempted_at, attempts, source)
         VALUES ($1, $2, $3, $4, $5, $6, $7, now(), 1, $8)
         ON CONFLICT (business_id, kind) DO UPDATE SET
           status = EXCLUDED.status,
           found_url = COALESCE(EXCLUDED.found_url, enrichment_jobs.found_url),
           found_handle = COALESCE(EXCLUDED.found_handle, enrichment_jobs.found_handle),
           hit_count = COALESCE(EXCLUDED.hit_count, enrichment_jobs.hit_count),
           raw_json = COALESCE(EXCLUDED.raw_json, enrichment_jobs.raw_json),
           attempted_at = now(),
           attempts = enrichment_jobs.attempts + 1,
           updated_at = now()`,
        [biz.id, kind, status, url, handle, f.items ? f.items.length : null,
         f.raw_json ? JSON.stringify(f.raw_json) : null, f.source || 'exa']
      );
      persisted.job++;

      // Apply the actual data to the business / socials / articles tables.
      if (kind === 'dba_name' && handle) {
        // DBA = "doing business as". BBCB licensee names are often the
        // owner's name ("WILKINS BERNIE") while the shop publicly trades as
        // a different brand ("Midway Barbershop"). Persist into JSON so we
        // never overwrite the canonical licensee name.
        await query(
          `UPDATE businesses
              SET source_data_json = jsonb_set(COALESCE(source_data_json, '{}'::jsonb), '{dba_name}', to_jsonb($1::text)),
                  updated_at = now()
            WHERE id = $2`,
          [handle, biz.id]
        );
      } else if (kind === 'website' && url && /^https?:\/\//i.test(url)) {
        // Only fill if the field is currently empty — never overwrite a
        // human-curated URL with an Exa guess.
        await query(
          `UPDATE businesses
              SET website = COALESCE(NULLIF(website, ''), $1),
                  source_data_json = jsonb_set(COALESCE(source_data_json, '{}'::jsonb), '{website_source}', '"exa"'),
                  updated_at = now()
            WHERE id = $2`,
          [url, biz.id]
        );
        persisted.website++;
      } else if (KIND_TO_PLATFORM[kind] && (url || handle)) {
        const platform = KIND_TO_PLATFORM[kind];
        await query(
          `INSERT INTO business_socials (business_id, platform, handle, url, raw_json, scraped_at)
           VALUES ($1, $2, $3, $4, $5, now())
           ON CONFLICT (business_id, platform) DO UPDATE SET
             handle = COALESCE(EXCLUDED.handle, business_socials.handle),
             url = COALESCE(EXCLUDED.url, business_socials.url),
             raw_json = COALESCE(EXCLUDED.raw_json, business_socials.raw_json),
             scraped_at = now()`,
          [biz.id, platform, handle, url, f.raw_json ? JSON.stringify(f.raw_json) : null]
        );
        persisted.social++;
      } else if (kind === 'articles' && Array.isArray(f.items) && f.items.length) {
        for (const it of f.items.slice(0, 30)) {
          const aurl = String(it.url || '').slice(0, 600);
          if (!/^https?:\/\//i.test(aurl)) continue;
          await query(
            `INSERT INTO business_articles (business_id, url, title, publisher, published_at, excerpt, source, raw_json)
             VALUES ($1, $2, $3, $4, $5, $6, 'exa', $7)
             ON CONFLICT (business_id, url) DO UPDATE SET
               title = COALESCE(EXCLUDED.title, business_articles.title),
               publisher = COALESCE(EXCLUDED.publisher, business_articles.publisher),
               published_at = COALESCE(EXCLUDED.published_at, business_articles.published_at),
               excerpt = COALESCE(EXCLUDED.excerpt, business_articles.excerpt)`,
            [biz.id, aurl, it.title?.slice(0, 240) || null, it.publisher?.slice(0, 120) || null,
             it.published_at || null, it.excerpt?.slice(0, 600) || null,
             JSON.stringify(it)]
          );
          persisted.article++;
        }
      }
    }
    res.json({ ok: true, persisted });
  } catch (e) {
    fail(res, 'admin/enrich', e);
  }
});

// GET /api/admin/enrich-batch?limit=50&kind=website — pull a batch of shops
// that need enrichment for a given kind. Used by exa-agents as their work-
// queue source. Skips shops we've already tried for that kind (status≠pending).
app.get('/api/admin/enrich-batch', async (req, res) => {
  if (!requireAdminJson(req, res)) return;
  const kind = String(req.query.kind || 'website').toLowerCase();
  const limit = Math.min(200, Math.max(1, parseInt(req.query.limit || '50', 10)));
  const tier = String(req.query.tier || 'sole_prop').toLowerCase();
  // Tier picker — start with the highest-value cohort. press_eligible
  // narrows to hoods where editorial coverage actually concentrates (per
  // Wave-2 B finding: suburban shops have ~0% press hit rate).
  const PRESS_HOODS = "ARRAY['Hollywood','East Hollywood','Hollywood Hills','Silver Lake','Echo Park','Highland Park','Koreatown','Mid-Wilshire','Downtown LA','Venice','Mar Vista','Beverly Hills','West Hollywood','Westwood','Brentwood','Pacific Palisades','Santa Monica','Culver City','Los Feliz','Atwater Village','Eagle Rock','Pasadena','South Pasadena','Bel-Air','Boyle Heights','Lincoln Heights','Studio City','Sherman Oaks','Beverly Grove']";
  const tierFilter = {
    sole_prop:      `AND b.source_data_json->>'shop_personality' = 'sole_prop'`,
    corridor:       `AND b.source_data_json->>'corridor' IS NOT NULL`,
    geocoded:       `AND b.latitude IS NOT NULL`,
    licensed:       `AND b.source_data_json->>'source' = 'dca_bbcb'`,
    press_eligible: `AND b.neighborhood = ANY(${PRESS_HOODS})`,
    all:            ``,
  }[tier] ?? '';
  const rows = await many(
    `SELECT b.id, b.slug, b.name, b.address, b.city, b.neighborhood, b.zip
       FROM businesses b
       LEFT JOIN enrichment_jobs j
         ON j.business_id = b.id AND j.kind = $1
      WHERE j.id IS NULL
        ${tierFilter}
      ORDER BY b.id
      LIMIT $2`,
    [kind, limit]
  );
  res.json({ ok: true, kind, tier, count: rows.length, batch: rows });
});

// POST /api/biz/review-source — opt-in review-source registration. Owners
// or fans submit a Reddit thread, Discord invite, Yelp page, etc. Goes into
// a moderation queue (verified=false) until admin flips the flag.
//
// Body: { slug, source_type, url, label?, submitted_by? }
const ALLOWED_SOURCE_TYPES = new Set([
  'reddit','discord','yelp','google','youtube','tiktok','instagram','facebook',
  'nextdoor','bluesky','x','pinterest','threads','substack','website','other',
]);
app.post('/api/biz/review-source', async (req, res) => {
  try {
    const { slug, source_type, url, label, submitted_by } = req.body || {};
    if (!slug || typeof slug !== 'string') return res.status(400).json({ error: 'slug required' });
    if (!ALLOWED_SOURCE_TYPES.has(String(source_type || '').toLowerCase())) {
      return res.status(400).json({ error: 'unknown source_type', allowed: [...ALLOWED_SOURCE_TYPES] });
    }
    // URL must be http(s) and bounded — keeps form-spam from injecting javascript: URIs.
    const cleanUrl = String(url || '').trim().slice(0, 600);
    if (!/^https?:\/\//i.test(cleanUrl)) return res.status(400).json({ error: 'url must be http(s)' });
    const biz = await loadBySlug(slug);
    if (!biz) return res.status(404).json({ error: 'unknown business' });
    const r = await query(
      `INSERT INTO business_review_sources (business_id, source_type, url, label, submitted_by)
       VALUES ($1, $2, $3, $4, $5)
       ON CONFLICT (business_id, url) DO UPDATE SET
         label = COALESCE(EXCLUDED.label, business_review_sources.label),
         submitted_by = COALESCE(business_review_sources.submitted_by, EXCLUDED.submitted_by),
         updated_at = now()
       RETURNING id, verified`,
      [biz.id, String(source_type).toLowerCase(), cleanUrl, label ? String(label).slice(0, 80) : null, submitted_by ? String(submitted_by).slice(0, 120) : null]
    );
    res.json({ ok: true, id: r.rows[0].id, verified: r.rows[0].verified, queued: !r.rows[0].verified });
  } catch (e) {
    fail(res, 'biz/review-source', e);
  }
});

// GET /api/biz/:slug/panel — combined payload for the drive-map side panel.
// Returns the slim shape the corridor-map UI needs: socials, articles (top 5),
// verified review-sources. One round trip, no fan-out from the client.
app.get('/api/biz/:slug/panel', async (req, res) => {
  const biz = await loadBySlug(req.params.slug);
  if (!biz) return res.status(404).json({ error: 'unknown business' });
  const [socials, articles, reviewSources] = await Promise.all([
    many(`SELECT platform, handle, url FROM business_socials WHERE business_id=$1 AND url IS NOT NULL ORDER BY platform`, [biz.id]),
    many(`SELECT url, title, publisher, published_at FROM business_articles WHERE business_id=$1 ORDER BY published_at DESC NULLS LAST, created_at DESC LIMIT 5`, [biz.id]),
    many(`SELECT source_type, url, label FROM business_review_sources WHERE business_id=$1 AND verified=true ORDER BY source_type`, [biz.id]),
  ]);
  res.json({ ok: true, slug: biz.slug, name: biz.name, website: biz.website || null, socials, articles, review_sources: reviewSources });
});

// GET /api/biz/:slug/review-sources — return verified sources for client use.
app.get('/api/biz/:slug/review-sources', async (req, res) => {
  const biz = await loadBySlug(req.params.slug);
  if (!biz) return res.status(404).json({ error: 'unknown business' });
  const rows = await many(
    `SELECT source_type, url, label
       FROM business_review_sources
      WHERE business_id = $1 AND verified = TRUE
      ORDER BY source_type, created_at`,
    [biz.id]
  );
  res.json({ ok: true, sources: rows });
});

// /biz/:slug/links — public-facing opt-in form. Anyone (owner, fan, journalist)
// can submit a Reddit/Discord/Yelp/etc. URL. The submission goes into the
// moderation queue (verified=false) until an admin approves it.
app.get('/biz/:slug/links', async (req, res) => {
  const biz = await loadBySlug(req.params.slug);
  if (!biz) return res.status(404).send('Unknown business');
  const existing = await many(
    `SELECT source_type, url, label, verified, created_at
       FROM business_review_sources
      WHERE business_id = $1
      ORDER BY verified DESC, created_at DESC`,
    [biz.id]
  );
  const verifiedRows = existing.filter(r => r.verified);
  const queuedRows   = existing.filter(r => !r.verified);
  res.set('content-type', 'text/html; charset=utf-8');
  res.send(`<!doctype html><html lang="en"><head><meta charset="utf-8">
<title>Add a link · ${esc(biz.name)} · LA Salon Directory</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="robots" content="noindex">
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>:root{--ink:#1a1a1a;--paper:#f5f5f5;--rule:#e6e3dc;--mute:#7a766f;--accent:#5a6e3a;--gold:#b8945a}*{box-sizing:border-box;margin:0;padding:0}body{background:var(--paper);color:var(--ink);font-family:Inter,system-ui;line-height:1.5;-webkit-font-smoothing:antialiased}main{max-width:640px;margin:0 auto;padding:64px 28px}h1{font-family:Cormorant Garamond,serif;font-size:42px;font-weight:500;line-height:1.05;letter-spacing:-.015em;margin-bottom:8px}h1 em{color:var(--accent);font-style:italic}.crumb{font-family:monospace;font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute);margin-bottom:24px}.lede{color:#3a3a3a;font-size:16px;margin-bottom:36px;max-width:60ch}form{display:grid;gap:14px;margin-bottom:48px}label{display:block;font-family:monospace;font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--mute);margin-bottom:4px}input,select{width:100%;padding:11px 14px;border:1px solid var(--rule);background:#fff;font-size:15px;font-family:inherit}button{padding:14px 24px;border:none;background:var(--accent);color:var(--paper);font-family:monospace;font-size:11px;letter-spacing:.22em;text-transform:uppercase;cursor:pointer;margin-top:6px}button:hover{background:var(--ink)}h2{font-family:Cormorant Garamond,serif;font-size:24px;font-weight:500;margin:32px 0 12px}.list{list-style:none}.list li{padding:11px 14px;border:1px solid var(--rule);background:#fff;margin-bottom:6px;font-family:monospace;font-size:12px;display:flex;justify-content:space-between;align-items:center;gap:12px}.list li.q{background:#fff8e6;border-color:#e8d896}.list li a{color:var(--accent);overflow:hidden;text-overflow:ellipsis}.tag{font-size:9px;letter-spacing:.18em;color:var(--mute);text-transform:uppercase;background:rgba(0,0,0,.04);padding:2px 6px}.tag.q{background:var(--gold);color:var(--ink)}.back{font-family:monospace;font-size:11px;letter-spacing:.16em;color:var(--mute);text-transform:uppercase;margin-top:48px;display:inline-block}.back a{color:var(--accent)}#out{font-family:monospace;font-size:13px;color:var(--accent);min-height:18px;margin-top:8px}</style></head>
<body><main>
  <div class="crumb">${esc(biz.neighborhood || biz.city || '')} · Public submission form</div>
  <h1>Add a <em>link</em>.<br>${esc(biz.name)}.</h1>
  <p class="lede">Got a Reddit thread, Discord server, Yelp page, YouTube review, or another community discussion about this business? Drop the link. We moderate, then surface verified sources on the public profile and the drive-mode map.</p>
  <form id="f">
    <input type="hidden" name="slug" value="${esc(biz.slug)}">
    <div><label>Source type</label>
      <select name="source_type" required>
        <option value="">— pick one —</option>
        ${[...ALLOWED_SOURCE_TYPES].map(t => `<option value="${t}">${t}</option>`).join('')}
      </select>
    </div>
    <div><label>URL</label><input name="url" type="url" placeholder="https://reddit.com/r/LosAngeles/..." required></div>
    <div><label>Label (optional)</label><input name="label" placeholder="r/LosAngeles · Best fade in K-Town?" maxlength="80"></div>
    <div><label>Submitted by (optional, your name or email)</label><input name="submitted_by" placeholder="optional" maxlength="120"></div>
    <button type="submit">Submit for moderation</button>
    <div id="out" aria-live="polite"></div>
  </form>
  ${verifiedRows.length ? `<h2>Verified sources</h2><ul class="list">${verifiedRows.map(r => `<li><span><span class="tag">${esc(r.source_type)}</span> <a href="${esc(r.url)}" target="_blank" rel="noopener">${esc(r.label || r.url)}</a></span></li>`).join('')}</ul>` : ''}
  ${queuedRows.length ? `<h2>In moderation queue</h2><ul class="list">${queuedRows.map(r => `<li class="q"><span><span class="tag q">${esc(r.source_type)}</span> <a href="${esc(r.url)}" target="_blank" rel="noopener">${esc(r.label || r.url)}</a></span><span class="tag">queued</span></li>`).join('')}</ul>` : ''}
  <a class="back" href="/biz/${esc(biz.slug)}">← back to profile</a>
</main>
<script>
document.getElementById('f').addEventListener('submit', async (e) => {
  e.preventDefault();
  const fd = new FormData(e.target);
  const body = Object.fromEntries(fd.entries());
  const out = document.getElementById('out');
  out.textContent = 'submitting…';
  try {
    const r = await fetch('/api/biz/review-source', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify(body) });
    const j = await r.json();
    if (j.ok) {
      out.textContent = j.queued ? '✓ submitted — pending moderation' : '✓ verified, live';
      setTimeout(() => location.reload(), 1200);
    } else {
      out.textContent = '✗ ' + (j.error || 'error');
      out.style.color = '#c2410c';
    }
  } catch (err) {
    out.textContent = '✗ network error';
    out.style.color = '#c2410c';
  }
});
</script></body></html>`);
});

// /proprietors — every sole-prop barber/salon flagged by the owner-name
// heuristic, grouped by neighborhood. Unique-angle SEO play: nobody else
// publishes a chairs-where-the-license-is-a-person directory.
app.get('/proprietors', async (_req, res) => {
  const businesses = await many(
    `SELECT slug, name, category, neighborhood, city, address
       FROM businesses
      WHERE source_data_json->>'shop_personality' = 'sole_prop'
      ORDER BY neighborhood NULLS LAST, name`
  );
  const hoodCounts = await many(
    `SELECT neighborhood, COUNT(*)::int AS count
       FROM businesses
      WHERE source_data_json->>'shop_personality' = 'sole_prop'
        AND neighborhood IS NOT NULL AND neighborhood <> ''
      GROUP BY neighborhood
      ORDER BY count DESC`
  );
  const { renderProprietors } = await import('../render/proprietors.js');
  res.set('content-type', 'text/html; charset=utf-8');
  res.send(renderProprietors({ businesses, hoodCounts }));
});

// /search?q=... — fast LIKE-based search across name + neighborhood + city.
// Editorial JSON results designed to be embeddable on any future landing
// page; the endpoint also doubles as the Google SearchAction target.
app.get('/search', async (req, res) => {
  const q = String(req.query.q || '').trim().slice(0, 80);
  if (!q || q.length < 2) {
    res.set('content-type', 'text/html; charset=utf-8');
    return res.send(`<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Search · LA Salon Directory</title><style>body{font-family:Inter,system-ui;margin:0;padding:80px 32px;background:#f5f5f5;color:#1a1a1a}form{max-width:560px;margin:0 auto;display:flex;gap:12px}input{flex:1;padding:14px 18px;font-size:18px;border:1px solid #ccc;background:#fff}button{padding:14px 28px;border:none;background:#5a6e3a;color:#f5f5f5;font-size:14px;letter-spacing:.18em;text-transform:uppercase;cursor:pointer}h1{text-align:center;font-family:Cormorant Garamond,serif;font-weight:500;font-size:48px;margin-bottom:48px}.back{text-align:center;margin-top:32px;font-size:13px;color:#888}.back a{color:#5a6e3a}</style></head><body><h1>Search the directory</h1><form method="get" action="/search"><input name="q" placeholder="Try: silver lake barber, koreatown nails, sherman oaks salon" autofocus><button>Search</button></form><div class="back"><a href="/">← Home</a></div></body></html>`);
  }
  // ILIKE on indexed-or-not is fine at our scale (27k rows, sub-50ms).
  const pattern = `%${q.replace(/[%_]/g, ch => '\\' + ch)}%`;
  const rows = await many(
    `SELECT slug, name, category, neighborhood, city, address,
            (source_data_json->>'source' = 'dca_bbcb') AS licensed
       FROM businesses
      WHERE name ILIKE $1
         OR neighborhood ILIKE $1
         OR city ILIKE $1
      ORDER BY (source_data_json->>'source' = 'dca_bbcb') DESC NULLS LAST,
               LENGTH(name) ASC,
               name ASC
      LIMIT 100`,
    [pattern]
  );
  res.set('content-type', 'text/html; charset=utf-8');
  res.send(`<!doctype html><html lang="en"><head><meta charset="utf-8">
<title>${esc(q)} · Search · LA Salon Directory</title>
<meta name="robots" content="noindex, follow">
<style>:root{--ink:#1a1a1a;--paper:#f5f5f5;--rule:#e6e3dc;--mute:#7a766f;--accent:#5a6e3a;--gold:#b8945a}*{box-sizing:border-box;margin:0;padding:0}body{background:var(--paper);color:var(--ink);font-family:Inter,system-ui;-webkit-font-smoothing:antialiased}a{color:inherit;text-decoration:none}.bar{padding:18px 32px;border-bottom:1px solid var(--rule);display:flex;gap:24px;align-items:center;background:#fff;position:sticky;top:0;z-index:5}.bar form{flex:1;display:flex;gap:8px}.bar input{flex:1;padding:10px 14px;border:1px solid var(--rule);font-size:15px}.bar button{padding:10px 20px;border:none;background:var(--accent);color:#fff;font-size:11px;letter-spacing:.18em;text-transform:uppercase}.brand{font-family:Cormorant Garamond,serif;font-size:20px;font-weight:500}.brand em{color:var(--accent);font-style:italic}.results{max-width:1100px;margin:0 auto;padding:48px 32px}.r-head{font-family:monospace;font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--mute);margin-bottom:24px;padding-bottom:12px;border-bottom:1px solid var(--rule)}.row{display:grid;grid-template-columns:1fr 200px 120px;gap:24px;align-items:baseline;padding:18px 0;border-bottom:1px dotted var(--rule);transition:padding 150ms}.row:hover{padding-left:8px;background:#fff}.r-name{font-family:Cormorant Garamond,serif;font-size:22px;font-weight:500}.r-name em{font-style:italic;color:var(--mute);font-size:12px;font-family:monospace;letter-spacing:.06em;margin-left:8px;text-transform:uppercase}.r-where{font-family:monospace;font-size:10px;color:var(--mute);text-transform:uppercase;letter-spacing:.04em}.r-cat{font-family:monospace;font-size:10px;color:var(--accent);letter-spacing:.18em;text-transform:uppercase;text-align:right}.r-cat .lic{background:var(--gold);color:var(--ink);padding:2px 6px;font-weight:700}.empty{text-align:center;padding:48px;font-family:Cormorant Garamond,serif;font-style:italic;font-size:24px;color:var(--mute)}@media (max-width:880px){.row{grid-template-columns:1fr;gap:4px}}</style></head>
<body><nav class="bar"><a class="brand" href="/">LA <em>Salon</em> Directory</a><form method="get" action="/search"><input name="q" value="${esc(q)}" autofocus><button>Search</button></form></nav>
<section class="results">
<div class="r-head">${rows.length} result${rows.length===1?'':'s'} for "${esc(q)}"</div>
${rows.length === 0 ? `<div class="empty">No matches. Try a neighborhood ("silver lake"), category ("nails"), or shop name.</div>` :
  rows.map(r => `<a class="row" href="/biz/${esc(r.slug)}">
    <span class="r-name">${esc(r.name)}<em>${esc(r.category || '')}</em></span>
    <span class="r-where">${esc(r.neighborhood || r.city || '')}${r.address ? ' · ' + esc(r.address) : ''}</span>
    <span class="r-cat">${r.licensed ? '<span class="lic">◆ Licensed</span>' : ''}</span>
  </a>`).join('')
}
</section></body></html>`);
});

// robots.txt — point Google at the sitemap. Block /admin and /biz/*/edit
// so the editor URLs don't end up in search results.
app.get('/robots.txt', (req, res) => {
  const host = req.get('host') || `127.0.0.1:${PORT}`;
  const proto = req.get('x-forwarded-proto') || 'http';
  res.set('content-type', 'text/plain; charset=utf-8');
  res.send(`User-agent: *
Disallow: /admin
Disallow: /api/
Disallow: /biz/*/edit
Disallow: /biz/*/interview
Allow: /

Sitemap: ${proto}://${host}/sitemap.xml
`);
});

// sitemap.xml — chunked into a top-level sitemap-index that points at three
// per-section sitemaps (shops, neighborhoods, corridors+categories+landings).
// Splitting keeps each <urlset> well under Google's 50k-URL/50MB limit and
// lets us refresh shops independently when the catalog grows.
app.get('/sitemap.xml', async (req, res) => {
  const host = req.get('host') || `127.0.0.1:${PORT}`;
  const proto = req.get('x-forwarded-proto') || 'http';
  const base = `${proto}://${host}`;
  const lastMod = new Date().toISOString().slice(0, 10);
  res.set('content-type', 'application/xml; charset=utf-8');
  res.send(`<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<sitemap><loc>${base}/sitemap-pages.xml</loc><lastmod>${lastMod}</lastmod></sitemap>
<sitemap><loc>${base}/sitemap-neighborhoods.xml</loc><lastmod>${lastMod}</lastmod></sitemap>
<sitemap><loc>${base}/sitemap-shops.xml</loc><lastmod>${lastMod}</lastmod></sitemap>
<sitemap><loc>${base}/sitemap-best.xml</loc><lastmod>${lastMod}</lastmod></sitemap>
</sitemapindex>`);
});

// "Best" sitemap = every /best/:hood and /best/:hood/:cat permutation. These are
// the SEO bigfoot URLs — verbatim Google query patterns ("best hair salons in X").
app.get('/sitemap-best.xml', async (req, res) => {
  const host = req.get('host') || `127.0.0.1:${PORT}`;
  const proto = req.get('x-forwarded-proto') || 'http';
  const base = `${proto}://${host}`;
  const lastMod = new Date().toISOString().slice(0, 10);
  const grid = await many(
    `SELECT neighborhood,
            COUNT(*) FILTER (WHERE category = 'salon')      AS salons,
            COUNT(*) FILTER (WHERE category = 'barbershop') AS barbershops,
            COUNT(*) FILTER (WHERE category = 'spa')        AS spas,
            COUNT(*) FILTER (WHERE category = 'nail')       AS nails
       FROM businesses
      WHERE neighborhood IS NOT NULL AND neighborhood <> ''
      GROUP BY neighborhood
      HAVING COUNT(*) >= 5
      ORDER BY neighborhood`
  );
  const slug = s => String(s).toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
  const urls = ['/best'];
  for (const g of grid) {
    const s = slug(g.neighborhood);
    urls.push(`/best/${s}`);
    if (Number(g.salons)      > 0) urls.push(`/best/${s}/salons`);
    if (Number(g.barbershops) > 0) urls.push(`/best/${s}/barbershops`);
    if (Number(g.spas)        > 0) urls.push(`/best/${s}/spas`);
    if (Number(g.nails)       > 0) urls.push(`/best/${s}/nail-salons`);
  }
  res.set('content-type', 'application/xml; charset=utf-8');
  res.send(`<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls.map(u => `<url><loc>${base}${u}</loc><lastmod>${lastMod}</lastmod><changefreq>weekly</changefreq><priority>0.8</priority></url>`).join('\n')}
</urlset>`);
});

// "Pages" sitemap = static-ish landing pages (corridors, categories, /fresh,
// /map, /neighborhoods, /).
app.get('/sitemap-pages.xml', async (req, res) => {
  const host = req.get('host') || `127.0.0.1:${PORT}`;
  const proto = req.get('x-forwarded-proto') || 'http';
  const base = `${proto}://${host}`;
  const lastMod = new Date().toISOString().slice(0, 10);
  const cats = await many(
    `SELECT DISTINCT category FROM businesses WHERE category IS NOT NULL AND category <> '' ORDER BY 1`
  );
  const corridors = ['ventura-blvd','sunset-blvd','wilshire-blvd','hollywood-blvd'];
  const urls = [
    '/', '/neighborhoods', '/map', '/fresh', '/proprietors', '/search',
    ...corridors.map(c => `/corridor/${c}`),
    ...cats.map(c => `/c/${c.category}`),
  ];
  res.set('content-type', 'application/xml; charset=utf-8');
  res.send(`<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls.map(u => `<url><loc>${base}${u}</loc><lastmod>${lastMod}</lastmod><changefreq>weekly</changefreq></url>`).join('\n')}
</urlset>`);
});

// "Neighborhoods" sitemap = one URL per /n/<slug> page.
app.get('/sitemap-neighborhoods.xml', async (req, res) => {
  const host = req.get('host') || `127.0.0.1:${PORT}`;
  const proto = req.get('x-forwarded-proto') || 'http';
  const base = `${proto}://${host}`;
  const lastMod = new Date().toISOString().slice(0, 10);
  const rows = await many(
    `SELECT DISTINCT neighborhood FROM businesses
      WHERE neighborhood IS NOT NULL AND neighborhood <> ''
      ORDER BY 1`
  );
  const urls = rows.map(r => `/n/${slugify(r.neighborhood)}`);
  res.set('content-type', 'application/xml; charset=utf-8');
  res.send(`<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls.map(u => `<url><loc>${base}${u}</loc><lastmod>${lastMod}</lastmod><changefreq>weekly</changefreq></url>`).join('\n')}
</urlset>`);
});

// "Shops" sitemap = one URL per /biz/<slug>. Capped at 50k URLs per Google's
// limit; if the catalog grows past that we'll need to chunk into shops-1,
// shops-2, etc.
app.get('/sitemap-shops.xml', async (req, res) => {
  const host = req.get('host') || `127.0.0.1:${PORT}`;
  const proto = req.get('x-forwarded-proto') || 'http';
  const base = `${proto}://${host}`;
  const rows = await many(
    `SELECT slug, GREATEST(updated_at, created_at) AS lastmod
       FROM businesses
      ORDER BY lastmod DESC
      LIMIT 50000`
  );
  res.set('content-type', 'application/xml; charset=utf-8');
  res.send(`<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${rows.map(r => `<url><loc>${base}/biz/${r.slug}</loc><lastmod>${new Date(r.lastmod).toISOString().slice(0,10)}</lastmod></url>`).join('\n')}
</urlset>`);
});

// /fresh — newest BBCB-licensed shops first. The "what's new this week" feed.
// We sort by issue_date as a date (parsed from MM/DD/YYYY in JS), so the page
// is fully driven off source_data_json without a separate column.
app.get('/fresh', async (_req, res) => {
  const rows = await many(
    `SELECT slug,name,category,neighborhood,city,address,zip,source_data_json
       FROM businesses
      WHERE source_data_json->>'source' = 'dca_bbcb'
        AND source_data_json->>'issue_date' ~ '^[0-9]{1,2}[-/][0-9]{1,2}[-/][0-9]{4}$'`
  );
  // Parse issue_date once, sort desc, slice 100. JS Date works fine for
  // MM/DD/YYYY strings; invalid dates push to the bottom via NaN-as-0 fallback.
  const parseIssue = (s) => {
    // BBCB stores either "MM-DD-YYYY" or "MM/DD/YYYY" depending on export pass.
    const m = String(s || '').match(/^(\d{1,2})[-/](\d{1,2})[-/](\d{4})$/);
    if (!m) return 0;
    const [, mm, dd, yyyy] = m;
    return new Date(parseInt(yyyy,10), parseInt(mm,10)-1, parseInt(dd,10)).getTime();
  };
  const sorted = rows
    .map(r => ({ ...r, _issued: parseIssue(r.source_data_json?.issue_date) }))
    .filter(r => r._issued > 0 && r._issued <= Date.now())
    .sort((a, b) => b._issued - a._issued)
    .slice(0, 100);
  const { renderFresh } = await import('../render/fresh.js');
  res.set('content-type', 'text/html; charset=utf-8');
  res.send(renderFresh({ businesses: sorted }));
});

// /c/:category — category landing page (barbershop, salon, spa, …). Shows top
// neighborhoods for the category, featured shops (state-licensed first), and
// an alphabetical roll. SEO play for "best barbers in LA" / "nail salons LA".
app.get('/c/:category', async (req, res) => {
  const category = String(req.params.category || '').toLowerCase().replace(/[^\w]/g, '');
  if (!category) return res.status(404).send('Unknown category');
  const businesses = await many(
    `SELECT slug,
            COALESCE(NULLIF(source_data_json->>'dba_name',''), name) AS name,
            category,address,city,state,zip,neighborhood,latitude,longitude,phone,website,hero_image_url,source_data_json
       FROM businesses
      WHERE category = $1
      ORDER BY (source_data_json->>'source' = 'dca_bbcb') DESC NULLS LAST,
               neighborhood NULLS LAST,
               name`,
    [category]
  );
  if (!businesses.length) return res.status(404).send('Unknown or empty category');
  const hoodCounts = await many(
    `SELECT neighborhood, COUNT(*)::int AS count
       FROM businesses
      WHERE category = $1 AND neighborhood IS NOT NULL AND neighborhood <> ''
      GROUP BY neighborhood
      ORDER BY count DESC
      LIMIT 50`,
    [category]
  );
  const totalLicensed = businesses.filter(b => b.source_data_json?.source === 'dca_bbcb').length;
  const { renderCategory } = await import('../render/category.js');
  res.set('content-type', 'text/html; charset=utf-8');
  res.send(renderCategory({ category, businesses, hoodCounts, totalLicensed }));
});

// /neighborhoods — full A-Z index of every neighborhood with at least one shop.
// One DB hit; aggregate at SQL layer for speed (160 rows, not 27k).
app.get('/neighborhoods', async (_req, res) => {
  const rows = await many(
    `SELECT neighborhood,
            COUNT(*)::int AS count,
            COUNT(*) FILTER (WHERE source_data_json->>'source' = 'dca_bbcb')::int AS licensed_count
       FROM businesses
      WHERE neighborhood IS NOT NULL AND neighborhood <> ''
      GROUP BY neighborhood
      ORDER BY count DESC`
  );
  const totals = await one(
    `SELECT COUNT(*)::int AS total_shops,
            COUNT(*) FILTER (WHERE source_data_json->>'source' = 'dca_bbcb')::int AS total_licensed
       FROM businesses
      WHERE neighborhood IS NOT NULL AND neighborhood <> ''`
  );
  const { renderNeighborhoods } = await import('../render/neighborhoods.js');
  res.set('content-type', 'text/html; charset=utf-8');
  res.send(renderNeighborhoods({
    neighborhoods: rows,
    totalShops: totals?.total_shops || 0,
    totalLicensed: totals?.total_licensed || 0,
  }));
});

// Original mockup-grid (admin/builder UI) lives at /builder now.
app.get('/builder', async (_req, res) => {
  const businesses = await many(
    `SELECT slug,name,category,city,state,tier,hero_image_url,color_primary,color_accent
     FROM businesses ORDER BY updated_at DESC LIMIT 12`
  );
  res.set('content-type', 'text/html; charset=utf-8');
  res.send(renderGrid({ businesses }));
});

// Neighborhood detail page — list the shops in one LA neighborhood, editorially.
// Corridor page — long linear stretch of road treated as the spine of a
// curated section. Currently only ventura-blvd-20mi is wired up.
// /corridor/:slug/map — drive-mode Leaflet map of one corridor with live
// geolocation, nearest-businesses panel, and click-to-side-panel detail.
// Reuses the same SQL as the editorial corridor page so any new corridor
// gets a /corridor/:slug/map for free.
app.get('/corridor/:slug/map', async (req, res) => {
  const slug = String(req.params.slug || '').toLowerCase();
  const tagMap = {
    'ventura-blvd': 'ventura-blvd-20mi', 'ventura-blvd-20mi': 'ventura-blvd-20mi',
    'sunset-blvd': 'sunset-blvd-22mi', 'sunset-blvd-22mi': 'sunset-blvd-22mi',
    'wilshire-blvd': 'wilshire-blvd-16mi', 'wilshire-blvd-16mi': 'wilshire-blvd-16mi',
    'hollywood-blvd': 'hollywood-blvd-5mi', 'hollywood-blvd-5mi': 'hollywood-blvd-5mi',
  };
  const corridorTag = tagMap[slug];
  if (!corridorTag) return res.status(404).send('Unknown corridor');
  const businesses = await many(
    `SELECT slug,
            COALESCE(NULLIF(source_data_json->>'dba_name',''), name) AS name,
            category,address,city,state,zip,neighborhood,
            latitude,longitude,phone,website,hero_image_url,source_data_json
       FROM businesses
      WHERE source_data_json->>'corridor' = $1
      ORDER BY longitude NULLS LAST, name`,
    [corridorTag]
  );
  // Pull the same CORRIDOR_META used by /corridor/:slug so we can inherit
  // accent + title without duplicating it server-side.
  const corridorMod = await import('../render/corridor.js');
  // CORRIDOR_META is module-internal; re-derive a tiny shim. The render
  // module also exports renderCorridor, so we just read what we need from
  // a scoped peek via a known-shape default fallback.
  const META = {
    'ventura-blvd-20mi':  { title:'Ventura Boulevard',   accent:'#5a6e3a' },
    'sunset-blvd-22mi':   { title:'Sunset Boulevard',    accent:'#c2410c' },
    'wilshire-blvd-16mi': { title:'Wilshire Boulevard',  accent:'#6b3a8e' },
    'hollywood-blvd-5mi': { title:'Hollywood Boulevard', accent:'#9b1c1c' },
  };
  const { renderCorridorMap } = await import('../render/corridor-map.js');
  res.set('content-type', 'text/html; charset=utf-8');
  res.send(renderCorridorMap({
    corridorTag,
    meta: META[corridorTag] || { title: 'Corridor', accent: '#5a6e3a' },
    businesses,
  }));
});

app.get('/corridor/:slug', async (req, res) => {
  const slug = String(req.params.slug || '').toLowerCase();
  const tagMap = {
    'ventura-blvd': 'ventura-blvd-20mi', 'ventura-blvd-20mi': 'ventura-blvd-20mi',
    'sunset-blvd': 'sunset-blvd-22mi', 'sunset-blvd-22mi': 'sunset-blvd-22mi',
    'wilshire-blvd': 'wilshire-blvd-16mi', 'wilshire-blvd-16mi': 'wilshire-blvd-16mi',
    'hollywood-blvd': 'hollywood-blvd-5mi', 'hollywood-blvd-5mi': 'hollywood-blvd-5mi',
  };
  const corridorTag = tagMap[slug];
  if (!corridorTag) return res.status(404).send('Unknown corridor');

  const { renderCorridor } = await import('../render/corridor.js');
  const businesses = await many(
    `SELECT slug,
            COALESCE(NULLIF(source_data_json->>'dba_name',''), name) AS name,
            category,address,city,state,zip,neighborhood,
            latitude,longitude,phone,website,hero_image_url,source_data_json
       FROM businesses
      WHERE source_data_json->>'corridor' = $1
      ORDER BY longitude NULLS LAST, name`,
    [corridorTag]
  );
  res.set('content-type', 'text/html; charset=utf-8');
  res.send(renderCorridor({ corridorTag, businesses }));
});

app.get('/n/:slug', async (req, res) => {
  const slug = String(req.params.slug || '').toLowerCase();
  // First, resolve slug → distinct neighborhood name(s). Pull only the unique
  // neighborhood strings rather than every business row, so this stays cheap
  // even as the catalog grows.
  const hoodRows = await many(
    `SELECT DISTINCT neighborhood FROM businesses WHERE neighborhood IS NOT NULL`
  );
  // Use the same slugify() that emits links elsewhere so the resolution rule is
  // single-sourced (diacritics/'/punctuation handling stays in lockstep).
  const matchedHoods = hoodRows
    .map(r => r.neighborhood)
    .filter(n => n && slugify(n) === slug);
  if (!matchedHoods.length) return res.status(404).send('Neighborhood not found');
  if (matchedHoods.length > 1) {
    log.err('neighborhood slug collision', { slug, matched: matchedHoods });
    return res.status(409).send(`Ambiguous neighborhood — ${matchedHoods.length} match this slug`);
  }
  const neighborhood = matchedHoods[0];
  const shops = await many(
    `SELECT slug,name,category,neighborhood,city,state,tier,hero_image_url,ranking_score
     FROM businesses WHERE neighborhood=$1
     ORDER BY ranking_score DESC NULLS LAST, updated_at DESC
     LIMIT 200`,
    [neighborhood]
  );
  const hoodCounts = await many(
    `SELECT neighborhood, COUNT(*)::int AS count FROM businesses
     WHERE neighborhood IS NOT NULL AND neighborhood <> $1
     GROUP BY neighborhood ORDER BY count DESC LIMIT 50`,
    [neighborhood]
  );
  // We render via renderHome with featured=barbers-in-this-hood + other=rest, slightly repurposed.
  const isBarber = (b) => b.category === 'barbershop' || b.category === 'beard';
  const featuredBarbers = shops.filter(isBarber);
  const featuredIds = new Set(featuredBarbers.map(b => b.slug));
  const otherShops = shops.filter(b => !featuredIds.has(b.slug));
  const neighborhoods = hoodCounts.map(r => ({ neighborhood: r.neighborhood, count: r.count }));

  res.set('content-type', 'text/html; charset=utf-8');
  res.send(renderHome({
    featuredBarbers, otherShops, neighborhoods,
    totalCount: shops.length,
  }));
});

// Editorial profile — interview Q&A is the centerpiece.
// (Mockup landing-pages still live at /biz/:slug/template/:id.)
app.get('/biz/:slug', async (req, res) => {
  const b = await loadBySlug(req.params.slug);
  if (!b) return res.status(404).send('Not found');
  const interview = await one(
    `SELECT qa_json, recorded_by, recorded_at FROM business_interviews
     WHERE business_id=$1 ORDER BY recorded_at DESC LIMIT 1`, [b.id]
  );
  const otherShopsInHood = b.neighborhood ? await many(
    `SELECT slug,name,category,neighborhood,hero_image_url FROM businesses
     WHERE neighborhood=$1 AND id<>$2
     ORDER BY ranking_score DESC NULLS LAST, updated_at DESC LIMIT 4`,
    [b.neighborhood, b.id]
  ) : [];
  // Pull Exa-enriched + community-submitted accessories: socials, articles,
  // and verified review-source links. profile.js renders these inline.
  const socials = await many(
    `SELECT platform, handle, url FROM business_socials
      WHERE business_id=$1 AND url IS NOT NULL
      ORDER BY platform`,
    [b.id]
  );
  const articles = await many(
    `SELECT url, title, publisher, published_at, excerpt
       FROM business_articles
      WHERE business_id=$1
      ORDER BY published_at DESC NULLS LAST, created_at DESC
      LIMIT 8`,
    [b.id]
  );
  const reviewSources = await many(
    `SELECT source_type, url, label
       FROM business_review_sources
      WHERE business_id=$1 AND verified=true
      ORDER BY source_type`,
    [b.id]
  );
  // Block clickjacking — foreign sites embedding the public profile in an
  // iframe could overlay invisible UI on top of the lead form.
  res.set('content-type', 'text/html; charset=utf-8');
  res.set('x-frame-options', 'SAMEORIGIN');
  res.set('content-security-policy', "frame-ancestors 'self'");
  res.send(renderProfile({ business: b, interview, otherShopsInHood, socials, articles, reviewSources }));
});

// Interview admin form — fill in the Q&A.
// Gated behind ADMIN_TOKEN — paired with the POST gate so anonymous visitors
// can't even reach the editing UI.
app.get('/biz/:slug/interview', async (req, res) => {
  if (!requireAdmin(req, res)) return;
  const b = await loadBySlug(req.params.slug);
  if (!b) return res.status(404).send('Not found');
  const interview = await one(
    `SELECT qa_json, recorded_by, recorded_at FROM business_interviews
     WHERE business_id=$1 ORDER BY recorded_at DESC LIMIT 1`, [b.id]
  );
  res.set('content-type', 'text/html; charset=utf-8');
  res.send(renderInterviewForm({ business: b, interview }));
});

// Live AI-avatar interview — webcam + speech. Browser-native, no server-side LLM.
// Same admin gate as the form route.
app.get('/biz/:slug/interview/live', async (req, res) => {
  if (!requireAdmin(req, res)) return;
  const b = await loadBySlug(req.params.slug);
  if (!b) return res.status(404).send('Not found');
  const interview = await one(
    `SELECT qa_json, recorded_by, recorded_at FROM business_interviews
     WHERE business_id=$1 ORDER BY recorded_at DESC LIMIT 1`, [b.id]
  );
  res.set('content-type', 'text/html; charset=utf-8');
  res.send(renderInterviewLive({ business: b, interview }));
});

// Webcam frame upload — saves a JPEG into /uploads, attaches as candidate hero.
app.post('/api/biz/photo/upload', async (req, res) => {
  try {
    const { slug, dataUrl, kind } = req.body || {};
    if (!slug || !dataUrl) return res.status(400).json({ error: 'slug + dataUrl required' });
    const b = await loadBySlug(slug);
    if (!b) return res.status(404).json({ error: 'business not found' });
    // Decode base64 from data URL.
    const m = String(dataUrl).match(/^data:(image\/(?:jpeg|png|webp));base64,(.+)$/);
    if (!m) return res.status(400).json({ error: 'invalid dataUrl (need image/jpeg|png|webp)' });
    const mime = m[1];
    const ext = mime === 'image/png' ? 'png' : (mime === 'image/webp' ? 'webp' : 'jpg');
    const buf = Buffer.from(m[2], 'base64');
    if (buf.length > 6 * 1024 * 1024) return res.status(413).json({ error: 'image too large' });
    const filename = `${slug}-${Date.now()}.${ext}`;
    const fullPath = path.join(UPLOADS_DIR, filename);
    fs.writeFileSync(fullPath, buf);
    const url = `/uploads/${filename}`;
    // If the business has no hero_image_url yet, adopt this one.
    if (!b.hero_image_url) {
      await query('UPDATE businesses SET hero_image_url=$1 WHERE id=$2', [url, b.id]);
    }
    log.info('photo uploaded', { slug, url, bytes: buf.length, kind: kind || 'unspecified' });
    res.json({ ok: true, url, bytes: buf.length, adopted_as_hero: !b.hero_image_url });
  } catch (e) {
    log.err('photo upload failed', { error: String(e.message || e) });
    res.status(500).json({ error: String(e.message || e) });
  }
});

// AI-draft an interview from existing context (about_text, vibe note, scraped meta).
// Hits local qwen3:14b on Mac Studio 1. Returns {qa: {...}} for the client to
// pre-fill the form — does NOT auto-save. Owner edits each [VERIFY] tag before saving.
app.post('/api/biz/interview/draft', async (req, res) => {
  try {
    const { slug } = req.body || {};
    if (!slug) return res.status(400).json({ error: 'slug required' });
    const b = await loadBySlug(slug);
    if (!b) return res.status(404).json({ error: 'business not found' });
    log.info('interview/draft start', { slug });
    const result = await draftInterview(b);
    log.info('interview/draft ok', {
      slug, fields: Object.keys(result.qa).length, model: result.model, host: result.host,
      eval_count: result.eval_count, ms: result.total_duration_ms,
    });
    res.json({ ok: true, slug, ...result });
  } catch (e) {
    log.err('interview/draft failed', { error: String(e.message || e) });
    res.status(500).json({ error: String(e.message || e) });
  }
});

// Save (newest-wins; we keep a rolling history capped at INTERVIEW_HISTORY_MAX rows).
// Auth: gated by the same ADMIN_TOKEN cookie as /admin so anonymous visitors
// can't overwrite arbitrary shop interviews.
app.post('/api/biz/interview', async (req, res) => {
  try {
    const tok = req.cookies?.smb_admin || req.body?.admin_token || req.query?.t || '';
    if (!ADMIN_TOKEN || tok !== ADMIN_TOKEN) {
      return res.status(401).json({ error: 'unauthorized' });
    }

    const body = req.body || {};
    const slug = body.slug;
    const recorded_by = body.recorded_by;

    // Accept either JSON `{ qa: { id: "answer", ... } }` (the JS path) OR a no-JS
    // urlencoded form post with `qa_<id>` keys. Reject arrays — `typeof []` is
    // 'object' so the prior typeof guard alone let attackers corrupt the JSONB.
    let qa = body.qa;
    if (qa === undefined || qa === null) {
      qa = {};
      for (const [k, v] of Object.entries(body)) {
        if (typeof k === 'string' && k.startsWith('qa_') && k.length > 3) {
          qa[k.slice(3)] = v;
        }
      }
    }
    if (!slug || !qa || typeof qa !== 'object' || Array.isArray(qa)) {
      return res.status(400).json({ error: 'slug + qa required' });
    }

    const b = await loadBySlug(slug);
    if (!b) return res.status(404).json({ error: 'business not found' });

    // Strip empty answers so the JSONB stays clean. Coerce arrays-as-values to a
    // single string so a malicious `qa_<id>=a&qa_<id>=b` repeat-key payload
    // can't sneak in non-string types. Whitelist against the canonical
    // QUESTION_IDS so an attacker can't balloon the JSONB with arbitrary keys
    // (or sneak in keys that later collide with other fields).
    const cleanQa = {};
    for (const [k, v] of Object.entries(qa)) {
      if (!QUESTION_IDS.has(k)) continue;
      const raw = Array.isArray(v) ? v[v.length - 1] : v;
      const s = String(raw || '').trim();
      if (s) cleanQa[k] = s.slice(0, 4000);
    }

    // Cast to ::jsonb explicitly so any pg driver version handles the string
    // identically — without the cast some versions double-encode.
    await query(
      `INSERT INTO business_interviews (business_id, qa_json, recorded_by) VALUES ($1, $2::jsonb, $3)`,
      [b.id, JSON.stringify(cleanQa), (recorded_by || '').slice(0, 120) || null]
    );

    // Keep a rolling history per business so the per-shop row count can't grow
    // unboundedly and slow down the latest-interview SELECT.
    const HISTORY_MAX = 5;
    await query(
      `DELETE FROM business_interviews
       WHERE business_id=$1
         AND id NOT IN (
           SELECT id FROM business_interviews
           WHERE business_id=$1
           ORDER BY recorded_at DESC NULLS LAST, id DESC
           LIMIT $2
         )`,
      [b.id, HISTORY_MAX]
    );

    log.info('interview saved', { slug, fields: Object.keys(cleanQa).length });

    // No-JS form posts get a redirect back to the profile; JSON callers get JSON.
    const wantsJson = (req.get('content-type') || '').includes('application/json') ||
                      (req.get('accept') || '').includes('application/json');
    if (!wantsJson) {
      return res.redirect(`/biz/${encodeURIComponent(slug)}`);
    }
    res.json({ ok: true, slug, fields: Object.keys(cleanQa).length });
  } catch (e) {
    log.err('interview save failed', { error: String(e.message || e) });
    res.status(500).json({ error: String(e.message || e) });
  }
});

app.get('/biz/:slug/edit', async (req, res) => {
  const b = await loadBySlug(req.params.slug);
  if (!b) return res.status(404).send('Not found');
  const socials = await many('SELECT * FROM business_socials WHERE business_id=$1', [b.id]);
  res.set('content-type', 'text/html; charset=utf-8');
  res.send(renderEdit({ business: b, socials }));
});

app.get('/biz/:slug/template/:id', async (req, res) => {
  const b = await loadBySlug(req.params.slug);
  if (!b) return res.status(404).send('Not found');
  const id = parseInt(req.params.id, 10);
  if (!(id >= 1 && id <= 5)) return res.status(400).send('Bad template id');
  // Same anti-clickjacking headers as /biz/:slug — only /preview is meant for
  // foreign embedding (the grid iframes).
  res.set('content-type', 'text/html; charset=utf-8');
  res.set('x-frame-options', 'SAMEORIGIN');
  res.set('content-security-policy', "frame-ancestors 'self'");
  res.send(renderTemplate(id, b));
});

// Same content but with iframe-friendly headers
app.get('/biz/:slug/template/:id/preview', async (req, res) => {
  const b = await loadBySlug(req.params.slug);
  if (!b) return res.status(404).send('Not found');
  const id = parseInt(req.params.id, 10);
  if (!(id >= 1 && id <= 5)) return res.status(400).send('Bad template id');
  res.set('content-type', 'text/html; charset=utf-8');
  res.set('x-frame-options', 'SAMEORIGIN');
  res.send(renderTemplate(id, b));
});

// --- scrape APIs ------------------------------------------------------------

app.post('/api/scrape-website', async (req, res) => {
  try {
    const { url, category, name } = req.body || {};
    if (!url) return res.status(400).json({ error: 'url required' });
    const meta = await scrapeWebsiteMeta(url);
    const finalName = name || meta.title || meta.site_name || (new URL(meta.url)).hostname;
    const slug = await uniqueSlug(finalName);

    const result = await one(
      `INSERT INTO businesses (
         slug, name, category, website, hero_image_url,
         color_primary, color_accent, about_text, phone, email, source_data_json
       ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
       RETURNING slug,id,name`,
      [
        slug,
        finalName,
        category || 'generic',
        meta.url,
        meta.hero_image,
        meta.color_primary,
        meta.color_accent,
        meta.description,
        meta.phone,
        meta.email,
        JSON.stringify({ scrape: meta }),
      ]
    );
    log.info('scrape-website', { slug: result.slug, name: result.name });
    res.json({ ok: true, slug: result.slug, id: result.id, scraped: meta });
  } catch (e) {
    log.err('scrape-website failed', { error: String(e.message || e) });
    res.status(500).json({ error: String(e.message || e) });
  }
});

app.post('/api/scrape-instagram', async (req, res) => {
  try {
    const { slug, handle } = req.body || {};
    if (!slug || !handle) return res.status(400).json({ error: 'slug + handle required' });
    const b = await loadBySlug(slug);
    if (!b) return res.status(404).json({ error: 'business not found' });
    const ig = await scrapeInstagramPublic(handle);
    await query(
      `INSERT INTO business_socials (business_id, platform, handle, url, follower_count, raw_json)
       VALUES ($1,'instagram',$2,$3,$4,$5)
       ON CONFLICT (business_id, platform)
       DO UPDATE SET handle=EXCLUDED.handle, url=EXCLUDED.url,
                     follower_count=EXCLUDED.follower_count,
                     raw_json=EXCLUDED.raw_json,
                     scraped_at=NOW()`,
      [b.id, ig.handle, ig.url, ig.follower_count, JSON.stringify(ig)]
    );
    // If the IG bio gives us a name/about and the business doesn't have it, fill it in
    if (ig.biography && !b.about_text) {
      await query('UPDATE businesses SET about_text=$1 WHERE id=$2', [ig.biography, b.id]);
    }
    res.json({ ok: true, slug, instagram: ig });
  } catch (e) {
    log.err('scrape-instagram failed', { error: String(e.message || e) });
    res.status(500).json({ error: String(e.message || e) });
  }
});

// --- field updates ----------------------------------------------------------

app.post('/api/biz/update', async (req, res) => {
  try {
    const { slug, ...fields } = req.body || {};
    if (!slug) return res.status(400).json({ error: 'slug required' });
    const b = await loadBySlug(slug);
    if (!b) return res.status(404).json({ error: 'not found' });

    const cols = [];
    const vals = [];
    let i = 1;
    for (const [k, v] of Object.entries(fields)) {
      if (!ALLOWED_FIELDS.has(k)) continue;
      if (v === '' || v === undefined || v === null) continue;
      cols.push(`${k} = $${i}`);
      vals.push(v);
      i += 1;
    }
    if (!cols.length) return res.json({ ok: true, slug, updated: 0 });
    vals.push(b.id);
    await query(`UPDATE businesses SET ${cols.join(', ')} WHERE id = $${i}`, vals);
    res.json({ ok: true, slug, updated: cols.length });
  } catch (e) {
    log.err('biz/update failed', { error: String(e.message || e) });
    res.status(500).json({ error: String(e.message || e) });
  }
});

app.post('/api/biz/socials', async (req, res) => {
  try {
    const { slug, ...rest } = req.body || {};
    if (!slug) return res.status(400).json({ error: 'slug required' });
    const b = await loadBySlug(slug);
    if (!b) return res.status(404).json({ error: 'not found' });

    const platMap = {
      instagram: 'https://instagram.com/',
      tiktok: 'https://tiktok.com/@',
      twitter: 'https://x.com/',
      linkedin: 'https://linkedin.com/in/',
      facebook: 'https://facebook.com/',
      youtube: 'https://youtube.com/@',
    };
    let n = 0;
    for (const [platform, baseUrl] of Object.entries(platMap)) {
      const handle = rest[platform];
      if (!handle) continue;
      const cleaned = String(handle).trim().replace(/^@/, '').replace(/^https?:\/\/[^/]+\//, '');
      await query(
        `INSERT INTO business_socials (business_id, platform, handle, url)
         VALUES ($1,$2,$3,$4)
         ON CONFLICT (business_id, platform)
         DO UPDATE SET handle=EXCLUDED.handle, url=EXCLUDED.url, scraped_at=NOW()`,
        [b.id, platform, cleaned, baseUrl + cleaned]
      );
      n += 1;
    }
    res.json({ ok: true, slug, updated: n });
  } catch (e) {
    log.err('biz/socials failed', { error: String(e.message || e) });
    res.status(500).json({ error: String(e.message || e) });
  }
});

// --- website-analysis -------------------------------------------------------
// Brutalist entry page (URL + Instagram) → result page with the user's site
// embedded + 3 mockup-themed rebuild cards. Backed by website_analyses +
// website_analysis_mockups tables (migration 002).

app.get('/website-analysis', (_req, res) => {
  res.set('content-type', 'text/html; charset=utf-8');
  res.send(renderWAEntry({}));
});

// Builds Index — sortable + collapsible table of every ~/Projects directory.
// Description text comes from MEMORY.md, dates from `git log -1 --format=%cI`,
// fallback mtime when not a git repo. Cached in-memory for 30s so a refresh
// doesn't shell out 100+ git commands per page-view.
// Q4 dream team: filesystem dir name (`wholivedthere`) doesn't always match the
// analysis row's slug (`wholivedthere-com` for URL-driven entries). Often BOTH
// rows exist (build-driven + URL-driven). Collect every match and prefer the
// one that actually has audit data so the /builds card picks up live signals.
function lookupAna(b, anaBySlug) {
  const candidates = [];
  if (anaBySlug.has(b.slug)) candidates.push(anaBySlug.get(b.slug));
  if (anaBySlug.has(`${b.slug}-com`)) candidates.push(anaBySlug.get(`${b.slug}-com`));
  if (b.url) {
    try {
      const host = new URL(b.url).hostname.toLowerCase().replace(/\./g, '-').replace(/^www-/, '');
      if (anaBySlug.has(host) && !candidates.find(c => c.slug === host)) candidates.push(anaBySlug.get(host));
    } catch {}
  }
  if (!candidates.length) return null;
  // Score each: +2 has audit_report, +2 has live_audit, +1 has copy_pack, +1 vertical specified.
  const score = (c) => (c.summary_json?.audit_report ? 2 : 0) + (c.summary_json?.live_audit ? 2 : 0)
                     + (c.summary_json?.copy_pack ? 1 : 0) + (c.vertical && c.vertical !== 'generic' ? 1 : 0);
  return candidates.reduce((best, c) => score(c) > score(best) ? c : best);
}

let _buildsCache = { at: 0, rows: null };
async function getBuildsRows() {
  const now = Date.now();
  if (!_buildsCache.rows || (now - _buildsCache.at) > 30_000) {
    const ana = await many('SELECT slug, vertical, summary_json, meta_json, ig_handle FROM website_analyses');
    const set = new Set(ana.map(r => r.slug));
    const baseRows = listBuilds({ analysisSlugs: set });
    const anaBySlug = new Map(ana.map(r => [r.slug, r]));
    const grades = new Map();
    for (const b of baseRows) {
      const a = lookupAna(b, anaBySlug) || null;
      const g = computeGrade({ analysis: a, build: b });
      const ar = a?.summary_json?.audit_report;
      const deepBlob = ar
        ? [...(ar.quick_wins || []), ...(ar.suggested_headlines || []), ...(ar.vertical_tips || [])].join(' ').toLowerCase()
        : '';
      grades.set(b.slug, { ...g, vertical: a?.vertical || null, deep_search: deepBlob, audit_score: ar?.score ?? null });
    }
    _buildsCache = { at: now, rows: listBuilds({ analysisSlugs: set, grades }) };
  }
  return _buildsCache.rows;
}

// Filter helpers — each takes the full row list + returns a slice.
const BUILD_VIEWS = {
  all:        { title: 'All Builds',          fn: rows => rows },
  active:     { title: 'Active · pm2 online', fn: rows => rows.filter(r => r.pm2?.status === 'online') },
  today:      { title: 'Touched today',       fn: rows => rows.filter(r => r.last_at && (Date.now() - Date.parse(r.last_at)) < 86_400_000) },
  week:       { title: 'Touched this week',   fn: rows => rows.filter(r => r.last_at && (Date.now() - Date.parse(r.last_at)) < 7 * 86_400_000) },
  stale:      { title: 'Stale · 30d+',        fn: rows => rows.filter(r => !r.last_at || (Date.now() - Date.parse(r.last_at)) > 30 * 86_400_000) },
  graded:     { title: 'Graded only',         fn: rows => rows.filter(r => r.grade) },
  ungraded:   { title: 'Ungraded',            fn: rows => rows.filter(r => !r.grade) },
  broken:     { title: 'Broken pm2',          fn: rows => rows.filter(r => r.pm2 && r.pm2.status !== 'online') },
  url:        { title: 'Has public URL',      fn: rows => rows.filter(r => r.url) },
  timeline:   { title: 'Timeline · newest first', fn: rows => [...rows].sort((a, b) => (b.last_at ? Date.parse(b.last_at) : 0) - (a.last_at ? Date.parse(a.last_at) : 0)) },
};

app.get('/builds', async (req, res) => {
  const rows = await getBuildsRows();
  const view = String(req.query.view || 'all').toLowerCase();
  const viewDef = BUILD_VIEWS[view] || BUILD_VIEWS.all;
  const grade = String(req.query.grade || '').toUpperCase();
  const cat = String(req.query.cat || '').toLowerCase();
  let filtered = viewDef.fn(rows);
  if (grade && /^[A-F]$/.test(grade)) filtered = filtered.filter(r => r.grade?.letter === grade);
  if (cat) filtered = filtered.filter(r => r.category === cat);
  res.set('content-type', 'text/html; charset=utf-8');
  res.send(renderBuildsIndex({
    builds: filtered,
    allBuilds: rows,
    activeView: view,
    activeGrade: grade,
    activeCat: cat,
  }));
});

app.get('/builds/:view', async (req, res, next) => {
  // Convenience redirects for /builds/active, /builds/today, etc.
  // Fall through (next()) to other matching routes for unknown segments — e.g.
  // /builds/pm2-logs, /builds/build/:slug — so they aren't shadowed by this
  // generic single-segment matcher.
  const view = String(req.params.view || '').toLowerCase();
  if (BUILD_VIEWS[view]) return res.redirect(302, `/builds?view=${encodeURIComponent(view)}`);
  if (view === 'by-grade') {
    const g = String(req.query.g || 'A').toUpperCase();
    return res.redirect(302, `/builds?view=graded&grade=${encodeURIComponent(g)}`);
  }
  return next();
});

app.get('/builds/by-grade/:letter', (req, res) => {
  const g = String(req.params.letter || 'A').toUpperCase();
  res.redirect(302, `/builds?view=graded&grade=${encodeURIComponent(g)}`);
});

// /builds/build/:slug — per-project deep-dive page. Two-segment path so it
// doesn't collide with /builds/:view (one-segment).
// Shows: pm2 health, last 20 git commits, package.json deps, README excerpt,
// audit grade, recent file mtimes. All cheap shell-outs are 4s-capped.
app.get('/builds/build/:slug', async (req, res) => {
  const slug = String(req.params.slug || '').toLowerCase();
  if (!/^[\w.-]+$/.test(slug)) return res.status(400).send('invalid slug');
  const rows = await getBuildsRows();
  const build = rows.find(r => (r.slug || r.name).toLowerCase() === slug || r.name.toLowerCase() === slug);
  if (!build) return res.status(404).send('Unknown build');

  const { execFile } = await import('node:child_process');
  const fsMod = await import('node:fs');
  const pathMod = await import('node:path');

  const sh = (cmd, args) => new Promise(resolve => {
    execFile(cmd, args, { timeout: 4000, maxBuffer: 1024 * 256 }, (err, stdout, stderr) => {
      resolve({ err, stdout: String(stdout || ''), stderr: String(stderr || '') });
    });
  });
  const [gitLog, gitStatus, gitBranch] = await Promise.all([
    build.has_git ? sh('git', ['-C', build.path, 'log', '-20', '--format=%cI %h %s']) : Promise.resolve({ stdout: '' }),
    build.has_git ? sh('git', ['-C', build.path, 'status', '--porcelain']) : Promise.resolve({ stdout: '' }),
    build.has_git ? sh('git', ['-C', build.path, 'rev-parse', '--abbrev-ref', 'HEAD']) : Promise.resolve({ stdout: '' }),
  ]);

  let pkg = null;
  try { pkg = JSON.parse(fsMod.readFileSync(pathMod.join(build.path, 'package.json'), 'utf8')); } catch {}

  let readme = null;
  for (const name of ['README.md', 'README.txt', 'README']) {
    try {
      const txt = fsMod.readFileSync(pathMod.join(build.path, name), 'utf8');
      readme = { file: name, body: txt.split('\n').slice(0, 200).join('\n') };
      break;
    } catch {}
  }

  let recentFiles = [];
  try {
    recentFiles = fsMod.readdirSync(build.path)
      .filter(e => !e.startsWith('.') && !['node_modules','dist','build','.next','coverage','.cache'].includes(e))
      .map(name => {
        try {
          const st = fsMod.statSync(pathMod.join(build.path, name));
          return { name, mtime: st.mtimeMs, size: st.size, dir: st.isDirectory() };
        } catch { return null; }
      })
      .filter(Boolean)
      .sort((a, b) => b.mtime - a.mtime)
      .slice(0, 30);
  } catch {}

  res.set('content-type', 'text/html; charset=utf-8');
  const safe = (s) => String(s == null ? '' : s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
  const fmtSize = (n) => n < 1024 ? `${n}B` : n < 1048576 ? `${(n/1024).toFixed(1)}K` : `${(n/1048576).toFixed(1)}M`;
  const fmtTime = (ms) => {
    const days = Math.floor((Date.now() - ms) / 86400000);
    if (days === 0) return 'today';
    if (days === 1) return '1d ago';
    if (days < 30) return `${days}d ago`;
    if (days < 365) return `${Math.floor(days/30)}mo ago`;
    return `${Math.floor(days/365)}y ago`;
  };
  let urlObj = null;
  try { urlObj = new URL(build.url || ''); } catch {}
  const ghSearch = `https://github.com/search?q=${encodeURIComponent(build.name)}+stevestudio2&type=repositories`;

  const commits = gitLog.stdout.split('\n').filter(Boolean).map(line => {
    const m = line.match(/^([\dT:.+\-Z]+)\s+([a-f0-9]+)\s+(.*)$/);
    return m ? { iso: m[1], hash: m[2], subject: m[3] } : null;
  }).filter(Boolean);
  const dirty = gitStatus.stdout.split('\n').filter(Boolean);
  const branch = gitBranch.stdout.trim() || '(no git)';
  const deps    = pkg?.dependencies    ? Object.entries(pkg.dependencies)    : [];
  const devDeps = pkg?.devDependencies ? Object.entries(pkg.devDependencies) : [];

  res.send(`<!doctype html><html lang="en"><head><meta charset="utf-8">
<title>${safe(build.name)} · Build · agentabrams</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<link href="https://fonts.googleapis.com/css2?family=Archivo+Black&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:'Space Mono',monospace;background:#fff;color:#000;line-height:1.4}
.hd{background:#000;color:#fff;border-bottom:4px solid #000;padding:14px 24px;display:flex;align-items:center;justify-content:space-between;gap:24px;flex-wrap:wrap}
.lg{font-family:'Archivo Black',sans-serif;font-size:24px;letter-spacing:-.04em;text-transform:uppercase;text-decoration:none;color:#fff}
.lg::after{content:'.';color:#facc15}
.hd-nav{display:flex;gap:6px;font-size:11px;font-weight:700;text-transform:uppercase}
.hd-nav a{padding:6px 12px;border:2px solid #fff;color:#fff;text-decoration:none}
.hd-nav a:hover{background:#facc15;color:#000;border-color:#facc15}
main{max-width:1240px;margin:0 auto;padding:32px 24px}
.crumb{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#666;margin-bottom:12px}
.crumb a{color:#000;text-decoration:underline}
h1{font-family:'Archivo Black',sans-serif;font-size:64px;line-height:.9;text-transform:uppercase;letter-spacing:-.04em;margin-bottom:8px;word-break:break-word}
h1 mark{background:#facc15;padding:0 8px}
.subtitle{font-size:13px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:#666;margin-bottom:18px;display:flex;flex-wrap:wrap;gap:8px;align-items:center}
.subtitle .grade{display:inline-block;padding:2px 10px;border:2px solid #000;font-family:'Archivo Black',sans-serif;font-size:14px;color:#000}
.btnrow{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:32px}
.bbtn{display:inline-block;background:#fff;color:#000;border:2px solid #000;padding:7px 12px;font-family:'Space Mono',monospace;font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;text-decoration:none;cursor:pointer}
.bbtn:hover{background:#facc15}
.bbtn-primary{background:#000;color:#facc15}
.bbtn-primary:hover{background:#facc15;color:#000}
.bbtn-port{background:#facc15;color:#000}
.bbtn-mock{background:#22c55e;color:#000}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:18px}
.panel{border:4px solid #000;background:#fff}
.panel-h{background:#000;color:#facc15;font-family:'Archivo Black',sans-serif;font-size:14px;text-transform:uppercase;letter-spacing:.04em;padding:10px 14px;display:flex;justify-content:space-between;align-items:center}
.panel-b{padding:14px;font-size:12px;line-height:1.5}
.panel-b ul{list-style:none}
.panel-b li{padding:4px 0;border-bottom:1px dotted #ccc;display:flex;justify-content:space-between;align-items:baseline;gap:8px}
.panel-b li:last-child{border-bottom:none}
.panel-b .meta{font-size:10px;color:#666;font-weight:700;text-transform:uppercase;letter-spacing:.04em;white-space:nowrap}
.commit{font-size:11px;padding:6px 0;border-bottom:1px dotted #ccc}
.commit-hash{font-family:'Space Mono',monospace;font-weight:700;background:#facc15;padding:1px 5px;font-size:10px;letter-spacing:.04em;margin-right:6px}
.commit-iso{font-size:10px;color:#666;font-weight:700;letter-spacing:.04em}
.dirty-line{font-family:'Space Mono',monospace;font-size:11px;color:#dc2626;padding:2px 0}
pre.readme{background:#fafaf6;border:none;padding:14px;font-family:'Space Mono',monospace;font-size:11px;line-height:1.5;white-space:pre-wrap;word-break:break-word;max-height:520px;overflow-y:auto}
.dep-tag{display:inline-block;background:#fafaf6;border:1px solid #000;padding:2px 6px;font-size:10px;font-weight:700;letter-spacing:.02em;margin:1px}
.dep-tag .v{color:#666}
.empty{color:#aaa;font-style:italic;padding:8px 0}
.pm2-online{background:#22c55e;color:#000;padding:2px 8px;font-weight:700;font-size:11px;letter-spacing:.04em;text-transform:uppercase}
.pm2-broken{background:#dc2626;color:#fff;padding:2px 8px;font-weight:700;font-size:11px;letter-spacing:.04em;text-transform:uppercase}
.kv-row{display:flex;justify-content:space-between;align-items:baseline;padding:6px 0;border-bottom:1px dotted #ccc;font-size:11px;gap:8px}
.kv-row strong{font-weight:700;font-family:'Archivo Black',sans-serif;font-size:11px;text-transform:uppercase}
.kv-row .v{font-family:'Space Mono',monospace;color:#444;text-align:right;word-break:break-all}
</style></head><body>
<header class="hd">
  <a href="/builds" class="lg">★ Builds</a>
  <nav class="hd-nav">
    <a href="/builds">All</a>
    <a href="/builds?view=active">Active</a>
    <a href="/builds?view=today">Today</a>
    <a href="/builds?view=graded">Graded</a>
    <a href="/builds?view=timeline">Timeline</a>
    <a href="/">Salon Dir →</a>
  </nav>
</header>
<main>
  <div class="crumb"><a href="/builds">All Builds</a> · <a href="/builds?cat=${encodeURIComponent(build.category)}">${safe(build.category)}</a></div>
  <h1>★ <mark>${safe(build.name)}</mark></h1>
  <div class="subtitle">
    ${build.grade ? `<span class="grade" style="background:${safe(build.grade.color)}">${safe(build.grade.letter)}</span>` : ''}
    <span>${safe(build.category)}</span>
    ${build.last_at ? `<span>· last touched ${fmtTime(Date.parse(build.last_at))}</span>` : ''}
    <span>· ~/Projects/${safe(build.name)}</span>
    ${build.has_git ? `<span>· git ✓ ${safe(branch)}</span>` : ''}
    ${build.pm2 ? (build.pm2.status === 'online' ? `<span class="pm2-online">★ pm2 ${safe(build.pm2.name)} :${build.pm2.port || '?'}</span>` : `<span class="pm2-broken">★ pm2 ${safe(build.pm2.name)} ${safe(build.pm2.status)}</span>`) : ''}
  </div>
  ${build.description ? `<p style="font-size:14px;line-height:1.55;color:#333;margin-bottom:18px;max-width:78ch">${safe(build.description)}</p>` : ''}

  <div class="btnrow">
    ${urlObj ? `<a class="bbtn bbtn-primary" href="${safe(build.url)}" target="_blank" rel="noopener">★ ${safe(urlObj.host)} ↗</a>` : ''}
    ${build.port ? `<a class="bbtn bbtn-port" href="http://127.0.0.1:${build.port}" target="_blank" rel="noopener">:${build.port}</a>` : ''}
    ${build.analysis_slug ? `<a class="bbtn bbtn-mock" href="/wa/${safe(build.analysis_slug)}" target="_blank" rel="noopener">★ Mockups</a>` : ''}
    <a class="bbtn" href="vscode://file${safe(build.path)}">VS Code</a>
    <a class="bbtn" href="file://${safe(build.path)}">Finder</a>
    <button class="bbtn" type="button" onclick="navigator.clipboard?.writeText('${safe(build.path)}');this.textContent='✓ Copied'">Copy Path</button>
    <a class="bbtn" href="${safe(ghSearch)}" target="_blank" rel="noopener">GitHub Search</a>
    ${build.pm2?.name ? `<a class="bbtn" href="/builds/pm2-logs?app=${encodeURIComponent(build.pm2.name)}" target="_blank" rel="noopener">pm2 Logs</a>` : ''}
  </div>

  <div class="grid">
    <div class="panel">
      <div class="panel-h"><span>★ Quick Facts</span><span style="color:#fff;font-size:10px">${build.has_git ? safe(branch) : ''}</span></div>
      <div class="panel-b">
        <div class="kv-row"><strong>Name</strong><span class="v">${safe(build.name)}</span></div>
        <div class="kv-row"><strong>Path</strong><span class="v">${safe(build.path)}</span></div>
        <div class="kv-row"><strong>Category</strong><span class="v">${safe(build.category)}</span></div>
        ${build.url ? `<div class="kv-row"><strong>URL</strong><span class="v"><a href="${safe(build.url)}" target="_blank" rel="noopener noreferrer">${safe(build.url)}</a></span></div>` : ''}
        ${build.port ? `<div class="kv-row"><strong>Port</strong><span class="v">${build.port}</span></div>` : ''}
        ${build.last_at ? `<div class="kv-row"><strong>Last Worked</strong><span class="v">${safe(build.last_at.slice(0,10))} · ${fmtTime(Date.parse(build.last_at))}</span></div>` : ''}
        ${build.has_git ? `<div class="kv-row"><strong>Git Branch</strong><span class="v">${safe(branch)}</span></div>` : ''}
        ${build.has_git && dirty.length ? `<div class="kv-row"><strong>Uncommitted</strong><span class="v">${dirty.length} files</span></div>` : ''}
        ${build.grade ? `<div class="kv-row"><strong>Grade</strong><span class="v"><span style="background:${safe(build.grade.color)};color:#000;border:1px solid #000;padding:0 6px;font-family:'Archivo Black',sans-serif">${safe(build.grade.letter)}</span> · score ${build.grade.score}/100</span></div>` : ''}
        ${pkg?.version ? `<div class="kv-row"><strong>Version</strong><span class="v">${safe(pkg.version)}</span></div>` : ''}
        ${pkg?.scripts ? `<div class="kv-row"><strong>Scripts</strong><span class="v">${Object.keys(pkg.scripts).slice(0,8).map(s => `<code style="background:#facc15;padding:0 4px;margin:1px;display:inline-block">${safe(s)}</code>`).join(' ')}</span></div>` : ''}
      </div>
    </div>

    ${commits.length ? `<div class="panel">
      <div class="panel-h"><span>★ Recent Commits</span><span style="color:#fff;font-size:10px">${commits.length}</span></div>
      <div class="panel-b">
        ${commits.map(c => `<div class="commit">
          <span class="commit-hash">${safe(c.hash)}</span>
          <span class="commit-iso">${safe(c.iso.slice(0,10))} · ${fmtTime(Date.parse(c.iso))}</span>
          <div style="margin-top:2px">${safe(c.subject)}</div>
        </div>`).join('')}
      </div>
    </div>` : ''}

    ${dirty.length ? `<div class="panel">
      <div class="panel-h"><span>★ Uncommitted</span><span style="color:#fff;font-size:10px">${dirty.length}</span></div>
      <div class="panel-b">
        ${dirty.slice(0, 60).map(line => `<div class="dirty-line">${safe(line)}</div>`).join('')}
        ${dirty.length > 60 ? `<div class="empty" style="margin-top:6px">… +${dirty.length - 60} more</div>` : ''}
      </div>
    </div>` : ''}

    ${recentFiles.length ? `<div class="panel">
      <div class="panel-h"><span>★ Recent Files</span><span style="color:#fff;font-size:10px">${recentFiles.length}</span></div>
      <div class="panel-b"><ul>
        ${recentFiles.map(f => `<li><span>${f.dir ? '📁 ' : '📄 '}<strong>${safe(f.name)}</strong></span><span class="meta">${fmtSize(f.size)} · ${fmtTime(f.mtime)}</span></li>`).join('')}
      </ul></div>
    </div>` : ''}

    ${deps.length ? `<div class="panel">
      <div class="panel-h"><span>★ Dependencies</span><span style="color:#fff;font-size:10px">${deps.length}</span></div>
      <div class="panel-b">
        ${deps.map(([k, v]) => `<span class="dep-tag">${safe(k)} <span class="v">${safe(v)}</span></span>`).join('')}
      </div>
    </div>` : ''}

    ${devDeps.length ? `<div class="panel">
      <div class="panel-h"><span>★ Dev Dependencies</span><span style="color:#fff;font-size:10px">${devDeps.length}</span></div>
      <div class="panel-b">
        ${devDeps.map(([k, v]) => `<span class="dep-tag">${safe(k)} <span class="v">${safe(v)}</span></span>`).join('')}
      </div>
    </div>` : ''}
  </div>

  ${readme ? `<div class="panel" style="margin-top:24px">
    <div class="panel-h"><span>★ ${safe(readme.file)}</span><span style="color:#fff;font-size:10px">first 200 lines</span></div>
    <div class="panel-b" style="padding:0"><pre class="readme">${safe(readme.body)}</pre></div>
  </div>` : ''}
</main>
</body></html>`);
});

// /builds/pm2-logs?app=<name> — tails `pm2 logs <name> --lines 200 --raw`
// once and returns the captured output. Read-only, no streaming yet.
app.get('/builds/pm2-logs', async (req, res) => {
  const name = String(req.query.app || '').trim();
  if (!name || !/^[\w@.\-]+$/.test(name)) return res.status(400).send('invalid app name');
  const { execFile } = await import('node:child_process');
  execFile('pm2', ['logs', name, '--lines', '200', '--nostream', '--raw'], { timeout: 8000 }, (err, stdout, stderr) => {
    res.set('content-type', 'text/html; charset=utf-8');
    const safe = (s) => String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
    res.send(`<!doctype html><html><head><meta charset="utf-8"><title>pm2 logs · ${safe(name)}</title>
<style>body{font-family:'SF Mono',Menlo,monospace;background:#000;color:#22c55e;padding:24px;line-height:1.5;font-size:12px}h1{color:#facc15;font-family:'Archivo Black',sans-serif;font-size:24px;margin-bottom:18px}pre{white-space:pre-wrap;word-break:break-word;background:#0a0a0a;border:2px solid #facc15;padding:14px;max-height:80vh;overflow-y:auto}.err{color:#dc2626}.bar{background:#facc15;color:#000;padding:8px 14px;font-family:'Archivo Black',sans-serif;font-size:11px;text-transform:uppercase;letter-spacing:.04em;display:inline-block;margin-bottom:14px}.bar a{color:#000;text-decoration:underline;margin-left:14px}</style>
</head><body>
<h1>★ pm2 logs · ${safe(name)}</h1>
<div class="bar">★ last 200 lines · refresh page to retail <a href="/builds">← back to builds</a></div>
${err && !stdout ? `<div class="err">★ pm2 logs ${safe(name)} failed: ${safe(err.message)}</div>` : ''}
<pre>${safe(stdout || '(empty)')}</pre>
${stderr ? `<h1 style="color:#dc2626;margin-top:24px">stderr</h1><pre class="err">${safe(stderr)}</pre>` : ''}
</body></html>`);
  });
});

function normalizeUrl(raw) {
  let u = String(raw || '').trim();
  if (!u) return null;
  if (!/^https?:\/\//i.test(u)) u = 'https://' + u;
  try {
    const p = new URL(u);
    if (p.protocol !== 'http:' && p.protocol !== 'https:') return null;
    return p.toString();
  } catch { return null; }
}

function normalizeIg(raw) {
  if (!raw) return null;
  const h = String(raw).trim().replace(/^@/, '').replace(/^https?:\/\/(www\.)?instagram\.com\//i, '').replace(/\/$/, '');
  if (!/^[A-Za-z0-9._]{1,30}$/.test(h)) return null;
  return h;
}

app.post('/api/website-analysis', async (req, res) => {
  const url = normalizeUrl(req.body?.url);
  const igHandle = normalizeIg(req.body?.ig_handle);
  if (!url) {
    res.set('content-type', 'text/html; charset=utf-8');
    return res.status(400).send(renderWAEntry({ error: 'Enter a valid website URL (e.g. yourbusiness.com)' }));
  }

  let meta;
  try {
    meta = await scrapeWebsiteMeta(url);
  } catch (e) {
    log.err('website-analysis scrape failed', { url, error: String(e?.message || e) });
    res.set('content-type', 'text/html; charset=utf-8');
    return res.status(400).send(renderWAEntry({ error: `Could not reach ${url}. Check the URL and try again.` }));
  }

  const vertical = detectVertical({ title: meta.title || '', description: meta.description || '', html: '' });
  const themes = pickThemesFor(vertical);
  const ctx = {
    title: meta.title || meta.site_name || new URL(url).hostname,
    tagline: meta.description || '',
    palette: meta.palette || [],
    hero_image: meta.hero_image || null,
    vertical,
    ig_handle: igHandle,
    url,
  };

  const baseSlug = slugify(new URL(url).hostname.replace(/^www\./, '')) || 'site';
  let slug = baseSlug;
  let i = 0;
  while (await one('SELECT 1 FROM website_analyses WHERE slug=$1', [slug])) {
    i += 1; slug = `${baseSlug}-${i}`;
  }

  const summary = {
    grade: 'pending',
    top_3_issues: [],
    vertical,
    title: ctx.title,
    tagline: ctx.tagline,
  };

  const metaBlob = {
    iframe_blocked: !!meta.iframe_blocked,
    palette: ctx.palette.slice(0, 16),
    hero_image: ctx.hero_image,
    title: ctx.title,
    tagline: ctx.tagline,
  };

  const inserted = await one(
    `INSERT INTO website_analyses (slug, url, ig_handle, vertical, status, summary_json, meta_json)
     VALUES ($1,$2,$3,$4,'ready',$5::jsonb,$6::jsonb) RETURNING id, slug`,
    [slug, url, igHandle, vertical, JSON.stringify(summary), JSON.stringify(metaBlob)]
  );

  for (let n = 0; n < themes.length; n += 1) {
    const theme = themes[n];
    const mockupId = `${String(n + 1).padStart(2, '0')}-${theme}`;
    const html = renderTheme(theme, ctx);
    await query(
      `INSERT INTO website_analysis_mockups (analysis_id, mockup_id, title, theme, preview_html)
       VALUES ($1,$2,$3,$4,$5)`,
      [inserted.id, mockupId, theme.charAt(0).toUpperCase() + theme.slice(1).replace(/-/g, ' '), theme, html]
    );
  }

  res.redirect(`/wa/${inserted.slug}`);
});

// /wall — public brutalist gallery of every audited+mockuped build (compliance
// blocklist applied). Pulls one preview mockup_id per row so cards can render
// a thumbnail iframe. Cached 60s.
let _wallCache = { at: 0, key: null, html: null };
app.get('/wall', async (req, res) => {
  const PER_PAGE = 24;
  const page = Math.max(1, parseInt(req.query.p || '1', 10) || 1);
  const vertical = String(req.query.v || 'all').toLowerCase().slice(0, 32);
  const cacheKey = `${page}:${vertical}`;
  const now = Date.now();
  if (_wallCache.key === cacheKey && (now - _wallCache.at) < 60_000 && _wallCache.html) {
    res.set('content-type', 'text/html; charset=utf-8');
    return res.send(_wallCache.html);
  }
  const baseFilter = `(meta_json->>'compliance_sensitive') IS DISTINCT FROM 'true'`;
  const vFilter = vertical !== 'all' ? `AND vertical = $1` : '';
  const params = vertical !== 'all' ? [vertical] : [];
  const totalRow = await one(`SELECT COUNT(*)::int AS c FROM website_analyses WHERE ${baseFilter} ${vFilter}`, params);
  const rows = await many(`
    SELECT a.id, a.slug, a.vertical, a.summary_json, a.meta_json,
           (SELECT mockup_id FROM website_analysis_mockups m
              WHERE m.analysis_id = a.id
              ORDER BY CASE WHEN m.theme = 'brutalist' THEN 0
                            WHEN m.theme = 'editorial' THEN 1
                            ELSE 2 END, m.mockup_id LIMIT 1) AS preview_mockup_id
    FROM website_analyses a
    WHERE ${baseFilter} ${vFilter}
    ORDER BY (summary_json ? 'live_audit') DESC NULLS LAST,
             (summary_json ? 'roast') DESC NULLS LAST,
             (summary_json->'live_audit'->'grade'->>'score')::int DESC NULLS LAST,
             a.id DESC
    OFFSET $${params.length + 1} LIMIT $${params.length + 2}
  `, [...params, (page - 1) * PER_PAGE, PER_PAGE]);
  // Verticals for filter chips — whatever shows up in the corpus.
  const verticalsRows = await many(`
    SELECT vertical, COUNT(*)::int c FROM website_analyses
    WHERE ${baseFilter} AND vertical IS NOT NULL AND vertical <> ''
    GROUP BY vertical ORDER BY c DESC LIMIT 12`);
  const verticals = verticalsRows.filter(r => r.c >= 3).map(r => r.vertical);
  // Marquee — most recent 8 roasts.
  const marqueeRows = await many(`
    SELECT summary_json->'roast'->>'text' AS text
    FROM website_analyses WHERE summary_json ? 'roast' AND ${baseFilter}
    ORDER BY (summary_json->'roast'->>'generated_at') DESC NULLS LAST LIMIT 8`);
  // Aggregate stats banner — single roundtrip.
  const statsRow = await one(`
    SELECT
      COUNT(*)::int AS total,
      COUNT(*) FILTER (WHERE summary_json ? 'live_audit')::int AS graded,
      COUNT(*) FILTER (WHERE summary_json ? 'roast')::int AS roasted,
      COALESCE(ROUND(AVG((summary_json->'live_audit'->'grade'->>'score')::numeric)
        FILTER (WHERE summary_json ? 'live_audit')), 0)::int AS avg_score
    FROM website_analyses
    WHERE ${baseFilter}`);
  const mockupCountRow = await one(`
    SELECT COUNT(*)::int AS c
    FROM website_analysis_mockups m
    JOIN website_analyses a ON a.id = m.analysis_id
    WHERE (a.meta_json->>'compliance_sensitive') IS DISTINCT FROM 'true'`);
  const avgScore = statsRow.avg_score;
  const avgGrade = avgScore >= 80 ? 'A' : avgScore >= 65 ? 'B' : avgScore >= 45 ? 'C' : avgScore >= 25 ? 'D' : 'F';
  const stats = {
    total: statsRow.total,
    graded: statsRow.graded,
    roasted: statsRow.roasted,
    mockups: mockupCountRow.c,
    avgGrade: `${avgGrade} ${avgScore}`,
  };
  const html = renderWall({
    rows, page, perPage: PER_PAGE, totalCount: totalRow.c,
    vertical, verticals, roastsForMarquee: marqueeRows.filter(r => r.text),
    stats,
  });
  _wallCache = { at: now, key: cacheKey, html };
  res.set('content-type', 'text/html; charset=utf-8');
  res.send(html);
});

// /grades — public top-12 rails. Pure SQL, brutalist render. 60s cache.
// (Originally mounted as /leaderboard but the LA Salon Directory took that path
// at line 1256, and Express picks first-match.)
let _lbCache = { at: 0, html: null };
app.get('/grades', async (_req, res) => {
  const now = Date.now();
  if (_lbCache.html && (now - _lbCache.at) < 60_000) {
    res.set('content-type', 'text/html; charset=utf-8');
    return res.send(_lbCache.html);
  }
  const baseFilter = `(meta_json->>'compliance_sensitive') IS DISTINCT FROM 'true'`;
  const topGraded = await many(`
    SELECT slug, vertical,
           summary_json->'live_audit'->'grade'->>'letter' AS letter,
           (summary_json->'live_audit'->'grade'->>'score')::int AS score,
           summary_json->'copy_pack'->>'headline' AS headline
    FROM website_analyses WHERE summary_json ? 'live_audit' AND ${baseFilter}
    ORDER BY (summary_json->'live_audit'->'grade'->>'score')::int DESC LIMIT 12`);
  const recentRoasts = await many(`
    SELECT slug, vertical,
           summary_json->'roast'->>'text' AS roast,
           summary_json->'copy_pack'->>'headline' AS headline,
           summary_json->'roast'->>'generated_at' AS at
    FROM website_analyses WHERE summary_json ? 'roast' AND ${baseFilter}
    ORDER BY (summary_json->'roast'->>'generated_at') DESC LIMIT 12`);
  const verticals = await many(`
    SELECT vertical, COUNT(*)::int n,
           ROUND(AVG((summary_json->'live_audit'->'grade'->>'score')::numeric))::int AS avg_score
    FROM website_analyses
    WHERE summary_json ? 'live_audit' AND ${baseFilter} AND vertical IS NOT NULL
    GROUP BY vertical ORDER BY avg_score DESC NULLS LAST`);

  const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
  const gradeColor = (s) => s>=80?'#22c55e':s>=65?'#84cc16':s>=45?'#facc15':s>=25?'#f97316':'#dc2626';
  const html = `<!doctype html><html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>★ THE LEADERBOARD · TOP-GRADED + MOST-ROASTED</title>
<meta name="description" content="Top-12 rails — best-graded sites, freshest qwen3 roasts, vertical averages.">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Archivo+Black&family=Space+Mono:wght@400;700&display=swap">
<style>
  :root{--bg:#fafaf6;--ink:#111;--paper:#fff;--accent:#facc15;--muted:#666}
  [data-theme="dark"]{--bg:#0a0a08;--ink:#fafaf6;--paper:#1a1a18;--accent:#facc15;--muted:#999}
  *{box-sizing:border-box} body{margin:0;font-family:'Space Mono',monospace;background:var(--bg);color:var(--ink);transition:background .15s,color .15s}
  body .row,body .roast-row,body .vert-row{background:var(--paper);color:var(--ink)}
  .theme-toggle{background:var(--paper);color:var(--ink);border:3px solid var(--ink);padding:8px 12px;font-family:'Archivo Black',sans-serif;font-size:12px;cursor:pointer;text-transform:uppercase;letter-spacing:.04em}
  .theme-toggle:hover{background:var(--accent);color:#000}
  body .rail h2{color:var(--ink)}
  a{color:inherit}
  h1,h2,h3{font-family:'Archivo Black',sans-serif;letter-spacing:-.02em;text-transform:uppercase;margin:0}
  .container{max-width:1320px;margin:0 auto;padding:0 24px}
  .nav{display:flex;justify-content:space-between;align-items:center;padding:24px;border-bottom:4px solid #000}
  .nav a{text-decoration:none;font-family:'Archivo Black',sans-serif;text-transform:uppercase;letter-spacing:.04em}
  .hero{padding:64px 24px;background:#000;color:#facc15;border-bottom:4px solid #facc15}
  .hero h1{font-size:clamp(48px,8vw,96px);line-height:.9;color:#fafaf6}
  .hero h1 mark{background:#facc15;color:#000;padding:0 .15em}
  .rail{padding:48px 0;border-bottom:4px solid #000}
  .rail h2{font-size:36px;color:#000;margin-bottom:18px}
  .rail .sub{font-size:12px;font-weight:700;color:#666;letter-spacing:.06em;text-transform:uppercase;margin-bottom:24px}
  .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:14px}
  .row{display:flex;align-items:center;gap:14px;border:3px solid #000;background:#fff;padding:12px 16px;text-decoration:none;color:#000;transition:transform .08s}
  .row:hover{transform:translate(-2px,-2px);box-shadow:4px 4px 0 0 #000}
  .rank{font-family:'Archivo Black',sans-serif;font-size:24px;color:#facc15;-webkit-text-stroke:1.5px #000;width:36px;text-align:center}
  .badge{font-family:'Archivo Black',sans-serif;font-size:18px;width:42px;height:42px;display:flex;align-items:center;justify-content:center;border:3px solid #000;flex-shrink:0}
  .info{flex:1;min-width:0}
  .name{font-family:'Archivo Black',sans-serif;font-size:14px;text-transform:uppercase;letter-spacing:-.01em;line-height:1.1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
  .meta{font-size:11px;color:#666;font-weight:700;letter-spacing:.04em;text-transform:uppercase;margin-top:3px}
  .roast-row{display:block;border:3px solid #000;background:#fff;padding:14px 18px;text-decoration:none;color:#000;transition:transform .08s}
  .roast-row:hover{transform:translate(-2px,-2px);box-shadow:4px 4px 0 0 #000}
  .roast-row .name{margin-bottom:6px}
  .roast-row .text{font-size:13px;line-height:1.45;font-style:italic;color:#222;border-left:3px solid #facc15;padding-left:10px}
  .vert-row{display:flex;justify-content:space-between;align-items:center;padding:14px 18px;border:3px solid #000;background:#fff}
  .vert-row .v{font-family:'Archivo Black',sans-serif;font-size:14px;text-transform:uppercase;letter-spacing:-.01em}
  .vert-row .s{font-family:'Space Mono',monospace;font-size:13px;font-weight:700;color:#666}
</style>
</head><body>
<script>(function(){try{var t=localStorage.getItem('lsa-theme');if(t==='dark')document.documentElement.setAttribute('data-theme','dark')}catch(e){}})()</script>
<nav class="nav container">
  <a href="/" style="font-size:18px">★ LIVE SITE AUDIT</a>
  <div style="display:flex;gap:14px;flex-wrap:wrap;align-items:center">
    <a href="/wall" style="font-size:13px">★ THE WALL</a>
    <button class="theme-toggle" id="theme-toggle" title="dark/light">★ <span id="theme-icon">☾</span></button>
    <a href="/website-analysis" style="font-size:13px;background:#facc15;border:3px solid #000;padding:8px 14px">★ AUDIT YOUR SITE</a>
  </div>
</nav>
<script>(function(){const tt=document.getElementById('theme-toggle'),ic=document.getElementById('theme-icon');function r(){const d=document.documentElement.getAttribute('data-theme')==='dark';if(ic)ic.textContent=d?'☀':'☾'}r();tt?.addEventListener('click',()=>{const d=document.documentElement.getAttribute('data-theme')==='dark';if(d){document.documentElement.removeAttribute('data-theme');localStorage.setItem('lsa-theme','light')}else{document.documentElement.setAttribute('data-theme','dark');localStorage.setItem('lsa-theme','dark')}r()})})()</script>
<header class="hero"><div class="container"><h1>THE<br><mark>LEADERBOARD.</mark></h1><p style="margin-top:18px;font-size:16px;font-weight:700;color:#fafaf6;text-transform:uppercase;letter-spacing:.04em;max-width:60ch">Best-graded sites. Freshest roasts. Vertical averages. Updated every 60 seconds.</p></div></header>
<section class="rail container"><h2>★ TOP 12 — LIVE-AUDIT GRADE</h2><div class="sub">By cheerio-fetched HTML signals · score / 100</div><div class="grid">${
  topGraded.map((r, i) => `<a class="row" href="/wa/${esc(r.slug)}" target="_blank" rel="noopener"><div class="rank">${i + 1}</div><div class="badge" style="background:${gradeColor(r.score)};">${esc(r.letter)}</div><div class="info"><div class="name">${esc((r.headline || r.slug).slice(0, 48))}</div><div class="meta">${esc(r.vertical || 'project')} · ${r.score}/100</div></div></a>`).join('')
}</div></section>
<section class="rail container"><h2>★ FRESHEST ROASTS</h2><div class="sub">Latest qwen3:14b stand-up takes · running locally on Mac1</div><div class="grid">${
  recentRoasts.map(r => `<a class="roast-row" href="/wa/${esc(r.slug)}" target="_blank" rel="noopener"><div class="name">${esc((r.headline || r.slug).slice(0, 48))}</div><div class="text">${esc((r.roast || '').slice(0, 200))}</div></a>`).join('')
}</div></section>
${verticals.length ? `<section class="rail container"><h2>★ AVG GRADE BY VERTICAL</h2><div class="sub">Where small businesses actually score</div><div class="grid">${
  verticals.map(v => `<div class="vert-row"><div class="v">${esc(v.vertical || '?')}</div><div class="s"><span style="background:${gradeColor(v.avg_score)};padding:4px 8px;border:2px solid #000;color:#000">${v.avg_score}/100</span> · ${v.n}</div></div>`).join('')
}</div></section>` : ''}
<footer style="border-top:4px solid #000;background:#000;color:#fafaf6;padding:48px 24px"><div class="container" style="display:flex;justify-content:space-between;flex-wrap:wrap;gap:18px"><div style="font-family:'Archivo Black',sans-serif;font-size:24px;color:#facc15">★ LIVE SITE AUDIT</div><div style="display:flex;gap:18px;flex-wrap:wrap"><a href="/wall" style="color:#fafaf6;text-decoration:none;font-family:'Archivo Black',sans-serif;font-size:13px;text-transform:uppercase;letter-spacing:.04em">★ THE WALL</a><a href="/grades" style="color:#facc15;text-decoration:none;font-family:'Archivo Black',sans-serif;font-size:13px;text-transform:uppercase;letter-spacing:.04em">★ LEADERBOARD</a><a href="/website-analysis" style="color:#fafaf6;text-decoration:none;font-family:'Archivo Black',sans-serif;font-size:13px;text-transform:uppercase;letter-spacing:.04em">★ AUDIT YOUR SITE</a></div></div></footer>
</body></html>`;
  _lbCache = { at: now, html };
  res.set('content-type', 'text/html; charset=utf-8');
  res.send(html);
});

// /roasts.rss — public RSS feed of latest qwen3 roasts (compliance-filtered).
app.get('/roasts.rss', async (_req, res) => {
  const rows = await many(`
    SELECT slug, summary_json->'roast'->>'text' AS text,
           summary_json->'roast'->>'generated_at' AS generated_at,
           summary_json->'copy_pack'->>'headline' AS headline
    FROM website_analyses
    WHERE summary_json ? 'roast'
      AND (meta_json->>'compliance_sensitive') IS DISTINCT FROM 'true'
    ORDER BY (summary_json->'roast'->>'generated_at') DESC NULLS LAST
    LIMIT 50`);
  const xmlEsc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&apos;' }[c]));
  const items = rows.filter(r => r.text).map(r => `
    <item>
      <title>${xmlEsc(`★ Roast: ${r.headline || r.slug}`)}</title>
      <link>https://livesiteaudit.agentabrams.com/wa/${xmlEsc(r.slug)}</link>
      <guid isPermaLink="false">roast-${xmlEsc(r.slug)}-${xmlEsc(r.generated_at || '')}</guid>
      <pubDate>${new Date(r.generated_at || Date.now()).toUTCString()}</pubDate>
      <description>${xmlEsc(r.text)}</description>
    </item>`).join('');
  const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel>
  <title>Live Site Audit · Roasts</title>
  <link>https://livesiteaudit.agentabrams.com/wall</link>
  <description>Daily savage-but-affectionate website roasts, generated by qwen3:14b running locally on Mac1.</description>
  <language>en-us</language>
  <lastBuildDate>${new Date().toUTCString()}</lastBuildDate>${items}
</channel></rss>`;
  res.set('content-type', 'application/rss+xml; charset=utf-8');
  res.send(xml);
});

// /api/roast-me — POST {url} → qwen3 roasts the user's site on the spot.
// Public path. Rate-limited per IP (5/min) so qwen3 isn't drowned.
const _roastRate = new Map();   // ip → [ts, ts, …]
app.post('/api/roast-me', express.json({ limit: '4kb' }), async (req, res) => {
  const ip = (req.headers['x-forwarded-for'] || req.ip || 'unknown').toString().split(',')[0].trim();
  const now = Date.now();
  const recent = (_roastRate.get(ip) || []).filter(t => now - t < 60_000);
  if (recent.length >= 5) return res.status(429).json({ error: 'whoa, slow down — try again in a minute' });
  recent.push(now);
  _roastRate.set(ip, recent);

  const url = String(req.body?.url || '').trim().slice(0, 240);
  if (!/^https?:\/\/[^\s]+$/i.test(url)) return res.status(400).json({ error: 'need a real http(s) URL' });
  let host;
  try { host = new URL(url).hostname; } catch { return res.status(400).json({ error: 'unparseable URL' }); }

  const SYS = `You are a stand-up comic roasting bad website design. SAVAGE BUT AFFECTIONATE. One paragraph, 2-4 sentences, max 60 words. Specific to the site, never generic. /no_think — output the roast directly with no preamble.`;
  const prompt = `/no_think\nROAST TARGET: ${host}\nFULL URL: ${url}\n\nWrite the roast. Just the paragraph, no quotes, no preamble.`;

  // Heartbeat the response immediately so nginx (and any reverse proxy with a
  // ~30s read timeout) sees byte flow well before qwen3 finishes — Mac1 has
  // OLLAMA_MAX_LOADED_MODELS=1 so a cold load + queue can take 30-60s.
  res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
  const heartbeat = setInterval(() => { try { res.write(' '); } catch {} }, 8_000);

  try {
    const r = await fetch('http://192.168.1.133:11434/api/generate', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      signal: AbortSignal.timeout(90_000),
      body: JSON.stringify({
        model: 'qwen3:14b', system: SYS, prompt, stream: false,
        options: { temperature: 0.95, num_predict: 250 },
      }),
    });
    if (!r.ok) throw new Error(`ollama ${r.status}`);
    const data = await r.json();
    let txt = (data.response || '').trim();
    txt = txt.replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
    txt = txt.replace(/^["']|["']$/g, '');
    txt = txt.replace(/^(here'?s|here is|roast:?|sure|okay|alright)[^.]*[:.]?\s*/i, '').trim();
    if (txt.length < 20) throw new Error('empty roast');
    clearInterval(heartbeat);
    res.end(JSON.stringify({ roast: txt, host }));
  } catch (e) {
    clearInterval(heartbeat);
    res.end(JSON.stringify({ error: 'qwen3 hung up on us — try again in a sec' }));
  }
});

app.get('/wa/:slug', async (req, res) => {
  const analysis = await one('SELECT * FROM website_analyses WHERE slug=$1', [req.params.slug]);
  if (!analysis) return res.status(404).send('not found');
  const mockups = await many(
    'SELECT mockup_id, title, theme FROM website_analysis_mockups WHERE analysis_id=$1 ORDER BY mockup_id',
    [analysis.id]
  );
  res.set('content-type', 'text/html; charset=utf-8');
  const isPublic = PUBLIC_HOSTS.has((req.hostname || '').toLowerCase());
  res.send(renderWAResult({ analysis, mockups, isPublic }));
});

// /wa/:slug/screenshot.png — Playwright-captured, disk-cached screenshot of the
// analyzed URL. Used as a visual fallback when the live site blocks iframe embeds.
// Steve's rule: "we must ALWAYS see the screen — if iframe does not work, screenshot."
app.get('/wa/:slug/screenshot.png', async (req, res) => {
  const slug = String(req.params.slug || '').slice(0, 96);
  if (!/^[a-z0-9-]+$/i.test(slug)) return res.status(400).send('bad slug');
  const SHOT_DIR = path.resolve('data/wa-screenshots');
  await fsp.mkdir(SHOT_DIR, { recursive: true });
  const cached = path.join(SHOT_DIR, `${slug}.png`);
  // Serve cached if <7 days old
  try {
    const st = await fsp.stat(cached);
    if (Date.now() - st.mtimeMs < 7 * 24 * 60 * 60 * 1000) {
      res.set('Cache-Control', 'public, max-age=86400');
      return res.sendFile(cached);
    }
  } catch {}
  const row = await one('SELECT url FROM website_analyses WHERE slug=$1', [slug]);
  if (!row) return res.status(404).send('not found');
  let browser;
  try {
    const { chromium } = await import('playwright-core');
    // chromium executablePath: prefer system Chrome/Edge if available
    const candidates = [
      '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
      '/Applications/Chromium.app/Contents/MacOS/Chromium',
      '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
      '/usr/bin/chromium',
      '/usr/bin/google-chrome',
    ];
    let executablePath;
    for (const p of candidates) { try { await fsp.access(p); executablePath = p; break; } catch {} }
    browser = await chromium.launch({ headless: true, executablePath });
    const ctx = await browser.newContext({ viewport: { width: 1280, height: 720 }, deviceScaleFactor: 1 });
    const page = await ctx.newPage();
    await page.goto(row.url, { waitUntil: 'domcontentloaded', timeout: 20000 });
    await page.waitForTimeout(1500);
    await page.screenshot({ path: cached, type: 'png', fullPage: false, animations: 'disabled' });
    await browser.close();
    res.set('Cache-Control', 'public, max-age=86400');
    return res.sendFile(cached);
  } catch (e) {
    if (browser) try { await browser.close(); } catch {}
    log.warn('screenshot capture failed', { slug, url: row.url, err: String(e?.message || e) });
    return res.status(502).send('screenshot capture failed');
  }
});

app.get('/wa/:slug/m/:mockup', async (req, res) => {
  const analysis = await one('SELECT id FROM website_analyses WHERE slug=$1', [req.params.slug]);
  if (!analysis) return res.status(404).send('not found');
  const m = await one(
    'SELECT preview_html FROM website_analysis_mockups WHERE analysis_id=$1 AND mockup_id=$2',
    [analysis.id, req.params.mockup]
  );
  if (!m) return res.status(404).send('not found');
  res.set('content-type', 'text/html; charset=utf-8');
  res.send(m.preview_html);
});

// "Ship this rebuild" — convert a website-analysis row + chosen mockup into a
// Deep-dive — synthesize a mini site-audit report from existing analysis row,
// store under summary_json.audit_report, then redirect back to /wa/:slug.
// Pure local synthesis: no scraping, no LLM. ~50ms per call.
import { runDeepDive } from '../lib/deep-dive.js';

// Bulk deep-dive — runs synthesis on every analysis in DB. Idempotent.
// Skips records already containing a recent (≤7d) audit_report unless ?force=1.
app.post('/api/builds/run-all-deep-dives', async (req, res) => {
  const force = req.query.force === '1' || req.body?.force === '1';
  const all = await many('SELECT id, slug, summary_json, meta_json, ig_handle, vertical FROM website_analyses');
  let ran = 0, skipped = 0;
  for (const a of all) {
    const existing = a.summary_json?.audit_report;
    if (!force && existing?.generated_at) {
      const ageDays = (Date.now() - Date.parse(existing.generated_at)) / 86400000;
      if (ageDays < 7) { skipped += 1; continue; }
    }
    const report = runDeepDive(a);
    const newSummary = { ...(a.summary_json || {}), audit_report: report };
    await query('UPDATE website_analyses SET summary_json=$1::jsonb, updated_at=NOW() WHERE id=$2',
                [JSON.stringify(newSummary), a.id]);
    ran += 1;
  }
  _buildsCache = { at: 0, rows: null };
  log.info('bulk deep-dive complete', { ran, skipped, total: all.length });
  res.redirect('/builds');
});

// CSV export — every build row + grade + audit_score, downloadable.
app.get('/api/builds/export.csv', async (_req, res) => {
  const ana = await many('SELECT slug, vertical, summary_json, meta_json, ig_handle FROM website_analyses');
  const set = new Set(ana.map(r => r.slug));
  const baseRows = listBuilds({ analysisSlugs: set });
  const anaBySlug = new Map(ana.map(r => [r.slug, r]));
  const grades = new Map();
  for (const b of baseRows) {
    const a = lookupAna(b, anaBySlug) || null;
    const g = computeGrade({ analysis: a, build: b });
    const ar = a?.summary_json?.audit_report;
    grades.set(b.slug, { ...g, vertical: a?.vertical || null, audit_score: ar?.score ?? null });
  }
  const rows = listBuilds({ analysisSlugs: set, grades });
  const escCsv = (v) => {
    const s = String(v == null ? '' : v).replace(/"/g, '""');
    return /[",\n]/.test(s) ? `"${s}"` : s;
  };
  const headers = ['name','category','grade','grade_score','audit_score','vertical','last_at','pm2_status','pm2_port','public_url','has_git','description'];
  const lines = [headers.join(',')];
  for (const r of rows) {
    lines.push([
      r.name, r.category, r.grade?.letter || '', r.grade?.score || '',
      r.audit_score ?? '', r.grade?.vertical || '', r.last_at || '',
      r.pm2?.status || '', r.pm2?.port || '', r.url || '',
      r.has_git ? 'yes' : '', (r.description || '').slice(0, 480),
    ].map(escCsv).join(','));
  }
  res.set('content-type', 'text/csv; charset=utf-8');
  res.set('content-disposition', `attachment; filename="builds-${new Date().toISOString().slice(0,10)}.csv"`);
  res.send(lines.join('\n'));
});

// REBUILD MOCKUPS — runs build-all-analyses.mjs --rebuild, streams output.
import { spawn as _spawn } from 'node:child_process';
app.post('/api/builds/rebuild-mockups', (_req, res) => {
  const child = _spawn('node', ['scripts/build-all-analyses.mjs', '--rebuild'], {
    cwd: path.resolve(__dirname, '..', '..'),
    env: process.env,
  });
  res.set('content-type', 'text/plain; charset=utf-8');
  res.set('refresh', '60; url=/builds');
  res.write('★ REBUILDING MOCKUPS — this page reloads /builds in 60s\n\n');
  child.stdout.on('data', d => res.write(d));
  child.stderr.on('data', d => res.write(d));
  child.on('close', code => {
    res.write(`\n\n★ DONE (exit ${code}). Reloading /builds...\n`);
    res.end();
    _buildsCache = { at: 0, rows: null };
    log.info('rebuild-mockups complete', { exit: code });
  });
});

// FIX VISIBLE — force-rerun deep-dive on a comma-separated list of slugs.
// Always overrides the 7-day skip. Bounded by 200 slugs to prevent runaway.
app.post('/api/builds/fix-visible', async (req, res) => {
  const raw = (req.body?.slugs || req.query?.slugs || '').toString();
  const slugs = raw.split(',').map(s => s.trim()).filter(Boolean).slice(0, 200);
  if (!slugs.length) return res.status(400).send('no slugs provided');
  const all = await many(
    `SELECT id, slug, summary_json, meta_json, ig_handle, vertical
       FROM website_analyses
      WHERE slug = ANY($1::text[])`,
    [slugs]
  );
  let ran = 0;
  for (const a of all) {
    const report = runDeepDive(a);
    const newSummary = { ...(a.summary_json || {}), audit_report: report };
    await query('UPDATE website_analyses SET summary_json=$1::jsonb, updated_at=NOW() WHERE id=$2',
                [JSON.stringify(newSummary), a.id]);
    ran += 1;
  }
  _buildsCache = { at: 0, rows: null };
  log.info('fix-visible ran', { requested: slugs.length, ran });
  res.redirect('/builds');
});

// Lightweight peek fragment for the drawer — small + fast.
app.get('/api/wa/:slug/peek', async (req, res) => {
  const a = await one('SELECT * FROM website_analyses WHERE slug=$1', [req.params.slug]);
  if (!a) return res.status(404).send('not found');
  const ar = a.summary_json?.audit_report;
  const meta = a.meta_json || {};
  const sum = a.summary_json || {};
  const title = sum.title || meta.title || a.slug;
  const tagline = sum.tagline || meta.tagline || '';
  const ig = a.ig_handle ? '@' + a.ig_handle : '';
  const body = `
<div style="font-family:'Space Mono',monospace">
  <div style="font-size:11px;font-weight:700;color:#666;letter-spacing:.06em;text-transform:uppercase;margin-bottom:8px">★ ${esc(a.vertical || 'generic')}</div>
  <h2 style="font-family:'Archivo Black',sans-serif;font-size:32px;line-height:1;letter-spacing:-.02em;text-transform:uppercase;margin-bottom:14px">${esc(title)}</h2>
  ${tagline ? `<p style="font-size:14px;line-height:1.5;color:#333;margin-bottom:14px">${esc(tagline)}</p>` : ''}
  ${ig ? `<div style="font-size:12px;font-weight:700;margin-bottom:14px">★ ${esc(ig)}</div>` : ''}
  <div style="display:flex;gap:10px;margin:14px 0;align-items:center;flex-wrap:wrap">
    ${ar ? `<div style="background:${ar.score>=70?'#22c55e':ar.score>=50?'#84cc16':ar.score>=30?'#facc15':'#dc2626'};color:#000;border:3px solid #000;padding:8px 14px;display:flex;gap:10px;align-items:center"><div style="font-family:'Archivo Black',sans-serif;font-size:36px;line-height:1">${ar.score}</div><div style="font-size:10px;font-weight:700">/100<br>★ AUDIT</div></div>` : ''}
    <button data-star="${esc(a.slug)}" type="button" style="background:#fff;color:#000;border:3px solid #000;padding:8px 14px;font-family:'Archivo Black',sans-serif;font-size:13px;text-transform:uppercase;cursor:pointer">★ STAR</button>
    <a href="/wa/${esc(a.slug)}" target="_blank" rel="noopener noreferrer" style="background:#000;color:#facc15;padding:8px 14px;font-family:'Archivo Black',sans-serif;font-size:13px;text-transform:uppercase;text-decoration:none;border:3px solid #000">★ FULL ↗</a>
    <form method="post" action="/api/wa/${esc(a.slug)}/deep-dive" style="display:inline;margin:0">
      <button type="submit" style="background:#facc15;color:#000;border:3px solid #000;padding:8px 14px;font-family:'Archivo Black',sans-serif;font-size:13px;text-transform:uppercase;cursor:pointer">★ DEEP-DIVE</button>
    </form>
  </div>
  ${ar?.quick_wins?.length ? `<div style="background:#dc2626;color:#fff;padding:14px;border:3px solid #000;margin-top:14px">
    <div style="font-family:'Archivo Black',sans-serif;font-size:14px;text-transform:uppercase;margin-bottom:10px">★ ${ar.quick_wins.length} QUICK WINS</div>
    <ul style="list-style:none;font-size:12px;line-height:1.5">${ar.quick_wins.map(q => `<li style="padding:4px 0;border-bottom:1px solid #fff8">→ ${esc(q)}</li>`).join('')}</ul>
  </div>` : '<div style="color:#666;font-size:12px;margin-top:14px">★ No deep-dive run yet — click DEEP-DIVE above.</div>'}
  ${ar?.suggested_headlines?.length ? `<div style="background:#facc15;color:#000;padding:14px;border:3px solid #000;margin-top:14px">
    <div style="font-family:'Archivo Black',sans-serif;font-size:14px;text-transform:uppercase;margin-bottom:10px">★ HEADLINES TO TRY</div>
    ${ar.suggested_headlines.map((h, i) => `<div style="padding:6px 0;border-bottom:2px solid #000;font-size:13px;font-weight:700"><span style="font-family:'Archivo Black',sans-serif;margin-right:6px">${i+1}.</span>${esc(h)}</div>`).join('')}
  </div>` : ''}
  ${ar?.vertical_tips?.length ? `<div style="background:#fff;color:#000;padding:14px;border:3px solid #000;margin-top:14px">
    <div style="font-family:'Archivo Black',sans-serif;font-size:14px;text-transform:uppercase;margin-bottom:10px">★ VERTICAL TIPS</div>
    ${ar.vertical_tips.map((t, i) => `<div style="padding:6px 0;border-bottom:2px solid #000;font-size:12px;line-height:1.5"><span style="font-family:'Archivo Black',sans-serif;margin-right:6px">${String.fromCharCode(65+i)}.</span>${esc(t)}</div>`).join('')}
  </div>` : ''}
</div>`;
  res.set('content-type', 'text/html; charset=utf-8');
  res.send(body);
});

app.post('/api/wa/:slug/deep-dive', async (req, res) => {
  const a = await one('SELECT * FROM website_analyses WHERE slug=$1', [req.params.slug]);
  if (!a) return res.status(404).send('not found');
  const report = runDeepDive(a);
  const newSummary = { ...(a.summary_json || {}), audit_report: report };
  await query('UPDATE website_analyses SET summary_json=$1::jsonb, updated_at=NOW() WHERE id=$2',
              [JSON.stringify(newSummary), a.id]);
  log.info('deep-dive ran', { slug: a.slug, score: report.score });
  res.redirect(`/wa/${a.slug}#audit-report`);
});

// real businesses row + business_mockups row, then redirect to the smb-builder
// edit flow so the user can keep iterating on the picked direction.
app.post('/api/wa/:slug/ship/:mockup', async (req, res) => {
  const analysis = await one('SELECT * FROM website_analyses WHERE slug=$1', [req.params.slug]);
  if (!analysis) return res.status(404).send('analysis not found');
  const m = await one(
    'SELECT mockup_id, title, theme, preview_html FROM website_analysis_mockups WHERE analysis_id=$1 AND mockup_id=$2',
    [analysis.id, req.params.mockup]
  );
  if (!m) return res.status(404).send('mockup not found');

  // If we already shipped this analysis to a business, route them to that biz.
  if (analysis.business_id) {
    const existing = await one('SELECT slug FROM businesses WHERE id=$1', [analysis.business_id]);
    if (existing) return res.redirect(`/biz/${existing.slug}/edit`);
  }

  const meta = analysis.meta_json || {};
  const baseName = meta.title || (new URL(analysis.url)).hostname.replace(/^www\./, '');
  const bSlug = await uniqueSlug(baseName);

  const created = await one(
    `INSERT INTO businesses (slug, name, category, website, hero_image_url, color_primary, color_accent, about_text, source_data_json)
     VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9::jsonb)
     RETURNING id, slug`,
    [
      bSlug, baseName, analysis.vertical || 'generic', analysis.url,
      meta.hero_image || null,
      (meta.palette && meta.palette[0]) || null,
      (meta.palette && meta.palette[1]) || null,
      meta.tagline || null,
      JSON.stringify({ from_website_analysis: analysis.slug, picked_mockup: m.mockup_id, picked_theme: m.theme }),
    ]
  );

  // Record the chosen mockup HTML as template_id 1 (default profile template)
  // so /biz/:slug/edit lights up immediately.
  await query(
    `INSERT INTO business_mockups (business_id, template_id, preview_url, full_html)
     VALUES ($1, 1, $2, $3)
     ON CONFLICT (business_id, template_id)
     DO UPDATE SET preview_url=EXCLUDED.preview_url, full_html=EXCLUDED.full_html, generated_at=NOW()`,
    [created.id, `/wa/${analysis.slug}/m/${m.mockup_id}`, m.preview_html]
  );

  await query(
    'UPDATE website_analyses SET selected_mockup_id=$1, business_id=$2, updated_at=NOW() WHERE id=$3',
    [m.mockup_id, created.id, analysis.id]
  );

  log.info('website-analysis shipped', { analysis_slug: analysis.slug, biz_slug: created.slug, theme: m.theme });
  res.redirect(`/biz/${created.slug}/edit`);
});

// --- /map + /api/map.json ---------------------------------------------------

// In-memory cache for map data (static-ish dataset, reuse for 60s)
let _mapCache = { at: 0, data: null };

app.get('/api/map.json', async (_req, res) => {
  try {
    const now = Date.now();
    if (!_mapCache.data || (now - _mapCache.at) > 60_000) {
      const rows = await many(
        `SELECT slug, name, category, address, city,
                latitude::float AS lat, longitude::float AS lng,
                source_data_json->>'source' AS src,
                source_data_json->>'license_number' AS lic_num,
                phone
         FROM businesses
         WHERE latitude IS NOT NULL
         ORDER BY category, name
         LIMIT 15000`
      );
      _mapCache.data = rows.map(r => ({
        slug:     r.slug     || null,
        name:     r.name     || '',
        category: r.category || '',
        address:  r.address  || '',
        city:     r.city     || '',
        lat:      r.lat,
        lng:      r.lng,
        phone:    r.phone    || null,
        lic:      r.src === 'dca_bbcb' ? (r.lic_num || null) : null,
      }));
      _mapCache.at = now;
    }
    res.set('content-type', 'application/json; charset=utf-8');
    res.set('cache-control', 'public, max-age=60');
    res.send(JSON.stringify(_mapCache.data));
  } catch (e) {
    fail(res, '/api/map.json', e);
  }
});

app.get('/map', async (_req, res) => {
  try {
    const stats = await one(
      `SELECT
         COUNT(*)                                           AS geocoded,
         COUNT(*) FILTER (WHERE source_data_json->>'source' = 'dca_bbcb') AS licensed,
         COUNT(DISTINCT city) FILTER (WHERE city IS NOT NULL AND city <> '') AS cities
       FROM businesses
       WHERE latitude IS NOT NULL`
    );
    res.set('content-type', 'text/html; charset=utf-8');
    res.send(renderMap({
      stats: {
        geocoded: parseInt(stats.geocoded, 10) || 0,
        licensed: parseInt(stats.licensed, 10) || 0,
        cities:   parseInt(stats.cities,   10) || 0,
      },
    }));
  } catch (e) {
    log.err('/map failed', { error: String(e?.stack || e?.message || e) });
    res.status(500).send('Internal error');
  }
});

// --- admin ------------------------------------------------------------------

app.get('/admin', async (req, res) => {
  const tok = req.query.t || req.cookies?.smb_admin || '';
  if (!ADMIN_TOKEN || tok !== ADMIN_TOKEN) {
    res.set('content-type', 'text/html; charset=utf-8');
    return res.send(`<form><input name="t" placeholder="admin token"><button>Enter</button></form>`);
  }
  res.cookie('smb_admin', tok, { httpOnly: true, sameSite: 'lax' });
  const rows = await many('SELECT id,slug,name,city,state,tier,created_at FROM businesses ORDER BY created_at DESC');
  res.set('content-type', 'text/html; charset=utf-8');
  res.send(`<!doctype html><meta charset="utf-8"><title>SMB Builder Admin</title>
    <style>body{font-family:system-ui;padding:24px;max-width:980px;margin:0 auto}
    table{width:100%;border-collapse:collapse}td,th{border-bottom:1px solid #eee;padding:8px;text-align:left}
    a{color:#d97706}.del{color:#c00;cursor:pointer;border:0;background:none}</style>
    <h1>Admin · ${rows.length} businesses</h1>
    <table><tr><th>id</th><th>slug</th><th>name</th><th>city</th><th>tier</th><th></th></tr>
    ${rows.map(r => `<tr><td>${r.id}</td><td><a href="/biz/${r.slug}">${r.slug}</a></td><td>${r.name}</td><td>${r.city || ''} ${r.state || ''}</td><td>${r.tier}</td>
      <td><form method="POST" action="/api/admin/delete" style="display:inline" onsubmit="return confirm('Delete ${r.slug}?')"><input type="hidden" name="slug" value="${r.slug}"><button class="del">delete</button></form></td></tr>`).join('')}
    </table>`);
});

app.post('/api/admin/delete', async (req, res) => {
  const tok = req.cookies?.smb_admin || req.body?.t || '';
  if (!ADMIN_TOKEN || tok !== ADMIN_TOKEN) return res.status(401).json({ error: 'unauthorized' });
  const slug = req.body?.slug;
  if (!slug) return res.status(400).json({ error: 'slug required' });
  await query('DELETE FROM businesses WHERE slug=$1', [slug]);
  res.redirect('/admin');
});

// --- boot -------------------------------------------------------------------

app.listen(PORT, () => {
  log.info('smb-builder listening', { port: PORT });
});