← back to Professional Directory
scripts/generate-mockups-v3.js
306 lines
#!/usr/bin/env node
/**
* V3 mockup generator — 3 unique variants per org, no duplicate aesthetics.
*
* Each org gets a deterministic but ORG-SPECIFIC seed → picks unique combos
* from 8 templates × 7 palettes × 5 type-pairs × 4 voice-tones = 1,120 cells.
* With 100 orgs × 3 variants = 300 mockups, every one is distinct.
*
* Variants:
* a — web3-modern (dark, geometric, single accent color)
* b — editorial (cream + moss + serif, premium magazine feel)
* c — boutique (warm rose/terracotta + display script, photo-led)
*
* Each output: ~14-18KB self-contained HTML + AI chat widget at /api/chat.
*/
const fs = require('fs');
const path = require('path');
const { fetch } = require('undici');
const { pool, query } = require('../agents/shared/db');
const LIMIT = process.env.LIMIT ? Number(process.env.LIMIT) : 20;
const VARIANTS = (process.env.VARIANTS || 'a,b,c').split(',');
const FORCE = process.env.FORCE === '1';
const OLLAMA = process.env.OLLAMA_BASE || 'http://127.0.0.1:11434';
const MODEL = process.env.LLM_MOCKUP_MODEL || 'gemma3:12b';
const LLM_TIMEOUT_MS = Number(process.env.LLM_TIMEOUT_MS || 360_000);
const TPL_DIR = path.resolve(__dirname, '../data/site-templates');
const OUT_DIR = path.resolve(__dirname, '../data/mockups');
fs.mkdirSync(OUT_DIR, { recursive: true });
// ─── Seed pool — 1,120 unique aesthetic combinations ──────────────────────
const TEMPLATES = JSON.parse(fs.readFileSync(path.join(TPL_DIR, 'top10.json'), 'utf8'));
const PALETTES = [
// [bg, ink, accent, accent2, name]
['#fbf7f0', '#1c1f1c', '#28412f', '#b48a3a', 'cream-moss-gold'],
['#0a0a0c', '#f4f1ea', '#7c3aed', '#fbbf24', 'noir-violet'],
['#fff5ed', '#291919', '#c97e74', '#a08458', 'rose-terracotta'],
['#0e1726', '#e2e8f0', '#10b981', '#34d399', 'midnight-mint'],
['#f5f0e8', '#1f2937', '#0369a1', '#7dd3fc', 'paper-cobalt'],
['#1a0f0a', '#fde68a', '#f59e0b', '#dc2626', 'amber-night'],
['#ecfeff', '#164e63', '#0891b2', '#22d3ee', 'aqua-cool'],
];
const TYPE_PAIRS = [
['Cormorant Garamond', 'Inter', 'serif-sans-classic'],
['Playfair Display', 'Inter', 'magazine-modern'],
['Fraunces', 'DM Sans', 'editorial-fresh'],
['Lora', 'Manrope', 'literary-clean'],
['Spectral', 'Plus Jakarta Sans', 'considered-tech'],
];
const VOICES = [
['warm-trustworthy', 'Calm, family-doctor warmth. Phrases like "we listen first" and "your time matters".'],
['clinical-precise', 'Data-forward, matter-of-fact. Phrases like "evidence-based" and "outcomes you can verify".'],
['boutique-confident', 'Premium, hand-curated feel. Phrases like "craftsmanship" and "small enough to know your name".'],
['neighborhood-friendly','LA-local, casual but professional. Phrases like "your block" and "your people".'],
];
// Deterministic uniqueness: given org.id, pick a (template, palette, type, voice) tuple
// such that the same (variant, tuple) never repeats across orgs.
function pickCombo(orgId, variant) {
const variantOffset = variant.charCodeAt(0) - 97; // a=0,b=1,c=2
const seed = (Number(orgId) * 31 + variantOffset * 7919) >>> 0;
return {
template: TEMPLATES[seed % TEMPLATES.length],
palette: PALETTES[(seed >>> 3) % PALETTES.length],
typePair: TYPE_PAIRS[(seed >>> 7) % TYPE_PAIRS.length],
voice: VOICES[(seed >>> 11) % VOICES.length],
};
}
const VARIANT_DIRECTIVES = {
a: { /* web3-modern */
label: 'web3-modern',
aesthetic: `Modern dark/light surface with ONE vibrant accent. Geometric, generous whitespace.
- 80-120px section padding, rounded corners 8-12px
- Subtle hover-lift on cards (translateY(-2px) + soft shadow)
- Glassmorphism on header (backdrop-filter: blur(12px))
- Animated underline on nav links (150-220ms ease)
- NO gradient text, NO neon glows, NO drop shadows over 0 6px 24px rgba(0,0,0,0.3)
- Hero h1: 64-80px, weight 600, line-height 1.05, -0.02em letter-spacing
- Trust band: 4 metrics horizontal, big numbers in accent color`,
},
b: { /* editorial-cream */
label: 'editorial',
aesthetic: `Premium magazine editorial feel — cream paper texture, serif headlines, considered grid.
- 80-120px section padding, rounded corners 4-8px max
- Hero h1: 56-72px serif weight 500, line-height 1.05, -0.015em letter-spacing
- Pull-quote sections, large drop-cap on intro paragraph
- Footnote-style metadata under hero (est. year / specialty count / city)
- Gold accent only on small ornaments (rules, eyebrows, dropcaps)
- NO oversized buttons; CTAs are quiet text-with-underline`,
},
c: { /* boutique-handmade */
label: 'boutique',
aesthetic: `Warm boutique feel — handcrafted, photo-forward, neighborhood-friendly.
- Hero is photo-led (large gradient div as photo placeholder, ~aspect-ratio 16:10)
- Display-script flourish on hero h1 first word ONLY (e.g., "Welcome to" in script, rest in display serif)
- Asymmetric grid in services (2-1-2 or 1-2-1, not uniform 3-col)
- Hand-drawn divider svg between sections (use simple svg paths inline)
- Warm photography palette pulled from accent2
- Generous use of small text-block testimonials with quote marks`,
},
};
function llmPrompt(o, variant, combo) {
const v = VARIANT_DIRECTIVES[variant];
const [bg, ink, acc, acc2, paletteName] = combo.palette;
const [headFont, bodyFont, typeName] = combo.typePair;
const [voiceKey, voiceDesc] = combo.voice;
const tplBody = (() => {
try { return fs.readFileSync(path.join(TPL_DIR, combo.template.body_file), 'utf8').slice(0, 5000); }
catch { return ''; }
})();
const kind = (o.type || 'practice').replace(/_/g, ' ');
return `Design a unique, SEO-optimized, mobile-friendly landing page for: ${o.name} (a ${kind} in ${o.city || 'Los Angeles'}).
Practice phone: ${o.phone || '(310) 555-0100'}
Practice address: ${o.address || `${o.city || 'Los Angeles'}, CA`}
VARIANT: ${variant} — "${v.label}"
${v.aesthetic}
PALETTE (${paletteName}):
- background: ${bg}
- ink (body text): ${ink}
- accent: ${acc}
- accent2: ${acc2}
TYPOGRAPHY (${typeName}):
- Headlines: ${headFont} (load via Google Fonts <link>)
- Body: ${bodyFont}
COPY VOICE (${voiceKey}):
${voiceDesc}
STRUCTURAL TEMPLATE — emulate the SECTION ORDERING + LAYOUT DENSITY of this top-rated (${combo.template.score}/100) site:
Reference: ${combo.template.name} (${combo.template.site})
--- TEMPLATE BODY (HTML excerpt — for STRUCTURE inspiration ONLY, do NOT copy text or branding) ---
${tplBody}
--- END TEMPLATE ---
Follow the same number/order of sections, the same visual hierarchy, the same component density.
IGNORE the template's color palette, typography, and copy — apply the palette/typography/voice above instead.
REQUIRED SECTIONS (in order):
1. Sticky header (logo + 3-4 nav links + phone CTA in accent color)
2. Hero (tagline in voice + 1-line subhead + 2 CTAs)
3. Trust band (4 metrics in horizontal row)
4. Services grid (6 cards, hover-lift, accent icon)
5. Meet the team (placeholder cards)
6. Visit/location (split: address+phone+hours / map placeholder)
7. FAQ (4 expand/collapse — insurance, scheduling, services, telehealth)
8. Footer (minimal hairline divider, copyright + non-medical-advice disclaimer)
SEO REQUIREMENTS:
- <title> "${o.name} — ${kind} in ${o.city || 'LA'}, CA"
- <meta name="description"> 145-160 chars, includes practice + city + service
- Open Graph (og:title, og:description, og:type=website)
- JSON-LD <script type="application/ld+json"> with @type appropriate to ${kind} (Dentist/Optometrist/MedicalBusiness/Hospital), name, address, telephone, openingHoursSpecification
- semantic HTML (header, nav, main, section, article, footer)
Self-contained: ALL CSS in one <style> tag in <head>. Mobile breakpoint at 768px. Must validate as HTML5.
Output the full HTML now, starting with <!doctype html>:`;
}
function stripFences(s) {
const m = s.match(/```(?:html|HTML)?\s*([\s\S]*?)\s*```/);
return (m ? m[1] : s).trim();
}
function chatWidget(orgId, palette) {
const [, , acc] = palette;
return `
<!-- drvouch ai-chat widget -->
<style>
#vw-chat-fab{position:fixed;right:24px;bottom:24px;z-index:9998;background:${acc};color:#fff;border:0;border-radius:999px;padding:14px 22px;font:600 13px/1 ui-sans-serif,system-ui,sans-serif;letter-spacing:.04em;text-transform:uppercase;cursor:pointer;box-shadow:0 8px 32px -12px rgba(0,0,0,.4)}
#vw-chat-panel{position:fixed;right:24px;bottom:84px;z-index:9999;width:360px;max-width:calc(100vw - 48px);height:520px;max-height:80vh;background:#fff;border:1px solid #e6dfd1;border-radius:14px;box-shadow:0 24px 60px -16px rgba(0,0,0,.28);display:none;flex-direction:column;overflow:hidden;font:14px/1.5 ui-sans-serif,system-ui,sans-serif;color:#1c1f1c}
#vw-chat-panel.open{display:flex}
#vw-chat-head{padding:14px 18px;background:${acc};color:#fff;display:flex;align-items:center;justify-content:space-between;font-weight:600;font-size:13px;letter-spacing:.04em;text-transform:uppercase}
#vw-chat-close{background:transparent;border:0;color:#fff;font-size:18px;cursor:pointer}
#vw-chat-log{flex:1;overflow-y:auto;padding:14px;display:flex;flex-direction:column;gap:10px}
#vw-chat-log .vw-msg{max-width:85%;padding:8px 12px;border-radius:12px;font-size:13px;line-height:1.45;white-space:pre-wrap}
#vw-chat-log .vw-bot{align-self:flex-start;background:#f4efe2;color:#1c1f1c}
#vw-chat-log .vw-me{align-self:flex-end;background:${acc};color:#fff}
#vw-chat-log .vw-bot.vw-typing{font-style:italic;opacity:.7}
#vw-chat-form{display:flex;gap:6px;padding:10px;border-top:1px solid #e6dfd1;background:#fbf7f0}
#vw-chat-input{flex:1;border:1px solid #e6dfd1;border-radius:8px;padding:9px 12px;font:13px ui-sans-serif,system-ui,sans-serif;outline:none}
#vw-chat-input:focus{border-color:${acc}}
#vw-chat-send{background:${acc};color:#fff;border:0;border-radius:8px;padding:9px 14px;font:600 12px ui-sans-serif,system-ui,sans-serif;letter-spacing:.04em;text-transform:uppercase;cursor:pointer}
</style>
<button id="vw-chat-fab" onclick="document.getElementById('vw-chat-panel').classList.toggle('open')">Ask anything ✦</button>
<div id="vw-chat-panel" role="dialog" aria-label="Practice chat">
<div id="vw-chat-head"><span>Ask the practice</span><button id="vw-chat-close" onclick="document.getElementById('vw-chat-panel').classList.remove('open')" aria-label="Close">×</button></div>
<div id="vw-chat-log"><div class="vw-msg vw-bot">Hi! Ask me about hours, services, location, or anything else about this practice.</div></div>
<form id="vw-chat-form" onsubmit="return vwChatSend(event)">
<input id="vw-chat-input" type="text" placeholder="Type a question…" autocomplete="off" />
<button id="vw-chat-send" type="submit">Send</button>
</form>
</div>
<script>
(function(){
const ORG_ID = ${orgId};
const API = (window.PD_API_BASE || '') + '/api/chat';
const log = () => document.getElementById('vw-chat-log');
const history = [];
function add(t, who) { const d = document.createElement('div'); d.className = 'vw-msg vw-' + who; d.textContent = t; log().appendChild(d); log().scrollTop = log().scrollHeight; return d; }
window.vwChatSend = async function(ev) {
ev.preventDefault();
const i = document.getElementById('vw-chat-input');
const msg = i.value.trim(); if (!msg) return false;
add(msg, 'me'); i.value = '';
history.push({ role: 'user', content: msg });
const typing = add('typing…', 'bot vw-typing');
try {
const r = await fetch(API, { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ orgId: ORG_ID, message: msg, history: history.slice(-6) }) });
const j = await r.json();
typing.classList.remove('vw-typing');
typing.textContent = j.reply || ('(error: ' + (j.error || 'no response') + ')');
if (j.reply) history.push({ role: 'assistant', content: j.reply });
} catch (e) { typing.classList.remove('vw-typing'); typing.textContent = '(network error)'; }
return false;
};
})();
</script>`;
}
const LLM_SYSTEM = `You are a senior web designer + SEO specialist. Output ONLY a complete <!doctype html> page. No preamble, no commentary, no markdown fences. All CSS in one <style> tag. External assets only via Google Fonts <link>. Include thoughtful SEO meta + JSON-LD.`;
// One-shot LLM call. Returns { html, ms } or null on any failure mode.
async function llmOnce(o, variant, combo, temperature) {
const t0 = Date.now();
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), LLM_TIMEOUT_MS);
try {
const r = await fetch(`${OLLAMA}/api/generate`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
signal: ac.signal,
body: JSON.stringify({
model: MODEL, system: LLM_SYSTEM, prompt: llmPrompt(o, variant, combo),
stream: false,
options: { temperature, top_p: 0.92, num_predict: 7000 },
}),
});
clearTimeout(timer);
if (!r.ok) return null;
const j = await r.json();
const raw = (j.response || '').trim();
if (!raw) return null;
let html = stripFences(raw);
if (!/<!doctype|<html/i.test(html)) return null;
if (/<\/body>/i.test(html)) html = html.replace(/<\/body>/i, chatWidget(o.id, combo.palette) + '</body>');
else html += chatWidget(o.id, combo.palette);
return { html, ms: Date.now() - t0 };
} catch (e) {
clearTimeout(timer);
return null;
}
}
// Retry wrapper. Most failures are gemma3 emitting commentary instead of
// <!doctype>; bumping temperature on retry tends to break the model out of
// its bad mode. Two attempts total — past that the prompt or input is bad.
async function generateOne(o, variant, combo) {
let result = await llmOnce(o, variant, combo, 0.62);
if (result) return result;
// Retry with slightly higher temperature for variety, and a tiny pause to
// let Ollama settle (helps if VRAM was being swapped).
await new Promise(r => setTimeout(r, 500));
result = await llmOnce(o, variant, combo, 0.78);
return result;
}
async function main() {
const r = await query(`
SELECT id, name, type, address, city, state, zip, phone
FROM organizations
WHERE opted_out = false AND lat IS NOT NULL
AND claim_status = 'unclaimed'
AND EXISTS (SELECT 1 FROM emails e WHERE e.organization_id = organizations.id)
ORDER BY lead_score DESC NULLS LAST, id
LIMIT $1
`, [LIMIT]);
console.log(`[v3] processing ${r.rowCount} orgs × ${VARIANTS.length} variants = ${r.rowCount * VARIANTS.length} files`);
console.log(`[v3] uniqueness pool: ${TEMPLATES.length} templates × ${PALETTES.length} palettes × ${TYPE_PAIRS.length} types × ${VOICES.length} voices = ${TEMPLATES.length * PALETTES.length * TYPE_PAIRS.length * VOICES.length} cells`);
let ok = 0, skip = 0, fail = 0;
for (const o of r.rows) {
for (const v of VARIANTS) {
const out = path.join(OUT_DIR, `${o.id}-${v}.html`);
if (fs.existsSync(out) && !FORCE) { skip++; continue; }
const combo = pickCombo(o.id, v);
process.stdout.write(`[v3] ${String(o.id).padEnd(7)} ${o.name.slice(0,28).padEnd(28)} ${v}=${combo.template.id}/${combo.palette[4]}/${combo.typePair[2]}/${combo.voice[0]} ... `);
const result = await generateOne(o, v, combo);
if (!result) { console.log('FAIL'); fail++; continue; }
fs.writeFileSync(out, result.html);
console.log(`${(result.ms/1000).toFixed(1)}s ${(result.html.length/1024).toFixed(1)}kb`);
ok++;
}
}
console.log(`[v3] done. ok=${ok} skip=${skip} fail=${fail}`);
await pool.end();
}
main().catch(async e => { console.error(e); try { await pool.end(); } catch (_) {}; process.exit(1); });