← back to Instant Audit

src/fulfill.js

262 lines

// fulfill.js — the fulfillment pipeline for a paid Instant Website Audit.
//
// On a (TEST) paid order we: (1) run the SDCC-family generators on the buyer's
// URL, (2) assemble a multi-page HTML report bundle under reports/<orderId>/,
// (3) return a delivery link + a draft delivery email. Delivery is $0 (local
// compute) — the report generation is deterministic HTML assembly here so the
// pipeline is provable end-to-end without any paid API. The heavier Playwright
// / LLM-vision generators (site-audit, competitors, mockups, tools-pack,
// peer-survey, info-hub skills) are wired as OPTIONAL enrichment steps that a
// human/agent runs to deepen a report — the base bundle stands alone.
//
// TIER MATRIX (what each tier includes):
//   lite     $49  — audit + tools-pack
//   standard $99  — audit + tools-pack + peer-survey + info-hub
//   pro      $199 — everything + competitor teardown + mockups
//
// NOTE: no live network calls are required for the base bundle. Where a real
// fetch of the buyer's homepage helps (title/meta/heuristics), we do a single
// best-effort GET with a short timeout and degrade gracefully offline.

import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPORTS_DIR = path.join(__dirname, '..', 'reports');

export const TIER_SECTIONS = {
  lite: ['audit', 'tools'],
  standard: ['audit', 'tools', 'peers', 'hub'],
  pro: ['audit', 'tools', 'peers', 'hub', 'competitors', 'mockups'],
};

const SECTION_META = {
  audit:       { title: 'Site Audit',           skill: 'site-audit',   file: 'report.html' },
  tools:       { title: 'Borrower/Visitor Tools', skill: 'tools-pack',  file: 'tools.html' },
  peers:       { title: 'Peer Survey',          skill: 'peer-survey',  file: 'peers.html' },
  hub:         { title: 'Trusted-Sources Hub',  skill: 'info-hub',     file: 'hub.html' },
  competitors: { title: 'Competitor Teardown',  skill: 'competitors',  file: 'competitors.html' },
  mockups:     { title: 'Homepage Concepts',    skill: 'mockups',      file: 'mockups.html' },
};

function esc(s) {
  return String(s == null ? '' : s).replace(/[&<>"']/g, (c) =>
    ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}

function hostOf(url) {
  try { return new URL(url).host; } catch { return String(url || 'the site'); }
}

// Single best-effort homepage fetch for real signal; degrades gracefully.
async function probe(url) {
  const out = { ok: false, title: '', metaDesc: '', hasH1: false, statusText: '' };
  try {
    const ctrl = new AbortController();
    const t = setTimeout(() => ctrl.abort(), 6000);
    const r = await fetch(url, { signal: ctrl.signal, redirect: 'follow' });
    clearTimeout(t);
    out.statusText = `${r.status} ${r.statusText}`;
    const html = await r.text();
    out.ok = r.ok;
    out.title = (html.match(/<title[^>]*>([^<]*)<\/title>/i) || [])[1]?.trim() || '';
    out.metaDesc = (html.match(/<meta[^>]+name=["']description["'][^>]+content=["']([^"']*)["']/i) || [])[1]?.trim() || '';
    out.hasH1 = /<h1[\s>]/i.test(html);
  } catch (e) { out.statusText = `unreachable (${e.name})`; }
  return out;
}

const SHELL = (title, host, body) => `<!doctype html><html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>${esc(title)}</title>
<style>
:root{--ink:#1a1a1a;--paper:#faf8f4;--line:#e4ddd2;--accent:#8a7355;--muted:#6b6257}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:Georgia,'Times New Roman',serif;background:var(--paper);color:var(--ink);line-height:1.6}
.wrap{max-width:860px;margin:0 auto;padding:48px 28px}
.rb{font-family:-apple-system,Segoe UI,sans-serif;letter-spacing:.22em;text-transform:uppercase;font-size:11px;color:var(--accent)}
h1{font-size:34px;letter-spacing:-.01em;margin:6px 0 4px}
h2{font-family:-apple-system,Segoe UI,sans-serif;font-size:19px;margin:36px 0 10px;padding-top:22px;border-top:1px solid var(--line)}
h3{font-family:-apple-system,Segoe UI,sans-serif;font-size:15px;margin:18px 0 6px}
p,li{font-size:16px;color:#2a2620;margin:8px 0}
ul{padding-left:22px}
.host{color:var(--muted);font-size:15px}
.grade{display:inline-block;font-family:-apple-system,sans-serif;font-weight:700;padding:2px 10px;border:1px solid var(--line);border-radius:3px;background:#fff}
table{border-collapse:collapse;width:100%;margin:12px 0;font-family:-apple-system,sans-serif;font-size:14px}
th,td{border:1px solid var(--line);padding:8px 10px;text-align:left}
th{background:#f0ebe1}
.nav{font-family:-apple-system,sans-serif;font-size:13px;margin:18px 0;padding:12px;background:#fff;border:1px solid var(--line);border-radius:4px}
.nav a{color:var(--accent);text-decoration:none;margin-right:14px}
.ft{margin-top:48px;padding-top:18px;border-top:1px solid var(--line);font-family:-apple-system,sans-serif;font-size:12px;color:var(--muted)}
</style></head><body><div class="wrap">
<div class="rb">Instant Website Audit</div>
<h1>${esc(title)}</h1><p class="host">${esc(host)}</p>
${body}
<div class="ft">Delivered by Instant Website Audit · generated locally · this report is confidential to the purchaser.</div>
</div></body></html>`;

function navBar(sections, current) {
  const links = sections.map((s) =>
    `<a href="${SECTION_META[s].file}"${s === current ? ' style="font-weight:700"' : ''}>${esc(SECTION_META[s].title)}</a>`).join('');
  return `<div class="nav"><strong>Report:</strong> ${links}</div>`;
}

// ---- section generators (deterministic HTML from the probe + heuristics) ----
function genAudit(host, p, sections) {
  const grade = p.ok ? (p.title && p.metaDesc && p.hasH1 ? 'B+' : p.title ? 'C+' : 'C') : 'N/A';
  const rows = [
    ['Reachability', p.statusText || 'n/a', p.ok ? 'Pass' : 'Investigate'],
    ['&lt;title&gt; tag', p.title ? esc(p.title) : 'MISSING', p.title ? (p.title.length <= 60 ? 'Good length' : 'Too long') : 'Add one'],
    ['Meta description', p.metaDesc ? `${p.metaDesc.length} chars` : 'MISSING', p.metaDesc ? (p.metaDesc.length <= 160 ? 'Good' : 'Trim to ≤160') : 'Add one (150–160 chars)'],
    ['&lt;h1&gt; heading', p.hasH1 ? 'Present' : 'MISSING', p.hasH1 ? 'Pass' : 'Add a single clear h1'],
  ];
  return SHELL(`Site Audit — ${host}`, host, navBar(sections, 'audit') + `
<h2>Executive grade <span class="grade">${grade}</span></h2>
<p>This audit inspects the homepage of <strong>${esc(host)}</strong> for the fundamentals that drive
search visibility, first-impression conversion, and trust. Below are the fast-win findings; the
full 9-section synthesis (accessibility, dead-link scan, component letter grades, 90-day roadmap)
is produced by the <em>site-audit</em> generator.</p>
<h2>Core signals</h2>
<table><tr><th>Signal</th><th>Observed</th><th>Verdict</th></tr>
${rows.map((r) => `<tr><td>${r[0]}</td><td>${r[1]}</td><td>${r[2]}</td></tr>`).join('')}
</table>
<h2>Top 5 recommended additions</h2>
<ul>
<li><strong>Above-the-fold value proposition</strong> — one sentence stating who you help and how, within the first viewport.</li>
<li><strong>Social proof block</strong> — testimonials or logos immediately under the hero raise conversion measurably.</li>
<li><strong>Sticky primary CTA</strong> — a single, high-contrast call-to-action that follows the scroll.</li>
<li><strong>FAQ / objection handling</strong> — pre-empt the 5 questions that stall a first-time visitor.</li>
<li><strong>Schema.org markup</strong> — Organization + LocalBusiness JSON-LD for richer search results.</li>
</ul>
<h2>90-day roadmap (summary)</h2>
<p>Weeks 1–2: metadata + h1 + schema. Weeks 3–6: hero rewrite + social proof + CTA. Weeks 7–12:
FAQ, accessibility pass, performance. The Pro tier includes hand-built homepage concepts to
execute against.</p>`);
}

function genTools(host, p, sections) {
  return SHELL(`Visitor Tools — ${host}`, host, navBar(sections, 'tools') + `
<h2>Working browser-side widgets</h2>
<p>The <em>tools-pack</em> generator produces a pack of real, client-side interactive tools tailored to
your vertical — no server, no data collection. Examples shipped in the reference SDCC pack:</p>
<ul>
<li>Instant quote / savings calculator</li>
<li>Eligibility or fit checker (branching questionnaire)</li>
<li>Comparison slider (you vs. the alternative)</li>
<li>Timeline / countdown planner</li>
<li>Share-worthy result card</li>
<li>Self-audit checklist with printable output</li>
</ul>
<p>Each is delivered as a drop-in HTML snippet you can paste into your site.</p>`);
}

function genPeers(host, p, sections) {
  return SHELL(`Peer Survey — ${host}`, host, navBar(sections, 'peers') + `
<h2>What comparable sites are doing</h2>
<p>The <em>peer-survey</em> generator captures front-page screenshots of comparable organizations and
identifies one specific UX move worth borrowing from each, in a side-by-side comparison.</p>
<h3>Sample findings shape</h3>
<ul>
<li><strong>Peer A</strong> — sticky booking bar → borrow the persistent CTA pattern.</li>
<li><strong>Peer B</strong> — trust ribbon (years in business, certifications) above the fold.</li>
<li><strong>Peer C</strong> — 3-step "how it works" strip that removes first-visit friction.</li>
</ul>`);
}

function genHub(host, p, sections) {
  return SHELL(`Trusted Sources — ${host}`, host, navBar(sections, 'hub') + `
<h2>Curated authoritative sources for your vertical</h2>
<p>The <em>info-hub</em> generator builds a directory of authoritative external sources your visitors
trust — each with a direct URL, whether it supports MFA, its retention policy, and mobile-friendliness
— plus a self-audit checklist. Adding a hub like this positions your site as the helpful hub of its niche.</p>`);
}

function genCompetitors(host, p, sections) {
  return SHELL(`Competitor Teardown — ${host}`, host, navBar(sections, 'competitors') + `
<h2>Where you can beat them</h2>
<p>The <em>competitors</em> generator dissects named commercial competitors on features, pricing,
privacy posture, and produces differentiation vectors <em>you</em> can ship that they structurally cannot.</p>
<h3>Differentiation vectors (sample shape)</h3>
<ul>
<li>Transparency they can't match (open pricing / no dark patterns).</li>
<li>Speed-to-value: a working tool on the homepage vs. a lead-gate.</li>
<li>Local trust signals a national competitor can't credibly claim.</li>
</ul>`);
}

function genMockups(host, p, sections) {
  return SHELL(`Homepage Concepts — ${host}`, host, navBar(sections, 'mockups') + `
<h2>Hand-built front-page concepts</h2>
<p>The <em>mockups</em> generator produces distinct aesthetic directions for your homepage — not template
re-skins. Each concept is a real, working front page you can adopt whole or cherry-pick from.</p>
<h3>Directions included at the Pro tier</h3>
<ul>
<li>Conversion-first (hero + proof + single CTA)</li>
<li>Editorial / magazine (authority-building)</li>
<li>Utility-first (tool on the homepage)</li>
</ul>`);
}

const GENERATORS = { audit: genAudit, tools: genTools, peers: genPeers, hub: genHub, competitors: genCompetitors, mockups: genMockups };

// Assemble the full report bundle for an order. Returns { dir, indexUrl, files, email }.
export async function fulfillOrder({ orderId, url, email, tier, baseUrl }) {
  const sections = TIER_SECTIONS[tier] || TIER_SECTIONS.standard;
  const host = hostOf(url);
  const p = await probe(url);
  const dir = path.join(REPORTS_DIR, orderId);
  fs.mkdirSync(dir, { recursive: true });

  const files = [];
  for (const s of sections) {
    const html = GENERATORS[s](host, p, sections);
    const fname = SECTION_META[s].file;
    fs.writeFileSync(path.join(dir, fname), html);
    files.push(fname);
  }
  // index.html = the audit is the front door
  fs.copyFileSync(path.join(dir, 'report.html'), path.join(dir, 'index.html'));

  // manifest for the delivery/status page
  const manifest = {
    orderId, url, host, email, tier, sections,
    createdAt: new Date().toISOString(),
    probe: p, files,
  };
  fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2));

  const deliveryUrl = `${baseUrl}/r/${orderId}/`;
  const email_draft = draftDeliveryEmail({ email, host, tier, deliveryUrl, sections });
  fs.writeFileSync(path.join(dir, 'delivery-email.txt'), email_draft.text);

  return { orderId, dir, deliveryUrl, files, manifest, email: email_draft };
}

function draftDeliveryEmail({ email, host, tier, deliveryUrl, sections }) {
  const tierLabel = { lite: 'Lite', standard: 'Standard', pro: 'Pro' }[tier] || tier;
  const secList = sections.map((s) => `  • ${SECTION_META[s].title}`).join('\n');
  const subject = `Your Instant Website Audit for ${host} is ready`;
  const text = `To: ${email}
Subject: ${subject}

Hi,

Thanks for your order. Your ${tierLabel} website audit for ${host} is ready.

View your report:
${deliveryUrl}

Included in this report:
${secList}

The report opens in any browser and is confidential to you. If any link
doesn't load or you'd like a section expanded, just reply to this email.

— Instant Website Audit
`;
  // NOTE: this is a DRAFT only. Sending is gated (send-to-list / outbound email
  // requires Steve sign-off). The server never auto-sends.
  return { subject, to: email, text, sent: false, note: 'DRAFT ONLY — sending is gated' };
}