← back to Professional Directory

scripts/generate-mockups.js

283 lines

#!/usr/bin/env node
/**
 * Generate per-org bespoke website mockups using template-rotation + local LLM.
 *
 * For each org in the input set:
 *   1. Pick template via org.id % N from data/site-templates/top10.json
 *   2. Inject template body excerpt as STRUCTURAL inspiration (LLM emulates
 *      section ordering + density, ignores the template's colors/fonts)
 *   3. Apply our healthcare-modern aesthetic (clean cream-on-moss editorial,
 *      OR dark-mode glass, OR warm-rose photo-led — picked by org type)
 *   4. Inject AI-chat widget (right-bottom floating; posts to /api/chat)
 *   5. Save to data/mockups/<orgId>.html + record metadata
 *
 * Adapted from lawyer-directory-builder/src/enrich/render_mockups.ts.
 *
 * Run:
 *   node scripts/generate-mockups.js                 # default: top 20 by lead_score
 *   LIMIT=50 node scripts/generate-mockups.js
 *   ONLY_DOCTORS=1 node scripts/generate-mockups.js  # restrict to medical types
 *   FORCE=1 node scripts/generate-mockups.js         # regenerate even if .html exists
 */
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 ONLY_DOCTORS = process.env.ONLY_DOCTORS === '1';
const FORCE = process.env.FORCE === '1';

const DOCTOR_TYPES = ['medical_group','clinic','dental_office','optometrist_office','surgery_center','hospital','fqhc','tcm_clinic','acupuncture_clinic','chiropractor_office','tcm_herbalist','rcfe','ccrc','adult_residential','home_care_agency','home_health','hospice','nursing_facility'];

const OLLAMA = process.env.OLLAMA_BASE || 'http://127.0.0.1:11434';
const MODEL  = process.env.LLM_MOCKUP_MODEL || 'gemma3:12b';
// gemma3:12b on MS2 takes 90-300s to write 4-6KB of HTML. Budget 6 min/org.
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 });

// ─── load template manifest ────────────────────────────────────────────────
let TEMPLATES = [];
try { TEMPLATES = JSON.parse(fs.readFileSync(path.join(TPL_DIR, 'top10.json'), 'utf8')); }
catch (_) { console.error('[gen] no template manifest — run scripts/cache-healthcare-templates.js first'); process.exit(2); }

function pickTemplate(id) {
  if (!TEMPLATES.length) return null;
  return TEMPLATES[id % TEMPLATES.length];
}
function loadTemplateBody(t) {
  try { return fs.readFileSync(path.join(TPL_DIR, t.body_file), 'utf8'); }
  catch { return ''; }
}

// ─── chat widget HTML — appended to every generated mockup ────────────────
function chatWidget(orgId) {
  return `
<!-- drvouch ai-chat widget -->
<style>
  #vw-chat-fab{position:fixed;right:24px;bottom:24px;z-index:9998;background:#28412f;color:#fbf7f0;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-fab:hover{background:#3f5f4a}
  #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:#28412f;color:#fbf7f0;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:#fbf7f0;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:#28412f;color:#fbf7f0}
  #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:#28412f}
  #vw-chat-send{background:#28412f;color:#fbf7f0;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(text, who) {
    const d = document.createElement('div');
    d.className = 'vw-msg vw-' + who;
    d.textContent = text;
    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 — chat backend may be offline)';
    }
    return false;
  };
})();
</script>
`;
}

// ─── LLM prompt ────────────────────────────────────────────────────────────
const LLM_SYSTEM = `You are a senior web designer and SEO specialist building a single self-contained landing page for a healthcare practice. Output ONLY a complete, valid <!doctype html> page — no preamble, no markdown fences, no commentary, just the HTML. All CSS in one <style> tag in the head. No external assets except Google Fonts via <link>. Include thoughtful SEO meta + JSON-LD schema (Organization or Dentist or MedicalBusiness as appropriate).`;

function llmPrompt(o, tpl, tplBody) {
  const city = o.city || 'Los Angeles';
  const phone = o.phone || '';
  const addr  = o.address || '';
  const orgName = o.name;
  const kind = (o.type || 'practice').replace(/_/g, ' ');
  const tplBlock = tpl && tplBody
    ? `\n\nSTRUCTURAL TEMPLATE — emulate the SECTION ORDERING + LAYOUT DENSITY of this top-rated (audit ${tpl.score}/100) healthcare site:\nReference: ${tpl.name} (${tpl.site})\n--- TEMPLATE BODY (HTML excerpt — for STRUCTURE inspiration ONLY, do NOT copy text or branding) ---\n${tplBody}\n--- END TEMPLATE ---\n\nFollow the same number/order of sections, the same visual hierarchy, the same component density.\nIGNORE the template's color palette, typography, and copy — apply the aesthetic below instead.\n`
    : '';

  return `Design a clean, SEO-optimized, mobile-friendly landing page for: ${orgName}
Practice type: ${kind}
City: ${city}
Phone: ${phone || '(310) 555-0100 — placeholder'}
Address: ${addr || `${city}, CA`}

Aesthetic — modern healthcare editorial, NEVER flashy:
- Cream + moss-green palette: #fbf7f0 background, #28412f primary, #b48a3a gold accent, #1c1f1c body text
- Serif headlines (Cormorant Garamond or Iowan Old Style), sans-serif body (Inter)
- Hero h1: 56-72px, weight 500, line-height 1.05, -0.015em letter-spacing
- Body: 16px/1.6 Inter weight 400
- Generous whitespace: 80-120px section padding
- Subtle hover-lift on cards (translateY(-2px) + soft box-shadow)
- Rounded corners 8-12px max
- NO gradient text, NO glow effects, NO drop shadows over 0 8px 24px rgba(0,0,0,0.12)
- Calm, trustworthy, premium-feel — think Mayo Clinic + One Medical, not flashy

Required sections, in order:
1. Sticky header — practice name in serif (text logo), 3-4 nav links, phone CTA
2. Hero — tagline, 1-line subhead, 2 CTAs (filled "${kind === 'dental_office' ? 'Book a Cleaning' : kind === 'optometrist_office' ? 'Book Eye Exam' : 'Book an Appointment'}" + ghost "Learn More")
3. Trust band — 4 metrics in horizontal row (years serving city / patients seen / 5-star reviews / same-week appointments)
4. Services grid — 6 cards in 3-column responsive grid, hover-lift, gold-color icon (Unicode: ✦ ✧ ◆ ◇ ★)
5. Meet the team — placeholder cards for providers (3-4 in a row)
6. Visit / location — split: address + phone + hours on left, embedded map placeholder on right
7. FAQ — 4 expand/collapse questions on insurance, scheduling, services, telehealth
8. Footer — minimal, hairline border, copyright + non-medical-advice disclaimer

SEO requirements:
- <title>${orgName} — ${kind === 'dental_office' ? 'Dentist' : kind === 'optometrist_office' ? 'Optometrist' : 'Healthcare'} in ${city}, CA</title>
- <meta name="description"> 145-160 chars, includes practice name + city + primary service
- Open Graph tags (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)
- alt text on all images (use placeholder gradient divs if no real images)

Self-contained: all CSS in one <style> tag in <head>. Mobile breakpoint at 768px. Must validate as HTML5.${tplBlock}

Output the full HTML now, starting with <!doctype html>:`;
}

function stripCodeFences(s) {
  // Lenient: handle ```html\n…\n```, ```\n…\n```, AND missing trailing newlines.
  const m = s.match(/```(?:html|HTML)?\s*([\s\S]*?)\s*```/);
  return (m ? m[1] : s).trim();
}

async function generateLLMHtml(o, signal) {
  const t = pickTemplate(Number(o.id));
  const body = t ? loadTemplateBody(t) : '';
  const t0 = Date.now();
  try {
    const r = await fetch(`${OLLAMA}/api/generate`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      signal,
      body: JSON.stringify({
        model: MODEL,
        system: LLM_SYSTEM,
        prompt: llmPrompt(o, t, body),
        stream: false,
        options: { temperature: 0.55, top_p: 0.9, num_predict: 6500 },
      }),
    });
    if (!r.ok) { console.warn(`[llm] HTTP ${r.status} for org ${o.id}`); return null; }
    const j = await r.json();
    const raw = (j.response || '').trim();
    if (!raw) return null;
    let html = stripCodeFences(raw);
    if (!/<!doctype|<html/i.test(html)) {
      console.warn(`[llm] org ${o.id} missing <html>`);
      return null;
    }
    // Inject AI chat widget right before </body>.
    if (/<\/body>/i.test(html)) {
      html = html.replace(/<\/body>/i, chatWidget(o.id) + '</body>');
    } else {
      html += chatWidget(o.id);
    }
    return { html, ms: Date.now() - t0, template_id: t?.id || null, template_score: t?.score || null };
  } catch (e) {
    if (e.name === 'AbortError') { console.warn(`[llm] org ${o.id} timed out after ${LLM_TIMEOUT_MS}ms`); return null; }
    console.warn(`[llm] org ${o.id} err: ${e.message}`);
    return null;
  }
}

async function main() {
  const where = [
    `o.opted_out = false`,
    `o.lat IS NOT NULL`,
    `o.claim_status = 'unclaimed'`,
    `EXISTS (SELECT 1 FROM emails e WHERE e.organization_id = o.id)`,
  ];
  const params = [];
  if (ONLY_DOCTORS) { where.push(`o.type = ANY($1)`); params.push(DOCTOR_TYPES); }

  const r = await query(`
    SELECT o.id, o.name, o.type, o.address, o.city, o.state, o.zip, o.phone, o.lead_score
      FROM organizations o
     WHERE ${where.join(' AND ')}
     ORDER BY o.lead_score DESC NULLS LAST, o.id
     LIMIT $${params.length + 1}
  `, [...params, LIMIT]);

  console.log(`[gen] processing ${r.rowCount} orgs, ${TEMPLATES.length} templates available, model=${MODEL}`);

  const results = [];
  for (const o of r.rows) {
    const out = path.join(OUT_DIR, `${o.id}.html`);
    if (fs.existsSync(out) && !FORCE) {
      console.log(`[gen] org ${o.id} ${o.name} — skip (exists, set FORCE=1 to regen)`);
      results.push({ id: o.id, name: o.name, status: 'cached' });
      continue;
    }
    const ac = new AbortController();
    const timer = setTimeout(() => ac.abort(), LLM_TIMEOUT_MS);
    const t = pickTemplate(Number(o.id));
    process.stdout.write(`[gen] org ${o.id} ${o.name.slice(0, 36).padEnd(36)} tpl=${(t?.id || 'none').padEnd(11)} ... `);
    const result = await generateLLMHtml(o, ac.signal);
    clearTimeout(timer);
    if (!result) { console.log('FAIL'); results.push({ id: o.id, name: o.name, status: 'fail' }); continue; }
    fs.writeFileSync(out, result.html);
    console.log(`${(result.ms / 1000).toFixed(1)}s ${(result.html.length / 1024).toFixed(1)}kb`);
    results.push({ id: o.id, name: o.name, status: 'ok', ms: result.ms, kb: Math.round(result.html.length / 1024), template: result.template_id });
  }

  // Manifest for the index page.
  fs.writeFileSync(path.join(OUT_DIR, '_manifest.json'), JSON.stringify({
    generated_at: new Date().toISOString(),
    model: MODEL,
    template_count: TEMPLATES.length,
    results,
  }, null, 2));

  const ok = results.filter(r => r.status === 'ok').length;
  console.log(`\n[gen] done. ok=${ok} cached=${results.filter(r=>r.status==='cached').length} fail=${results.filter(r=>r.status==='fail').length}`);
  await pool.end();
}

main().catch(async (e) => { console.error(e); try { await pool.end(); } catch (_) {} process.exit(1); });