← 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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 => ({'&':'&','<':'<','>':'>'}[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="<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:11px;text-align:center;padding:12px\\">no mockup yet</div>"">' +
'</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,'"') + '">' +
'<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)`);
});