← 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); });