← back to Marketing Command Center
4 panels built (parallel agents): Constant Contact (mock+gated sends), Marketing Calendar, Suggested Copy, On-Demand Layouts — all mount + smoke-tested
e8363cede9c41056413fae889c098ff8e55b09e8 · 2026-06-10 07:58:11 -0700 · Steve
Files touched
A data/calendar-dates.jsonA data/calendar-schedule.jsonA modules/calendar/index.jsA modules/constant-contact/index.jsA modules/copy/index.jsA modules/layouts/index.jsA public/panels/calendar.htmlA public/panels/calendar.jsA public/panels/constant-contact.htmlA public/panels/constant-contact.jsA public/panels/copy.htmlA public/panels/copy.jsA public/panels/layouts.htmlA public/panels/layouts.js
Diff
commit e8363cede9c41056413fae889c098ff8e55b09e8
Author: Steve <steve@designerwallcoverings.com>
Date: Wed Jun 10 07:58:11 2026 -0700
4 panels built (parallel agents): Constant Contact (mock+gated sends), Marketing Calendar, Suggested Copy, On-Demand Layouts — all mount + smoke-tested
---
data/calendar-dates.json | 60 ++++++
data/calendar-schedule.json | 1 +
modules/calendar/index.js | 141 ++++++++++++++
modules/constant-contact/index.js | 330 +++++++++++++++++++++++++++++++
modules/copy/index.js | 310 +++++++++++++++++++++++++++++
modules/layouts/index.js | 379 ++++++++++++++++++++++++++++++++++++
public/panels/calendar.html | 114 +++++++++++
public/panels/calendar.js | 226 +++++++++++++++++++++
public/panels/constant-contact.html | 58 ++++++
public/panels/constant-contact.js | 166 ++++++++++++++++
public/panels/copy.html | 42 ++++
public/panels/copy.js | 135 +++++++++++++
public/panels/layouts.html | 109 +++++++++++
public/panels/layouts.js | 272 ++++++++++++++++++++++++++
14 files changed, 2343 insertions(+)
diff --git a/data/calendar-dates.json b/data/calendar-dates.json
new file mode 100644
index 0000000..beebce1
--- /dev/null
+++ b/data/calendar-dates.json
@@ -0,0 +1,60 @@
+{
+ "fixed": [
+ { "month": 1, "day": 1, "title": "New Year's Day", "type": "holiday", "suggestion": "“A New Year, A New Wall” — open with a fresh-start grasscloth refresh email." },
+ { "month": 1, "day": 6, "title": "Winter Design Reset", "type": "awareness", "suggestion": "Post-holiday hush: spotlight serene linen + silk neutrals for a calm reset." },
+ { "month": 1, "day": 15, "title": "January White Sale Season", "type": "retail", "suggestion": "Reframe the classic white sale as a luxury memo-sample event for designers." },
+ { "month": 1, "day": 25, "title": "Pantone Color of the Year Spotlight", "type": "trade", "suggestion": "Pair the year's hue with metallic + flocked papers in an editorial lookbook." },
+
+ { "month": 2, "day": 2, "title": "Spring Market Prep", "type": "trade", "suggestion": "Tease upcoming High Point arrivals to your trade list — early-access memo offer." },
+ { "month": 2, "day": 14, "title": "Valentine's Day", "type": "holiday", "suggestion": "“Fall in love with your walls” — romantic glass-bead + silk vignette on Instagram." },
+ { "month": 2, "day": 20, "title": "Presidents' Day Sale", "type": "retail", "suggestion": "Trade-pricing weekend on mural collections; emphasize spec-ready lead times." },
+
+ { "month": 3, "day": 1, "title": "Spring Refresh Kickoff", "type": "awareness", "suggestion": "Botanical murals + soft cork — “Bring the season indoors” seasonal feature." },
+ { "month": 3, "day": 17, "title": "St. Patrick's Day", "type": "holiday", "suggestion": "Subtle sage + emerald grasscloth moodboard; keep it elevated, not gimmicky." },
+ { "month": 3, "day": 20, "title": "First Day of Spring", "type": "awareness", "suggestion": "Launch the spring collection lookbook with a Request-Memo-Samples CTA." },
+
+ { "month": 4, "day": 1, "title": "Spring Cleaning / Renewal", "type": "awareness", "suggestion": "Reposition as 'spring redesign' — before/after with a fresh metallic accent wall." },
+ { "month": 4, "day": 22, "title": "Earth Day", "type": "awareness", "suggestion": "Feature natural fibers: grasscloth, cork, linen — sustainable luxury story." },
+ { "month": 4, "day": 28, "title": "High Point Market (Spring)", "type": "trade", "suggestion": "Trade-show recap email: standout introductions + book a trade consultation." },
+
+ { "month": 5, "day": 1, "title": "May Design Season", "type": "awareness", "suggestion": "Editorial feature on color-drenched rooms in silk + flocked papers." },
+ { "month": 5, "day": 12, "title": "Mother's Day", "type": "holiday", "suggestion": "“Spaces that nurture” — soft, tactile interiors gift-of-design angle." },
+ { "month": 5, "day": 26, "title": "Memorial Day Sale", "type": "retail", "suggestion": "Kick-off-of-summer trade event; highlight outdoor-room-adjacent murals." },
+
+ { "month": 6, "day": 1, "title": "Summer Entertaining", "type": "awareness", "suggestion": "Dining-room + powder-room statement walls for the entertaining season." },
+ { "month": 6, "day": 15, "title": "Father's Day", "type": "holiday", "suggestion": "Refined study + library moodboard — leather-look + deep flocked tones." },
+ { "month": 6, "day": 21, "title": "First Day of Summer", "type": "awareness", "suggestion": "Light, airy linen + glass-bead palettes; “summer-light interiors.”" },
+
+ { "month": 7, "day": 4, "title": "Independence Day", "type": "holiday", "suggestion": "Americana done luxe — heritage damask + metallic in a restrained palette." },
+ { "month": 7, "day": 11, "title": "Mid-Summer Trade Push", "type": "retail", "suggestion": "Quiet-season memo-sample drive to keep designers spec-ing through summer." },
+ { "month": 7, "day": 24, "title": "Fall Preview Tease", "type": "trade", "suggestion": "Sneak-peek the autumn collection to your trade list before the rush." },
+
+ { "month": 8, "day": 1, "title": "Back-to-Business Season", "type": "awareness", "suggestion": "Hospitality + commercial spec story — durable luxury for contract jobs." },
+ { "month": 8, "day": 15, "title": "Late Summer Refresh", "type": "awareness", "suggestion": "“One wall, whole new room” — single-accent-wall mural campaign." },
+ { "month": 8, "day": 28, "title": "Fall Collection Launch", "type": "trade", "suggestion": "Headline the autumn drop: rich metallics, deep grasscloth, moody murals." },
+
+ { "month": 9, "day": 1, "title": "Fall Design Season Kickoff", "type": "awareness", "suggestion": "“Layer for autumn” — texture-forward editorial on cork + flocked papers." },
+ { "month": 9, "day": 22, "title": "First Day of Fall", "type": "awareness", "suggestion": "Warm, enveloping rooms feature; book-a-trade-consultation CTA." },
+
+ { "month": 10, "day": 14, "title": "High Point Market (Fall)", "type": "trade", "suggestion": "Market introductions recap + exclusive trade early-access to new lines." },
+ { "month": 10, "day": 20, "title": "Cozy Season", "type": "awareness", "suggestion": "Bedrooms + libraries in tactile silk and deep tones — “rooms to retreat to.”" },
+ { "month": 10, "day": 31, "title": "Halloween", "type": "holiday", "suggestion": "Dramatic dark murals + moody damask — keep it sophisticated, not kitsch." },
+
+ { "month": 11, "day": 1, "title": "Holiday Hosting Prep", "type": "awareness", "suggestion": "Dining-room reveal series before the holidays — statement-wall how-to." },
+ { "month": 11, "day": 11, "title": "Veterans Day", "type": "holiday", "suggestion": "Quiet brand-values note; pair with a heritage-craft wallcovering story." },
+ { "month": 11, "day": 30, "title": "Small Business Saturday", "type": "retail", "suggestion": "Celebrate the trade artisans behind the papers — maker spotlight." },
+
+ { "month": 12, "day": 1, "title": "Holiday Gifting Season", "type": "retail", "suggestion": "“The gift of a transformed room” — design-gift-card + consultation offer." },
+ { "month": 12, "day": 21, "title": "Winter Solstice", "type": "awareness", "suggestion": "Candlelit, jewel-toned interiors in silk + glass bead — the longest night, beautifully wrapped." },
+ { "month": 12, "day": 25, "title": "Christmas", "type": "holiday", "suggestion": "Warm holiday wishes to the trade; soft brand-only touch, no hard sell." },
+ { "month": 12, "day": 31, "title": "New Year's Eve / Year-End", "type": "awareness", "suggestion": "Year-in-rooms retrospective + tease next year's collection." }
+ ],
+ "floating": [
+ { "month": 2, "weekday": 1, "ordinal": 3, "title": "Presidents' Day (Federal)", "type": "holiday", "suggestion": "Federal-holiday timing for the Presidents' Day trade event." },
+ { "month": 5, "weekday": 1, "ordinal": -1, "title": "Memorial Day (Federal)", "type": "holiday", "suggestion": "Unofficial start of summer — align the sale send to the long weekend." },
+ { "month": 9, "weekday": 1, "ordinal": 1, "title": "Labor Day Sale", "type": "retail", "suggestion": "End-of-summer trade event; reposition toward the fall spec season." },
+ { "month": 11, "weekday": 4, "ordinal": 4, "title": "Thanksgiving", "type": "holiday", "suggestion": "Gratitude note to designers + warm dining-room mural feature." },
+ { "month": 11, "weekday": 4, "ordinal": 4, "offset": 1, "title": "Black Friday", "type": "retail", "suggestion": "Restrained luxury offer — memo-sample bundle, not a doorbuster." },
+ { "month": 11, "weekday": 4, "ordinal": 4, "offset": 4, "title": "Cyber Monday", "type": "retail", "suggestion": "Online-exclusive trade pricing on murals; emphasize easy digital spec." }
+ ]
+}
diff --git a/data/calendar-schedule.json b/data/calendar-schedule.json
new file mode 100644
index 0000000..fe51488
--- /dev/null
+++ b/data/calendar-schedule.json
@@ -0,0 +1 @@
+[]
diff --git a/modules/calendar/index.js b/modules/calendar/index.js
new file mode 100644
index 0000000..c9d4679
--- /dev/null
+++ b/modules/calendar/index.js
@@ -0,0 +1,141 @@
+// Marketing Calendar module — curated retail/holiday/awareness dates for a luxury
+// wallcoverings house + a lightweight campaign-schedule store. Self-contained per
+// the MODULE CONTRACT (see README.md). No outside sends here — purely planning.
+const fs = require('fs');
+const path = require('path');
+
+const DATA_DIR = path.join(__dirname, '..', '..', 'data');
+const DATES_FILE = path.join(DATA_DIR, 'calendar-dates.json');
+const SCHEDULE_FILE = path.join(DATA_DIR, 'calendar-schedule.json');
+
+// ── tiny JSON persistence helpers ────────────────────────────────────────────
+function readJson(file, fallback) {
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); }
+ catch { return fallback; }
+}
+function writeJson(file, val) {
+ try { fs.mkdirSync(path.dirname(file), { recursive: true }); } catch {}
+ fs.writeFileSync(file, JSON.stringify(val, null, 2));
+}
+function loadSchedule() {
+ const v = readJson(SCHEDULE_FILE, null);
+ return Array.isArray(v) ? v : [];
+}
+function saveSchedule(list) { writeJson(SCHEDULE_FILE, list); }
+
+// ── date math ─────────────────────────────────────────────────────────────────
+const pad = n => String(n).padStart(2, '0');
+const iso = (y, m, d) => `${y}-${pad(m)}-${pad(d)}`;
+
+// nth occurrence (ordinal, 1-based; -1 = last) of weekday (0=Sun..6=Sat) in a month
+function nthWeekday(year, month, weekday, ordinal) {
+ if (ordinal === -1) {
+ const last = new Date(year, month, 0).getDate(); // last day of month (month is 1-based here)
+ for (let d = last; d >= 1; d--) {
+ if (new Date(year, month - 1, d).getDay() === weekday) return d;
+ }
+ return last;
+ }
+ let count = 0;
+ const daysInMonth = new Date(year, month, 0).getDate();
+ for (let d = 1; d <= daysInMonth; d++) {
+ if (new Date(year, month - 1, d).getDay() === weekday) {
+ if (++count === ordinal) return d;
+ }
+ }
+ return null;
+}
+
+// Resolve every curated date for a given year+month → concrete entries.
+function datesForMonth(year, month) {
+ const ds = readJson(DATES_FILE, { fixed: [], floating: [] });
+ const out = [];
+
+ for (const f of ds.fixed || []) {
+ if (f.month !== month) continue;
+ const daysInMonth = new Date(year, month, 0).getDate();
+ const day = Math.min(f.day, daysInMonth);
+ out.push({ date: iso(year, month, day), title: f.title, type: f.type, suggestion: f.suggestion });
+ }
+
+ for (const f of ds.floating || []) {
+ if (f.month !== month) continue;
+ const base = nthWeekday(year, month, f.weekday, f.ordinal);
+ if (base == null) continue;
+ let dt = new Date(year, month - 1, base);
+ if (f.offset) dt = new Date(dt.getTime() + f.offset * 86400000);
+ out.push({
+ date: iso(dt.getFullYear(), dt.getMonth() + 1, dt.getDate()),
+ title: f.title, type: f.type, suggestion: f.suggestion,
+ });
+ }
+
+ out.sort((a, b) => a.date.localeCompare(b.date));
+ return out;
+}
+
+// ── module ─────────────────────────────────────────────────────────────────────
+module.exports = {
+ id: 'calendar',
+ title: 'Marketing Calendar',
+ icon: '🗓️',
+
+ mount(router) {
+ // GET /dates?year=YYYY&month=MM — curated marketing moments for the window
+ router.get('/dates', (req, res) => {
+ const now = new Date();
+ let year = parseInt(req.query.year, 10);
+ let month = parseInt(req.query.month, 10);
+ if (!Number.isInteger(year)) year = now.getFullYear();
+ if (!Number.isInteger(month) || month < 1 || month > 12) month = now.getMonth() + 1;
+ res.json({ year, month, dates: datesForMonth(year, month) });
+ });
+
+ // GET /schedule — all planned campaigns (optionally filter by year/month)
+ router.get('/schedule', (req, res) => {
+ let list = loadSchedule();
+ const ym = req.query.month && req.query.year
+ ? `${req.query.year}-${pad(parseInt(req.query.month, 10))}`
+ : null;
+ if (ym) list = list.filter(c => (c.date || '').startsWith(ym));
+ list.sort((a, b) => (a.date || '').localeCompare(b.date || ''));
+ res.json({ schedule: list });
+ });
+
+ // POST /schedule — add/update one campaign; ?delete=id removes one
+ router.post('/schedule', (req, res) => {
+ const list = loadSchedule();
+
+ if (req.query.delete) {
+ const id = String(req.query.delete);
+ const next = list.filter(c => c.id !== id);
+ saveSchedule(next);
+ return res.json({ ok: true, deleted: id, schedule: next });
+ }
+
+ const b = req.body || {};
+ if (!b.date || !b.title) {
+ return res.status(400).json({ ok: false, error: 'date and title are required' });
+ }
+ const STATUSES = ['planned', 'drafted', 'scheduled', 'sent'];
+ const CHANNELS = ['email', 'instagram', 'tiktok', 'other'];
+ const entry = {
+ id: b.id && list.some(c => c.id === b.id) ? String(b.id)
+ : 'cmp_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
+ date: String(b.date),
+ title: String(b.title).slice(0, 160),
+ channel: CHANNELS.includes(b.channel) ? b.channel : 'email',
+ status: STATUSES.includes(b.status) ? b.status : 'planned',
+ notes: String(b.notes || '').slice(0, 1000),
+ updatedAt: new Date().toISOString(),
+ };
+
+ const i = list.findIndex(c => c.id === entry.id);
+ if (i >= 0) list[i] = { ...list[i], ...entry };
+ else list.push(entry);
+
+ saveSchedule(list);
+ res.json({ ok: true, entry, schedule: list });
+ });
+ },
+};
diff --git a/modules/constant-contact/index.js b/modules/constant-contact/index.js
new file mode 100644
index 0000000..ec4c58a
--- /dev/null
+++ b/modules/constant-contact/index.js
@@ -0,0 +1,330 @@
+// Constant Contact module — v3 API adapter for the Marketing Command Center.
+// FULL read + draft/schedule. SENDS ARE GATED: schedule requires confirm:true,
+// defaults to dry-run, and refuses unless the campaign HTML carries a physical
+// mailing address + an unsubscribe link (CAN-SPAM). Runs in MOCK MODE when
+// CTCT_ACCESS_TOKEN is absent so the panel is fully usable without a key.
+//
+// Owns nothing on disk persistently; draft ids are returned to the caller.
+// Secrets come from process.env only — never hardcode keys.
+const brand = require('../../lib/brand.js');
+
+const API = 'https://api.cc.email/v3';
+
+const live = () => Boolean(process.env.CTCT_ACCESS_TOKEN);
+
+// ── thin v3 fetch helper ──────────────────────────────────────────────────────
+async function cc(pathname, { method = 'GET', body } = {}) {
+ const headers = {
+ Authorization: `Bearer ${process.env.CTCT_ACCESS_TOKEN}`,
+ Accept: 'application/json',
+ };
+ if (process.env.CTCT_API_KEY) headers['x-api-key'] = process.env.CTCT_API_KEY;
+ if (body) headers['Content-Type'] = 'application/json';
+ const r = await fetch(`${API}${pathname}`, {
+ method,
+ headers,
+ body: body ? JSON.stringify(body) : undefined,
+ });
+ const text = await r.text();
+ let json;
+ try { json = text ? JSON.parse(text) : {}; } catch { json = { raw: text }; }
+ if (!r.ok) {
+ const err = new Error(`CC ${method} ${pathname} → ${r.status}`);
+ err.status = r.status;
+ err.detail = json;
+ throw err;
+ }
+ return json;
+}
+
+// ── CAN-SPAM compliance gate ──────────────────────────────────────────────────
+// A send is only allowed when the HTML carries BOTH a physical mailing address
+// and a working unsubscribe link. This is intentionally conservative.
+function canSpamCheck(html = '') {
+ const h = String(html);
+ const lower = h.toLowerCase();
+
+ // unsubscribe: an explicit opt-out word or CC's merge token.
+ const hasUnsub =
+ /unsubscribe|opt[\s-]?out|\[\[?\s*unsubscribe|\{unsubscribe\}|opt out of/i.test(lower) ||
+ /update (your )?(email )?preferences/i.test(lower);
+
+ // physical address: a US-style "City, ST 12345" or CC's address merge token,
+ // or a street line (number + street-type word).
+ const hasAddress =
+ /\b\d{1,6}\s+[\w.\s]{2,40}\b(street|st\.?|ave(nue)?|avenue|road|rd\.?|blvd|boulevard|lane|ln\.?|drive|dr\.?|suite|ste\.?|way|court|ct\.?|place|pl\.?)\b/i.test(lower) ||
+ /\b[a-z .]+,\s*[a-z]{2}\s+\d{5}(-\d{4})?\b/i.test(lower) ||
+ /\[\[?\s*(physical_address|organization_address|address)/i.test(lower) ||
+ /\{physical_address\}|\{address\}/i.test(lower);
+
+ const missing = [];
+ if (!hasUnsub) missing.push('unsubscribe link');
+ if (!hasAddress) missing.push('physical mailing address');
+ return { ok: missing.length === 0, hasUnsub, hasAddress, missing };
+}
+
+// ── MOCK DATA (realistic shapes, used whenever no access token) ────────────────
+const MOCK = {
+ campaigns: [
+ { id: 'mock-cmp-001', name: 'Spring Grasscloth Edit', status: 'DONE',
+ sent_at: '2026-05-21T14:00:00Z', sends: 4180, opens: 1672, clicks: 388,
+ open_rate: 0.40, click_rate: 0.093 },
+ { id: 'mock-cmp-002', name: 'Trade Memo — Silk & Linen', status: 'SCHEDULED',
+ scheduled_at: '2026-06-15T15:00:00Z', sends: 0, opens: 0, clicks: 0,
+ open_rate: 0, click_rate: 0 },
+ { id: 'mock-cmp-003', name: 'Metallic Murals Lookbook', status: 'DRAFT',
+ sent_at: null, sends: 0, opens: 0, clicks: 0, open_rate: 0, click_rate: 0 },
+ ],
+ contacts: {
+ count: 4218,
+ recent: [
+ { email: 'studio@atelier-noir.com', created_at: '2026-06-08T18:22:00Z' },
+ { email: 'projects@maison-blanc.co', created_at: '2026-06-07T11:05:00Z' },
+ { email: 'spec@harborhospitality.com', created_at: '2026-06-06T09:41:00Z' },
+ { email: 'hello@verdantinteriors.com', created_at: '2026-06-05T16:10:00Z' },
+ ],
+ },
+ lists: [
+ { id: 'mock-list-trade', name: 'Trade & Designers', membership_count: 2640 },
+ { id: 'mock-list-retail', name: 'Retail Newsletter', membership_count: 1578 },
+ ],
+ stats: { sends: 4180, opens: 1672, clicks: 388, open_rate: 0.40, click_rate: 0.093,
+ bounces: 47, unsubscribes: 12, campaigns_30d: 2 },
+};
+
+function mockCalendar() {
+ return MOCK.campaigns
+ .filter(c => c.sent_at || c.scheduled_at)
+ .map(c => ({
+ date: (c.sent_at || c.scheduled_at).slice(0, 10),
+ id: c.id, name: c.name,
+ status: c.status, kind: c.status === 'SCHEDULED' ? 'scheduled' : 'sent',
+ }));
+}
+
+// ── live mappers (defensive against v3 field variance) ─────────────────────────
+function pct(n, d) { return d > 0 ? Number((n / d).toFixed(4)) : 0; }
+
+function mapCampaign(c) {
+ const a = c.campaign_activities || c.activities || [];
+ const stat = c.stats || c.click_through_details || {};
+ const sends = c.sends || stat.sends || 0;
+ const opens = c.opens || stat.opens || 0;
+ const clicks = c.clicks || stat.clicks || 0;
+ return {
+ id: c.campaign_id || c.id,
+ name: c.name || c.campaign_name || '(untitled)',
+ status: c.current_status || c.status || 'UNKNOWN',
+ sent_at: c.last_sent_date || c.updated_at || null,
+ scheduled_at: (a[0] && a[0].scheduled_time) || c.scheduled_time || null,
+ sends, opens, clicks,
+ open_rate: pct(opens, sends), click_rate: pct(clicks, sends),
+ };
+}
+
+// ── route table ────────────────────────────────────────────────────────────────
+function mount(router) {
+ const guard = (handler) => async (req, res) => {
+ try { await handler(req, res); }
+ catch (e) {
+ res.status(e.status && e.status < 500 ? e.status : 502)
+ .json({ error: e.message, detail: e.detail || null });
+ }
+ };
+
+ // GET /campaigns — list email campaigns + status
+ router.get('/campaigns', guard(async (_req, res) => {
+ if (!live()) return res.json({ mock: true, campaigns: MOCK.campaigns });
+ const data = await cc('/emails?limit=50');
+ const list = (data.campaigns || data.emails || []).map(mapCampaign);
+ res.json({ mock: false, campaigns: list });
+ }));
+
+ // GET /contacts — count + recent
+ router.get('/contacts', guard(async (_req, res) => {
+ if (!live()) return res.json({ mock: true, ...MOCK.contacts });
+ const summary = await cc('/contacts?limit=10&include_count=true&sort=created_desc');
+ const count = (summary.contacts_count != null)
+ ? summary.contacts_count
+ : (summary._metadata && summary._metadata.total) || (summary.contacts || []).length;
+ const recent = (summary.contacts || []).slice(0, 6).map(c => ({
+ email: (c.email_address && c.email_address.address) || c.email || '(no email)',
+ created_at: c.created_at || null,
+ }));
+ res.json({ mock: false, count, recent });
+ }));
+
+ // GET /lists — contact lists
+ router.get('/lists', guard(async (_req, res) => {
+ if (!live()) return res.json({ mock: true, lists: MOCK.lists });
+ const data = await cc('/contact_lists?include_count=true&limit=50');
+ const lists = (data.lists || []).map(l => ({
+ id: l.list_id || l.id, name: l.name,
+ membership_count: l.membership_count != null ? l.membership_count : 0,
+ }));
+ res.json({ mock: false, lists });
+ }));
+
+ // GET /calendar — scheduled/sent campaigns as date-keyed entries
+ router.get('/calendar', guard(async (_req, res) => {
+ if (!live()) {
+ const entries = mockCalendar();
+ return res.json({ mock: true, entries, byDate: groupByDate(entries) });
+ }
+ const data = await cc('/emails?limit=50');
+ const entries = (data.campaigns || data.emails || [])
+ .map(mapCampaign)
+ .filter(c => c.sent_at || c.scheduled_at)
+ .map(c => ({
+ date: (c.scheduled_at || c.sent_at).slice(0, 10),
+ id: c.id, name: c.name, status: c.status,
+ kind: c.status === 'SCHEDULED' ? 'scheduled' : 'sent',
+ }));
+ res.json({ mock: false, entries, byDate: groupByDate(entries) });
+ }));
+
+ // GET /stats — opens/clicks/sends summary
+ router.get('/stats', guard(async (_req, res) => {
+ if (!live()) return res.json({ mock: true, stats: MOCK.stats });
+ const data = await cc('/emails?limit=50');
+ const camps = (data.campaigns || data.emails || []).map(mapCampaign);
+ const agg = camps.reduce((a, c) => {
+ a.sends += c.sends; a.opens += c.opens; a.clicks += c.clicks; return a;
+ }, { sends: 0, opens: 0, clicks: 0 });
+ res.json({
+ mock: false,
+ stats: {
+ ...agg,
+ open_rate: pct(agg.opens, agg.sends),
+ click_rate: pct(agg.clicks, agg.sends),
+ campaigns_30d: camps.length,
+ },
+ });
+ }));
+
+ // POST /draft — create a DRAFT campaign. Does NOT send.
+ router.post('/draft', guard(async (req, res) => {
+ const { name, subject, from, html } = req.body || {};
+ const missing = ['name', 'subject', 'from', 'html'].filter(k => !req.body || !req.body[k]);
+ if (missing.length) return res.status(400).json({ error: `missing fields: ${missing.join(', ')}` });
+
+ const compliance = canSpamCheck(html);
+
+ if (!live()) {
+ const id = `mock-draft-${Date.now().toString(36)}`;
+ return res.json({
+ mock: true, ok: true, draft_id: id, status: 'DRAFT',
+ message: `Draft “${name}” saved (mock). Not sent.`,
+ compliance,
+ });
+ }
+
+ // v3: create an email campaign with a single primary_email activity.
+ const payload = {
+ name,
+ email_campaign_activities: [{
+ format_type: 5,
+ from_name: brand.name,
+ from_email: from,
+ reply_to_email: from,
+ subject,
+ html_content: html,
+ }],
+ };
+ const created = await cc('/emails', { method: 'POST', body: payload });
+ res.json({
+ mock: false, ok: true,
+ draft_id: created.campaign_id || created.id,
+ status: created.current_status || 'DRAFT',
+ message: `Draft “${name}” created. Not sent.`,
+ compliance,
+ });
+ }));
+
+ // POST /schedule — GATED. Requires confirm:true; defaults to dry-run; refuses
+ // unless CAN-SPAM fields are present. NEVER sends without confirm && !dryRun.
+ router.post('/schedule', guard(async (req, res) => {
+ const b = req.body || {};
+ const { draft_id, scheduled_time, html } = b;
+ const confirm = b.confirm === true;
+ const dryRun = b.dryRun !== false; // default true → dry-run unless explicitly false
+
+ if (!draft_id) return res.status(400).json({ error: 'draft_id required' });
+
+ // CAN-SPAM gate first — block before anything else if non-compliant.
+ const compliance = canSpamCheck(html || '');
+ if (!compliance.ok) {
+ return res.status(422).json({
+ ok: false, blocked: true, reason: 'CAN-SPAM',
+ message: `Refusing to schedule: campaign HTML is missing ${compliance.missing.join(' + ')}. ${brand.compliance}`,
+ compliance,
+ });
+ }
+
+ // Gate: no confirm → never proceeds to a real send path.
+ if (!confirm) {
+ return res.status(412).json({
+ ok: false, gated: true, reason: 'CONFIRM_REQUIRED',
+ message: 'Schedule is gated. Re-submit with confirm:true (and dryRun:false to actually schedule).',
+ compliance,
+ });
+ }
+
+ // Dry-run (default): validate + echo what WOULD happen. No outbound call.
+ if (dryRun) {
+ return res.json({
+ ok: true, dryRun: true, wouldSchedule: true,
+ draft_id, scheduled_time: scheduled_time || 'now',
+ message: `DRY RUN — would schedule draft ${draft_id} for ${scheduled_time || 'immediate'} send. Nothing was sent. Re-submit with dryRun:false to commit.`,
+ compliance,
+ });
+ }
+
+ // Live commit: confirm:true + dryRun:false + CAN-SPAM passed.
+ if (!live()) {
+ return res.json({
+ mock: true, ok: true, scheduled: true,
+ draft_id, scheduled_time: scheduled_time || 'now',
+ message: `Mock schedule committed for draft ${draft_id}. (No live key — nothing left the building.)`,
+ compliance,
+ });
+ }
+
+ // Real v3 schedule. The schedule lives on the campaign activity, so we look
+ // up the primary activity for the campaign, then POST its schedule.
+ const detail = await cc(`/emails/${encodeURIComponent(draft_id)}`);
+ const primary = (detail.campaign_activities || [])
+ .find(a => a.role === 'primary_email') || (detail.campaign_activities || [])[0];
+ if (!primary || !primary.campaign_activity_id) {
+ return res.status(409).json({ ok: false, error: 'No primary email activity found for this draft.' });
+ }
+ const schedulePayload = scheduled_time
+ ? { scheduled_date: scheduled_time }
+ : { scheduled_date: '0' }; // CC convention: "0" == send immediately
+ const result = await cc(
+ `/emails/activities/${encodeURIComponent(primary.campaign_activity_id)}/schedules`,
+ { method: 'POST', body: schedulePayload },
+ );
+ res.json({
+ mock: false, ok: true, scheduled: true,
+ draft_id, scheduled_time: scheduled_time || 'immediate',
+ result, compliance,
+ message: `Scheduled draft ${draft_id} for ${scheduled_time || 'immediate'} send.`,
+ });
+ }));
+}
+
+function groupByDate(entries) {
+ const out = {};
+ for (const e of entries) { (out[e.date] = out[e.date] || []).push(e); }
+ return out;
+}
+
+module.exports = {
+ id: 'constant-contact',
+ title: 'Constant Contact',
+ icon: '📣',
+ mount,
+ // exported for any internal reuse / testing
+ _canSpamCheck: canSpamCheck,
+};
diff --git a/modules/copy/index.js b/modules/copy/index.js
new file mode 100644
index 0000000..6e00bec
--- /dev/null
+++ b/modules/copy/index.js
@@ -0,0 +1,310 @@
+// copy — Suggested Copy module for the Marketing Command Center.
+// On-brand marketing copy generator for Designer Wallcoverings (luxury
+// wallcoverings). LLM-backed via Anthropic when ANTHROPIC_API_KEY is set;
+// otherwise falls back to well-crafted local templates seeded with brand.js.
+//
+// Routes (mounted at /api/copy by the shell):
+// POST /generate {kind, topic, product, audience, tone, n} -> {variants:[…], mock?}
+// GET /presets -> {kinds, tones, audiences, products, ctas}
+//
+// No outbound sends here — this only DRAFTS copy. CAN-SPAM footer is flagged as
+// a required manual add for the 'email' kind (compliance lives in brand.js).
+const brand = require('../../lib/brand.js');
+
+const KINDS = [
+ { id: 'subject-lines', label: 'Email subject lines' },
+ { id: 'email', label: 'Email body' },
+ { id: 'instagram', label: 'Instagram caption' },
+ { id: 'tiktok', label: 'TikTok caption' },
+ { id: 'sms', label: 'SMS / text' },
+ { id: 'headline', label: 'Web / ad headline' },
+];
+const TONES = ['editorial', 'warm', 'bold', 'minimal', 'playful', 'authoritative'];
+
+// ── helpers ──────────────────────────────────────────────────────────────────
+const cap = s => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s);
+const pick = (arr, i) => arr[((i % arr.length) + arr.length) % arr.length];
+const clampN = n => Math.max(1, Math.min(8, parseInt(n, 10) || 3));
+const A = arr => arr[Math.floor(Math.random() * arr.length)];
+
+// A product phrase that reads naturally in a sentence.
+function productPhrase(product) {
+ if (!product || product === 'any' || product === 'all') return 'wallcoverings';
+ return /grasscloth|silk|cork|linen|mural|metallic/i.test(product)
+ ? `${product} wallcoverings`
+ : `${product} wallcoverings`;
+}
+function audiencePhrase(audience) {
+ if (!audience || audience === 'any' || audience === 'all') return 'designers and discerning homeowners';
+ return audience;
+}
+function hashtags(product) {
+ const base = ['#DesignerWallcoverings', '#LuxuryInteriors', '#InteriorDesign', '#WallcoveringDesign'];
+ const prod = product && product !== 'any'
+ ? '#' + product.replace(/[^a-z0-9]/gi, '').replace(/^./, c => c.toUpperCase())
+ : '#Grasscloth';
+ return [...base, prod, '#TradeOnly', '#MemoSamples'];
+}
+
+// ── local template generators (fallback / mock mode) ─────────────────────────
+function tplSubjectLines(ctx, n) {
+ const p = productPhrase(ctx.product);
+ const t = ctx.topic || 'the new collection';
+ const ctaWords = brand.cta;
+ const formulas = [
+ () => `${cap(t)} — now in ${p}`,
+ () => `A closer look: ${t}`,
+ () => `${cap(p)}, reimagined for ${audiencePhrase(ctx.audience).split('/')[0].trim()}`,
+ () => `Inside ${ctx.name || brand.name}: ${t}`,
+ () => `${cap(t)}. ${A(ctaWords)}.`,
+ () => `The room starts here — ${t}`,
+ () => `Limited memo: ${t}`,
+ () => `${cap(p)} worth specifying`,
+ ];
+ return Array.from({ length: n }, (_, i) => ({
+ text: pick(formulas, i)(),
+ meta: 'subject line · keep under ~50 chars for inbox preview',
+ }));
+}
+
+function tplEmail(ctx, n) {
+ const p = productPhrase(ctx.product);
+ const aud = audiencePhrase(ctx.audience);
+ const topic = ctx.topic || 'our latest collection';
+ const cta = A(brand.cta);
+ return Array.from({ length: n }, (_, i) => {
+ const opener = pick([
+ `There is a quiet luxury in ${p} — the kind that rewards a second look.`,
+ `Some surfaces simply belong in extraordinary rooms. ${cap(topic)} is one of them.`,
+ `For the ${aud} who specify with intention, ${topic} deserves a place on the board.`,
+ ], i);
+ const body =
+`${opener}
+
+Designed and curated by ${brand.name}, ${topic} brings ${p} into focus — tactile, considered, and built for spaces that matter. ${brand.tagline}.
+
+We invite you to ${cta.toLowerCase()} and see how it reads at scale.`;
+ return {
+ text: `Subject: ${cap(topic)} — ${p}\n\n${body}\n\n[ ${cta} ]`,
+ meta: 'email body · ADD CAN-SPAM footer (physical mailing address + working unsubscribe) before sending',
+ footerRequired: true,
+ };
+ });
+}
+
+function tplInstagram(ctx, n) {
+ const p = productPhrase(ctx.product);
+ const topic = ctx.topic || 'the new collection';
+ const tags = hashtags(ctx.product);
+ return Array.from({ length: n }, (_, i) => {
+ const hook = pick([
+ `Texture you can feel across the room. ✨`,
+ `Where the wall becomes the moment.`,
+ `${cap(topic)} — for spaces that earn a second glance.`,
+ ], i);
+ const line = `${hook}\n\n${cap(p)} from ${brand.name}. ${A(brand.cta)} — link in bio.`;
+ return { text: `${line}\n\n${tags.join(' ')}`, meta: 'IG caption · first line is the hook' };
+ });
+}
+
+function tplTiktok(ctx, n) {
+ const p = productPhrase(ctx.product);
+ const topic = ctx.topic || 'this wall transformation';
+ const tags = hashtags(ctx.product);
+ return Array.from({ length: n }, (_, i) => {
+ const hook = pick([
+ `POV: you specified the ${p.replace(' wallcoverings', '')} and the whole room changed.`,
+ `Watch ${topic} come together →`,
+ `Designers, this one's for your moodboard.`,
+ ], i);
+ return {
+ text: `${hook}\n\n${cap(topic)} · ${brand.name}\n${A(brand.cta)} 👇\n\n${tags.slice(0, 5).join(' ')}`,
+ meta: 'TikTok caption · hook in first 1–2s, keep it punchy',
+ };
+ });
+}
+
+function tplSms(ctx, n) {
+ const p = productPhrase(ctx.product);
+ const topic = ctx.topic || 'New arrivals';
+ const cta = A(brand.cta);
+ return Array.from({ length: n }, (_, i) => {
+ const body = pick([
+ `${brand.name}: ${cap(topic)} in ${p} just landed. ${cta} →`,
+ `${cap(topic)} — ${p} you'll want to spec. ${cta}.`,
+ `New from ${brand.name}: ${topic}. ${cta} →`,
+ ], i);
+ return { text: `${body} Reply STOP to opt out.`, meta: 'SMS · ~160 chars · STOP opt-out included' };
+ });
+}
+
+function tplHeadline(ctx, n) {
+ const p = productPhrase(ctx.product);
+ const topic = ctx.topic || brand.tagline;
+ return Array.from({ length: n }, (_, i) => ({
+ text: pick([
+ `${cap(p)} for extraordinary spaces`,
+ cap(topic),
+ `Specify the surface that defines the room`,
+ `${cap(p)}, made to be remembered`,
+ `The art of the wall — ${p}`,
+ `${cap(topic)}, in ${p}`,
+ ], i),
+ meta: 'headline · pairs with a single supporting line + CTA',
+ }));
+}
+
+const GENERATORS = {
+ 'subject-lines': tplSubjectLines,
+ email: tplEmail,
+ instagram: tplInstagram,
+ tiktok: tplTiktok,
+ sms: tplSms,
+ headline: tplHeadline,
+};
+
+function localGenerate(ctx, n) {
+ const gen = GENERATORS[ctx.kind] || tplHeadline;
+ return gen(ctx, n);
+}
+
+// ── LLM path (Anthropic Messages API via global fetch) ───────────────────────
+function brandSystemPrompt() {
+ return [
+ `You are the senior copywriter for ${brand.name} — a luxury wallcovering house.`,
+ `Tagline: "${brand.tagline}".`,
+ `Brand voice: ${brand.voice.join(', ')}. Write elevated, editorial, tactile copy. Never gimmicky, never salesy clichés, no exclamation-point spam.`,
+ `Product lines you may reference: ${brand.productLines.join(', ')}.`,
+ `Audiences: ${brand.audiences.join(', ')}.`,
+ `Preferred calls to action: ${brand.cta.join('; ')}.`,
+ `Compliance reminder: ${brand.compliance} (You DRAFT only — never invent a fake address or unsubscribe link; flag that the footer must be added.)`,
+ `Always return ONLY valid JSON of the exact shape {"variants":[{"text":"…","meta":"…"}]} with no markdown fences, no prose before or after.`,
+ ].join('\n');
+}
+
+function userPromptFor(ctx, n) {
+ const lines = [
+ `Write ${n} distinct ${ctx.kind} variant(s) for ${brand.name}.`,
+ ctx.topic ? `Topic / campaign: ${ctx.topic}.` : '',
+ ctx.product && ctx.product !== 'any' ? `Featured product line: ${ctx.product}.` : '',
+ ctx.audience && ctx.audience !== 'any' ? `Primary audience: ${ctx.audience}.` : '',
+ ctx.tone ? `Tone: ${ctx.tone}.` : '',
+ ].filter(Boolean);
+ if (ctx.kind === 'subject-lines') lines.push('Each variant is one subject line, under ~50 characters.');
+ if (ctx.kind === 'email') lines.push('Each variant is a short email: a subject line, then 2–3 tight paragraphs, then a CTA in [brackets]. Do NOT include a footer — note in meta that the CAN-SPAM footer (physical address + unsubscribe) must be added before sending.');
+ if (ctx.kind === 'instagram') lines.push('Each variant: a strong first-line hook, a 1–2 sentence body, then a set of on-brand hashtags.');
+ if (ctx.kind === 'tiktok') lines.push('Each variant: a scroll-stopping hook for the first 1–2 seconds, punchy, plus a few hashtags.');
+ if (ctx.kind === 'sms') lines.push('Each variant: one SMS under ~160 chars, include a STOP opt-out.');
+ if (ctx.kind === 'headline') lines.push('Each variant is a single web/ad headline.');
+ lines.push('Put a short helpful note in each variant\'s "meta" field (length tips, footer reminders, etc.).');
+ return lines.join('\n');
+}
+
+async function llmGenerate(ctx, n) {
+ const key = process.env.ANTHROPIC_API_KEY;
+ const resp = await fetch('https://api.anthropic.com/v1/messages', {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ 'x-api-key': key,
+ 'anthropic-version': '2023-06-01',
+ },
+ body: JSON.stringify({
+ model: 'claude-fable-5',
+ max_tokens: 1500,
+ system: brandSystemPrompt(),
+ messages: [{ role: 'user', content: userPromptFor(ctx, n) }],
+ }),
+ });
+ if (!resp.ok) {
+ const detail = await resp.text().catch(() => '');
+ throw new Error(`anthropic ${resp.status}: ${detail.slice(0, 300)}`);
+ }
+ const data = await resp.json();
+ const text = (data.content || []).map(b => (b && b.type === 'text' ? b.text : '')).join('').trim();
+ return parseVariants(text, n);
+}
+
+// Parse the model's response into clean variants. Tolerant of JSON, fenced
+// JSON, or plain newline/numbered lists.
+function parseVariants(raw, n) {
+ if (!raw) return [];
+ let body = raw.trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '').trim();
+ // Try a clean JSON object first, then the first {...} block.
+ const tryJson = str => {
+ try {
+ const o = JSON.parse(str);
+ const arr = Array.isArray(o) ? o : o.variants;
+ if (Array.isArray(arr)) {
+ return arr.map(v => (typeof v === 'string'
+ ? { text: v.trim(), meta: '' }
+ : { text: String(v.text || v.copy || '').trim(), meta: String(v.meta || '').trim() }))
+ .filter(v => v.text);
+ }
+ } catch { /* fall through */ }
+ return null;
+ };
+ let out = tryJson(body);
+ if (!out) {
+ const m = body.match(/\{[\s\S]*\}/);
+ if (m) out = tryJson(m[0]);
+ }
+ if (!out || !out.length) {
+ // Plain-text fallback: split on blank lines or numbered list markers.
+ out = body
+ .split(/\n\s*\n|\n(?=\d+[.)]\s)/)
+ .map(s => s.replace(/^\s*\d+[.)]\s*/, '').trim())
+ .filter(Boolean)
+ .map(s => ({ text: s, meta: '' }));
+ }
+ return out.slice(0, n);
+}
+
+// ── module ───────────────────────────────────────────────────────────────────
+module.exports = {
+ id: 'copy',
+ title: 'Suggested Copy',
+ icon: '✍️',
+ mount(router) {
+ router.get('/presets', (_req, res) => {
+ res.json({
+ kinds: KINDS,
+ tones: TONES,
+ audiences: brand.audiences,
+ products: brand.productLines,
+ ctas: brand.cta,
+ voice: brand.voice,
+ tagline: brand.tagline,
+ compliance: brand.compliance,
+ llm: !!process.env.ANTHROPIC_API_KEY,
+ });
+ });
+
+ router.post('/generate', async (req, res) => {
+ const b = req.body || {};
+ const kind = KINDS.some(k => k.id === b.kind) ? b.kind : 'headline';
+ const ctx = {
+ kind,
+ topic: (b.topic || '').toString().slice(0, 240).trim(),
+ product: (b.product || 'any').toString().trim(),
+ audience: (b.audience || 'any').toString().trim(),
+ tone: (b.tone || 'editorial').toString().trim(),
+ name: brand.name,
+ };
+ const n = clampN(b.n);
+
+ // LLM path when keyed; gracefully fall back to templates on any failure.
+ if (process.env.ANTHROPIC_API_KEY) {
+ try {
+ const variants = await llmGenerate(ctx, n);
+ if (variants.length) return res.json({ variants, kind, mock: false, source: 'anthropic' });
+ // Empty parse → fall through to templates rather than return nothing.
+ } catch (e) {
+ console.warn(`[copy] LLM failed, using templates: ${e.message}`);
+ }
+ }
+ const variants = localGenerate(ctx, n);
+ res.json({ variants, kind, mock: true, source: 'templates' });
+ });
+ },
+};
diff --git a/modules/layouts/index.js b/modules/layouts/index.js
new file mode 100644
index 0000000..b8f6587
--- /dev/null
+++ b/modules/layouts/index.js
@@ -0,0 +1,379 @@
+'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)} |
+ <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);
+ const products = [
+ { name: 'Grasscloth Linen', sku: 'DW-GL-001', price: '$12.50/yd' },
+ { name: 'Silk Damask', sku: 'DW-SD-044', price: '$28.00/yd' },
+ { name: 'Cork Weave', sku: 'DW-CW-012', 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, '&')
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+// ── 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 });
+ }
+ });
+ },
+};
diff --git a/public/panels/calendar.html b/public/panels/calendar.html
new file mode 100644
index 0000000..c6bc07a
--- /dev/null
+++ b/public/panels/calendar.html
@@ -0,0 +1,114 @@
+<div class="muted-banner">
+ Plan campaigns around the moments that matter to the trade. Curated dates are suggestions —
+ click any day to schedule a campaign. Nothing here sends; it's a planning board.
+</div>
+
+<div class="cal-wrap">
+ <div class="cal-main">
+ <div class="card">
+ <div class="cal-head">
+ <button class="btn ghost" id="cal-prev" aria-label="Previous month">‹</button>
+ <h2 id="cal-title" style="margin:0;text-align:center;flex:1">—</h2>
+ <button class="btn ghost" id="cal-next" aria-label="Next month">›</button>
+ <button class="btn ghost" id="cal-today" title="Jump to current month">Today</button>
+ </div>
+
+ <div class="cal-legend">
+ <span class="lg"><i class="dotc t-holiday"></i>Holiday</span>
+ <span class="lg"><i class="dotc t-retail"></i>Retail</span>
+ <span class="lg"><i class="dotc t-trade"></i>Trade</span>
+ <span class="lg"><i class="dotc t-awareness"></i>Awareness</span>
+ <span class="lg"><i class="dotc t-campaign"></i>Your campaign</span>
+ </div>
+
+ <div class="cal-dow">
+ <span>Sun</span><span>Mon</span><span>Tue</span><span>Wed</span><span>Thu</span><span>Fri</span><span>Sat</span>
+ </div>
+ <div class="cal-grid" id="cal-grid"></div>
+ </div>
+ </div>
+
+ <div class="cal-side">
+ <div class="card">
+ <h2>This month's marketing moments</h2>
+ <p class="muted" style="margin:2px 0 12px;font-size:12.5px">On-brand campaign ideas for the dates ahead.</p>
+ <div id="cal-moments"></div>
+ </div>
+ <div class="card">
+ <h2>Scheduled this month</h2>
+ <div id="cal-sched-list"></div>
+ </div>
+ </div>
+</div>
+
+<!-- inline add/edit form (rendered into a day cell on click) -->
+<template id="cal-form-tpl">
+ <form class="cal-form">
+ <div class="row" style="gap:8px">
+ <div style="flex:1;min-width:160px">
+ <label>Campaign title</label>
+ <input name="title" placeholder="e.g. Spring grasscloth refresh" required>
+ </div>
+ <div style="width:140px">
+ <label>Channel</label>
+ <select name="channel">
+ <option value="email">Email</option>
+ <option value="instagram">Instagram</option>
+ <option value="tiktok">TikTok</option>
+ <option value="other">Other</option>
+ </select>
+ </div>
+ </div>
+ <div style="margin-top:8px">
+ <label>Notes</label>
+ <textarea name="notes" rows="2" placeholder="Angle, audience, assets…"></textarea>
+ </div>
+ <div class="row" style="margin-top:10px;align-items:center">
+ <button type="submit" class="btn gold">Add to plan</button>
+ <button type="button" class="btn ghost" data-cancel>Cancel</button>
+ <span class="muted" data-formdate style="font-size:12px;margin-left:auto"></span>
+ </div>
+ </form>
+</template>
+
+<style>
+.cal-wrap{display:grid;grid-template-columns:minmax(0,1fr) 340px;gap:18px;align-items:start}
+@media(max-width:1080px){.cal-wrap{grid-template-columns:1fr}}
+.cal-head{display:flex;align-items:center;gap:8px;margin-bottom:12px}
+.cal-legend{display:flex;flex-wrap:wrap;gap:14px;margin-bottom:12px;font-size:11.5px;color:var(--mut)}
+.cal-legend .lg{display:inline-flex;align-items:center;gap:6px}
+.dotc{width:9px;height:9px;border-radius:50%;display:inline-block}
+.t-holiday{background:#8a5a44}.t-retail{background:#b8925a}.t-trade{background:#6f7a63}
+.t-awareness{background:#7a8aa0}.t-campaign{background:#14110f}
+.cal-dow{display:grid;grid-template-columns:repeat(7,1fr);gap:6px;margin-bottom:6px}
+.cal-dow span{font-size:11px;font-weight:700;letter-spacing:.5px;color:var(--mut);text-align:center;text-transform:uppercase}
+.cal-grid{display:grid;grid-template-columns:repeat(7,1fr);gap:6px}
+.cal-cell{background:#fff;border:1px solid var(--line);border-radius:10px;min-height:88px;padding:6px 7px;cursor:pointer;
+ display:flex;flex-direction:column;gap:4px;transition:border-color .12s,box-shadow .12s}
+.cal-cell:hover{border-color:var(--gold);box-shadow:0 1px 6px rgba(184,146,90,.18)}
+.cal-cell.empty{background:transparent;border:0;cursor:default;box-shadow:none}
+.cal-cell.today{border-color:var(--ink);box-shadow:0 0 0 1px var(--ink) inset}
+.cal-cell .num{font-size:12px;font-weight:700;color:var(--ink);line-height:1}
+.cal-cell.today .num{color:var(--accent)}
+.cal-evt{font-size:10.5px;line-height:1.25;display:flex;gap:4px;align-items:flex-start;overflow:hidden}
+.cal-evt i{flex:0 0 auto;width:7px;height:7px;border-radius:50%;margin-top:3px}
+.cal-evt span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.cal-cell .more{font-size:10px;color:var(--mut)}
+.cal-form{background:var(--cream);border:1px solid var(--line);border-radius:12px;padding:14px;margin:6px 0 4px}
+.cal-cell.editing{cursor:default}
+.cal-cell.editing:hover{box-shadow:none;border-color:var(--ink)}
+.moment{border:1px solid var(--line);border-radius:10px;padding:10px 12px;margin-bottom:9px}
+.moment .mt{display:flex;align-items:center;gap:8px}
+.moment .md{font-size:11px;color:var(--mut);font-weight:700}
+.moment b{font-size:13.5px}
+.moment p{margin:6px 0 8px;font-size:12.5px;color:#4a443c;line-height:1.45}
+.sched-item{display:flex;gap:8px;align-items:flex-start;border-bottom:1px solid var(--line);padding:9px 0}
+.sched-item:last-child{border-bottom:0}
+.sched-item .sd{font-size:11px;color:var(--mut);font-weight:700;width:46px;flex:0 0 auto}
+.sched-item .sx{flex:1;min-width:0}
+.sched-item .st{font-size:13px;font-weight:600}
+.sched-item .sm{font-size:11px;color:var(--mut)}
+.sched-del{background:transparent;border:0;color:var(--mut);cursor:pointer;font-size:14px;padding:0 4px}
+.sched-del:hover{color:var(--accent)}
+.cal-empty{color:var(--mut);font-size:12.5px;padding:6px 0}
+</style>
diff --git a/public/panels/calendar.js b/public/panels/calendar.js
new file mode 100644
index 0000000..8eaacbd
--- /dev/null
+++ b/public/panels/calendar.js
@@ -0,0 +1,226 @@
+// Marketing Calendar panel — month grid + curated moments + campaign scheduler.
+// Registers into the shell's MCC_PANELS map (see public/app.js).
+window.MCC_PANELS = window.MCC_PANELS || {};
+window.MCC_PANELS['calendar'] = {
+ init(root) {
+ const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June',
+ 'July', 'August', 'September', 'October', 'November', 'December'];
+ const pad = n => String(n).padStart(2, '0');
+ const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c =>
+ ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
+
+ const now = new Date();
+ const state = {
+ year: now.getFullYear(),
+ month: now.getMonth() + 1, // 1-based
+ dates: [], // curated moments
+ schedule: [], // user campaigns this month
+ editingDate: null, // iso of the day cell with the open form
+ prefill: null, // {title, channel, notes} to seed the form
+ };
+
+ const $ = sel => root.querySelector(sel);
+ const grid = $('#cal-grid');
+ const formTpl = $('#cal-form-tpl');
+
+ async function load() {
+ const qs = `year=${state.year}&month=${pad(state.month)}`;
+ try {
+ const [d, s] = await Promise.all([
+ fetch(`/api/calendar/dates?${qs}`).then(r => r.json()),
+ fetch(`/api/calendar/schedule?${qs}`).then(r => r.json()),
+ ]);
+ state.dates = (d && d.dates) || [];
+ state.schedule = (s && s.schedule) || [];
+ } catch (e) {
+ state.dates = []; state.schedule = [];
+ }
+ render();
+ }
+
+ function eventsForDay(iso) {
+ const moments = state.dates.filter(x => x.date === iso)
+ .map(x => ({ kind: x.type, title: x.title }));
+ const camps = state.schedule.filter(x => x.date === iso)
+ .map(x => ({ kind: 'campaign', title: x.title }));
+ return moments.concat(camps);
+ }
+
+ function render() {
+ $('#cal-title').textContent = `${MONTHS[state.month - 1]} ${state.year}`;
+ renderGrid();
+ renderMoments();
+ renderSchedule();
+ }
+
+ function renderGrid() {
+ const first = new Date(state.year, state.month - 1, 1);
+ const startDow = first.getDay(); // 0=Sun
+ const daysInMonth = new Date(state.year, state.month, 0).getDate();
+ const todayIso = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
+
+ let cells = '';
+ for (let i = 0; i < startDow; i++) cells += `<div class="cal-cell empty"></div>`;
+ for (let d = 1; d <= daysInMonth; d++) {
+ const iso = `${state.year}-${pad(state.month)}-${pad(d)}`;
+ const evts = eventsForDay(iso);
+ const shown = evts.slice(0, 3);
+ const dots = shown.map(e =>
+ `<div class="cal-evt"><i class="t-${e.kind}"></i><span title="${esc(e.title)}">${esc(e.title)}</span></div>`
+ ).join('');
+ const more = evts.length > 3 ? `<div class="more">+${evts.length - 3} more</div>` : '';
+ const cls = ['cal-cell'];
+ if (iso === todayIso) cls.push('today');
+ if (iso === state.editingDate) cls.push('editing');
+ cells += `<div class="${cls.join(' ')}" data-iso="${iso}">
+ <div class="num">${d}</div>${dots}${more}
+ </div>`;
+ }
+ grid.innerHTML = cells;
+
+ grid.querySelectorAll('.cal-cell[data-iso]').forEach(cell => {
+ cell.addEventListener('click', e => {
+ if (e.target.closest('.cal-form')) return; // clicks inside the form
+ openForm(cell.dataset.iso);
+ });
+ });
+
+ if (state.editingDate) mountForm();
+ }
+
+ function openForm(iso) {
+ state.editingDate = (state.editingDate === iso && !state.prefill) ? null : iso;
+ renderGrid();
+ }
+
+ function mountForm() {
+ const cell = grid.querySelector(`.cal-cell[data-iso="${state.editingDate}"]`);
+ if (!cell) return;
+ const node = formTpl.content.firstElementChild.cloneNode(true);
+ const dateLabel = new Date(state.editingDate + 'T00:00:00')
+ .toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
+ node.querySelector('[data-formdate]').textContent = dateLabel;
+
+ if (state.prefill) {
+ if (state.prefill.title) node.querySelector('[name=title]').value = state.prefill.title;
+ if (state.prefill.channel) node.querySelector('[name=channel]').value = state.prefill.channel;
+ if (state.prefill.notes) node.querySelector('[name=notes]').value = state.prefill.notes;
+ state.prefill = null;
+ }
+
+ node.querySelector('[data-cancel]').addEventListener('click', () => {
+ state.editingDate = null; renderGrid();
+ });
+ node.addEventListener('submit', async e => {
+ e.preventDefault();
+ const fd = new FormData(node);
+ const title = (fd.get('title') || '').toString().trim();
+ if (!title) { node.querySelector('[name=title]').focus(); return; }
+ const body = {
+ date: state.editingDate,
+ title,
+ channel: fd.get('channel'),
+ notes: (fd.get('notes') || '').toString().trim(),
+ status: 'planned',
+ };
+ const btn = node.querySelector('button[type=submit]');
+ btn.disabled = true; btn.textContent = 'Saving…';
+ try {
+ await fetch('/api/calendar/schedule', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ } catch (_) {}
+ state.editingDate = null;
+ await load();
+ });
+
+ cell.appendChild(node);
+ const t = node.querySelector('[name=title]');
+ if (t && !t.value) t.focus();
+ }
+
+ function renderMoments() {
+ const box = $('#cal-moments');
+ if (!state.dates.length) {
+ box.innerHTML = `<div class="cal-empty">No curated moments this month — a good window for an evergreen feature.</div>`;
+ return;
+ }
+ box.innerHTML = state.dates.map(m => {
+ const dd = new Date(m.date + 'T00:00:00')
+ .toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
+ return `<div class="moment">
+ <div class="mt"><i class="dotc t-${m.type}"></i><span class="md">${esc(dd)}</span>
+ <span class="pill">${esc(m.type)}</span></div>
+ <b>${esc(m.title)}</b>
+ <p>${esc(m.suggestion)}</p>
+ <button class="btn ghost cal-planit"
+ data-date="${m.date}" data-title="${esc(m.title)}" data-notes="${esc(m.suggestion)}">Plan it</button>
+ </div>`;
+ }).join('');
+
+ box.querySelectorAll('.cal-planit').forEach(b => {
+ b.addEventListener('click', () => {
+ state.prefill = {
+ title: b.dataset.title,
+ channel: 'email',
+ notes: b.dataset.notes,
+ };
+ state.editingDate = b.dataset.date;
+ renderGrid();
+ const cell = grid.querySelector(`.cal-cell[data-iso="${b.dataset.date}"]`);
+ if (cell) cell.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ });
+ });
+ }
+
+ function renderSchedule() {
+ const box = $('#cal-sched-list');
+ if (!state.schedule.length) {
+ box.innerHTML = `<div class="cal-empty">Nothing scheduled yet. Click a day, or hit “Plan it” on a moment.</div>`;
+ return;
+ }
+ box.innerHTML = state.schedule.map(c => {
+ const dd = new Date(c.date + 'T00:00:00')
+ .toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
+ return `<div class="sched-item">
+ <span class="sd">${esc(dd)}</span>
+ <div class="sx">
+ <div class="st">${esc(c.title)}</div>
+ <div class="sm">${esc(c.channel)} · ${esc(c.status)}${c.notes ? ' · ' + esc(c.notes) : ''}</div>
+ </div>
+ <button class="sched-del" data-del="${esc(c.id)}" title="Remove">✕</button>
+ </div>`;
+ }).join('');
+
+ box.querySelectorAll('.sched-del').forEach(b => {
+ b.addEventListener('click', async () => {
+ if (!confirm('Remove this planned campaign?')) return;
+ try {
+ await fetch(`/api/calendar/schedule?delete=${encodeURIComponent(b.dataset.del)}`,
+ { method: 'POST' });
+ } catch (_) {}
+ await load();
+ });
+ });
+ }
+
+ function step(delta) {
+ let m = state.month + delta, y = state.year;
+ if (m < 1) { m = 12; y--; }
+ if (m > 12) { m = 1; y++; }
+ state.month = m; state.year = y; state.editingDate = null;
+ load();
+ }
+
+ $('#cal-prev').addEventListener('click', () => step(-1));
+ $('#cal-next').addEventListener('click', () => step(1));
+ $('#cal-today').addEventListener('click', () => {
+ state.year = now.getFullYear(); state.month = now.getMonth() + 1;
+ state.editingDate = null; load();
+ });
+
+ load();
+ },
+};
diff --git a/public/panels/constant-contact.html b/public/panels/constant-contact.html
new file mode 100644
index 0000000..d55b55a
--- /dev/null
+++ b/public/panels/constant-contact.html
@@ -0,0 +1,58 @@
+<div id="cc-banner"></div>
+
+<div class="grid" style="grid-template-columns:repeat(auto-fit,minmax(280px,1fr))">
+
+ <div class="card" id="cc-contacts">
+ <h2>Contacts</h2>
+ <div class="muted" style="font-size:12px">List membership & recent signups</div>
+ <div id="cc-contacts-body" class="loading">Loading…</div>
+ </div>
+
+ <div class="card" id="cc-stats">
+ <h2>Stats summary</h2>
+ <div class="muted" style="font-size:12px">Aggregate sends / opens / clicks</div>
+ <div id="cc-stats-body" class="loading">Loading…</div>
+ </div>
+
+</div>
+
+<div class="card" id="cc-campaigns">
+ <h2>Recent Campaigns</h2>
+ <div class="muted" style="font-size:12px">Name · status · sends · open rate</div>
+ <div id="cc-campaigns-body" class="loading">Loading…</div>
+</div>
+
+<div class="card" id="cc-draft">
+ <h2>Draft a campaign</h2>
+ <div class="muted" style="font-size:12px">Saves a DRAFT only. Sending is gated & CAN-SPAM checked.</div>
+
+ <div class="grid" style="margin-top:14px;grid-template-columns:1fr 1fr">
+ <div>
+ <label for="cc-f-name">Campaign name</label>
+ <input id="cc-f-name" placeholder="Spring Grasscloth Edit">
+ </div>
+ <div>
+ <label for="cc-f-from">From email</label>
+ <input id="cc-f-from" placeholder="studio@designerwallcoverings.com">
+ </div>
+ </div>
+
+ <div style="margin-top:12px">
+ <label for="cc-f-subject">Subject line</label>
+ <input id="cc-f-subject" placeholder="Introducing the Silk & Linen memo">
+ </div>
+
+ <div style="margin-top:12px">
+ <label for="cc-f-html">Email HTML</label>
+ <textarea id="cc-f-html" rows="7" placeholder="<html>…</html> — must include a physical mailing address + an unsubscribe link to be sendable"></textarea>
+ </div>
+
+ <div id="cc-compliance" style="margin-top:12px"></div>
+
+ <div class="row" style="margin-top:14px;align-items:center">
+ <button class="btn" id="cc-save-draft">Save draft</button>
+ <button class="btn gated" id="cc-schedule" title="Gated send — requires confirmation and passes a CAN-SPAM check">Schedule send…</button>
+ <span class="pill" id="cc-draft-id" style="display:none"></span>
+ <span class="muted" id="cc-draft-msg" style="font-size:12.5px"></span>
+ </div>
+</div>
diff --git a/public/panels/constant-contact.js b/public/panels/constant-contact.js
new file mode 100644
index 0000000..664737f
--- /dev/null
+++ b/public/panels/constant-contact.js
@@ -0,0 +1,166 @@
+// Constant Contact panel — campaigns, contacts, lists, stats + a draft/schedule
+// mini-form. Sends are gated: the "Schedule send…" button defaults to a dry-run,
+// requires an explicit confirm, and surfaces the CAN-SPAM check result.
+window.MCC_PANELS = window.MCC_PANELS || {};
+window.MCC_PANELS['constant-contact'] = {
+ init(root) {
+ const $ = s => root.querySelector(s);
+ const api = (p, opts) => fetch(`/api/constant-contact${p}`, opts).then(r => r.json());
+ const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c =>
+ ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
+ const pct = n => `${(Number(n || 0) * 100).toFixed(1)}%`;
+ const fmt = d => d ? new Date(d).toLocaleString(undefined,
+ { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : '—';
+ const STATUS = { DONE: 'sent', SCHEDULED: 'scheduled', DRAFT: 'draft' };
+
+ let anyMock = false;
+ const markMock = m => { if (m) anyMock = true; };
+ function banner() {
+ $('#cc-banner').innerHTML = anyMock
+ ? `<div class="muted-banner">Mock data — paste your <b>CTCT_ACCESS_TOKEN</b> in <code>.env</code> to go live. Drafting & the gated schedule check both work in mock mode.</div>`
+ : '';
+ }
+
+ // ── Contacts + Lists ──────────────────────────────────────────────────────
+ Promise.all([api('/contacts'), api('/lists')]).then(([c, l]) => {
+ markMock(c.mock || l.mock); banner();
+ const lists = (l.lists || []).map(x =>
+ `<div class="row" style="justify-content:space-between;border-top:1px solid var(--line);padding:7px 0">
+ <span>${esc(x.name)}</span>
+ <span class="pill">${Number(x.membership_count || 0).toLocaleString()}</span>
+ </div>`).join('') || '<div class="muted">No lists.</div>';
+ const recent = (c.recent || []).map(r =>
+ `<div class="muted" style="font-size:12px">${esc(r.email)} <span style="opacity:.6">· ${fmt(r.created_at)}</span></div>`
+ ).join('') || '';
+ $('#cc-contacts-body').innerHTML =
+ `<div style="font:600 30px/1 'Cormorant Garamond',Georgia,serif;margin:8px 0 2px">
+ ${Number(c.count || 0).toLocaleString()}</div>
+ <div class="muted" style="font-size:12px;margin-bottom:10px">total contacts</div>
+ <div style="font-weight:600;font-size:12px;color:var(--mut);margin-bottom:2px">Lists</div>
+ ${lists}
+ ${recent ? `<div style="font-weight:600;font-size:12px;color:var(--mut);margin:12px 0 4px">Recent signups</div>${recent}` : ''}`;
+ }).catch(e => { $('#cc-contacts-body').innerHTML = `<div class="muted">Error: ${esc(e.message)}</div>`; });
+
+ // ── Stats ─────────────────────────────────────────────────────────────────
+ api('/stats').then(d => {
+ markMock(d.mock); banner();
+ const s = d.stats || {};
+ const cell = (label, val) =>
+ `<div><div style="font:600 22px/1 'Cormorant Garamond',Georgia,serif">${val}</div>
+ <div class="muted" style="font-size:11px">${label}</div></div>`;
+ $('#cc-stats-body').innerHTML =
+ `<div class="grid" style="grid-template-columns:repeat(3,1fr);margin-top:10px">
+ ${cell('sends', Number(s.sends || 0).toLocaleString())}
+ ${cell('open rate', pct(s.open_rate))}
+ ${cell('click rate', pct(s.click_rate))}
+ ${cell('opens', Number(s.opens || 0).toLocaleString())}
+ ${cell('clicks', Number(s.clicks || 0).toLocaleString())}
+ ${cell('campaigns', Number(s.campaigns_30d || 0).toLocaleString())}
+ </div>`;
+ }).catch(e => { $('#cc-stats-body').innerHTML = `<div class="muted">Error: ${esc(e.message)}</div>`; });
+
+ // ── Campaigns ─────────────────────────────────────────────────────────────
+ api('/campaigns').then(d => {
+ markMock(d.mock); banner();
+ const rows = (d.campaigns || []).map(c => {
+ const when = c.status === 'SCHEDULED' ? c.scheduled_at : c.sent_at;
+ return `<div class="row" style="justify-content:space-between;align-items:center;border-top:1px solid var(--line);padding:9px 0">
+ <div>
+ <div style="font-weight:600">${esc(c.name)}</div>
+ <div class="muted" style="font-size:11.5px">${fmt(when)}</div>
+ </div>
+ <div class="row" style="align-items:center;gap:8px">
+ <span class="pill">${esc(STATUS[c.status] || (c.status || '').toLowerCase() || '—')}</span>
+ <span class="muted" style="font-size:12px;min-width:74px;text-align:right">
+ ${Number(c.sends || 0).toLocaleString()} sent</span>
+ <span style="font-weight:600;min-width:54px;text-align:right">${pct(c.open_rate)}</span>
+ </div>
+ </div>`;
+ }).join('') || '<div class="muted">No campaigns.</div>';
+ $('#cc-campaigns-body').innerHTML = rows;
+ }).catch(e => { $('#cc-campaigns-body').innerHTML = `<div class="muted">Error: ${esc(e.message)}</div>`; });
+
+ // ── Draft + Schedule ──────────────────────────────────────────────────────
+ let lastDraftId = null;
+ const field = id => $(`#cc-f-${id}`).value.trim();
+ const setMsg = (t, warn) => {
+ const el = $('#cc-draft-msg');
+ el.textContent = t || '';
+ el.style.color = warn ? 'var(--accent)' : 'var(--mut)';
+ };
+ function showCompliance(c) {
+ if (!c) { $('#cc-compliance').innerHTML = ''; return; }
+ const ok = v => v ? '✓' : '✗';
+ const color = c.ok ? '#5a7a52' : 'var(--accent)';
+ $('#cc-compliance').innerHTML =
+ `<div style="font-size:12.5px;color:${color};font-weight:600">
+ CAN-SPAM ${c.ok ? 'PASS' : 'FAIL'} —
+ ${ok(c.hasAddress)} physical address ${ok(c.hasUnsub)} unsubscribe link
+ ${c.ok ? '' : `<div style="font-weight:400;margin-top:3px">Missing: ${esc((c.missing || []).join(' + '))}. Sending is blocked until both are present.</div>`}
+ </div>`;
+ }
+
+ $('#cc-save-draft').onclick = async () => {
+ const body = { name: field('name'), subject: field('subject'), from: field('from'), html: field('html') };
+ if (!body.name || !body.subject || !body.from || !body.html) {
+ setMsg('Fill name, from, subject and HTML first.', true); return;
+ }
+ $('#cc-save-draft').disabled = true; setMsg('Saving draft…');
+ try {
+ const r = await api('/draft', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
+ markMock(r.mock); banner();
+ if (r.error) { setMsg(r.error, true); }
+ else {
+ lastDraftId = r.draft_id;
+ const pill = $('#cc-draft-id');
+ pill.style.display = ''; pill.textContent = `draft ${r.draft_id}`;
+ setMsg(r.message || 'Draft saved.');
+ showCompliance(r.compliance);
+ }
+ } catch (e) { setMsg(`Error: ${e.message}`, true); }
+ finally { $('#cc-save-draft').disabled = false; }
+ };
+
+ $('#cc-schedule').onclick = async () => {
+ if (!lastDraftId) { setMsg('Save a draft first, then schedule it.', true); return; }
+ const html = field('html');
+
+ // Step 1: dry-run with confirm — validates + runs the CAN-SPAM check.
+ setMsg('Running pre-flight (dry-run + CAN-SPAM)…');
+ let dry;
+ try {
+ dry = await api('/schedule', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ draft_id: lastDraftId, html, confirm: true, dryRun: true }),
+ });
+ } catch (e) { setMsg(`Error: ${e.message}`, true); return; }
+
+ showCompliance(dry.compliance);
+ if (dry.blocked) { setMsg(dry.message || 'Blocked by CAN-SPAM.', true); return; }
+ if (!dry.ok) { setMsg(dry.message || 'Pre-flight failed.', true); return; }
+
+ // Step 2: explicit human confirm before any real schedule.
+ const when = prompt(
+ 'Pre-flight passed (CAN-SPAM OK).\n\n' +
+ 'Enter a send time (ISO, e.g. 2026-06-20T15:00:00Z), or leave blank for IMMEDIATE.\n' +
+ 'Press Cancel to abort. This is a GATED send.', '');
+ if (when === null) { setMsg('Schedule aborted.', true); return; }
+ if (!confirm(`CONFIRM gated send of draft ${lastDraftId} ${when ? `at ${when}` : 'IMMEDIATELY'}?`)) {
+ setMsg('Schedule aborted.', true); return;
+ }
+
+ $('#cc-schedule').disabled = true; setMsg('Scheduling…');
+ try {
+ const r = await api('/schedule', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ draft_id: lastDraftId, scheduled_time: when || undefined, html, confirm: true, dryRun: false }),
+ });
+ markMock(r.mock); banner();
+ showCompliance(r.compliance);
+ if (r.ok) setMsg(r.message || 'Scheduled.');
+ else setMsg(r.message || r.error || 'Schedule failed.', true);
+ } catch (e) { setMsg(`Error: ${e.message}`, true); }
+ finally { $('#cc-schedule').disabled = false; }
+ };
+ },
+};
diff --git a/public/panels/copy.html b/public/panels/copy.html
new file mode 100644
index 0000000..afce9f1
--- /dev/null
+++ b/public/panels/copy.html
@@ -0,0 +1,42 @@
+<div class="card">
+ <h2>Suggested Copy</h2>
+ <p class="muted" style="margin:0 0 14px">On-brand marketing copy for Designer Wallcoverings — subject lines, email & social body, drafted in the house voice.</p>
+
+ <div id="copy-banner"></div>
+
+ <div class="grid" style="grid-template-columns:repeat(3,1fr)">
+ <div>
+ <label for="copy-kind">What to write</label>
+ <select id="copy-kind"></select>
+ </div>
+ <div>
+ <label for="copy-product">Product line</label>
+ <select id="copy-product"></select>
+ </div>
+ <div>
+ <label for="copy-audience">Audience</label>
+ <select id="copy-audience"></select>
+ </div>
+ </div>
+
+ <div class="grid" style="grid-template-columns:2fr 1fr;margin-top:14px">
+ <div>
+ <label for="copy-topic">Topic / campaign</label>
+ <input id="copy-topic" type="text" placeholder="e.g. the new grasscloth collection, spring trade event…" />
+ </div>
+ <div>
+ <label for="copy-tone">Tone</label>
+ <select id="copy-tone"></select>
+ </div>
+ </div>
+
+ <div class="row" style="margin-top:14px;align-items:flex-end">
+ <div style="flex:1;min-width:220px">
+ <label for="copy-n">Variants — <b id="copy-n-val">3</b></label>
+ <input id="copy-n" type="range" min="1" max="8" step="1" value="3" />
+ </div>
+ <button id="copy-go" class="btn gold">✍️ Generate</button>
+ </div>
+</div>
+
+<div id="copy-out" class="grid"></div>
diff --git a/public/panels/copy.js b/public/panels/copy.js
new file mode 100644
index 0000000..752f83a
--- /dev/null
+++ b/public/panels/copy.js
@@ -0,0 +1,135 @@
+// copy panel — on-brand marketing copy generator.
+// Loads presets, builds the form, POSTs /api/copy/generate, renders variants as
+// copy-to-clipboard cards. Banner shows when running in templates (mock) mode.
+window.MCC_PANELS = window.MCC_PANELS || {};
+window.MCC_PANELS['copy'] = {
+ async init(root) {
+ const $ = s => root.querySelector(s);
+ const esc = s => String(s == null ? '' : s)
+ .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
+
+ // ── presets → populate selects ───────────────────────────────────────────
+ let presets;
+ try {
+ presets = await (await fetch('/api/copy/presets')).json();
+ } catch (e) {
+ $('#copy-out').innerHTML = '<div class="muted-banner">Could not reach the copy service.</div>';
+ return;
+ }
+
+ const fill = (sel, items, { value = x => x, label = x => x, lead } = {}) => {
+ const el = $(sel);
+ if (!el) return;
+ el.innerHTML =
+ (lead ? `<option value="any">${lead}</option>` : '') +
+ items.map(it => `<option value="${esc(value(it))}">${esc(label(it))}</option>`).join('');
+ };
+
+ fill('#copy-kind', presets.kinds || [], { value: k => k.id, label: k => k.label });
+ fill('#copy-product', presets.products || [], { label: p => cap(p), lead: 'Any / not product-specific' });
+ fill('#copy-audience', presets.audiences || [], { lead: 'Any audience' });
+ fill('#copy-tone', presets.tones || [], { label: t => cap(t) });
+
+ function cap(s) { return s ? s.charAt(0).toUpperCase() + s.slice(1) : s; }
+
+ // ── count slider live label ──────────────────────────────────────────────
+ const nEl = $('#copy-n'), nVal = $('#copy-n-val');
+ nEl.addEventListener('input', () => { nVal.textContent = nEl.value; });
+
+ // ── generate ─────────────────────────────────────────────────────────────
+ const out = $('#copy-out');
+ const banner = $('#copy-banner');
+ const goBtn = $('#copy-go');
+
+ async function generate() {
+ const payload = {
+ kind: $('#copy-kind').value,
+ topic: $('#copy-topic').value.trim(),
+ product: $('#copy-product').value,
+ audience: $('#copy-audience').value,
+ tone: $('#copy-tone').value,
+ n: parseInt(nEl.value, 10) || 3,
+ };
+ goBtn.disabled = true;
+ const orig = goBtn.textContent;
+ goBtn.textContent = 'Writing…';
+ out.innerHTML = '<div class="loading">Drafting copy…</div>';
+ try {
+ const r = await fetch('/api/copy/generate', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify(payload),
+ });
+ const data = await r.json();
+ render(data, payload.kind);
+ } catch (e) {
+ out.innerHTML = '<div class="muted-banner">Generation failed — please try again.</div>';
+ } finally {
+ goBtn.disabled = false;
+ goBtn.textContent = orig;
+ }
+ }
+
+ function render(data, kind) {
+ const variants = (data && data.variants) || [];
+ banner.innerHTML = data && data.mock
+ ? '<div class="muted-banner">Templates mode — add <b>ANTHROPIC_API_KEY</b> for AI-written copy. These are crafted brand-voice templates.</div>'
+ : '';
+ if (!variants.length) {
+ out.innerHTML = '<div class="muted-banner">No variants returned. Try a different topic.</div>';
+ return;
+ }
+ out.innerHTML = variants.map((v, i) => {
+ const text = v.text || '';
+ const meta = v.meta || '';
+ const footer = (kind === 'email' || v.footerRequired)
+ ? '<div class="pill" style="margin-top:8px;background:#fff0e6;color:#8a5a44">⚠ Add CAN-SPAM footer (physical mailing address + working unsubscribe) before sending</div>'
+ : '';
+ return `<div class="card" style="margin-bottom:0">
+ <div class="row" style="justify-content:space-between;align-items:center;margin-bottom:8px">
+ <span class="pill">Variant ${i + 1}</span>
+ <button class="btn ghost copy-btn" data-i="${i}" style="padding:5px 12px">📋 Copy</button>
+ </div>
+ <div style="white-space:pre-wrap;font-size:14px;line-height:1.55">${esc(text)}</div>
+ ${meta ? `<div class="muted" style="margin-top:8px;font-size:12px">${esc(meta)}</div>` : ''}
+ ${footer}
+ </div>`;
+ }).join('');
+
+ out.querySelectorAll('.copy-btn').forEach(btn => {
+ btn.addEventListener('click', async () => {
+ const v = variants[+btn.dataset.i];
+ const ok = await copyText(v && v.text ? v.text : '');
+ const prev = btn.textContent;
+ btn.textContent = ok ? '✓ Copied' : 'Copy failed';
+ setTimeout(() => { btn.textContent = prev; }, 1400);
+ });
+ });
+ }
+
+ async function copyText(text) {
+ try {
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ await navigator.clipboard.writeText(text);
+ return true;
+ }
+ } catch { /* fall through to legacy */ }
+ try {
+ const ta = document.createElement('textarea');
+ ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0';
+ document.body.appendChild(ta); ta.select();
+ const ok = document.execCommand('copy');
+ document.body.removeChild(ta);
+ return ok;
+ } catch { return false; }
+ }
+
+ goBtn.addEventListener('click', generate);
+ $('#copy-topic').addEventListener('keydown', e => { if (e.key === 'Enter') generate(); });
+
+ // Initial banner reflecting whether the LLM key is configured.
+ if (presets && presets.llm === false) {
+ banner.innerHTML = '<div class="muted-banner">Templates mode — add <b>ANTHROPIC_API_KEY</b> for AI-written copy.</div>';
+ }
+ },
+};
diff --git a/public/panels/layouts.html b/public/panels/layouts.html
new file mode 100644
index 0000000..dee2613
--- /dev/null
+++ b/public/panels/layouts.html
@@ -0,0 +1,109 @@
+<!-- layouts panel — On-Demand Layouts -->
+<div id="layouts-root" style="display:flex;gap:0;height:calc(100vh - 80px);min-height:520px;overflow:hidden;">
+
+ <!-- ── LEFT: Form pane ─────────────────────────────────────────────────── -->
+ <div id="layouts-form-pane" style="width:300px;min-width:260px;max-width:320px;flex-shrink:0;overflow-y:auto;padding:20px 22px 32px;border-right:1px solid var(--line);background:var(--paper);">
+
+ <div style="margin-bottom:20px;">
+ <div style="font-family:'Cormorant Garamond',Georgia,serif;font-size:18px;font-weight:600;color:var(--ink);margin-bottom:4px;">Layout Generator</div>
+ <div style="font-size:12px;color:var(--mut);">Instant brand-matched mockups</div>
+ </div>
+
+ <!-- Template selector -->
+ <div style="margin-bottom:14px;">
+ <label for="ly-template">Template</label>
+ <select id="ly-template">
+ <option value="">Loading templates…</option>
+ </select>
+ <div id="ly-template-desc" style="font-size:11px;color:var(--mut);margin-top:5px;line-height:1.4;min-height:16px;"></div>
+ </div>
+
+ <!-- Headline -->
+ <div style="margin-bottom:14px;">
+ <label for="ly-headline">Headline</label>
+ <input id="ly-headline" type="text" placeholder="e.g. The New Silk Collection" maxlength="120">
+ </div>
+
+ <!-- Subhead -->
+ <div style="margin-bottom:14px;">
+ <label for="ly-subhead">Subhead / Body copy</label>
+ <textarea id="ly-subhead" rows="2" placeholder="e.g. Woven in Japan, designed for extraordinary interiors." style="resize:vertical;font-size:13px;"></textarea>
+ </div>
+
+ <!-- CTA -->
+ <div style="margin-bottom:14px;">
+ <label for="ly-cta">CTA Text</label>
+ <input id="ly-cta" type="text" placeholder="e.g. Explore the collection" maxlength="60">
+ </div>
+
+ <!-- Product name -->
+ <div style="margin-bottom:14px;">
+ <label for="ly-product">Product / Collection name <span style="color:var(--mut);font-weight:400;">(optional)</span></label>
+ <input id="ly-product" type="text" placeholder="e.g. Kyoto Grasscloth" maxlength="80">
+ </div>
+
+ <!-- Image URL -->
+ <div style="margin-bottom:18px;">
+ <label for="ly-image">Image URL <span style="color:var(--mut);font-weight:400;">(optional)</span></label>
+ <input id="ly-image" type="url" placeholder="https://…" style="font-size:12px;">
+ <div style="font-size:11px;color:var(--mut);margin-top:4px;">Leave blank for brand gradient placeholder</div>
+ </div>
+
+ <!-- Palette swatches -->
+ <div style="margin-bottom:22px;">
+ <label>Palette</label>
+ <div style="display:flex;gap:7px;flex-wrap:wrap;align-items:center;">
+ <button class="ly-swatch active" data-palette="brand" title="Brand (default)" style="width:28px;height:28px;border-radius:50%;background:linear-gradient(135deg,#14110f 50%,#b8925a 50%);border:2px solid var(--ink);cursor:pointer;"></button>
+ <button class="ly-swatch" data-palette="cream" title="Light / Cream" style="width:28px;height:28px;border-radius:50%;background:linear-gradient(135deg,#f4efe7 50%,#b8925a 50%);border:2px solid var(--line);cursor:pointer;"></button>
+ <button class="ly-swatch" data-palette="sage" title="Sage / Nature" style="width:28px;height:28px;border-radius:50%;background:linear-gradient(135deg,#6f7a63 50%,#f4efe7 50%);border:2px solid var(--line);cursor:pointer;"></button>
+ <button class="ly-swatch" data-palette="rust" title="Rust / Warm" style="width:28px;height:28px;border-radius:50%;background:linear-gradient(135deg,#8a5a44 50%,#f4efe7 50%);border:2px solid var(--line);cursor:pointer;"></button>
+ <span id="ly-palette-label" style="font-size:11px;color:var(--mut);margin-left:2px;">Brand</span>
+ </div>
+ </div>
+
+ <!-- Render button -->
+ <button id="ly-render-btn" class="btn gold" style="width:100%;font-size:13px;letter-spacing:.5px;">Generate Layout</button>
+ <div id="ly-render-status" style="font-size:11px;color:var(--mut);margin-top:8px;text-align:center;min-height:16px;"></div>
+
+ </div>
+
+ <!-- ── RIGHT: Preview pane ─────────────────────────────────────────────── -->
+ <div id="layouts-preview-pane" style="flex:1;display:flex;flex-direction:column;min-width:0;background:#e8e3da;">
+
+ <!-- Preview toolbar -->
+ <div style="display:flex;align-items:center;justify-content:space-between;padding:10px 18px;background:var(--paper);border-bottom:1px solid var(--line);flex-shrink:0;gap:10px;">
+ <div style="display:flex;align-items:center;gap:10px;">
+ <span id="ly-preview-label" style="font-size:13px;font-weight:600;color:var(--ink);">Preview</span>
+ <span id="ly-preview-kind" class="pill" style="display:none;">email</span>
+ </div>
+ <div style="display:flex;gap:8px;">
+ <button id="ly-copy-btn" class="btn ghost" style="font-size:12px;padding:7px 14px;" disabled>⬇ Copy HTML</button>
+ <button id="ly-open-btn" class="btn ghost" style="font-size:12px;padding:7px 14px;" disabled>↗ New Tab</button>
+ </div>
+ </div>
+
+ <!-- Scale controls for social templates -->
+ <div id="ly-scale-bar" style="display:none;padding:8px 18px;background:var(--paper);border-bottom:1px solid var(--line);flex-shrink:0;gap:10px;align-items:center;font-size:12px;color:var(--mut);">
+ <span>Scale:</span>
+ <input id="ly-scale-slider" type="range" min="10" max="100" value="40" style="width:120px;padding:0;">
+ <span id="ly-scale-pct">40%</span>
+ </div>
+
+ <!-- Iframe container -->
+ <div id="ly-iframe-wrap" style="flex:1;overflow:auto;display:flex;align-items:flex-start;justify-content:center;padding:28px;">
+ <div id="ly-placeholder" style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;min-height:280px;color:var(--mut);text-align:center;gap:12px;width:100%;">
+ <span style="font-size:40px;opacity:.3;">🎨</span>
+ <div style="font-family:'Cormorant Garamond',Georgia,serif;font-size:22px;font-weight:300;opacity:.5;">Your layout will appear here</div>
+ <div style="font-size:12px;opacity:.4;">Select a template and click Generate Layout</div>
+ </div>
+ <div id="ly-iframe-container" style="display:none;position:relative;">
+ <iframe id="ly-preview-frame"
+ sandbox="allow-same-origin"
+ style="border:none;display:block;box-shadow:0 8px 40px rgba(20,17,15,.18);"
+ title="Layout preview"
+ ></iframe>
+ </div>
+ </div>
+
+ </div>
+</div>
diff --git a/public/panels/layouts.js b/public/panels/layouts.js
new file mode 100644
index 0000000..e8c67a3
--- /dev/null
+++ b/public/panels/layouts.js
@@ -0,0 +1,272 @@
+/* global window, document, fetch, URL, Blob */
+window.MCC_PANELS = window.MCC_PANELS || {};
+window.MCC_PANELS['layouts'] = {
+ init(root) {
+ // ── DOM refs ──────────────────────────────────────────────────────────────
+ const $ = id => root.querySelector(id);
+ const templateSel = $('#ly-template');
+ const templateDesc = $('#ly-template-desc');
+ const headlineIn = $('#ly-headline');
+ const subheadIn = $('#ly-subhead');
+ const ctaIn = $('#ly-cta');
+ const productIn = $('#ly-product');
+ const imageIn = $('#ly-image');
+ const renderBtn = $('#ly-render-btn');
+ const status = $('#ly-render-status');
+ const copyBtn = $('#ly-copy-btn');
+ const openBtn = $('#ly-open-btn');
+ const previewFrame = $('#ly-preview-frame');
+ const iframeWrap = $('#ly-iframe-container');
+ const placeholder = $('#ly-placeholder');
+ const previewLabel = $('#ly-preview-label');
+ const previewKind = $('#ly-preview-kind');
+ const scaleBar = $('#ly-scale-bar');
+ const scaleSlider = $('#ly-scale-slider');
+ const scalePct = $('#ly-scale-pct');
+ const swatches = root.querySelectorAll('.ly-swatch');
+ const paletteLabel = $('#ly-palette-label');
+
+ // ── State ─────────────────────────────────────────────────────────────────
+ let templates = [];
+ let currentHtml = '';
+ let debounceTimer = null;
+ let selectedPalette = 'brand';
+ let currentKind = 'email';
+
+ // Palette presets — override ink/card/bg tones
+ const PALETTES = {
+ brand: { label: 'Brand', overrides: null },
+ cream: {
+ label: 'Cream',
+ overrides: { ink: '#14110f', card: '#ffffff', bg: '#f4efe7' },
+ },
+ sage: {
+ label: 'Sage',
+ overrides: { ink: '#3a4235', cream: '#f4efe7', card: '#eef0eb', bg: '#e4e8de', gold: '#b8925a' },
+ },
+ rust: {
+ label: 'Rust',
+ overrides: { ink: '#4a2418', cream: '#fdf5ef', card: '#fff9f5', bg: '#f7ece4', gold: '#c2874e', accent: '#8a5a44' },
+ },
+ };
+
+ // ── Aspect ratio + scaling helpers ────────────────────────────────────────
+ // Returns { nativeW, nativeH } for each template
+ const NATIVE_DIMS = {
+ 'email-hero': { w: 600, h: 740 },
+ 'email-product-grid': { w: 600, h: 820 },
+ 'email-announcement': { w: 600, h: 760 },
+ 'instagram-post': { w: 1080, h: 1080 },
+ 'instagram-story': { w: 1080, h: 1920 },
+ 'social-banner': { w: 1200, h: 300 },
+ };
+
+ function applyFrameSize(templateId, scaleFactor) {
+ const dims = NATIVE_DIMS[templateId] || { w: 600, h: 600 };
+ const s = scaleFactor / 100;
+ const w = Math.round(dims.w * s);
+ const h = Math.round(dims.h * s);
+ previewFrame.style.width = w + 'px';
+ previewFrame.style.height = h + 'px';
+ previewFrame.style.transform = `scale(1)`;
+ // Use viewport meta trick: set iframe native size to full dims, then CSS-scale down
+ // Better: just set iframe width to native and use CSS transform scale
+ previewFrame.width = dims.w;
+ previewFrame.height = dims.h;
+ previewFrame.style.width = dims.w + 'px';
+ previewFrame.style.height = dims.h + 'px';
+ previewFrame.style.transformOrigin = 'top left';
+ previewFrame.style.transform = `scale(${s})`;
+ iframeWrap.style.width = w + 'px';
+ iframeWrap.style.height = h + 'px';
+ }
+
+ function defaultScale(templateId) {
+ const dims = NATIVE_DIMS[templateId] || { w: 600, h: 600 };
+ // Target: fit within ~640px wide, max scale 100%
+ const targetW = 640;
+ const s = Math.min(100, Math.round((targetW / dims.w) * 100));
+ return s;
+ }
+
+ // ── Load templates ────────────────────────────────────────────────────────
+ async function loadTemplates() {
+ try {
+ const res = await fetch('/api/layouts/templates');
+ const data = await res.json();
+ templates = data.templates || [];
+ templateSel.innerHTML = templates.map(t =>
+ `<option value="${t.id}">${t.name}</option>`
+ ).join('');
+ updateTemplateDesc();
+ } catch (e) {
+ templateSel.innerHTML = '<option value="">Error loading templates</option>';
+ showStatus('Could not load templates: ' + e.message, true);
+ }
+ }
+
+ function updateTemplateDesc() {
+ const id = templateSel.value;
+ const t = templates.find(t => t.id === id);
+ templateDesc.textContent = t ? t.desc : '';
+ currentKind = t ? t.kind : 'email';
+ }
+
+ templateSel.addEventListener('change', () => {
+ updateTemplateDesc();
+ scheduleRender();
+ });
+
+ // ── Palette swatches ──────────────────────────────────────────────────────
+ swatches.forEach(sw => {
+ sw.addEventListener('click', () => {
+ swatches.forEach(s => {
+ s.style.border = '2px solid var(--line)';
+ });
+ sw.style.border = '2px solid var(--ink)';
+ selectedPalette = sw.dataset.palette;
+ const preset = PALETTES[selectedPalette];
+ paletteLabel.textContent = preset ? preset.label : 'Brand';
+ scheduleRender();
+ });
+ });
+ // Mark brand swatch active on init
+ root.querySelector('.ly-swatch[data-palette="brand"]').style.border = '2px solid var(--ink)';
+
+ // ── Scale slider ──────────────────────────────────────────────────────────
+ scaleSlider.addEventListener('input', () => {
+ scalePct.textContent = scaleSlider.value + '%';
+ const id = templateSel.value;
+ if (id) applyFrameSize(id, parseInt(scaleSlider.value, 10));
+ });
+
+ // ── Form inputs → debounced re-render ─────────────────────────────────────
+ [headlineIn, subheadIn, ctaIn, productIn, imageIn].forEach(el => {
+ el.addEventListener('input', () => scheduleRender());
+ });
+
+ function scheduleRender() {
+ clearTimeout(debounceTimer);
+ debounceTimer = setTimeout(() => doRender(), 500);
+ }
+
+ renderBtn.addEventListener('click', () => {
+ clearTimeout(debounceTimer);
+ doRender();
+ });
+
+ // ── Render ────────────────────────────────────────────────────────────────
+ async function doRender() {
+ const template = templateSel.value;
+ if (!template) {
+ showStatus('Please select a template first.');
+ return;
+ }
+
+ showStatus('Generating…');
+ renderBtn.disabled = true;
+
+ const preset = PALETTES[selectedPalette] || PALETTES.brand;
+ const body = {
+ template,
+ headline: headlineIn.value.trim() || undefined,
+ subhead: subheadIn.value.trim() || undefined,
+ cta: ctaIn.value.trim() || undefined,
+ productName: productIn.value.trim() || undefined,
+ imageUrl: imageIn.value.trim() || undefined,
+ palette: preset.overrides || undefined,
+ };
+
+ try {
+ const res = await fetch('/api/layouts/render', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ const data = await res.json();
+ if (!res.ok) throw new Error(data.error || 'Render failed');
+
+ currentHtml = data.html;
+ showPreview(template, data.html);
+ showStatus('');
+ copyBtn.disabled = false;
+ openBtn.disabled = false;
+ } catch (e) {
+ showStatus('Error: ' + e.message, true);
+ } finally {
+ renderBtn.disabled = false;
+ }
+ }
+
+ function showPreview(templateId, html) {
+ // Show iframe, hide placeholder
+ placeholder.style.display = 'none';
+ iframeWrap.style.display = 'block';
+
+ // Label + kind pill
+ const t = templates.find(t => t.id === templateId);
+ previewLabel.textContent = t ? t.name : 'Preview';
+ previewKind.style.display = 'inline-block';
+ previewKind.textContent = t ? t.kind : '';
+
+ // Show/hide scale bar for social templates
+ const isSocial = t && t.kind === 'social';
+ scaleBar.style.display = isSocial ? 'flex' : 'none';
+
+ // Set scale
+ const scale = defaultScale(templateId);
+ scaleSlider.value = scale;
+ scalePct.textContent = scale + '%';
+
+ // Write srcdoc
+ previewFrame.srcdoc = html;
+
+ // Apply sizing after browser has a chance to paint
+ setTimeout(() => applyFrameSize(templateId, scale), 30);
+ }
+
+ // ── Copy HTML ─────────────────────────────────────────────────────────────
+ copyBtn.addEventListener('click', async () => {
+ if (!currentHtml) return;
+ try {
+ await navigator.clipboard.writeText(currentHtml);
+ const orig = copyBtn.textContent;
+ copyBtn.textContent = '✓ Copied!';
+ setTimeout(() => { copyBtn.textContent = orig; }, 1600);
+ } catch {
+ // Fallback
+ const ta = document.createElement('textarea');
+ ta.value = currentHtml;
+ ta.style.position = 'fixed'; ta.style.opacity = '0';
+ document.body.appendChild(ta);
+ ta.select();
+ document.execCommand('copy');
+ document.body.removeChild(ta);
+ copyBtn.textContent = '✓ Copied!';
+ setTimeout(() => { copyBtn.textContent = '⬇ Copy HTML'; }, 1600);
+ }
+ });
+
+ // ── Open in new tab ───────────────────────────────────────────────────────
+ openBtn.addEventListener('click', () => {
+ if (!currentHtml) return;
+ const blob = new Blob([currentHtml], { type: 'text/html' });
+ const url = URL.createObjectURL(blob);
+ window.open(url, '_blank');
+ // Revoke after delay
+ setTimeout(() => URL.revokeObjectURL(url), 30000);
+ });
+
+ // ── Status helper ─────────────────────────────────────────────────────────
+ function showStatus(msg, isError) {
+ status.textContent = msg;
+ status.style.color = isError ? '#e07a5f' : 'var(--mut)';
+ }
+
+ // ── Boot ──────────────────────────────────────────────────────────────────
+ loadTemplates().then(() => {
+ // Auto-render first template with defaults so panel is not empty
+ if (templates.length) doRender();
+ });
+ },
+};
← de56576 scaffold: modular marketing command center shell (express, b
·
back to Marketing Command Center
·
deploy config → port 9662 (9660/9661 taken on Kamatera) 4b6184a →