← back to Marketing Command Center

modules/layouts/index.js

382 lines

'use strict';
// Marketing Command Center — layouts module
// id: layouts | On-Demand Layouts — returns self-contained HTML/CSS mockups
// No external deps; all template logic lives here.

const brand = require('../../lib/brand.js');
const { ink, cream, gold, sage, accent } = brand.palette;
const { display: displayFont, body: bodyFont } = brand.fonts;

// ── Template catalog ────────────────────────────────────────────────────────
const TEMPLATES = [
  { id: 'email-hero',         name: 'Email — Hero',           kind: 'email',   aspect: '3/1.2',  desc: 'Newsletter top banner with full-width hero image area' },
  { id: 'email-product-grid', name: 'Email — Product Grid',   kind: 'email',   aspect: '3/2.2',  desc: '3-up product feature grid with CTAs' },
  { id: 'email-announcement', name: 'Email — Announcement',   kind: 'email',   aspect: '3/1.8',  desc: 'Collection launch announcement with editorial headline' },
  { id: 'instagram-post',     name: 'Instagram Post',         kind: 'social',  aspect: '1/1',    desc: '1080×1080 square social post' },
  { id: 'instagram-story',    name: 'Instagram Story',        kind: 'social',  aspect: '9/16',   desc: '1080×1920 vertical story format' },
  { id: 'social-banner',      name: 'Social Banner (Wide)',   kind: 'social',  aspect: '4/1',    desc: '1200×300 wide banner for Facebook/LinkedIn/Twitter headers' },
];

// ── Shared snippet builders ──────────────────────────────────────────────────
function googleFontsLink() {
  return '<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,600;1,300;1,400&family=Inter:wght@400;600&display=swap" rel="stylesheet">';
}

function imagePlaceholder(imageUrl, width, height, alt) {
  if (imageUrl) {
    return `<img src="${esc(imageUrl)}" alt="${esc(alt || '')}" width="${width}" height="${height}" style="width:100%;height:100%;object-fit:cover;display:block;border:0;">`;
  }
  // CSS gradient placeholder — subtle, on-brand
  return `<div style="width:100%;height:100%;background:linear-gradient(135deg,${cream} 0%,#e8e0d0 40%,${gold}33 100%);display:flex;align-items:center;justify-content:center;">
    <span style="font-family:${displayFont};font-size:13px;color:${gold};letter-spacing:2px;text-transform:uppercase;opacity:.7;">Image</span>
  </div>`;
}

function canSpamFooter(physical) {
  const addr = physical || '{{physical_address}}';
  return `<tr><td style="background:${cream};padding:24px 40px;text-align:center;border-top:1px solid #e3ddd0;">
    <p style="margin:0 0 6px;font-family:${bodyFont};font-size:11px;color:#8a8275;line-height:1.6;">
      You are receiving this email because you opted in to communications from ${brand.name}.
    </p>
    <p style="margin:0;font-family:${bodyFont};font-size:11px;color:#8a8275;">
      ${esc(addr)} &nbsp;|&nbsp;
      <a href="{{unsubscribe_url}}" style="color:#8a8275;text-decoration:underline;">Unsubscribe</a>
    </p>
  </td></tr>`;
}

function emailWrapper(bodyRows, bgColor) {
  return `<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>${brand.name}</title>
${googleFontsLink()}
</head>
<body style="margin:0;padding:0;background:${bgColor || '#f7f4ee'};-webkit-text-size-adjust:100%;mso-line-height-rule:exactly;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background:${bgColor || '#f7f4ee'};padding:32px 0;">
  <tr><td align="center">
    <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="600" style="max-width:600px;width:100%;">
      ${bodyRows}
      ${canSpamFooter()}
    </table>
  </td></tr>
</table>
</body>
</html>`;
}

// ── Template renderers ───────────────────────────────────────────────────────
function renderEmailHero({ headline, subhead, cta, productName, imageUrl, palette }) {
  const p = resolvePalette(palette);
  const heroH = 320;
  return emailWrapper(`
    <!-- Pre-header -->
    <tr><td style="background:${p.bg};padding:12px 40px;text-align:center;border-bottom:1px solid ${p.line};">
      <span style="font-family:${bodyFont};font-size:11px;color:${p.muted};letter-spacing:2px;text-transform:uppercase;">${brand.name}</span>
    </td></tr>
    <!-- Logo bar -->
    <tr><td style="background:${p.bg};padding:28px 40px 24px;text-align:center;">
      <span style="font-family:${displayFont};font-size:28px;font-weight:300;color:${p.ink};letter-spacing:3px;text-transform:uppercase;">${brand.name}</span>
    </td></tr>
    <!-- Hero image -->
    <tr><td style="padding:0;height:${heroH}px;overflow:hidden;">
      ${imagePlaceholder(imageUrl, 600, heroH, productName || headline)}
    </td></tr>
    <!-- Headline block -->
    <tr><td style="background:${p.card};padding:44px 56px 36px;text-align:center;">
      <h1 style="margin:0 0 16px;font-family:${displayFont};font-size:42px;font-weight:300;color:${p.ink};line-height:1.15;letter-spacing:.5px;">${esc(headline || brand.tagline)}</h1>
      ${subhead ? `<p style="margin:0 0 28px;font-family:${bodyFont};font-size:15px;color:${p.muted};line-height:1.7;max-width:440px;display:inline-block;">${esc(subhead)}</p>` : ''}
      <br>
      <a href="{{cta_url}}" style="display:inline-block;background:${p.ink};color:${p.cream};font-family:${bodyFont};font-size:13px;font-weight:600;letter-spacing:1.5px;text-transform:uppercase;text-decoration:none;padding:14px 36px;border-radius:2px;margin-top:${subhead ? 0 : 8}px;">${esc(cta || brand.cta[0])}</a>
    </td></tr>
    <!-- Tagline rule -->
    <tr><td style="background:${p.bg};padding:20px 40px;text-align:center;border-top:1px solid ${p.line};">
      <span style="font-family:${displayFont};font-size:13px;font-style:italic;color:${p.muted};">${brand.tagline}</span>
    </td></tr>
  `, p.bg);
}

function renderEmailProductGrid({ headline, subhead, cta, palette }) {
  const p = resolvePalette(palette);
  // Placeholder demo products — SKUs must match the real DW format DW<code>-###### (e.g.
  // DWRW-361308, DWTT-74294): "DW" + vendor letters + dash + 6 digits. NOT "DW-GL-001".
  const products = [
    { name: 'Grasscloth Linen', sku: 'DWGL-100001', price: '$12.50/yd' },
    { name: 'Silk Damask',      sku: 'DWSD-100044', price: '$28.00/yd' },
    { name: 'Cork Weave',       sku: 'DWCW-100012', price: '$18.75/yd' },
  ];

  const productCells = products.map(pr => `
    <td width="178" valign="top" style="padding:0 4px;">
      <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="178" style="background:${p.card};border:1px solid ${p.line};">
        <tr><td style="height:200px;overflow:hidden;">${imagePlaceholder(null, 178, 200, pr.name)}</td></tr>
        <tr><td style="padding:14px 16px 18px;">
          <p style="margin:0 0 4px;font-family:${displayFont};font-size:18px;font-weight:400;color:${p.ink};line-height:1.2;">${pr.name}</p>
          <p style="margin:0 0 10px;font-family:${bodyFont};font-size:11px;color:${p.muted};letter-spacing:1px;text-transform:uppercase;">${pr.sku}</p>
          <p style="margin:0 0 14px;font-family:${bodyFont};font-size:13px;font-weight:600;color:${p.accent};">${pr.price}</p>
          <a href="{{product_url}}" style="font-family:${bodyFont};font-size:12px;font-weight:600;color:${p.ink};text-decoration:none;letter-spacing:1px;text-transform:uppercase;border-bottom:1px solid ${p.ink};">View Sample</a>
        </td></tr>
      </table>
    </td>`).join('');

  return emailWrapper(`
    <!-- Logo bar -->
    <tr><td style="background:${p.ink};padding:22px 40px;text-align:center;">
      <span style="font-family:${displayFont};font-size:24px;font-weight:300;color:${p.cream};letter-spacing:3px;text-transform:uppercase;">${brand.name}</span>
    </td></tr>
    <!-- Section headline -->
    <tr><td style="background:${p.bg};padding:36px 40px 28px;text-align:center;">
      <h2 style="margin:0 0 10px;font-family:${displayFont};font-size:34px;font-weight:300;color:${p.ink};letter-spacing:.3px;">${esc(headline || 'Featured Collection')}</h2>
      ${subhead ? `<p style="margin:0;font-family:${bodyFont};font-size:14px;color:${p.muted};line-height:1.6;">${esc(subhead)}</p>` : ''}
    </td></tr>
    <!-- Gold rule -->
    <tr><td style="padding:0 40px 28px;">
      <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
        <tr><td style="height:1px;background:${p.gold};"></td></tr>
      </table>
    </td></tr>
    <!-- Product grid -->
    <tr><td style="background:${p.bg};padding:0 36px 32px;">
      <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
        <tr>${productCells}</tr>
      </table>
    </td></tr>
    <!-- CTA -->
    <tr><td style="background:${p.bg};padding:8px 40px 36px;text-align:center;">
      <a href="{{cta_url}}" style="display:inline-block;background:${p.gold};color:${p.ink};font-family:${bodyFont};font-size:13px;font-weight:600;letter-spacing:1.5px;text-transform:uppercase;text-decoration:none;padding:14px 40px;border-radius:2px;">${esc(cta || brand.cta[1])}</a>
    </td></tr>
  `, p.bg);
}

function renderEmailAnnouncement({ headline, subhead, cta, productName, imageUrl, palette }) {
  const p = resolvePalette(palette);
  return emailWrapper(`
    <!-- Dark header bar -->
    <tr><td style="background:${p.ink};padding:20px 40px;text-align:center;">
      <span style="font-family:${bodyFont};font-size:10px;font-weight:600;color:${p.gold};letter-spacing:3px;text-transform:uppercase;">New Collection</span>
    </td></tr>
    <!-- Image - half width aesthetic -->
    <tr><td style="background:${p.card};padding:40px 40px 0;height:260px;overflow:hidden;">
      ${imagePlaceholder(imageUrl, 520, 260, productName || headline)}
    </td></tr>
    <!-- Announcement body -->
    <tr><td style="background:${p.card};padding:44px 56px 48px;border-bottom:3px solid ${p.gold};">
      <p style="margin:0 0 18px;font-family:${bodyFont};font-size:10px;font-weight:600;color:${p.gold};letter-spacing:3px;text-transform:uppercase;">Introducing</p>
      <h1 style="margin:0 0 20px;font-family:${displayFont};font-size:46px;font-weight:300;color:${p.ink};line-height:1.1;letter-spacing:.5px;">${esc(headline || (productName ? productName + ' Collection' : 'The New Collection'))}</h1>
      ${subhead ? `<p style="margin:0 0 30px;font-family:${bodyFont};font-size:15px;color:${p.muted};line-height:1.75;">${esc(subhead)}</p>` : `<p style="margin:0 0 30px;font-family:${bodyFont};font-size:15px;color:${p.muted};line-height:1.75;">An extraordinary study in texture and light — crafted for the spaces that define a life.</p>`}
      <a href="{{cta_url}}" style="display:inline-block;background:transparent;color:${p.ink};font-family:${bodyFont};font-size:13px;font-weight:600;letter-spacing:2px;text-transform:uppercase;text-decoration:none;padding:13px 32px;border:1.5px solid ${p.ink};border-radius:2px;">${esc(cta || brand.cta[2])}</a>
    </td></tr>
    <!-- Quote/signature strip -->
    <tr><td style="background:${p.bg};padding:28px 56px;text-align:center;">
      <p style="margin:0;font-family:${displayFont};font-size:15px;font-style:italic;color:${p.muted};">"${brand.tagline}"</p>
    </td></tr>
  `, p.bg);
}

function renderInstagramPost({ headline, subhead, cta, productName, imageUrl, palette }) {
  const p = resolvePalette(palette);
  return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=1080">
<title>${brand.name} — Instagram Post</title>
${googleFontsLink()}
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{width:1080px;height:1080px;overflow:hidden;background:${p.ink};}
.frame{position:relative;width:1080px;height:1080px;display:flex;flex-direction:column;}
.bg{position:absolute;inset:0;}
.overlay{position:absolute;inset:0;background:linear-gradient(to top,${p.ink}ee 0%,${p.ink}66 45%,transparent 100%);}
.content{position:relative;z-index:2;display:flex;flex-direction:column;height:100%;padding:72px;}
.logo{font-family:${displayFont};font-size:18px;font-weight:300;color:${p.cream};letter-spacing:4px;text-transform:uppercase;opacity:.9;}
.spacer{flex:1;}
.tag{font-family:${bodyFont};font-size:16px;font-weight:600;color:${p.gold};letter-spacing:3px;text-transform:uppercase;margin-bottom:20px;}
h1{font-family:${displayFont};font-size:88px;font-weight:300;color:${p.cream};line-height:1.0;letter-spacing:1px;margin-bottom:24px;}
.sub{font-family:${bodyFont};font-size:22px;color:${p.cream};opacity:.8;line-height:1.5;margin-bottom:40px;max-width:700px;}
.cta-pill{display:inline-block;background:${p.gold};color:${p.ink};font-family:${bodyFont};font-size:18px;font-weight:600;letter-spacing:2px;text-transform:uppercase;padding:18px 48px;border-radius:2px;}
.rule{width:60px;height:2px;background:${p.gold};margin-bottom:32px;}
</style>
</head>
<body>
<div class="frame">
  <div class="bg">${imagePlaceholder(imageUrl, 1080, 1080, productName || headline)}</div>
  <div class="overlay"></div>
  <div class="content">
    <div class="logo">${brand.name}</div>
    <div class="spacer"></div>
    ${productName ? `<div class="tag">${esc(productName)}</div>` : ''}
    <div class="rule"></div>
    <h1>${esc(headline || brand.tagline)}</h1>
    ${subhead ? `<p class="sub">${esc(subhead)}</p>` : ''}
    <div><span class="cta-pill">${esc(cta || brand.cta[0])}</span></div>
  </div>
</div>
</body>
</html>`;
}

function renderInstagramStory({ headline, subhead, cta, productName, imageUrl, palette }) {
  const p = resolvePalette(palette);
  return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=1080">
<title>${brand.name} — Instagram Story</title>
${googleFontsLink()}
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{width:1080px;height:1920px;overflow:hidden;background:${p.ink};}
.frame{position:relative;width:1080px;height:1920px;display:flex;flex-direction:column;}
.bg{position:absolute;inset:0;}
.overlay{position:absolute;inset:0;background:linear-gradient(to bottom,${p.ink}99 0%,transparent 30%,transparent 55%,${p.ink}f0 100%);}
.top-bar{position:relative;z-index:2;padding:80px 72px 0;display:flex;align-items:center;justify-content:space-between;}
.logo{font-family:${displayFont};font-size:20px;font-weight:300;color:${p.cream};letter-spacing:4px;text-transform:uppercase;}
.spacer{flex:1;}
.bottom{position:relative;z-index:2;padding:0 72px 100px;}
.eyebrow{font-family:${bodyFont};font-size:18px;font-weight:600;color:${p.gold};letter-spacing:3px;text-transform:uppercase;margin-bottom:24px;}
.rule{width:60px;height:2px;background:${p.gold};margin-bottom:36px;}
h1{font-family:${displayFont};font-size:100px;font-weight:300;color:${p.cream};line-height:1.0;letter-spacing:1px;margin-bottom:28px;}
.sub{font-family:${bodyFont};font-size:26px;color:${p.cream};opacity:.8;line-height:1.5;margin-bottom:52px;}
.cta-row{display:flex;align-items:center;gap:28px;}
.cta-pill{display:inline-block;background:${p.gold};color:${p.ink};font-family:${bodyFont};font-size:20px;font-weight:600;letter-spacing:2px;text-transform:uppercase;padding:22px 56px;border-radius:2px;}
.swipe{font-family:${bodyFont};font-size:18px;color:${p.cream};opacity:.6;letter-spacing:2px;text-transform:uppercase;}
</style>
</head>
<body>
<div class="frame">
  <div class="bg">${imagePlaceholder(imageUrl, 1080, 1920, productName || headline)}</div>
  <div class="overlay"></div>
  <div class="top-bar">
    <div class="logo">${brand.name}</div>
  </div>
  <div class="spacer"></div>
  <div class="bottom">
    ${productName ? `<div class="eyebrow">${esc(productName)}</div>` : ''}
    <div class="rule"></div>
    <h1>${esc(headline || brand.tagline)}</h1>
    ${subhead ? `<p class="sub">${esc(subhead)}</p>` : ''}
    <div class="cta-row">
      <span class="cta-pill">${esc(cta || brand.cta[0])}</span>
      <span class="swipe">↑ Swipe Up</span>
    </div>
  </div>
</div>
</body>
</html>`;
}

function renderSocialBanner({ headline, subhead, cta, productName, imageUrl, palette }) {
  const p = resolvePalette(palette);
  return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=1200">
<title>${brand.name} — Social Banner</title>
${googleFontsLink()}
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{width:1200px;height:300px;overflow:hidden;background:${p.ink};}
.frame{position:relative;width:1200px;height:300px;display:flex;align-items:stretch;}
.bg-left{position:relative;width:420px;flex-shrink:0;overflow:hidden;}
.divider{width:1px;background:${p.gold};flex-shrink:0;z-index:2;}
.content{flex:1;background:${p.ink};padding:0 56px;display:flex;flex-direction:column;justify-content:center;}
.logo-rule{display:flex;align-items:center;gap:20px;margin-bottom:24px;}
.logo{font-family:${displayFont};font-size:14px;font-weight:300;color:${p.cream};letter-spacing:4px;text-transform:uppercase;white-space:nowrap;}
.rule{flex:1;height:1px;background:${p.gold};opacity:.4;}
h1{font-family:${displayFont};font-size:44px;font-weight:300;color:${p.cream};line-height:1.1;letter-spacing:.5px;margin-bottom:12px;}
.sub{font-family:${bodyFont};font-size:14px;color:${p.cream};opacity:.65;line-height:1.5;margin-bottom:24px;}
.cta-link{font-family:${bodyFont};font-size:12px;font-weight:600;color:${p.gold};letter-spacing:2px;text-transform:uppercase;text-decoration:none;border-bottom:1px solid ${p.gold};padding-bottom:2px;}
${productName ? `.product-tag{font-family:${bodyFont};font-size:11px;font-weight:600;color:${p.gold};letter-spacing:3px;text-transform:uppercase;margin-bottom:10px;}` : ''}
</style>
</head>
<body>
<div class="frame">
  <div class="bg-left">${imagePlaceholder(imageUrl, 420, 300, productName || headline)}</div>
  <div class="divider"></div>
  <div class="content">
    <div class="logo-rule">
      <span class="logo">${brand.name}</span>
      <span class="rule"></span>
    </div>
    ${productName ? `<div class="product-tag">${esc(productName)}</div>` : ''}
    <h1>${esc(headline || brand.tagline)}</h1>
    ${subhead ? `<p class="sub">${esc(subhead)}</p>` : ''}
    <a href="{{cta_url}}" class="cta-link">${esc(cta || brand.cta[1])} →</a>
  </div>
</div>
</body>
</html>`;
}

// ── Palette resolver ─────────────────────────────────────────────────────────
const BASE_PALETTE = {
  ink: ink, cream: cream, gold: gold, sage: sage, accent: accent,
  bg: '#f7f4ee', card: '#ffffff', line: '#e3ddd0', muted: '#8a8275',
};
function resolvePalette(override) {
  if (!override) return BASE_PALETTE;
  return Object.assign({}, BASE_PALETTE, override);
}

// ── Escape HTML ──────────────────────────────────────────────────────────────
function esc(str) {
  if (str == null) return '';
  return String(str)
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
}

// ── Renderer dispatch ────────────────────────────────────────────────────────
const RENDERERS = {
  'email-hero':         renderEmailHero,
  'email-product-grid': renderEmailProductGrid,
  'email-announcement': renderEmailAnnouncement,
  'instagram-post':     renderInstagramPost,
  'instagram-story':    renderInstagramStory,
  'social-banner':      renderSocialBanner,
};

// ── Module export (MODULE CONTRACT) ─────────────────────────────────────────
module.exports = {
  id:    'layouts',
  title: 'On-Demand Layouts',
  icon:  '🎨',

  mount(router) {
    // GET /api/layouts/templates — list all templates
    router.get('/templates', (_req, res) => {
      res.json({ templates: TEMPLATES });
    });

    // POST /api/layouts/render — render a template to HTML
    // Body: { template, headline, subhead, cta, productName, imageUrl?, palette? }
    router.post('/render', (req, res) => {
      const { template, headline, subhead, cta, productName, imageUrl, palette } = req.body || {};

      if (!template) {
        return res.status(400).json({ error: 'template is required' });
      }
      const renderer = RENDERERS[template];
      if (!renderer) {
        return res.status(404).json({ error: `Unknown template: ${template}`, available: Object.keys(RENDERERS) });
      }

      try {
        const html = renderer({ headline, subhead, cta, productName, imageUrl, palette });
        res.json({ html, template, mock: false });
      } catch (err) {
        res.status(500).json({ error: err.message });
      }
    });
  },
};