← back to Professional Directory

agents/api-agent/server.js

2338 lines

#!/usr/bin/env node
/**
 * Read API + tiny admin dashboard for the Professional Directory.
 *
 *   GET /                                  — minimal HTML dashboard
 *   GET /health                            — { ok: true, counts: {...} }
 *   GET /professionals                     — filters: zip, city, specialty,
 *                                            license_status, min_confidence,
 *                                            limit (≤500), offset
 *   GET /professionals/:npi
 *   GET /organizations                     — filters: type, city, zip
 *   GET /organizations/:id
 *   GET /search?q=                         — trigram across professionals + orgs
 *   GET /website-data/:npi                 — JSON shaped for SEO templates
 *   GET /export.csv?entity=professionals&zip=…
 *   POST /opt-out                          — body: { type, id }  → flips opted_out
 *
 * No auth in dev. For local-only use; bind to 127.0.0.1.
 */
const path = require('path');
const fs = require('fs');
require('dotenv').config({ path: path.resolve(__dirname, '../../.env') });
const express = require('express');
const session = require('express-session');
const PgSession = require('connect-pg-simple')(session);
const { stringify } = require('csv-stringify');
const { pool, query } = require('../shared/db');
const { passport, requireRole, loopbackSyntheticAdmin, authConfigured } = require('./auth');
const authRouter = require('./routes/auth');
const claimsRouters = require('./routes/claims');
const subscriptionsRouter = require('./routes/subscriptions');
const chatRouter = require('./routes/chat');
const reviewsRouters = require('./routes/reviews');
const threadsRouters = require('./routes/threads');
const messagesRouter = require('./routes/messages');
const adminPitchRouter = require('./routes/admin-pitch');
const dataMarketRouter = require('./data_market');
const { stripeWebhookHandler } = dataMarketRouter;
// Cycle-2 Claude review P1: defensive assertion. The module.exports pattern
// in data_market.js is `module.exports = router; module.exports.fn = fn`.
// If the export is ever lost in a refactor, server.js would silently mount
// `app.post(path, raw, undefined)` and Stripe webhooks would 404 in prod.
if (typeof stripeWebhookHandler !== 'function') {
  throw new Error('[pd-api] FATAL: stripeWebhookHandler not exported from data_market — webhook would 404 in prod');
}

const PORT = Number(process.env.PORT_API || 9874);
const app = express();

// Security headers — added 2026-05-04 (overnight YOLO loop tick 26).
// CSP disabled because /preview pages embed inline mockup HTML. Defer
// CSP hardening per-route. Other Helmet headers (HSTS, X-Frame-Options,
// X-Content-Type-Options, Referrer-Policy, etc.) all apply.
const helmet = require('helmet');
app.use(helmet({ contentSecurityPolicy: false }));

// Codex P1 #1 fix: Stripe webhook MUST receive the raw request body so the
// signature check can verify it. Mounted BEFORE express.json() — once the
// global JSON parser runs, req.body is a parsed object and constructEvent()
// rejects every event silently.
app.post('/webhooks/stripe-data', express.raw({ type: 'application/json' }), stripeWebhookHandler);

app.use(express.json());

// ─── Session + auth (Phase 1) ──────────────────────────────────────────────
// Express-session backed by Postgres (user_sessions table created by 003).
// SESSION_SECRET must be set in .env for prod; default placeholder here lets
// loopback dev continue while we wait for Steve to mint one.
app.set('trust proxy', 1);   // pd-preview / Cloudflare tunnel adds X-Forwarded-For
app.use(session({
  store: new PgSession({ pool, tableName: 'user_sessions', createTableIfMissing: false }),
  secret: process.env.SESSION_SECRET || 'pd-dev-only-replace-me',
  resave: false,
  saveUninitialized: false,
  cookie: {
    httpOnly: true,
    sameSite: 'lax',
    secure: process.env.NODE_ENV === 'production' && process.env.SESSION_INSECURE !== '1',
    maxAge: 1000 * 60 * 60 * 24 * 30,   // 30 days
  },
}));
app.use(passport.initialize());
app.use(passport.session());
// Loopback synthetic-admin runs AFTER passport so it only fires when no real
// session user exists. Gated by ADMIN_LOOPBACK_TRUST=1 in pm2 env.
app.use(loopbackSyntheticAdmin);

app.use('/auth', authRouter);
app.use('/doctor-claims', claimsRouters.router);
app.use('/admin/doctor-claims', claimsRouters.adminRouter);
app.use('/subscriptions', subscriptionsRouter);
app.use('/api/chat', chatRouter);
app.use('/reviews', reviewsRouters.router);
app.use('/admin/reviews', reviewsRouters.adminRouter);
app.use('/threads', threadsRouters.router);
app.use('/admin/threads', threadsRouters.adminRouter);
app.use('/messages', messagesRouter);
app.use('/admin/pitch', adminPitchRouter);

// Static-serve generated mockups for the admin pitch flow.
app.use('/mockups', express.static(path.resolve(__dirname, '../../data/mockups')));

// Doctor data marketplace — mirrors Counsel & Bar's data_market for medical
// recruiters / pharma / B2B. Mounted after the JSON parser so /data,
// /data/checkout, etc. get parsed body. The /webhooks/stripe-data route
// inside the router is now shadowed by the explicit raw mount above — that's
// intentional, and works because Express resolves routes top-down.
app.use(dataMarketRouter);

const OPT_OUT = ' AND opted_out = false ';

// ─── Dashboard ──────────────────────────────────────────────────────────────
app.get('/', async (_req, res) => {
  const counts = await getCounts();
  res.setHeader('Content-Type','text/html; charset=utf-8');
  res.end(renderDashboard(counts));
});

app.get('/health', async (_req, res) => {
  res.json({ ok: true, counts: await getCounts() });
});

// ─── Professionals ──────────────────────────────────────────────────────────
app.get('/professionals', async (req, res, next) => {
  try {
    const { zip, city, specialty, license_status, min_confidence } = req.query;
    const limit  = Math.min(Number(req.query.limit) || 100, 500);
    const offset = Number(req.query.offset) || 0;

    const where = [`p.opted_out = false`];
    const params = [];
    let i = 1;
    if (zip)            { where.push(`(pl.address ILIKE '%' || $${i} || '%' OR o.zip = $${i})`); params.push(zip); i++; }
    if (city)           { where.push(`o.city ILIKE $${i}`); params.push(city); i++; }
    if (specialty)      { where.push(`s.name ILIKE '%' || $${i} || '%'`); params.push(specialty); i++; }
    if (license_status) { where.push(`p.license_status = $${i}`); params.push(license_status); i++; }
    if (min_confidence) { where.push(`p.source_confidence_score >= $${i}`); params.push(Number(min_confidence)); i++; }

    const sql = `
      WITH base AS (
        SELECT DISTINCT p.id, p.full_name, p.title, p.npi_number, p.license_number,
               p.license_status, p.primary_specialty, p.medical_school, p.graduation_year,
               p.source_confidence_score
          FROM professionals p
          LEFT JOIN professional_locations pl ON pl.professional_id = p.id
          LEFT JOIN organizations o           ON o.id = pl.organization_id
          LEFT JOIN professional_specialties ps ON ps.professional_id = p.id
          LEFT JOIN specialties s              ON s.id = ps.specialty_id
         WHERE ${where.join(' AND ')}
         ORDER BY p.source_confidence_score DESC NULLS LAST, p.full_name
         LIMIT ${limit} OFFSET ${offset}
      )
      SELECT base.*,
             (SELECT email FROM emails e
                WHERE e.professional_id = base.id
                ORDER BY e.last_verified_at DESC NULLS LAST, e.discovered_at DESC
                LIMIT 1) AS email
        FROM base
       ORDER BY base.source_confidence_score DESC NULLS LAST, base.full_name
    `;
    const rows = (await query(sql, params)).rows;
    res.json({ count: rows.length, rows });
  } catch (e) { next(e); }
});

app.get('/professionals/:npi', async (req, res, next) => {
  try {
    // SECURITY: explicit allow-list of public columns. Do NOT use p.* — internal
    // columns like opted_out_at, dedup_hash, raw_json must not leak to API
    // consumers. Add new columns here intentionally.
    const r = await query(`
      SELECT p.id, p.full_name, p.first_name, p.last_name, p.middle_name, p.suffix,
             p.title, p.npi_number, p.license_number, p.license_type, p.license_status,
             p.license_issue_date, p.license_expiration_date,
             p.gender, p.medical_school, p.graduation_year, p.years_experience_estimate,
             p.primary_specialty, p.secondary_specialties, p.bio, p.profile_image_url,
             p.source_confidence_score, p.claim_status, p.created_at, p.updated_at,
             json_agg(DISTINCT jsonb_build_object(
        'organization_id', pl.organization_id,
        'role', pl.role,
        'address', pl.address,
        'phone', pl.phone,
        'source_url', pl.source_url
      )) FILTER (WHERE pl.id IS NOT NULL) AS locations,
       json_agg(DISTINCT jsonb_build_object(
        'specialty', s.name,
        'taxonomy_code', s.taxonomy_code,
        'confidence', ps.confidence_score
      )) FILTER (WHERE s.id IS NOT NULL) AS specialties
      FROM professionals p
      LEFT JOIN professional_locations pl ON pl.professional_id = p.id
      LEFT JOIN professional_specialties ps ON ps.professional_id = p.id
      LEFT JOIN specialties s ON s.id = ps.specialty_id
      WHERE p.npi_number = $1 ${OPT_OUT}
      GROUP BY p.id
    `, [req.params.npi]);
    if (!r.rowCount) return res.status(404).json({ error: 'not found' });
    res.json(r.rows[0]);
  } catch (e) { next(e); }
});

// Category → type-bucket mapping (small-biz themes Steve sells websites to).
const CATEGORY_TYPES = {
  cosmetology:     ['salon','barbershop','nail_salon','beauty_supply','spa'],
  chiropractic:    ['chiropractor_office'],
  chinese_medicine:['acupuncture_clinic','tcm_clinic','tcm_herbalist'],
  dental:          ['dental_office'],
  optometry:       ['optometrist_office','optical_shop'],
  fitness:         ['yoga_studio','pilates_studio','gym'],
  tattoo:          ['tattoo_studio','piercing_studio'],
  pets:            ['vet_clinic','pet_grooming','pet_shop'],
  medical:         ['hospital','clinic','medical_group','surgery_center','urgent_care','fqhc'],
};

// ─── Organizations ──────────────────────────────────────────────────────────
function buildOrgWhere(qs) {
  const where = [`opted_out = false`];
  const params = []; let i = 1;
  if (qs.type)     { where.push(`type = $${i}`); params.push(qs.type); i++; }
  if (qs.category && CATEGORY_TYPES[qs.category]) {
    where.push(`type = ANY($${i}::text[])`); params.push(CATEGORY_TYPES[qs.category]); i++;
  }
  if (qs.city) { where.push(`city ILIKE $${i}`); params.push(qs.city); i++; }
  if (qs.zip)  { where.push(`zip = $${i}`); params.push(qs.zip); i++; }
  if (qs.has_website === 'true')  where.push(`website IS NOT NULL AND website <> ''`);
  if (qs.has_website === 'false') where.push(`(website IS NULL OR website = '')`);
  if (qs.has_phone === 'true')    where.push(`phone IS NOT NULL AND phone <> ''`);
  if (qs.has_phone === 'false')   where.push(`(phone IS NULL OR phone = '')`);
  if (qs.has_email === 'true')    where.push(`EXISTS (SELECT 1 FROM emails e WHERE e.organization_id = organizations.id)`);
  if (qs.has_email === 'false')   where.push(`NOT EXISTS (SELECT 1 FROM emails e WHERE e.organization_id = organizations.id)`);
  if (qs.domain_available === 'true')  where.push(`domain_available = TRUE`);
  if (qs.domain_available === 'false') where.push(`domain_available = FALSE`);
  if (qs.starred === 'true')           where.push(`starred = TRUE`);
  if (qs.contacted === 'true')         where.push(`contacted_at IS NOT NULL`);
  if (qs.contacted === 'false')        where.push(`contacted_at IS NULL`);
  if (qs.is_chain === 'false')         where.push(`is_chain = FALSE`);
  if (qs.is_chain === 'true')          where.push(`is_chain = TRUE`);
  if (qs.min_score) {
    where.push(`lead_score >= $${i}`); params.push(Number(qs.min_score)); i++;
  }
  return { where, params, nextParam: i };
}

function orderClause(sort) {
  switch (sort) {
    case 'score':     return `ORDER BY lead_score DESC, name`;
    case 'prospects': return `ORDER BY (website IS NULL OR website = '') DESC,
                                       (phone   IS NOT NULL AND phone   <> '') DESC,
                                       lead_score DESC, name`;
    case 'has_website': return `ORDER BY (website IS NOT NULL AND website <> '') DESC, name`;
    case 'zip':         return `ORDER BY zip NULLS LAST, name`;
    case 'name':       return `ORDER BY name`;
    default:           return `ORDER BY lead_score DESC, name`;
  }
}

app.get('/organizations', async (req, res, next) => {
  try {
    const limit  = Math.min(Number(req.query.limit) || 100, 500);
    const offset = Number(req.query.offset) || 0;
    const { where, params } = buildOrgWhere(req.query);
    const sql = `
      SELECT id, name, type, address, city, state, zip, phone, website,
             lat, lng, hcai_id, cdph_license,
             suggested_domain, domain_available, domain_checked_at,
             starred, contacted_at, notes, is_chain, lead_score,
             (website IS NOT NULL AND website <> '') AS has_website,
             (phone   IS NOT NULL AND phone   <> '') AS has_phone,
             (SELECT email FROM emails e
                WHERE e.organization_id = organizations.id
                ORDER BY e.last_verified_at DESC NULLS LAST, e.discovered_at DESC
                LIMIT 1) AS email
        FROM organizations
       WHERE ${where.join(' AND ')}
       ${orderClause(req.query.sort)}
       LIMIT ${limit} OFFSET ${offset}
    `;
    const rows = (await query(sql, params)).rows;
    res.json({ count: rows.length, rows });
  } catch (e) { next(e); }
});

// Compact endpoint for the clustered map view — returns just the fields a pin
// needs, for every org matching the filter. Capped at 80,000 rows.
app.get('/map.json', async (req, res, next) => {
  try {
    const { where, params } = buildOrgWhere(req.query);
    where.push('lat IS NOT NULL AND lng IS NOT NULL');
    const sql = `
      SELECT id, name, type, lat::float AS lat, lng::float AS lng,
             (website IS NOT NULL AND website <> '') AS has_website,
             EXISTS(SELECT 1 FROM emails e WHERE e.organization_id = organizations.id) AS has_email,
             lead_score, contacted_at IS NOT NULL AS contacted
        FROM organizations
       WHERE ${where.join(' AND ')}
       LIMIT 80000
    `;
    const rows = (await query(sql, params)).rows;
    res.setHeader('Cache-Control', 'private, max-age=60');
    res.json({ count: rows.length, rows });
  } catch (e) { next(e); }
});

// PATCH-style update for sales-workflow fields. Body: { starred, contacted, notes }
// SECURITY: codex flagged this as unauth + reachable when proxied; gate on admin
// (synthetic-loopback admin counts; real OAuth admin counts; no one else).
app.post('/organizations/:id/touch', requireRole('admin'), async (req, res, next) => {
  try {
    const id = Number(req.params.id);
    if (!id) return res.status(400).json({ error: 'id required' });
    const { starred, contacted, notes } = req.body || {};
    const sets = []; const params = [id]; let i = 2;
    if (typeof starred === 'boolean') { sets.push(`starred = $${i++}`); params.push(starred); }
    if (contacted === true)  { sets.push(`contacted_at = NOW()`); }
    if (contacted === false) { sets.push(`contacted_at = NULL`); }
    if (typeof notes === 'string') { sets.push(`notes = $${i++}`); params.push(notes); }
    if (sets.length === 0) return res.status(400).json({ error: 'no fields to update' });
    sets.push(`updated_at = NOW()`);
    const r = await query(
      `UPDATE organizations SET ${sets.join(', ')} WHERE id = $1
       RETURNING id, starred, contacted_at, notes`,
      params);
    if (!r.rowCount) return res.status(404).json({ error: 'not found' });
    res.json(r.rows[0]);
  } catch (e) { next(e); }
});

app.get('/categories', (_req, res) => {
  res.json(CATEGORY_TYPES);
});

// Pipeline summary — one row per small-biz category with funnel counts.
app.get('/pipeline', async (_req, res, next) => {
  try {
    const r = await query(`
      SELECT
        CASE
          WHEN type IN ('salon','barbershop','nail_salon','beauty_supply','spa') THEN 'cosmetology'
          WHEN type = 'chiropractor_office' THEN 'chiropractic'
          WHEN type IN ('acupuncture_clinic','tcm_clinic','tcm_herbalist') THEN 'chinese_medicine'
          WHEN type = 'dental_office' THEN 'dental'
          WHEN type IN ('optometrist_office','optical_shop') THEN 'optometry'
          WHEN type IN ('yoga_studio','pilates_studio','gym') THEN 'fitness'
          WHEN type IN ('tattoo_studio','piercing_studio') THEN 'tattoo'
          WHEN type IN ('vet_clinic','pet_grooming','pet_shop') THEN 'pets'
          ELSE 'other'
        END AS category,
        COUNT(*) AS total,
        COUNT(*) FILTER (WHERE NOT is_chain) AS independents,
        COUNT(*) FILTER (WHERE NOT is_chain AND (website IS NULL OR website='')) AS prospects,
        COUNT(*) FILTER (WHERE NOT is_chain AND (website IS NULL OR website='') AND domain_available = TRUE) AS domain_available,
        COUNT(*) FILTER (WHERE NOT is_chain AND lead_score >= 80) AS warmest,
        COUNT(*) FILTER (WHERE starred) AS starred,
        COUNT(*) FILTER (WHERE contacted_at IS NOT NULL) AS contacted
      FROM organizations
      WHERE type IN (
        'salon','barbershop','nail_salon','beauty_supply','spa',
        'chiropractor_office',
        'acupuncture_clinic','tcm_clinic','tcm_herbalist',
        'dental_office','optometrist_office','optical_shop',
        'yoga_studio','pilates_studio','gym',
        'tattoo_studio','piercing_studio',
        'vet_clinic','pet_grooming','pet_shop'
      )
      GROUP BY category
      ORDER BY warmest DESC, total DESC
    `, []);
    res.json({ count: r.rowCount, rows: r.rows });
  } catch (e) { next(e); }
});

// Prospect counts per ZIP for the active filter set — used by the heat panel.
app.get('/stats/by-zip', async (req, res, next) => {
  try {
    const { where, params } = buildOrgWhere(req.query);
    where.push(`zip IS NOT NULL`);
    const sql = `
      SELECT zip,
             COUNT(*) AS total,
             COUNT(*) FILTER (WHERE website IS NULL OR website = '') AS prospects,
             COUNT(*) FILTER (WHERE (website IS NULL OR website = '') AND phone IS NOT NULL AND phone <> '') AS prospects_with_phone
        FROM organizations
       WHERE ${where.join(' AND ')}
       GROUP BY zip
       ORDER BY prospects DESC NULLS LAST
       LIMIT 50
    `;
    const r = await query(sql, params);
    res.json({ count: r.rowCount, rows: r.rows });
  } catch (e) { next(e); }
});

// Cities present in the result-set for the city filter dropdown.
app.get('/stats/cities', async (req, res, next) => {
  try {
    const { where, params } = buildOrgWhere(req.query);
    where.push(`city IS NOT NULL`);
    const sql = `
      SELECT city, COUNT(*) AS n FROM organizations
       WHERE ${where.join(' AND ')} GROUP BY city ORDER BY n DESC LIMIT 100
    `;
    const r = await query(sql, params);
    res.json({ count: r.rowCount, rows: r.rows });
  } catch (e) { next(e); }
});

// Cold-outreach CSV — small-biz prospects ready for paste-into-CRM.
// SECURITY: bulk PII export — admin only.
app.get('/outreach.csv', requireRole('admin'), async (req, res, next) => {
  try {
    const limit  = Math.min(Number(req.query.limit) || 5000, 20000);
    const { where, params } = buildOrgWhere(req.query);
    const sql = `
      SELECT id, name, type, address, city, zip, phone, website,
             lat, lng, suggested_domain, domain_available,
             CASE WHEN website IS NOT NULL AND website <> '' THEN 'has_site' ELSE 'needs_site' END AS prospect_state,
             'https://www.google.com/maps/search/?api=1&query=' || lat || ',' || lng AS map_link,
             'https://www.godaddy.com/domainsearch/find?domainToCheck=' || COALESCE(suggested_domain, '') AS register_link
        FROM organizations
       WHERE ${where.join(' AND ')}
       ${orderClause(req.query.sort || 'prospects')}
       LIMIT ${limit}
    `;
    const r = await query(sql, params);
    const cols = ['id','name','type','prospect_state','suggested_domain','domain_available',
                  'phone','address','city','zip','website',
                  'lat','lng','map_link','register_link'];
    res.setHeader('Content-Type','text/csv; charset=utf-8');
    // Restrict filename slug to safe ASCII to prevent CRLF injection in headers.
    const safeSlug = String(req.query.category || 'all').toLowerCase().replace(/[^a-z0-9_-]/g, '').slice(0, 32) || 'all';
    res.setHeader('Content-Disposition',`attachment; filename="outreach-${safeSlug}.csv"`);
    const stringifier = stringify({ header: true, columns: cols });
    stringifier.pipe(res);
    for (const row of r.rows) stringifier.write(row);
    stringifier.end();
  } catch (e) { next(e); }
});

app.get('/organizations/:id', async (req, res, next) => {
  try {
    const r = await query(
      `SELECT * FROM organizations WHERE id = $1 ${OPT_OUT}`,
      [req.params.id]
    );
    if (!r.rowCount) return res.status(404).json({ error: 'not found' });
    res.json(r.rows[0]);
  } catch (e) { next(e); }
});

// ─── /organizations/:id/emails — recipient list for pitch modal ───────────
app.get('/organizations/:id/emails', async (req, res, next) => {
  try {
    const r = await query(
      `SELECT email, email_type, source_url, last_verified_at, verification_status
         FROM emails WHERE organization_id = $1
        ORDER BY last_verified_at DESC NULLS LAST, id`,
      [req.params.id]
    );
    res.json({ count: r.rowCount, rows: r.rows });
  } catch (e) { next(e); }
});

// ─── /preview/:orgId — "the live revamp the prospect can visit" ────────────
// A standalone, beautifully-styled landing page populated from the org's real
// data. Used as the destination URL in pitch emails ("don't chat — go see it").
app.get('/preview/:orgId', async (req, res, next) => {
  try {
    const o = (await query(
      `SELECT * FROM organizations WHERE id = $1 ${OPT_OUT}`,
      [req.params.orgId]
    )).rows[0];
    if (!o) return res.status(404).send('Not found');

    const pros = (await query(
      `SELECT p.id, p.full_name, p.title, p.primary_specialty, p.npi_number
         FROM professional_locations pl
         JOIN professionals p ON p.id = pl.professional_id
        WHERE pl.organization_id = $1 AND p.opted_out = false
        ORDER BY p.source_confidence_score DESC NULLS LAST, p.full_name
        LIMIT 12`,
      [o.id]
    )).rows;

    res.setHeader('Content-Type','text/html; charset=utf-8');
    res.end(renderPreview(o, pros));
  } catch (e) { next(e); }
});

// 3-up index showing all variants for the same org (a/b/c side by side).
// Use this in the pitch flow so the prospect can pick the direction they like.
app.get('/preview/:orgId/all', async (req, res, next) => {
  try {
    const o = (await query(
      `SELECT id, name FROM organizations WHERE id = $1 ${OPT_OUT}`,
      [req.params.orgId]
    )).rows[0];
    if (!o) return res.status(404).send('Not found');
    res.setHeader('Content-Type','text/html; charset=utf-8');
    res.end(renderPreviewVariantIndex(o));
  } catch (e) { next(e); }
});

// 3 distinct mockup directions per org. Hash-keyed by orgId so the same org
// always gets the same 3 designs across reloads (deterministic per-org variation
// in palette + accent + hero shape inside each template). Variant validation
// happens in the handler — Express 5's path-to-regexp v8 dropped inline regex
// like ":variant(a|b|c)", so we accept any token and 404 in the handler.
app.get('/preview/:orgId/:variant', async (req, res, next) => {
  try {
    const v = req.params.variant;
    const VALID = new Set(['a','b','c','d','e','modern','community','premium','wellness','editorial','brutalist']);
    if (!VALID.has(v)) return res.status(404).send('Not found');
    const o = (await query(
      `SELECT * FROM organizations WHERE id = $1 ${OPT_OUT}`,
      [req.params.orgId]
    )).rows[0];
    if (!o) return res.status(404).send('Not found');
    const pros = (await query(
      `SELECT p.id, p.full_name, p.title, p.primary_specialty, p.npi_number
         FROM professional_locations pl
         JOIN professionals p ON p.id = pl.professional_id
        WHERE pl.organization_id = $1 AND p.opted_out = false
        ORDER BY p.source_confidence_score DESC NULLS LAST, p.full_name
        LIMIT 12`,
      [o.id]
    )).rows;
    const renderer = (v === 'a' || v === 'modern') ? renderPreviewModernClinical
                   : (v === 'b' || v === 'community') ? renderPreviewWarmCommunity
                   : (v === 'c' || v === 'premium') ? renderPreviewPremiumWellness
                   : (v === 'd' || v === 'wellness') ? renderPreviewWellnessApp
                   : renderPreviewEditorialBrutalist;
    res.setHeader('Content-Type','text/html; charset=utf-8');
    res.end(renderer(o, pros));
  } catch (e) { next(e); }
});

// ─── Search ────────────────────────────────────────────────────────────────
app.get('/search', async (req, res, next) => {
  try {
    const q = String(req.query.q || '').trim();
    if (q.length < 2) return res.json({ count: 0, rows: [] });
    const limit = Math.min(Number(req.query.limit) || 25, 100);

    const r = await query(`
      (SELECT 'professional' AS kind, p.id, p.full_name AS name, p.primary_specialty AS detail,
              p.npi_number, similarity(p.full_name, $1) AS score
         FROM professionals p
        WHERE p.opted_out = false AND p.full_name ILIKE '%' || $1 || '%')
      UNION ALL
      (SELECT 'organization' AS kind, o.id, o.name, o.type AS detail,
              o.npi_number, similarity(o.name, $1) AS score
         FROM organizations o
        WHERE o.opted_out = false AND o.name ILIKE '%' || $1 || '%')
      ORDER BY score DESC NULLS LAST
      LIMIT ${limit}
    `, [q]);
    res.json({ count: r.rowCount, rows: r.rows });
  } catch (e) { next(e); }
});

// ─── Website-data helper ────────────────────────────────────────────────────
app.get('/website-data/:npi', async (req, res, next) => {
  try {
    const r = await query(`
      SELECT p.full_name, p.title, p.primary_specialty, p.medical_school,
             p.graduation_year, p.bio, p.profile_image_url,
             p.license_number, p.license_status,
             json_agg(DISTINCT jsonb_build_object(
               'org', o.name, 'address', pl.address, 'phone', pl.phone, 'website', o.website
             )) FILTER (WHERE pl.id IS NOT NULL) AS locations
        FROM professionals p
        LEFT JOIN professional_locations pl ON pl.professional_id = p.id
        LEFT JOIN organizations o ON o.id = pl.organization_id
       WHERE p.npi_number = $1 AND p.opted_out = false
       GROUP BY p.id
    `, [req.params.npi]);
    if (!r.rowCount) return res.status(404).json({ error: 'not found' });
    res.json(r.rows[0]);
  } catch (e) { next(e); }
});

// ─── CSV export ─────────────────────────────────────────────────────────────
// SECURITY: bulk PII (NPI, license, school) — admin only.
app.get('/export.csv', requireRole('admin'), async (req, res, next) => {
  try {
    const entity = (req.query.entity || 'professionals').toString();
    let sql, cols;
    if (entity === 'organizations') {
      cols = ['id','name','type','address','city','state','zip','phone','website','lat','lng'];
      sql  = `SELECT ${cols.join(',')} FROM organizations WHERE opted_out=false ORDER BY name`;
    } else {
      cols = ['id','full_name','title','npi_number','license_number','license_status',
              'primary_specialty','medical_school','graduation_year','source_confidence_score'];
      sql  = `SELECT ${cols.join(',')} FROM professionals WHERE opted_out=false ORDER BY full_name`;
    }
    const r = await query(sql, []);
    res.setHeader('Content-Type','text/csv; charset=utf-8');
    // entity is already validated to one of the known set above, but defense-in-depth:
    const safeEntity = String(entity).replace(/[^a-z0-9_-]/gi, '').slice(0, 32) || 'export';
    res.setHeader('Content-Disposition',`attachment; filename="${safeEntity}.csv"`);
    const stringifier = stringify({ header: true, columns: cols });
    stringifier.pipe(res);
    for (const row of r.rows) stringifier.write(row);
    stringifier.end();
  } catch (e) { next(e); }
});

// ─── Opt-out ────────────────────────────────────────────────────────────────
// SECURITY: was unauthenticated — anyone could flip opted_out=TRUE on any
// professional or org, mass-removing the directory. Now admin-only until a
// signed-token flow (emailed to the practitioner) is built. See REVIEW-2026-05-04.md.
app.post('/opt-out', requireRole('admin'), async (req, res, next) => {
  try {
    const { type, id } = req.body || {};
    if (!['professional','organization'].includes(type)) {
      return res.status(400).json({ error: 'type must be professional|organization' });
    }
    const table = type === 'professional' ? 'professionals' : 'organizations';
    const r = await query(`
      UPDATE ${table} SET opted_out = TRUE,
             ${type === 'professional' ? 'opted_out_at = NOW(),' : ''}
             updated_at = NOW()
      WHERE id = $1 RETURNING id
    `, [id]);
    if (!r.rowCount) return res.status(404).json({ error: 'not found' });
    res.json({ ok: true, id });
  } catch (e) { next(e); }
});

// ─── Errors ─────────────────────────────────────────────────────────────────
// SECURITY: PG error messages can leak table/column/constraint names + query
// fragments. Log full error server-side, return generic message to client.
app.use((err, _req, res, _next) => {
  console.error('[api]', err);
  const isProd = process.env.NODE_ENV === 'production';
  res.status(500).json({
    error: 'internal_server_error',
    ...(isProd ? {} : { detail: err.message }),
  });
});

// ─── Helpers ────────────────────────────────────────────────────────────────
async function getCounts() {
  const r = await query(`
    SELECT
      (SELECT COUNT(*) FROM professionals WHERE opted_out=false) AS professionals,
      (SELECT COUNT(*) FROM organizations WHERE opted_out=false) AS organizations,
      (SELECT COUNT(*) FROM organizations WHERE type='hospital' AND opted_out=false) AS hospitals,
      (SELECT COUNT(*) FROM la_zips) AS la_zips,
      (SELECT COUNT(*) FROM specialties) AS specialties,
      (SELECT COUNT(*) FROM raw_records) AS raw_records,
      (SELECT MAX(updated_at) FROM professionals) AS last_update
  `, []);
  return r.rows[0];
}

// ─── Preview-page renderer ─────────────────────────────────────────────────
// Generates a clean, modern public-style landing page for an organization.
// Adapts hero copy + service language by org.type so a senior-care RCFE
// reads differently from an MD's medical group, but the layout is constant.
const PREVIEW_COPY = {
  rcfe:                { kind: 'Senior Living', tagline: 'A warm, dignified home for the next chapter.', cta: 'Schedule a Tour', services: ['Assisted Living','Memory Care','Wellness Programs','Daily Activities','24-Hour Caregiving','Chef-Prepared Dining'] },
  ccrc:                { kind: 'Continuing Care Community', tagline: 'Independence today. Care for whatever tomorrow brings.', cta: 'Book a Visit', services: ['Independent Living','Assisted Living','Memory Care','Skilled Nursing','Wellness Programs','Resident Activities'] },
  adult_residential:   { kind: 'Residential Care', tagline: 'A supportive home where every resident is known.', cta: 'Schedule a Tour', services: ['Personal Care','Medication Management','Meals & Nutrition','Daily Activities','Transportation','Specialized Support'] },
  adult_day_program:   { kind: 'Adult Day Program', tagline: 'Engaged days. Peace of mind for families.', cta: 'Schedule a Visit', services: ['Therapeutic Activities','Group Outings','Meals & Snacks','Caregiver Respite','Transportation','Health Monitoring'] },
  home_care_agency:    { kind: 'Home Care', tagline: 'The care your family needs, in the home you love.', cta: 'Request a Free Consultation', services: ['Personal Care','Companionship','Meal Preparation','Medication Reminders','Transportation','Hospital-to-Home Transitions'] },
  home_health:         { kind: 'Home Health', tagline: 'Skilled clinical care delivered at home.', cta: 'Request a Visit', services: ['Skilled Nursing','Physical Therapy','Occupational Therapy','Speech Therapy','Wound Care','Medication Management'] },
  hospice:             { kind: 'Hospice & Palliative', tagline: 'Comfort, dignity, and presence — wherever home is.', cta: 'Speak with a Counselor', services: ['Pain & Symptom Management','Nursing Visits','Spiritual & Emotional Support','Family Counseling','Bereavement Care','24-Hour On-Call'] },
  nursing_facility:    { kind: 'Skilled Nursing', tagline: 'Round-the-clock skilled care, in a place that feels like home.', cta: 'Schedule a Visit', services: ['Skilled Nursing','Rehabilitation','Long-Term Care','Memory Care','IV Therapy','Wound Care'] },
  medical_group:       { kind: 'Medical Practice', tagline: 'Care that knows you, schedules that respect you.', cta: 'Book an Appointment', services: ['Same-Week Appointments','Online Scheduling','Lab & Imaging On-Site','Telehealth Visits','Insurance Billing','Bilingual Staff'] },
  clinic:              { kind: 'Clinic', tagline: 'Walk-in care without the wait.', cta: 'Book an Appointment', services: ['Walk-In Visits','Same-Week Scheduling','Lab Work','Imaging','Telehealth','Insurance Billing'] },
  dental_office:       { kind: 'Dental', tagline: 'Modern dentistry. Honest pricing. Quiet rooms.', cta: 'Book a Cleaning', services: ['Cleanings & Exams','Cosmetic Dentistry','Implants','Invisalign','Emergency Visits','Children Welcome'] },
  optometrist_office:  { kind: 'Optometry', tagline: 'Better vision, better days.', cta: 'Book an Eye Exam', services: ['Eye Exams','Glasses & Frames','Contact Lens Fittings','Pediatric Eye Care','Dry Eye Treatment','LASIK Consultation'] },
  surgery_center:      { kind: 'Surgery Center', tagline: 'Outpatient care. Hospital expertise.', cta: 'Request a Consult', services: ['Outpatient Surgery','Anesthesia Services','Pre-Op Evaluations','Same-Day Discharge','Insurance Billing','Specialized Recovery'] },
  hospital:            { kind: 'Hospital', tagline: 'Care for every moment of your life.', cta: 'Find a Doctor', services: ['Emergency Department','Surgical Services','Maternity','Imaging','Cardiology','Specialty Clinics'] },
  fqhc:                { kind: 'Community Health Center', tagline: 'Quality care for everyone in our community.', cta: 'Become a Patient', services: ['Primary Care','Pediatrics','Dental','Behavioral Health','Sliding-Scale Fees','Insurance Enrollment Help'] },
  tcm_clinic:          { kind: 'Acupuncture & TCM', tagline: 'Time-honored care for modern lives.', cta: 'Book a Session', services: ['Acupuncture','Cupping','Herbal Formulas','Tui Na Massage','Nutrition Counseling','Stress & Pain Relief'] },
  acupuncture_clinic:  { kind: 'Acupuncture', tagline: 'Time-honored care for modern lives.', cta: 'Book a Session', services: ['Acupuncture','Pain Relief','Stress Reduction','Fertility Support','Wellness Plans','Insurance-Friendly'] },
  chiropractor_office: { kind: 'Chiropractic', tagline: 'Move better. Live better. Feel better.', cta: 'Book an Adjustment', services: ['Spinal Adjustments','Sports Injury Care','Posture Therapy','Massage','Pre-Natal Care','Insurance-Friendly'] },
};
const PREVIEW_DEFAULT = { kind: 'Local Practice', tagline: 'Care that knows you. Service that respects your time.', cta: 'Get in Touch', services: ['Welcoming Front Desk','Online Booking','Insurance-Friendly','Bilingual Staff','Same-Week Appointments','Patient Portal'] };

function escHtml(s) {
  return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
// Allowlist for href values — strips javascript:/data:/vbscript: and only emits http(s).
function safeUrl(u) {
  const s = String(u || '').trim();
  if (!s) return '';
  if (!/^https?:\/\//i.test(s)) return '';
  return s;
}
function titleCase(s) {
  return String(s || '').toLowerCase().replace(/\b\w/g, c => c.toUpperCase());
}
function fmtPhone(p) {
  if (!p) return '';
  const d = String(p).replace(/\D/g,'');
  if (d.length === 10) return `(${d.slice(0,3)}) ${d.slice(3,6)}-${d.slice(6)}`;
  if (d.length === 11 && d[0] === '1') return `(${d.slice(1,4)}) ${d.slice(4,7)}-${d.slice(7)}`;
  return p;
}

function renderPreview(o, pros) {
  const copy = PREVIEW_COPY[o.type] || PREVIEW_DEFAULT;
  const displayName = titleCase(o.name || 'Our Practice');
  const cityZip = [titleCase(o.city || ''), o.zip || ''].filter(Boolean).join(', ');
  const phone = fmtPhone(o.phone);
  const phoneTel = (o.phone || '').replace(/[^+0-9]/g,'');
  // SECURITY: lat/lng come from PG as strings; address is feed-text.
  // Encode every component so a quote in addr can't break out of href="...".
  const mapHref = (o.lat && o.lng)
    ? `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(o.lat)},${encodeURIComponent(o.lng)}`
    : (o.address
       ? `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent([o.address,o.city,o.state,o.zip].filter(Boolean).join(', '))}`
       : '#');
  const isMedicalCohort = pros && pros.length > 0;

  const team = isMedicalCohort ? pros.slice(0, 8).map(p => {
    const initials = (p.full_name || '').split(/\s+/).filter(Boolean).slice(0,2).map(s => s[0]).join('').toUpperCase() || '·';
    return `<div class="team-card">
      <div class="avatar">${escHtml(initials)}</div>
      <div class="team-name">${escHtml(titleCase(p.full_name))}${p.title ? ', ' + escHtml(p.title) : ''}</div>
      <div class="team-spec">${escHtml(p.primary_specialty || '')}</div>
    </div>`;
  }).join('') : '';

  const services = copy.services.map(s => `<li>${escHtml(s)}</li>`).join('');

  return `<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <title>${escHtml(displayName)} — ${escHtml(copy.kind)}</title>
  <meta name="description" content="${escHtml(displayName)} — ${escHtml(copy.tagline)}">
  <style>
    *,*::before,*::after { box-sizing:border-box; }
    :root {
      --cream: #fbf7f0;
      --ink: #1c1f1c;
      --ink-soft: #4a504a;
      --line: #e6dfd1;
      --moss: #3f5f4a;
      --moss-deep: #28412f;
      --gold: #b48a3a;
      --rose: #c97e74;
    }
    html, body { margin:0; }
    body {
      font: 16px/1.6 ui-serif, "Iowan Old Style", "Apple Garamond", Garamond, "Times New Roman", serif;
      color: var(--ink); background: var(--cream); -webkit-font-smoothing: antialiased;
    }
    .nav { padding: 22px 32px; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--line); background: rgba(251,247,240,0.92); backdrop-filter: blur(8px); position:sticky; top:0; z-index:10; }
    .nav .brand { font-weight:600; font-size:18px; letter-spacing:-0.01em; }
    .nav .brand small { font-weight:400; color:var(--ink-soft); margin-left:10px; font-size:13px; letter-spacing:0.04em; text-transform:uppercase; }
    .nav .links a { color:var(--ink); text-decoration:none; margin-left:26px; font: 14px/1.4 ui-sans-serif, system-ui, sans-serif; letter-spacing:0.02em; }
    .nav .cta { background:var(--moss); color:#fff; padding:9px 16px; border-radius:999px; font: 600 13px/1 ui-sans-serif,system-ui,sans-serif; letter-spacing:0.05em; text-transform:uppercase; text-decoration:none; }

    .hero { padding: 80px 32px 64px; max-width: 1120px; margin: 0 auto; display:grid; grid-template-columns: 1fr 1fr; gap: 60px; align-items:center; }
    .hero h1 { font-size: clamp(36px, 5vw, 60px); line-height:1.05; letter-spacing:-0.015em; margin: 0 0 22px; font-weight: 500; }
    .hero p.tagline { font-size: 19px; line-height:1.55; color: var(--ink-soft); margin: 0 0 32px; max-width: 520px; }
    .hero .actions { display:flex; gap: 14px; flex-wrap:wrap; align-items:center; }
    .btn-primary { background: var(--moss); color:#fff; padding: 14px 26px; border-radius: 999px; font: 600 14px/1 ui-sans-serif,system-ui,sans-serif; letter-spacing:0.05em; text-transform:uppercase; text-decoration:none; display:inline-block; }
    .btn-primary:hover { background: var(--moss-deep); }
    .btn-secondary { color: var(--ink); padding: 14px 22px; font: 600 14px/1 ui-sans-serif,system-ui,sans-serif; letter-spacing:0.05em; text-transform:uppercase; text-decoration:none; border-bottom:1px solid var(--ink); }
    .distance-badge { display:inline-flex; gap:8px; align-items:center; margin: 0 0 22px; padding: 8px 14px; background: rgba(63,95,74,0.08); border:1px solid var(--moss); color: var(--moss-deep); border-radius: 999px; font: 600 13px/1.2 ui-sans-serif, system-ui, sans-serif; letter-spacing:0.02em; }
    .distance-badge::before { content: '📍'; font-size: 14px; }
    .hero .photo { aspect-ratio: 5/4; border-radius: 6px; background: linear-gradient(135deg, #d4cdb8 0%, #b9a988 50%, #8a8771 100%); position:relative; overflow:hidden; box-shadow: 0 30px 60px -30px rgba(0,0,0,0.25); }
    .hero .photo::after {
      content: ""; position:absolute; inset:0;
      background:
        radial-gradient(circle at 30% 30%, rgba(255,247,225,0.5), transparent 50%),
        radial-gradient(circle at 70% 70%, rgba(63,95,74,0.25), transparent 60%);
    }
    .hero .photo .photo-label { position:absolute; left:24px; bottom:22px; right:24px; color:#fbf7f0; font: 500 14px/1.45 ui-sans-serif,system-ui,sans-serif; letter-spacing:0.03em; z-index:1; }

    .stat-strip { padding: 28px 32px; background:var(--moss); color:#fbf7f0; }
    .stat-strip .row { max-width:1120px; margin:0 auto; display:grid; grid-template-columns: repeat(4, 1fr); gap:32px; }
    .stat-strip .stat { font: 14px/1.5 ui-sans-serif,system-ui,sans-serif; letter-spacing:0.02em; }
    .stat-strip .stat b { display:block; font: 500 28px/1 ui-serif, serif; margin-bottom:6px; color:#fff; letter-spacing:-0.01em; }

    section { max-width: 1120px; margin: 0 auto; padding: 88px 32px; }
    .eyebrow { color: var(--gold); font: 600 12px/1 ui-sans-serif,system-ui,sans-serif; letter-spacing:0.18em; text-transform:uppercase; margin-bottom: 18px; }
    h2 { font-size: clamp(28px, 3.5vw, 40px); margin: 0 0 32px; font-weight: 500; letter-spacing:-0.01em; line-height:1.1; }
    h3 { font-size: 19px; margin: 0 0 8px; font-weight: 600; letter-spacing:-0.005em; }

    .services { display:grid; grid-template-columns: repeat(3, 1fr); gap: 18px; list-style:none; padding:0; margin:0; }
    .services li { padding: 26px 22px; border:1px solid var(--line); border-radius: 8px; background: #fff; font: 500 16px/1.4 ui-sans-serif,system-ui,sans-serif; letter-spacing:-0.005em; }

    .team { display:grid; grid-template-columns: repeat(4, 1fr); gap: 22px; }
    .team-card { padding: 22px; border:1px solid var(--line); border-radius: 8px; background:#fff; text-align:left; }
    .team-card .avatar { width:54px; height:54px; border-radius:50%; background:var(--moss); color:#fbf7f0; display:flex; align-items:center; justify-content:center; font: 600 16px/1 ui-sans-serif,system-ui,sans-serif; letter-spacing:0.04em; margin-bottom:14px; }
    .team-card .team-name { font: 600 14px/1.35 ui-sans-serif,system-ui,sans-serif; margin-bottom:4px; letter-spacing:-0.005em; }
    .team-card .team-spec { font: 13px/1.4 ui-sans-serif,system-ui,sans-serif; color:var(--ink-soft); }

    .visit { display:grid; grid-template-columns: 1fr 1fr; gap: 60px; align-items:start; padding-bottom: 60px; }
    .visit-card { padding:30px; border:1px solid var(--line); border-radius: 10px; background:#fff; }
    .visit-card .row { display:flex; gap:14px; padding: 16px 0; border-bottom: 1px solid var(--line); align-items:flex-start; }
    .visit-card .row:last-child { border-bottom:0; }
    .visit-card .label { width: 80px; flex:none; color:var(--ink-soft); font: 12px/1.4 ui-sans-serif,system-ui,sans-serif; letter-spacing:0.08em; text-transform:uppercase; padding-top:2px; }
    .visit-card .value { flex:1; font: 16px/1.55 ui-sans-serif,system-ui,sans-serif; color:var(--ink); letter-spacing:-0.005em; }
    .visit-card .value a { color: var(--moss-deep); text-decoration:none; border-bottom:1px solid var(--line); padding-bottom:1px; }

    .map-frame { aspect-ratio: 4/3; border-radius:10px; background:#dad3c5 url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100'><path d='M0 50 Q25 30 50 50 T100 50' fill='none' stroke='%23a89e88' stroke-width='1.5'/><path d='M0 70 Q25 50 50 70 T100 70' fill='none' stroke='%23a89e88' stroke-width='1'/><circle cx='50' cy='50' r='8' fill='%23${(o.type === 'rcfe' || o.type === 'ccrc' ? 'b48a3a' : '3f5f4a')}'/></svg>") center/cover no-repeat; position:relative; overflow:hidden; }
    .map-frame a { position:absolute; inset:auto 16px 16px auto; background:#fff; padding: 9px 14px; border-radius:999px; font: 600 12px/1 ui-sans-serif,system-ui,sans-serif; color: var(--ink); text-decoration:none; letter-spacing:0.06em; text-transform:uppercase; box-shadow: 0 4px 18px rgba(0,0,0,0.12); }

    footer { padding: 56px 32px; background: var(--moss-deep); color: rgba(251,247,240,0.85); }
    footer .inner { max-width: 1120px; margin: 0 auto; display:flex; align-items:flex-end; justify-content:space-between; flex-wrap:wrap; gap: 30px; font: 14px/1.6 ui-sans-serif,system-ui,sans-serif; letter-spacing:0.02em; }
    footer .inner h4 { font: 500 22px/1.1 ui-serif, serif; color:#fff; margin: 0 0 8px; letter-spacing:-0.01em; }
    footer .draft { padding:6px 12px; background: var(--gold); color: var(--moss-deep); border-radius: 999px; font: 700 11px/1 ui-sans-serif,system-ui,sans-serif; letter-spacing:0.12em; text-transform:uppercase; }

    @media (max-width: 880px) {
      .hero { grid-template-columns: 1fr; padding: 56px 24px 40px; gap: 36px; }
      .hero .photo { order:-1; aspect-ratio: 4/3; }
      .stat-strip .row { grid-template-columns: repeat(2, 1fr); gap: 20px; }
      .services { grid-template-columns: 1fr; }
      .team { grid-template-columns: repeat(2, 1fr); }
      .visit { grid-template-columns: 1fr; gap: 32px; }
      section { padding: 56px 24px; }
      .nav { padding: 16px 22px; }
      .nav .links { display:none; }
    }
  </style>
</head>
<body>
  <nav class="nav">
    <div class="brand">${escHtml(displayName)}<small>${escHtml(copy.kind)}</small></div>
    <div class="links">
      <a href="#services">Services</a>
      ${isMedicalCohort ? '<a href="#team">Our Team</a>' : ''}
      <a href="#visit">Visit</a>
    </div>
    ${phone ? `<a class="cta" href="tel:${phoneTel}">${escHtml(phone)}</a>` : `<a class="cta" href="#visit">Get in Touch</a>`}
  </nav>

  <section class="hero">
    <div>
      <h1>${escHtml(copy.tagline)}</h1>
      <p class="tagline">${escHtml(displayName)} ${cityZip ? 'in ' + escHtml(cityZip) + '. ' : ''}Built around the people we serve, not the paperwork.</p>
      <div id="distance-badge" class="distance-badge" style="display:none"></div>
      <div class="actions">
        ${phone ? `<a class="btn-primary" href="tel:${phoneTel}">${escHtml(copy.cta)}</a>` : `<a class="btn-primary" href="#visit">${escHtml(copy.cta)}</a>`}
        <a class="btn-secondary" href="#services">Learn More →</a>
        <a class="btn-secondary" id="directions-link" href="${escHtml(mapHref)}" target="_blank" rel="noopener">Directions →</a>
      </div>
    </div>
    <div class="photo"><div class="photo-label">${escHtml(copy.kind)} · ${escHtml(titleCase(o.city || 'Los Angeles County'))}</div></div>
  </section>

  <div class="stat-strip">
    <div class="row">
      <div class="stat"><b>${cityZip ? '90s' : '—'}</b>Years of trust in our community</div>
      <div class="stat"><b>${pros.length || '12+'}</b>${pros.length ? 'On our team' : 'Specialists in network'}</div>
      <div class="stat"><b>5★</b>Average patient rating</div>
      <div class="stat"><b>24/7</b>Help when you need it</div>
    </div>
  </div>

  <section id="services">
    <div class="eyebrow">What we offer</div>
    <h2>Services designed around real lives.</h2>
    <ul class="services">${services}</ul>
  </section>

  ${isMedicalCohort ? `
  <section id="team" style="background:#f5efe2;border-top:1px solid var(--line);border-bottom:1px solid var(--line);max-width:none;padding:88px 32px">
    <div style="max-width:1120px;margin:0 auto">
      <div class="eyebrow">People</div>
      <h2>Meet our team.</h2>
      <div class="team">${team}</div>
    </div>
  </section>` : ''}

  <section id="visit" class="visit">
    <div>
      <div class="eyebrow">Plan your visit</div>
      <h2>Stop by, or call ahead.</h2>
      <div class="visit-card">
        ${o.address ? `<div class="row"><div class="label">Address</div><div class="value"><a href="${escHtml(mapHref)}" target="_blank" rel="noopener">${escHtml(titleCase(o.address))}<br>${escHtml(cityZip)}</a></div></div>` : ''}
        ${phone ? `<div class="row"><div class="label">Phone</div><div class="value"><a href="tel:${phoneTel}">${escHtml(phone)}</a></div></div>` : ''}
        ${(() => { const u = safeUrl(o.website); return u ? `<div class="row"><div class="label">Web</div><div class="value"><a href="${escHtml(u)}" target="_blank" rel="noopener">${escHtml(u.replace(new RegExp('^https?://'),'').replace(new RegExp('/$'),''))}</a></div></div>` : ''; })()}
        <div class="row"><div class="label">Hours</div><div class="value">Mon – Fri · 9 a.m. – 5 p.m.<br>Sat by appointment</div></div>
      </div>
    </div>
    <a class="map-frame" href="${escHtml(mapHref)}" target="_blank" rel="noopener" aria-label="Open in Google Maps">
      <span>Open in Maps →</span>
    </a>
  </section>

  <footer>
    <div class="inner">
      <div>
        <h4>${escHtml(displayName)}</h4>
        ${o.address ? escHtml(titleCase(o.address)) + '<br>' : ''}
        ${cityZip ? escHtml(cityZip) : ''}${phone ? ' · ' + escHtml(phone) : ''}
      </div>
      <span class="draft">Concept Preview</span>
    </div>
  </footer>

  <script>
  // Geolocation: on load, request the visitor's current position; if granted,
  // show "X miles away" in the hero and rewrite the Directions link to start
  // from their location instead of just the destination.
  (function() {
    var ORG_LAT = ${o.lat ? Number(o.lat) : 'null'};
    var ORG_LNG = ${o.lng ? Number(o.lng) : 'null'};
    if (ORG_LAT == null || ORG_LNG == null || !navigator.geolocation) return;

    function haversineMi(lat1, lng1, lat2, lng2) {
      var R = 3958.8; // miles
      var toRad = function(d){ return d * Math.PI / 180; };
      var dLat = toRad(lat2 - lat1), dLng = toRad(lng2 - lng1);
      var a = Math.sin(dLat/2)*Math.sin(dLat/2) +
              Math.cos(toRad(lat1))*Math.cos(toRad(lat2)) *
              Math.sin(dLng/2)*Math.sin(dLng/2);
      return 2 * R * Math.asin(Math.sqrt(a));
    }

    navigator.geolocation.getCurrentPosition(function(pos) {
      var miles = haversineMi(pos.coords.latitude, pos.coords.longitude, ORG_LAT, ORG_LNG);
      var fmt = miles < 1 ? '< 1 mile away' : miles.toFixed(1) + ' miles from you';
      var badge = document.getElementById('distance-badge');
      if (badge) { badge.textContent = fmt; badge.style.display = 'inline-flex'; }
      var dir = document.getElementById('directions-link');
      if (dir) {
        dir.href = 'https://www.google.com/maps/dir/?api=1&origin=' +
          pos.coords.latitude + ',' + pos.coords.longitude +
          '&destination=' + ORG_LAT + ',' + ORG_LNG;
      }
    }, function(){ /* user denied — leave defaults */ }, { enableHighAccuracy: false, timeout: 7000, maximumAge: 600000 });
  })();
  </script>
</body>
</html>`;
}

// ─── Mockup variants — 3 distinct design directions per org ─────────────────
// Each template is parameterized by a deterministic per-org seed so two orgs in
// the same template never look identical (palette + hero shape + heading rotate).
// Inspired by 21st.dev component patterns + Figma-style design tokens.
//
// pickSeed: stable 0..N-1 from string. Used to rotate palette + hero shape.
function pickSeed(str, mod) {
  let h = 0;
  const s = String(str || '');
  for (let i = 0; i < s.length; i++) h = ((h << 5) - h) + s.charCodeAt(i) | 0;
  return Math.abs(h) % mod;
}

// splitNameAccent: returns { lead, tail } for templates that wrap the last word
// in <em>. For single-word names, lead is empty and the caller should NOT emit
// a leading span (otherwise template B/C produce an empty span + italic-everything).
function splitNameAccent(name) {
  const parts = String(name || '').split(/\s+/).filter(Boolean);
  if (parts.length <= 1) return { lead: '', tail: parts[0] || '' };
  return { lead: parts.slice(0, -1).join(' '), tail: parts[parts.length - 1] };
}

// ctaHref: prefer phone tel: link; fall back to in-page #contact anchor instead
// of dead "#" so the CTA actually scrolls somewhere when no phone is on file.
function ctaHref(phoneTel) {
  return phoneTel ? `tel:${phoneTel}` : '#contact';
}

// ─── Template A: Modern Clinical ──────────────────────────────────────────────
// Minimalist whitespace, sans-serif, generous line-height, accent strip.
// Palette rotates across 5 cool clinical tones; hero shape rotates 3 ways.
function renderPreviewModernClinical(o, pros) {
  const copy = PREVIEW_COPY[o.type] || PREVIEW_DEFAULT;
  const name = titleCase(o.name || 'Our Practice');
  const cityZip = [titleCase(o.city || ''), o.zip || ''].filter(Boolean).join(', ');
  const phone = fmtPhone(o.phone);
  const phoneTel = (o.phone || '').replace(/[^+0-9]/g,'');
  const palettes = [
    { bg:'#F7F9FB', ink:'#0E1726', accent:'#0F766E', soft:'#E6F2F1', name:'teal' },
    { bg:'#FAFAFC', ink:'#1F2937', accent:'#2563EB', soft:'#E8EFFE', name:'azure' },
    { bg:'#F8FAFC', ink:'#0B1220', accent:'#0EA5E9', soft:'#E0F2FE', name:'sky' },
    { bg:'#F4F8F4', ink:'#14241D', accent:'#15803D', soft:'#DCFCE7', name:'forest' },
    { bg:'#FAF8F4', ink:'#221A0F', accent:'#B45309', soft:'#FDE9C9', name:'amber' },
  ];
  const p = palettes[pickSeed(o.id + 'A', palettes.length)];
  const heroShape = pickSeed(o.id + 'A-hero', 3); // 0=split, 1=stacked, 2=banner
  const services = copy.services.map(s =>
    `<li><span class="dot"></span><span>${escHtml(s)}</span></li>`).join('');
  const team = pros.slice(0, 4).map(p => {
    const initials = (p.full_name||'').split(/\s+/).slice(0,2).map(s=>s[0]).join('').toUpperCase()||'·';
    return `<div class="provider"><div class="ini">${escHtml(initials)}</div><div><div class="pn">${escHtml(titleCase(p.full_name))}${p.title?', '+escHtml(p.title):''}</div><div class="ps">${escHtml(p.primary_specialty||'')}</div></div></div>`;
  }).join('');
  const heroBlock = heroShape === 0 ? // split
    `<div class="hero split"><div class="hero-text"><div class="kicker">${escHtml(copy.kind)} · ${escHtml(cityZip)}</div><h1>${escHtml(name)}</h1><p class="lede">${escHtml(copy.tagline)}</p><div class="cta-row"><a class="btn-primary" href="${ctaHref(phoneTel)}">${escHtml(copy.cta)}</a><a class="btn-ghost" href="#services">View services →</a></div></div><div class="hero-card"><div class="hc-line">Now accepting</div><div class="hc-big">New patients</div><div class="hc-sub">Same-week appointments</div></div></div>`
    : heroShape === 1 ? // stacked centered
    `<div class="hero stacked"><div class="kicker">${escHtml(copy.kind)} · ${escHtml(cityZip)}</div><h1>${escHtml(name)}</h1><p class="lede">${escHtml(copy.tagline)}</p><div class="cta-row centered"><a class="btn-primary" href="${ctaHref(phoneTel)}">${escHtml(copy.cta)}</a></div></div>`
    : // banner with accent stripe
    `<div class="hero banner"><div class="stripe"></div><div class="hb-inner"><div class="kicker">${escHtml(copy.kind)} · ${escHtml(cityZip)}</div><h1>${escHtml(name)}</h1><p class="lede">${escHtml(copy.tagline)}</p><a class="btn-primary" href="${ctaHref(phoneTel)}">${escHtml(copy.cta)}</a></div></div>`;
  return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escHtml(name)} — ${escHtml(copy.kind)}</title><style>
*,*::before,*::after{box-sizing:border-box}html,body{margin:0;background:${p.bg};color:${p.ink};font:15px/1.6 ui-sans-serif,system-ui,-apple-system,'SF Pro Text','Inter',sans-serif;-webkit-font-smoothing:antialiased}
.wrap{max-width:1180px;margin:0 auto;padding:0 28px}
.nav{display:flex;align-items:center;justify-content:space-between;padding:22px 0;border-bottom:1px solid rgba(0,0,0,0.06)}
.brand{font-weight:600;letter-spacing:-0.01em;font-size:17px}.brand small{color:#64748B;font-weight:400;margin-left:10px}
.nav-links a{color:${p.ink};text-decoration:none;margin-left:28px;font-size:14px}
.btn-primary{background:${p.accent};color:#fff;padding:13px 22px;border-radius:8px;font-weight:600;font-size:14px;text-decoration:none;display:inline-block;letter-spacing:0.01em}
.btn-ghost{color:${p.ink};padding:13px 0;font-size:14px;text-decoration:none;border-bottom:1px solid ${p.ink};margin-left:18px}
.kicker{color:${p.accent};font-weight:600;font-size:12px;text-transform:uppercase;letter-spacing:0.14em;margin-bottom:18px}
.hero{padding:88px 0 72px}
.hero h1{font-size:clamp(40px,5.5vw,68px);line-height:1.04;margin:0 0 22px;font-weight:600;letter-spacing:-0.025em}
.hero .lede{font-size:19px;line-height:1.55;color:#475569;max-width:520px;margin:0 0 32px}
.cta-row{display:flex;align-items:center;gap:0;flex-wrap:wrap}.cta-row.centered{justify-content:center}
.hero.split{display:grid;grid-template-columns:1.2fr 1fr;gap:72px;align-items:center}
.hero.stacked{text-align:center;max-width:760px;margin:0 auto}
.hero.banner{position:relative;padding:120px 0 100px}.hero.banner .stripe{position:absolute;top:0;left:-28px;right:-28px;height:6px;background:linear-gradient(90deg,${p.accent},${p.accent}aa,${p.accent}66)}
.hero-card{background:#fff;border:1px solid rgba(0,0,0,0.06);border-radius:14px;padding:36px;box-shadow:0 1px 0 rgba(0,0,0,0.02),0 30px 60px -40px rgba(0,0,0,0.18)}
.hc-line{color:${p.accent};font-size:12px;font-weight:600;letter-spacing:0.14em;text-transform:uppercase;margin-bottom:8px}
.hc-big{font-size:30px;font-weight:600;letter-spacing:-0.02em;margin-bottom:6px}.hc-sub{color:#64748B;font-size:15px}
section{padding:80px 0;border-top:1px solid rgba(0,0,0,0.05)}
section h2{font-size:clamp(26px,3.2vw,36px);font-weight:600;letter-spacing:-0.018em;margin:0 0 12px}
section .sub{color:#64748B;margin:0 0 40px;font-size:16px}
.services{list-style:none;padding:0;margin:0;display:grid;grid-template-columns:repeat(3,1fr);gap:14px}
.services li{display:flex;align-items:center;gap:14px;padding:22px 24px;background:#fff;border:1px solid rgba(0,0,0,0.06);border-radius:10px;font-weight:500;font-size:15.5px}
.services li .dot{width:8px;height:8px;border-radius:50%;background:${p.accent};flex-shrink:0}
.providers{display:grid;grid-template-columns:repeat(2,1fr);gap:14px}
.provider{display:flex;align-items:center;gap:18px;padding:22px;background:#fff;border:1px solid rgba(0,0,0,0.06);border-radius:10px}
.ini{width:48px;height:48px;border-radius:50%;background:${p.soft};color:${p.accent};font-weight:600;display:flex;align-items:center;justify-content:center;flex-shrink:0;letter-spacing:0.02em}
.pn{font-weight:600;font-size:15px}.ps{color:#64748B;font-size:13px;margin-top:2px}
.contact{background:${p.soft};padding:64px 0;text-align:center;border-radius:18px;margin:40px 0 80px}
.contact h2{margin:0 0 12px;color:${p.ink}}.contact .lede{margin:0 0 28px;color:#475569}
@media(max-width:780px){.hero.split{grid-template-columns:1fr}.services,.providers{grid-template-columns:1fr}}
</style></head><body><div class="wrap">
<nav class="nav"><div class="brand">${escHtml(name.split(/\s+/).slice(0,2).join(' '))}<small>${escHtml(copy.kind)}</small></div><div class="nav-links"><a href="#services">Services</a><a href="#team">Team</a><a href="#contact">Contact</a></div></nav>
${heroBlock}
<section id="services"><div class="kicker">What we do</div><h2>Care, with the friction taken out.</h2><p class="sub">Six things you can count on every visit.</p><ul class="services">${services}</ul></section>
${pros.length?`<section id="team"><div class="kicker">Your providers</div><h2>The team you'll actually meet.</h2><p class="sub">${pros.length} licensed providers practicing here.</p><div class="providers">${team}</div></section>`:''}
<section id="contact"><div class="contact"><div class="kicker">Get in touch</div><h2>${escHtml(copy.cta)}</h2><p class="lede">${cityZip?escHtml(cityZip):''}${phone?` · <a href="tel:${escHtml(phoneTel)}" style="color:${p.accent}">${escHtml(phone)}</a>`:''}</p>${phone?`<a class="btn-primary" href="tel:${escHtml(phoneTel)}">Call ${escHtml(phone)}</a>`:''}</div></section>
</div><!-- variant=A modern-clinical · palette=${p.name} · hero=${heroShape} --></body></html>`;
}

// ─── Template B: Warm Community ───────────────────────────────────────────────
// Earth tones, serif headlines, soft geometric badges. Family-practice feel.
// Layout: editorial — tagline overlaps a colored card; service icons in circles.
function renderPreviewWarmCommunity(o, pros) {
  const copy = PREVIEW_COPY[o.type] || PREVIEW_DEFAULT;
  const name = titleCase(o.name || 'Our Practice');
  const cityZip = [titleCase(o.city || ''), o.zip || ''].filter(Boolean).join(', ');
  const phone = fmtPhone(o.phone);
  const phoneTel = (o.phone || '').replace(/[^+0-9]/g,'');
  const palettes = [
    { bg:'#FBF6EF', ink:'#2A1F12', accent:'#A16207', soft:'#F2E2C5', card:'#FFFAF1', name:'honey' },
    { bg:'#F7F2EA', ink:'#1F1B16', accent:'#9F2F00', soft:'#F1D3C2', card:'#FFF6EE', name:'terracotta' },
    { bg:'#F4F6F0', ink:'#1B2014', accent:'#4D7C0F', soft:'#DCEBC2', card:'#FBFCEF', name:'olive' },
    { bg:'#FAF2F4', ink:'#241419', accent:'#9D174D', soft:'#F4D3DD', card:'#FFF7F9', name:'rose' },
    { bg:'#F0EFF7', ink:'#191625', accent:'#5B21B6', soft:'#DCD6F5', card:'#F8F6FF', name:'lavender' },
  ];
  const p = palettes[pickSeed(o.id + 'B', palettes.length)];
  const services = copy.services.map((s, i) =>
    `<div class="svc"><div class="badge" style="transform:rotate(${(i%3-1)*4}deg)">${i+1}</div><div class="svc-text">${escHtml(s)}</div></div>`
  ).join('');
  const team = pros.slice(0, 6).map(p => {
    const initials = (p.full_name||'').split(/\s+/).slice(0,2).map(s=>s[0]).join('').toUpperCase()||'·';
    return `<div class="card"><div class="circle">${escHtml(initials)}</div><div><div class="cn">${escHtml(titleCase(p.full_name))}${p.title?', '+escHtml(p.title):''}</div><div class="cs">${escHtml(p.primary_specialty||'')}</div></div></div>`;
  }).join('');
  return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escHtml(name)} — ${escHtml(copy.kind)}</title><style>
*,*::before,*::after{box-sizing:border-box}html,body{margin:0;background:${p.bg};color:${p.ink};font:16px/1.65 ui-sans-serif,'Inter','Helvetica Neue',sans-serif;-webkit-font-smoothing:antialiased}
.wrap{max-width:1100px;margin:0 auto;padding:0 28px}
nav{display:flex;align-items:center;justify-content:space-between;padding:32px 0}
.logo{font:600 18px/1 ui-serif,'Iowan Old Style','Apple Garamond',Garamond,serif;letter-spacing:-0.01em}
.logo .dot{display:inline-block;width:10px;height:10px;background:${p.accent};border-radius:50%;margin-left:6px;vertical-align:middle}
nav a{color:${p.ink};text-decoration:none;margin-left:24px;font-size:14px;opacity:0.75}
.cta{background:${p.ink};color:${p.bg};padding:11px 18px;border-radius:999px;font-weight:600;font-size:13px;text-decoration:none}
.hero{padding:64px 0 84px;display:grid;grid-template-columns:1.1fr 0.9fr;gap:48px;align-items:center}
.hero h1{font:600 clamp(42px,6vw,72px)/1.02 ui-serif,'Iowan Old Style','Apple Garamond',Garamond,serif;letter-spacing:-0.02em;margin:0 0 24px}
.hero h1 em{font-style:italic;color:${p.accent}}
.hero .lede{font-size:19px;line-height:1.55;color:${p.ink};opacity:0.78;margin:0 0 32px;max-width:480px}
.hero-card{background:${p.card};border:1px solid ${p.soft};border-radius:24px;padding:44px;position:relative;box-shadow:0 30px 80px -40px rgba(0,0,0,0.2)}
.hero-card::before{content:'';position:absolute;top:-22px;right:-22px;width:80px;height:80px;background:${p.accent};border-radius:50%;opacity:0.85}
.hc-eyebrow{font:600 11px/1 ui-sans-serif,sans-serif;letter-spacing:0.18em;text-transform:uppercase;color:${p.accent};margin-bottom:14px}
.hc-h{font:600 26px/1.2 ui-serif,serif;margin-bottom:14px;letter-spacing:-0.01em}
.hc-p{font-size:15px;line-height:1.55;opacity:0.78;margin:0}
.hero-actions{display:flex;align-items:center;gap:18px}
.btn-fill{background:${p.accent};color:#fff;padding:15px 26px;border-radius:999px;font-weight:600;text-decoration:none}
.btn-link{color:${p.ink};text-decoration:underline;text-underline-offset:5px;font-weight:500}
section{padding:80px 0}
.eyebrow{font:600 11px/1 ui-sans-serif,sans-serif;letter-spacing:0.18em;text-transform:uppercase;color:${p.accent};margin-bottom:18px}
h2{font:600 clamp(28px,3.4vw,40px)/1.1 ui-serif,'Iowan Old Style',serif;margin:0 0 14px;letter-spacing:-0.018em}
.sub{font-size:17px;line-height:1.5;opacity:0.7;max-width:600px;margin:0 0 44px}
.services-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:24px}
.svc{display:flex;align-items:center;gap:18px;padding:24px;background:${p.card};border-radius:16px;border:1px solid ${p.soft}}
.badge{flex-shrink:0;width:40px;height:40px;border-radius:10px;background:${p.accent};color:#fff;display:flex;align-items:center;justify-content:center;font:700 16px/1 ui-sans-serif,sans-serif}
.svc-text{font-weight:500;font-size:15.5px}
.team-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:20px}
.card{display:flex;align-items:center;gap:18px;padding:24px;background:${p.card};border:1px solid ${p.soft};border-radius:16px}
.circle{width:54px;height:54px;border-radius:50%;background:${p.soft};color:${p.accent};font-weight:700;display:flex;align-items:center;justify-content:center;flex-shrink:0;font-family:ui-serif,serif;font-size:18px}
.cn{font-weight:600;font-size:15px;font-family:ui-serif,serif}.cs{opacity:0.65;font-size:13px;margin-top:3px}
.contact-strip{background:${p.ink};color:${p.bg};border-radius:24px;padding:64px 48px;text-align:center;margin:40px 0 80px}
.contact-strip h2{color:${p.bg}}.contact-strip .sub{color:${p.bg};opacity:0.75;margin-bottom:32px}
.contact-strip .btn-fill{background:${p.accent}}
@media(max-width:780px){.hero{grid-template-columns:1fr}.services-grid,.team-grid{grid-template-columns:1fr}}
</style></head><body><div class="wrap">
<nav><div class="logo">${escHtml(name.split(/\s+/).slice(0,2).join(' '))}<span class="dot"></span></div><div><a href="#services">Services</a><a href="#team">Our Team</a><a class="cta" href="${ctaHref(phoneTel)}">${escHtml(copy.cta)}</a></div></nav>
<div class="hero"><div><div class="eyebrow">${escHtml(copy.kind)} · ${escHtml(cityZip)}</div><h1>${(() => { const s = splitNameAccent(name); return s.lead ? `${escHtml(s.lead)} <em>${escHtml(s.tail)}</em>` : `<em>${escHtml(s.tail)}</em>`; })()}</h1><p class="lede">${escHtml(copy.tagline)}</p><div class="hero-actions"><a class="btn-fill" href="${ctaHref(phoneTel)}">${escHtml(copy.cta)}</a><a class="btn-link" href="#services">See what we offer</a></div></div><div class="hero-card"><div class="hc-eyebrow">Welcoming new patients</div><div class="hc-h">Same-week appointments. Real human at the front desk.</div><p class="hc-p">${cityZip?escHtml(cityZip):''}${phone?` · <a href="tel:${escHtml(phoneTel)}" style="color:${p.accent}">${escHtml(phone)}</a>`:''}</p></div></div>
<section id="services"><div class="eyebrow">What we do</div><h2>Six things you can count on.</h2><p class="sub">Care that respects your time, your budget, and your history.</p><div class="services-grid">${services}</div></section>
${pros.length?`<section id="team"><div class="eyebrow">Your providers</div><h2>The faces behind the practice.</h2><p class="sub">${pros.length} licensed providers, all practicing here.</p><div class="team-grid">${team}</div></section>`:''}
<section><div class="contact-strip"><div class="eyebrow" style="color:${p.bg};opacity:0.75">Get in touch</div><h2>${escHtml(copy.cta)}</h2><p class="sub">${cityZip?escHtml(cityZip):''}</p>${phone?`<a class="btn-fill" href="tel:${escHtml(phoneTel)}">Call ${escHtml(phone)}</a>`:''}</div></section>
</div><!-- variant=B warm-community · palette=${p.name} --></body></html>`;
}

// ─── Template C: Premium Wellness ─────────────────────────────────────────────
// Dark luxury, gold/cream accents, spa/concierge feel.
// Layout: full-bleed hero, oversized typography, 2-column service rhythm.
function renderPreviewPremiumWellness(o, pros) {
  const copy = PREVIEW_COPY[o.type] || PREVIEW_DEFAULT;
  const name = titleCase(o.name || 'Our Practice');
  const cityZip = [titleCase(o.city || ''), o.zip || ''].filter(Boolean).join(', ');
  const phone = fmtPhone(o.phone);
  const phoneTel = (o.phone || '').replace(/[^+0-9]/g,'');
  const palettes = [
    { bg:'#0E0F12', ink:'#F1ECE0', accent:'#C9A567', soft:'#1B1D22', card:'#16181D', name:'gold' },
    { bg:'#0C1116', ink:'#E8E4D8', accent:'#9CA88F', soft:'#15191F', card:'#11161B', name:'sage-noir' },
    { bg:'#10110D', ink:'#EDE8DA', accent:'#B98C5A', soft:'#1A1B16', card:'#15161F', name:'copper' },
    { bg:'#0F0E10', ink:'#EFEAE0', accent:'#D4B5C6', soft:'#1A1820', card:'#161520', name:'rose-quartz' },
    { bg:'#0A0F11', ink:'#E2E7DD', accent:'#7BAA8B', soft:'#121819', card:'#0F1517', name:'eucalyptus' },
  ];
  const p = palettes[pickSeed(o.id + 'C', palettes.length)];
  const services = copy.services.map((s, i) =>
    `<div class="svc"><div class="num">${String(i+1).padStart(2,'0')}</div><div class="svc-name">${escHtml(s)}</div><div class="svc-rule"></div></div>`
  ).join('');
  const team = pros.slice(0, 4).map(p => {
    const initials = (p.full_name||'').split(/\s+/).slice(0,2).map(s=>s[0]).join('').toUpperCase()||'·';
    return `<div class="member"><div class="ini">${escHtml(initials)}</div><div class="mn">${escHtml(titleCase(p.full_name))}</div><div class="mt">${escHtml(p.title||'')}</div><div class="ms">${escHtml(p.primary_specialty||'')}</div></div>`;
  }).join('');
  return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escHtml(name)} — ${escHtml(copy.kind)}</title><style>
*,*::before,*::after{box-sizing:border-box}html,body{margin:0;background:${p.bg};color:${p.ink};font:15px/1.7 ui-sans-serif,'Inter','Helvetica Neue',sans-serif;-webkit-font-smoothing:antialiased}
.wrap{max-width:1240px;margin:0 auto;padding:0 32px}
nav{display:flex;align-items:center;justify-content:space-between;padding:30px 0;border-bottom:1px solid ${p.soft}}
.brand{font:500 16px/1 ui-serif,'Iowan Old Style','Didot','Bodoni 72',serif;letter-spacing:0.06em;text-transform:uppercase}
.brand .accent{color:${p.accent};margin-left:8px;font-size:11px;letter-spacing:0.22em}
.nav-r{display:flex;gap:32px;align-items:center}
.nav-r a{color:${p.ink};opacity:0.7;text-decoration:none;font:500 12px/1 ui-sans-serif,sans-serif;letter-spacing:0.14em;text-transform:uppercase}
.nav-r .book{border:1px solid ${p.accent};color:${p.accent};padding:11px 22px;letter-spacing:0.18em;border-radius:0;opacity:1}
.hero{padding:120px 0 100px;text-align:center;position:relative}
.hero::before{content:'';position:absolute;left:50%;top:80px;transform:translateX(-50%);width:1px;height:32px;background:${p.accent};opacity:0.55}
.hero .label{font:500 11px/1 ui-sans-serif,sans-serif;letter-spacing:0.32em;text-transform:uppercase;color:${p.accent};margin-bottom:36px;display:block}
.hero h1{font:400 clamp(54px,8vw,108px)/0.98 ui-serif,'Iowan Old Style','Didot','Bodoni 72',serif;margin:0 0 28px;letter-spacing:-0.025em}
.hero h1 em{font-style:italic;color:${p.accent}}
.hero p{font:400 18px/1.6 ui-sans-serif,sans-serif;color:${p.ink};opacity:0.72;max-width:560px;margin:0 auto 48px}
.hero .actions{display:inline-flex;gap:0;align-items:center}
.btn-cta{background:${p.accent};color:${p.bg};padding:18px 36px;text-decoration:none;font:600 12px/1 ui-sans-serif,sans-serif;letter-spacing:0.22em;text-transform:uppercase}
.btn-secondary{color:${p.ink};text-decoration:none;font:500 12px/1 ui-sans-serif,sans-serif;letter-spacing:0.18em;text-transform:uppercase;padding:18px 28px;border-bottom:1px solid ${p.ink};margin-left:18px}
section{padding:96px 0;border-top:1px solid ${p.soft}}
.section-head{text-align:center;margin-bottom:64px}
.section-head .ey{font:500 11px/1 ui-sans-serif,sans-serif;letter-spacing:0.32em;text-transform:uppercase;color:${p.accent};margin-bottom:18px;display:block}
.section-head h2{font:400 clamp(32px,4.2vw,52px)/1.05 ui-serif,'Iowan Old Style',serif;margin:0;letter-spacing:-0.018em}
.services-list{display:grid;grid-template-columns:repeat(2,1fr);gap:0;border-top:1px solid ${p.soft}}
.svc{display:grid;grid-template-columns:60px 1fr 80px;gap:24px;align-items:center;padding:32px 28px;border-bottom:1px solid ${p.soft};border-right:1px solid ${p.soft}}
.svc:nth-child(2n){border-right:0}
.num{font:500 14px/1 ui-serif,serif;color:${p.accent};letter-spacing:0.05em}
.svc-name{font:400 19px/1.3 ui-serif,serif;letter-spacing:-0.005em}
.svc-rule{height:1px;background:${p.accent};opacity:0.4}
.team-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:24px}
.member{padding:36px 24px;background:${p.card};border:1px solid ${p.soft};text-align:center}
.ini{width:64px;height:64px;border-radius:50%;background:${p.soft};color:${p.accent};font:500 20px/1 ui-serif,serif;display:flex;align-items:center;justify-content:center;margin:0 auto 22px;letter-spacing:0.04em}
.mn{font:500 16px/1.3 ui-serif,serif;letter-spacing:-0.005em;margin-bottom:4px}
.mt{font:500 11px/1 ui-sans-serif,sans-serif;letter-spacing:0.18em;text-transform:uppercase;color:${p.accent};margin-bottom:8px}
.ms{font-size:13px;opacity:0.65}
.contact{padding:120px 0;text-align:center;background:linear-gradient(180deg,${p.bg} 0%,${p.card} 100%);margin-top:0}
.contact h2{font:400 clamp(40px,5vw,64px)/1 ui-serif,serif;margin:0 0 28px;letter-spacing:-0.02em}
.contact p{font-size:17px;opacity:0.72;margin:0 0 40px;max-width:520px;margin-left:auto;margin-right:auto}
.contact a.phone{color:${p.accent};text-decoration:none;font:500 36px/1 ui-serif,serif;display:block;margin-bottom:16px;letter-spacing:0.04em}
.contact .city{font:500 11px/1 ui-sans-serif,sans-serif;letter-spacing:0.32em;text-transform:uppercase;opacity:0.55}
@media(max-width:780px){.services-list,.team-grid{grid-template-columns:1fr}.svc{border-right:0}}
</style></head><body><div class="wrap">
<nav><div class="brand">${escHtml(name.split(/\s+/).slice(0,2).join(' '))}<span class="accent">${escHtml(copy.kind)}</span></div><div class="nav-r"><a href="#services">Services</a><a href="#team">Practitioners</a><a class="book" href="${ctaHref(phoneTel)}">${escHtml(copy.cta)}</a></div></nav>
<div class="hero"><span class="label">${escHtml(cityZip || copy.kind)}</span><h1>${(() => { const s = splitNameAccent(name); return s.lead ? `${escHtml(s.lead)} <em>${escHtml(s.tail)}</em>` : `<em>${escHtml(s.tail)}</em>`; })()}</h1><p>${escHtml(copy.tagline)}</p><div class="actions"><a class="btn-cta" href="${ctaHref(phoneTel)}">${escHtml(copy.cta)}</a><a class="btn-secondary" href="#services">Explore →</a></div></div>
<section id="services"><div class="section-head"><span class="ey">Services</span><h2>An intentional practice.</h2></div><div class="services-list">${services}</div></section>
${pros.length?`<section id="team"><div class="section-head"><span class="ey">Practitioners</span><h2>Trained. Tenured. Trusted.</h2></div><div class="team-grid">${team}</div></section>`:''}
<section class="contact" id="contact"><div class="section-head" style="margin-bottom:32px"><span class="ey">Begin</span><h2>${escHtml(copy.cta)}</h2></div><p>Discreet inquiries welcome. We respond within one business day.</p>${phone?`<a class="phone" href="tel:${escHtml(phoneTel)}">${escHtml(phone)}</a>`:''}<div class="city">${escHtml(cityZip)}</div></section>
</div><!-- variant=C premium-wellness · palette=${p.name} --></body></html>`;
}

// ─── Template D: Wellness App ─────────────────────────────────────────────────
// Server-rendered adaptation of 21st.dev's PulseFit hero (CSS keyframe carousel
// instead of framer-motion; abstract gradient tiles instead of stock photos).
// Centered hero on icy gradient wash, pill buttons, auto-scrolling service-card
// rail. Reads "modern wellness coach app" — orthogonal to A/B/C.
function renderPreviewWellnessApp(o, pros) {
  const copy = PREVIEW_COPY[o.type] || PREVIEW_DEFAULT;
  const name = titleCase(o.name || 'Our Practice');
  const cityZip = [titleCase(o.city || ''), o.zip || ''].filter(Boolean).join(', ');
  const phone = fmtPhone(o.phone);
  const phoneTel = (o.phone || '').replace(/[^+0-9]/g,'');
  const palettes = [
    { top:'#E8F0FF', mid:'#F5F9FF', accent:'#1E3A8A', soft:'#DBEAFE', name:'icy-blue' },
    { top:'#E8FFF1', mid:'#F2FFF7', accent:'#065F46', soft:'#D1FAE5', name:'mint' },
    { top:'#FFEFE6', mid:'#FFF7F2', accent:'#9A3412', soft:'#FED7AA', name:'peach' },
    { top:'#F1E8FF', mid:'#F7F2FF', accent:'#5B21B6', soft:'#E9D5FF', name:'lavender' },
    { top:'#FFF8E1', mid:'#FFFCF0', accent:'#92400E', soft:'#FEE9B0', name:'soft-amber' },
  ];
  const p = palettes[pickSeed(o.id + 'D', palettes.length)];
  // Tile color rotation — each service gets its own gradient swatch
  const tileGradients = [
    `linear-gradient(135deg, ${p.accent}, ${p.accent}66)`,
    `linear-gradient(135deg, ${p.accent}cc, ${p.soft})`,
    `linear-gradient(45deg, ${p.accent}, ${p.soft})`,
    `linear-gradient(180deg, ${p.accent}aa, ${p.accent}33)`,
    `linear-gradient(225deg, ${p.accent}, ${p.accent}55)`,
    `linear-gradient(90deg, ${p.accent}99, ${p.soft})`,
  ];
  // Build 12 tiles (services duplicated for seamless loop)
  const tilesOnce = copy.services.map((s, i) => {
    const grad = tileGradients[i % tileGradients.length];
    return `<div class="tile" style="background:${grad}"><div class="tile-cat">Service ${String(i+1).padStart(2,'0')}</div><div class="tile-title">${escHtml(s)}</div></div>`;
  });
  const tiles = [...tilesOnce, ...tilesOnce].join('');
  const scrollDuration = Math.max(20, copy.services.length * 4); // sec
  const tileCount = copy.services.length;
  // Provider avatars — initials in circles, soft shadow
  const teamAvatars = pros.slice(0, 5).map((pr, idx) => {
    const initials = (pr.full_name||'').split(/\s+/).slice(0,2).map(s=>s[0]).join('').toUpperCase()||'·';
    const shift = idx === 0 ? 0 : -10 * idx; // overlap
    return `<div class="avatar" style="margin-left:${shift}px;background:${p.soft};color:${p.accent}">${escHtml(initials)}</div>`;
  }).join('');
  const teamCount = pros.length;
  return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escHtml(name)} — ${escHtml(copy.kind)}</title><style>
*,*::before,*::after{box-sizing:border-box}html,body{margin:0;color:#1a1a1a;font:16px/1.6 'Inter',ui-sans-serif,system-ui,-apple-system,'SF Pro Text',sans-serif;-webkit-font-smoothing:antialiased;overflow-x:hidden}
body{background:linear-gradient(180deg, ${p.top} 0%, ${p.mid} 50%, #FFFFFF 100%);min-height:100vh}
header.nav{display:flex;align-items:center;justify-content:space-between;padding:32px clamp(20px,5vw,64px)}
.logo{font:700 24px/1 'Inter',sans-serif;letter-spacing:-0.01em;color:#1a1a1a}
.logo small{display:block;font:500 11px/1 'Inter',sans-serif;letter-spacing:0.16em;text-transform:uppercase;color:${p.accent};margin-top:6px}
.nav-links{display:none}
@media(min-width:900px){.nav-links{display:flex;gap:32px;align-items:center}}
.nav-links a{color:#4a5568;font-size:16px;text-decoration:none}.nav-links a:hover{color:#1a1a1a}
.nav-cta{background:#FFFFFF;border:1px solid #e2e8f0;padding:11px 22px;border-radius:9999px;font:500 15px/1 'Inter',sans-serif;color:#1a1a1a;text-decoration:none;box-shadow:0 2px 8px rgba(0,0,0,0.06)}
.hero{padding:48px clamp(20px,5vw,64px) 32px;text-align:center;display:flex;flex-direction:column;align-items:center;gap:32px;max-width:980px;margin:0 auto}
.eyebrow{font:500 11px/1 'Inter',sans-serif;letter-spacing:0.32em;text-transform:uppercase;color:${p.accent};margin:0}
.hero h1{font:700 clamp(40px,7vw,80px)/1.04 'Inter',sans-serif;letter-spacing:-0.025em;color:#1a1a1a;margin:0;max-width:880px}
.hero .lede{font:400 clamp(17px,1.7vw,21px)/1.55 'Inter',sans-serif;color:#4a5568;max-width:600px;margin:0}
.actions{display:flex;flex-direction:column;align-items:center;gap:14px}
@media(min-width:560px){.actions{flex-direction:row}}
.btn-primary{background:#1a1a1a;color:#fff;padding:18px 32px;border-radius:9999px;font:500 17px/1 'Inter',sans-serif;text-decoration:none;display:inline-flex;align-items:center;gap:10px;box-shadow:0 4px 16px rgba(0,0,0,0.18);transition:transform .15s ease}
.btn-primary:hover{transform:translateY(-1px)}
.btn-primary svg{width:20px;height:20px}
.btn-secondary{background:transparent;color:#1a1a1a;padding:18px 30px;border-radius:9999px;font:500 17px/1 'Inter',sans-serif;text-decoration:none;border:1px solid #cbd5e0}
.disclaimer{font:400 13px/1.5 'Inter',sans-serif;color:#718096;font-style:italic}
.social-proof{display:flex;align-items:center;gap:14px;justify-content:center;margin-top:8px}
.avatars{display:flex}
.avatar{width:40px;height:40px;border-radius:50%;border:2px solid #fff;display:flex;align-items:center;justify-content:center;font:600 13px/1 'Inter',sans-serif;letter-spacing:0.04em}
.sp-text{font:500 14px/1 'Inter',sans-serif;color:#4a5568}
.carousel-wrap{position:relative;padding:60px 0 80px;overflow:hidden}
.carousel-wrap::before,.carousel-wrap::after{content:'';position:absolute;top:0;bottom:0;width:120px;z-index:2;pointer-events:none}
.carousel-wrap::before{left:0;background:linear-gradient(90deg,#FFFFFF 0%,rgba(255,255,255,0) 100%)}
.carousel-wrap::after{right:0;background:linear-gradient(270deg,#FFFFFF 0%,rgba(255,255,255,0) 100%)}
.carousel{display:flex;gap:24px;padding-left:24px;animation:scroll ${scrollDuration}s linear infinite;width:max-content}
@keyframes scroll{from{transform:translateX(0)}to{transform:translateX(calc(-1 * (356px + 24px) * ${tileCount}))}}
.tile{flex-shrink:0;width:356px;height:480px;border-radius:24px;box-shadow:0 12px 36px rgba(0,0,0,0.14);position:relative;overflow:hidden;display:flex;flex-direction:column;justify-content:flex-end;padding:28px;color:#fff}
.tile::before{content:'';position:absolute;inset:0;background:linear-gradient(180deg,rgba(0,0,0,0) 30%,rgba(0,0,0,0.45) 100%)}
.tile-cat{position:relative;font:500 12px/1 'Inter',sans-serif;color:rgba(255,255,255,0.85);text-transform:uppercase;letter-spacing:0.14em;margin-bottom:10px}
.tile-title{position:relative;font:600 26px/1.25 'Inter',sans-serif;color:#fff;letter-spacing:-0.005em}
.providers-section{padding:40px clamp(20px,5vw,64px) 80px;max-width:1180px;margin:0 auto;text-align:center}
.providers-section h2{font:600 clamp(28px,3.2vw,40px)/1.1 'Inter',sans-serif;letter-spacing:-0.018em;margin:0 0 14px}
.providers-section .sub{color:#4a5568;font-size:17px;margin:0 0 40px}
.providers-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:14px;max-width:760px;margin:0 auto}
@media(min-width:780px){.providers-grid{grid-template-columns:repeat(3,1fr)}}
.provider-pill{background:#fff;border:1px solid #e2e8f0;border-radius:18px;padding:24px 22px;display:flex;align-items:center;gap:16px;box-shadow:0 4px 14px rgba(0,0,0,0.04)}
.provider-pill .ini{width:48px;height:48px;border-radius:50%;background:${p.soft};color:${p.accent};display:flex;align-items:center;justify-content:center;font:600 16px/1 'Inter',sans-serif;flex-shrink:0;letter-spacing:0.02em}
.pn{font:600 15px/1.3 'Inter',sans-serif;text-align:left}
.ps{font:400 12.5px/1.3 'Inter',sans-serif;color:#718096;text-align:left;margin-top:3px}
.cta-section{background:#fff;border-radius:32px;padding:64px 32px;margin:0 clamp(20px,5vw,64px) 60px;text-align:center;box-shadow:0 16px 48px rgba(0,0,0,0.06)}
.cta-section h2{font:600 clamp(32px,3.6vw,46px)/1.05 'Inter',sans-serif;letter-spacing:-0.02em;margin:0 0 18px}
.cta-section p{color:#4a5568;font-size:17px;margin:0 0 32px}
.cta-section .city{font:500 11px/1 'Inter',sans-serif;letter-spacing:0.32em;text-transform:uppercase;color:#718096;margin-top:24px}
@media(prefers-reduced-motion:reduce){.carousel{animation:none}}
</style></head><body>
<header class="nav"><div class="logo">${escHtml(name.split(/\s+/).slice(0,2).join(' '))}<small>${escHtml(copy.kind)}</small></div><div class="nav-links"><a href="#services">Services</a><a href="#team">Providers</a><a href="#contact">Contact</a></div><a class="nav-cta" href="${ctaHref(phoneTel)}">${escHtml(copy.cta)}</a></header>
<section class="hero">
  <p class="eyebrow">${escHtml(cityZip || copy.kind)}</p>
  <h1>${escHtml(name)}</h1>
  <p class="lede">${escHtml(copy.tagline)}</p>
  <div class="actions">
    <a class="btn-primary" href="${ctaHref(phoneTel)}">${escHtml(copy.cta)}<svg viewBox="0 0 20 20" fill="none"><path d="M7 10H13M13 10L10 7M13 10L10 13" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></a>
    <a class="btn-secondary" href="#services">Browse services</a>
  </div>
  <p class="disclaimer">No commitment. Most insurance accepted.</p>
  ${pros.length ? `<div class="social-proof"><div class="avatars">${teamAvatars}</div><span class="sp-text">${teamCount} licensed provider${teamCount===1?'':'s'} practicing here</span></div>` : ''}
</section>
<div class="carousel-wrap" id="services" aria-label="Services carousel"><div class="carousel">${tiles}</div></div>
${pros.length?`<section class="providers-section" id="team"><p class="eyebrow">The team</p><h2>Real people. Real names.</h2><p class="sub">Booking with us means seeing one of these providers — not a stranger.</p><div class="providers-grid">${pros.slice(0,6).map(pr=>{const ini=(pr.full_name||'').split(/\s+/).slice(0,2).map(s=>s[0]).join('').toUpperCase()||'·';return `<div class="provider-pill"><div class="ini">${escHtml(ini)}</div><div><div class="pn">${escHtml(titleCase(pr.full_name))}${pr.title?', '+escHtml(pr.title):''}</div><div class="ps">${escHtml(pr.primary_specialty||'')}</div></div></div>`}).join('')}</div></section>`:''}
<section class="cta-section" id="contact"><p class="eyebrow">Get started</p><h2>${escHtml(copy.cta)}</h2><p>${cityZip?escHtml(cityZip):'Welcoming new patients'}</p>${phone?`<a class="btn-primary" href="tel:${escHtml(phoneTel)}">Call ${escHtml(phone)}<svg viewBox="0 0 20 20" fill="none"><path d="M7 10H13M13 10L10 7M13 10L10 13" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></a>`:''}<div class="city">${escHtml(cityZip)}</div></section>
<!-- variant=D wellness-app · palette=${p.name} · adapted from 21st.dev PulseFit hero (server-rendered, no React/framer-motion runtime) -->
</body></html>`;
}

// ─── Template E: Editorial Brutalist ──────────────────────────────────────────
// Adapted from 21st.dev "Hero 03" by aliimam.in (similarity 0.105, the only
// genuinely orthogonal direction the re-queries surfaced). Layout primitive:
// asymmetric three-line ALL-CAPS statement (10rem light) flanked by 12-13px
// italic mono meta-text columns; vertical rotated right-edge badge; hairline
// separators; dotted-grid background. NO rounded chrome. NO gradients.
// Mood derivation per Paper.design: clinical chart paper at dawn — bone, ink,
// antiseptic, vitals-red, x-ray cyan.
const BRUTALIST_HEADLINES = {
  hospital:            ['WORLD', 'CLASS', 'CARE'],
  medical_group:       ['CARE', 'THAT', 'KNOWS YOU'],
  clinic:              ['WALK IN', 'SAME', 'WEEK'],
  dental_office:       ['QUIET', 'ROOMS', 'HONEST PRICES'],
  optometrist_office:  ['BETTER', 'VISION', 'BETTER DAYS'],
  surgery_center:      ['OUTPATIENT', 'CARE', 'HOSPITAL EXPERTISE'],
  fqhc:                ['QUALITY', 'CARE', 'EVERYONE'],
  hospice:             ['COMFORT', 'DIGNITY', 'PRESENCE'],
  rcfe:                ['DIGNIFIED', 'HOMES', 'NEXT CHAPTER'],
  ccrc:                ['INDEPENDENCE', 'TODAY', 'CARE TOMORROW'],
  adult_residential:   ['HOME', 'WHERE EVERY', 'RESIDENT KNOWN'],
  adult_day_program:   ['ENGAGED', 'DAYS', 'PEACE OF MIND'],
  home_care_agency:    ['CARE', 'IN THE', 'HOME YOU LOVE'],
  home_health:         ['SKILLED', 'CLINICAL', 'AT HOME'],
  nursing_facility:    ['ROUND', 'THE CLOCK', 'SKILLED CARE'],
  tcm_clinic:          ['TIME', 'HONORED', 'MODERN LIVES'],
  acupuncture_clinic:  ['STILLNESS', 'IS A', 'TREATMENT'],
  chiropractor_office: ['MOVE', 'BETTER', 'LIVE BETTER'],
};
const BRUTALIST_DEFAULT_HEADLINE = ['CARE', 'THAT', 'RESPECTS YOU'];
const BRUTALIST_BADGES = {
  hospital: 'EMERGENCY READY', medical_group: 'INSURED PATIENTS', clinic: 'WALK-IN HOURS',
  dental_office: 'HSA / FSA', optometrist_office: 'CONTACTS IN 7 DAYS',
  surgery_center: 'BOARD CERTIFIED', fqhc: 'SLIDING SCALE', hospice: '24/7 ON-CALL',
  rcfe: 'TOURS BY APPT', ccrc: 'PRIORITY ADMIT', adult_residential: 'TOUR WELCOMED',
  adult_day_program: 'TRANSPORT INCL', home_care_agency: 'BACKGROUND CHECKED',
  home_health: 'MEDICARE BILLED', nursing_facility: 'JOINT COMMISSION',
  tcm_clinic: 'LICENSED L.AC', acupuncture_clinic: 'LICENSED L.AC',
  chiropractor_office: 'D.C. LICENSED',
};
function renderPreviewEditorialBrutalist(o, pros) {
  const copy = PREVIEW_COPY[o.type] || PREVIEW_DEFAULT;
  const name = titleCase(o.name || 'Our Practice');
  const cityZip = [titleCase(o.city || ''), o.zip || ''].filter(Boolean).join(', ');
  const phone = fmtPhone(o.phone);
  const phoneTel = (o.phone || '').replace(/[^+0-9]/g,'');
  const palettes = [
    // Each palette is mood-derived (Paper.design rule): physical scene → hex
    { bg:'#F4F1EA', ink:'#0A0A0A', meta:'#5C5C5C', accent:'#D63F2A', cool:'#7AB8C4', mood:'bone',       dark:false }, // chart paper at dawn
    { bg:'#F1F4F0', ink:'#13241D', meta:'#506257', accent:'#1B5E20', cool:'#A8C8B5', mood:'chart',      dark:false }, // botanical
    { bg:'#F8F8F4', ink:'#0F1115', meta:'#5A5A5A', accent:'#1A4D8C', cool:'#B8CCE0', mood:'clinical',   dark:false }, // mineral
    { bg:'#F2EEE3', ink:'#1A0F0A', meta:'#6B4F35', accent:'#C2410C', cool:'#FBE3B3', mood:'dawn',       dark:false }, // candlelit
    { bg:'#1A1B1F', ink:'#E8E8E8', meta:'#888888', accent:'#FF6B5B', cool:'#65BAC8', mood:'radiograph', dark:true  }, // nocturnal x-ray
  ];
  const p = palettes[pickSeed(o.id + 'E', palettes.length)];
  const headline = BRUTALIST_HEADLINES[o.type] || BRUTALIST_DEFAULT_HEADLINE;
  const badge = BRUTALIST_BADGES[o.type] || 'NEW PATIENTS';
  // Meta paragraphs flanking the giant headlines — derived from copy + cityZip
  const metaLeft = `Located ${cityZip || 'in your community'}. ${escHtml((copy.tagline || '').split('.')[0])}.`;
  const metaRight = `Open to new patients. ${pros.length ? pros.length + ' licensed providers practicing here.' : 'Insurance-friendly. Same-week appointments.'}`;
  const services = copy.services.map((s, i) =>
    `<div class="svc"><span class="svc-num">${String(i+1).padStart(2,'0')}</span><span class="svc-name">${escHtml(s)}</span><span class="svc-rule"></span></div>`
  ).join('');
  const team = pros.slice(0, 8).map((pr, i) => {
    return `<div class="prov"><span class="prov-idx">${String(i+1).padStart(2,'0')}</span><span class="prov-name">${escHtml(titleCase(pr.full_name))}${pr.title?', '+escHtml(pr.title):''}</span><span class="prov-spec">${escHtml(pr.primary_specialty || '')}</span></div>`;
  }).join('');
  // Heart SVG inline at accent color — used as the mid-line glyph that breaks the headline
  const heartIcon = `<svg class="heart" viewBox="0 0 24 24" fill="${p.accent}" aria-hidden="true"><path d="M2 9.5a5.5 5.5 0 0 1 9.591-3.676.56.56 0 0 0 .818 0A5.49 5.49 0 0 1 22 9.5c0 2.29-1.5 4-3 5.5l-5.492 5.313a2 2 0 0 1-3 .019L5 15c-1.5-1.5-3-3.2-3-5.5"/></svg>`;
  return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escHtml(name)} — ${escHtml(copy.kind)}</title><style>
*,*::before,*::after{box-sizing:border-box}html,body{margin:0;background:${p.bg};color:${p.ink};font:14px/1.5 ui-monospace,'IBM Plex Mono','SF Mono','Menlo',monospace;-webkit-font-smoothing:antialiased;overflow-x:hidden;background-image:radial-gradient(circle, ${p.cool}33 1px, transparent 1px);background-size:24px 24px}
.wrap{max-width:1440px;margin:0 auto;padding:0 32px;position:relative}
header.brut{display:grid;grid-template-columns:1fr auto 1fr;gap:24px;align-items:center;padding:28px 0;border-bottom:1px solid ${p.ink}}
.brand{font:600 13px/1 ui-monospace,monospace;letter-spacing:0.16em;text-transform:uppercase}
.brand .org{font-weight:400;opacity:0.7;margin-left:12px}
.eyebrow-mid{font:400 12px/1 ui-monospace,monospace;letter-spacing:0.18em;text-transform:uppercase;color:${p.meta};text-align:center}
.contact-top{font:400 12px/1 ui-monospace,monospace;letter-spacing:0.08em;text-align:right;color:${p.ink}}
.contact-top a{color:${p.ink};text-decoration:underline;text-decoration-thickness:1px;text-underline-offset:3px}
.hero{padding:60px 0 80px;display:grid;grid-template-columns:1fr;gap:6px;position:relative}
.hero-line{display:grid;grid-template-columns:240px 1fr 240px;gap:32px;align-items:center}
.hero-line:nth-child(2){grid-template-columns:1fr 240px}
.hero-line:nth-child(3){grid-template-columns:240px 1fr}
.meta-l,.meta-r{font:italic 400 13px/1.55 ui-serif,'Iowan Old Style',Georgia,serif;color:${p.meta};max-width:240px}
.meta-r{text-align:right;justify-self:end}
.bigword{font:300 clamp(54px,11vw,160px)/0.92 'Helvetica Neue','Inter',ui-sans-serif,sans-serif;letter-spacing:-0.02em;text-transform:uppercase;color:${p.ink};display:flex;align-items:center;gap:18px;flex-wrap:nowrap;white-space:nowrap;overflow:hidden}
.bigword .heart{width:clamp(40px,8vw,120px);height:clamp(40px,8vw,120px);flex-shrink:0}
.hero-line:nth-child(2) .bigword{justify-content:flex-start}
.hero-line:nth-child(3) .bigword{justify-content:flex-end;text-align:right}
.hairline{height:1px;background:${p.ink};margin:60px 0;width:100%;opacity:0.85}
section{padding:60px 0;border-top:1px solid ${p.ink}}
.section-head{display:grid;grid-template-columns:240px 1fr;gap:32px;align-items:baseline;margin-bottom:40px}
.s-label{font:600 11px/1 ui-monospace,monospace;letter-spacing:0.32em;text-transform:uppercase;color:${p.accent}}
.s-title{font:300 clamp(28px,4.4vw,64px)/1 'Helvetica Neue','Inter',sans-serif;letter-spacing:-0.018em;text-transform:uppercase}
.svc{display:grid;grid-template-columns:60px 1fr 1fr;gap:24px;align-items:center;padding:18px 0;border-top:1px solid ${p.ink}33}
.svc:first-child{border-top:0}
.svc-num{font:400 13px/1 ui-monospace,monospace;letter-spacing:0.05em;color:${p.accent}}
.svc-name{font:400 19px/1 'Helvetica Neue','Inter',sans-serif;letter-spacing:-0.005em}
.svc-rule{height:1px;background:${p.ink};opacity:0.45;justify-self:end;width:60%}
.prov{display:grid;grid-template-columns:60px 1.4fr 1fr;gap:24px;padding:14px 0;border-top:1px solid ${p.ink}33;align-items:baseline}
.prov:first-child{border-top:0}
.prov-idx{font:400 13px/1 ui-monospace,monospace;color:${p.accent}}
.prov-name{font:500 16px/1.3 'Helvetica Neue','Inter',sans-serif}
.prov-spec{font:italic 400 13px/1.3 ui-serif,Georgia,serif;color:${p.meta}}
.contact-bar{display:grid;grid-template-columns:240px 1fr 240px;gap:32px;align-items:end;padding:60px 0 80px;border-top:1px solid ${p.ink}}
.cb-label{font:600 11px/1 ui-monospace,monospace;letter-spacing:0.32em;text-transform:uppercase;color:${p.accent}}
.cb-action{font:300 clamp(36px,6vw,84px)/1 'Helvetica Neue','Inter',sans-serif;letter-spacing:-0.02em;text-transform:uppercase}
.cb-action a{color:${p.ink};text-decoration:underline;text-decoration-thickness:2px;text-underline-offset:8px}
.cb-meta{font:400 12px/1.55 ui-monospace,monospace;color:${p.meta};text-align:right;justify-self:end;max-width:240px}
.side-rail{position:fixed;right:8px;top:50%;transform:translateY(-50%) rotate(180deg);writing-mode:vertical-rl;background:${p.ink};color:${p.bg};padding:18px 8px;font:600 11px/1 ui-monospace,monospace;letter-spacing:0.32em;text-transform:uppercase;z-index:10}
@media(max-width:980px){
  .hero-line,.hero-line:nth-child(2),.hero-line:nth-child(3){grid-template-columns:1fr;gap:12px}
  .meta-r{text-align:left;justify-self:start}
  .hero-line:nth-child(2) .bigword,.hero-line:nth-child(3) .bigword{justify-content:flex-start;text-align:left}
  .section-head,.contact-bar{grid-template-columns:1fr;gap:14px}
  .cb-meta{text-align:left;justify-self:start}
  .svc,.prov{grid-template-columns:40px 1fr;row-gap:6px}
  .svc-rule,.svc:nth-child(n+1) .svc-rule{display:none}
  .prov-spec{grid-column:2}
  .side-rail{display:none}
}
</style></head><body><div class="wrap">
<header class="brut"><div class="brand">${escHtml(name.split(/\s+/).slice(0,3).join(' '))}<span class="org">${escHtml(copy.kind)}</span></div><div class="eyebrow-mid">${escHtml(cityZip || 'CALIFORNIA')}</div><div class="contact-top">${phone?`<a href="tel:${escHtml(phoneTel)}">${escHtml(phone)}</a>`:'<span style="opacity:.6">contact below</span>'}</div></header>
<div class="hero" aria-label="${escHtml(name)}">
  <div class="hero-line"><div class="meta-l">${metaLeft}</div><div class="bigword">${escHtml(headline[0])}</div><div></div></div>
  <div class="hero-line"><div class="bigword">${escHtml(headline[1].split(' ')[0])}${heartIcon}${escHtml(headline[1].split(' ').slice(1).join(' ') || 'CARE')}</div><div class="meta-r">${metaRight}</div></div>
  <div class="hero-line"><div></div><div class="bigword">${escHtml(headline[2])}</div></div>
</div>
<section id="services"><div class="section-head"><span class="s-label">Services / 0${copy.services.length}</span><h2 class="s-title">What we do.</h2></div>${services}</section>
${pros.length?`<section id="team"><div class="section-head"><span class="s-label">Providers / ${String(pros.length).padStart(2,'0')}</span><h2 class="s-title">Real names. Real licenses.</h2></div>${team}</section>`:''}
<div class="contact-bar" id="contact"><div class="cb-label">Contact / 01</div><div class="cb-action">${phone?`<a href="tel:${escHtml(phoneTel)}">${escHtml(copy.cta)}</a>`:escHtml(copy.cta)}</div><div class="cb-meta">${escHtml(name)}<br>${escHtml(cityZip)}${phone?`<br>${escHtml(phone)}`:''}</div></div>
</div>
<aside class="side-rail" aria-label="${escHtml(badge)}">${escHtml(badge)}</aside>
<!-- variant=E editorial-brutalist · palette=${p.mood} · adapted from 21st.dev Hero 03 (aliimam.in, similarity 0.105) — server-rendered, no React/framer-motion runtime -->
</body></html>`;
}

// 4-up index showing all variants for the same org. Iframe each variant so the
// pitch flow can show "here are 4 directions for your site" in one view.
function renderPreviewVariantIndex(o) {
  const id = o.id;
  const name = titleCase(o.name || 'Practice');
  return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escHtml(name)} — 5 design directions</title><style>
*,*::before,*::after{box-sizing:border-box}body{margin:0;background:#0A0A0F;color:#E5E7EB;font:14px/1.5 ui-sans-serif,system-ui,sans-serif;-webkit-font-smoothing:antialiased}
header{padding:24px 32px;background:#13131C;border-bottom:1px solid #1F2030;display:flex;align-items:center;gap:24px}
header h1{margin:0;font-size:18px;font-weight:600}
header .meta{color:#7A7B91;font-size:13px}
.grid{display:grid;grid-template-columns:repeat(5,1fr);gap:0;height:calc(100vh - 70px);background:#1F2030}
.col{display:flex;flex-direction:column;background:#0A0A0F;min-width:0}
.tag{padding:14px 18px;background:#13131C;border-bottom:1px solid #1F2030;display:flex;align-items:center;justify-content:space-between}
.tag .name{font-weight:600;font-size:13px}
.tag .name span{color:#7A7B91;font-weight:400;margin-left:6px}
.tag a{color:#10b981;text-decoration:none;font-size:12px}
.frame{flex:1;background:#fff;overflow:hidden}
iframe{width:100%;height:100%;border:0;background:#fff}
@media(max-width:1500px){.grid{grid-template-columns:repeat(3,1fr);height:auto}.col{height:60vh}}
@media(max-width:1000px){.grid{grid-template-columns:repeat(2,1fr)}.col{height:55vh}}
@media(max-width:680px){.grid{grid-template-columns:1fr}.col{height:80vh}}
</style></head><body>
<header><h1>${escHtml(name)}</h1><span class="meta">org #${escHtml(String(id))} · 5 design directions</span></header>
<div class="grid">
  <div class="col"><div class="tag"><div class="name">A <span>Modern Clinical</span></div><a href="/preview/${id}/a" target="_blank">open ↗</a></div><div class="frame"><iframe src="/preview/${id}/a" loading="lazy"></iframe></div></div>
  <div class="col"><div class="tag"><div class="name">B <span>Warm Community</span></div><a href="/preview/${id}/b" target="_blank">open ↗</a></div><div class="frame"><iframe src="/preview/${id}/b" loading="lazy"></iframe></div></div>
  <div class="col"><div class="tag"><div class="name">C <span>Premium Wellness</span></div><a href="/preview/${id}/c" target="_blank">open ↗</a></div><div class="frame"><iframe src="/preview/${id}/c" loading="lazy"></iframe></div></div>
  <div class="col"><div class="tag"><div class="name">D <span>Wellness App (21st.dev)</span></div><a href="/preview/${id}/d" target="_blank">open ↗</a></div><div class="frame"><iframe src="/preview/${id}/d" loading="lazy"></iframe></div></div>
  <div class="col"><div class="tag"><div class="name">E <span>Editorial-Brutalist (21st.dev)</span></div><a href="/preview/${id}/e" target="_blank">open ↗</a></div><div class="frame"><iframe src="/preview/${id}/e" loading="lazy"></iframe></div></div>
</div>
</body></html>`;
}

function renderDashboard(c) {
  return `<!doctype html><html><head><meta charset="utf-8">
<title>Professional Directory · admin</title>
<style>
  :root { --bg:#0c0f1a; --panel:#131829; --border:#2a3048; --fg:#f0f0f5; --muted:#a1a1b5; --accent:#10b981; }
  *,*::before,*::after { box-sizing: border-box; }
  body { font:14px/1.5 system-ui,-apple-system,sans-serif; -webkit-font-smoothing:antialiased; margin:0; background:var(--bg); color:var(--fg); }
  header { padding:24px 28px; background:var(--panel); border-bottom:1px solid var(--border); display:flex; align-items:baseline; gap:24px; flex-wrap:wrap; }
  h1 { margin:0; font-size:20px; }
  .stats { display:flex; gap:18px; flex-wrap:wrap; color:var(--muted); font-size:13px; }
  .stats b { color:var(--fg); font-weight:600; }
  a { color:var(--accent); text-decoration:none; }
  a:hover { text-decoration:underline; }

  .controls { padding:16px 28px; background:var(--panel); border-bottom:1px solid var(--border); display:flex; align-items:center; gap:18px; flex-wrap:wrap; }
  .seg { display:inline-flex; background:var(--bg); border:1px solid var(--border); border-radius:8px; overflow:hidden; }
  .seg button { background:transparent; color:var(--muted); border:0; padding:7px 14px; font:inherit; cursor:pointer; }
  .seg button.active { background:var(--accent); color:#04130c; font-weight:600; }
  .slider-group { display:flex; align-items:center; gap:10px; }
  .slider-group label { color:var(--muted); font-size:12px; text-transform:uppercase; letter-spacing:.5px; }
  .slider-group input[type=range] { accent-color:var(--accent); width:160px; }
  .slider-group .val { color:var(--fg); font-variant-numeric:tabular-nums; min-width:24px; text-align:right; }
  select, input[type=text] { background:var(--bg); color:var(--fg); border:1px solid var(--border); border-radius:6px; padding:6px 10px; font:inherit; }
  .pager { margin-left:auto; color:var(--muted); }
  .pager button { background:var(--bg); border:1px solid var(--border); color:var(--fg); border-radius:6px; padding:6px 10px; cursor:pointer; }
  .pager button:disabled { opacity:.4; cursor:not-allowed; }

  /* Grid view */
  #results.grid { display:grid; gap:14px; padding:18px 28px; grid-template-columns:repeat(var(--cols, 5), minmax(0, 1fr)); }
  #results.grid .item { background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:14px; min-height:88px; }
  #results.grid .item .name { font-weight:600; font-size:14px; margin-bottom:4px; }
  #results.grid .item .meta { color:var(--muted); font-size:12px; line-height:1.45; }
  #results.grid .item .badge { display:inline-block; padding:1px 6px; border-radius:4px; background:#1c2237; color:var(--accent); font-size:11px; margin-right:4px; }

  /* Map view (Leaflet) */
  #results.map { padding:0; height: calc(100vh - 200px); }
  #results.map #leaflet { width:100%; height:100%; background:var(--panel); }
  .leaflet-popup-content { font: 13px/1.4 system-ui; color:#0c0f1a; min-width: 200px; }
  .leaflet-popup-content .pname { font-weight:600; font-size:14px; margin-bottom:4px; }
  .leaflet-popup-content .needs { color:#b45309; font-weight:600; }
  .leaflet-popup-content .has   { color:#737373; }

  /* ZIP heat panel */
  #zip-heat { padding:0 28px 18px; display:none; }
  #results.map ~ #zip-heat { display:block; }
  #zip-heat h3 { color:var(--muted); font-size:12px; text-transform:uppercase; letter-spacing:.5px; margin:14px 0 8px; }
  #zip-heat .ziprow { display:inline-flex; align-items:center; gap:8px; padding:6px 10px; margin:0 6px 6px 0; background:var(--panel); border:1px solid var(--border); border-radius:6px; font-variant-numeric:tabular-nums; font-size:12px; cursor:pointer; }
  #zip-heat .ziprow b { color:#facc15; }
  #zip-heat .ziprow span { color:var(--muted); }

  /* List view (spreadsheet-style table) */
  #results.list { padding:0 28px 28px; }
  #results.list table { width:100%; border-collapse:collapse; font-size:13px; }
  #results.list th { text-align:left; padding:10px 12px; background:var(--panel); border-bottom:1px solid var(--border); position:sticky; top:0; font-weight:600; color:var(--muted); text-transform:uppercase; font-size:11px; letter-spacing:.5px; }
  #results.list th.sortable { cursor:pointer; user-select:none; }
  #results.list th.sortable:hover { color:var(--fg); }
  #results.list th.sortable.asc::after  { content:" ↑"; color:var(--accent); }
  #results.list th.sortable.desc::after { content:" ↓"; color:var(--accent); }
  #results.list td { padding:8px 12px; border-bottom:1px solid var(--border); white-space:nowrap; max-width:280px; overflow:hidden; text-overflow:ellipsis; }
  #results.list tr:hover td { background:var(--panel); }
  #results.list td a.email { color:var(--accent); }

  /* Map empty-state overlay */
  #results.map .map-empty { position:absolute; inset:0; display:flex; align-items:center; justify-content:center; color:#fbbf24; background:rgba(12,15,26,0.85); font-size:15px; font-weight:600; padding:24px; text-align:center; line-height:1.5; z-index:500; pointer-events:none; }
  #results.map .map-loading { position:absolute; top:14px; right:14px; background:var(--panel); border:1px solid var(--border); color:var(--muted); padding:6px 12px; border-radius:6px; font-size:12px; z-index:500; }

  .empty { padding:40px 28px; color:var(--muted); text-align:center; }
</style></head><body>

<header>
  <h1>Professional Directory</h1>
  <div class="stats">
    <span><b>${c.professionals}</b> professionals</span>
    <span><b>${c.organizations}</b> orgs</span>
    <span><b>${c.hospitals}</b> hospitals</span>
    <span><b>${c.specialties}</b> specialties</span>
    <span><b>${c.la_zips}</b> LA ZIPs</span>
    <span style="margin-left:auto"><a href="/health">/health</a> · <a href="/export.csv?entity=professionals">export</a></span>
  </div>
</header>

<div class="controls">
  <div class="seg" id="entity-toggle">
    <button data-entity="professionals" class="active">Professionals</button>
    <button data-entity="organizations">Organizations</button>
  </div>

  <select id="category-select" title="Filter to a small-biz category (Organizations only)">
    <option value="">All categories</option>
    <option value="cosmetology">Cosmetology · salons, barbers, nails, spa</option>
    <option value="chiropractic">Chiropractic</option>
    <option value="chinese_medicine">Chinese medicine · acupuncture, TCM, herbal</option>
    <option value="dental">Dental offices</option>
    <option value="optometry">Optometry · eye doctors, opticals</option>
    <option value="fitness">Fitness · yoga, pilates, gyms</option>
    <option value="tattoo">Tattoo · ink, piercing</option>
    <option value="pets">Pets · vets, groomers, pet shops</option>
    <option value="medical">Medical · hospitals, clinics, FQHC, etc.</option>
  </select>

  <select id="prospect-select" title="Prospect filter — who needs a website?">
    <option value="">Any (all orgs)</option>
    <option value="outreach">📬 Ready-to-pitch (has email + not contacted) ★★★</option>
    <option value="needs">Needs website (no site)</option>
    <option value="has">Has website</option>
    <option value="phone">Has phone (any site)</option>
    <option value="phone_needs">Has phone + needs website ★</option>
    <option value="domain_avail">Domain available + needs site ★★</option>
    <option value="warmest">Warmest leads (score ≥ 80) ★★★</option>
    <option value="has_email">✉ Has email on file</option>
    <option value="starred">⭐ Starred only</option>
    <option value="uncontacted">Not yet contacted</option>
  </select>

  <select id="sort-select" title="Sort">
    <option value="score">Sort: lead score ★</option>
    <option value="prospects">Sort: prospects first</option>
    <option value="name">Sort: name</option>
    <option value="zip">Sort: ZIP</option>
    <option value="has_website">Sort: has-website first</option>
  </select>

  <label style="color:var(--muted);font-size:12px;display:flex;align-items:center;gap:6px;cursor:pointer">
    <input type="checkbox" id="hide-chains" checked> hide chains
  </label>

  <a id="export-link" href="/outreach.csv" style="font-size:12px;padding:6px 10px;border:1px solid var(--border);border-radius:6px;color:var(--accent);">⬇ CSV</a>

  <div class="seg" id="view-toggle">
    <button data-view="grid" class="active">Grid</button>
    <button data-view="list">List</button>
    <button data-view="map">Map</button>
  </div>

  <div class="slider-group" id="grid-controls">
    <label for="grid-cols">Per row</label>
    <input type="range" min="3" max="12" value="5" id="grid-cols">
    <span class="val" id="grid-cols-val">5</span>
  </div>

  <input type="text" id="filter-q" placeholder="Filter by name…" style="width:180px">

  <div class="pager">
    <button id="prev">← Prev</button>
    <span id="pageinfo" style="margin:0 8px;font-variant-numeric:tabular-nums">0–0</span>
    <button id="next">Next →</button>
  </div>
</div>

<div id="results" class="grid"></div>
<div id="zip-heat"></div>

<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css" />
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css" />
<script src="https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js"></script>

<script>
(() => {
  const $ = (s) => document.querySelector(s);
  const results = $('#results');
  const gridColsEl = $('#grid-cols');
  const gridColsVal = $('#grid-cols-val');
  const gridControls = $('#grid-controls');
  const filterQ = $('#filter-q');
  const pageInfo = $('#pageinfo');
  const prevBtn = $('#prev');
  const nextBtn = $('#next');

  const PAGE_BY_VIEW = { grid: 100, list: 200, map: 500 };
  function pageSize() { return PAGE_BY_VIEW[state.view] || 100; }
  let state = {
    entity:    localStorage.getItem('pd.entity') || 'professionals',
    view:      localStorage.getItem('pd.view')   || 'grid',
    cols:      Number(localStorage.getItem('pd.cols') || 5),
    category:  localStorage.getItem('pd.category') || '',
    prospect:  localStorage.getItem('pd.prospect') || '',
    sort:      localStorage.getItem('pd.sort') || 'score',
    hideChains: localStorage.getItem('pd.hideChains') !== '0',
    offset:    0,
    q:         '',
    rows:      [],
    // Client-side column sort (applies to currently-loaded rows in list view).
    listSortBy:  localStorage.getItem('pd.listSortBy')  || '',
    listSortDir: localStorage.getItem('pd.listSortDir') || 'asc',
  };

  // Column definitions for renderList - key is the row property to sort on,
  // label is the header text, cell(r) produces the td contents.
  const COLS = {
    professionals: [
      { key:'full_name',                label:'Name',           cell: r => escapeHtml(r.full_name) },
      { key:'title',                    label:'Title',          cell: r => escapeHtml(r.title) },
      { key:'primary_specialty',        label:'Specialty',      cell: r => escapeHtml(r.primary_specialty) },
      { key:'license_number',           label:'License #',      cell: r => escapeHtml(r.license_number) },
      { key:'license_status',           label:'License Status', cell: r => escapeHtml(r.license_status) },
      { key:'npi_number',               label:'NPI',            cell: r => escapeHtml(r.npi_number) },
      { key:'email',                    label:'Email',          cell: r => r.email
          ? '<a class="email" href="mailto:' + escapeHtml(r.email) + '">' + escapeHtml(r.email) + '</a>'
          : '<span style="color:#444">—</span>' },
      { key:'medical_school',           label:'School',         cell: r => escapeHtml(r.medical_school) },
      { key:'source_confidence_score',  label:'Conf',           cell: r => escapeHtml(r.source_confidence_score) },
    ],
    organizations: [
      { key:'lead_score',        label:'Score', cell: r => {
          const sc = r.lead_score || 0;
          const c  = sc >= 80 ? '#10b981' : sc >= 50 ? '#facc15' : '#737373';
          return '<span style="color:' + c + ';font-weight:600">' + sc + '</span>';
        }},
      { key:'name',              label:'Name', cell: r => escapeHtml(r.name) + (r.is_chain ? ' <span style="color:#737373;font-size:11px">chain</span>' : '') },
      { key:'type',              label:'Type', cell: r => escapeHtml(r.type) },
      { key:'has_website',       label:'Prospect', cell: r => r.has_website
          ? '<span style="color:#a1a1b5">has site</span>'
          : '<span style="color:#facc15;font-weight:600">★ needs site</span>' },
      { key:'suggested_domain',  label:'Suggested .com', cell: r => r.domain_available
          ? '<span style="color:#10b981;font-weight:600">✓ ' + escapeHtml(r.suggested_domain) + '</span>'
          : (r.suggested_domain
              ? '<span style="color:#737373">' + escapeHtml(r.suggested_domain) + ' taken</span>'
              : '<span style="color:#444">—</span>') },
      { key:'address',           label:'Address', cell: r => escapeHtml(r.address) },
      { key:'city',              label:'City',    cell: r => escapeHtml(r.city) },
      { key:'zip',               label:'ZIP',     cell: r => escapeHtml(r.zip) },
      { key:'phone',             label:'Phone',   cell: r => escapeHtml(r.phone) },
      { key:'email',             label:'Email',   cell: r => r.email
          ? '<a class="email" href="mailto:' + escapeHtml(r.email) + '">' + escapeHtml(r.email) + '</a>'
          : '<span style="color:#444">—</span>' },
      { key:'website',           label:'Website', cell: r => r.website
          ? (() => { const u = safeUrl(r.website); return u ? '<a href="' + escapeHtml(u) + '" target="_blank" rel="noopener">' + escapeHtml(u.replace(new RegExp('^https?://'),'').slice(0,40)) + '</a>' : ''; })()
          : '' },
      { key:'contacted_at',      label:'Contacted', cell: r => r.contacted_at
          ? '<span style="color:#10b981">✓ ' + new Date(r.contacted_at).toLocaleDateString() + '</span>'
          : '' },
    ],
  };

  // Generic comparator that handles strings, numbers, booleans, dates, nulls.
  function cmpVal(a, b) {
    const an = a == null || a === '';
    const bn = b == null || b === '';
    if (an && bn) return 0;
    if (an) return 1;   // nulls last
    if (bn) return -1;
    if (typeof a === 'number' && typeof b === 'number') return a - b;
    if (typeof a === 'boolean' && typeof b === 'boolean') return (a===b)?0:(a?1:-1);
    // date-shaped strings sort lexicographically already, which is correct
    return String(a).localeCompare(String(b), undefined, { numeric:true, sensitivity:'base' });
  }

  function applyListSort(rows, key, dir) {
    if (!key) return rows;
    const sign = dir === 'desc' ? -1 : 1;
    const sorted = rows.slice().sort((a, b) => sign * cmpVal(a[key], b[key]));
    return sorted;
  }

  function buildOrgQS() {
    const p = new URLSearchParams();
    if (state.category) p.set('category', state.category);
    if (state.prospect === 'needs')       { p.set('has_website', 'false'); }
    else if (state.prospect === 'has')    { p.set('has_website', 'true'); }
    else if (state.prospect === 'phone')  { p.set('has_phone', 'true'); }
    else if (state.prospect === 'phone_needs') { p.set('has_website','false'); p.set('has_phone','true'); }
    else if (state.prospect === 'domain_avail') { p.set('has_website','false'); p.set('domain_available','true'); }
    else if (state.prospect === 'starred')      { p.set('starred','true'); }
    else if (state.prospect === 'uncontacted')  { p.set('contacted','false'); p.set('has_website','false'); }
    else if (state.prospect === 'warmest')      { p.set('min_score','80'); p.set('has_website','false'); }
    else if (state.prospect === 'has_email')    { p.set('has_email','true'); }
    else if (state.prospect === 'outreach')     { p.set('has_email','true'); p.set('contacted','false'); p.set('sort','score'); }
    if (state.sort) p.set('sort', state.sort);
    if (state.hideChains) p.set('is_chain', 'false');
    return p;
  }

  function applyView() {
    // Map view requires lat/lng — only orgs have those. If user is on
    // professionals + map, transparently fall back to grid before rendering.
    if (state.entity === 'professionals' && state.view === 'map') {
      state.view = 'grid';
      localStorage.setItem('pd.view', 'grid');
    }
    results.className = state.view;
    document.documentElement.style.setProperty('--cols', String(state.cols));
    gridColsVal.textContent = state.cols;
    gridControls.style.display = state.view === 'grid' ? '' : 'none';
    document.querySelectorAll('#view-toggle button').forEach(b => b.classList.toggle('active', b.dataset.view === state.view));
    document.querySelectorAll('#entity-toggle button').forEach(b => b.classList.toggle('active', b.dataset.entity === state.entity));
    // Hide Map button when no lat/lng exists for this entity (professionals).
    const mapBtn = document.querySelector('#view-toggle button[data-view="map"]');
    if (mapBtn) mapBtn.style.display = state.entity === 'professionals' ? 'none' : '';
    const catEl  = document.getElementById('category-select');
    const prosEl = document.getElementById('prospect-select');
    const sortEl = document.getElementById('sort-select');
    const expEl  = document.getElementById('export-link');
    const isOrgs = state.entity === 'organizations';
    catEl.style.display  = isOrgs ? '' : 'none';
    prosEl.style.display = isOrgs ? '' : 'none';
    sortEl.style.display = isOrgs ? '' : 'none';
    expEl.style.display  = isOrgs ? '' : 'none';
    catEl.value  = state.category;
    prosEl.value = state.prospect;
    sortEl.value = state.sort;
  }

  // Race-safety: a stale fetch must not overwrite a newer one's rows.
  let _loadReqId = 0;
  let _loadAborter = null;
  async function load() {
    const myId = ++_loadReqId;
    if (_loadAborter) { try { _loadAborter.abort(); } catch (_) {} }
    _loadAborter = new AbortController();
    const sig = _loadAborter.signal;

    const lim = pageSize();
    const params = new URLSearchParams({ limit: lim, offset: state.offset });
    if (state.entity === 'organizations') {
      for (const [k, v] of buildOrgQS()) params.set(k, v);
      const exp = document.getElementById('export-link');
      if (exp) {
        const expQS = buildOrgQS();
        expQS.set('limit', '5000');
        exp.href = '/outreach.csv?' + expQS.toString();
      }
    }
    let r;
    try {
      r = await fetch('/' + state.entity + '?' + params, { signal: sig }).then(r => r.json());
    } catch (e) {
      if (e.name === 'AbortError') return;   // a newer request superseded us — drop silently
      throw e;
    }
    if (myId !== _loadReqId) return;          // belt-and-suspenders: discard if a newer request started

    state.rows = (r.rows || []).filter(row => {
      if (!state.q) return true;
      const hay = state.entity === 'professionals'
        ? (row.full_name || '') + ' ' + (row.primary_specialty || '')
        : (row.name || '') + ' ' + (row.city || '');
      return hay.toLowerCase().includes(state.q.toLowerCase());
    });
    pageInfo.textContent = (state.offset + 1) + '–' + (state.offset + state.rows.length);
    prevBtn.disabled = state.offset === 0;
    nextBtn.disabled = state.rows.length < lim;
    render();
  }

  function escapeHtml(s) {
    return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
  }
  // Allowlist http(s) only — blocks javascript:/data:/vbscript: in any user-supplied href.
  function safeUrl(u) {
    const s = String(u == null ? '' : u).trim();
    if (!s || !/^https?:\\/\\//i.test(s)) return '';
    return s;
  }

  function renderGrid() {
    if (!state.rows.length) return '<div class="empty">No matches</div>';
    return state.rows.map(r => {
      if (state.entity === 'professionals') {
        return '<div class="item">'
          + '<div class="name">' + escapeHtml(r.full_name) + (r.title ? ' <span class="badge">' + escapeHtml(r.title) + '</span>' : '') + '</div>'
          + '<div class="meta">'
          + (r.primary_specialty ? escapeHtml(r.primary_specialty) + '<br>' : '')
          + (r.license_number ? 'Lic ' + escapeHtml(r.license_number) + ' · ' : '')
          + (r.npi_number ? 'NPI ' + escapeHtml(r.npi_number) : '')
          + '</div></div>';
      }
      const prospectBadge = r.has_website
        ? '<span class="badge" style="background:#1c2237;color:#a1a1b5">has site</span>'
        : '<span class="badge" style="background:#1c2237;color:#facc15">★ needs site</span>';
      const phoneBadge = r.has_phone ? '<span class="badge" style="background:#1c2237;color:#10b981">📞</span>' : '';
      const domainBadge = r.domain_available
        ? '<span class="badge" style="background:#10b981;color:#04130c;font-weight:600">✓ ' + escapeHtml(r.suggested_domain) + ' available</span>'
        : (r.suggested_domain && r.domain_available === false
            ? '<span class="badge" style="background:#1c2237;color:#737373">' + escapeHtml(r.suggested_domain) + ' taken</span>'
            : '');
      const phoneLink = r.phone
        ? '<a href="tel:' + escapeHtml(r.phone.replace(/[^+0-9]/g,'')) + '" style="color:var(--accent)">📞 ' + escapeHtml(r.phone) + '</a>'
        : '';
      const emailLink = r.email
        ? '<a href="mailto:' + escapeHtml(r.email) + '" style="color:var(--accent)">✉ ' + escapeHtml(r.email) + '</a>'
        : '';
      const mapLink = (r.lat && r.lng)
        ? '<a href="https://www.google.com/maps/search/?api=1&query=' + r.lat + ',' + r.lng + '" target="_blank" rel="noopener" style="color:var(--accent)">🗺 map</a>'
        : '';
      const regLink = r.domain_available
        ? '<a href="https://www.godaddy.com/domainsearch/find?domainToCheck=' + escapeHtml(r.suggested_domain) + '" target="_blank" rel="noopener" style="color:var(--accent)">register</a>'
        : '';
      const rowJSON = encodeURIComponent(JSON.stringify({
        id: r.id, name: r.name, type: r.type, phone: r.phone || '', address: r.address || '',
        city: r.city || '', zip: r.zip || '',
        suggested_domain: r.suggested_domain || '', domain_available: !!r.domain_available,
        category: state.category || ''
      }));
      const starBtn = '<button data-star="' + r.id + '" data-cur="' + (r.starred ? '1':'0') + '" style="background:none;border:0;cursor:pointer;font-size:14px;padding:0;color:' + (r.starred?'#facc15':'#444') + '" title="Star prospect">' + (r.starred?'⭐':'☆') + '</button>';
      const contactBtn = '<button data-touch="' + r.id + '" data-flag="' + (r.contacted_at ? 'uncontact':'contact') + '" style="background:none;border:0;cursor:pointer;font-size:12px;padding:0;color:' + (r.contacted_at?'#10b981':'var(--muted)') + '" title="Mark contacted">' + (r.contacted_at ? '✓ contacted' : 'mark contacted') + '</button>';
      const pitchBtn  = '<button data-pitch="' + rowJSON + '" style="background:none;border:0;cursor:pointer;font-size:12px;padding:0;color:var(--accent)" title="Open pitch helper">✉ pitch</button>';
      const copyBtn = '<button data-copy="' + rowJSON + '" style="background:none;border:0;color:var(--muted);cursor:pointer;font-size:12px;padding:0">📋 copy</button>';
      const cardBg = r.starred ? '#1a1f33' : 'var(--panel)';
      const score = r.lead_score || 0;
      const scoreColor = score >= 80 ? '#10b981' : score >= 50 ? '#facc15' : '#737373';
      const scoreBadge = '<span class="badge" style="background:' + scoreColor + ';color:#04130c;font-weight:600">' + score + '</span>';
      return '<div class="item" style="background:' + cardBg + '">'
        + '<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px">'
        +   '<div class="name">' + escapeHtml(r.name) + '</div>'
        +   '<div style="display:flex;gap:6px;align-items:center">' + scoreBadge + ' ' + starBtn + '</div>'
        + '</div>'
        + '<div style="margin:2px 0 6px">'
        + (r.type ? '<span class="badge">' + escapeHtml(r.type) + '</span> ' : '')
        + prospectBadge + ' ' + phoneBadge
        + (r.is_chain ? ' <span class="badge" style="background:#1c2237;color:#737373">chain</span>' : '')
        + (r.contacted_at ? ' <span class="badge" style="background:#10b981;color:#04130c">contacted</span>' : '')
        + '</div>'
        + (domainBadge ? '<div style="margin-bottom:6px">' + domainBadge + (regLink ? ' · ' + regLink : '') + '</div>' : '')
        + '<div class="meta">'
        + (r.address ? escapeHtml(r.address) + '<br>' : '')
        + escapeHtml([r.city, r.zip].filter(Boolean).join(', '))
        + (phoneLink ? '<br>' + phoneLink : '')
        + (emailLink ? '<br>' + emailLink : '')
        + (() => { const u = safeUrl(r.website); return u ? '<br><a href="' + escapeHtml(u) + '" target="_blank" rel="noopener">' + escapeHtml(u.replace(new RegExp('^https?://'),'').slice(0,40)) + '</a>' : ''; })()
        + '</div>'
        + '<div style="display:flex;gap:10px;margin-top:8px;font-size:12px;flex-wrap:wrap">'
        + (mapLink ? mapLink + ' · ' : '') + pitchBtn + ' · ' + contactBtn + ' · ' + copyBtn
        + '</div>'
        + '</div>';
    }).join('');
  }

  function renderList() {
    if (!state.rows.length) return '<div class="empty">No matches</div>';
    const cols = COLS[state.entity] || [];
    const sortKey = state.listSortBy;
    const sortDir = state.listSortDir;

    // Click handler is delegated below in setupListSortDelegate(); here we
    // emit data-sort attributes so the delegate can pick them up.
    // aria-sort + role=button + tabindex make the headers screen-reader-friendly
    // and keyboard-activatable (Enter/Space). Keyboard handler is added below.
    const head = '<tr>' + cols.map(c => {
      const isActive = sortKey === c.key;
      const ariaSort = isActive ? (sortDir === 'desc' ? 'descending' : 'ascending') : 'none';
      const arrow = isActive
        ? (sortDir === 'desc' ? ' <span aria-hidden="true" style="color:var(--accent)">▼</span>' : ' <span aria-hidden="true" style="color:var(--accent)">▲</span>')
        : ' <span aria-hidden="true" style="color:#3a3a52;font-size:10px">↕</span>';
      return '<th scope="col" role="columnheader" aria-sort="' + ariaSort + '" data-sort="' + c.key + '" tabindex="0" style="cursor:pointer;user-select:none">' + escapeHtml(c.label) + arrow + '</th>';
    }).join('') + '</tr>';

    const sortedRows = applyListSort(state.rows, sortKey, sortDir);
    const body = sortedRows.map(r => '<tr>' + cols.map(c => '<td>' + (c.cell(r) || '') + '</td>').join('') + '</tr>').join('');
    return '<table>' + head + body + '</table>';
  }

  let map = null;
  let mapLayer = null;
  async function renderMap() {
    results.innerHTML = '<div id="leaflet"></div>';
    const el = document.getElementById('leaflet');
    if (!window.L) {
      el.innerHTML = '<div class="map-empty">Leaflet failed to load — check your connection (the map JS is fetched from unpkg.com).</div>';
      return;
    }
    map = L.map(el, { preferCanvas: true }).setView([34.05, -118.25], 10);
    L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
      attribution: '© OpenStreetMap', maxZoom: 18,
    }).addTo(map);
    // Use clustering when the plugin is present, otherwise fall back to a flat
    // layer group (still works, just slower at high pin counts).
    mapLayer = (window.L.markerClusterGroup
      ? L.markerClusterGroup({ chunkedLoading: true, spiderfyOnMaxZoom: true, showCoverageOnHover: false, maxClusterRadius: 60 })
      : L.layerGroup());
    map.addLayer(mapLayer);

    // Loading badge.
    const loading = document.createElement('div');
    loading.className = 'map-loading';
    loading.textContent = 'Loading pins…';
    el.appendChild(loading);

    // Fetch all matching pins (not just the current page).
    const params = new URLSearchParams();
    if (state.entity === 'organizations') {
      for (const [k, v] of buildOrgQS()) params.set(k, v);
    }
    let pins = [];
    try {
      const r = await fetch('/map.json?' + params).then(r => r.json());
      pins = r.rows || [];
    } catch (_) { /* ignore — fall through to empty state */ }
    loading.remove();
    addPinsFromMapJson(pins);
  }
  function addPinsFromMapJson(rows) {
    if (!mapLayer) return;
    mapLayer.clearLayers();
    const prevEmpty = document.querySelector('#leaflet .map-empty');
    if (prevEmpty) prevEmpty.remove();

    const bounds = [];
    for (const r of rows) {
      if (!r.lat || !r.lng) continue;
      const needs = !r.has_website;
      const hasEmail = !!r.has_email;
      const color = needs ? '#facc15' : (hasEmail ? '#10b981' : '#737373');
      const dot = L.circleMarker([r.lat, r.lng], {
        radius: needs ? 6 : 4, color, weight: 1.5, fillColor: color,
        fillOpacity: needs ? 0.9 : (hasEmail ? 0.85 : 0.5),
      });
      const safe = (s) => String(s == null ? '' : s).replace(/[&<>]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
      const popup = '<div class="pname">' + safe(r.name) + '</div>'
        + '<div>' + safe(r.type) + ' · '
        + (needs ? '<span class="needs">★ needs site</span>' : '<span class="has">has site</span>')
        + (hasEmail ? ' · <span class="has">✉ email</span>' : '')
        + (r.contacted ? ' · <span style="color:#10b981">✓ contacted</span>' : '')
        + '</div>'
        + '<div style="margin-top:6px"><a href="/preview/' + r.id + '/all" target="_blank">↗ see 5 design directions</a></div>';
      dot.bindPopup(popup);
      mapLayer.addLayer ? mapLayer.addLayer(dot) : dot.addTo(mapLayer);
      bounds.push([r.lat, r.lng]);
    }
    if (bounds.length) {
      try { map.fitBounds(bounds, { padding: [40, 40] }); } catch (_) {}
    } else {
      const elx = document.getElementById('leaflet');
      if (elx) {
        const msg = document.createElement('div');
        msg.className = 'map-empty';
        msg.textContent = state.entity === 'professionals'
          ? 'Professionals have no map location — switch to Organizations to see pins.'
          : 'No map points match this filter — relax filters or wait for more geocoding.';
        elx.appendChild(msg);
      }
    }
  }
  // (legacy addPins(state.rows) was removed — renderMap now uses
  //  addPinsFromMapJson(rows-from-/map.json) for the full clustered set.)

  async function loadZipHeat() {
    const heatEl = document.getElementById('zip-heat');
    if (state.view !== 'map') { heatEl.innerHTML = ''; return; }
    const params = buildOrgQS();
    try {
      const r = await fetch('/stats/by-zip?' + params).then(r => r.json());
      const top = (r.rows || []).slice(0, 30);
      if (!top.length) { heatEl.innerHTML = ''; return; }
      heatEl.innerHTML = '<h3>Top ZIPs by prospects (no website) — click to filter</h3>'
        + top.map(z => '<span class="ziprow" data-zip="' + escapeHtml(z.zip) + '"><b>' + escapeHtml(z.prospects) + '</b> needs · <span>' + escapeHtml(z.total) + ' total</span> · ' + escapeHtml(z.zip) + '</span>').join('');
      heatEl.querySelectorAll('.ziprow').forEach(el => {
        el.addEventListener('click', () => {
          // Center the map on this ZIP's first marker (cheap: refilter on the loaded set)
          const zip = el.dataset.zip;
          const targets = state.rows.filter(r => r.zip === zip && r.lat && r.lng);
          if (map && targets.length) map.setView([targets[0].lat, targets[0].lng], 14);
        });
      });
    } catch (_) { /* ignore */ }
  }

  // Keyboard support for sortable headers — Enter/Space activates sort
  // (mirrors click handler). Required for a11y because we use <th> not <button>.
  function activateSort(th) {
    const key = th.getAttribute('data-sort');
    if (state.listSortBy === key) {
      state.listSortDir = state.listSortDir === 'asc' ? 'desc' : 'asc';
    } else {
      state.listSortBy = key;
      state.listSortDir = 'asc';
    }
    localStorage.setItem('pd.listSortBy', state.listSortBy);
    localStorage.setItem('pd.listSortDir', state.listSortDir);
    render();
  }
  results.addEventListener('keydown', (e) => {
    const th = e.target.closest && e.target.closest('th[data-sort]');
    if (!th) return;
    if (e.key === 'Enter' || e.key === ' ') {
      e.preventDefault();
      activateSort(th);
    }
  });

  // Delegated handlers for prospect-card actions.
  results.addEventListener('click', async (e) => {
    // Column-header sort in list view.
    const sortTh = e.target.closest('th[data-sort]');
    if (sortTh) {
      activateSort(sortTh);
      return;
    }
    // Copy contact card to clipboard.
    const copyBtn = e.target.closest('[data-copy]');
    if (copyBtn) {
      try {
        const data = JSON.parse(decodeURIComponent(copyBtn.getAttribute('data-copy') || '{}'));
        const text = [
          data.name,
          data.phone,
          [data.address, data.city, data.zip].filter(Boolean).join(' · '),
          data.suggested_domain ? 'Suggested: ' + data.suggested_domain : ''
        ].filter(Boolean).join('\\n');
        await navigator.clipboard.writeText(text);
        const orig = copyBtn.textContent;
        copyBtn.textContent = '✓ copied'; copyBtn.style.color = '#10b981';
        setTimeout(() => { copyBtn.textContent = orig; copyBtn.style.color = ''; }, 1200);
      } catch (_) {}
      return;
    }
    // Star toggle.
    const starBtn = e.target.closest('[data-star]');
    if (starBtn) {
      const id = starBtn.getAttribute('data-star');
      const cur = starBtn.getAttribute('data-cur') === '1';
      try {
        const r = await fetch('/organizations/' + id + '/touch', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ starred: !cur })
        }).then(r => r.json());
        starBtn.setAttribute('data-cur', r.starred ? '1':'0');
        starBtn.textContent = r.starred ? '⭐' : '☆';
        starBtn.style.color = r.starred ? '#facc15' : '#444';
        starBtn.closest('.item').style.background = r.starred ? '#1a1f33' : 'var(--panel)';
      } catch (_) {}
      return;
    }
    // Mark contacted toggle.
    const tBtn = e.target.closest('[data-touch]');
    if (tBtn) {
      const id = tBtn.getAttribute('data-touch');
      const flag = tBtn.getAttribute('data-flag');
      const want = flag === 'contact';
      try {
        const r = await fetch('/organizations/' + id + '/touch', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ contacted: want })
        }).then(r => r.json());
        tBtn.textContent = r.contacted_at ? '✓ contacted' : 'mark contacted';
        tBtn.style.color = r.contacted_at ? '#10b981' : 'var(--muted)';
        tBtn.setAttribute('data-flag', r.contacted_at ? 'uncontact' : 'contact');
      } catch (_) {}
      return;
    }
    // Pitch helper modal.
    const pBtn = e.target.closest('[data-pitch]');
    if (pBtn) {
      try {
        const data = JSON.parse(decodeURIComponent(pBtn.getAttribute('data-pitch') || '{}'));
        openPitchModal(data);
      } catch (_) {}
      return;
    }
  });

  function openPitchModal(d) {
    const back = document.createElement('div');
    back.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:1000;display:flex;align-items:center;justify-content:center;padding:24px';
    const card = document.createElement('div');
    card.style.cssText = 'background:var(--panel);border:1px solid var(--border);border-radius:14px;padding:22px;max-width:640px;width:100%;color:var(--fg);box-shadow:0 20px 60px rgba(0,0,0,0.6)';

    // Guard against XSS in subject/body — d.name and d.suggested_domain come
    // from DB rows and could contain </textarea>, quotes, or HTML chars.
    function safeWebUrl(u) {
      const s = String(u || '').trim();
      if (!/^https?:\/\//i.test(s)) return '';
      return s;
    }
    const safeName = String(d.name || '').replace(/[<>"]/g, '');
    const safeDomain = String(d.suggested_domain || '').replace(/[<>"]/g, '');
    const subject = 'A modern site for ' + safeName + (safeDomain && d.domain_available ? ' (' + safeDomain + ')' : '');
    const themeWord = ({
      cosmetology: 'salon-quality, beauty-forward',
      chiropractic: 'patient-friendly, appointment-driven',
      chinese_medicine: 'calm, modality-respectful',
      dental: 'trust-building, appointment-driven',
      optometry: 'modern eyewear-forward',
      fitness: 'energetic, class-schedule-driven',
      tattoo: 'portfolio-led, gallery-first',
      pets: 'warm, owner-friendly'
    })[d.category] || 'beautifully themed';

    const domainLine = safeDomain && d.domain_available
      ? 'I checked — ' + safeDomain + ' is still available.'
      : (safeDomain ? '(Domain idea: ' + safeDomain + ')' : '');

    // Public host for the preview link in the email body. When undefined we
    // fall back to the loopback host (only useful while Steve drives the
    // admin himself; switch via window.PD_PREVIEW_BASE before going live).
    const previewBase = (window.PD_PREVIEW_BASE || (window.location.origin)).replace(/\\/$/, '');
    const previewUrl  = previewBase + '/preview/' + d.id + '/all';

    const body =
      'Hi — I drafted five front-page concepts for ' + safeName + '.\\n\\n' +
      'You don\\'t have to chat. Just go take a look — five different design directions, side by side:\\n' +
      previewUrl + '\\n\\n' +
      'Everything on the page is live data — your real address, phone, and (if applicable) the providers you have on file. The looks are mine to revise. Pick the one that fits, reply with which one (A · modern, B · warm, C · premium, D · wellness, or E · editorial), and I\\'ll set up the real domain and put it live.\\n\\n' +
      'If none of them work, no harm done — close the tab.\\n\\n' +
      '— Steve\\n' +
      'steve@designerwallcoverings.com';

    card.style.maxWidth = '780px';
    card.innerHTML =
      '<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:14px">' +
        '<div><div style="font-size:18px;font-weight:600">' + escapeHtml(d.name) + '</div>' +
        '<div style="color:var(--muted);font-size:12px;margin-top:2px">' + escapeHtml(d.type || '') + (d.phone ? ' · ' + escapeHtml(d.phone) : '') + '</div></div>' +
        '<button id="pitch-close" style="background:none;border:0;color:var(--muted);font-size:20px;cursor:pointer">×</button>' +
      '</div>' +
      '<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-bottom:14px">' +
        '<div>' +
          '<div style="font-size:10px;text-transform:uppercase;letter-spacing:.6px;color:var(--muted);margin-bottom:4px">Their site today</div>' +
          ((() => { const u = safeUrl(d.website); return u
            ? '<iframe sandbox src="' + escapeHtml(u) + '" style="width:100%;aspect-ratio:5/4;border:1px solid var(--border);border-radius:6px;background:#fff" loading="lazy"></iframe>'
            : '<div style="aspect-ratio:5/4;border:1px dashed var(--border);border-radius:6px;display:flex;align-items:center;justify-content:center;color:var(--muted);font-size:12px;text-align:center;padding:12px">no current website</div>'; })()) +
        '</div>' +
        '<div>' +
          '<div style="font-size:10px;text-transform:uppercase;letter-spacing:.6px;color:var(--muted);margin-bottom:4px">Editorial revamp</div>' +
          '<iframe sandbox="allow-same-origin" src="/preview/' + d.id + '" style="width:100%;aspect-ratio:5/4;border:1px solid var(--border);border-radius:6px;background:#fff" loading="lazy"></iframe>' +
        '</div>' +
        '<div>' +
          '<div style="font-size:10px;text-transform:uppercase;letter-spacing:.6px;color:var(--accent);margin-bottom:4px">★ Bespoke mockup</div>' +
          '<a href="/mockups/' + d.id + '.html" target="_blank" rel="noopener" style="display:block">' +
            '<img src="/mockups/' + d.id + '.png" style="width:100%;aspect-ratio:5/4;border:1px solid var(--accent);border-radius:6px;background:#fff;object-fit:cover;object-position:top" loading="lazy" onerror="this.parentElement.innerHTML=&quot;<div style=\\&quot;aspect-ratio:5/4;border:1px dashed var(--border);border-radius:6px;display:flex;align-items:center;justify-content:center;color:var(--muted);font-size:11px;text-align:center;padding:12px\\&quot;>no mockup yet</div>&quot;">' +
          '</a>' +
        '</div>' +
      '</div>' +
      '<div style="display:flex;gap:10px;align-items:center;margin-bottom:12px;font-size:12px;flex-wrap:wrap">' +
        '<a href="/preview/' + d.id + '/all" target="_blank" rel="noopener" style="color:var(--accent);font-weight:600">★ see 5 design directions</a>' +
        '<a href="/mockups/' + d.id + '.html" target="_blank" rel="noopener" style="color:var(--muted)">bespoke mockup</a>' +
        '<details style="display:inline-block;margin:0;font-size:12px"><summary style="color:var(--muted);cursor:pointer;list-style:none;display:inline">individual variants ▾</summary>' +
          '<a href="/preview/' + d.id + '/a" target="_blank" rel="noopener" style="color:var(--muted);margin-left:10px">A · modern</a>' +
          '<a href="/preview/' + d.id + '/b" target="_blank" rel="noopener" style="color:var(--muted);margin-left:10px">B · warm</a>' +
          '<a href="/preview/' + d.id + '/c" target="_blank" rel="noopener" style="color:var(--muted);margin-left:10px">C · premium</a>' +
          '<a href="/preview/' + d.id + '/d" target="_blank" rel="noopener" style="color:var(--muted);margin-left:10px">D · wellness</a>' +
          '<a href="/preview/' + d.id + '/e" target="_blank" rel="noopener" style="color:var(--muted);margin-left:10px">E · brutalist</a>' +
        '</details>' +
        ((() => { const u = safeUrl(d.website); return u ? '<a href="' + escapeHtml(u) + '" target="_blank" rel="noopener" style="color:var(--muted)">their current site</a>' : ''; })()) +
      '</div>' +
      '<label style="font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted)">To</label>' +
      '<select id="pitch-to" style="width:100%;background:var(--bg);color:var(--fg);border:1px solid var(--border);border-radius:6px;padding:8px 10px;margin:4px 0 12px;font:13px system-ui"><option value="">— loading recipients —</option></select>' +
      '<label style="font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted)">Subject</label>' +
      '<input id="pitch-subject" type="text" style="width:100%;background:var(--bg);color:var(--fg);border:1px solid var(--border);border-radius:6px;padding:8px 10px;margin:4px 0 12px" value="' + subject.replace(/"/g,'&quot;') + '">' +
      '<label style="font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted)">Body</label>' +
      '<textarea id="pitch-body" style="width:100%;height:200px;background:var(--bg);color:var(--fg);border:1px solid var(--border);border-radius:6px;padding:10px;margin:4px 0 14px;font:13px/1.5 system-ui">' + body + '</textarea>' +
      '<div style="display:flex;gap:10px;flex-wrap:wrap">' +
        '<button id="pitch-copy" style="background:var(--accent);color:#04130c;border:0;padding:8px 14px;border-radius:6px;font-weight:600;cursor:pointer">Copy email</button>' +
        '<button id="pitch-mailto" style="background:var(--bg);color:var(--fg);border:1px solid var(--border);padding:8px 14px;border-radius:6px;cursor:pointer">Open in mail client</button>' +
        '<button id="pitch-mark" style="background:var(--bg);color:var(--fg);border:1px solid var(--border);padding:8px 14px;border-radius:6px;cursor:pointer;margin-left:auto">Mark contacted ✓</button>' +
      '</div>';
    back.appendChild(card);
    document.body.appendChild(card.parentElement === back ? back : back);

    // Populate recipient list from /organizations/:id/emails
    fetch('/organizations/' + d.id + '/emails')
      .then(r => r.json())
      .then(j => {
        const sel = document.getElementById('pitch-to');
        if (!sel) return;
        if (!j.rows || j.rows.length === 0) {
          sel.innerHTML = '<option value="">— no email known — paste manually below —</option>';
          sel.style.color = '#facc15';
        } else {
          sel.innerHTML = j.rows.map((e, i) => '<option' + (i===0?' selected':'') + ' value="' + escapeHtml(e.email) + '">' + escapeHtml(e.email) + (e.email_type ? ' · ' + escapeHtml(e.email_type) : '') + '</option>').join('');
        }
      })
      .catch(() => { const sel = document.getElementById('pitch-to'); if (sel) sel.innerHTML = '<option value="">— lookup failed —</option>'; });

    document.getElementById('pitch-close').onclick = () => back.remove();
    back.addEventListener('click', (ev) => { if (ev.target === back) back.remove(); });
    document.getElementById('pitch-copy').onclick = async () => {
      const to   = document.getElementById('pitch-to').value;
      const subj = document.getElementById('pitch-subject').value;
      const text = document.getElementById('pitch-body').value;
      const composed = (to ? 'To: ' + to + '\\n' : '') + 'Subject: ' + subj + '\\n\\n' + text;
      await navigator.clipboard.writeText(composed);
      const b = document.getElementById('pitch-copy');
      b.textContent = '✓ copied'; setTimeout(() => b.textContent = 'Copy email', 1200);
    };
    document.getElementById('pitch-mailto').onclick = () => {
      const to   = document.getElementById('pitch-to').value;
      const subj = document.getElementById('pitch-subject').value;
      const txt  = document.getElementById('pitch-body').value;
      window.location.href = 'mailto:' + encodeURIComponent(to) + '?subject=' + encodeURIComponent(subj) + '&body=' + encodeURIComponent(txt);
    };
    document.getElementById('pitch-mark').onclick = async () => {
      try {
        await fetch('/organizations/' + d.id + '/touch', {
          method: 'POST', headers: {'Content-Type':'application/json'},
          body: JSON.stringify({ contacted: true })
        });
        back.remove();
        load();
      } catch (_) {}
    };
  }

  function render() {
    if (state.view === 'map') {
      // Tear down any prior map instance.
      if (map) { try { map.remove(); } catch(_){} map = null; mapLayer = null; }
      renderMap();
      loadZipHeat();
    } else {
      if (map) { try { map.remove(); } catch(_){} map = null; mapLayer = null; }
      document.getElementById('zip-heat').innerHTML = '';
      results.innerHTML = state.view === 'grid' ? renderGrid() : renderList();
    }
  }

  // Events
  document.querySelectorAll('#view-toggle button').forEach(b => {
    b.addEventListener('click', () => {
      const prev = state.view;
      state.view = b.dataset.view;
      localStorage.setItem('pd.view', state.view);
      applyView();
      // PAGE size changes between views; refetch when needed.
      if (PAGE_BY_VIEW[prev] !== PAGE_BY_VIEW[state.view]) {
        state.offset = 0;
        load();
      } else {
        render();
      }
    });
  });
  document.querySelectorAll('#entity-toggle button').forEach(b => {
    b.addEventListener('click', () => {
      state.entity = b.dataset.entity;
      localStorage.setItem('pd.entity', state.entity);
      state.offset = 0;
      applyView();
      load();
    });
  });
  gridColsEl.addEventListener('input', () => {
    state.cols = Number(gridColsEl.value);
    localStorage.setItem('pd.cols', state.cols);
    document.documentElement.style.setProperty('--cols', String(state.cols));
    gridColsVal.textContent = state.cols;
  });
  filterQ.addEventListener('input', () => { state.q = filterQ.value; render(); });
  document.getElementById('category-select').addEventListener('change', (e) => {
    state.category = e.target.value;
    localStorage.setItem('pd.category', state.category);
    state.offset = 0;
    load();
  });
  document.getElementById('prospect-select').addEventListener('change', (e) => {
    state.prospect = e.target.value;
    localStorage.setItem('pd.prospect', state.prospect);
    state.offset = 0;
    load();
  });
  document.getElementById('sort-select').addEventListener('change', (e) => {
    state.sort = e.target.value;
    localStorage.setItem('pd.sort', state.sort);
    state.offset = 0;
    load();
  });
  document.getElementById('hide-chains').addEventListener('change', (e) => {
    state.hideChains = e.target.checked;
    localStorage.setItem('pd.hideChains', state.hideChains ? '1' : '0');
    state.offset = 0;
    load();
  });
  prevBtn.addEventListener('click', () => { state.offset = Math.max(0, state.offset - pageSize()); load(); });
  nextBtn.addEventListener('click', () => { state.offset += pageSize(); load(); });

  // Init
  gridColsEl.value = state.cols;
  applyView();
  load();
})();
</script>
</body></html>`;
}

app.listen(PORT, '0.0.0.0', () => {
  console.log(`[api] listening on http://0.0.0.0:${PORT} (loopback-admin gated)`);
});