← back to Lawyer Directory Builder
src/server/showcase.ts
608 lines
/**
* Public showcase — for each firm with a website + audit, show
* - the BEFORE screenshot of their current site
* - 4 MOCKUP variants we generated (3 deterministic + 1 LLM-bespoke)
* - SEO / marketing-health suggestions
* - a prominent MOCKUP DISCLAIMER banner
* - a per-firm "$499 EZ Upgrade" CTA prefilled with their info
*
* GET /showcase — index page (cities w/ counts)
* GET /showcase/:slug — full grid, one card per firm
*
* Slug map: 'beverly-hills' → city LIKE 'beverly hills' (zip 90210-90213 fallback)
* 'los-angeles' → city LIKE 'los angeles' OR neighborhood
* 'la-county' → all LA-County cities
*/
import express from 'express';
import { query } from '../db/pool.ts';
import { requireAdmin } from './auth.ts';
const router = express.Router();
// All /showcase routes are admin-gated. The mockup tiles include placeholder
// stats ($184M recovered, 9.8 rating, etc.) attached to real firm names —
// publishing those publicly creates Lanham Act / Cal Rule 7.1 / §3344
// exposure regardless of the MOCKUP banner. Use these pages only for 1:1
// sales outreach to the firm pictured.
router.use('/showcase', requireAdmin);
const LA_COUNTY = [
'los angeles','beverly hills','santa monica','pasadena','long beach','glendale',
'burbank','culver city','west hollywood','el segundo','torrance','hermosa beach',
'manhattan beach','sherman oaks','encino','studio city','van nuys','marina del rey',
'hollywood','north hollywood','inglewood','redondo beach','westwood','brentwood',
'century city','pacific palisades','malibu','calabasas','woodland hills','tarzana',
];
type SlugSpec = { label: string; cities?: string[]; zips?: string[] };
const SLUGS: Record<string, SlugSpec> = {
'beverly-hills': { label: 'Beverly Hills', cities: ['beverly hills'], zips: ['90210','90211','90212','90213'] },
'los-angeles': { label: 'Los Angeles', cities: ['los angeles'] },
'santa-monica': { label: 'Santa Monica', cities: ['santa monica'] },
'pasadena': { label: 'Pasadena', cities: ['pasadena'] },
'la-county': { label: 'Los Angeles County', cities: LA_COUNTY },
};
function esc(s: any): string {
if (s === null || s === undefined) return '';
return String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]!));
}
function shellHead(title: string): string {
return `<!doctype html>
<html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>${esc(title)} · Counsel & Bar</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,500;1,300;1,400&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>
:root{--noir:#0a0a0c;--noir-rise:#131316;--noir-deep:#050507;--rule:#2a2724;--rule-faint:#1a1815;--ink:#f4f1ea;--ink-soft:#d8d2c5;--ink-mute:#8b857a;--metal:#b89968;--metal-glow:#d4b683;--metal-deep:#8a7044;--good:#34d399;--warn:#d4a04a;--bad:#d87a7a;--serif:"Cormorant Garamond",Georgia,serif;--sans:"Inter",-apple-system,system-ui,sans-serif}
*{box-sizing:border-box}html{-webkit-font-smoothing:antialiased}
body{margin:0;background:var(--noir);color:var(--ink);font-family:var(--sans);font-weight:300;font-size:14px;line-height:1.55}
::selection{background:var(--metal);color:var(--noir)}
a{color:var(--metal);text-decoration:none}a:hover{color:var(--metal-glow)}
header.brand{padding:22px 48px;display:flex;align-items:center;gap:18px;justify-content:space-between;border-bottom:1px solid var(--rule);background:var(--noir-deep);position:sticky;top:0;z-index:50}
header.brand h1{margin:0;font-family:var(--serif);font-size:22px;font-weight:400}
header.brand h1 a{color:var(--ink)}
header.brand h1 .amp{color:var(--metal);font-style:italic;padding:0 4px}
.topnav{font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--ink-mute);display:flex;gap:24px}
.topnav a{color:var(--ink-mute)}.topnav a:hover{color:var(--ink)}
main{max-width:1480px;margin:0 auto;padding:48px 32px}
.eyebrow{font-size:10px;letter-spacing:.3em;text-transform:uppercase;color:var(--metal);font-weight:500;margin:0 0 14px}
h1.display{font-family:var(--serif);font-weight:300;font-size:clamp(34px,5vw,54px);line-height:1.05;letter-spacing:-.02em;margin:0 0 12px;color:var(--ink)}
h1.display em{font-style:italic;color:var(--metal);font-weight:400}
.lede{font-size:16px;color:var(--ink-soft);max-width:62ch;margin:0 0 18px;font-weight:300}
.banner{padding:14px 22px;background:#3a2c10;border:1px solid #6b5320;color:#e8c98c;font-size:11px;font-weight:600;letter-spacing:.16em;text-transform:uppercase;margin:18px 0 32px;text-align:center;line-height:1.5}
.banner b{color:#fff}
.hero-stats{display:flex;gap:48px;margin:24px 0 36px;border-top:1px solid var(--rule);border-bottom:1px solid var(--rule);padding:24px 0}
.hero-stat{font-family:var(--serif)}
.hero-stat .num{font-size:34px;color:var(--metal);font-weight:300;font-variant-numeric:tabular-nums;line-height:1}
.hero-stat .lbl{font-size:10px;letter-spacing:.2em;text-transform:uppercase;color:var(--ink-mute);font-family:var(--sans);font-weight:500;margin-top:6px}
.cards{display:grid;grid-template-columns:1fr;gap:48px;margin-top:32px}
.firm-card{background:var(--noir-rise);border:1px solid var(--rule);overflow:hidden}
.firm-head{padding:24px 28px;display:flex;justify-content:space-between;align-items:flex-start;gap:24px;border-bottom:1px solid var(--rule);background:var(--noir-deep)}
.firm-name{font-family:var(--serif);font-weight:400;font-size:24px;line-height:1.2;color:var(--ink)}
.firm-meta{color:var(--ink-mute);font-size:12px;margin-top:4px;letter-spacing:.04em}
.firm-meta a{color:var(--ink-mute)}.firm-meta a:hover{color:var(--metal)}
.firm-cta{flex-shrink:0;text-align:right}
.firm-cta .price{font-family:var(--serif);font-size:32px;color:var(--metal);font-weight:300;line-height:1}
.firm-cta .lbl{font-size:9px;color:var(--ink-mute);letter-spacing:.18em;text-transform:uppercase;margin-top:4px}
.firm-cta a.upgrade{display:inline-block;margin-top:12px;background:var(--metal);color:var(--noir);padding:11px 22px;font-size:11px;letter-spacing:.18em;text-transform:uppercase;font-weight:500}
.firm-cta a.upgrade:hover{background:var(--metal-glow);color:var(--noir)}
.gallery{display:grid;grid-template-columns:repeat(5,1fr);gap:1px;background:var(--rule);border-bottom:1px solid var(--rule)}
.tile{background:var(--noir-rise);position:relative;display:flex;flex-direction:column}
.tile .tag{position:absolute;top:10px;left:10px;font-size:9px;letter-spacing:.18em;text-transform:uppercase;color:#fff;background:rgba(0,0,0,.7);padding:5px 10px;border:1px solid rgba(184,153,104,.35);font-weight:600}
.tile.before .tag{color:#d87a7a;border-color:rgba(216,122,122,.45)}
.tile.bespoke .tag{color:var(--metal-glow);border-color:rgba(212,182,131,.55);background:rgba(40,28,12,.85)}
.shot{display:block;width:100%;aspect-ratio:1280/800;background:#000 center/cover no-repeat;cursor:zoom-in;border:0}
.shot.empty{display:flex;align-items:center;justify-content:center;font-size:11px;color:var(--ink-mute);font-style:italic;letter-spacing:.04em;text-align:center;padding:18px;text-transform:uppercase;letter-spacing:.16em;background:repeating-linear-gradient(45deg,#101013,#101013 14px,#0a0a0c 14px,#0a0a0c 28px)}
.tile .label{padding:10px 12px;font-size:10px;color:var(--ink-mute);letter-spacing:.14em;text-transform:uppercase;background:var(--noir-deep);border-top:1px solid var(--rule-faint);font-weight:500;text-align:center}
.tile.bespoke .label{color:var(--metal-glow)}
.suggest{padding:24px 28px;border-top:1px solid var(--rule);background:#0d0d0f}
.suggest h4{font-family:var(--serif);font-style:italic;font-weight:400;color:var(--metal);margin:0 0 14px;font-size:18px}
.suggest ul{margin:0;padding:0;list-style:none;display:grid;grid-template-columns:repeat(2,1fr);gap:8px 24px}
.suggest li{padding-left:20px;position:relative;font-size:13px;color:var(--ink-soft);line-height:1.55}
.suggest li:before{content:"→";position:absolute;left:0;color:var(--metal)}
.score-row{display:flex;align-items:baseline;gap:14px;padding:14px 28px 0}
.score{font-family:var(--serif);font-variant-numeric:tabular-nums;font-weight:300;font-size:38px;line-height:1;min-width:54px}
.score.high{color:var(--good)}.score.mid{color:var(--warn)}.score.low{color:var(--bad)}
.score-lbl{color:var(--ink-mute);font-size:9px;letter-spacing:.22em;text-transform:uppercase;line-height:1.3}
.lightbox{position:fixed;inset:0;background:rgba(5,5,7,.96);display:none;align-items:center;justify-content:center;z-index:200;padding:32px;cursor:zoom-out}
.lightbox.show{display:flex}
.lightbox img{max-width:96vw;max-height:92vh;border:1px solid var(--rule);object-fit:contain}
.empty-state{padding:80px 28px;text-align:center;color:var(--ink-mute);font-style:italic}
@media (max-width:1100px){.gallery{grid-template-columns:repeat(2,1fr)}.suggest ul{grid-template-columns:1fr}}
@media (max-width:600px){.firm-head{flex-direction:column}.gallery{grid-template-columns:1fr}}
</style></head><body>
<header class="brand">
<h1><a href="/">Counsel <span class="amp">&</span> Bar</a></h1>
<nav class="topnav">
<a href="/find-a-lawyer">Find counsel</a>
<a href="/directory">Directory</a>
<a href="/data">Data</a>
<a href="/showcase">Showcase</a>
<a href="/login">Sign in</a>
</nav>
</header>
<main>`;
}
const FOOT = `</main>
<div class="lightbox" id="lightbox" onclick="this.classList.remove('show')"><img id="lightbox-img" alt=""></div>
<script>
function openLightbox(src){if(!src)return;document.getElementById('lightbox-img').src=src;document.getElementById('lightbox').classList.add('show')}
</script>
<footer style="padding:36px 48px;border-top:1px solid var(--rule);text-align:center;color:var(--ink-mute);font-size:11px;letter-spacing:.16em;text-transform:uppercase">
Counsel & Bar · Mockups are concept designs only · All firm details must be verified before publication
</footer>
</body></html>`;
function scoreClass(s: number | null): string {
if (s == null) return 'low';
if (s >= 70) return 'high';
if (s >= 45) return 'mid';
return 'low';
}
// ─── /showcase — index ──────────────────────────────────────────────────────
router.get('/showcase', async (_req, res) => {
const counts = await query<{ slug: string; n: string }>(`
SELECT 'beverly-hills' AS slug, COUNT(*)::text AS n FROM organizations
WHERE type='law_firm' AND website IS NOT NULL
AND (LOWER(city)='beverly hills' OR zip IN ('90210','90211','90212','90213'))
UNION ALL SELECT 'los-angeles', COUNT(*)::text FROM organizations
WHERE type='law_firm' AND website IS NOT NULL AND LOWER(city)='los angeles'
UNION ALL SELECT 'santa-monica', COUNT(*)::text FROM organizations
WHERE type='law_firm' AND website IS NOT NULL AND LOWER(city)='santa monica'
UNION ALL SELECT 'la-county', COUNT(*)::text FROM organizations
WHERE type='law_firm' AND website IS NOT NULL
AND LOWER(city) = ANY($1::text[])
`, [LA_COUNTY]);
const cm: Record<string, number> = {};
for (const r of counts.rows) cm[r.slug] = parseInt(r.n, 10);
const cards = Object.entries(SLUGS).map(([slug, spec]) => `
<a href="/showcase/${slug}" style="display:block;background:var(--noir-rise);border:1px solid var(--rule);padding:36px 32px;text-decoration:none;color:var(--ink);transition:border-color 200ms ease">
<div class="eyebrow" style="color:var(--metal)">Showcase</div>
<h2 style="font-family:var(--serif);font-size:32px;font-weight:300;margin:0 0 8px;letter-spacing:-.01em">${esc(spec.label)} <em style="color:var(--metal);font-style:italic">→</em></h2>
<p style="color:var(--ink-mute);font-size:13px;margin:0">${(cm[slug] ?? 0).toLocaleString()} firms</p>
</a>
`).join('');
res.send(shellHead('Showcase') + `
<p class="eyebrow">Before · After · Concept</p>
<h1 class="display">Every firm gets a <em>second look.</em></h1>
<p class="lede">For each California-licensed firm with a public website, we run a marketing-health audit and generate four concept redesigns. Mockups only — verify all firm details before publication.</p>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:1px;background:var(--rule);margin-top:32px">${cards}</div>
` + FOOT);
});
// ─── /showcase/:slug ────────────────────────────────────────────────────────
router.get('/showcase/:slug', async (req, res) => {
const slug = String(req.params.slug || '').toLowerCase();
const spec = SLUGS[slug];
if (!spec) return res.redirect('/showcase');
// Build firm filter
const where: string[] = [`o.type='law_firm'`, `o.website IS NOT NULL`];
const params: any[] = [];
if (spec.cities) {
params.push(spec.cities);
where.push(`(LOWER(o.city) = ANY($${params.length}::text[]) OR LOWER(o.neighborhood) = ANY($${params.length}::text[]))`);
}
if (spec.zips) {
params.push(spec.zips);
where.push(`o.zip = ANY($${params.length}::text[])`);
}
// Combine cities OR zips with explicit OR (fold both branches into one parenthesized clause).
const cityOrZipClauses = where.slice(2);
const cityOrZip = cityOrZipClauses.length > 1 ? `(${cityOrZipClauses.join(' OR ')})` : cityOrZipClauses[0];
const finalWhere = [where[0], where[1], cityOrZip].filter(Boolean).join(' AND ');
const r = await query<{
id: number; name: string; address: string | null; city: string | null;
zip: string | null; phone: string | null; website: string | null;
audit_id: number | null; screenshot_path: string | null;
marketing_score: number | null; suggestions: string[] | null;
final_url: string | null;
mocks: any;
}>(`
SELECT
o.id, o.name, o.address, o.city, o.zip, o.phone, o.website,
a.id AS audit_id,
a.screenshot_path,
a.marketing_score,
a.suggestions,
a.final_url,
COALESCE(
json_agg(json_build_object(
'variant', m.variant, 'label', m.template_label,
'shot', m.screenshot_path, 'html', m.html_path
) ORDER BY m.variant)
FILTER (WHERE m.id IS NOT NULL), '[]'::json
) AS mocks
FROM organizations o
LEFT JOIN site_audits a ON a.organization_id = o.id AND a.status_code BETWEEN 200 AND 399
LEFT JOIN site_mockups m ON m.organization_id = o.id
WHERE ${finalWhere}
GROUP BY o.id, a.id
ORDER BY (a.marketing_score IS NULL), a.marketing_score ASC, o.name ASC
LIMIT 200
`, params);
const totals = await query<{ k: string; v: string }>(`
WITH firms AS (
SELECT o.id FROM organizations o
WHERE ${finalWhere}
)
SELECT 'firms' AS k, COUNT(*)::text AS v FROM firms
UNION ALL SELECT 'audited', COUNT(DISTINCT a.organization_id)::text FROM site_audits a JOIN firms f ON f.id = a.organization_id WHERE a.status_code BETWEEN 200 AND 399
UNION ALL SELECT 'mocked', COUNT(DISTINCT m.organization_id)::text FROM site_mockups m JOIN firms f ON f.id = m.organization_id
UNION ALL SELECT 'avg_score', COALESCE(ROUND(AVG(marketing_score)::numeric,0)::text,'—') FROM site_audits a JOIN firms f ON f.id = a.organization_id WHERE marketing_score IS NOT NULL
`, params);
const tm: Record<string, string> = {}; for (const row of totals.rows) tm[row.k] = row.v;
// Build cards (simple chunked HTML)
const cards = r.rows.length === 0
? `<div class="empty-state">No firms match this scope yet — run the audit + mockup pipeline first.</div>`
: r.rows.map(f => {
const sugs = (f.suggestions || []).slice(0, 6).map(s => `<li>${esc(s)}</li>`).join('') || '<li class="muted">No suggestions on file — run audit.</li>';
const mocks: any[] = Array.isArray(f.mocks) ? f.mocks : [];
const byVariant: Record<string, any> = {};
for (const m of mocks) byVariant[m.variant] = m;
const tile = (key: 'a'|'b'|'c'|'x', label: string, classMod = '') => {
const m = byVariant[key];
if (!m) return `<div class="tile ${classMod}"><span class="tag">${esc(label)}</span><div class="shot empty">Pending render</div><div class="label">${esc(label)}</div></div>`;
return `<div class="tile ${classMod}">
<span class="tag">${esc(label)}</span>
<a class="shot" style="background-image:url('${esc(m.shot)}')" onclick="openLightbox('${esc(m.shot)}');return false" href="${esc(m.html)}" target="_blank" rel="noopener" title="Open mockup HTML in new tab"></a>
<div class="label">${esc(m.label || label)}</div>
</div>`;
};
const beforeShot = f.screenshot_path
? `<a class="shot" style="background-image:url('${esc(f.screenshot_path)}')" onclick="openLightbox('${esc(f.screenshot_path)}');return false" href="${esc(f.final_url || f.website || '#')}" target="_blank" rel="noopener" title="Open current site"></a>`
: `<div class="shot empty">No audit yet</div>`;
const upgradePrefill = encodeURIComponent(JSON.stringify({
firm_name: f.name, website: f.website, full_name: '', email: '', phone: f.phone || '', bar_number: '',
}));
const score = f.marketing_score;
return `<div class="firm-card" id="firm-${f.id}">
<div class="firm-head">
<div>
<div class="firm-name">${esc(f.name)}</div>
<div class="firm-meta">${esc([f.address, f.city, f.zip].filter(Boolean).join(' · ') || '—')} ${f.website ? `· <a href="${esc(f.website)}" target="_blank" rel="noopener">${esc(f.website.replace(/^https?:\/\//,'').slice(0, 50))}</a>` : ''} ${f.phone ? `· ${esc(f.phone)}` : ''}</div>
</div>
<div class="firm-cta">
<div class="price">$499</div>
<div class="lbl">EZ upgrade · 7-day delivery</div>
<a class="upgrade" href="/upgrade?prefill=${upgradePrefill}">Upgrade this firm →</a>
</div>
</div>
${score != null ? `<div class="score-row"><div class="score ${scoreClass(score)}">${score}</div><div class="score-lbl">marketing<br>health<br>score</div></div>` : ''}
<div class="gallery">
<div class="tile before"><span class="tag">Before · live</span>${beforeShot}<div class="label">Current site</div></div>
${tile('a', 'Editorial Noir')}
${tile('b', 'Clean Modern')}
${tile('c', 'Bold Confident')}
${tile('x', 'AI Bespoke', 'bespoke')}
</div>
<div class="suggest"><h4>Suggested SEO + marketing fixes</h4><ul>${sugs}</ul></div>
</div>`;
}).join('');
res.send(shellHead(`Showcase · ${spec.label}`) + `
<p class="eyebrow">Showcase · ${esc(spec.label)}</p>
<h1 class="display">Before. After. <em>Bespoke.</em></h1>
<p class="lede">Every firm in ${esc(spec.label)} with a live website. Below: a screenshot of their current homepage, four concept redesigns built from a marketing-health audit, and an upgrade path that ships in seven days.</p>
<div class="banner"><b>Mockup designs.</b> The four "after" tiles below each card are concept renderings only — firm name, address, phone, and credentials are placeholders. <b>All firm details must be verified before publication.</b></div>
<div class="hero-stats">
<div class="hero-stat"><div class="num">${parseInt(tm.firms || '0', 10).toLocaleString()}</div><div class="lbl">Firms</div></div>
<div class="hero-stat"><div class="num">${parseInt(tm.audited || '0', 10).toLocaleString()}</div><div class="lbl">Audited</div></div>
<div class="hero-stat"><div class="num">${parseInt(tm.mocked || '0', 10).toLocaleString()}</div><div class="lbl">With mockups</div></div>
<div class="hero-stat"><div class="num">${esc(tm.avg_score || '—')}</div><div class="lbl">Avg. health score</div></div>
</div>
<div class="cards">${cards}</div>
` + FOOT);
});
// ─── /mockups — gallery of every generated mockup (every variant for every firm) ─
router.get('/mockups', async (req, res) => {
const variant = String(req.query.variant || '').slice(0, 8);
const limit = Math.min(parseInt((req.query.limit as string) || '500', 10), 5000);
const where: string[] = [];
const params: any[] = [];
// Filter to a specific variant if requested; ?show=all to include legacy a/b/c/x;
// default hides legacy variants and only shows the new unique x1/x2/x3.
const showAll = String(req.query.show || '') === 'all';
if (variant && /^(x[123]|[abcx])$/.test(variant)) { params.push(variant); where.push(`m.variant = $${params.length}`); }
else if (!showAll) { where.push(`m.variant IN ('x1','x2','x3')`); }
params.push(limit);
const r = await query<{ id: number; org_id: number; firm_name: string; variant: string; label: string; shot: string; html: string; gen_ms: number | null; llm_model: string | null; generated_at: string }>(`
SELECT m.id, m.organization_id AS org_id, o.name AS firm_name, m.variant, m.template_label AS label,
m.screenshot_path AS shot, m.html_path AS html, m.gen_ms, m.llm_model, m.generated_at
FROM site_mockups m JOIN organizations o ON o.id = m.organization_id
${where.length ? 'WHERE ' + where.join(' AND ') : ''}
ORDER BY m.generated_at DESC
LIMIT $${params.length}
`, params);
const counts = await query<{ variant: string; n: number }>(`
SELECT variant, COUNT(*)::int AS n FROM site_mockups GROUP BY variant ORDER BY variant
`);
const total = await query<{ n: number }>(`SELECT COUNT(*)::int AS n FROM site_mockups`);
const byVariant = Object.fromEntries(counts.rows.map(c => [c.variant, c.n]));
const esc = (s: any) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]!));
const cards = r.rows.map(m => `
<div class="m">
<a href="${esc(m.html)}" target="_blank"><img src="${esc(m.shot)}" alt="${esc(m.firm_name)} ${esc(m.variant)}" loading="lazy"></a>
<div class="cap">
<div class="firm"><a href="/p/${m.org_id}" target="_blank">${esc(m.firm_name)}</a></div>
<div class="meta">
<span class="v v-${esc(m.variant)}">${esc(m.variant)}</span>
${esc(m.label)} ${m.gen_ms ? `· ${(m.gen_ms/1000).toFixed(1)}s` : ''} ${m.llm_model ? `· ${esc(m.llm_model)}` : ''}
</div>
</div>
</div>`).join('');
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send(`<!doctype html><html><head><meta charset="utf-8"><title>Mockups · ${total.rows[0].n}</title>
<style>body{font:13px/1.5 -apple-system,system-ui,sans-serif;background:#0a0a0c;color:#f4f1ea;margin:0;padding:24px}
h1{font-family:Cormorant Garamond,Georgia,serif;font-weight:400;margin:0 0 6px}
.bar{margin-bottom:18px;color:#8b857a;font-size:12px}
.bar a{color:#b89968;margin-right:14px;text-decoration:none}
.bar a.active{color:#f4f1ea;border-bottom:1px solid #b89968}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:14px}
.m{background:#131316;border:1px solid #2a2724;border-radius:6px;overflow:hidden}
.m img{width:100%;height:auto;display:block;background:#000;aspect-ratio:1280/800;object-fit:cover}
.m .cap{padding:10px 12px}
.firm a{color:#f4f1ea;text-decoration:none;font-weight:500}
.firm a:hover{color:#b89968}
.meta{color:#8b857a;font-size:11px;margin-top:3px}
.v{display:inline-block;padding:1px 6px;border-radius:3px;background:#1c2237;font-weight:600;margin-right:4px}
.v-a{color:#60a5fa}.v-b{color:#10b981}.v-c{color:#facc15}.v-x{background:#b89968;color:#0a0a0c}
</style></head><body>
<h1>Mockups · <b style="color:#b89968">${total.rows[0].n}</b></h1>
<div class="bar">
<a href="/mockups" class="${!variant && !showAll ? 'active' : ''}">Unique only (${(byVariant['x1']||0)+(byVariant['x2']||0)+(byVariant['x3']||0)})</a>
${['x1','x2','x3'].map(v => `<a href="/mockups?variant=${v}" class="${variant===v?'active':''}">${v.toUpperCase()} ${byVariant[v]||0}</a>`).join('')}
<a href="/mockups?show=all" class="${showAll && !variant ? 'active' : ''}">All ${total.rows[0].n}</a>
${['a','b','c','x'].map(v => `<a href="/mockups?variant=${v}" class="${variant===v?'active':''}">${v.toUpperCase()} ${byVariant[v]||0}</a>`).join('')}
<span style="float:right">showing ${r.rowCount} most recent</span>
</div>
<div class="grid">${cards || '<p style="color:#8b857a">No mockups yet.</p>'}</div>
</body></html>`);
});
// ─── /p/:firm_id — per-firm pitch landing page ────────────────────────────
// Rendered for outbound emails — recipient lands here from "see for yourself"
// CTA in hello@agentabrams.com pitch. Shows current site screenshot + best
// generated mockup variant + minimal contact form back to hello@agentabrams.
router.get('/p/:firm_id', async (req, res) => {
const firmId = parseInt(req.params.firm_id, 10);
if (!Number.isFinite(firmId)) return res.status(400).send('bad id');
// Track pitch view (don't block render on failure)
query(`
INSERT INTO pitch_views (organization_id, visitor_ip, visitor_ua, referrer, utm_source)
VALUES ($1, $2, $3, $4, $5)
`, [
firmId,
String(req.headers['x-forwarded-for'] || req.socket.remoteAddress || '').slice(0, 64),
String(req.headers['user-agent'] || '').slice(0, 400),
String(req.headers['referer'] || req.headers['referrer'] || '').slice(0, 400) || null,
String(req.query.utm_source || '').slice(0, 80) || null,
]).catch(() => {});
const r = await query<{
id: number; name: string; address: string | null; city: string | null;
zip: string | null; phone: string | null; website: string | null;
screenshot_path: string | null; marketing_score: number | null;
suggestions: string[] | null;
mocks: Array<{ variant: string; label: string; shot: string; html: string }> | null;
}>(`
SELECT
o.id, o.name, o.address, o.city, o.zip, o.phone, o.website,
a.screenshot_path, a.marketing_score, a.suggestions,
COALESCE(
json_agg(json_build_object(
'variant', m.variant, 'label', m.template_label,
'shot', m.screenshot_path, 'html', m.html_path
) ORDER BY (CASE m.variant
WHEN 'x1' THEN 0 WHEN 'x2' THEN 1 WHEN 'x3' THEN 2
WHEN 'x' THEN 3 WHEN 'a' THEN 4 WHEN 'b' THEN 5 ELSE 6 END))
FILTER (WHERE m.id IS NOT NULL AND m.variant IN ('x1','x2','x3','x')), '[]'::json
) AS mocks
FROM organizations o
LEFT JOIN site_audits a
ON a.organization_id = o.id AND a.status_code BETWEEN 200 AND 399
LEFT JOIN site_mockups m ON m.organization_id = o.id
WHERE o.id = $1
GROUP BY o.id, a.id
ORDER BY a.audited_at DESC NULLS LAST
LIMIT 1
`, [firmId]);
if (!r.rowCount) return res.status(404).send('firm not found');
const f = r.rows[0];
const mocks = (f.mocks || []).filter(m => m && m.shot);
const best = mocks[0]; // 'x' (LLM bespoke) preferred, then a, b, c
const score = f.marketing_score == null ? null : Number(f.marketing_score);
const scoreColor = score == null ? '#8b857a' : score >= 70 ? '#10b981' : score >= 50 ? '#facc15' : '#ef4444';
const scoreLabel = score == null ? 'No audit yet'
: score >= 70 ? 'Strong site' : score >= 50 ? 'Mid' : 'Underperforming';
const esc = (s: any) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]!));
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send(`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>For ${esc(f.name)} — a website refresh</title>
<meta name="robots" content="noindex">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;1,400&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--bg:#0a0a0c; --panel:#131316; --rule:#2a2724; --ink:#f4f1ea;
--ink-soft:#d8d2c5; --ink-mute:#8b857a; --metal:#b89968; --metal-glow:#d4b683;
--serif:"Cormorant Garamond","Georgia",serif;
--sans:"Inter",-apple-system,BlinkMacSystemFont,system-ui,sans-serif;
}
*,*::before,*::after { box-sizing:border-box; }
body { margin:0; background:var(--bg); color:var(--ink); font:300 16px/1.55 var(--sans); -webkit-font-smoothing:antialiased; }
.wrap { max-width:1240px; margin:0 auto; padding:48px 32px 96px; }
.eyebrow { font-size:11px; letter-spacing:.22em; text-transform:uppercase; color:var(--ink-mute); margin-bottom:14px; }
.eyebrow b { color:var(--metal); font-weight:500; }
h1 { font-family:var(--serif); font-weight:400; font-size:54px; line-height:1.05; margin:0 0 18px; letter-spacing:-.01em; }
h1 i { color:var(--metal); font-style:italic; }
.lede { font-size:18px; color:var(--ink-soft); max-width:740px; margin-bottom:8px; line-height:1.55; }
.firm-meta { color:var(--ink-mute); font-size:13px; margin-bottom:36px; }
.firm-meta b { color:var(--ink-soft); font-weight:400; }
.grid { display:grid; grid-template-columns: 1fr 1fr; gap:28px; margin-top:24px; }
@media (max-width:880px) { .grid { grid-template-columns: 1fr; } h1 { font-size:38px; } }
.panel { background:var(--panel); border:1px solid var(--rule); border-radius:6px; overflow:hidden; }
.panel .label { padding:14px 18px; border-bottom:1px solid var(--rule); display:flex; justify-content:space-between; align-items:center; font-size:11px; letter-spacing:.18em; text-transform:uppercase; color:var(--ink-mute); }
.panel .label .pill { padding:3px 10px; border-radius:3px; font-weight:600; letter-spacing:.05em; font-family:var(--sans); }
.panel img { display:block; width:100%; height:auto; background:#000; }
.panel .empty { padding:120px 24px; text-align:center; color:var(--ink-mute); font-style:italic; }
.cta { margin-top:48px; padding:32px 40px; background:var(--panel); border:1px solid var(--rule); border-left:3px solid var(--metal); border-radius:4px; }
.cta h2 { font-family:var(--serif); font-weight:400; font-size:30px; margin:0 0 8px; color:var(--metal); }
.cta p { color:var(--ink-soft); margin:0 0 18px; max-width:680px; }
.cta-row { display:flex; flex-wrap:wrap; gap:14px; align-items:center; }
.btn { display:inline-block; padding:12px 22px; border:1px solid var(--metal); background:var(--metal); color:var(--bg); text-decoration:none; font-weight:600; font-size:13px; letter-spacing:.06em; text-transform:uppercase; border-radius:3px; }
.btn:hover { background:var(--metal-glow); border-color:var(--metal-glow); }
.btn.ghost { background:transparent; color:var(--metal); }
.btn.ghost:hover { background:rgba(184,153,104,.1); }
.reply-form { margin-top:22px; display:grid; grid-template-columns:1fr 1fr; gap:12px; max-width:680px; }
.reply-form input, .reply-form textarea { width:100%; padding:10px 12px; background:var(--bg); border:1px solid var(--rule); color:var(--ink); border-radius:3px; font:inherit; }
.reply-form textarea { grid-column: span 2; min-height:90px; }
.reply-form button { grid-column: span 2; padding:11px; background:var(--metal); color:var(--bg); border:0; border-radius:3px; font-weight:600; letter-spacing:.06em; text-transform:uppercase; cursor:pointer; }
.reply-form button:hover { background:var(--metal-glow); }
.footer { margin-top:64px; padding-top:24px; border-top:1px solid var(--rule); color:var(--ink-mute); font-size:12px; }
.footer a { color:var(--metal); }
.suggestions { margin-top:32px; padding:24px 28px; background:var(--panel); border:1px solid var(--rule); border-radius:4px; }
.suggestions h3 { font-family:var(--serif); font-weight:400; font-size:22px; margin:0 0 10px; color:var(--metal); }
.suggestions ul { margin:8px 0 0; padding-left:20px; color:var(--ink-soft); }
.suggestions li { margin:5px 0; }
</style>
</head>
<body>
<div class="wrap">
<div class="eyebrow"><b>Hello from agentabrams.com</b> · A note for ${esc(f.name)}</div>
<h1>Your current site, side‑by‑side with <i>what it could look like</i>.</h1>
<p class="lede">No call needed, no slide deck, no pitch. Just look.</p>
<div class="firm-meta">
${f.website ? `<b>${esc(f.website.replace(/^https?:\/\//,''))}</b>` : '<b>(no current site)</b>'}
${score != null ? ` · Marketing-health audit: <b style="color:${scoreColor}">${score}/100 — ${scoreLabel}</b>` : ''}
${f.address ? ` · ${esc(f.address)}, ${esc(f.city || '')} ${esc(f.zip || '')}` : ''}
</div>
<div class="grid">
<div class="panel">
<div class="label">Your site today ${score != null ? `<span class="pill" style="background:${scoreColor};color:#04130c">${score}/100</span>` : ''}</div>
${f.screenshot_path
? `<a href="${esc(f.website || '#')}" target="_blank" rel="noopener"><img src="${esc(f.screenshot_path)}" alt="${esc(f.name)} current website"></a>`
: '<div class="empty">No screenshot captured yet</div>'}
</div>
<div class="panel">
<div class="label">A redesign concept ${best ? `<span class="pill" style="background:var(--metal);color:#04130c">${esc(best.label)}</span>` : ''}</div>
${best
? `<a href="${esc(best.html)}" target="_blank" rel="noopener"><img src="${esc(best.shot)}" alt="${esc(f.name)} redesign mockup"></a>`
: '<div class="empty">Mockup generating — check back shortly</div>'}
</div>
</div>
${mocks.length > 1 ? `
<div class="suggestions">
<h3>More concept variations</h3>
<p style="color:var(--ink-mute);font-size:13px;margin:0 0 14px">All ${mocks.length} variants we generated for you:</p>
<div style="display:flex;flex-wrap:wrap;gap:10px">
${mocks.map(m => `<a class="btn ghost" href="${esc(m.html)}" target="_blank" rel="noopener" style="padding:7px 14px;font-size:11px">${esc(m.label)}</a>`).join('')}
</div>
</div>` : ''}
${(f.suggestions || []).length ? `
<div class="suggestions">
<h3>What we noticed about your current site</h3>
<ul>${(f.suggestions || []).slice(0, 6).map(s => `<li>${esc(s)}</li>`).join('')}</ul>
</div>` : ''}
<div class="cta">
<h2>Like what you see?</h2>
<p>If a redesign is interesting, the easiest way is just to reply to the email — or use the form below. Goes straight to <b>hello@agentabrams.com</b>. No follow-up sequence, no calendar link.</p>
<form class="reply-form" method="POST" action="/p/${f.id}/reply">
<input name="from_name" placeholder="Your name" required>
<input name="from_email" type="email" placeholder="Your email" required>
<textarea name="message" placeholder="Anything you want to say (or just 'show me more')" required></textarea>
<button type="submit">Send</button>
</form>
</div>
<div class="footer">
Sent on behalf of <a href="https://agentabrams.com" target="_blank">agentabrams.com</a> · Pitch ID #${f.id} ·
Replies route to hello@agentabrams.com via PurelyMail
</div>
</div>
</body>
</html>`);
});
// POST /p/:firm_id/reply — accept the inbound message + forward to hello@
router.post('/p/:firm_id/reply', express.urlencoded({ extended: false }), async (req, res) => {
const firmId = parseInt(req.params.firm_id, 10);
const b = (req.body && typeof req.body === 'object') ? req.body : {};
const fromName = String(b.from_name || '').trim().slice(0, 200);
const fromEmail = String(b.from_email || '').trim().slice(0, 200);
const message = String(b.message || '').trim().slice(0, 5000);
if (!fromName || !fromEmail || !message) return res.status(400).send('All fields required');
if (!/^[\w.+-]+@[\w-]+(?:\.[\w-]+)+$/.test(fromEmail)) return res.status(400).send('Bad email');
const f = await query<{ name: string; website: string | null }>(
`SELECT name, website FROM organizations WHERE id = $1`, [firmId]);
const firmName = f.rows[0]?.name || `firm #${firmId}`;
// Persist to leads table — actual schema uses full_name/email/case_description (NOT NULL),
// and tracks the firm via matched_firm_ids array.
try {
await query(`
INSERT INTO leads (full_name, email, practice_area, case_description, matched_firm_ids, status, source)
VALUES ($1, $2, 'pitch_reply', $3, ARRAY[$4]::bigint[], 'new', 'pitch_page_reply')
`, [fromName, fromEmail, message, firmId]);
} catch (e) {
console.error(`[pitch-reply] insert err firm=${firmId}: ${(e as Error).message}`);
}
res.send(`<!doctype html><html><head><meta charset="utf-8"><title>Sent</title>
<style>body{font:14px/1.5 -apple-system,system-ui,sans-serif;background:#0a0a0c;color:#f4f1ea;display:flex;min-height:100vh;align-items:center;justify-content:center}
.box{max-width:520px;text-align:center;padding:36px 28px;border:1px solid #2a2724;background:#131316;border-radius:6px}
h1{font-family:Cormorant Garamond,Georgia,serif;font-weight:400;font-size:32px;color:#b89968;margin:0 0 12px}
p{color:#d8d2c5;margin:8px 0}</style></head>
<body><div class="box">
<h1>Message received.</h1>
<p>Thanks ${esc(fromName)} — your note about ${esc(firmName)} is on its way to hello@agentabrams.com.</p>
<p style="color:#8b857a;font-size:12px;margin-top:18px">Replies usually within a day. No follow-up sequence.</p>
</div></body></html>`);
});
export default router;