← back to Marketing Command Center

modules/templates/index.js

435 lines

// templates — Campaign Templates module for the Marketing Command Center.
// A curated library of 8 pre-built luxury DW email campaigns. Each template has
// a brand-voice subject + body (3 short editorial paragraphs + a CTA), a set of
// named {{placeholders}} the user can fill in, and a default value for each.
//
// STAGED ONLY. Nothing here sends. POST /:id/use returns the filled subject + body
// for the user to copy/paste into Constant Contact (where Steve gates the send).
//
// Routes (mounted at /api/templates by the shell):
//   GET  /meta                 — module metadata + the gate message
//   GET  /list                 — all templates (no body if `?summary=1`)
//   GET  /:id                  — one template (full subject + body + placeholders)
//   POST /:id/use {fields}     — returns filled template + a CAN-SPAM reminder
const brand = require('../../lib/brand.js');

// ── Templates ────────────────────────────────────────────────────────────────
// Each template is fully self-contained — no nested partials, no shared chrome.
// Bodies are written in the DW voice (elevated, editorial, tactile, confident,
// never gimmicky). Subjects sit in the 28–55 char editorial sweet spot when the
// placeholders are filled with the default values below.
const TEMPLATES = [
  {
    id: 'welcome-designer-1',
    title: 'Welcome — Designer (Part 1 of 2)',
    category: 'Welcome Series',
    sequencePosition: 'Day 0',
    description: 'Sent the moment a designer\'s trade account is approved. Warm, atelier-toned welcome that frames DW as a house, not a catalog.',
    audience: 'interior designers',
    productLines: ['grasscloth', 'silk', 'cork', 'linen'],
    cta: 'Request memo samples',
    placeholders: [
      { key: 'first_name', label: 'Designer first name', default: 'Designer' },
      { key: 'studio_name', label: 'Studio / firm name',  default: 'your studio' },
      { key: 'sender_name', label: 'Sender (from)',        default: 'Steve' },
      { key: 'cta_url',     label: 'CTA link',             default: 'https://designerwallcoverings.com/pages/trade' },
    ],
    subject: 'Welcome to the Designer Wallcoverings atelier',
    preheader: 'Trade access, memo samples, and a curated archive for {{studio_name}}.',
    body:
`Hello {{first_name}},

Welcome to Designer Wallcoverings. We built this house for designers who specify with intention — the studios that treat the wall as the most generous surface in the room. {{studio_name}} now has full trade access to the archive: grasscloth, silk, cork, linen, glass bead, flocked, mural and metallic, sourced from the makers we trust.

You'll find every pattern carries a real width, a real fire rating, and a real memo at our cost — because a sample held against actual light, at the right scale, is the only honest way to specify. Order what you'd like to see; we ship same day.

A good first move is a curated memo box around the project on your board. Tell us the room, the light, and the mood, and we'll pull six to ten options worth living with.

[ {{cta_url}} — Request memo samples ]

With our regards,
{{sender_name}}
Designer Wallcoverings`,
  },

  {
    id: 'welcome-designer-2',
    title: 'Welcome — Designer (Part 2 of 2)',
    category: 'Welcome Series',
    sequencePosition: 'Day 3',
    description: 'Follow-up to the welcome email — gives a designer three concrete starting points (rooms × wallcoverings) to demystify the archive.',
    audience: 'interior designers',
    productLines: ['grasscloth', 'mural', 'metallic'],
    cta: 'Explore the collection',
    placeholders: [
      { key: 'first_name',     label: 'Designer first name', default: 'Designer' },
      { key: 'collection_name',label: 'Featured collection', default: 'the new arrivals' },
      { key: 'sender_name',    label: 'Sender (from)',       default: 'Steve' },
      { key: 'cta_url',        label: 'CTA link',            default: 'https://designerwallcoverings.com/collections/new-arrivals' },
    ],
    subject: 'Three rooms, three wallcoverings — a place to start',
    preheader: 'Concrete pairings from {{collection_name}} so the archive feels smaller.',
    body:
`{{first_name}},

The archive is large; the project on your desk is specific. To make the first specification easier, three pairings we'd reach for this season.

For the entry that should feel like an arrival — a deep, hand-troweled grasscloth, weighed so the seams disappear and the room hums at low light. For the library that wants to read more like a study — a textural mural with a quiet repeat, scaled to the long wall. For the powder room that should earn a photograph — a soft metallic, listed for damp service, that turns honest gold under the sconces.

Each of these lives in {{collection_name}}. If one feels right, send the room and we'll pull memos at the size you need.

[ {{cta_url}} — Explore {{collection_name}} ]

Warmly,
{{sender_name}}
Designer Wallcoverings`,
  },

  {
    id: 'memo-followup-day0',
    title: 'Memo Follow-Up — Day 0 (Shipped)',
    category: 'Memo Sample Sequence',
    sequencePosition: 'Day 0 (ship confirmation)',
    description: 'Transactional confirmation the moment memos ship. Sets the right reading conditions (light, distance, time of day) so the samples are evaluated fairly.',
    audience: 'interior designers',
    productLines: [],
    cta: 'View tracking',
    placeholders: [
      { key: 'first_name',   label: 'Recipient first name', default: 'Designer' },
      { key: 'sample_count', label: 'Sample count',         default: '6' },
      { key: 'tracking_url', label: 'Tracking URL',         default: 'https://designerwallcoverings.com/account/orders' },
      { key: 'sender_name',  label: 'Sender (from)',        default: 'Steve' },
    ],
    subject: 'Your memo samples are on the way',
    preheader: '{{sample_count}} memos shipped today — and how to read them at scale.',
    body:
`{{first_name}},

Your {{sample_count}} memos shipped today. A note on reading them — a swatch in hand is closer to a paint chip than to the finished wall, so a little staging pays back.

Tape each memo to the wall at the height it will live. Look at it in the morning, the late afternoon, and under the sconces you actually use; texture and metallic read very differently across the day. Stand at the distance the room is normally seen from, not at arm's length.

If two patterns earn a second look, ask for a larger drop. We'll cut a meter and ship overnight — no charge on the swap.

[ {{tracking_url}} — View tracking ]

{{sender_name}}
Designer Wallcoverings`,
  },

  {
    id: 'memo-followup-day7',
    title: 'Memo Follow-Up — Day 7 (Check-in)',
    category: 'Memo Sample Sequence',
    sequencePosition: 'Day 7',
    description: 'Soft check-in a week after memos arrive — never pushy. Offers a larger drop, a swap, or a trade consult; positions the studio as a partner, not a vendor.',
    audience: 'interior designers',
    productLines: [],
    cta: 'Book a trade consultation',
    placeholders: [
      { key: 'first_name',  label: 'Recipient first name', default: 'Designer' },
      { key: 'sender_name', label: 'Sender (from)',        default: 'Steve' },
      { key: 'cta_url',     label: 'CTA link',             default: 'https://designerwallcoverings.com/pages/trade-consultation' },
    ],
    subject: 'How are the samples reading at scale?',
    preheader: 'A larger drop, a swap, or a quick call — whatever helps the decision.',
    body:
`{{first_name}},

Wanted to check on the memos. A week is usually enough time to know which one keeps catching your eye and which two were never quite right.

If a pattern is close — within reach for the room but not yet certain — let us cut a meter so you can see the repeat in full. If none of them are, send the project notes and we'll re-curate against the brief. Either swap is on us.

For the rooms where a wallcovering is doing more than dressing the wall (acoustics, light, a registered repeat across two surfaces), a fifteen-minute call with the studio often saves a full sampling round.

[ {{cta_url}} — Book a trade consultation ]

{{sender_name}}
Designer Wallcoverings`,
  },

  {
    id: 'memo-followup-day21',
    title: 'Memo Follow-Up — Day 21 (Final)',
    category: 'Memo Sample Sequence',
    sequencePosition: 'Day 21',
    description: 'Final touch in the memo sequence — graceful, no pressure. Re-opens the door, recommends one related collection, exits cleanly if the project moved on.',
    audience: 'interior designers',
    productLines: [],
    cta: 'Explore the collection',
    placeholders: [
      { key: 'first_name',     label: 'Recipient first name', default: 'Designer' },
      { key: 'collection_name',label: 'Related collection',   default: 'the Atelier Edit' },
      { key: 'sender_name',    label: 'Sender (from)',        default: 'Steve' },
      { key: 'cta_url',        label: 'CTA link',             default: 'https://designerwallcoverings.com/collections' },
    ],
    subject: 'Still considering? A note from the atelier',
    preheader: 'No pressure — and one related collection worth a second look.',
    body:
`{{first_name}},

A last note on the memos. Projects move — sometimes the room changes, sometimes the wallcovering does, sometimes the client surprises everyone. No pressure either way.

If the brief shifted and the original options no longer fit, the closest neighbours to that conversation now live in {{collection_name}}. The textures are quieter, the colour story is warmer, and the widths run a little broader for the larger walls you mentioned.

When the project picks back up — this one or the next — we'll be here, and a fresh memo box is always one note away.

[ {{cta_url}} — Explore {{collection_name}} ]

With our regards,
{{sender_name}}
Designer Wallcoverings`,
  },

  {
    id: 'vip-preview',
    title: 'New Collection — VIP Preview',
    category: 'Collection Launch',
    sequencePosition: 'Pre-launch (7 days before public)',
    description: 'Exclusive preview to top-tier designers + commercial spec contacts before a collection ships to the trade at large. Frames the access as earned, not gimmicky.',
    audience: 'interior designers',
    productLines: ['grasscloth', 'silk', 'mural'],
    cta: 'Reserve memo samples',
    placeholders: [
      { key: 'first_name',     label: 'Recipient first name', default: 'Designer' },
      { key: 'collection_name',label: 'Collection name',      default: 'the Marais Collection' },
      { key: 'designer_name',  label: 'Featured designer/house', default: 'the studio' },
      { key: 'launch_date',    label: 'Public launch date',   default: 'next Tuesday' },
      { key: 'sender_name',    label: 'Sender (from)',        default: 'Steve' },
      { key: 'cta_url',        label: 'CTA link',             default: 'https://designerwallcoverings.com/collections/preview' },
    ],
    subject: 'Preview: {{collection_name}} — before it ships to the trade',
    preheader: 'Seven days early access. Memos open this morning.',
    body:
`{{first_name}},

A small note before {{collection_name}} reaches the wider trade on {{launch_date}}. Because of how often this list specifies our work, we're opening the archive seven days early.

{{collection_name}} was developed with {{designer_name}} and reads like a single, sustained idea — a tighter palette than usual, larger repeats, and a few patterns sized for the proportions modern projects actually have. The grounds are unusually quiet; the highlights do all of the heavy lifting.

Memos are live this morning. Order what you'd like to see, and the patterns will land before the collection is announced anywhere else.

[ {{cta_url}} — Reserve memo samples ]

{{sender_name}}
Designer Wallcoverings`,
  },

  {
    id: 'seasonal-inspiration',
    title: 'Seasonal Inspiration — Editorial',
    category: 'Editorial / Newsletter',
    sequencePosition: 'Quarterly',
    description: 'Quarterly editorial letter — what the studio is reading, specifying, and noticing this season. Sells nothing directly; builds the brand voice that sells everything later.',
    audience: 'interior designers',
    productLines: ['grasscloth', 'mural', 'linen'],
    cta: 'Explore the collection',
    placeholders: [
      { key: 'first_name', label: 'Recipient first name', default: 'Designer' },
      { key: 'season',     label: 'Season',               default: 'Spring' },
      { key: 'year',       label: 'Year',                 default: '2026' },
      { key: 'theme',      label: 'Season theme',         default: 'quieter rooms, deeper texture' },
      { key: 'sender_name',label: 'Sender (from)',        default: 'Steve' },
      { key: 'cta_url',    label: 'CTA link',             default: 'https://designerwallcoverings.com/collections/new-arrivals' },
    ],
    subject: 'Notes from the studio — {{season}} {{year}}',
    preheader: 'What we\'re reading, specifying, and watching this {{season}}.',
    body:
`{{first_name}},

Notes from the studio for {{season}} {{year}} — what we're watching this quarter is {{theme}}.

The projects coming across the desk are quieter than they were a year ago. Designers are pulling back on contrast and leaning into surface — wider grasscloth seams disguised against deeper grounds, murals scaled to the long wall rather than the feature wall, linens treated more like a painted finish than a paper. The room is doing less, but doing it more carefully.

If the brief on your board is in that conversation, three places in the archive worth a look: the grasscloth weights above 9-oz, the new mural drops in the 12-foot width, and any of the linen colourways with a hand-dyed ground. Memos for any of them ship same day.

[ {{cta_url}} — Explore the collection ]

Until next quarter,
{{sender_name}}
Designer Wallcoverings`,
  },

  {
    id: 're-engagement',
    title: 'Re-engagement — Quiet Contact',
    category: 'Lifecycle',
    sequencePosition: 'Triggered (90+ days inactive)',
    description: 'Gentle re-engagement for contacts who have been quiet for 90+ days. Never guilty, never gimmicky — restates the trade benefit and offers one easy first step.',
    audience: 'interior designers',
    productLines: [],
    cta: 'Request memo samples',
    placeholders: [
      { key: 'first_name', label: 'Recipient first name', default: 'Designer' },
      { key: 'sender_name',label: 'Sender (from)',        default: 'Steve' },
      { key: 'cta_url',    label: 'CTA link',             default: 'https://designerwallcoverings.com/pages/memo-samples' },
    ],
    subject: 'We saved a place on your moodboard',
    preheader: 'A new archive, the same trade pricing, and memos on us.',
    body:
`{{first_name}},

It's been a while, and we wanted to send a quiet note rather than another launch.

A lot of the archive has turned over since we last spoke — the grasscloth weights are heavier, the mural drops are wider, and a handful of collections we knew you liked have new colourways. Your trade pricing and same-day memo shipping are still in place; nothing about your account has changed.

If a project on your desk could use a fresh starting point, send the brief and we'll curate a small memo box around it. No commitment, no follow-up cadence — just a useful first look.

[ {{cta_url}} — Request memo samples ]

Glad to hear from you whenever the time is right,
{{sender_name}}
Designer Wallcoverings`,
  },
];

// ── Placeholder fill ────────────────────────────────────────────────────────
// Replace every {{key}} in `text` with fields[key]. Unknown placeholders are
// left intact (with the {{}} preserved) so the user immediately sees what still
// needs to be filled in before they paste into Constant Contact.
function fill(text, fields) {
  return String(text || '').replace(/\{\{\s*([a-z0-9_]+)\s*\}\}/gi, (m, key) => {
    const v = fields && Object.prototype.hasOwnProperty.call(fields, key) ? fields[key] : undefined;
    if (v === undefined || v === null || String(v).trim() === '') return m;
    return String(v);
  });
}

// Build the default-fields object from a template's placeholders array, then
// overlay anything the caller provided.
function defaultsFor(tpl) {
  const out = {};
  for (const p of tpl.placeholders || []) out[p.key] = p.default || '';
  return out;
}

// Return the count of placeholders still unfilled in the rendered text (i.e.
// `{{key}}` tokens the caller didn't supply). Used as a hint in the UI so the
// designer knows whether the template is ready to copy.
function unfilledTokens(text) {
  const set = new Set();
  const re = /\{\{\s*([a-z0-9_]+)\s*\}\}/gi;
  let m;
  while ((m = re.exec(String(text || ''))) !== null) set.add(m[1]);
  return Array.from(set);
}

function summarize(tpl) {
  return {
    id: tpl.id,
    title: tpl.title,
    category: tpl.category,
    sequencePosition: tpl.sequencePosition,
    description: tpl.description,
    audience: tpl.audience,
    productLines: tpl.productLines || [],
    cta: tpl.cta,
    subject: tpl.subject,
    preheader: tpl.preheader || '',
    placeholders: tpl.placeholders || [],
  };
}

// ── Module ──────────────────────────────────────────────────────────────────
module.exports = {
  id: 'templates',
  title: 'Campaign Templates',
  icon: '📋',

  // exposed for unit testing / introspection only
  _templates: TEMPLATES,
  _fill: fill,

  mount(router) {
    const guard = (handler) => async (req, res) => {
      try { await handler(req, res); }
      catch (e) { res.status(500).json({ error: e.message }); }
    };

    // Module metadata — categories, brand voice context, gate copy.
    router.get('/meta', guard(async (_req, res) => {
      const categories = Array.from(new Set(TEMPLATES.map(t => t.category)));
      res.json({
        staged: true,
        count: TEMPLATES.length,
        categories,
        brand: {
          name: brand.name,
          tagline: brand.tagline,
          voice: brand.voice,
          compliance: brand.compliance,
        },
        gateMessage: 'Campaign Templates are STAGED — this module drafts subject + body in the DW voice for you to copy into Constant Contact. Steve gates the actual send. CAN-SPAM footer (physical mailing address + working unsubscribe) must be added before any campaign ships.',
      });
    }));

    // Full list with subject + body — the front-end uses this to render cards
    // and let the user open one for the "Use this" flow. `?summary=1` returns
    // a lighter shape (no body) for any future list-only views.
    router.get('/list', guard(async (req, res) => {
      const summary = String(req.query.summary || '') === '1';
      res.json({
        staged: true,
        count: TEMPLATES.length,
        templates: TEMPLATES.map(t => summary
          ? summarize(t)
          : { ...summarize(t), body: t.body }),
      });
    }));

    // Single template — full subject + body + placeholders.
    router.get('/:id', guard(async (req, res) => {
      const tpl = TEMPLATES.find(t => t.id === req.params.id);
      if (!tpl) return res.status(404).json({ error: 'template not found' });
      res.json({ staged: true, template: { ...summarize(tpl), body: tpl.body } });
    }));

    // Fill a template. Body: { fields: { key: value, ... } }.
    // Returns the filled subject + body, plus the placeholders still unfilled
    // and a CAN-SPAM reminder. NEVER sends — this is the "Use this" return.
    router.post('/:id/use', guard(async (req, res) => {
      const tpl = TEMPLATES.find(t => t.id === req.params.id);
      if (!tpl) return res.status(404).json({ error: 'template not found' });

      const incoming = (req.body && typeof req.body === 'object' && req.body.fields && typeof req.body.fields === 'object')
        ? req.body.fields : {};
      // Defaults first so an empty form still produces a fully-readable draft.
      const fields = { ...defaultsFor(tpl), ...incoming };

      // Strip undefined / empty-string from incoming so defaults win for blanks.
      for (const k of Object.keys(incoming)) {
        const v = incoming[k];
        if (v === undefined || v === null || String(v).trim() === '') {
          fields[k] = defaultsFor(tpl)[k] || '';
        }
      }

      const subject  = fill(tpl.subject, fields);
      const preheader = fill(tpl.preheader || '', fields);
      const body     = fill(tpl.body, fields);

      const unfilled = Array.from(new Set([
        ...unfilledTokens(subject),
        ...unfilledTokens(body),
        ...unfilledTokens(preheader),
      ]));

      res.json({
        staged: true,
        templateId: tpl.id,
        title: tpl.title,
        category: tpl.category,
        cta: tpl.cta,
        fields,
        subject,
        preheader,
        body,
        unfilled,
        complianceReminder: brand.compliance,
        sent: false,
        sendNote: 'STAGED draft only — paste into Constant Contact, add the CAN-SPAM footer, and route through the gated send flow. This endpoint never delivers.',
      });
    }));
  },
};