← back to Marketing Command Center
backsync: pull Yuri's 10 CRM features from prod (RFM, send-times, ab-tests, profiles, segment-perf, templates, journeys, browse-abandon, copy-scoring, playbook) — Mac2 source now matches live 18-panel build
a905372f818459471a2008a433aeb6682e4bd660 · 2026-06-10 16:30:48 -0700 · Steve
Files touched
A data/ab-tests.jsonA data/browse-abandon-config.jsonA data/browse-abandon-events.jsonA data/journeys.jsonA data/playbook.jsonA modules/ab-tests/index.jsA modules/browse-abandon/index.jsM modules/copy/index.jsA modules/journeys/index.jsA modules/playbook/index.jsA modules/profiles/index.jsM modules/registry.jsA modules/segment-perf/index.jsM modules/segments/index.jsA modules/send-times/index.jsA modules/templates/index.jsA public/panels/ab-tests.htmlA public/panels/ab-tests.jsA public/panels/browse-abandon.htmlA public/panels/browse-abandon.jsM public/panels/copy.htmlM public/panels/copy.jsA public/panels/journeys.htmlA public/panels/journeys.jsA public/panels/playbook.htmlA public/panels/playbook.jsA public/panels/profiles.htmlA public/panels/profiles.jsA public/panels/segment-perf.htmlA public/panels/segment-perf.jsM public/panels/segments.htmlM public/panels/segments.jsA public/panels/send-times.htmlA public/panels/send-times.jsA public/panels/templates.htmlA public/panels/templates.js
Diff
commit a905372f818459471a2008a433aeb6682e4bd660
Author: Steve <steve@designerwallcoverings.com>
Date: Wed Jun 10 16:30:48 2026 -0700
backsync: pull Yuri's 10 CRM features from prod (RFM, send-times, ab-tests, profiles, segment-perf, templates, journeys, browse-abandon, copy-scoring, playbook) — Mac2 source now matches live 18-panel build
---
data/ab-tests.json | 1 +
data/browse-abandon-config.json | 39 +++
data/browse-abandon-events.json | 218 +++++++++++++
data/journeys.json | 72 +++++
data/playbook.json | 184 +++++++++++
modules/ab-tests/index.js | 395 +++++++++++++++++++++++
modules/browse-abandon/index.js | 531 +++++++++++++++++++++++++++++++
modules/copy/index.js | 434 ++++++++++++++++++++++++++
modules/journeys/index.js | 639 ++++++++++++++++++++++++++++++++++++++
modules/playbook/index.js | 614 ++++++++++++++++++++++++++++++++++++
modules/profiles/index.js | 539 ++++++++++++++++++++++++++++++++
modules/registry.js | 8 +
modules/segment-perf/index.js | 307 ++++++++++++++++++
modules/segments/index.js | 205 +++++++++++-
modules/send-times/index.js | 372 ++++++++++++++++++++++
modules/templates/index.js | 434 ++++++++++++++++++++++++++
public/panels/ab-tests.html | 95 ++++++
public/panels/ab-tests.js | 287 +++++++++++++++++
public/panels/browse-abandon.html | 91 ++++++
public/panels/browse-abandon.js | 392 +++++++++++++++++++++++
public/panels/copy.html | 31 ++
public/panels/copy.js | 134 ++++++++
public/panels/journeys.html | 53 ++++
public/panels/journeys.js | 427 +++++++++++++++++++++++++
public/panels/playbook.html | 77 +++++
public/panels/playbook.js | 310 ++++++++++++++++++
public/panels/profiles.html | 27 ++
public/panels/profiles.js | 296 ++++++++++++++++++
public/panels/segment-perf.html | 30 ++
public/panels/segment-perf.js | 294 ++++++++++++++++++
public/panels/segments.html | 19 ++
public/panels/segments.js | 65 ++++
public/panels/send-times.html | 64 ++++
public/panels/send-times.js | 208 +++++++++++++
public/panels/templates.html | 26 ++
public/panels/templates.js | 251 +++++++++++++++
36 files changed, 8167 insertions(+), 2 deletions(-)
diff --git a/data/ab-tests.json b/data/ab-tests.json
new file mode 100644
index 0000000..0637a08
--- /dev/null
+++ b/data/ab-tests.json
@@ -0,0 +1 @@
+[]
\ No newline at end of file
diff --git a/data/browse-abandon-config.json b/data/browse-abandon-config.json
new file mode 100644
index 0000000..b1292bb
--- /dev/null
+++ b/data/browse-abandon-config.json
@@ -0,0 +1,39 @@
+{
+ "enabled": true,
+ "noSampleThresholdDays": 1,
+ "dailySendCap": 250,
+ "steps": [
+ {
+ "id": "step_day1_storytelling",
+ "dayOffset": 1,
+ "kind": "email",
+ "enabled": true,
+ "title": "Day 1 — Storytelling",
+ "subject": "The story behind {pattern_name}",
+ "bodyTemplate": "Hello {first_name},\n\nYou spent a moment with {pattern_name} from our {collection} edit — and we wanted to share where it comes from. {vendor} has been hand-finishing this {product_type} for decades, and every roll carries the irregularities that separate a true wallcovering from a printed substitute.\n\nIf it stayed with you, the next move is a memo sample. Hold it against your scheme; the gold, the cream, the ink all read differently in your light.\n\nRequest a memo: {memo_url}\n\nQuietly,\nThe Designer Wallcoverings atelier",
+ "cta": "Request memo samples",
+ "suppressIfSampleRequested": true
+ },
+ {
+ "id": "step_day14_room",
+ "dayOffset": 14,
+ "kind": "email",
+ "enabled": true,
+ "title": "Day 14 — Room setting",
+ "subject": "{pattern_name} — see it in a room",
+ "bodyTemplate": "Hello {first_name},\n\n{pattern_name} reads one way as a flat scan and another way on a wall. We staged a room-setting render so you can see the scale, the drop, and the way the {color_name} sits against trim.\n\nView the room setting: {product_url}\n\nA memo box ships free to your trade address — the swatches let you confirm the field colour under your client's light.\n\nRequest a memo: {memo_url}\n\nWith care,\nThe Designer Wallcoverings atelier",
+ "cta": "Explore the collection",
+ "suppressIfSampleRequested": true
+ },
+ {
+ "id": "step_day30_suppress",
+ "dayOffset": 30,
+ "kind": "suppress",
+ "enabled": true,
+ "title": "Day 30 — Suppress",
+ "suppressList": "browse-abandon-lapsed",
+ "note": "Move the contact to the \"browse-abandon-lapsed\" cohort, stop the drip, and roll them into the quarterly editorial cadence instead. No email fires on Day 30."
+ }
+ ],
+ "updatedAt": "2026-06-10T17:49:07.845Z"
+}
\ No newline at end of file
diff --git a/data/browse-abandon-events.json b/data/browse-abandon-events.json
new file mode 100644
index 0000000..4672c93
--- /dev/null
+++ b/data/browse-abandon-events.json
@@ -0,0 +1,218 @@
+[
+ {
+ "id": "evt_606110e1",
+ "contactEmail": "audrey.bell@studiobell.com",
+ "contactFirstName": "Audrey",
+ "segment": "Trade & Designers — engaged",
+ "productSku": "DWBR-7821",
+ "productPattern": "Cliveden Grasscloth",
+ "productColor": "Ink Slate",
+ "productType": "Wallcovering",
+ "vendor": "Brewster",
+ "collection": "Brewster Naturals",
+ "productLine": "grasscloth",
+ "productUrl": "https://designerwallcoverings.com/products/dwbr-7821",
+ "memoUrl": "https://designerwallcoverings.com/memos/request?sku=DWBR-7821",
+ "browsedAt": "2026-06-10T17:49:07.875Z",
+ "sampleRequestedAt": null,
+ "mock": true
+ },
+ {
+ "id": "evt_9ca40b7c",
+ "contactEmail": "marcus@hauserarch.com",
+ "contactFirstName": "Marcus",
+ "segment": "Trade & Designers — engaged",
+ "productSku": "DWKK-4490",
+ "productPattern": "Hampshire Silk",
+ "productColor": "Antique Cream",
+ "productType": "Wallcovering",
+ "vendor": "Kravet",
+ "collection": "Heritage Silk",
+ "productLine": "silk",
+ "productUrl": "https://designerwallcoverings.com/products/dwkk-4490",
+ "memoUrl": "https://designerwallcoverings.com/memos/request?sku=DWKK-4490",
+ "browsedAt": "2026-06-09T17:49:07.875Z",
+ "sampleRequestedAt": null,
+ "mock": true
+ },
+ {
+ "id": "evt_bb82f044",
+ "contactEmail": "priya@vermillionatelier.com",
+ "contactFirstName": "Priya",
+ "segment": "High-intent — clicked memo CTA",
+ "productSku": "DWAR-1208",
+ "productPattern": "Estoril Cork",
+ "productColor": "Mineral Sage",
+ "productType": "Wallcovering",
+ "vendor": "Arte",
+ "collection": "Mediterranean",
+ "productLine": "cork",
+ "productUrl": "https://designerwallcoverings.com/products/dwar-1208",
+ "memoUrl": "https://designerwallcoverings.com/memos/request?sku=DWAR-1208",
+ "browsedAt": "2026-06-09T17:49:07.875Z",
+ "sampleRequestedAt": null,
+ "mock": true
+ },
+ {
+ "id": "evt_249331f4",
+ "contactEmail": "eli.kane@kanedesign.studio",
+ "contactFirstName": "Eli",
+ "segment": "Trade & Designers — engaged",
+ "productSku": "DWMR-3361",
+ "productPattern": "Aurora Flock",
+ "productColor": "Carmine",
+ "productType": "Wallcovering",
+ "vendor": "Maya Romanoff",
+ "collection": "Aurora",
+ "productLine": "flocked",
+ "productUrl": "https://designerwallcoverings.com/products/dwmr-3361",
+ "memoUrl": "https://designerwallcoverings.com/memos/request?sku=DWMR-3361",
+ "browsedAt": "2026-06-08T17:49:07.875Z",
+ "sampleRequestedAt": null,
+ "mock": true
+ },
+ {
+ "id": "evt_591383fc",
+ "contactEmail": "naomi.s@hospitalitypartnersgroup.com",
+ "contactFirstName": "Naomi",
+ "segment": "Hospitality / Commercial spec",
+ "productSku": "DWPR-9904",
+ "productPattern": "Coral Gables Vinyl",
+ "productColor": "Surf",
+ "productType": "Wallcovering",
+ "vendor": "Phillipe Romano",
+ "collection": "Hollywood Vinyls Vol. 1",
+ "productLine": "vinyl",
+ "productUrl": "https://designerwallcoverings.com/products/dwpr-9904",
+ "memoUrl": "https://designerwallcoverings.com/memos/request?sku=DWPR-9904",
+ "browsedAt": "2026-06-05T17:49:07.875Z",
+ "sampleRequestedAt": "2026-06-06T17:49:07.875Z",
+ "mock": true
+ },
+ {
+ "id": "evt_3f6d8808",
+ "contactEmail": "rhianna@laurelandfern.co",
+ "contactFirstName": "Rhianna",
+ "segment": "Retail — lapsed 90d",
+ "productSku": "DWGB-1145",
+ "productPattern": "Beaded Constellation",
+ "productColor": "Champagne",
+ "productType": "Wallcovering",
+ "vendor": "Glass Bead",
+ "collection": "Constellation",
+ "productLine": "glass bead",
+ "productUrl": "https://designerwallcoverings.com/products/dwgb-1145",
+ "memoUrl": "https://designerwallcoverings.com/memos/request?sku=DWGB-1145",
+ "browsedAt": "2026-06-03T17:49:07.875Z",
+ "sampleRequestedAt": null,
+ "mock": true
+ },
+ {
+ "id": "evt_988d3529",
+ "contactEmail": "cyrus@morrowinteriors.com",
+ "contactFirstName": "Cyrus",
+ "segment": "Trade & Designers — engaged",
+ "productSku": "DWPI-2003",
+ "productPattern": "Empire Linen",
+ "productColor": "Bone",
+ "productType": "Wallcovering",
+ "vendor": "Phillip Jeffries",
+ "collection": "Empire",
+ "productLine": "linen",
+ "productUrl": "https://designerwallcoverings.com/products/dwpi-2003",
+ "memoUrl": "https://designerwallcoverings.com/memos/request?sku=DWPI-2003",
+ "browsedAt": "2026-05-27T17:49:07.875Z",
+ "sampleRequestedAt": null,
+ "mock": true
+ },
+ {
+ "id": "evt_53fb304b",
+ "contactEmail": "wren.day@daydesignhouse.com",
+ "contactFirstName": "Wren",
+ "segment": "High-intent — clicked memo CTA",
+ "productSku": "DWSC-6677",
+ "productPattern": "Schiaparelli Mural",
+ "productColor": "Garden",
+ "productType": "Mural",
+ "vendor": "Schumacher",
+ "collection": "Couture Murals",
+ "productLine": "mural",
+ "productUrl": "https://designerwallcoverings.com/products/dwsc-6677",
+ "memoUrl": "https://designerwallcoverings.com/memos/request?sku=DWSC-6677",
+ "browsedAt": "2026-05-26T17:49:07.875Z",
+ "sampleRequestedAt": null,
+ "mock": true
+ },
+ {
+ "id": "evt_89ac8349",
+ "contactEmail": "imogen@borealis-design.co.uk",
+ "contactFirstName": "Imogen",
+ "segment": "Trade & Designers — engaged",
+ "productSku": "DWBR-7821",
+ "productPattern": "Cliveden Grasscloth",
+ "productColor": "Ink Slate",
+ "productType": "Wallcovering",
+ "vendor": "Brewster",
+ "collection": "Brewster Naturals",
+ "productLine": "grasscloth",
+ "productUrl": "https://designerwallcoverings.com/products/dwbr-7821",
+ "memoUrl": "https://designerwallcoverings.com/memos/request?sku=DWBR-7821",
+ "browsedAt": "2026-05-20T17:49:07.875Z",
+ "sampleRequestedAt": null,
+ "mock": true
+ },
+ {
+ "id": "evt_eb4f547f",
+ "contactEmail": "tobias@northernlightstudio.com",
+ "contactFirstName": "Tobias",
+ "segment": "Hospitality / Commercial spec",
+ "productSku": "DWKK-4490",
+ "productPattern": "Hampshire Silk",
+ "productColor": "Antique Cream",
+ "productType": "Wallcovering",
+ "vendor": "Kravet",
+ "collection": "Heritage Silk",
+ "productLine": "silk",
+ "productUrl": "https://designerwallcoverings.com/products/dwkk-4490",
+ "memoUrl": "https://designerwallcoverings.com/memos/request?sku=DWKK-4490",
+ "browsedAt": "2026-05-13T17:49:07.875Z",
+ "sampleRequestedAt": null,
+ "mock": true
+ },
+ {
+ "id": "evt_ff18d5fd",
+ "contactEmail": "sasha@sashalindersinteriors.com",
+ "contactFirstName": "Sasha",
+ "segment": "Trade & Designers — engaged",
+ "productSku": "DWAR-1208",
+ "productPattern": "Estoril Cork",
+ "productColor": "Mineral Sage",
+ "productType": "Wallcovering",
+ "vendor": "Arte",
+ "collection": "Mediterranean",
+ "productLine": "cork",
+ "productUrl": "https://designerwallcoverings.com/products/dwar-1208",
+ "memoUrl": "https://designerwallcoverings.com/memos/request?sku=DWAR-1208",
+ "browsedAt": "2026-05-11T17:49:07.875Z",
+ "sampleRequestedAt": null,
+ "mock": true
+ },
+ {
+ "id": "evt_1f905ec1",
+ "contactEmail": "leo.b@leonardobruzzese.it",
+ "contactFirstName": "Leo",
+ "segment": "High-intent — clicked memo CTA",
+ "productSku": "DWMR-3361",
+ "productPattern": "Aurora Flock",
+ "productColor": "Carmine",
+ "productType": "Wallcovering",
+ "vendor": "Maya Romanoff",
+ "collection": "Aurora",
+ "productLine": "flocked",
+ "productUrl": "https://designerwallcoverings.com/products/dwmr-3361",
+ "memoUrl": "https://designerwallcoverings.com/memos/request?sku=DWMR-3361",
+ "browsedAt": "2026-05-08T17:49:07.875Z",
+ "sampleRequestedAt": null,
+ "mock": true
+ }
+]
\ No newline at end of file
diff --git a/data/journeys.json b/data/journeys.json
new file mode 100644
index 0000000..c6d1c54
--- /dev/null
+++ b/data/journeys.json
@@ -0,0 +1,72 @@
+[
+ {
+ "id": "jny_seed_welcome",
+ "name": "Designer Welcome — Cross-Channel",
+ "description": "New trade-approved designer: welcome email → 24h SMS for non-clickers → 3-day IG retarget on non-openers → 7-day re-engagement.",
+ "segmentId": "seg_trade_engaged",
+ "segmentName": "Trade & Designers — engaged",
+ "audienceSize": 240,
+ "status": "staged",
+ "createdAt": "2026-06-10T17:33:22.196Z",
+ "updatedAt": "2026-06-10T17:33:22.196Z",
+ "steps": [
+ {
+ "id": "stp_seed_1",
+ "kind": "email",
+ "condition": "always",
+ "subject": "Welcome to the Designer Wallcoverings atelier",
+ "templateId": "welcome-designer-1",
+ "note": "Anchor welcome email (Part 1 of 2)."
+ },
+ {
+ "id": "stp_seed_2",
+ "kind": "wait",
+ "hours": 24,
+ "label": "24 hours"
+ },
+ {
+ "id": "stp_seed_3",
+ "kind": "sms",
+ "condition": "if-no-click",
+ "message": "Designer Wallcoverings — your memo box from this week's edit is one tap away: dwall.co/memos. Reply STOP to opt out.",
+ "note": "Catches mobile openers who did not tap through."
+ },
+ {
+ "id": "stp_seed_4",
+ "kind": "wait",
+ "hours": 72,
+ "label": "3 days"
+ },
+ {
+ "id": "stp_seed_5",
+ "kind": "social",
+ "condition": "if-no-open",
+ "platform": "instagram",
+ "caption": "Three rooms, three wallcoverings — a place to start. #InteriorDesign #Grasscloth",
+ "note": "IG retarget creative for non-openers of the welcome email."
+ },
+ {
+ "id": "stp_seed_6",
+ "kind": "retarget",
+ "condition": "if-no-open",
+ "platform": "meta",
+ "audienceType": "non-openers",
+ "note": "Push non-openers into Meta custom audience for a 7-day window."
+ },
+ {
+ "id": "stp_seed_7",
+ "kind": "wait",
+ "hours": 168,
+ "label": "7 days"
+ },
+ {
+ "id": "stp_seed_8",
+ "kind": "email",
+ "condition": "if-no-open",
+ "subject": "Still considering? A note from the atelier",
+ "templateId": "memo-followup-day21",
+ "note": "Graceful re-engagement before exit."
+ }
+ ]
+ }
+]
\ No newline at end of file
diff --git a/data/playbook.json b/data/playbook.json
new file mode 100644
index 0000000..9ac5319
--- /dev/null
+++ b/data/playbook.json
@@ -0,0 +1,184 @@
+{
+ "playbooks": {
+ "2026-06": {
+ "monthId": "2026-06",
+ "startIso": "2026-06-01",
+ "generatedAt": "2026-06-10T18:13:39.585Z",
+ "mode": "template-fallback",
+ "generatorError": "gemini_http_429",
+ "signals": {
+ "startIso": "2026-06-01",
+ "days": 28,
+ "dates": [
+ {
+ "date": "2026-06-01",
+ "title": "Summer Entertaining",
+ "type": "awareness",
+ "suggestion": "Dining-room + powder-room statement walls for the entertaining season."
+ },
+ {
+ "date": "2026-06-15",
+ "title": "Father's Day",
+ "type": "holiday",
+ "suggestion": "Refined study + library moodboard — leather-look + deep flocked tones."
+ },
+ {
+ "date": "2026-06-21",
+ "title": "First Day of Summer",
+ "type": "awareness",
+ "suggestion": "Light, airy linen + glass-bead palettes; “summer-light interiors.”"
+ }
+ ],
+ "segments": [
+ {
+ "id": "designers",
+ "name": "Interior Designers",
+ "icon": "🪷",
+ "listSize": 2840,
+ "sendsPerCampaign": 2620,
+ "openRate": 0.4437,
+ "pctChange30d": 12.8,
+ "band": "good"
+ },
+ {
+ "id": "commercial",
+ "name": "Commercial / Hospitality",
+ "icon": "🏛",
+ "listSize": 612,
+ "sendsPerCampaign": 590,
+ "openRate": 0.3959,
+ "pctChange30d": 3.4,
+ "band": "good"
+ },
+ {
+ "id": "retail",
+ "name": "Retail",
+ "icon": "🛍",
+ "listSize": 7415,
+ "sendsPerCampaign": 7080,
+ "openRate": 0.2849,
+ "pctChange30d": -1.6,
+ "band": "ok"
+ },
+ {
+ "id": "at-risk",
+ "name": "At-Risk (Winback)",
+ "icon": "⚠",
+ "listSize": 1190,
+ "sendsPerCampaign": 1150,
+ "openRate": 0.071,
+ "pctChange30d": -21.4,
+ "band": "bad"
+ }
+ ],
+ "gaps": [
+ {
+ "segmentId": "at-risk",
+ "segment": "At-Risk (Winback)",
+ "kind": "declining",
+ "note": "At-Risk (Winback) 30-day open rate is down -21.4% — schedule a winback or refresh creative."
+ },
+ {
+ "segmentId": "at-risk",
+ "segment": "At-Risk (Winback)",
+ "kind": "underperforming",
+ "note": "At-Risk (Winback) sits in the BAD band on open rate (7.1%) — try a high-relevance moment or list hygiene pass."
+ }
+ ]
+ },
+ "entries": [
+ {
+ "id": "play_78857c90",
+ "week": 1,
+ "date": "2026-06-01",
+ "title": "Summer Entertaining",
+ "audience": "designers",
+ "audienceLabel": "Interior Designers",
+ "channel": "email",
+ "type": "awareness",
+ "rationale": "Dining-room + powder-room statement walls for the entertaining season.",
+ "source": "calendar",
+ "status": "approved",
+ "notes": "flagged for trade preview",
+ "updatedAt": "2026-06-10T18:13:53.455Z"
+ },
+ {
+ "id": "play_800cf012",
+ "week": 1,
+ "date": "2026-06-03",
+ "title": "At-Risk (Winback) — Winback editorial",
+ "audience": "at-risk",
+ "audienceLabel": "At-Risk Winback",
+ "channel": "email",
+ "type": "declining",
+ "rationale": "At-Risk (Winback) 30-day open rate is down -21.4% — schedule a winback or refresh creative.",
+ "source": "performance-gap",
+ "status": "suggested",
+ "notes": "",
+ "updatedAt": "2026-06-10T18:13:39.585Z"
+ },
+ {
+ "id": "play_8dbfa919",
+ "week": 2,
+ "date": "2026-06-13",
+ "title": "Interior Designers — Glass bead editorial",
+ "audience": "designers",
+ "audienceLabel": "Interior Designers",
+ "channel": "email",
+ "type": "segment-feature",
+ "rationale": "Interior Designers 30d open rate 44.4% (good). Feed momentum with a glass bead-led editorial — keep voice elevated / editorial.",
+ "source": "segment-health",
+ "status": "suggested",
+ "notes": "",
+ "updatedAt": "2026-06-10T18:13:39.585Z"
+ },
+ {
+ "id": "play_c6bc6701",
+ "week": 2,
+ "date": "2026-06-13",
+ "title": "Commercial / Hospitality — Linen editorial",
+ "audience": "commercial",
+ "audienceLabel": "Commercial / Hospitality",
+ "channel": "email",
+ "type": "segment-feature",
+ "rationale": "Commercial / Hospitality 30d open rate 39.6% (good). Feed momentum with a linen-led editorial — keep voice elevated / editorial.",
+ "source": "segment-health",
+ "status": "suggested",
+ "notes": "",
+ "updatedAt": "2026-06-10T18:13:39.585Z"
+ },
+ {
+ "id": "play_d8f59448",
+ "week": 3,
+ "date": "2026-06-17",
+ "title": "At-Risk (Winback) — High-relevance refresh",
+ "audience": "at-risk",
+ "audienceLabel": "At-Risk Winback",
+ "channel": "email",
+ "type": "underperforming",
+ "rationale": "At-Risk (Winback) sits in the BAD band on open rate (7.1%) — try a high-relevance moment or list hygiene pass.",
+ "source": "performance-gap",
+ "status": "suggested",
+ "notes": "",
+ "updatedAt": "2026-06-10T18:13:39.585Z"
+ },
+ {
+ "id": "play_aed3714e",
+ "week": 3,
+ "date": "2026-06-21",
+ "title": "First Day of Summer",
+ "audience": "designers",
+ "audienceLabel": "Interior Designers",
+ "channel": "email",
+ "type": "awareness",
+ "rationale": "Light, airy linen + glass-bead palettes; “summer-light interiors.”",
+ "source": "calendar",
+ "status": "suggested",
+ "notes": "",
+ "updatedAt": "2026-06-10T18:13:39.585Z"
+ }
+ ]
+ }
+ },
+ "updatedAt": "2026-06-10T18:13:53.541Z"
+}
\ No newline at end of file
diff --git a/modules/ab-tests/index.js b/modules/ab-tests/index.js
new file mode 100644
index 0000000..bd3c2c2
--- /dev/null
+++ b/modules/ab-tests/index.js
@@ -0,0 +1,395 @@
+// A/B Tests module — plan + track email A/B campaign tests. The user defines
+// two variants (subject + body/angle) and a holdout %; this module persists the
+// test to data/ab-tests.json, simulates predicted opens/clicks per variant
+// using a deterministic copy-quality heuristic, and surfaces a winner call.
+//
+// STAGED ONLY. No endpoint here triggers a real send. The "conclude" route
+// just freezes the simulated/staged numbers + names a winner in the JSON.
+// If real send numbers ever flow in (CTCT reporting webhook, GA4 event tap),
+// the same record can be updated via PATCH /tests/:id/results without
+// changing the front-end contract.
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+
+const brand = require('../../lib/brand.js');
+
+const DATA_FILE = path.join(__dirname, '..', '..', 'data', 'ab-tests.json');
+
+// ── persistence ─────────────────────────────────────────────────────────────
+function readAll() {
+ try { return JSON.parse(fs.readFileSync(DATA_FILE, 'utf8')); }
+ catch { return []; }
+}
+function writeAll(rows) {
+ fs.mkdirSync(path.dirname(DATA_FILE), { recursive: true });
+ fs.writeFileSync(DATA_FILE, JSON.stringify(rows, null, 2));
+}
+function newId() {
+ return 'abt_' + crypto.randomBytes(4).toString('hex');
+}
+
+// ── deterministic PRNG (mulberry32) so the same test re-simulates to the
+// same numbers unless the user explicitly re-rolls with a new seed.
+function mulberry32(seed) {
+ let s = seed >>> 0;
+ return function () {
+ s |= 0; s = (s + 0x6d2b79f5) | 0;
+ let t = Math.imul(s ^ (s >>> 15), 1 | s);
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+}
+function hashSeed(s) {
+ let h = 2166136261 >>> 0;
+ for (let i = 0; i < s.length; i++) {
+ h ^= s.charCodeAt(i);
+ h = Math.imul(h, 16777619) >>> 0;
+ }
+ return h >>> 0;
+}
+
+// ── copy-quality heuristic ──────────────────────────────────────────────────
+// Translates a variant's subject + body/angle into a baseline open-rate and
+// click-rate multiplier. Designed to be transparent (each factor is named
+// and exposed in the API response) so the user can see *why* a variant won.
+const BRAND_KEYWORDS = brand.productLines.concat([
+ 'grasscloth', 'silk', 'cork', 'mural', 'metallic', 'memo', 'lookbook',
+ 'atelier', 'archive', 'limited', 'exclusive', 'designer', 'curated',
+]);
+const POWER_VERBS = ['discover', 'introducing', 'unveil', 'inside', 'meet', 'explore', 'request'];
+const GIMMICKY = ['!!!', 'free', '$$$', 'act now', 'last chance', 'urgent', 'click here'];
+
+function scoreCopy(variant) {
+ const subj = String(variant.subject || '').trim();
+ const body = String(variant.body || '').trim();
+ const angle = String(variant.angle || '').trim().toLowerCase();
+ const haystack = (subj + ' ' + body + ' ' + angle).toLowerCase();
+
+ const factors = [];
+ let openMult = 1.0;
+ let clickMult = 1.0;
+
+ // Subject length sweet spot 28-55 chars (editorial-feeling)
+ const len = subj.length;
+ if (len === 0) { openMult *= 0.4; factors.push({ name: 'empty-subject', open: -60, click: 0 }); }
+ else if (len < 18) { openMult *= 0.88; factors.push({ name: 'subject-too-short', open: -12, click: 0 }); }
+ else if (len > 70) { openMult *= 0.85; factors.push({ name: 'subject-too-long', open: -15, click: -3 }); clickMult *= 0.97; }
+ else if (len >= 28 && len <= 55) { openMult *= 1.10; factors.push({ name: 'subject-length-ideal', open: +10, click: 0 }); }
+
+ // Personalization-ish token (Designer, Studio, "your")
+ if (/\b(designer|studio|atelier|your)\b/i.test(subj)) {
+ openMult *= 1.06; factors.push({ name: 'audience-cue', open: +6, click: +1 }); clickMult *= 1.01;
+ }
+
+ // Question-mark headlines = curiosity bump
+ if (/\?$/.test(subj)) { openMult *= 1.04; factors.push({ name: 'curiosity-question', open: +4, click: 0 }); }
+
+ // Power verb in opening
+ if (POWER_VERBS.some(v => new RegExp('^\\s*' + v + '\\b', 'i').test(subj))) {
+ openMult *= 1.05; factors.push({ name: 'power-verb-open', open: +5, click: +1 }); clickMult *= 1.01;
+ }
+
+ // ALL CAPS penalty (>40% of subject in caps)
+ const caps = (subj.match(/[A-Z]/g) || []).length;
+ if (subj.length >= 10 && caps / subj.length > 0.4) {
+ openMult *= 0.82; factors.push({ name: 'shouting-caps', open: -18, click: -5 }); clickMult *= 0.95;
+ }
+
+ // Gimmicky / spammy tokens (DW voice = "never gimmicky")
+ let gimCount = 0;
+ for (const g of GIMMICKY) if (haystack.includes(g)) gimCount++;
+ if (gimCount > 0) {
+ const pen = Math.min(0.9, 0.94 ** gimCount);
+ openMult *= pen; clickMult *= pen;
+ factors.push({ name: 'gimmicky-words', open: -((1 - pen) * 100).toFixed(0) * 1, click: -((1 - pen) * 100).toFixed(0) * 1, count: gimCount });
+ }
+
+ // Brand keyword in subject (grasscloth, silk, mural, etc.) — concrete product
+ // tokens out-perform vague "wallpaper" subjects in DW historical data.
+ let brandHit = false;
+ for (const kw of BRAND_KEYWORDS) {
+ if (subj.toLowerCase().includes(kw)) { brandHit = true; break; }
+ }
+ if (brandHit) { openMult *= 1.07; clickMult *= 1.04; factors.push({ name: 'concrete-product-token', open: +7, click: +4 }); }
+
+ // Body length 80-700 chars = readable preview-text length
+ if (body.length === 0) { clickMult *= 0.7; factors.push({ name: 'empty-body', open: 0, click: -30 }); }
+ else if (body.length < 60) { clickMult *= 0.9; factors.push({ name: 'thin-body', open: 0, click: -10 }); }
+ else if (body.length >= 80 && body.length <= 700) { clickMult *= 1.05; factors.push({ name: 'body-length-ideal', open: 0, click: +5 }); }
+
+ // Body has a CTA-shaped phrase
+ const ctaHit = brand.cta.some(c => haystack.includes(c.toLowerCase()))
+ || /\b(request|explore|book|shop|view|see)\b/i.test(body);
+ if (ctaHit) { clickMult *= 1.12; factors.push({ name: 'clear-cta', open: 0, click: +12 }); }
+
+ // Angle tag bumps (editorial > promo > generic)
+ if (angle === 'editorial' || angle === 'story') { openMult *= 1.05; clickMult *= 1.03; factors.push({ name: 'editorial-angle', open: +5, click: +3 }); }
+ else if (angle === 'promo' || angle === 'sale') { openMult *= 0.95; clickMult *= 1.06; factors.push({ name: 'promo-angle', open: -5, click: +6 }); }
+ else if (angle === 'new-arrival' || angle === 'launch') { openMult *= 1.04; clickMult *= 1.04; factors.push({ name: 'launch-angle', open: +4, click: +4 }); }
+
+ return { openMult, clickMult, factors };
+}
+
+// ── simulator ───────────────────────────────────────────────────────────────
+// Baseline DW historical-ish rates: 22% open, 3.2% click on a trade-leaning
+// list. We split the audience between A / B (after holdout), apply each
+// variant's quality multipliers, then add a small per-variant noise term
+// derived from the test seed so re-rolls produce slightly different numbers.
+const BASE_OPEN = 0.22;
+const BASE_CLICK = 0.032;
+
+function simulate(test) {
+ const seed = hashSeed(test.id + ':' + (test.seedSalt || 'v1'));
+ const rnd = mulberry32(seed);
+ const audienceSize = Math.max(0, Number(test.audienceSize) || 0);
+ const holdoutPct = Math.max(0, Math.min(50, Number(test.holdoutPct) || 0));
+
+ const holdoutSize = Math.round(audienceSize * (holdoutPct / 100));
+ const sendable = Math.max(0, audienceSize - holdoutSize);
+ // Equal A/B split of the sendable pool. Round A down; B gets the remainder.
+ const sentA = Math.floor(sendable / 2);
+ const sentB = sendable - sentA;
+
+ const scoreA = scoreCopy(test.variants[0]);
+ const scoreB = scoreCopy(test.variants[1]);
+
+ const noise = () => 0.94 + rnd() * 0.12; // ~±6%
+
+ const variantResult = (sent, sc) => {
+ const openRate = Math.max(0.01, Math.min(0.85, BASE_OPEN * sc.openMult * noise()));
+ const clickRate = Math.max(0.001, Math.min(0.35, BASE_CLICK * sc.clickMult * sc.openMult * noise()));
+ const opens = Math.round(sent * openRate);
+ const clicks = Math.round(sent * clickRate);
+ return {
+ sent,
+ opens, clicks,
+ openRate: +openRate.toFixed(4),
+ clickRate: +clickRate.toFixed(4),
+ openMult: +sc.openMult.toFixed(3),
+ clickMult: +sc.clickMult.toFixed(3),
+ factors: sc.factors,
+ };
+ };
+
+ // Holdout = no email at all — surface as zeros so the UI shows the bucket
+ // size for "what we held back" comparison.
+ const holdout = {
+ sent: 0,
+ held: holdoutSize,
+ opens: 0,
+ clicks: 0,
+ openRate: 0,
+ clickRate: 0,
+ };
+
+ const a = variantResult(sentA, scoreA);
+ const b = variantResult(sentB, scoreB);
+
+ return { variant_a: a, variant_b: b, holdout, simulatedAt: new Date().toISOString() };
+}
+
+// Two-proportion z-test (open-rate) for variant comparison. Returns the
+// absolute lift, relative lift, and a coarse confidence bucket. We don't
+// want to pretend this is a real significance result with mock data, so
+// we expose the z-score honestly and tier it as low/med/high.
+function compareWinner(results) {
+ const a = results.variant_a, b = results.variant_b;
+ if (!a.sent || !b.sent) return { winner: null, confidence: 'n/a', lift: 0, z: 0, metric: 'open-rate' };
+
+ // Composite score: blended open × click, since either alone can mislead
+ // (a clickbait subject might win opens but lose clicks).
+ const compA = a.openRate * 0.6 + a.clickRate * 0.4;
+ const compB = b.openRate * 0.6 + b.clickRate * 0.4;
+ const winnerKey = compA === compB ? null : (compA > compB ? 'A' : 'B');
+
+ // Stats on open-rate (more samples than clicks → tighter CI)
+ const p1 = a.openRate, p2 = b.openRate;
+ const n1 = a.sent, n2 = b.sent;
+ const p = (a.opens + b.opens) / (n1 + n2);
+ const se = Math.sqrt(p * (1 - p) * (1 / n1 + 1 / n2));
+ const z = se > 0 ? Math.abs((p1 - p2) / se) : 0;
+
+ // Map z to confidence: 1.28→80%, 1.65→90%, 1.96→95%, 2.58→99%
+ let confidence = 'low';
+ if (z >= 1.96) confidence = 'high';
+ else if (z >= 1.28) confidence = 'med';
+
+ const liftAbs = Math.abs(p1 - p2);
+ const liftRel = winnerKey === 'A'
+ ? (p2 > 0 ? (p1 - p2) / p2 : 0)
+ : (p1 > 0 ? (p2 - p1) / p1 : 0);
+
+ return {
+ winner: winnerKey,
+ confidence,
+ metric: 'open-rate',
+ liftAbsolute: +liftAbs.toFixed(4),
+ liftRelative: +liftRel.toFixed(4),
+ z: +z.toFixed(3),
+ compositeA: +compA.toFixed(4),
+ compositeB: +compB.toFixed(4),
+ };
+}
+
+// ── validation ──────────────────────────────────────────────────────────────
+function validateInput(body) {
+ const errs = [];
+ if (!body || typeof body !== 'object') return ['body must be an object'];
+ if (!String(body.name || '').trim()) errs.push('name is required');
+ if (!Array.isArray(body.variants) || body.variants.length !== 2) errs.push('variants must be an array of exactly 2');
+ if (Array.isArray(body.variants)) {
+ body.variants.forEach((v, i) => {
+ const label = i === 0 ? 'A' : 'B';
+ if (!v || typeof v !== 'object') errs.push(`variant ${label} must be an object`);
+ else {
+ if (!String(v.subject || '').trim()) errs.push(`variant ${label} subject is required`);
+ if (!String(v.body || '').trim()) errs.push(`variant ${label} body is required`);
+ }
+ });
+ }
+ const aSize = Number(body.audienceSize);
+ if (!Number.isFinite(aSize) || aSize < 100) errs.push('audienceSize must be ≥ 100');
+ const hp = Number(body.holdoutPct);
+ if (!Number.isFinite(hp) || hp < 0 || hp > 50) errs.push('holdoutPct must be 0–50');
+ return errs;
+}
+
+function normalize(body) {
+ return {
+ name: String(body.name).trim(),
+ description: String(body.description || '').trim(),
+ audienceSegmentId: body.audienceSegmentId ? String(body.audienceSegmentId) : null,
+ audienceSize: Math.round(Number(body.audienceSize)),
+ holdoutPct: +Number(body.holdoutPct).toFixed(2),
+ variants: [0, 1].map(i => ({
+ key: i === 0 ? 'A' : 'B',
+ subject: String(body.variants[i].subject).trim(),
+ body: String(body.variants[i].body).trim(),
+ angle: String(body.variants[i].angle || '').trim(),
+ })),
+ };
+}
+
+// ── module ──────────────────────────────────────────────────────────────────
+module.exports = {
+ id: 'ab-tests',
+ title: 'A/B Tests',
+ icon: '🔬',
+
+ _scoreCopy: scoreCopy,
+ _simulate: simulate,
+ _compareWinner: compareWinner,
+
+ mount(router) {
+ const guard = (handler) => async (req, res) => {
+ try { await handler(req, res); }
+ catch (e) { res.status(500).json({ error: e.message }); }
+ };
+
+ // Constants — reference for the panel (angle options, baselines, gate copy)
+ router.get('/meta', guard(async (_req, res) => {
+ res.json({
+ mock: true,
+ staged: true,
+ baseline: { openRate: BASE_OPEN, clickRate: BASE_CLICK, note: 'DW trade-leaning historical baseline' },
+ angles: ['editorial', 'story', 'new-arrival', 'launch', 'promo', 'sale', 'memo-cta', 'lookbook'],
+ holdoutRange: [0, 50],
+ gateMessage: 'A/B Tests are STAGED — this module plans and tracks; it never triggers a live send. Approved variants must still ship through Constant Contact with Steve gating the send.',
+ });
+ }));
+
+ // List all tests
+ router.get('/tests', guard(async (_req, res) => {
+ const rows = readAll();
+ res.json({
+ mock: true, staged: true,
+ count: rows.length,
+ tests: rows.map(t => ({
+ id: t.id, name: t.name, status: t.status,
+ createdAt: t.createdAt, concludedAt: t.concludedAt || null,
+ audienceSize: t.audienceSize, holdoutPct: t.holdoutPct,
+ winner: t.winner ? t.winner.winner : null,
+ subjectA: t.variants?.[0]?.subject,
+ subjectB: t.variants?.[1]?.subject,
+ })),
+ });
+ }));
+
+ // Single test detail
+ router.get('/tests/:id', guard(async (req, res) => {
+ const row = readAll().find(t => t.id === req.params.id);
+ if (!row) return res.status(404).json({ error: 'test not found' });
+ res.json({ mock: true, staged: true, test: row });
+ }));
+
+ // Create a new A/B test (staged — no send)
+ router.post('/tests', guard(async (req, res) => {
+ const errs = validateInput(req.body);
+ if (errs.length) return res.status(400).json({ error: 'validation failed', issues: errs });
+
+ const norm = normalize(req.body);
+ const id = newId();
+ const test = {
+ id,
+ ...norm,
+ status: 'staged', // staged → simulated → concluded
+ createdAt: new Date().toISOString(),
+ seedSalt: 'v1',
+ };
+ test.results = simulate(test);
+ test.winner = compareWinner(test.results);
+
+ const rows = readAll();
+ rows.unshift(test);
+ writeAll(rows);
+ res.json({ mock: true, staged: true, test });
+ }));
+
+ // Re-simulate (re-roll noise term so the user can see variance)
+ router.post('/tests/:id/simulate', guard(async (req, res) => {
+ const rows = readAll();
+ const idx = rows.findIndex(t => t.id === req.params.id);
+ if (idx < 0) return res.status(404).json({ error: 'test not found' });
+ const test = rows[idx];
+ if (test.status === 'concluded') {
+ return res.status(409).json({ error: 'test is concluded; results are frozen' });
+ }
+ // bump seed so simulation differs
+ test.seedSalt = 'v' + (Date.now() % 100000);
+ test.results = simulate(test);
+ test.winner = compareWinner(test.results);
+ rows[idx] = test;
+ writeAll(rows);
+ res.json({ mock: true, staged: true, test });
+ }));
+
+ // Conclude (freeze current numbers + winner). Still no send.
+ router.post('/tests/:id/conclude', guard(async (req, res) => {
+ const rows = readAll();
+ const idx = rows.findIndex(t => t.id === req.params.id);
+ if (idx < 0) return res.status(404).json({ error: 'test not found' });
+ const test = rows[idx];
+ if (test.status === 'concluded') {
+ return res.json({ mock: true, staged: true, test, note: 'already concluded' });
+ }
+ if (!test.results) test.results = simulate(test);
+ if (!test.winner) test.winner = compareWinner(test.results);
+ test.status = 'concluded';
+ test.concludedAt = new Date().toISOString();
+ rows[idx] = test;
+ writeAll(rows);
+ res.json({ mock: true, staged: true, test });
+ }));
+
+ // Delete a test
+ router.delete('/tests/:id', guard(async (req, res) => {
+ const rows = readAll();
+ const idx = rows.findIndex(t => t.id === req.params.id);
+ if (idx < 0) return res.status(404).json({ error: 'test not found' });
+ const [removed] = rows.splice(idx, 1);
+ writeAll(rows);
+ res.json({ mock: true, staged: true, deleted: removed.id });
+ }));
+ },
+};
diff --git a/modules/browse-abandon/index.js b/modules/browse-abandon/index.js
new file mode 100644
index 0000000..5f02f75
--- /dev/null
+++ b/modules/browse-abandon/index.js
@@ -0,0 +1,531 @@
+// browse-abandon — Browse Abandonment drip module for the Marketing Command Center.
+//
+// A designer (or trade contact) loads a product page on the DW store but never
+// asks for a memo sample. After a quiet window we stage a small drip:
+// Day 1 — storytelling email (the pattern, the maker, the collection lineage)
+// Day 14 — room-setting email (the same product photographed in situ)
+// Day 30 — suppress (move to lapsed cohort; stop the drip)
+//
+// PLANNING ONLY. Nothing here ever fires a real email. The /events endpoint
+// shows what WOULD fire today + over the next 7 days; the /fire/:id endpoint is
+// a dry-run renderer that returns the proposed subject + body for inspection.
+//
+// State lives in two files:
+// data/browse-abandon-config.json — drip steps + the "no-sample threshold"
+// data/browse-abandon-events.json — mock browse events (no real shopper data)
+//
+// External integrations (Shopify event stream, Constant Contact send) are not
+// wired here — Steve gates those separately. The mock event seed gives the UI
+// realistic-shaped data so the dashboard works out of the box.
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+
+const brand = require('../../lib/brand.js');
+
+const CONFIG_FILE = path.join(__dirname, '..', '..', 'data', 'browse-abandon-config.json');
+const EVENTS_FILE = path.join(__dirname, '..', '..', 'data', 'browse-abandon-events.json');
+
+// ── default drip config ─────────────────────────────────────────────────────
+// Three canonical steps. Steve can edit / disable / reorder via PUT /config.
+// noSampleThresholdDays = the silence window before the drip evaluates the
+// contact (so a designer who requested a sample within 24h never enters).
+function defaultConfig() {
+ return {
+ enabled: true,
+ noSampleThresholdDays: 1,
+ dailySendCap: 250,
+ steps: [
+ {
+ id: 'step_day1_storytelling',
+ dayOffset: 1,
+ kind: 'email',
+ enabled: true,
+ title: 'Day 1 — Storytelling',
+ subject: 'The story behind {pattern_name}',
+ bodyTemplate:
+ 'Hello {first_name},\n\n' +
+ 'You spent a moment with {pattern_name} from our {collection} edit — and we wanted to share where it comes from. ' +
+ '{vendor} has been hand-finishing this {product_type} for decades, and every roll carries the irregularities that ' +
+ 'separate a true wallcovering from a printed substitute.\n\n' +
+ 'If it stayed with you, the next move is a memo sample. Hold it against your scheme; the gold, the cream, the ink ' +
+ 'all read differently in your light.\n\n' +
+ 'Request a memo: {memo_url}\n\n' +
+ 'Quietly,\nThe Designer Wallcoverings atelier',
+ cta: 'Request memo samples',
+ suppressIfSampleRequested: true,
+ },
+ {
+ id: 'step_day14_room',
+ dayOffset: 14,
+ kind: 'email',
+ enabled: true,
+ title: 'Day 14 — Room setting',
+ subject: '{pattern_name} — see it in a room',
+ bodyTemplate:
+ 'Hello {first_name},\n\n' +
+ '{pattern_name} reads one way as a flat scan and another way on a wall. We staged a room-setting render so ' +
+ 'you can see the scale, the drop, and the way the {color_name} sits against trim.\n\n' +
+ 'View the room setting: {product_url}\n\n' +
+ 'A memo box ships free to your trade address — the swatches let you confirm the field colour under your client\'s light.\n\n' +
+ 'Request a memo: {memo_url}\n\n' +
+ 'With care,\nThe Designer Wallcoverings atelier',
+ cta: 'Explore the collection',
+ suppressIfSampleRequested: true,
+ },
+ {
+ id: 'step_day30_suppress',
+ dayOffset: 30,
+ kind: 'suppress',
+ enabled: true,
+ title: 'Day 30 — Suppress',
+ suppressList: 'browse-abandon-lapsed',
+ note:
+ 'Move the contact to the "browse-abandon-lapsed" cohort, stop the drip, and roll them into the quarterly ' +
+ 'editorial cadence instead. No email fires on Day 30.',
+ },
+ ],
+ updatedAt: new Date().toISOString(),
+ };
+}
+
+// ── default mock events ─────────────────────────────────────────────────────
+// A spread of realistic-shaped browse events: trade designers + retail, across
+// the canonical DW product lines (grasscloth, silk, cork, flocked, glass bead),
+// each browsed N days ago so the dashboard shows entries at every drip stage.
+const MOCK_PEOPLE = [
+ { email: 'audrey.bell@studiobell.com', firstName: 'Audrey', segment: 'Trade & Designers — engaged' },
+ { email: 'marcus@hauserarch.com', firstName: 'Marcus', segment: 'Trade & Designers — engaged' },
+ { email: 'priya@vermillionatelier.com', firstName: 'Priya', segment: 'High-intent — clicked memo CTA' },
+ { email: 'eli.kane@kanedesign.studio', firstName: 'Eli', segment: 'Trade & Designers — engaged' },
+ { email: 'naomi.s@hospitalitypartnersgroup.com', firstName: 'Naomi', segment: 'Hospitality / Commercial spec' },
+ { email: 'rhianna@laurelandfern.co', firstName: 'Rhianna', segment: 'Retail — lapsed 90d' },
+ { email: 'cyrus@morrowinteriors.com', firstName: 'Cyrus', segment: 'Trade & Designers — engaged' },
+ { email: 'wren.day@daydesignhouse.com', firstName: 'Wren', segment: 'High-intent — clicked memo CTA' },
+ { email: 'imogen@borealis-design.co.uk', firstName: 'Imogen', segment: 'Trade & Designers — engaged' },
+ { email: 'tobias@northernlightstudio.com', firstName: 'Tobias', segment: 'Hospitality / Commercial spec' },
+ { email: 'sasha@sashalindersinteriors.com', firstName: 'Sasha', segment: 'Trade & Designers — engaged' },
+ { email: 'leo.b@leonardobruzzese.it', firstName: 'Leo', segment: 'High-intent — clicked memo CTA' },
+];
+
+const MOCK_PRODUCTS = [
+ { sku: 'DWBR-7821', pattern: 'Cliveden Grasscloth', color: 'Ink Slate', productType: 'Wallcovering', vendor: 'Brewster', collection: 'Brewster Naturals', line: 'grasscloth' },
+ { sku: 'DWKK-4490', pattern: 'Hampshire Silk', color: 'Antique Cream', productType: 'Wallcovering', vendor: 'Kravet', collection: 'Heritage Silk', line: 'silk' },
+ { sku: 'DWAR-1208', pattern: 'Estoril Cork', color: 'Mineral Sage', productType: 'Wallcovering', vendor: 'Arte', collection: 'Mediterranean', line: 'cork' },
+ { sku: 'DWMR-3361', pattern: 'Aurora Flock', color: 'Carmine', productType: 'Wallcovering', vendor: 'Maya Romanoff',collection: 'Aurora', line: 'flocked' },
+ { sku: 'DWPR-9904', pattern: 'Coral Gables Vinyl', color: 'Surf', productType: 'Wallcovering', vendor: 'Phillipe Romano', collection: 'Hollywood Vinyls Vol. 1', line: 'vinyl' },
+ { sku: 'DWGB-1145', pattern: 'Beaded Constellation', color: 'Champagne', productType: 'Wallcovering', vendor: 'Glass Bead', collection: 'Constellation', line: 'glass bead' },
+ { sku: 'DWPI-2003', pattern: 'Empire Linen', color: 'Bone', productType: 'Wallcovering', vendor: 'Phillip Jeffries', collection: 'Empire', line: 'linen' },
+ { sku: 'DWSC-6677', pattern: 'Schiaparelli Mural', color: 'Garden', productType: 'Mural', vendor: 'Schumacher', collection: 'Couture Murals', line: 'mural' },
+];
+
+function seedMockEvents(now = Date.now()) {
+ // Spread browse events across day-windows so every drip stage is visible.
+ // browsedDaysAgo controls which drip step the event has reached.
+ const pattern = [
+ { browsedDaysAgo: 0, sampleRequestedDaysAgo: null },
+ { browsedDaysAgo: 1, sampleRequestedDaysAgo: null },
+ { browsedDaysAgo: 1, sampleRequestedDaysAgo: null },
+ { browsedDaysAgo: 2, sampleRequestedDaysAgo: null },
+ { browsedDaysAgo: 5, sampleRequestedDaysAgo: 4 }, // requested → suppressed
+ { browsedDaysAgo: 7, sampleRequestedDaysAgo: null },
+ { browsedDaysAgo: 14, sampleRequestedDaysAgo: null },
+ { browsedDaysAgo: 15, sampleRequestedDaysAgo: null },
+ { browsedDaysAgo: 21, sampleRequestedDaysAgo: null },
+ { browsedDaysAgo: 28, sampleRequestedDaysAgo: null },
+ { browsedDaysAgo: 30, sampleRequestedDaysAgo: null },
+ { browsedDaysAgo: 33, sampleRequestedDaysAgo: null },
+ ];
+ const day = 24 * 60 * 60 * 1000;
+ return pattern.map((p, i) => {
+ const person = MOCK_PEOPLE[i % MOCK_PEOPLE.length];
+ const product = MOCK_PRODUCTS[i % MOCK_PRODUCTS.length];
+ const browsedAt = new Date(now - p.browsedDaysAgo * day).toISOString();
+ const sampleRequestedAt = p.sampleRequestedDaysAgo != null
+ ? new Date(now - p.sampleRequestedDaysAgo * day).toISOString()
+ : null;
+ return {
+ id: 'evt_' + crypto.randomBytes(4).toString('hex'),
+ contactEmail: person.email,
+ contactFirstName: person.firstName,
+ segment: person.segment,
+ productSku: product.sku,
+ productPattern: product.pattern,
+ productColor: product.color,
+ productType: product.productType,
+ vendor: product.vendor,
+ collection: product.collection,
+ productLine: product.line,
+ productUrl: `https://designerwallcoverings.com/products/${product.sku.toLowerCase()}`,
+ memoUrl: `https://designerwallcoverings.com/memos/request?sku=${product.sku}`,
+ browsedAt,
+ sampleRequestedAt,
+ mock: true,
+ };
+ });
+}
+
+// ── persistence ─────────────────────────────────────────────────────────────
+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 readConfig() {
+ const v = readJson(CONFIG_FILE, null);
+ if (v && typeof v === 'object') return v;
+ const def = defaultConfig();
+ writeJson(CONFIG_FILE, def);
+ return def;
+}
+function writeConfig(cfg) { writeJson(CONFIG_FILE, cfg); }
+
+function readEvents() {
+ const v = readJson(EVENTS_FILE, null);
+ if (Array.isArray(v) && v.length) return v;
+ const seeded = seedMockEvents();
+ writeJson(EVENTS_FILE, seeded);
+ return seeded;
+}
+function writeEvents(rows) { writeJson(EVENTS_FILE, rows); }
+
+// ── validation ──────────────────────────────────────────────────────────────
+const STEP_KINDS = ['email', 'suppress'];
+
+function validateConfig(body) {
+ const errs = [];
+ if (!body || typeof body !== 'object') return ['body must be an object'];
+ if (body.enabled !== undefined && typeof body.enabled !== 'boolean') errs.push('enabled must be boolean');
+ if (body.noSampleThresholdDays !== undefined) {
+ const n = Number(body.noSampleThresholdDays);
+ if (!Number.isFinite(n) || n < 0 || n > 90) errs.push('noSampleThresholdDays must be a number between 0 and 90');
+ }
+ if (body.dailySendCap !== undefined) {
+ const n = Number(body.dailySendCap);
+ if (!Number.isFinite(n) || n < 0 || n > 10000) errs.push('dailySendCap must be a number between 0 and 10000');
+ }
+ if (body.steps !== undefined) {
+ if (!Array.isArray(body.steps)) errs.push('steps must be an array');
+ else {
+ if (body.steps.length === 0) errs.push('at least one step is required');
+ const seenOffsets = new Set();
+ body.steps.forEach((s, i) => {
+ if (!s || typeof s !== 'object') { errs.push(`step #${i + 1} must be an object`); return; }
+ if (!STEP_KINDS.includes(s.kind)) errs.push(`step #${i + 1} has unknown kind "${s.kind}" (allowed: ${STEP_KINDS.join(', ')})`);
+ const off = Number(s.dayOffset);
+ if (!Number.isFinite(off) || off < 0 || off > 365) errs.push(`step #${i + 1} dayOffset must be 0–365`);
+ else if (seenOffsets.has(off)) errs.push(`step #${i + 1} duplicate dayOffset ${off} — each step needs a unique day`);
+ else seenOffsets.add(off);
+ if (s.kind === 'email') {
+ if (!String(s.subject || '').trim()) errs.push(`step #${i + 1} (email) subject is required`);
+ if (!String(s.bodyTemplate || '').trim()) errs.push(`step #${i + 1} (email) bodyTemplate is required`);
+ }
+ if (s.kind === 'suppress') {
+ if (!String(s.suppressList || '').trim()) errs.push(`step #${i + 1} (suppress) suppressList is required`);
+ }
+ });
+ }
+ }
+ return errs;
+}
+
+function normalizeStep(s) {
+ const out = {
+ id: s.id && /^step_/.test(s.id) ? s.id : 'step_' + crypto.randomBytes(3).toString('hex'),
+ dayOffset: Math.max(0, Math.min(365, Math.round(Number(s.dayOffset) || 0))),
+ kind: STEP_KINDS.includes(s.kind) ? s.kind : 'email',
+ enabled: s.enabled === false ? false : true,
+ title: String(s.title || '').trim().slice(0, 80) || `Day ${s.dayOffset || 0}`,
+ };
+ if (out.kind === 'email') {
+ out.subject = String(s.subject || '').trim().slice(0, 200);
+ out.bodyTemplate = String(s.bodyTemplate || '').trim().slice(0, 4000);
+ out.cta = String(s.cta || '').trim().slice(0, 60);
+ out.suppressIfSampleRequested = s.suppressIfSampleRequested !== false;
+ }
+ if (out.kind === 'suppress') {
+ out.suppressList = String(s.suppressList || '').trim().slice(0, 80);
+ out.note = String(s.note || '').trim().slice(0, 400);
+ }
+ return out;
+}
+
+function normalizeConfig(body, existing) {
+ const cur = existing || readConfig();
+ const merged = {
+ enabled: body.enabled !== undefined ? !!body.enabled : !!cur.enabled,
+ noSampleThresholdDays: body.noSampleThresholdDays !== undefined
+ ? Math.max(0, Math.min(90, Math.round(Number(body.noSampleThresholdDays))))
+ : Number(cur.noSampleThresholdDays) || 1,
+ dailySendCap: body.dailySendCap !== undefined
+ ? Math.max(0, Math.min(10000, Math.round(Number(body.dailySendCap))))
+ : Number(cur.dailySendCap) || 250,
+ steps: Array.isArray(body.steps) ? body.steps.map(normalizeStep) : (cur.steps || []),
+ updatedAt: new Date().toISOString(),
+ };
+ // Keep steps sorted by dayOffset so the UI + scheduler walk them in order.
+ merged.steps.sort((a, b) => a.dayOffset - b.dayOffset);
+ return merged;
+}
+
+// ── drip evaluation ─────────────────────────────────────────────────────────
+// Given an event and the current config, decide what stage it sits in and
+// which step (if any) WOULD fire today.
+function dayDiffDays(fromIso, now) {
+ if (!fromIso) return null;
+ const t = new Date(fromIso).getTime();
+ if (!Number.isFinite(t)) return null;
+ return Math.floor((now - t) / (24 * 60 * 60 * 1000));
+}
+
+function evaluateEvent(evt, cfg, now = Date.now()) {
+ const browsedDaysAgo = dayDiffDays(evt.browsedAt, now);
+ const sampleRequested = !!evt.sampleRequestedAt;
+ const sampleDaysAgo = dayDiffDays(evt.sampleRequestedAt, now);
+
+ // Sample requested → out of the drip immediately. The Day 30 suppress also
+ // takes any contact past day 30 out, regardless of intermediate progress.
+ let stage = 'pending';
+ let reason = '';
+ if (sampleRequested) {
+ stage = 'sample-requested';
+ reason = `Sample requested ${sampleDaysAgo} day${sampleDaysAgo === 1 ? '' : 's'} ago — removed from drip.`;
+ } else if (browsedDaysAgo == null || browsedDaysAgo < cfg.noSampleThresholdDays) {
+ stage = 'cooling';
+ reason = `Browsed ${browsedDaysAgo == null ? '—' : browsedDaysAgo + 'd'} ago · still inside the ${cfg.noSampleThresholdDays}d no-sample window.`;
+ }
+
+ // Which step is *due today*? A step is "due" when browsedDaysAgo === dayOffset.
+ // "would fire today" = the matching step is enabled and the contact is still in the drip.
+ const orderedSteps = (cfg.steps || []).slice().sort((a, b) => a.dayOffset - b.dayOffset);
+ let dueStep = null;
+ let lastDueIdx = -1;
+ for (let i = 0; i < orderedSteps.length; i++) {
+ const s = orderedSteps[i];
+ if (browsedDaysAgo != null && browsedDaysAgo >= s.dayOffset) lastDueIdx = i;
+ if (browsedDaysAgo === s.dayOffset) dueStep = s;
+ }
+
+ // Determine "current step" for stage labelling: the latest step the contact
+ // has reached (used so the dashboard groups them as "at Day 1" / "at Day 14").
+ let currentStep = lastDueIdx >= 0 ? orderedSteps[lastDueIdx] : null;
+ if (currentStep && currentStep.kind === 'suppress') stage = 'suppressed';
+ else if (stage === 'pending' && currentStep) stage = 'in-drip';
+
+ // What WOULD fire today?
+ let wouldFire = null;
+ if (dueStep && dueStep.enabled !== false && stage !== 'sample-requested' && stage !== 'cooling') {
+ wouldFire = dueStep;
+ }
+
+ return {
+ browsedDaysAgo,
+ sampleRequestedDaysAgo: sampleDaysAgo,
+ stage,
+ reason,
+ currentStep: currentStep ? { id: currentStep.id, title: currentStep.title, dayOffset: currentStep.dayOffset, kind: currentStep.kind } : null,
+ wouldFire: wouldFire ? { id: wouldFire.id, title: wouldFire.title, dayOffset: wouldFire.dayOffset, kind: wouldFire.kind } : null,
+ };
+}
+
+// ── template rendering ──────────────────────────────────────────────────────
+// Replace {token} placeholders with event fields. Used only for dry-run preview.
+function renderTemplate(text, evt) {
+ const subs = {
+ first_name: evt.contactFirstName || 'there',
+ pattern_name: evt.productPattern || 'this pattern',
+ color_name: evt.productColor || 'the colour',
+ product_type: (evt.productType || 'wallcovering').toLowerCase(),
+ vendor: evt.vendor || 'the mill',
+ collection: evt.collection || 'the collection',
+ product_url: evt.productUrl || 'https://designerwallcoverings.com',
+ memo_url: evt.memoUrl || 'https://designerwallcoverings.com/memos',
+ sku: evt.productSku || '',
+ };
+ return String(text || '').replace(/\{(\w+)\}/g, (m, k) => (subs[k] != null ? subs[k] : m));
+}
+
+function renderEmailPreview(step, evt) {
+ return {
+ to: evt.contactEmail,
+ subject: renderTemplate(step.subject, evt),
+ body: renderTemplate(step.bodyTemplate, evt),
+ cta: step.cta || '',
+ canSpamFooter:
+ `${brand.name} · 8 Greenwich Office Park, Greenwich, CT 06831 · ` +
+ `Unsubscribe: https://designerwallcoverings.com/unsubscribe?c=${encodeURIComponent(evt.contactEmail || '')}`,
+ compliance: brand.compliance,
+ };
+}
+
+// ── schedule projection ─────────────────────────────────────────────────────
+// For each upcoming N-day window, count how many events WOULD hit each step.
+function projectSchedule(events, cfg, days = 7, now = Date.now()) {
+ const oneDay = 24 * 60 * 60 * 1000;
+ const out = [];
+ for (let d = 0; d <= days; d++) {
+ const asOf = now + d * oneDay;
+ const dayLabel = d === 0 ? 'Today' : d === 1 ? 'Tomorrow' : new Date(asOf).toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
+ const buckets = {};
+ for (const evt of events) {
+ // Skip events that have requested a sample by now.
+ if (evt.sampleRequestedAt && new Date(evt.sampleRequestedAt).getTime() <= asOf) continue;
+ const ev = evaluateEvent(evt, cfg, asOf);
+ if (!ev.wouldFire) continue;
+ const key = ev.wouldFire.id;
+ if (!buckets[key]) buckets[key] = { stepId: key, title: ev.wouldFire.title, kind: ev.wouldFire.kind, dayOffset: ev.wouldFire.dayOffset, count: 0 };
+ buckets[key].count += 1;
+ }
+ const buckArr = Object.values(buckets).sort((a, b) => a.dayOffset - b.dayOffset);
+ out.push({
+ dayIndex: d,
+ label: dayLabel,
+ iso: new Date(asOf).toISOString().slice(0, 10),
+ totalWouldFire: buckArr.reduce((s, b) => s + b.count, 0),
+ byStep: buckArr,
+ hitCap: buckArr.reduce((s, b) => s + b.count, 0) > (cfg.dailySendCap || 0),
+ });
+ }
+ return out;
+}
+
+// ── module ──────────────────────────────────────────────────────────────────
+module.exports = {
+ id: 'browse-abandon',
+ title: 'Browse Abandonment',
+ icon: '🔁',
+
+ _evaluateEvent: evaluateEvent,
+ _renderEmailPreview: renderEmailPreview,
+ _projectSchedule: projectSchedule,
+
+ mount(router) {
+ const guard = (handler) => async (req, res) => {
+ try { await handler(req, res); }
+ catch (e) { res.status(500).json({ error: e.message }); }
+ };
+
+ // Touch both files on first mount so a fresh deploy has shape.
+ readConfig();
+ readEvents();
+
+ router.get('/meta', guard(async (_req, res) => {
+ const cfg = readConfig();
+ res.json({
+ staged: true,
+ mock: true,
+ stepKinds: STEP_KINDS,
+ tokens: ['first_name', 'pattern_name', 'color_name', 'product_type', 'vendor', 'collection', 'product_url', 'memo_url', 'sku'],
+ defaults: defaultConfig(),
+ brand: { voice: brand.voice, cta: brand.cta },
+ currentConfigUpdatedAt: cfg.updatedAt,
+ gateMessage:
+ 'Browse Abandonment is PLANNING ONLY. It defines the drip (Day 1 storytelling → Day 14 room-setting → Day 30 ' +
+ 'suppress) and shows which mock browse events WOULD fire today and over the next 7 days. Nothing is sent. ' +
+ 'A live execution path requires a Shopify browse-event feed, contact identity resolution, and the existing ' +
+ 'Constant Contact send pipeline — each step Steve-gated. CAN-SPAM footer (physical address + working ' +
+ 'unsubscribe) is required on every live email.',
+ });
+ }));
+
+ router.get('/config', guard(async (_req, res) => {
+ res.json({ staged: true, config: readConfig() });
+ }));
+
+ router.put('/config', guard(async (req, res) => {
+ const errs = validateConfig(req.body);
+ if (errs.length) return res.status(400).json({ error: 'validation failed', issues: errs });
+ const next = normalizeConfig(req.body || {}, readConfig());
+ writeConfig(next);
+ res.json({ staged: true, config: next, saved: true });
+ }));
+
+ router.post('/config/reset', guard(async (_req, res) => {
+ const def = defaultConfig();
+ writeConfig(def);
+ res.json({ staged: true, config: def, reset: true });
+ }));
+
+ router.get('/events', guard(async (_req, res) => {
+ const cfg = readConfig();
+ const events = readEvents();
+ const now = Date.now();
+ const enriched = events.map(e => {
+ const ev = evaluateEvent(e, cfg, now);
+ return { ...e, ...ev };
+ });
+ // Summary buckets the dashboard uses for headline counts.
+ const summary = {
+ total: enriched.length,
+ sampleRequested: enriched.filter(e => e.stage === 'sample-requested').length,
+ cooling: enriched.filter(e => e.stage === 'cooling').length,
+ inDrip: enriched.filter(e => e.stage === 'in-drip').length,
+ suppressed: enriched.filter(e => e.stage === 'suppressed').length,
+ wouldFireToday: enriched.filter(e => e.wouldFire).length,
+ };
+ res.json({
+ staged: true,
+ mock: true,
+ config: { enabled: cfg.enabled, noSampleThresholdDays: cfg.noSampleThresholdDays, dailySendCap: cfg.dailySendCap },
+ summary,
+ events: enriched.sort((a, b) => (a.browsedDaysAgo ?? 0) - (b.browsedDaysAgo ?? 0)),
+ });
+ }));
+
+ router.post('/events/refresh-mock', guard(async (_req, res) => {
+ const seeded = seedMockEvents();
+ writeEvents(seeded);
+ res.json({ staged: true, mock: true, count: seeded.length, reseededAt: new Date().toISOString() });
+ }));
+
+ router.get('/schedule', guard(async (req, res) => {
+ const cfg = readConfig();
+ const events = readEvents();
+ const days = Math.max(1, Math.min(30, Number(req.query.days) || 7));
+ const projection = projectSchedule(events, cfg, days);
+ res.json({
+ staged: true,
+ mock: true,
+ days,
+ dailySendCap: cfg.dailySendCap,
+ projection,
+ sendNote: 'PLANNING ONLY — these counts show what WOULD fire if the drip were live. Zero real sends occur.',
+ });
+ }));
+
+ // Dry-run renderer: returns the rendered email (or suppress note) for the
+ // step that is due TODAY for this event. Never sends. The UI uses it as the
+ // "Preview what would fire" inspector.
+ router.post('/fire/:eventId', guard(async (req, res) => {
+ const cfg = readConfig();
+ const events = readEvents();
+ const evt = events.find(e => e.id === req.params.eventId);
+ if (!evt) return res.status(404).json({ error: 'event not found' });
+ const ev = evaluateEvent(evt, cfg);
+ if (!ev.wouldFire) {
+ return res.json({
+ staged: true,
+ dryRun: true,
+ fired: false,
+ reason: ev.reason || `No step is due on day ${ev.browsedDaysAgo} for this contact (stage: ${ev.stage}).`,
+ stage: ev.stage,
+ eventId: evt.id,
+ });
+ }
+ const step = (cfg.steps || []).find(s => s.id === ev.wouldFire.id);
+ const payload = { staged: true, dryRun: true, fired: false, eventId: evt.id, stage: ev.stage, step: { id: step.id, title: step.title, kind: step.kind, dayOffset: step.dayOffset } };
+ if (step.kind === 'email') {
+ payload.preview = renderEmailPreview(step, evt);
+ payload.note = 'PLANNING ONLY — this email was NOT sent. It is a rendered preview for inspection.';
+ } else if (step.kind === 'suppress') {
+ payload.suppress = { list: step.suppressList, note: renderTemplate(step.note, evt) };
+ payload.note = 'PLANNING ONLY — the contact would be moved to the "' + step.suppressList + '" cohort. No action taken.';
+ }
+ res.json(payload);
+ }));
+ },
+};
diff --git a/modules/copy/index.js b/modules/copy/index.js
index 26918a2..2c07175 100644
--- a/modules/copy/index.js
+++ b/modules/copy/index.js
@@ -5,6 +5,9 @@
//
// Routes (mounted at /api/copy by the shell):
// POST /generate {kind, topic, product, audience, tone, n} -> {variants:[…], mock?}
+// POST /score {kind, text} -> {score, openClass, tone:{reassurance,storytelling,urgency},
+// personalization:{tokens, missing, recommended, coverage},
+// flags:[…], notes, source}
// GET /presets -> {kinds, tones, audiences, products, ctas}
//
// No outbound sends here — this only DRAFTS copy. CAN-SPAM footer is flagged as
@@ -283,6 +286,377 @@ function parseVariants(raw, n) {
return out.slice(0, n);
}
+// ── scoring ──────────────────────────────────────────────────────────────────
+// Inline copy/subject-line scoring. Returns a predicted-open score (0–100), a
+// luxury-tone breakdown (reassurance / storytelling / urgency, summing to 100),
+// and a personalization-coverage check that hunts for merge tags across the
+// dialects we see in the wild (Constant Contact, Mailchimp, Klaviyo, Liquid).
+//
+// Heuristic-first: this is the baseline and always runs so the panel works
+// without external creds. If GEMINI_API_KEY is set we additionally ask Gemini
+// for a second opinion, average the two predicted-open scores, and pass through
+// Gemini's notes. On any Gemini failure we fall back silently to the heuristic.
+
+const PERSONALIZATION_PATTERNS = [
+ // {{first_name}}, {{ firstName }}, {{customer.first_name}}
+ /\{\{\s*([a-z0-9_.]+)\s*\}\}/gi,
+ // [firstName], [first_name]
+ /\[\s*([a-z0-9_.]+)\s*\]/gi,
+ // %FIRSTNAME%
+ /%([A-Z0-9_]+)%/g,
+ // Mailchimp-style *|FNAME|*
+ /\*\|([A-Z0-9_]+)\|\*/g,
+ // Constant Contact / generic [[token]]
+ /\[\[\s*([a-z0-9_.]+)\s*\]\]/gi,
+];
+
+// Canonical merge-tag buckets we look for. Each entry: bucket label + a regex
+// matched against the lowercased, normalized token name extracted above.
+const PERSONALIZATION_BUCKETS = [
+ { id: 'first_name', label: 'First name', match: /(^|[_.])f(irst)?[_.]?name$|^fname$|^firstname$/i },
+ { id: 'last_name', label: 'Last name', match: /(^|[_.])l(ast)?[_.]?name$|^lname$|^lastname$/i },
+ { id: 'company', label: 'Firm / company', match: /^(company|firm|studio|business|organization|org)$/i },
+ { id: 'role', label: 'Designer role', match: /^(role|title|job|jobtitle|persona|segment)$/i },
+ { id: 'city', label: 'City / region', match: /^(city|region|state|territory|market)$/i },
+];
+
+// Word banks tuned to luxury-wallcovering voice. Each is matched word-boundary,
+// case-insensitive. Counts are clamped before being normalized.
+const TONE_BANKS = {
+ reassurance: [
+ 'handcrafted', 'handmade', 'curated', 'considered', 'trade-only', 'trade only',
+ 'memo', 'samples', 'archive', 'heritage', 'house', 'guarantee', 'guaranteed',
+ 'trusted', 'made to order', 'made-to-order', 'workroom', 'atelier', 'crafted',
+ 'designed', 'specified', 'spec', 'quiet luxury', 'timeless', 'enduring',
+ 'authentic', 'pedigree', 'provenance', 'since',
+ ],
+ storytelling: [
+ 'imagine', 'picture', 'envision', 'consider', 'inside', 'closer look',
+ 'inside look', 'the story', 'story', 'journey', 'origin', 'inspired',
+ 'inspiration', 'tactile', 'texture', 'light', 'shadow', 'soft', 'silken',
+ 'grain', 'hand', 'weave', 'thread', 'fiber', 'patina', 'depth', 'whisper',
+ 'glow', 'mood', 'atmosphere', 'rooms that', 'room that', 'walls that',
+ 'wall that', 'a closer look', 'between',
+ ],
+ urgency: [
+ 'today', 'now', 'limited', 'last chance', 'final', 'ending', 'ends',
+ 'closes', 'closing', 'this week', 'this month', 'while supplies last',
+ "don't miss", 'just dropped', 'just landed', 'new', 'just in', 'hurry',
+ 'before', 'expires', 'expiring', 'only', 'exclusive', 'this weekend',
+ 'tonight', 'tomorrow', 'last', 'final hours',
+ ],
+};
+
+// Subject-line predicted-open heuristics. Each rule contributes a delta to a
+// baseline. The result is clamped to [0,100]. Tuned against ESP best-practice
+// guidance for luxury B2B/B2C senders (designers + trade).
+function scoreSubjectLine(text) {
+ const t = String(text || '');
+ const lower = t.toLowerCase();
+ const len = t.trim().length;
+ const words = t.trim().split(/\s+/).filter(Boolean);
+ const reasons = [];
+ let s = 56;
+
+ // length sweet spot (30–55 chars)
+ if (len === 0) { reasons.push({ d: -56, why: 'empty subject' }); s -= 56; }
+ else if (len < 18) { reasons.push({ d: -8, why: `short (${len} chars)` }); s -= 8; }
+ else if (len <= 55) { reasons.push({ d: +10, why: `length on target (${len} chars)` }); s += 10; }
+ else if (len <= 70) { reasons.push({ d: -4, why: `getting long (${len} chars)` }); s -= 4; }
+ else { reasons.push({ d: -14, why: `too long for inbox preview (${len} chars)` }); s -= 14; }
+
+ // SHOUTING is brand-off and lowers opens for premium senders
+ const upperWords = words.filter(w => w.length >= 3 && w === w.toUpperCase() && /[A-Z]/.test(w));
+ if (upperWords.length >= 2) { reasons.push({ d: -10, why: 'ALL-CAPS reads shouty' }); s -= 10; }
+
+ // exclamation spam
+ const bangs = (t.match(/!/g) || []).length;
+ if (bangs >= 2) { reasons.push({ d: -8, why: 'multiple exclamation points' }); s -= 8; }
+ else if (bangs === 1) { reasons.push({ d: -2, why: 'avoid exclamation in luxury voice' }); s -= 2; }
+
+ // common spam triggers (luxury-relevant subset)
+ const spamTriggers = ['free', 'free!!', 'guarantee', 'cheap', '100%', 'act now', 'click here',
+ '$$$', 'winner', 'congratulations', 'risk-free', 'no obligation', 'discount', 'sale!!'];
+ const hits = spamTriggers.filter(w => lower.includes(w));
+ if (hits.length) {
+ reasons.push({ d: -6 * hits.length, why: `spam-trigger words: ${hits.slice(0,3).join(', ')}` });
+ s -= 6 * hits.length;
+ }
+
+ // personalization bumps opens materially
+ const merged = extractMergeTokens(t).list.length;
+ if (merged >= 1) {
+ reasons.push({ d: +8, why: 'personalization token present' });
+ s += 8;
+ }
+
+ // numerals / specificity ("5 grasscloths", "12 walls"), modest bump
+ if (/\b\d{1,3}\b/.test(t)) { reasons.push({ d: +3, why: 'specific number' }); s += 3; }
+
+ // luxury / curiosity words
+ const lux = ['inside', 'closer look', 'memo', 'curated', 'editor', 'trade', 'collection',
+ 'archive', 'atelier', 'limited', 'private', 'first look', 'preview'];
+ if (lux.some(w => lower.includes(w))) { reasons.push({ d: +4, why: 'editorial / curiosity word' }); s += 4; }
+
+ // questions perform well in B2B
+ if (/\?$/.test(t.trim())) { reasons.push({ d: +3, why: 'question framing' }); s += 3; }
+
+ return { score: clamp(Math.round(s), 0, 100), reasons };
+}
+
+// Body / caption / SMS scoring. Slightly different signal set: rewards
+// concrete CTA presence, specificity, sensory language, and presence of merge
+// tags; penalizes wall-of-text and missing CTA.
+function scoreBody(text, kind) {
+ const t = String(text || '');
+ const lower = t.toLowerCase();
+ const len = t.trim().length;
+ const reasons = [];
+ let s = 55;
+
+ if (len === 0) { reasons.push({ d: -55, why: 'empty copy' }); return { score: 0, reasons }; }
+
+ // length expectations differ by kind
+ const targets = {
+ email: { min: 220, ideal: [320, 900], max: 1600 },
+ instagram: { min: 90, ideal: [130, 320], max: 600 },
+ tiktok: { min: 60, ideal: [80, 220], max: 400 },
+ sms: { min: 40, ideal: [70, 160], max: 220 },
+ headline: { min: 16, ideal: [22, 60], max: 90 },
+ 'subject-lines': { min: 18, ideal: [30, 55], max: 70 },
+ }[kind] || { min: 80, ideal: [180, 500], max: 1200 };
+
+ if (len < targets.min) { reasons.push({ d: -10, why: `short for ${kind} (${len} chars)` }); s -= 10; }
+ else if (len > targets.max) { reasons.push({ d: -12, why: `long for ${kind} (${len} chars)` }); s -= 12; }
+ else if (len >= targets.ideal[0] && len <= targets.ideal[1]) {
+ reasons.push({ d: +8, why: `length on target (${len} chars)` }); s += 8;
+ }
+
+ // CTA presence — match brand CTAs + common verbs
+ const ctaWords = [...brand.cta.map(c => c.toLowerCase()), 'request', 'explore', 'book',
+ 'reserve', 'browse', 'shop', 'see', 'order memo', 'order samples', 'view', 'learn more'];
+ const hasCTA = ctaWords.some(w => lower.includes(w));
+ if (hasCTA) { reasons.push({ d: +8, why: 'CTA present' }); s += 8; }
+ else { reasons.push({ d: -10, why: 'no clear CTA' }); s -= 10; }
+
+ // personalization tokens
+ const merged = extractMergeTokens(t).list.length;
+ if (merged >= 1) { reasons.push({ d: +5, why: 'personalization token present' }); s += 5; }
+
+ // sensory / brand-voice words (rewards luxury copy)
+ const sensoryHits = TONE_BANKS.storytelling.filter(w => lower.includes(w)).length;
+ if (sensoryHits >= 2) { reasons.push({ d: +6, why: 'sensory / editorial language' }); s += 6; }
+
+ // SHOUTING penalty
+ const bangs = (t.match(/!/g) || []).length;
+ if (bangs >= 3) { reasons.push({ d: -8, why: 'exclamation-heavy' }); s -= 8; }
+ const wordsArr = t.split(/\s+/).filter(Boolean);
+ const upperWords = wordsArr.filter(w => w.length >= 3 && w === w.toUpperCase() && /[A-Z]/.test(w));
+ if (upperWords.length >= 3) { reasons.push({ d: -8, why: 'ALL-CAPS bursts' }); s -= 8; }
+
+ // big wall of unbroken text reads poorly on mobile
+ const longestParagraph = Math.max(0, ...t.split(/\n\s*\n/).map(p => p.length));
+ if (longestParagraph > 600) { reasons.push({ d: -6, why: 'paragraph too long for mobile' }); s -= 6; }
+
+ // SMS-specific: STOP opt-out
+ if (kind === 'sms' && !/stop/i.test(lower)) {
+ reasons.push({ d: -10, why: 'SMS missing STOP opt-out' });
+ s -= 10;
+ }
+
+ // hashtags help social
+ if ((kind === 'instagram' || kind === 'tiktok') && (t.match(/#\w+/g) || []).length < 3) {
+ reasons.push({ d: -4, why: 'social caption needs more hashtags' });
+ s -= 4;
+ }
+
+ return { score: clamp(Math.round(s), 0, 100), reasons };
+}
+
+function clamp(n, lo, hi) { return Math.min(hi, Math.max(lo, n)); }
+
+function openClassOf(score) {
+ if (score >= 80) return 'excellent';
+ if (score >= 65) return 'strong';
+ if (score >= 50) return 'fair';
+ if (score >= 35) return 'weak';
+ return 'poor';
+}
+
+// Tone breakdown — count word-bank hits per bucket, normalize to a 100% split.
+// If nothing matches, we report a neutral 33/33/34 with `confidence: 'low'`.
+function toneBreakdown(text) {
+ const lower = String(text || '').toLowerCase();
+ const count = bank => bank.reduce((acc, w) => {
+ const re = new RegExp(`\\b${w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'gi');
+ const m = lower.match(re);
+ return acc + (m ? m.length : 0);
+ }, 0);
+
+ const raw = {
+ reassurance: count(TONE_BANKS.reassurance),
+ storytelling: count(TONE_BANKS.storytelling),
+ urgency: count(TONE_BANKS.urgency),
+ };
+ const total = raw.reassurance + raw.storytelling + raw.urgency;
+ if (total === 0) return { reassurance: 33, storytelling: 33, urgency: 34, confidence: 'low', hits: raw };
+
+ // normalize, then round preserving sum = 100
+ const pct = {
+ reassurance: (raw.reassurance / total) * 100,
+ storytelling: (raw.storytelling / total) * 100,
+ urgency: (raw.urgency / total) * 100,
+ };
+ const r = { reassurance: Math.round(pct.reassurance), storytelling: Math.round(pct.storytelling), urgency: Math.round(pct.urgency) };
+ const drift = 100 - (r.reassurance + r.storytelling + r.urgency);
+ if (drift !== 0) {
+ const key = Object.keys(pct).sort((a, b) => pct[b] - pct[a])[0];
+ r[key] += drift;
+ }
+ return { ...r, confidence: total >= 3 ? 'high' : 'medium', hits: raw };
+}
+
+// Pull every merge token out of the text, normalize the name, and label it.
+function extractMergeTokens(text) {
+ const list = [];
+ const seen = new Set();
+ const s = String(text || '');
+ for (const re of PERSONALIZATION_PATTERNS) {
+ let m;
+ re.lastIndex = 0;
+ while ((m = re.exec(s)) !== null) {
+ const raw = m[0];
+ const name = (m[1] || '').trim();
+ if (!name) continue;
+ const key = name.toLowerCase();
+ if (seen.has(raw)) continue;
+ seen.add(raw);
+ const bucket = PERSONALIZATION_BUCKETS.find(b => b.match.test(key)) || null;
+ list.push({ raw, name, bucket: bucket ? bucket.id : 'other', label: bucket ? bucket.label : name });
+ }
+ }
+ return { list };
+}
+
+function personalizationCoverage(text, kind) {
+ const { list } = extractMergeTokens(text);
+ const covered = new Set(list.map(t => t.bucket).filter(b => b !== 'other'));
+ // What we recommend by kind. Luxury subject lines benefit most from first name
+ // + firm; email body adds role; social/SMS typically skip personalization.
+ const recommendedMap = {
+ 'subject-lines': ['first_name'],
+ email: ['first_name', 'company'],
+ headline: [],
+ instagram: [],
+ tiktok: [],
+ sms: ['first_name'],
+ };
+ const recommendedIds = recommendedMap[kind] || ['first_name'];
+ const recommended = recommendedIds.map(id => {
+ const def = PERSONALIZATION_BUCKETS.find(b => b.id === id);
+ return { id, label: def ? def.label : id, present: covered.has(id) };
+ });
+ const missing = recommended.filter(r => !r.present).map(r => r.id);
+ const coverage = recommended.length === 0
+ ? 1
+ : recommended.filter(r => r.present).length / recommended.length;
+ return { tokens: list, recommended, missing, coverage: Math.round(coverage * 100) };
+}
+
+function heuristicScore({ kind, text }) {
+ const k = String(kind || '').toLowerCase();
+ const isSubject = k === 'subject-lines' || k === 'subject' || k === 'subject-line';
+ const head = isSubject ? scoreSubjectLine(text) : scoreBody(text, k);
+ const tone = toneBreakdown(text);
+ const personalization = personalizationCoverage(text, isSubject ? 'subject-lines' : k);
+
+ const flags = [];
+ if (k === 'email' && !/unsubscribe/i.test(text)) flags.push('email draft has no unsubscribe phrasing — CAN-SPAM footer still required before sending');
+ if (k === 'sms' && !/stop/i.test(text)) flags.push('SMS draft is missing STOP opt-out');
+ if (isSubject && text.trim().length > 70) flags.push('subject likely truncated in most inboxes');
+ if (text.length > 8000) flags.push('text is unusually long — was a whole thread pasted in?');
+
+ // tone-shape notes
+ const dominant = ['reassurance', 'storytelling', 'urgency'].sort((a, b) => tone[b] - tone[a])[0];
+ const notes = [];
+ notes.push(`Predicted-open class: ${openClassOf(head.score)} (heuristic).`);
+ notes.push(`Tone leans ${dominant} (${tone[dominant]}%).`);
+ if (personalization.missing.length) {
+ notes.push(`Add merge token(s): ${personalization.missing.join(', ')}.`);
+ } else if (personalization.tokens.length) {
+ notes.push(`Personalization coverage: ${personalization.coverage}%.`);
+ }
+
+ return {
+ score: head.score,
+ openClass: openClassOf(head.score),
+ tone,
+ personalization,
+ reasons: head.reasons,
+ flags,
+ notes: notes.join(' '),
+ source: 'heuristic',
+ };
+}
+
+// Optional Gemini second-opinion. Returns null on any failure so the caller
+// uses the heuristic result unchanged.
+async function geminiScore({ kind, text }) {
+ const key = process.env.GEMINI_API_KEY || process.env.GOOGLE_GENERATIVE_AI_API_KEY;
+ if (!key) return null;
+ const model = process.env.GEMINI_MODEL || 'gemini-2.0-flash';
+ const sys = [
+ `You are a senior email & social copy reviewer for ${brand.name}, a luxury wallcovering house.`,
+ `Brand voice: ${brand.voice.join(', ')}. Audience: ${brand.audiences.join(', ')}.`,
+ `Score the supplied ${kind} copy. Return ONLY valid JSON of shape`,
+ `{"predicted_open":0-100,"tone":{"reassurance":0-100,"storytelling":0-100,"urgency":0-100},"notes":"…"}`,
+ `Tone keys MUST sum to 100. No markdown, no prose.`,
+ ].join('\n');
+ const user = `KIND: ${kind}\nCOPY:\n${String(text || '').slice(0, 4000)}`;
+ let resp;
+ try {
+ resp = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${key}`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({
+ systemInstruction: { parts: [{ text: sys }] },
+ contents: [{ role: 'user', parts: [{ text: user }] }],
+ generationConfig: { maxOutputTokens: 400, temperature: 0.2 },
+ }),
+ });
+ } catch (e) {
+ console.warn(`[copy/score] gemini network: ${e.message}`);
+ return null;
+ }
+ if (!resp.ok) {
+ console.warn(`[copy/score] gemini ${resp.status}`);
+ return null;
+ }
+ let data;
+ try { data = await resp.json(); } catch { return null; }
+ const raw = (data.candidates?.[0]?.content?.parts || []).map(p => p.text || '').join('').trim();
+ let obj;
+ try {
+ const cleaned = raw.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '').trim();
+ obj = JSON.parse(cleaned);
+ } catch {
+ const m = raw.match(/\{[\s\S]*\}/);
+ if (!m) return null;
+ try { obj = JSON.parse(m[0]); } catch { return null; }
+ }
+ if (!obj || typeof obj.predicted_open !== 'number') return null;
+ const tone = obj.tone || {};
+ return {
+ predictedOpen: clamp(Math.round(obj.predicted_open), 0, 100),
+ tone: {
+ reassurance: clamp(Math.round(+tone.reassurance || 0), 0, 100),
+ storytelling: clamp(Math.round(+tone.storytelling || 0), 0, 100),
+ urgency: clamp(Math.round(+tone.urgency || 0), 0, 100),
+ },
+ notes: String(obj.notes || '').slice(0, 600),
+ };
+}
+
// ── module ───────────────────────────────────────────────────────────────────
module.exports = {
id: 'copy',
@@ -333,5 +707,65 @@ module.exports = {
const variants = localGenerate(ctx, n);
res.json({ variants, kind, mock: true, source: 'templates' });
});
+
+ // POST /api/copy/score — inline scoring for a subject line or copy block.
+ // Always runs the heuristic; if Gemini is keyed, averages predicted-open
+ // with Gemini's read and blends the tone breakdown (heuristic weight 0.6,
+ // Gemini 0.4). Returns the same shape either way so the panel stays simple.
+ router.post('/score', async (req, res) => {
+ const b = req.body || {};
+ const kind = (b.kind || 'subject-lines').toString().trim().toLowerCase();
+ const text = (b.text || '').toString().slice(0, 12000);
+ if (!text.trim()) {
+ return res.status(400).json({ error: 'empty text' });
+ }
+
+ const base = heuristicScore({ kind, text });
+ let source = 'heuristic';
+ let gem = null;
+ if (process.env.GEMINI_API_KEY || process.env.GOOGLE_GENERATIVE_AI_API_KEY) {
+ try { gem = await geminiScore({ kind, text }); }
+ catch (e) { console.warn(`[copy/score] gemini failed: ${e.message}`); }
+ }
+
+ if (gem) {
+ source = 'gemini+heuristic';
+ const blendedScore = Math.round(base.score * 0.6 + gem.predictedOpen * 0.4);
+ // weighted tone blend, then normalize to sum=100
+ const blendTone = {
+ reassurance: base.tone.reassurance * 0.6 + gem.tone.reassurance * 0.4,
+ storytelling: base.tone.storytelling * 0.6 + gem.tone.storytelling * 0.4,
+ urgency: base.tone.urgency * 0.6 + gem.tone.urgency * 0.4,
+ };
+ const sum = blendTone.reassurance + blendTone.storytelling + blendTone.urgency || 1;
+ const norm = {
+ reassurance: Math.round((blendTone.reassurance / sum) * 100),
+ storytelling: Math.round((blendTone.storytelling / sum) * 100),
+ urgency: Math.round((blendTone.urgency / sum) * 100),
+ };
+ const drift = 100 - (norm.reassurance + norm.storytelling + norm.urgency);
+ if (drift !== 0) {
+ const top = ['reassurance', 'storytelling', 'urgency']
+ .sort((a, b) => norm[b] - norm[a])[0];
+ norm[top] += drift;
+ }
+ return res.json({
+ score: blendedScore,
+ openClass: openClassOf(blendedScore),
+ tone: { ...norm, confidence: 'high', hits: base.tone.hits },
+ personalization: base.personalization,
+ reasons: base.reasons,
+ flags: base.flags,
+ notes: [base.notes, gem.notes].filter(Boolean).join(' '),
+ source,
+ detail: {
+ heuristic: { score: base.score, tone: base.tone },
+ gemini: { score: gem.predictedOpen, tone: gem.tone },
+ },
+ });
+ }
+
+ res.json({ ...base, source });
+ });
},
};
diff --git a/modules/journeys/index.js b/modules/journeys/index.js
new file mode 100644
index 0000000..ffc6320
--- /dev/null
+++ b/modules/journeys/index.js
@@ -0,0 +1,639 @@
+// journeys — Cross-Channel Journeys module for the Marketing Command Center.
+// A journey is an ordered sequence of marketing steps for one segment:
+// email → wait → SMS → wait → social retarget → email re-engagement
+// Steps carry an optional "condition" (e.g. if-no-click) so the user can plan a
+// branching cross-channel cadence. Journeys persist to data/journeys.json.
+//
+// PLANNING ONLY. Nothing here fires a live send/SMS/social post. POST /:id/run
+// returns a dry-run simulation (estimated reach + drop-off across steps) — never
+// a side effect. If a real automation engine wires up later, it can read these
+// journeys from disk; this module owns the plan, not the execution.
+//
+// Routes (mounted at /api/journeys by the shell):
+// GET /meta — module metadata, step kinds, recommendation rules
+// GET /refs — segments + templates the UI uses to populate dropdowns
+// GET /journeys — list all journeys (summary shape)
+// GET /journeys/:id — one journey (full steps)
+// POST /journeys — create
+// PUT /journeys/:id — update (name/description/segment/steps)
+// DELETE /journeys/:id — remove
+// POST /journeys/:id/recommend — recommended next steps to append after current journey
+// POST /journeys/:id/run — dry-run simulation (audience flow, never sends)
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+
+const brand = require('../../lib/brand.js');
+
+const DATA_FILE = path.join(__dirname, '..', '..', 'data', 'journeys.json');
+const SEG_FILE = path.join(__dirname, '..', '..', 'data', 'segments.json');
+
+// ── step schema ─────────────────────────────────────────────────────────────
+// Each kind declares the fields the UI should collect. The validator below
+// uses the same shape so the schema and the gate never drift.
+const STEP_KINDS = {
+ email: {
+ label: 'Email',
+ icon: '✉️',
+ description: 'Send a templated email (drafted; live send is gated by Constant Contact).',
+ fields: [
+ { key: 'subject', label: 'Subject line', required: true },
+ { key: 'templateId', label: 'Template id (optional)', required: false },
+ { key: 'note', label: 'Internal note', required: false },
+ ],
+ supportsCondition: true,
+ },
+ wait: {
+ label: 'Wait',
+ icon: '⏱',
+ description: 'Pause the journey for a defined window before evaluating the next step.',
+ fields: [
+ { key: 'hours', label: 'Wait (hours)', required: true, type: 'number' },
+ { key: 'label', label: 'Reason (optional)', required: false },
+ ],
+ supportsCondition: false,
+ },
+ sms: {
+ label: 'SMS',
+ icon: '📱',
+ description: 'Send a short SMS follow-up (planning only; live send routes through a gated provider).',
+ fields: [
+ { key: 'message', label: 'SMS message (160 chars)', required: true },
+ { key: 'note', label: 'Internal note', required: false },
+ ],
+ supportsCondition: true,
+ },
+ social: {
+ label: 'Social',
+ icon: '📸',
+ description: 'Stage an organic social post (Instagram / Facebook / TikTok). Live publish stays gated.',
+ fields: [
+ { key: 'platform', label: 'Platform', required: true, enum: ['instagram', 'facebook', 'tiktok', 'linkedin'] },
+ { key: 'caption', label: 'Caption', required: true },
+ { key: 'note', label: 'Internal note', required: false },
+ ],
+ supportsCondition: true,
+ },
+ retarget: {
+ label: 'Retarget',
+ icon: '🎯',
+ description: 'Add the segment slice to a retargeting audience (Meta / Google). Planning only.',
+ fields: [
+ { key: 'platform', label: 'Retarget platform', required: true, enum: ['meta', 'google', 'tiktok'] },
+ { key: 'audienceType', label: 'Audience slice', required: true, enum: ['non-openers', 'non-clickers', 'openers', 'all'] },
+ { key: 'note', label: 'Internal note', required: false },
+ ],
+ supportsCondition: true,
+ },
+};
+
+// Conditions that can gate a non-wait step. "always" = no branching. The other
+// values fire only when the prior email step's outcome matches.
+const CONDITIONS = {
+ always: { label: 'Always', applies: () => true },
+ 'if-no-open': { label: 'If no open', requiresPriorEmail: true },
+ 'if-no-click': { label: 'If no click', requiresPriorEmail: true },
+ 'if-opened': { label: 'If opened', requiresPriorEmail: true },
+ 'if-clicked': { label: 'If clicked', requiresPriorEmail: true },
+};
+
+// ── recommendations engine ──────────────────────────────────────────────────
+// Given a journey, recommend cross-channel next steps. The first recommendation
+// only fires when the journey ends on an email step — that's the inflection
+// point where cross-channel follow-up actually helps.
+function recommendNextSteps(journey) {
+ const steps = Array.isArray(journey.steps) ? journey.steps : [];
+ const last = steps[steps.length - 1] || null;
+ const lastEmailIdx = (() => {
+ for (let i = steps.length - 1; i >= 0; i--) if (steps[i].kind === 'email') return i;
+ return -1;
+ })();
+ const hasEmail = lastEmailIdx >= 0;
+ const recs = [];
+
+ if (!steps.length) {
+ recs.push({
+ title: 'Start with a welcome email',
+ reason: 'Every journey opens with one anchor email — set the brand voice before any cross-channel follow-up.',
+ steps: [
+ {
+ kind: 'email',
+ condition: 'always',
+ subject: 'Welcome to the Designer Wallcoverings atelier',
+ note: 'Use the welcome-designer-1 template from the Campaign Templates module.',
+ },
+ ],
+ });
+ return recs;
+ }
+
+ if (!hasEmail) {
+ recs.push({
+ title: 'Add an anchor email before branching cross-channel',
+ reason: 'The cross-channel recommendations below assume an email open/click signal to react to. Add an email step first.',
+ steps: [
+ { kind: 'email', condition: 'always', subject: 'Designer Wallcoverings — a curated look', note: 'Anchor send for the journey.' },
+ ],
+ });
+ return recs;
+ }
+
+ // Standard cross-channel ladder after an email send.
+ recs.push({
+ title: '24h SMS to non-clickers',
+ reason: 'A short SMS one day later catches designers who opened the email on mobile but did not tap through. Keep it under 160 characters and lead with the memo offer — never a discount.',
+ steps: [
+ { kind: 'wait', hours: 24, label: '24h after email send' },
+ {
+ kind: 'sms',
+ condition: 'if-no-click',
+ message: 'Designer Wallcoverings — your memo box from this week\'s edit is one tap away: dwall.co/memos. Reply STOP to opt out.',
+ note: 'Provider TBD; live send stays gated behind Steve approval.',
+ },
+ ],
+ });
+
+ recs.push({
+ title: '3-day Instagram retarget on non-openers',
+ reason: 'After three days, anyone who never opened is unreachable by email until next send — surface the collection through paid social retargeting instead. Plan only; never auto-publish.',
+ steps: [
+ { kind: 'wait', hours: 72, label: '3 days after email send' },
+ {
+ kind: 'social',
+ condition: 'if-no-open',
+ platform: 'instagram',
+ caption: 'Three rooms, three wallcoverings — a place to start. #InteriorDesign #Grasscloth',
+ note: 'Stage as a retarget creative; tag the journey segment in the audience set.',
+ },
+ {
+ kind: 'retarget',
+ condition: 'if-no-open',
+ platform: 'meta',
+ audienceType: 'non-openers',
+ note: 'Push the non-opener slice into a Meta custom audience for a 7-day retarget window.',
+ },
+ ],
+ });
+
+ recs.push({
+ title: '7-day re-engagement email to non-openers',
+ reason: 'The graceful close — a final email re-cast at a different angle (editorial vs. transactional). After this, the contact rolls off this journey.',
+ steps: [
+ { kind: 'wait', hours: 168, label: '7 days after email send' },
+ {
+ kind: 'email',
+ condition: 'if-no-open',
+ subject: 'Still considering? A note from the atelier',
+ note: 'Use the memo-followup-day21 or re-engagement template — softer, no pressure.',
+ },
+ ],
+ });
+
+ // If the journey is already long, hint at an exit step.
+ if (steps.length >= 5) {
+ recs.push({
+ title: 'Add a graceful exit',
+ reason: 'A journey longer than five steps risks fatigue. Close with a wait + retarget rollover so the audience moves to evergreen, not silence.',
+ steps: [
+ { kind: 'wait', hours: 168, label: 'Cool-down before exit' },
+ {
+ kind: 'retarget',
+ condition: 'always',
+ platform: 'meta',
+ audienceType: 'all',
+ note: 'Roll the whole segment into the evergreen brand-retention audience and exit the journey.',
+ },
+ ],
+ });
+ }
+
+ // Annotate each recommendation with the anchor email it follows so the UI can
+ // explain "this 24h SMS follows step #2 (your welcome email)".
+ for (const r of recs) r.anchorStepIndex = lastEmailIdx;
+ return recs;
+}
+
+// ── dry-run simulation ──────────────────────────────────────────────────────
+// Walk the journey and project audience flow: how many enter each step, how
+// many proceed, how many drop off (because the condition gated them out, or
+// because we model an open/click rate per email).
+//
+// Deterministic per-journey via a seeded PRNG so the same journey simulates to
+// the same numbers — predictable for the UI.
+function mulberry32(seed) {
+ let s = seed >>> 0;
+ return function () {
+ s |= 0; s = (s + 0x6d2b79f5) | 0;
+ let t = Math.imul(s ^ (s >>> 15), 1 | s);
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+}
+function hashSeed(s) {
+ let h = 2166136261 >>> 0;
+ for (let i = 0; i < s.length; i++) {
+ h ^= s.charCodeAt(i);
+ h = Math.imul(h, 16777619) >>> 0;
+ }
+ return h >>> 0;
+}
+
+const BASE_OPEN = 0.22;
+const BASE_CLICK = 0.032;
+
+function simulateJourney(journey, audienceSize) {
+ const seed = hashSeed((journey.id || 'jny') + ':' + journey.steps.length);
+ const rnd = mulberry32(seed);
+ const noise = () => 0.92 + rnd() * 0.16;
+
+ // Track the audience as it flows: { active, opened, clicked }. "active" is
+ // the count still in the journey at this step; opens/clicks come from the
+ // most recent email and reset whenever a new email is sent.
+ let active = Math.max(0, Math.round(Number(audienceSize) || 0));
+ let opened = 0;
+ let clicked = 0;
+ let lastEmailIdx = -1;
+
+ const stepResults = [];
+ for (let i = 0; i < journey.steps.length; i++) {
+ const step = journey.steps[i];
+ const entry = active;
+ let proceeded = active;
+ let droppedByCondition = 0;
+ let note = '';
+
+ if (step.kind === 'email') {
+ // Send to everyone active; project opens + clicks.
+ const openRate = Math.max(0.02, Math.min(0.7, BASE_OPEN * noise()));
+ const clickRate = Math.max(0.005, Math.min(0.4, BASE_CLICK * noise()));
+ opened = Math.round(active * openRate);
+ clicked = Math.round(active * clickRate);
+ lastEmailIdx = i;
+ note = `Projected ${opened} opens (${(openRate * 100).toFixed(1)}%) · ${clicked} clicks (${(clickRate * 100).toFixed(1)}%)`;
+ proceeded = active;
+ } else if (step.kind === 'wait') {
+ const hours = Number(step.hours) || 0;
+ note = `Pause ${hours}h — audience continues.`;
+ proceeded = active;
+ } else {
+ // sms / social / retarget — gate by condition relative to the last email.
+ const cond = step.condition || 'always';
+ if (cond === 'if-no-open') { proceeded = Math.max(0, active - opened); }
+ else if (cond === 'if-no-click') { proceeded = Math.max(0, active - clicked); }
+ else if (cond === 'if-opened') { proceeded = opened; }
+ else if (cond === 'if-clicked') { proceeded = clicked; }
+ else proceeded = active;
+ droppedByCondition = entry - proceeded;
+ if (cond !== 'always' && lastEmailIdx < 0) {
+ note = `Condition "${cond}" needs a prior email step — no signal to branch on, treating as 0 reach.`;
+ proceeded = 0;
+ droppedByCondition = entry;
+ } else {
+ note = `${proceeded.toLocaleString()} continue${droppedByCondition ? ` · ${droppedByCondition.toLocaleString()} held by "${cond}"` : ''}`;
+ }
+ }
+
+ stepResults.push({
+ index: i,
+ kind: step.kind,
+ condition: step.condition || 'always',
+ entry,
+ proceeded,
+ droppedByCondition,
+ opens: step.kind === 'email' ? opened : null,
+ clicks: step.kind === 'email' ? clicked : null,
+ note,
+ });
+
+ // Audience that drops by condition exits the journey entirely.
+ active = proceeded;
+ }
+
+ return {
+ audienceSize: Math.max(0, Math.round(Number(audienceSize) || 0)),
+ finalActive: active,
+ totalEmails: journey.steps.filter(s => s.kind === 'email').length,
+ totalSms: journey.steps.filter(s => s.kind === 'sms').length,
+ totalSocial: journey.steps.filter(s => s.kind === 'social').length,
+ totalRetargets: journey.steps.filter(s => s.kind === 'retarget').length,
+ stepResults,
+ simulatedAt: new Date().toISOString(),
+ };
+}
+
+// ── persistence ─────────────────────────────────────────────────────────────
+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 readAll() { const v = readJson(DATA_FILE, null); return Array.isArray(v) ? v : []; }
+function writeAll(rows) { writeJson(DATA_FILE, rows); }
+function newId() { return 'jny_' + crypto.randomBytes(4).toString('hex'); }
+function newStepId() { return 'stp_' + crypto.randomBytes(3).toString('hex'); }
+
+// Seed a couple of realistic journeys on first load so the UI has content out
+// of the gate. Only written if the file doesn't already exist.
+function defaultJourneys() {
+ return [
+ {
+ id: 'jny_seed_welcome',
+ name: 'Designer Welcome — Cross-Channel',
+ description: 'New trade-approved designer: welcome email → 24h SMS for non-clickers → 3-day IG retarget on non-openers → 7-day re-engagement.',
+ segmentId: 'seg_trade_engaged',
+ segmentName: 'Trade & Designers — engaged',
+ audienceSize: 240,
+ status: 'staged',
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ steps: [
+ {
+ id: 'stp_seed_1', kind: 'email', condition: 'always',
+ subject: 'Welcome to the Designer Wallcoverings atelier',
+ templateId: 'welcome-designer-1',
+ note: 'Anchor welcome email (Part 1 of 2).',
+ },
+ { id: 'stp_seed_2', kind: 'wait', hours: 24, label: '24 hours' },
+ {
+ id: 'stp_seed_3', kind: 'sms', condition: 'if-no-click',
+ message: 'Designer Wallcoverings — your memo box from this week\'s edit is one tap away: dwall.co/memos. Reply STOP to opt out.',
+ note: 'Catches mobile openers who did not tap through.',
+ },
+ { id: 'stp_seed_4', kind: 'wait', hours: 72, label: '3 days' },
+ {
+ id: 'stp_seed_5', kind: 'social', condition: 'if-no-open',
+ platform: 'instagram',
+ caption: 'Three rooms, three wallcoverings — a place to start. #InteriorDesign #Grasscloth',
+ note: 'IG retarget creative for non-openers of the welcome email.',
+ },
+ {
+ id: 'stp_seed_6', kind: 'retarget', condition: 'if-no-open',
+ platform: 'meta', audienceType: 'non-openers',
+ note: 'Push non-openers into Meta custom audience for a 7-day window.',
+ },
+ { id: 'stp_seed_7', kind: 'wait', hours: 168, label: '7 days' },
+ {
+ id: 'stp_seed_8', kind: 'email', condition: 'if-no-open',
+ subject: 'Still considering? A note from the atelier',
+ templateId: 'memo-followup-day21',
+ note: 'Graceful re-engagement before exit.',
+ },
+ ],
+ },
+ ];
+}
+
+// ── validation ──────────────────────────────────────────────────────────────
+function validateStep(step, index) {
+ const errs = [];
+ if (!step || typeof step !== 'object') { errs.push(`step #${index + 1} must be an object`); return errs; }
+ const kindSpec = STEP_KINDS[step.kind];
+ if (!kindSpec) { errs.push(`step #${index + 1} has unknown kind "${step.kind}"`); return errs; }
+ for (const f of kindSpec.fields) {
+ if (!f.required) continue;
+ const v = step[f.key];
+ if (v === undefined || v === null || String(v).trim() === '') {
+ errs.push(`step #${index + 1} (${kindSpec.label}) missing required field "${f.key}"`);
+ } else if (f.enum && !f.enum.includes(String(v))) {
+ errs.push(`step #${index + 1} field "${f.key}" must be one of: ${f.enum.join(', ')}`);
+ } else if (f.type === 'number' && !Number.isFinite(Number(v))) {
+ errs.push(`step #${index + 1} field "${f.key}" must be a number`);
+ }
+ }
+ if (kindSpec.supportsCondition) {
+ const c = step.condition || 'always';
+ if (!CONDITIONS[c]) errs.push(`step #${index + 1} has unknown condition "${c}"`);
+ }
+ return errs;
+}
+
+function validateJourney(body, { partial = false } = {}) {
+ const errs = [];
+ if (!body || typeof body !== 'object') return ['body must be an object'];
+ if (!partial && !String(body.name || '').trim()) errs.push('name is required');
+ if (body.steps !== undefined) {
+ if (!Array.isArray(body.steps)) errs.push('steps must be an array');
+ else if (!partial && body.steps.length === 0) errs.push('journey needs at least one step');
+ else body.steps.forEach((s, i) => errs.push(...validateStep(s, i)));
+ } else if (!partial) {
+ errs.push('steps is required');
+ }
+ const audience = Number(body.audienceSize);
+ if (body.audienceSize !== undefined && (!Number.isFinite(audience) || audience < 0)) {
+ errs.push('audienceSize must be a non-negative number');
+ }
+ return errs;
+}
+
+function normalizeStep(step) {
+ const kindSpec = STEP_KINDS[step.kind];
+ const out = { id: step.id && /^stp_/.test(step.id) ? step.id : newStepId(), kind: step.kind };
+ for (const f of kindSpec.fields) {
+ if (step[f.key] !== undefined) {
+ if (f.type === 'number') out[f.key] = Number(step[f.key]);
+ else out[f.key] = String(step[f.key]).trim();
+ }
+ }
+ if (kindSpec.supportsCondition) out.condition = CONDITIONS[step.condition] ? step.condition : 'always';
+ return out;
+}
+
+function normalizeJourney(body, existing = null) {
+ const segmentName = body.segmentName !== undefined
+ ? String(body.segmentName).trim()
+ : (existing && existing.segmentName) || '';
+ return {
+ id: existing ? existing.id : newId(),
+ name: String(body.name || (existing && existing.name) || '').trim().slice(0, 140),
+ description: String(body.description !== undefined ? body.description : (existing && existing.description) || '').trim().slice(0, 500),
+ segmentId: body.segmentId !== undefined ? (body.segmentId ? String(body.segmentId) : null) : (existing && existing.segmentId) || null,
+ segmentName,
+ audienceSize: body.audienceSize !== undefined
+ ? Math.max(0, Math.round(Number(body.audienceSize)))
+ : (existing && existing.audienceSize) || 0,
+ status: 'staged',
+ steps: Array.isArray(body.steps)
+ ? body.steps.map(normalizeStep)
+ : (existing && existing.steps) || [],
+ createdAt: existing ? existing.createdAt : new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ };
+}
+
+function summarize(j) {
+ return {
+ id: j.id,
+ name: j.name,
+ description: j.description,
+ segmentId: j.segmentId || null,
+ segmentName: j.segmentName || '',
+ audienceSize: j.audienceSize || 0,
+ status: j.status || 'staged',
+ stepCount: (j.steps || []).length,
+ kinds: Array.from(new Set((j.steps || []).map(s => s.kind))),
+ updatedAt: j.updatedAt,
+ createdAt: j.createdAt,
+ };
+}
+
+// Pull the segments file directly (peer module). Read-only — no mutation.
+function loadSegmentRefs() {
+ const v = readJson(SEG_FILE, []);
+ if (!Array.isArray(v)) return [];
+ return v.map(s => ({
+ id: s.id,
+ name: s.name,
+ description: s.description || '',
+ estimatedSize: s.estimatedSize || null,
+ }));
+}
+
+// Templates are in-memory in the templates module — reuse them so the UI can
+// show "use this template" labels in email steps without a second fetch.
+function loadTemplateRefs() {
+ try {
+ const tmpl = require('../templates');
+ const arr = Array.isArray(tmpl._templates) ? tmpl._templates : [];
+ return arr.map(t => ({
+ id: t.id,
+ title: t.title,
+ subject: t.subject,
+ category: t.category,
+ sequencePosition: t.sequencePosition || '',
+ }));
+ } catch { return []; }
+}
+
+// ── module ──────────────────────────────────────────────────────────────────
+module.exports = {
+ id: 'journeys',
+ title: 'Cross-Channel Journeys',
+ icon: '🗺️',
+
+ _STEP_KINDS: STEP_KINDS,
+ _CONDITIONS: CONDITIONS,
+ _recommend: recommendNextSteps,
+ _simulate: simulateJourney,
+
+ mount(router) {
+ const guard = (handler) => async (req, res) => {
+ try { await handler(req, res); }
+ catch (e) { res.status(500).json({ error: e.message }); }
+ };
+
+ // Seed on first load — only if data file is empty.
+ if (!readAll().length) writeAll(defaultJourneys());
+
+ router.get('/meta', guard(async (_req, res) => {
+ const kinds = Object.entries(STEP_KINDS).map(([key, spec]) => ({
+ key, label: spec.label, icon: spec.icon, description: spec.description,
+ fields: spec.fields, supportsCondition: spec.supportsCondition,
+ }));
+ const conditions = Object.entries(CONDITIONS).map(([key, spec]) => ({
+ key, label: spec.label, requiresPriorEmail: !!spec.requiresPriorEmail,
+ }));
+ res.json({
+ staged: true,
+ count: readAll().length,
+ kinds,
+ conditions,
+ brand: { voice: brand.voice, cta: brand.cta },
+ gateMessage:
+ 'Cross-Channel Journeys are PLANNING ONLY. This module visualizes and saves the journey. ' +
+ 'It never fires an email, SMS, social post, or retarget audience push. Live sends still ' +
+ 'route through Constant Contact (or the matching channel provider) with Steve gating each ' +
+ 'send. CAN-SPAM footer (physical mailing address + working unsubscribe) is required on ' +
+ 'every email step before the real send.',
+ });
+ }));
+
+ // Combined refs endpoint — segments + templates the UI populates dropdowns with.
+ router.get('/refs', guard(async (_req, res) => {
+ res.json({
+ staged: true,
+ segments: loadSegmentRefs(),
+ templates: loadTemplateRefs(),
+ });
+ }));
+
+ router.get('/journeys', guard(async (_req, res) => {
+ const rows = readAll();
+ res.json({ staged: true, count: rows.length, journeys: rows.map(summarize) });
+ }));
+
+ router.get('/journeys/:id', guard(async (req, res) => {
+ const row = readAll().find(j => j.id === req.params.id);
+ if (!row) return res.status(404).json({ error: 'journey not found' });
+ res.json({ staged: true, journey: row });
+ }));
+
+ router.post('/journeys', guard(async (req, res) => {
+ const errs = validateJourney(req.body);
+ if (errs.length) return res.status(400).json({ error: 'validation failed', issues: errs });
+ const norm = normalizeJourney(req.body);
+ const rows = readAll();
+ rows.unshift(norm);
+ writeAll(rows);
+ res.json({ staged: true, journey: norm });
+ }));
+
+ router.put('/journeys/:id', guard(async (req, res) => {
+ const rows = readAll();
+ const idx = rows.findIndex(j => j.id === req.params.id);
+ if (idx < 0) return res.status(404).json({ error: 'journey not found' });
+ const errs = validateJourney(req.body, { partial: true });
+ if (errs.length) return res.status(400).json({ error: 'validation failed', issues: errs });
+ const norm = normalizeJourney(req.body, rows[idx]);
+ rows[idx] = norm;
+ writeAll(rows);
+ res.json({ staged: true, journey: norm });
+ }));
+
+ router.delete('/journeys/:id', guard(async (req, res) => {
+ const rows = readAll();
+ const idx = rows.findIndex(j => j.id === req.params.id);
+ if (idx < 0) return res.status(404).json({ error: 'journey not found' });
+ const [removed] = rows.splice(idx, 1);
+ writeAll(rows);
+ res.json({ staged: true, deleted: removed.id });
+ }));
+
+ // Recommended next steps. Reads the journey from disk (or uses a posted
+ // draft if the caller hasn't saved yet) and returns a list of recommended
+ // appendable step blocks for the user to accept/reject in the UI.
+ router.post('/journeys/:id/recommend', guard(async (req, res) => {
+ let journey;
+ if (req.params.id === 'draft') {
+ // Allow drafts that haven't been saved yet — useful for the builder UI.
+ const body = req.body || {};
+ journey = { id: 'draft', steps: Array.isArray(body.steps) ? body.steps : [] };
+ } else {
+ journey = readAll().find(j => j.id === req.params.id);
+ if (!journey) return res.status(404).json({ error: 'journey not found' });
+ }
+ const recs = recommendNextSteps(journey);
+ res.json({ staged: true, journeyId: journey.id, recommendations: recs });
+ }));
+
+ // Dry-run simulation — NEVER sends. Returns the projected step-by-step flow.
+ router.post('/journeys/:id/run', guard(async (req, res) => {
+ const rows = readAll();
+ const journey = rows.find(j => j.id === req.params.id);
+ if (!journey) return res.status(404).json({ error: 'journey not found' });
+ const audience = (req.body && Number.isFinite(Number(req.body.audienceSize)))
+ ? Number(req.body.audienceSize)
+ : (journey.audienceSize || 0);
+ const sim = simulateJourney(journey, audience);
+ res.json({
+ staged: true,
+ dryRun: true,
+ journeyId: journey.id,
+ ...sim,
+ sendNote: 'PLANNING ONLY — this run produced zero real sends. Live execution requires Steve-gated provider flows.',
+ });
+ }));
+ },
+};
diff --git a/modules/playbook/index.js b/modules/playbook/index.js
new file mode 100644
index 0000000..d49fd4f
--- /dev/null
+++ b/modules/playbook/index.js
@@ -0,0 +1,614 @@
+// playbook — Monthly Marketing Playbook generator for the Command Center.
+//
+// Pulls three live-ish signals from sibling modules:
+// 1. Calendar dates — curated retail/holiday/awareness/trade moments for
+// the four weeks ahead (modules/calendar + dates file).
+// 2. Segment health — per-segment open / CTOR / click / rev-per-recipient
+// bands from segment-perf, so weak cohorts surface
+// as winback / re-engagement plays.
+// 3. Performance gaps — list growth + recent open-rate trend (modules/
+// performance helpers reused indirectly).
+//
+// Then it produces 4–6 suggested campaigns/actions for the next 4 weeks, each
+// with title, audience, channel, date, rationale, source signal. Gemini is used
+// when GEMINI_API_KEY is set; otherwise a deterministic template generator runs
+// against the same signal set. Output is persisted to data/playbook.json and is
+// editable from the panel. SUGGESTIONS ONLY — nothing here sends or schedules a
+// live campaign. The calendar module is the canonical home for scheduled work;
+// the panel offers a one-click "Add to calendar" handoff that POSTs to its
+// /api/calendar/schedule endpoint.
+
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+
+const brand = require('../../lib/brand.js');
+const segmentPerf = require('../segment-perf');
+
+const DATA_DIR = path.join(__dirname, '..', '..', 'data');
+const STORE_FILE = path.join(DATA_DIR, 'playbook.json');
+const CAL_DATES_FILE = path.join(DATA_DIR, 'calendar-dates.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 readStore() {
+ const v = readJson(STORE_FILE, null);
+ if (v && typeof v === 'object' && v.playbooks) return v;
+ const def = { playbooks: {}, updatedAt: new Date().toISOString() };
+ writeJson(STORE_FILE, def);
+ return def;
+}
+function writeStore(s) { s.updatedAt = new Date().toISOString(); writeJson(STORE_FILE, s); }
+
+// ── date helpers (lifted verbatim from the calendar module so we don't have
+// to cross-require its private helpers; the curated dates file is the source) ─
+const pad = n => String(n).padStart(2, '0');
+const iso = (y, m, d) => `${y}-${pad(m)}-${pad(d)}`;
+
+function nthWeekday(year, month, weekday, ordinal) {
+ if (ordinal === -1) {
+ const last = new Date(year, month, 0).getDate();
+ 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;
+}
+
+function datesForMonth(year, month) {
+ const ds = readJson(CAL_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;
+}
+
+// Curated dates that fall inside the 4-week window starting from `startIso`.
+function datesInWindow(startIso, days = 28) {
+ const start = new Date(startIso + 'T00:00:00');
+ const end = new Date(start.getTime() + (days - 1) * 86400000);
+ const months = new Set();
+ for (let d = new Date(start); d <= end; d = new Date(d.getTime() + 86400000)) {
+ months.add(`${d.getFullYear()}-${pad(d.getMonth() + 1)}`);
+ }
+ const all = [];
+ for (const ym of months) {
+ const [y, m] = ym.split('-').map(Number);
+ for (const e of datesForMonth(y, m)) all.push(e);
+ }
+ const startKey = iso(start.getFullYear(), start.getMonth() + 1, start.getDate());
+ const endKey = iso(end.getFullYear(), end.getMonth() + 1, end.getDate());
+ return all
+ .filter(d => d.date >= startKey && d.date <= endKey)
+ .sort((a, b) => a.date.localeCompare(b.date));
+}
+
+// ── segment health snapshot ─────────────────────────────────────────────────
+// Reuse the segment-perf helpers to read each segment's 30d open-rate trend
+// and produce a health verdict: which cohorts are healthy vs. need rescuing.
+function segmentSignals() {
+ const segs = segmentPerf._segments || [];
+ const health = segmentPerf._health || {};
+ const out = [];
+ for (const s of segs) {
+ const openTrend = segmentPerf._trendFor(s, 'openRate', 90);
+ const last30 = openTrend.series.slice(-30).map(p => p.value);
+ const meanOpen = last30.reduce((a, b) => a + b, 0) / last30.length;
+ let band = 'unknown';
+ if (health.openRate) {
+ band = meanOpen < health.openRate.bad ? 'bad'
+ : meanOpen < health.openRate.good ? 'ok' : 'good';
+ }
+ out.push({
+ id: s.id, name: s.name, icon: s.icon,
+ listSize: s.listSize, sendsPerCampaign: s.sendsPerCampaign,
+ openRate: +meanOpen.toFixed(4),
+ pctChange30d: openTrend.pctChange,
+ band,
+ });
+ }
+ return out;
+}
+
+// Performance gap heuristic — derive from segment signals so we don't duplicate
+// the performance module's mock. Surfaces the segment that's leaking the most
+// engagement (largest negative trend), which becomes a recommended winback.
+function performanceGaps(segments) {
+ if (!segments.length) return [];
+ const declining = segments.filter(s => s.pctChange30d < -5);
+ const worstBand = segments
+ .filter(s => s.band === 'bad' || s.band === 'ok')
+ .sort((a, b) => a.openRate - b.openRate);
+ const gaps = [];
+ for (const s of declining) {
+ gaps.push({
+ segmentId: s.id, segment: s.name,
+ kind: 'declining',
+ note: `${s.name} 30-day open rate is down ${s.pctChange30d}% — schedule a winback or refresh creative.`,
+ });
+ }
+ for (const s of worstBand.slice(0, 1)) {
+ gaps.push({
+ segmentId: s.id, segment: s.name,
+ kind: 'underperforming',
+ note: `${s.name} sits in the ${s.band.toUpperCase()} band on open rate (${(s.openRate * 100).toFixed(1)}%) — try a high-relevance moment or list hygiene pass.`,
+ });
+ }
+ return gaps;
+}
+
+// ── month + week helpers ────────────────────────────────────────────────────
+function monthIdToWindow(monthId) {
+ // monthId: 'YYYY-MM' — we treat the window as the FIRST 28 days of that month
+ // so "4 weeks" is exact. If the caller wants a rolling window, they can pass
+ // ?start=YYYY-MM-DD on the route instead.
+ const [y, m] = monthId.split('-').map(Number);
+ return iso(y, m, 1);
+}
+function thisMonthId(now = new Date()) {
+ return `${now.getFullYear()}-${pad(now.getMonth() + 1)}`;
+}
+function weekOf(startIso, dateIso) {
+ const a = new Date(startIso + 'T00:00:00').getTime();
+ const b = new Date(dateIso + 'T00:00:00').getTime();
+ const dDay = Math.floor((b - a) / 86400000);
+ return Math.max(1, Math.min(4, Math.floor(dDay / 7) + 1));
+}
+
+// ── signal aggregation ──────────────────────────────────────────────────────
+function gatherSignals(startIso, days = 28) {
+ const dates = datesInWindow(startIso, days);
+ const segments = segmentSignals();
+ const gaps = performanceGaps(segments);
+ return { startIso, days, dates, segments, gaps };
+}
+
+// ── template generator (fallback when Gemini isn't available) ───────────────
+const AUDIENCES = ['designers', 'commercial', 'retail', 'at-risk'];
+const AUDIENCE_LABEL = {
+ designers: 'Interior Designers',
+ commercial: 'Commercial / Hospitality',
+ retail: 'Retail',
+ 'at-risk': 'At-Risk Winback',
+};
+
+function pickAudienceForDate(d) {
+ if (d.type === 'trade') return 'designers';
+ if (d.type === 'retail') return 'retail';
+ if (d.type === 'holiday') return 'retail';
+ return 'designers';
+}
+
+function templateEntryFromCalendar(d, startIso) {
+ const aud = pickAudienceForDate(d);
+ return {
+ week: weekOf(startIso, d.date),
+ date: d.date,
+ title: d.title,
+ audience: aud,
+ audienceLabel: AUDIENCE_LABEL[aud] || aud,
+ channel: d.type === 'holiday' || d.type === 'retail' ? 'email + instagram' : 'email',
+ type: d.type,
+ rationale: d.suggestion || `Curated marketing moment (${d.type}). Lean into the brand voice — ${brand.voice.slice(0, 3).join(', ')}.`,
+ source: 'calendar',
+ };
+}
+
+function templateEntryFromGap(g, startIso) {
+ // Place gap-driven plays evenly across the 4 weeks based on hash so it's
+ // deterministic between regenerations of the same signal set.
+ const seed = (g.segmentId + g.kind).split('').reduce((a, c) => a + c.charCodeAt(0), 0);
+ const dayOffset = (seed % 4) * 7 + 2; // mid-week
+ const dt = new Date(startIso + 'T00:00:00');
+ dt.setDate(dt.getDate() + dayOffset);
+ const dateIso = iso(dt.getFullYear(), dt.getMonth() + 1, dt.getDate());
+ return {
+ week: weekOf(startIso, dateIso),
+ date: dateIso,
+ title: g.kind === 'declining'
+ ? `${g.segment} — Winback editorial`
+ : `${g.segment} — High-relevance refresh`,
+ audience: g.segmentId,
+ audienceLabel: g.segment,
+ channel: 'email',
+ type: g.kind,
+ rationale: g.note,
+ source: 'performance-gap',
+ };
+}
+
+function templateEntryFromSegmentHealth(s, startIso) {
+ // For segments that look healthy, lean in — schedule one positive-reinforcement
+ // campaign tied to a luxury product line so the cohort keeps engaging.
+ const line = brand.productLines[Math.abs(s.id.charCodeAt(0)) % brand.productLines.length];
+ const seed = s.id.charCodeAt(0) + s.id.length;
+ const dayOffset = (seed % 3) * 7 + 5;
+ const dt = new Date(startIso + 'T00:00:00');
+ dt.setDate(dt.getDate() + dayOffset);
+ const dateIso = iso(dt.getFullYear(), dt.getMonth() + 1, dt.getDate());
+ return {
+ week: weekOf(startIso, dateIso),
+ date: dateIso,
+ title: `${s.name} — ${line.charAt(0).toUpperCase() + line.slice(1)} editorial`,
+ audience: s.id,
+ audienceLabel: s.name,
+ channel: 'email',
+ type: 'segment-feature',
+ rationale: `${s.name} 30d open rate ${(s.openRate * 100).toFixed(1)}% (${s.band}). Feed momentum with a ${line}-led editorial — keep voice ${brand.voice.slice(0, 2).join(' / ')}.`,
+ source: 'segment-health',
+ };
+}
+
+function templateGenerate(signals) {
+ const { startIso, dates, segments, gaps } = signals;
+ const entries = [];
+
+ // Up to 2 calendar-driven picks (best of curated moments).
+ const tradeAndAware = dates.filter(d => ['trade', 'awareness', 'retail'].includes(d.type));
+ for (const d of tradeAndAware.slice(0, 2)) entries.push(templateEntryFromCalendar(d, startIso));
+
+ // 1 gap-driven pick if we have one.
+ for (const g of gaps.slice(0, 2)) entries.push(templateEntryFromGap(g, startIso));
+
+ // 1-2 healthy-segment leans.
+ const healthy = segments.filter(s => s.band === 'good').slice(0, 2);
+ for (const s of healthy) entries.push(templateEntryFromSegmentHealth(s, startIso));
+
+ // If we're short of 4 (sparse calendar month), fall back to a holiday/holiday-
+ // adjacent calendar pick or a generic editorial on the dominant line.
+ if (entries.length < 4) {
+ const remaining = dates.filter(d => !entries.find(e => e.date === d.date && e.title === d.title));
+ for (const d of remaining) {
+ if (entries.length >= 5) break;
+ entries.push(templateEntryFromCalendar(d, startIso));
+ }
+ }
+ if (entries.length < 4) {
+ // Synthesize a brand-tone editorial campaign per remaining week.
+ for (let w = 1; w <= 4 && entries.length < 4; w++) {
+ if (entries.find(e => e.week === w)) continue;
+ const dt = new Date(startIso + 'T00:00:00');
+ dt.setDate(dt.getDate() + (w - 1) * 7 + 3);
+ const dateIso = iso(dt.getFullYear(), dt.getMonth() + 1, dt.getDate());
+ const line = brand.productLines[(w - 1) % brand.productLines.length];
+ entries.push({
+ week: w,
+ date: dateIso,
+ title: `${line.charAt(0).toUpperCase() + line.slice(1)} editorial — Week ${w}`,
+ audience: 'designers',
+ audienceLabel: AUDIENCE_LABEL.designers,
+ channel: 'email + instagram',
+ type: 'editorial',
+ rationale: `Quiet calendar week — fall back on a tactile ${line} editorial. Voice: ${brand.voice.slice(0, 3).join(', ')}.`,
+ source: 'fallback-editorial',
+ });
+ }
+ }
+
+ // Trim to 6 and sort by date.
+ entries.sort((a, b) => (a.date || '').localeCompare(b.date || ''));
+ return entries.slice(0, 6);
+}
+
+// ── Gemini generator ────────────────────────────────────────────────────────
+const GEMINI_MODEL = 'gemini-2.0-flash';
+function geminiKey() {
+ return process.env.GEMINI_API_KEY || process.env.GEMINI_KEY || '';
+}
+
+function geminiPrompt(signals) {
+ return [
+ 'You are the marketing planner for Designer Wallcoverings, a luxury wallcoverings house.',
+ `Brand voice: ${brand.voice.join(', ')}. Tagline: "${brand.tagline}".`,
+ `Audiences: ${brand.audiences.join('; ')}.`,
+ `Product lines: ${brand.productLines.join(', ')}.`,
+ '',
+ 'Build a 4-WEEK marketing playbook starting ' + signals.startIso + '.',
+ 'Output 4 to 6 distinct campaigns or actions. Each must reference at least one signal below.',
+ 'Spread the dates across the 4 weeks. No two entries on the same day.',
+ '',
+ 'SIGNAL — Curated marketing moments in the window:',
+ ...signals.dates.slice(0, 12).map(d => ` • ${d.date} (${d.type}) ${d.title} — ${d.suggestion || ''}`),
+ '',
+ 'SIGNAL — Segment health (30-day open rate, trend % vs prior 30d):',
+ ...signals.segments.map(s => ` • ${s.name}: ${(s.openRate * 100).toFixed(1)}% open · trend ${s.pctChange30d}% · band ${s.band} · list ${s.listSize}`),
+ '',
+ 'SIGNAL — Performance gaps:',
+ ...signals.gaps.map(g => ` • ${g.kind.toUpperCase()} — ${g.note}`),
+ '',
+ 'Return ONLY a JSON array. No prose, no markdown fence. Schema for each entry:',
+ '{',
+ ' "date": "YYYY-MM-DD", // must fall inside the 28-day window',
+ ' "week": 1|2|3|4,',
+ ' "title": "...",',
+ ' "audience": "designers"|"commercial"|"retail"|"at-risk",',
+ ' "channel": "email"|"instagram"|"email + instagram"|"tiktok",',
+ ' "type": "campaign"|"editorial"|"winback"|"trade"|"holiday"|"retail",',
+ ' "rationale": "1-2 sentence justification referencing the signal",',
+ ' "source": "calendar"|"segment-health"|"performance-gap"',
+ '}',
+ ].join('\n');
+}
+
+async function geminiGenerate(signals) {
+ const key = geminiKey();
+ if (!key) throw new Error('no_gemini_key');
+ if (typeof fetch !== 'function') throw new Error('fetch_unavailable');
+ const url = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent?key=${encodeURIComponent(key)}`;
+ const body = {
+ contents: [{ role: 'user', parts: [{ text: geminiPrompt(signals) }] }],
+ generationConfig: { temperature: 0.55, maxOutputTokens: 1400, responseMimeType: 'application/json' },
+ };
+ const r = await fetch(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ const text = await r.text();
+ let json; try { json = JSON.parse(text); } catch { json = null; }
+ if (!r.ok || !json) {
+ const err = new Error(`gemini_http_${r.status}`);
+ err.detail = json || text;
+ throw err;
+ }
+ const out = json.candidates && json.candidates[0] && json.candidates[0].content
+ && json.candidates[0].content.parts && json.candidates[0].content.parts[0]
+ && json.candidates[0].content.parts[0].text;
+ if (!out) throw new Error('gemini_empty');
+ // out should be a JSON array per the prompt; tolerate stray fence anyway.
+ let arr;
+ try { arr = JSON.parse(out); }
+ catch {
+ const m = out.match(/\[[\s\S]*\]/);
+ if (!m) throw new Error('gemini_unparseable');
+ arr = JSON.parse(m[0]);
+ }
+ if (!Array.isArray(arr)) throw new Error('gemini_not_array');
+ return arr;
+}
+
+// ── entry normalization (Gemini output + edits both pass through here) ─────
+const VALID_AUDIENCES = new Set(AUDIENCES);
+const VALID_CHANNELS = new Set(['email', 'instagram', 'tiktok', 'email + instagram', 'social', 'other']);
+const VALID_STATUSES = new Set(['suggested', 'approved', 'scheduled', 'skipped', 'done']);
+
+function normalizeEntry(raw, startIso) {
+ const e = raw || {};
+ const id = e.id && /^play_/.test(e.id) ? e.id : 'play_' + crypto.randomBytes(4).toString('hex');
+ const dateOk = typeof e.date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(e.date);
+ const date = dateOk ? e.date : startIso;
+ const audience = VALID_AUDIENCES.has(e.audience) ? e.audience : 'designers';
+ return {
+ id,
+ week: Number.isInteger(e.week) && e.week >= 1 && e.week <= 4 ? e.week : weekOf(startIso, date),
+ date,
+ title: String(e.title || 'Untitled campaign').trim().slice(0, 140),
+ audience,
+ audienceLabel: AUDIENCE_LABEL[audience] || audience,
+ channel: VALID_CHANNELS.has(e.channel) ? e.channel : 'email',
+ type: String(e.type || 'campaign').trim().slice(0, 40),
+ rationale: String(e.rationale || '').trim().slice(0, 800),
+ source: ['calendar', 'segment-health', 'performance-gap', 'fallback-editorial', 'manual', 'gemini'].includes(e.source) ? e.source : 'gemini',
+ status: VALID_STATUSES.has(e.status) ? e.status : 'suggested',
+ notes: String(e.notes || '').trim().slice(0, 1000),
+ updatedAt: new Date().toISOString(),
+ };
+}
+
+// ── unified generate ────────────────────────────────────────────────────────
+async function generatePlaybook(signals) {
+ let raw, mode = 'gemini', error = null;
+ if (geminiKey()) {
+ try { raw = await geminiGenerate(signals); }
+ catch (e) {
+ error = e.message;
+ raw = templateGenerate(signals);
+ mode = 'template-fallback';
+ }
+ } else {
+ raw = templateGenerate(signals);
+ mode = 'template';
+ }
+ const entries = (Array.isArray(raw) ? raw : [])
+ .slice(0, 6)
+ .map(r => normalizeEntry(r, signals.startIso));
+ // Re-sort + de-dup same-date+title in case Gemini repeated itself.
+ const seen = new Set();
+ const unique = [];
+ for (const e of entries.sort((a, b) => a.date.localeCompare(b.date))) {
+ const k = `${e.date}|${e.title.toLowerCase()}`;
+ if (seen.has(k)) continue;
+ seen.add(k); unique.push(e);
+ }
+ // If Gemini returned too few, top up with template entries.
+ if (unique.length < 4) {
+ const filler = templateGenerate(signals)
+ .map(r => normalizeEntry(r, signals.startIso))
+ .filter(t => !seen.has(`${t.date}|${t.title.toLowerCase()}`));
+ for (const t of filler) {
+ if (unique.length >= 6) break;
+ unique.push(t);
+ }
+ unique.sort((a, b) => a.date.localeCompare(b.date));
+ }
+ return { mode, error, entries: unique };
+}
+
+// ── module ──────────────────────────────────────────────────────────────────
+module.exports = {
+ id: 'playbook',
+ title: 'Monthly Playbook',
+ icon: '📅',
+
+ // exposed for tests / sibling reuse
+ _gatherSignals: gatherSignals,
+ _templateGenerate: templateGenerate,
+ _normalizeEntry: normalizeEntry,
+
+ mount(router) {
+ const guard = handler => async (req, res) => {
+ try { await handler(req, res); }
+ catch (e) { res.status(500).json({ ok: false, error: e.message }); }
+ };
+
+ readStore(); // touch the file so a fresh deploy has shape
+
+ // GET /meta — module description + whether Gemini is keyed
+ router.get('/meta', guard(async (_req, res) => {
+ res.json({
+ staged: true,
+ mock: !geminiKey(),
+ geminiKeyed: !!geminiKey(),
+ geminiModel: GEMINI_MODEL,
+ audiences: AUDIENCES.map(id => ({ id, label: AUDIENCE_LABEL[id] })),
+ statuses: [...VALID_STATUSES],
+ channels: [...VALID_CHANNELS],
+ sources: ['calendar', 'segment-health', 'performance-gap', 'fallback-editorial', 'manual', 'gemini'],
+ gateMessage:
+ 'Monthly Playbook is SUGGESTIONS ONLY. It generates 4–6 campaign/action ideas for the next 4 weeks ' +
+ 'from calendar moments, segment health, and performance gaps. Entries are editable and persistent. ' +
+ 'Nothing here schedules or sends anything — promoting a suggestion to a real plan still goes through ' +
+ 'the Calendar module (and any actual send is gated separately in Constant Contact / Channels).',
+ brand: { voice: brand.voice, tagline: brand.tagline },
+ });
+ }));
+
+ // GET /signals?start=YYYY-MM-DD — what we'd feed the generator right now
+ router.get('/signals', guard(async (req, res) => {
+ const start = typeof req.query.start === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(req.query.start)
+ ? req.query.start
+ : monthIdToWindow(typeof req.query.month === 'string' ? req.query.month : thisMonthId());
+ const days = Math.max(7, Math.min(56, Number(req.query.days) || 28));
+ res.json({ staged: true, signals: gatherSignals(start, days) });
+ }));
+
+ // GET /playbook?month=YYYY-MM — read the persisted playbook for a month
+ router.get('/playbook', guard(async (req, res) => {
+ const monthId = typeof req.query.month === 'string' && /^\d{4}-\d{2}$/.test(req.query.month)
+ ? req.query.month
+ : thisMonthId();
+ const store = readStore();
+ const pb = store.playbooks[monthId] || null;
+ res.json({ staged: true, monthId, playbook: pb });
+ }));
+
+ // GET /full — full store (for export / debugging)
+ router.get('/full', guard(async (_req, res) => {
+ res.json({ staged: true, store: readStore() });
+ }));
+
+ // POST /generate { month?: 'YYYY-MM', start?: 'YYYY-MM-DD' } — regenerate
+ router.post('/generate', guard(async (req, res) => {
+ const body = req.body || {};
+ const monthId = typeof body.month === 'string' && /^\d{4}-\d{2}$/.test(body.month)
+ ? body.month
+ : thisMonthId();
+ const start = typeof body.start === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(body.start)
+ ? body.start
+ : monthIdToWindow(monthId);
+ const signals = gatherSignals(start, 28);
+ const { mode, error, entries } = await generatePlaybook(signals);
+ const pb = {
+ monthId,
+ startIso: start,
+ generatedAt: new Date().toISOString(),
+ mode, // 'gemini' | 'template' | 'template-fallback'
+ generatorError: error || null, // populated when Gemini failed → template kicked in
+ signals,
+ entries,
+ };
+ const store = readStore();
+ store.playbooks[monthId] = pb;
+ writeStore(store);
+ res.json({ staged: true, mock: !geminiKey() || mode !== 'gemini', monthId, playbook: pb });
+ }));
+
+ // POST /entry { month, ...entry } — add or upsert a single entry
+ router.post('/entry', guard(async (req, res) => {
+ const body = req.body || {};
+ const monthId = typeof body.month === 'string' && /^\d{4}-\d{2}$/.test(body.month)
+ ? body.month
+ : thisMonthId();
+ const store = readStore();
+ const pb = store.playbooks[monthId] || {
+ monthId, startIso: monthIdToWindow(monthId), generatedAt: new Date().toISOString(),
+ mode: 'manual', generatorError: null,
+ signals: gatherSignals(monthIdToWindow(monthId), 28),
+ entries: [],
+ };
+ const entry = normalizeEntry({ ...body, source: body.source || 'manual' }, pb.startIso);
+ const i = pb.entries.findIndex(e => e.id === entry.id);
+ if (i >= 0) pb.entries[i] = { ...pb.entries[i], ...entry };
+ else pb.entries.push(entry);
+ pb.entries.sort((a, b) => a.date.localeCompare(b.date));
+ store.playbooks[monthId] = pb;
+ writeStore(store);
+ res.json({ staged: true, monthId, entry, playbook: pb });
+ }));
+
+ // PUT /entry/:id?month=YYYY-MM — edit one entry's fields
+ router.put('/entry/:id', guard(async (req, res) => {
+ const monthId = typeof req.query.month === 'string' && /^\d{4}-\d{2}$/.test(req.query.month)
+ ? req.query.month
+ : thisMonthId();
+ const store = readStore();
+ const pb = store.playbooks[monthId];
+ if (!pb) return res.status(404).json({ ok: false, error: 'no playbook for that month — generate one first' });
+ const i = pb.entries.findIndex(e => e.id === req.params.id);
+ if (i < 0) return res.status(404).json({ ok: false, error: 'entry not found' });
+ const merged = normalizeEntry({ ...pb.entries[i], ...(req.body || {}), id: pb.entries[i].id }, pb.startIso);
+ pb.entries[i] = merged;
+ pb.entries.sort((a, b) => a.date.localeCompare(b.date));
+ writeStore(store);
+ res.json({ staged: true, monthId, entry: merged, playbook: pb });
+ }));
+
+ // POST /delete?month=YYYY-MM&id=... — remove an entry
+ router.post('/delete', guard(async (req, res) => {
+ const monthId = typeof req.query.month === 'string' && /^\d{4}-\d{2}$/.test(req.query.month)
+ ? req.query.month
+ : thisMonthId();
+ const id = String(req.query.id || (req.body && req.body.id) || '');
+ const store = readStore();
+ const pb = store.playbooks[monthId];
+ if (!pb) return res.status(404).json({ ok: false, error: 'no playbook for that month' });
+ const before = pb.entries.length;
+ pb.entries = pb.entries.filter(e => e.id !== id);
+ if (pb.entries.length === before) return res.status(404).json({ ok: false, error: 'entry not found' });
+ writeStore(store);
+ res.json({ staged: true, monthId, deletedId: id, playbook: pb });
+ }));
+ },
+};
diff --git a/modules/profiles/index.js b/modules/profiles/index.js
new file mode 100644
index 0000000..318f8d0
--- /dev/null
+++ b/modules/profiles/index.js
@@ -0,0 +1,539 @@
+// Contact Profiles module — a unified 360° view of one contact for the Marketing
+// Command Center. Read-only. Surfaces:
+// • who they are (name, company, role, list memberships, tags, lifecycle stage)
+// • email engagement timeline (campaign-level sent / open / click events)
+// • segment memberships (resolved against saved segments from segments.json)
+// • cross-channel placeholder: last site visit + last social touch
+//
+// Live data sources, when CTCT_ACCESS_TOKEN is present, are read via the v3 API
+// for the contact roster; per-contact event streams are not first-class in CC v3,
+// so the timeline is *derived* from lastOpen/lastClick + recent campaigns and is
+// flagged with derived:true on each event. Cross-channel data is ALWAYS a
+// placeholder (no GA4 / IG creds wired) and is flagged with placeholder:true.
+//
+// Self-contained per the MODULE CONTRACT (README.md). No writes to outside
+// systems — pure read.
+const fs = require('fs');
+const path = require('path');
+
+const DATA_DIR = path.join(__dirname, '..', '..', 'data');
+const SEG_FILE = path.join(DATA_DIR, 'segments.json');
+
+const CTCT_API = 'https://api.cc.email/v3';
+const live = () => Boolean(process.env.CTCT_ACCESS_TOKEN);
+
+// ── shared lookup vocab (kept aligned with segments + constant-contact mocks) ──
+const LISTS = ['Trade & Designers', 'Retail Newsletter', 'Memo Sample Requests', 'Hospitality / Commercial Spec'];
+const TAGS = ['grasscloth', 'silk', 'cork', 'mural', 'metallic', 'memo-cta', 'lookbook', 'high-intent', 'vip', 'architect'];
+const ROLES = ['Principal Designer', 'Senior Designer', 'Architect', 'Project Manager',
+ 'Studio Director', 'Procurement Lead', 'Spec Writer', 'Homeowner', 'Showroom Manager'];
+const COUNTRIES = ['United States', 'United States', 'United States', 'Canada', 'United Kingdom', 'Mexico'];
+const CITIES = [
+ ['New York', 'NY'], ['Los Angeles', 'CA'], ['Chicago', 'IL'], ['Austin', 'TX'],
+ ['Miami', 'FL'], ['Boston', 'MA'], ['Seattle', 'WA'], ['San Francisco', 'CA'],
+ ['Atlanta', 'GA'], ['Denver', 'CO'], ['Charleston', 'SC'], ['Toronto', 'ON'],
+ ['London', null], ['Mexico City', 'CDMX'],
+];
+const SITE_PAGES = [
+ { slug: 'grasscloth-wallcoverings', title: 'Grasscloth Wallcoverings' },
+ { slug: 'silk-wallcoverings', title: 'Silk Wallcoverings' },
+ { slug: 'cork-wallcoverings', title: 'Cork Wallcoverings' },
+ { slug: 'flocked-wallpaper', title: 'Flocked Wallpaper' },
+ { slug: 'glass-bead-wallcoverings', title: 'Glass-Bead Wallcoverings' },
+ { slug: 'trade-program', title: 'Trade Program' },
+ { slug: 'memo-samples', title: 'Request Memo Samples' },
+ { slug: 'lookbook-2026', title: '2026 Lookbook' },
+ { slug: 'product/phillip-jeffries-silver-velvet', title: 'PJ Silver Velvet — product' },
+ { slug: 'collections/hollywood-vinyls-vol-1', title: 'Hollywood Vinyls Vol. 1' },
+];
+const SOCIAL_PLATFORMS = ['instagram', 'pinterest', 'linkedin'];
+
+// Mock email campaigns the timeline can reference (aligned with constant-contact's
+// MOCK pool conceptually; we don't import to keep the module standalone).
+const MOCK_CAMPAIGNS = [
+ { id: 'mock-cmp-001', name: 'Spring Grasscloth Edit', sentAgoDays: 17 },
+ { id: 'mock-cmp-101', name: 'Cork & Linen — May Memo', sentAgoDays: 36 },
+ { id: 'mock-cmp-102', name: 'Metallic Murals Lookbook', sentAgoDays: 51 },
+ { id: 'mock-cmp-103', name: 'Trade-Only Studio Visits', sentAgoDays: 67 },
+ { id: 'mock-cmp-104', name: 'Hospitality Spec Highlights', sentAgoDays: 82 },
+ { id: 'mock-cmp-105', name: 'Glass-Bead Drop', sentAgoDays: 104 },
+];
+
+// ── deterministic mock contact pool ────────────────────────────────────────────
+// Seeded mulberry32 PRNG so the same install always sees the same contacts. Pool
+// shape is a superset of what segments.js evaluates over so segment membership
+// resolution is consistent. ~240 contacts is plenty for search demo without
+// making the panel sluggish.
+const POOL_SIZE = 240;
+
+function mulberry32(seed) {
+ let s = seed >>> 0;
+ return function () {
+ s |= 0; s = (s + 0x6d2b79f5) | 0;
+ let t = Math.imul(s ^ (s >>> 15), 1 | s);
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+}
+
+let _pool = null;
+function pool() {
+ if (_pool) return _pool;
+ const rnd = mulberry32(0xC0FFEE);
+ const pick = arr => arr[Math.floor(rnd() * arr.length)];
+
+ const studios = [
+ ['atelier-noir', 'Atelier Noir'],
+ ['maison-blanc', 'Maison Blanc'],
+ ['verdant-interiors', 'Verdant Interiors'],
+ ['harbor-hospitality', 'Harbor Hospitality Group'],
+ ['lumiere-design', 'Lumière Design Co.'],
+ ['oak-and-ash', 'Oak & Ash Studio'],
+ ['studio-meridian', 'Studio Meridian'],
+ ['thornfield-co', 'Thornfield & Co.'],
+ ['cobalt-interiors', 'Cobalt Interiors'],
+ ['gilded-room', 'The Gilded Room'],
+ ['north-star-design', 'North Star Design'],
+ ['ember-and-stone', 'Ember & Stone'],
+ ['palette-house', 'Palette House'],
+ ['crestwood-studio', 'Crestwood Studio'],
+ ['ivory-lane', 'Ivory Lane Interiors'],
+ ['monarch-interiors', 'Monarch Interiors'],
+ ['saffron-studio', 'Saffron Studio'],
+ ['westbrook-design', 'Westbrook Design Group'],
+ ['vellum-co', 'Vellum Co.'],
+ ['aria-spaces', 'Aria Spaces'],
+ ];
+ const firstNames = ['Avery', 'Bea', 'Camille', 'Dante', 'Elena', 'Felix', 'Greta', 'Henrik',
+ 'Iris', 'Jude', 'Kira', 'Leon', 'Mira', 'Nadia', 'Otto', 'Petra', 'Quinn', 'Ruth',
+ 'Soren', 'Theo', 'Una', 'Vera', 'Wren', 'Xavi', 'Yael', 'Zara'];
+ const lastNames = ['Ashford', 'Bellamy', 'Castell', 'Devereux', 'Eastwood', 'Fontaine',
+ 'Greaves', 'Holloway', 'Iverson', 'Janowicz', 'Kestrel', 'Lockhart', 'Marchetti',
+ 'Norwood', 'Okafor', 'Pemberton', 'Quill', 'Rowan', 'Saint-Clair', 'Thackeray',
+ 'Underhill', 'Vance', 'Whitlock', 'Yardley', 'Zborowski'];
+ const tlds = ['com', 'co', 'studio', 'design'];
+
+ const now = Date.now();
+ const DAY = 86400000;
+ const ago = d => d == null ? null : new Date(now - d * DAY).toISOString();
+
+ const out = [];
+ for (let i = 0; i < POOL_SIZE; i++) {
+ const [studioSlug, studioName] = studios[i % studios.length];
+ const isTrade = rnd() < 0.6;
+ const type = isTrade ? 'trade' : 'retail';
+
+ const first = pick(firstNames);
+ const last = pick(lastNames);
+ const role = isTrade
+ ? pick(['Principal Designer', 'Senior Designer', 'Architect', 'Project Manager',
+ 'Studio Director', 'Procurement Lead', 'Spec Writer', 'Showroom Manager'])
+ : pick(['Homeowner', 'Project Manager', 'Spec Writer']);
+ const [city, region] = pick(CITIES);
+ const country = region == null ? pick(['United Kingdom', 'United Kingdom', 'Mexico']) : pick(COUNTRIES);
+
+ const lists = [];
+ if (isTrade) {
+ lists.push('Trade & Designers');
+ if (rnd() < 0.35) lists.push('Hospitality / Commercial Spec');
+ } else {
+ lists.push('Retail Newsletter');
+ }
+ if (rnd() < 0.30) lists.push('Memo Sample Requests');
+
+ // engagement recency mirrors the segments module distribution
+ let openAgo, clickAgo;
+ const r = rnd();
+ if (r < 0.18) { openAgo = null; clickAgo = null; }
+ else if (r < 0.40) { openAgo = 90 + Math.floor(rnd() * 200);
+ clickAgo = rnd() < 0.3 ? openAgo + Math.floor(rnd() * 10) : null; }
+ else { openAgo = Math.floor(rnd() * 60);
+ clickAgo = rnd() < 0.55 ? openAgo + Math.floor(rnd() * 8) : null; }
+
+ const tags = [];
+ const nTags = Math.floor(rnd() * 3);
+ for (let t = 0; t < nTags; t++) {
+ const tg = pick(TAGS);
+ if (!tags.includes(tg)) tags.push(tg);
+ }
+ if (clickAgo != null && clickAgo <= 30 && rnd() < 0.5 && !tags.includes('memo-cta')) tags.push('memo-cta');
+ if (isTrade && rnd() < 0.15 && !tags.includes('vip')) tags.push('vip');
+ if (role === 'Architect' && !tags.includes('architect')) tags.push('architect');
+
+ // opens/clicks counts correlate to recency (same logic the segments RFM uses)
+ let opens = 0, clicks = 0;
+ if (openAgo != null) {
+ if (openAgo <= 14) opens = 6 + Math.floor(rnd() * 9);
+ else if (openAgo <= 30) opens = 4 + Math.floor(rnd() * 6);
+ else if (openAgo <= 60) opens = 2 + Math.floor(rnd() * 4);
+ else if (openAgo <= 120) opens = 1 + Math.floor(rnd() * 3);
+ else opens = Math.floor(rnd() * 2);
+ }
+ if (clickAgo != null) {
+ if (clickAgo <= 14) clicks = 2 + Math.floor(rnd() * 5);
+ else if (clickAgo <= 30) clicks = 1 + Math.floor(rnd() * 4);
+ else if (clickAgo <= 60) clicks = 1 + Math.floor(rnd() * 2);
+ else clicks = Math.floor(rnd() * 2);
+ }
+
+ const handle = `${pick(['hello', 'studio', 'projects', 'spec', 'design', 'info'])}+${i}`;
+ const email = `${handle}@${studioSlug}.${pick(tlds)}`;
+
+ // last site visit + social: cross-channel placeholders. Most engaged contacts
+ // get a recent visit; lapsed ones may not.
+ const visited = openAgo != null && rnd() < 0.85;
+ const visitAgo = visited ? Math.max(0, (openAgo || 30) + Math.floor(rnd() * 8) - 4) : null;
+ const page = visited ? pick(SITE_PAGES) : null;
+
+ const hasSocial = rnd() < 0.55;
+ const social = hasSocial ? {
+ platform: pick(SOCIAL_PLATFORMS),
+ handle: `@${first.toLowerCase()}.${last.toLowerCase().replace(/[^a-z]/g, '')}`.slice(0, 30),
+ lastTouch: ago(Math.floor(rnd() * 120)),
+ action: pick(['liked a post', 'commented', 'saved a post', 'DM\'d', 'followed']),
+ } : null;
+
+ out.push({
+ id: `prof_${i.toString(36).padStart(3, '0')}`,
+ email,
+ name: `${first} ${last}`,
+ firstName: first,
+ lastName: last,
+ company: studioName,
+ role,
+ type,
+ location: { city, region, country },
+ lists,
+ tags,
+ lastOpen: ago(openAgo),
+ lastClick: ago(clickAgo),
+ opens,
+ clicks,
+ lastSiteVisit: visited ? {
+ at: ago(visitAgo),
+ page: page.slug,
+ pageTitle: page.title,
+ source: pick(['email', 'google', 'instagram', 'direct', 'pinterest']),
+ placeholder: true,
+ } : null,
+ social: social ? { ...social, placeholder: true } : null,
+ // a stable "joined" date in the past 0..900 days
+ since: ago(30 + Math.floor(rnd() * 870)),
+ });
+ }
+ _pool = out;
+ return out;
+}
+
+// ── live CTCT contact pull (best-effort) ───────────────────────────────────────
+async function ctct(pathname) {
+ 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;
+ const r = await fetch(`${CTCT_API}${pathname}`, { headers });
+ const text = await r.text();
+ let json; try { json = text ? JSON.parse(text) : {}; } catch { json = {}; }
+ if (!r.ok) { const e = new Error(`CC GET ${pathname} → ${r.status}`); e.status = r.status; e.detail = json; throw e; }
+ return json;
+}
+
+function mapContact(c, listNameById, i) {
+ const email = (c.email_address && c.email_address.address) || c.email || '';
+ const lists = (c.list_memberships || []).map(id => listNameById[id]).filter(Boolean);
+ const isTrade = lists.some(n => /trade|designer|spec|commercial|hospitality/i.test(n));
+ const tags = (c.taggings || c.tags || [])
+ .map(t => (typeof t === 'string' ? t : (t.name || t.tag_id)))
+ .filter(Boolean);
+ const lastOpen = c.last_open_date || null;
+ const lastClick = c.last_click_date || null;
+ const fn = c.first_name || '';
+ const ln = c.last_name || '';
+ const name = (`${fn} ${ln}`).trim() || email.split('@')[0];
+ const company = c.company_name || (email.split('@')[1] || '').split('.')[0] || '';
+ return {
+ id: `cc_${c.contact_id || c.id || i}`,
+ email,
+ name,
+ firstName: fn,
+ lastName: ln,
+ company,
+ role: c.job_title || '',
+ type: isTrade ? 'trade' : 'retail',
+ location: {
+ city: (c.street_addresses && c.street_addresses[0] && c.street_addresses[0].city) || '',
+ region: (c.street_addresses && c.street_addresses[0] && c.street_addresses[0].state) || '',
+ country: (c.street_addresses && c.street_addresses[0] && c.street_addresses[0].country) || '',
+ },
+ lists,
+ tags,
+ lastOpen,
+ lastClick,
+ opens: Number.isFinite(c.opens_count) ? c.opens_count : 0,
+ clicks: Number.isFinite(c.clicks_count) ? c.clicks_count : 0,
+ lastSiteVisit: null, // GA4 not wired — kept null for live mode
+ social: null, // IG/LI not wired — kept null for live mode
+ since: c.created_at || null,
+ };
+}
+
+async function contactPool() {
+ if (!live()) return { pool: pool(), mock: true };
+ try {
+ const listsResp = await ctct('/contact_lists?include_count=false&limit=100');
+ const listNameById = {};
+ for (const l of (listsResp.lists || [])) listNameById[l.list_id || l.id] = l.name;
+ const data = await ctct('/contacts?limit=200&include=list_memberships,taggings,custom_fields');
+ const p = (data.contacts || []).map((c, i) => mapContact(c, listNameById, i)).filter(c => c.email);
+ if (!p.length) return { pool: pool(), mock: true };
+ return { pool: p, mock: false };
+ } catch {
+ return { pool: pool(), mock: true };
+ }
+}
+
+// ── derive an engagement timeline from a contact + campaign list ───────────────
+// CC v3 doesn't expose per-contact event streams cheaply, so we synthesise a
+// realistic timeline from lastOpen / lastClick / counts against the recent
+// campaigns. Each event carries derived:true so the UI can disclose it.
+function timelineFor(contact, campaigns = MOCK_CAMPAIGNS, useMock = true) {
+ const events = [];
+ const lastOpenAgo = daysSince(contact.lastOpen);
+ const lastClickAgo = daysSince(contact.lastClick);
+
+ for (const c of campaigns) {
+ const sentAt = isoDaysAgo(c.sentAgoDays);
+ events.push({ at: sentAt, type: 'sent', campaignId: c.id, campaignName: c.name, derived: useMock });
+
+ // "open" if the campaign was sent before lastOpen and within a ~30d window of it
+ const opened = isFiniteNum(lastOpenAgo) && c.sentAgoDays >= lastOpenAgo && c.sentAgoDays - lastOpenAgo <= 35;
+ if (opened) {
+ const openAtAgo = Math.max(0, c.sentAgoDays - Math.floor(Math.random() * 2));
+ events.push({ at: isoDaysAgo(openAtAgo), type: 'open', campaignId: c.id, campaignName: c.name, derived: true });
+
+ // "click" if there's a recent click that aligns
+ const clicked = isFiniteNum(lastClickAgo) && Math.abs(c.sentAgoDays - lastClickAgo) <= 35
+ && contact.clicks > 0;
+ if (clicked) {
+ events.push({
+ at: isoDaysAgo(Math.max(0, openAtAgo - 0)),
+ type: 'click', campaignId: c.id, campaignName: c.name,
+ link: pickStable(['/grasscloth-wallcoverings', '/silk-wallcoverings', '/memo-samples', '/trade-program', '/lookbook-2026'], c.id),
+ derived: true,
+ });
+ }
+ }
+ }
+
+ // sort newest-first
+ events.sort((a, b) => Date.parse(b.at) - Date.parse(a.at));
+ return events;
+}
+
+function isFiniteNum(n) { return typeof n === 'number' && Number.isFinite(n); }
+function daysSince(iso) {
+ if (!iso) return Infinity;
+ const t = Date.parse(iso);
+ return Number.isNaN(t) ? Infinity : (Date.now() - t) / 86400000;
+}
+function isoDaysAgo(d) {
+ if (d == null) return null;
+ return new Date(Date.now() - d * 86400000).toISOString();
+}
+// Deterministic pick — same campaign id → same link, so a re-render doesn't shuffle.
+function pickStable(arr, key) {
+ let h = 0;
+ for (const ch of String(key)) h = (h * 31 + ch.charCodeAt(0)) | 0;
+ return arr[Math.abs(h) % arr.length];
+}
+
+// ── segment membership resolver ────────────────────────────────────────────────
+// We reuse the segment rule schema rather than require()-ing the segments module
+// (modules stay independent). Identical evaluation semantics.
+function loadSegments() {
+ try { return JSON.parse(fs.readFileSync(SEG_FILE, 'utf8')) || []; }
+ catch { return []; }
+}
+
+function evalRule(contact, rule) {
+ if (!rule || !rule.field) return true;
+ const { field, op, value } = rule;
+ switch (field) {
+ case 'type':
+ return op === 'is_not' ? contact.type !== value : contact.type === value;
+ case 'list': {
+ const has = (contact.lists || []).includes(value);
+ return op === 'not_in' ? !has : has;
+ }
+ case 'tag': {
+ const has = (contact.tags || []).includes(value);
+ return op === 'not_has' ? !has : has;
+ }
+ case 'engagement': {
+ const o = daysSince(contact.lastOpen);
+ const c = daysSince(contact.lastClick);
+ switch (value) {
+ case 'opened-last-30d': return o <= 30;
+ case 'clicked-last-30d': return c <= 30;
+ case 'clicked': return c !== Infinity;
+ case 'lapsed-90d': return o > 90;
+ case 'never-opened': return o === Infinity;
+ default: return true;
+ }
+ }
+ default: return true;
+ }
+}
+function matchesAll(contact, rules) {
+ if (!Array.isArray(rules) || !rules.length) return true;
+ return rules.every(r => evalRule(contact, r));
+}
+
+function segmentMembershipsFor(contact) {
+ return loadSegments()
+ .filter(seg => matchesAll(contact, seg.rules || []))
+ .map(seg => ({ id: seg.id, name: seg.name, description: seg.description || '' }));
+}
+
+// ── lifecycle stage (a friendlier rollup than the raw engagement field) ────────
+function lifecycleFor(contact) {
+ const o = daysSince(contact.lastOpen);
+ const c = daysSince(contact.lastClick);
+ if (o === Infinity) return { stage: 'Never engaged', tone: 'cold' };
+ if (c <= 30) return { stage: 'High-intent', tone: 'hot' };
+ if (o <= 30) return { stage: 'Engaged', tone: 'warm' };
+ if (o <= 60) return { stage: 'Recently active', tone: 'warm' };
+ if (o <= 90) return { stage: 'Cooling', tone: 'neutral' };
+ return { stage: 'Lapsed', tone: 'cold' };
+}
+
+// ── summary row (used by /search and /list) ────────────────────────────────────
+function summarize(contact) {
+ return {
+ id: contact.id,
+ email: contact.email,
+ name: contact.name,
+ company: contact.company,
+ role: contact.role,
+ type: contact.type,
+ lastOpen: contact.lastOpen,
+ opens: contact.opens,
+ clicks: contact.clicks,
+ lifecycle: lifecycleFor(contact).stage,
+ };
+}
+
+// ── module ─────────────────────────────────────────────────────────────────────
+module.exports = {
+ id: 'profiles',
+ title: 'Contact Profiles',
+ icon: '👤',
+
+ // exported for tests
+ _pool: pool,
+ _timelineFor: timelineFor,
+ _segmentMembershipsFor: segmentMembershipsFor,
+ _lifecycleFor: lifecycleFor,
+
+ 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 /list — recent / most-engaged contacts (default landing list)
+ router.get('/list', guard(async (req, res) => {
+ const { pool, mock } = await contactPool();
+ const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 25, 1), 100);
+ // sort by lastOpen desc (most recently engaged first)
+ const sorted = [...pool].sort((a, b) =>
+ (Date.parse(b.lastOpen || 0) || 0) - (Date.parse(a.lastOpen || 0) || 0));
+ res.json({
+ mock,
+ total: pool.length,
+ contacts: sorted.slice(0, limit).map(summarize),
+ });
+ }));
+
+ // GET /search?q=... — case-insensitive search across email/name/company/role/tags
+ router.get('/search', guard(async (req, res) => {
+ const q = String(req.query.q || '').trim().toLowerCase();
+ const { pool, mock } = await contactPool();
+ if (!q) {
+ return res.json({ mock, query: '', total: pool.length, count: 0, contacts: [] });
+ }
+ const hits = pool.filter(c => {
+ const hay = [
+ c.email, c.name, c.company, c.role,
+ ...(c.tags || []),
+ ...(c.lists || []),
+ ].join(' ').toLowerCase();
+ return hay.includes(q);
+ });
+ res.json({
+ mock,
+ query: q,
+ total: pool.length,
+ count: hits.length,
+ contacts: hits.slice(0, 25).map(summarize),
+ });
+ }));
+
+ // GET /contact?email=... OR /contact?id=...
+ // Full 360 view: profile, lifecycle, lists, tags, segment memberships,
+ // engagement timeline, last site visit, social cross-channel.
+ router.get('/contact', guard(async (req, res) => {
+ const email = String(req.query.email || '').trim().toLowerCase();
+ const id = String(req.query.id || '').trim();
+ if (!email && !id) {
+ return res.status(400).json({ error: 'email or id is required' });
+ }
+ const { pool, mock } = await contactPool();
+ const contact = pool.find(c =>
+ (id && c.id === id) || (email && (c.email || '').toLowerCase() === email));
+ if (!contact) return res.status(404).json({ error: 'contact not found' });
+
+ const lifecycle = lifecycleFor(contact);
+ const segments = segmentMembershipsFor(contact);
+ const timeline = timelineFor(contact, MOCK_CAMPAIGNS, mock);
+ // condense timeline into top-line counts the UI uses for headline stats
+ const counts = timeline.reduce((a, e) => { a[e.type] = (a[e.type] || 0) + 1; return a; },
+ { sent: 0, open: 0, click: 0 });
+
+ res.json({
+ mock,
+ contact: {
+ id: contact.id,
+ email: contact.email,
+ name: contact.name,
+ company: contact.company,
+ role: contact.role,
+ type: contact.type,
+ location: contact.location,
+ since: contact.since,
+ lists: contact.lists,
+ tags: contact.tags,
+ opens: contact.opens,
+ clicks: contact.clicks,
+ lastOpen: contact.lastOpen,
+ lastClick: contact.lastClick,
+ },
+ lifecycle,
+ segments,
+ timeline,
+ timelineCounts: counts,
+ crossChannel: {
+ lastSiteVisit: contact.lastSiteVisit,
+ social: contact.social,
+ // explicit so the UI can render a "wire GA4 / IG to fill these in" note
+ note: 'Cross-channel signals are placeholders — wire GA4 + social tokens to populate.',
+ placeholder: true,
+ },
+ });
+ }));
+ },
+};
diff --git a/modules/registry.js b/modules/registry.js
index 565bc01..05d6db6 100644
--- a/modules/registry.js
+++ b/modules/registry.js
@@ -17,4 +17,12 @@ module.exports = [
'segments', // audience segments: rule builder + live preview over contacts
'vendors', // vendor IG reporting: DW + all vendor Instagram accounts roster
'channels', // publishing engine: FB/IG/TikTok/YouTube connectors + composer (gated live posts)
+ 'send-times', // send-time optimization: heatmap + per-contact predicted peak open window
+ 'ab-tests', // A/B test planner: two variants + holdout, predicted opens/clicks, winner call (STAGED)
+ 'profiles', // contact 360: search + email engagement timeline + segment memberships + cross-channel placeholder (read-only)
+ 'segment-perf', // segment performance: email KPIs split by audience (Designers/Commercial/Retail/At-Risk), 90d SVG trends, health colour-coding
+ 'templates', // campaign templates: 8 pre-built DW luxury email campaigns (welcome, memo D0/D7/D21, VIP preview, seasonal, re-engagement) — STAGED
+ 'journeys', // cross-channel journeys: email → wait → SMS/social → retarget per segment, persisted, with recommendations + dry-run (STAGED)
+ 'browse-abandon', // browse-abandonment drip: Day1 storytelling → Day14 room-setting → Day30 suppress, mock events + dry-run preview (STAGED)
+ 'playbook', // monthly playbook: 4-6 suggested campaigns/actions for next 4 weeks from calendar + segment health + perf gaps (Gemini or template)
];
diff --git a/modules/segment-perf/index.js b/modules/segment-perf/index.js
new file mode 100644
index 0000000..5ccb5f9
--- /dev/null
+++ b/modules/segment-perf/index.js
@@ -0,0 +1,307 @@
+// Segment Performance module — splits email KPIs by audience segment so the
+// marketing lead can see at a glance which cohorts are healthy vs. tanking and
+// where revenue per recipient actually lives. Read-only analysis; never sends.
+//
+// KPIs surfaced per segment:
+// • open rate (opens / sends)
+// • CTOR (clicks / opens — engagement quality of openers)
+// • click rate (clicks / sends — overall response)
+// • est revenue / recipient (orders × AOV / sends)
+//
+// Each KPI is colour-coded against luxury B2B email benchmarks (see HEALTH). A
+// 90-day daily trend per segment + metric powers the SVG bar chart panel-side.
+//
+// Data: deterministic mock (CTCT reporting + Shopify revenue attribution not
+// wired here). Every response carries { mock: true } so the panel can banner.
+//
+// The four segments mirror DW's real audience lens:
+// designers — interior designers / trade (mainstream luxury residential)
+// commercial — hospitality, healthcare, corporate spec / Type II vinyls
+// retail — direct-to-consumer newsletter readers
+// at-risk — historically engaged contacts who've gone cold (winback)
+
+const SEGMENTS = [
+ {
+ id: 'designers',
+ name: 'Interior Designers',
+ description: 'Trade designers + residential specifiers — the core luxury book of business.',
+ icon: '🪷',
+ listSize: 2840,
+ sendsPerCampaign: 2620,
+ // realistic baselines for a strong trade list
+ baseOpenRate: 0.42, // 42%
+ baseCTOR: 0.155, // 15.5%
+ aov: 1480, // avg order value for a trade specifier
+ orderRate: 0.014, // 1.4% of recipients buy within attribution window
+ },
+ {
+ id: 'commercial',
+ name: 'Commercial / Hospitality',
+ description: 'Architects + hospitality / healthcare spec — fewer eyes but high project value.',
+ icon: '🏛',
+ listSize: 612,
+ sendsPerCampaign: 590,
+ baseOpenRate: 0.385,
+ baseCTOR: 0.122,
+ aov: 6200, // big contract project
+ orderRate: 0.006, // rare but huge
+ },
+ {
+ id: 'retail',
+ name: 'Retail',
+ description: 'Direct-to-consumer newsletter — broadest list, lowest intent.',
+ icon: '🛍',
+ listSize: 7415,
+ sendsPerCampaign: 7080,
+ baseOpenRate: 0.275,
+ baseCTOR: 0.072,
+ aov: 340,
+ orderRate: 0.0085,
+ },
+ {
+ id: 'at-risk',
+ name: 'At-Risk (Winback)',
+ description: 'Previously engaged contacts that have gone cold — winback campaigns target these.',
+ icon: '⚠',
+ listSize: 1190,
+ sendsPerCampaign: 1150,
+ baseOpenRate: 0.082,
+ baseCTOR: 0.034,
+ aov: 410,
+ orderRate: 0.0011,
+ },
+];
+
+// Health thresholds — luxury B2B email standards. Anything below `bad` is red,
+// between bad and good is amber, at-or-above good is green. Higher-is-better
+// for every metric we track.
+const HEALTH = {
+ openRate: { bad: 0.20, good: 0.35 },
+ ctor: { bad: 0.08, good: 0.13 },
+ clickRate: { bad: 0.025, good: 0.055 },
+ revPerRecipient: { bad: 0.50, good: 2.00 },
+};
+
+function healthBand(metric, value) {
+ const h = HEALTH[metric];
+ if (!h) return 'unknown';
+ if (value < h.bad) return 'bad';
+ if (value < h.good) return 'ok';
+ return 'good';
+}
+
+// ── deterministic PRNG (mulberry32) — same generator used by the other mock
+// modules in this app so behaviour stays stable across calls.
+function mulberry32(seed) {
+ let s = seed >>> 0;
+ return function () {
+ s |= 0; s = (s + 0x6d2b79f5) | 0;
+ let t = Math.imul(s ^ (s >>> 15), 1 | s);
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+}
+function hashSeed(str) {
+ let h = 2166136261 >>> 0;
+ for (let i = 0; i < str.length; i++) {
+ h ^= str.charCodeAt(i);
+ h = Math.imul(h, 16777619) >>> 0;
+ }
+ return h >>> 0;
+}
+
+const pad = n => String(n).padStart(2, '0');
+const isoDay = d => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
+function dayList(days, end = new Date()) {
+ const out = [];
+ const base = new Date(end.getFullYear(), end.getMonth(), end.getDate());
+ for (let i = days - 1; i >= 0; i--) {
+ out.push(isoDay(new Date(base.getTime() - i * 86400000)));
+ }
+ return out;
+}
+
+// Generate a 90-day trend for one segment+metric. Approach: ~9 campaigns spaced
+// evenly across the window (one every ~10 days). Each campaign yields a per-send
+// metric value; non-campaign days inherit the previous campaign's value so the
+// SVG bars read as a step-down history. At-Risk gets a slight downward drift to
+// look like an audience genuinely going cold.
+function trendFor(segment, metric, days = 90) {
+ const dates = dayList(days);
+ const rnd = mulberry32(hashSeed(`${segment.id}|${metric}|v2`));
+ const nCampaigns = Math.max(2, Math.round(days / 10));
+ // pick campaign dates (indices into `dates`) — evenly distributed
+ const campIdxs = [];
+ for (let i = 0; i < nCampaigns; i++) {
+ campIdxs.push(Math.round(((i + 0.5) / nCampaigns) * (days - 1)));
+ }
+
+ // Step 1: compute campaign-day values
+ const campValues = campIdxs.map((idx, i) => {
+ // jitter ±18% on the base rate
+ const jitter = 0.82 + 0.36 * rnd();
+ // slight directional drift across the window
+ const driftSlope =
+ segment.id === 'at-risk' ? -0.25 : // going cold
+ segment.id === 'commercial' ? +0.12 : // tightening up
+ segment.id === 'designers' ? +0.05 : // mild improvement
+ -0.04; // retail mostly flat
+ const drift = 1 + driftSlope * ((i / Math.max(1, nCampaigns - 1)) - 0.5);
+
+ let value;
+ if (metric === 'openRate') {
+ value = segment.baseOpenRate * jitter * drift;
+ } else if (metric === 'ctor') {
+ value = segment.baseCTOR * jitter * drift;
+ } else if (metric === 'clickRate') {
+ // click rate = open rate × CTOR with their own jitter
+ const o = segment.baseOpenRate * (0.85 + 0.30 * rnd());
+ const c = segment.baseCTOR * (0.85 + 0.30 * rnd());
+ value = o * c * drift;
+ } else if (metric === 'revPerRecipient') {
+ // expected dollars per recipient = orderRate × AOV, with project-deal noise
+ const orderRate = segment.orderRate * (0.6 + 1.0 * rnd()) * drift;
+ value = orderRate * segment.aov;
+ } else {
+ value = 0;
+ }
+ return value;
+ });
+
+ // Step 2: build per-day series by holding the last campaign value
+ const series = [];
+ let lastVal = campValues[0];
+ let lastCampIdx = -1;
+ for (let i = 0; i < days; i++) {
+ const ci = campIdxs.indexOf(i);
+ if (ci >= 0) { lastVal = campValues[ci]; lastCampIdx = ci; }
+ series.push({
+ date: dates[i],
+ value: +lastVal.toFixed(metric === 'revPerRecipient' ? 3 : 4),
+ isCampaign: ci >= 0,
+ campaignIndex: lastCampIdx,
+ });
+ }
+
+ const mean = campValues.reduce((a, b) => a + b, 0) / campValues.length;
+ // 30-day trend: avg of last 30 campaign-day values vs first 30
+ const last30 = series.slice(-30).map(s => s.value);
+ const first30 = series.slice(0, 30).map(s => s.value);
+ const lastAvg = last30.reduce((a, b) => a + b, 0) / last30.length;
+ const firstAvg = first30.reduce((a, b) => a + b, 0) / first30.length;
+ const pctChange = firstAvg ? +(((lastAvg - firstAvg) / firstAvg) * 100).toFixed(1) : 0;
+
+ return { series, mean: +mean.toFixed(4), campaignDays: campIdxs.map(i => dates[i]), pctChange };
+}
+
+// Headline KPI block for one segment — uses the last-30-day average from each
+// metric's trend so the headline matches the chart.
+function kpisFor(segment) {
+ const openTrend = trendFor(segment, 'openRate');
+ const ctorTrend = trendFor(segment, 'ctor');
+ const clickTrend = trendFor(segment, 'clickRate');
+ const revTrend = trendFor(segment, 'revPerRecipient');
+
+ // 30-day windowed averages (chart-aligned)
+ const avg = arr => arr.reduce((a, b) => a + b, 0) / arr.length;
+ const openRate = avg(openTrend.series.slice(-30).map(s => s.value));
+ const ctor = avg(ctorTrend.series.slice(-30).map(s => s.value));
+ const clickRate = avg(clickTrend.series.slice(-30).map(s => s.value));
+ const revPerRecipient = avg(revTrend.series.slice(-30).map(s => s.value));
+
+ return {
+ openRate: { value: +openRate.toFixed(4), band: healthBand('openRate', openRate), trend: openTrend.pctChange },
+ ctor: { value: +ctor.toFixed(4), band: healthBand('ctor', ctor), trend: ctorTrend.pctChange },
+ clickRate: { value: +clickRate.toFixed(4), band: healthBand('clickRate', clickRate), trend: clickTrend.pctChange },
+ revPerRecipient: { value: +revPerRecipient.toFixed(3), band: healthBand('revPerRecipient', revPerRecipient), trend: revTrend.pctChange },
+ };
+}
+
+// Overall band: worst single KPI drives the segment-level health colour, so the
+// summary card honestly flags problem cohorts even if 3 of 4 metrics look fine.
+function overallBand(kpis) {
+ const order = { bad: 0, ok: 1, good: 2, unknown: 3 };
+ return Object.values(kpis).reduce((worst, k) =>
+ (order[k.band] < order[worst] ? k.band : worst), 'good');
+}
+
+// ── module ─────────────────────────────────────────────────────────────────────
+module.exports = {
+ id: 'segment-perf',
+ title: 'Segment Performance',
+ icon: '📈',
+
+ // exposed for unit tests / sibling modules if they ever need to reuse the model
+ _segments: SEGMENTS,
+ _health: HEALTH,
+ _trendFor: trendFor,
+
+ mount(router) {
+ // GET /summary — one card's worth of data per segment
+ router.get('/summary', (_req, res) => {
+ const segments = SEGMENTS.map(s => {
+ const kpis = kpisFor(s);
+ return {
+ id: s.id,
+ name: s.name,
+ description: s.description,
+ icon: s.icon,
+ listSize: s.listSize,
+ sendsPerCampaign: s.sendsPerCampaign,
+ kpis,
+ overallBand: overallBand(kpis),
+ };
+ });
+ res.json({
+ mock: true,
+ generatedAt: new Date().toISOString(),
+ benchmarks: HEALTH,
+ segments,
+ });
+ });
+
+ // GET /trend?segmentId=designers&metric=openRate&days=90 — bar chart series
+ router.get('/trend', (req, res) => {
+ const segmentId = String(req.query.segmentId || '');
+ const metric = String(req.query.metric || 'openRate');
+ const days = Math.max(7, Math.min(180, Number(req.query.days) || 90));
+
+ const seg = SEGMENTS.find(s => s.id === segmentId);
+ if (!seg) {
+ return res.status(404).json({
+ ok: false,
+ error: `unknown segment "${segmentId}"`,
+ allowed: SEGMENTS.map(s => s.id),
+ });
+ }
+ const allowedMetrics = ['openRate', 'ctor', 'clickRate', 'revPerRecipient'];
+ if (!allowedMetrics.includes(metric)) {
+ return res.status(400).json({
+ ok: false,
+ error: `unknown metric "${metric}"`,
+ allowed: allowedMetrics,
+ });
+ }
+
+ const t = trendFor(seg, metric, days);
+ const benchmark = HEALTH[metric];
+ res.json({
+ mock: true,
+ segmentId,
+ segmentName: seg.name,
+ metric,
+ days,
+ benchmark, // { bad, good }
+ mean: t.mean,
+ pctChange: t.pctChange, // last 30d vs first 30d
+ campaignDays: t.campaignDays,
+ series: t.series,
+ });
+ });
+
+ // GET /benchmarks — just the thresholds (panel uses these to colour the legend)
+ router.get('/benchmarks', (_req, res) => {
+ res.json({ mock: true, benchmarks: HEALTH });
+ });
+ },
+};
diff --git a/modules/segments/index.js b/modules/segments/index.js
index 4813634..6ab00a6 100644
--- a/modules/segments/index.js
+++ b/modules/segments/index.js
@@ -104,10 +104,28 @@ function mockPool() {
const studio = studios[i % studios.length];
const email = `${pick(['hello', 'studio', 'projects', 'spec', 'design', 'info'])}+${i}@${studio}.${pick(tlds)}`;
+ // opens/clicks counts — correlate with recency so RFM scoring is realistic.
+ // Never-engaged contacts get 0; recently-engaged contacts get the highest counts.
+ let opens = 0, clicks = 0;
+ if (openDaysAgo != null) {
+ if (openDaysAgo <= 14) opens = 6 + Math.floor(rnd() * 9); // 6-14
+ else if (openDaysAgo <= 30) opens = 4 + Math.floor(rnd() * 6); // 4-9
+ else if (openDaysAgo <= 60) opens = 2 + Math.floor(rnd() * 4); // 2-5
+ else if (openDaysAgo <= 120) opens = 1 + Math.floor(rnd() * 3); // 1-3
+ else opens = Math.floor(rnd() * 2); // 0-1 (lapsed tail)
+ }
+ if (clickDaysAgo != null) {
+ if (clickDaysAgo <= 14) clicks = 2 + Math.floor(rnd() * 5); // 2-6
+ else if (clickDaysAgo <= 30) clicks = 1 + Math.floor(rnd() * 4); // 1-4
+ else if (clickDaysAgo <= 60) clicks = 1 + Math.floor(rnd() * 2); // 1-2
+ else clicks = Math.floor(rnd() * 2); // 0-1
+ }
+
pool.push({
email, type, lists,
lastOpen: isoDaysAgo(openDaysAgo),
lastClick: isoDaysAgo(clickDaysAgo),
+ opens, clicks,
tags,
});
}
@@ -136,16 +154,36 @@ function mapContact(c, listNameById) {
// CC has no first-class trade/retail flag; infer from list names, else 'retail'.
const isTrade = lists.some(n => /trade|designer|spec|commercial|hospitality/i.test(n));
const tags = (c.taggings || c.tags || []).map(t => (typeof t === 'string' ? t : (t.name || t.tag_id))).filter(Boolean);
+ const lastOpen = c.last_open_date || null;
+ const lastClick = c.last_click_date || null;
+ // CC v3 doesn't expose lifetime open/click counts on the contact resource.
+ // Derive a count heuristic from recency so RFM can score live contacts too.
+ // Explicit lifetime counts (if a future API change exposes them) win.
+ const explicitOpens = Number.isFinite(c.opens_count) ? c.opens_count
+ : Number.isFinite(c.lifetime_opens) ? c.lifetime_opens : null;
+ const explicitClicks = Number.isFinite(c.clicks_count) ? c.clicks_count
+ : Number.isFinite(c.lifetime_clicks) ? c.lifetime_clicks : null;
return {
email,
type: isTrade ? 'trade' : 'retail',
lists,
- lastOpen: c.last_open_date || null, // not always present on v3 contact; null-safe
- lastClick: c.last_click_date || null,
+ lastOpen,
+ lastClick,
+ opens: explicitOpens != null ? explicitOpens : derivedCount(lastOpen, 'open'),
+ clicks: explicitClicks != null ? explicitClicks : derivedCount(lastClick, 'click'),
tags,
};
}
+// Heuristic count for live contacts when CC doesn't surface lifetime open/click counts.
+function derivedCount(iso, kind) {
+ if (!iso) return 0;
+ const d = daysSince(iso);
+ if (d === Infinity) return 0;
+ const scale = kind === 'open' ? 8 : 4; // opens range higher than clicks
+ return Math.max(0, Math.round(scale / (1 + d / 30)));
+}
+
// Pull a live pool (capped). Falls back to mock on any failure so the panel never
// breaks. Returns { pool, mock }.
async function contactPool() {
@@ -219,6 +257,158 @@ function evaluate(pool, rules, sampleSize = 12) {
return { count: hits.length, total: pool.length, sample };
}
+// ── RFM auto-segmentation ─────────────────────────────────────────────────────
+// R(ecency) — days since lastOpen (lower days → higher score, 1-5)
+// F(requency) — lifetime opens count (higher → higher score)
+// M(onetary proxy) — lifetime clicks (intent / conversion signal)
+//
+// We use thresholds rather than per-pool quintiles so scores are stable when a
+// new contact is added and so small pools (< 100) still bucket sensibly.
+const RFM_THRESHOLDS = {
+ // boundaries: score 5 if value beats first threshold, 4 next, ... 1 last
+ recencyDays: [14, 30, 60, 120], // days since open — fewer days = higher score
+ frequencyOpens: [10, 6, 3, 1], // opens count — more = higher score
+ monetaryClicks: [5, 3, 2, 1], // clicks count — more = higher score
+};
+
+function rScore(daysAgo) {
+ const t = RFM_THRESHOLDS.recencyDays;
+ if (daysAgo <= t[0]) return 5;
+ if (daysAgo <= t[1]) return 4;
+ if (daysAgo <= t[2]) return 3;
+ if (daysAgo <= t[3]) return 2;
+ return 1;
+}
+function fmScore(n, table) {
+ if (n >= table[0]) return 5;
+ if (n >= table[1]) return 4;
+ if (n >= table[2]) return 3;
+ if (n >= table[3]) return 2;
+ return 1;
+}
+
+function rfmScore(contact) {
+ const r = rScore(daysSince(contact.lastOpen));
+ const f = fmScore(Number(contact.opens) || 0, RFM_THRESHOLDS.frequencyOpens);
+ const m = fmScore(Number(contact.clicks) || 0, RFM_THRESHOLDS.monetaryClicks);
+ return { r, f, m };
+}
+
+// Bucket priority order — first match wins so every contact lands in exactly one.
+// Promising catches recently-engaged-but-low-frequency (new subscribers, casual)
+// so totals reconcile to pool size.
+const RFM_BUCKETS = [
+ {
+ id: 'champions',
+ name: 'Champions',
+ icon: '🏆',
+ description: 'Recently engaged, frequent openers, strong click intent — your top tier.',
+ criteria: 'R≥4 · F≥4 · M≥3',
+ test: ({ r, f, m }) => r >= 4 && f >= 4 && m >= 3,
+ nextStep: 'Send the curated trade lookbook + a $0 memo-bundle CTA.',
+ },
+ {
+ id: 'loyal',
+ name: 'Loyal',
+ icon: '💛',
+ description: 'Consistent openers across recent campaigns — keep the cadence.',
+ criteria: 'R≥3 · F≥3',
+ test: ({ r, f }) => r >= 3 && f >= 3,
+ nextStep: 'Reward with a behind-the-scenes vendor story or new-arrival peek.',
+ },
+ {
+ id: 'at-risk',
+ name: 'At-Risk',
+ icon: '⚠️',
+ description: 'Used to engage frequently — opens have gone cold.',
+ criteria: 'R≤2 · F≥3',
+ test: ({ r, f }) => r <= 2 && f >= 3,
+ nextStep: 'Win-back: "We miss you" subject + a hand-picked vignette from past clicks.',
+ },
+ {
+ id: 'hibernating',
+ name: 'Hibernating',
+ icon: '🌙',
+ description: 'Disengaged across recency and frequency — re-permission or sunset.',
+ criteria: 'R≤2 · F≤2',
+ test: ({ r, f }) => r <= 2 && f <= 2,
+ nextStep: 'Final re-permission ask, then move off the active sending pool.',
+ },
+ // Catch-all so counts reconcile to pool size (recently joined, low frequency)
+ {
+ id: 'promising',
+ name: 'Promising',
+ icon: '🌱',
+ description: 'Recent opens but low frequency — newer or casual subscribers.',
+ criteria: 'R≥3 · F≤2',
+ test: ({ r, f }) => r >= 3 && f <= 2,
+ nextStep: 'Welcome-style nurture: brand story + memo-sample CTA.',
+ },
+];
+
+function bucketFor(score) {
+ for (const b of RFM_BUCKETS) {
+ if (b.test(score)) return b.id;
+ }
+ return 'promising';
+}
+
+// Score every contact, group, and return per-bucket counts + sample.
+function rfmBuckets(pool, sampleSize = 8) {
+ const total = pool.length;
+ const groups = {};
+ for (const b of RFM_BUCKETS) groups[b.id] = [];
+ let sumR = 0, sumF = 0, sumM = 0;
+ const scored = pool.map(c => {
+ const s = rfmScore(c);
+ sumR += s.r; sumF += s.f; sumM += s.m;
+ return { contact: c, score: s };
+ });
+ // Sort by Champions/Loyal preference (highest R+F+M first), so the sample
+ // pulled per bucket is the strongest representative of that bucket.
+ scored.sort((a, b) => (b.score.r + b.score.f + b.score.m) - (a.score.r + a.score.f + a.score.m));
+ for (const row of scored) {
+ const id = bucketFor(row.score);
+ groups[id].push(row);
+ }
+
+ const buckets = RFM_BUCKETS.map(b => {
+ const rows = groups[b.id] || [];
+ const sample = rows.slice(0, sampleSize).map(({ contact, score }) => ({
+ email: contact.email,
+ type: contact.type,
+ lists: contact.lists,
+ lastOpen: contact.lastOpen,
+ lastClick: contact.lastClick,
+ opens: contact.opens || 0,
+ clicks: contact.clicks || 0,
+ r: score.r, f: score.f, m: score.m,
+ }));
+ return {
+ id: b.id,
+ name: b.name,
+ icon: b.icon,
+ description: b.description,
+ criteria: b.criteria,
+ nextStep: b.nextStep,
+ count: rows.length,
+ share: total ? rows.length / total : 0,
+ sample,
+ };
+ });
+
+ return {
+ total,
+ buckets,
+ summary: {
+ avgR: total ? +(sumR / total).toFixed(2) : 0,
+ avgF: total ? +(sumF / total).toFixed(2) : 0,
+ avgM: total ? +(sumM / total).toFixed(2) : 0,
+ },
+ thresholds: RFM_THRESHOLDS,
+ };
+}
+
// ── segment store ────────────────────────────────────────────────────────────
function defaultSegments() {
return [
@@ -305,6 +495,8 @@ module.exports = {
// exported for reuse / testing
_evaluate: evaluate,
_matches: matches,
+ _rfmScore: rfmScore,
+ _rfmBuckets: rfmBuckets,
mount(router) {
const guard = (handler) => async (req, res) => {
@@ -391,5 +583,14 @@ module.exports = {
const result = evaluate(pool, rules);
res.json({ mock, rules, ...result });
}));
+
+ // GET /rfm — auto-bucket contacts into Champions / Loyal / At-Risk /
+ // Hibernating / Promising using Recency-Frequency-Monetary-style scoring
+ // over email engagement (lastOpen, opens count, lastClick/clicks as M proxy).
+ router.get('/rfm', guard(async (_req, res) => {
+ const { pool, mock } = await contactPool();
+ const result = rfmBuckets(pool);
+ res.json({ mock, ...result });
+ }));
},
};
diff --git a/modules/send-times/index.js b/modules/send-times/index.js
new file mode 100644
index 0000000..550ff57
--- /dev/null
+++ b/modules/send-times/index.js
@@ -0,0 +1,372 @@
+// Send-Time Optimization module — for a chosen segment, mine historical email
+// open timestamps and recommend the best day×hour send window. Read-only; never
+// triggers a send. Heatmap is built per-segment from per-contact open history.
+//
+// Data source: when CTCT_ACCESS_TOKEN is present we'd pull v3 reporting, but that
+// endpoint isn't on every CC plan, so this module always operates on a stable
+// deterministic mock pool (~200 contacts, each with 12-48 historical opens drawn
+// from a persona-shaped day×hour distribution). Every response includes mock:true
+// so the panel can show the "mock data" banner.
+//
+// Segment filtering reuses the segments module's rule evaluator (require it so
+// any schema additions there flow through here automatically).
+const fs = require('fs');
+const path = require('path');
+
+const segmentsMod = require('../segments');
+const SEG_FILE = path.join(__dirname, '..', '..', 'data', 'segments.json');
+
+const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
+
+// ── deterministic PRNG ──────────────────────────────────────────────────────
+// mulberry32 — same generator used by the segments mock so behaviour is stable.
+function mulberry32(seed) {
+ let s = seed >>> 0;
+ return function () {
+ s |= 0; s = (s + 0x6d2b79f5) | 0;
+ let t = Math.imul(s ^ (s >>> 15), 1 | s);
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+}
+
+// ── persona day×hour open distributions ─────────────────────────────────────
+// Each persona has a probability mass over (day, hour). Sampling from these
+// produces realistic-looking individual open timestamps and the aggregate
+// heatmap will surface the persona's peak windows.
+//
+// Trade designers: weekday mornings 9-11 AM, light secondary lunch 12-1 PM.
+// Retail: weekend evenings 7-9 PM + weekday lunch 12 PM.
+// Hospitality / spec: Mon-Tue 7-9 AM (project-week kickoff).
+function personaWeights(persona) {
+ const W = Array.from({ length: 7 }, () => new Array(24).fill(0.02)); // floor
+ if (persona === 'trade') {
+ for (const d of [1, 2, 3, 4]) { // Mon-Thu
+ W[d][9] = 1.4; W[d][10] = 1.7; W[d][11] = 1.2;
+ W[d][12] = 0.5; W[d][13] = 0.4;
+ W[d][14] = 0.7; W[d][15] = 0.6;
+ }
+ W[5][10] = 0.6; // Fri morning fade
+ W[2][10] = 1.9; // Tue 10 AM is the peak day
+ } else if (persona === 'hospitality') {
+ for (const d of [1, 2]) { // Mon, Tue
+ W[d][7] = 1.5; W[d][8] = 1.8; W[d][9] = 1.3;
+ }
+ W[3][8] = 0.8; W[4][8] = 0.6;
+ } else { // retail
+ W[0][19] = 1.5; W[0][20] = 1.4; W[6][19] = 1.3; W[6][20] = 1.4; // weekend evenings
+ for (const d of [1, 2, 3, 4, 5]) {
+ W[d][12] = 0.9; W[d][13] = 0.5;
+ W[d][20] = 0.7;
+ }
+ }
+ // normalize
+ let sum = 0;
+ for (let d = 0; d < 7; d++) for (let h = 0; h < 24; h++) sum += W[d][h];
+ for (let d = 0; d < 7; d++) for (let h = 0; h < 24; h++) W[d][h] /= sum;
+ return W;
+}
+
+// Sample a (day, hour) cell from a weight matrix using `rnd` for the draw.
+function sampleCell(W, rnd) {
+ const r = rnd();
+ let acc = 0;
+ for (let d = 0; d < 7; d++) {
+ for (let h = 0; h < 24; h++) {
+ acc += W[d][h];
+ if (r <= acc) return { day: d, hour: h };
+ }
+ }
+ return { day: 2, hour: 10 };
+}
+
+// Build an ISO timestamp for the most-recent past occurrence of (day, hour),
+// offset by `weeksBack` whole weeks. Used to synthesise realistic open history.
+function isoForDayHour(day, hour, weeksBack) {
+ const d = new Date();
+ d.setUTCMinutes(0, 0, 0);
+ d.setUTCHours(hour);
+ const dayDelta = (d.getUTCDay() - day + 7) % 7;
+ d.setUTCDate(d.getUTCDate() - dayDelta - 7 * weeksBack);
+ return d.toISOString();
+}
+
+// ── mock contact pool with open history ─────────────────────────────────────
+let _pool = null;
+function mockPool() {
+ if (_pool) return _pool;
+ const rnd = mulberry32(0x517a8e35); // distinct seed from segments module
+ const studios = ['atelier-noir', 'maison-blanc', 'verdant-interiors', 'harbor-hospitality',
+ 'lumiere-design', 'oak-and-ash', 'studio-meridian', 'thornfield-co', 'cobalt-interiors',
+ 'gilded-room', 'north-star-design', 'ember-and-stone', 'palette-house', 'crestwood-studio',
+ 'ivory-lane', 'monarch-interiors', 'saffron-studio', 'westbrook-design', 'vellum-co', 'aria-spaces'];
+ const tlds = ['com', 'co', 'studio', 'design'];
+ const TAGS = ['grasscloth', 'silk', 'cork', 'mural', 'metallic', 'memo-cta', 'lookbook', 'high-intent', 'vip', 'architect'];
+ const pick = arr => arr[Math.floor(rnd() * arr.length)];
+
+ const pool = [];
+ for (let i = 0; i < 200; i++) {
+ const isTrade = rnd() < 0.58;
+ const type = isTrade ? 'trade' : 'retail';
+
+ const lists = [];
+ if (isTrade) {
+ lists.push('Trade & Designers');
+ if (rnd() < 0.35) lists.push('Hospitality / Commercial Spec');
+ } else {
+ lists.push('Retail Newsletter');
+ }
+ if (rnd() < 0.30) lists.push('Memo Sample Requests');
+
+ let persona = 'retail';
+ if (lists.includes('Hospitality / Commercial Spec')) persona = 'hospitality';
+ else if (isTrade) persona = 'trade';
+
+ const tags = [];
+ const nTags = Math.floor(rnd() * 3);
+ for (let t = 0; t < nTags; t++) {
+ const tg = pick(TAGS);
+ if (!tags.includes(tg)) tags.push(tg);
+ }
+
+ // 12% never-engaged → no open history at all
+ const neverEngaged = rnd() < 0.12;
+ let openHistory = [];
+ if (!neverEngaged) {
+ const W = personaWeights(persona);
+ const nOpens = 12 + Math.floor(rnd() * 37); // 12-48 opens
+ for (let k = 0; k < nOpens; k++) {
+ const { day, hour } = sampleCell(W, rnd);
+ // distribute across ~16 weeks back; recent weeks slightly likelier.
+ const weeksBack = Math.floor(rnd() * rnd() * 16);
+ openHistory.push({ day, hour, ts: isoForDayHour(day, hour, weeksBack) });
+ }
+ // sort newest first so lastOpen is the head
+ openHistory.sort((a, b) => Date.parse(b.ts) - Date.parse(a.ts));
+ }
+
+ const lastOpen = openHistory[0] ? openHistory[0].ts : null;
+ // synthesise lastClick as ~40% of the most recent open
+ const lastClick = !neverEngaged && rnd() < 0.4 && openHistory[0]
+ ? openHistory[0].ts
+ : null;
+
+ const studio = studios[i % studios.length];
+ const email = `${pick(['hello', 'studio', 'projects', 'spec', 'design', 'info'])}+${i}@${studio}.${pick(tlds)}`;
+
+ pool.push({
+ email, type, lists, tags,
+ persona,
+ lastOpen, lastClick,
+ opens: openHistory.length,
+ clicks: lastClick ? Math.max(1, Math.floor(openHistory.length * 0.25)) : 0,
+ openHistory,
+ });
+ }
+ _pool = pool;
+ return pool;
+}
+
+// ── aggregation ─────────────────────────────────────────────────────────────
+function blankMatrix() {
+ return Array.from({ length: 7 }, () => new Array(24).fill(0));
+}
+
+function aggregateHeatmap(contacts) {
+ const M = blankMatrix();
+ let totalOpens = 0;
+ let contactsWithHistory = 0;
+ for (const c of contacts) {
+ if (!c.openHistory || !c.openHistory.length) continue;
+ contactsWithHistory++;
+ for (const o of c.openHistory) {
+ M[o.day][o.hour]++;
+ totalOpens++;
+ }
+ }
+ return { matrix: M, totalOpens, contactsWithHistory };
+}
+
+// Find the single best (day, hour) cell and a second-best for backup.
+function topCells(matrix, n = 3) {
+ const cells = [];
+ for (let d = 0; d < 7; d++) {
+ for (let h = 0; h < 24; h++) {
+ cells.push({ day: d, hour: h, count: matrix[d][h] });
+ }
+ }
+ cells.sort((a, b) => b.count - a.count);
+ return cells.slice(0, n);
+}
+
+// Group cells by day to produce a "best hour per day" digest (useful for
+// scheduling around a constraint like "must send on Tuesday").
+function bestHourPerDay(matrix) {
+ return matrix.map((row, day) => {
+ let bestHour = 0, bestCount = -1;
+ for (let h = 0; h < 24; h++) {
+ if (row[h] > bestCount) { bestCount = row[h]; bestHour = h; }
+ }
+ return { day, dayLabel: DAY_LABELS[day], hour: bestHour, count: bestCount };
+ });
+}
+
+// Per-contact predicted peak — argmax over their own openHistory matrix.
+function predictedPeak(contact) {
+ if (!contact.openHistory || contact.openHistory.length < 3) {
+ return { day: null, hour: null, confidence: 'low', samples: contact.openHistory ? contact.openHistory.length : 0 };
+ }
+ const M = blankMatrix();
+ for (const o of contact.openHistory) M[o.day][o.hour]++;
+ let bestDay = 0, bestHour = 0, bestCount = 0;
+ for (let d = 0; d < 7; d++) {
+ for (let h = 0; h < 24; h++) {
+ if (M[d][h] > bestCount) { bestCount = M[d][h]; bestDay = d; bestHour = h; }
+ }
+ }
+ // Confidence: share of opens that land in the top cell.
+ const share = bestCount / contact.openHistory.length;
+ let confidence = 'low';
+ if (share >= 0.35) confidence = 'high';
+ else if (share >= 0.20) confidence = 'med';
+ return {
+ day: bestDay, dayLabel: DAY_LABELS[bestDay],
+ hour: bestHour,
+ confidence,
+ share: +share.toFixed(2),
+ samples: contact.openHistory.length,
+ };
+}
+
+// ── segment loading ─────────────────────────────────────────────────────────
+function loadSegments() {
+ try { return JSON.parse(fs.readFileSync(SEG_FILE, 'utf8')); }
+ catch { return []; }
+}
+
+// Filter the pool by segment rules. If segmentId == '__all__' or unknown,
+// return the full pool.
+function filterBySegment(pool, segmentId) {
+ if (!segmentId || segmentId === '__all__') return { contacts: pool, segment: null };
+ const seg = loadSegments().find(s => s.id === segmentId);
+ if (!seg) return { contacts: pool, segment: null };
+ const match = segmentsMod._matches || (() => true);
+ return { contacts: pool.filter(c => match(c, seg.rules)), segment: seg };
+}
+
+function fmtHour(h) {
+ if (h == null) return '—';
+ const am = h < 12;
+ const h12 = h % 12 || 12;
+ return `${h12}:00 ${am ? 'AM' : 'PM'}`;
+}
+
+function describeCell(cell) {
+ if (!cell || cell.count === 0) return null;
+ return {
+ day: cell.day,
+ dayLabel: DAY_LABELS[cell.day],
+ hour: cell.hour,
+ hourLabel: fmtHour(cell.hour),
+ count: cell.count,
+ };
+}
+
+// ── module ──────────────────────────────────────────────────────────────────
+module.exports = {
+ id: 'send-times',
+ title: 'Send-Time Optimization',
+ icon: '⏰',
+
+ _mockPool: mockPool,
+ _aggregate: aggregateHeatmap,
+ _predictedPeak: predictedPeak,
+
+ mount(router) {
+ const guard = (handler) => async (req, res) => {
+ try { await handler(req, res); }
+ catch (e) {
+ res.status(500).json({ error: e.message });
+ }
+ };
+
+ // GET /segments — picker list (saved segments + an "all contacts" sentinel)
+ router.get('/segments', guard(async (_req, res) => {
+ const pool = mockPool();
+ const segs = loadSegments();
+ const items = [{ id: '__all__', name: 'All contacts (whole pool)', estimatedSize: pool.length }];
+ for (const s of segs) {
+ const match = segmentsMod._matches || (() => true);
+ const count = pool.filter(c => match(c, s.rules)).length;
+ items.push({ id: s.id, name: s.name, description: s.description, estimatedSize: count });
+ }
+ res.json({ mock: true, segments: items });
+ }));
+
+ // GET /heatmap?segmentId=... — day×hour aggregate + recommendations
+ router.get('/heatmap', guard(async (req, res) => {
+ const pool = mockPool();
+ const { contacts, segment } = filterBySegment(pool, String(req.query.segmentId || '__all__'));
+ const { matrix, totalOpens, contactsWithHistory } = aggregateHeatmap(contacts);
+ const top = topCells(matrix, 3).map(describeCell).filter(Boolean);
+ const perDay = bestHourPerDay(matrix);
+ res.json({
+ mock: true,
+ segment: segment ? { id: segment.id, name: segment.name, description: segment.description } : null,
+ segmentId: req.query.segmentId || '__all__',
+ contactCount: contacts.length,
+ contactsWithHistory,
+ totalOpens,
+ matrix,
+ dayLabels: DAY_LABELS,
+ recommendation: top[0] || null,
+ backups: top.slice(1),
+ bestHourPerDay: perDay,
+ });
+ }));
+
+ // GET /contacts?segmentId=...&limit=50 — per-contact predicted peak window
+ router.get('/contacts', guard(async (req, res) => {
+ const pool = mockPool();
+ const { contacts, segment } = filterBySegment(pool, String(req.query.segmentId || '__all__'));
+ const limit = Math.max(1, Math.min(500, Number(req.query.limit) || 50));
+ const rows = contacts
+ .map(c => {
+ const peak = predictedPeak(c);
+ return {
+ email: c.email,
+ type: c.type,
+ lists: c.lists,
+ tags: c.tags,
+ opens: c.opens,
+ clicks: c.clicks,
+ lastOpen: c.lastOpen,
+ peak: {
+ day: peak.day,
+ dayLabel: peak.dayLabel || null,
+ hour: peak.hour,
+ hourLabel: peak.hour == null ? null : fmtHour(peak.hour),
+ confidence: peak.confidence,
+ share: peak.share == null ? null : peak.share,
+ samples: peak.samples,
+ },
+ };
+ })
+ // Strongest signal first: high-confidence + most samples
+ .sort((a, b) => {
+ const rank = x => ({ high: 3, med: 2, low: 1 }[x.peak.confidence] || 0);
+ if (rank(b) !== rank(a)) return rank(b) - rank(a);
+ return (b.peak.samples || 0) - (a.peak.samples || 0);
+ })
+ .slice(0, limit);
+ res.json({
+ mock: true,
+ segment: segment ? { id: segment.id, name: segment.name } : null,
+ segmentId: req.query.segmentId || '__all__',
+ total: contacts.length,
+ returned: rows.length,
+ contacts: rows,
+ });
+ }));
+ },
+};
diff --git a/modules/templates/index.js b/modules/templates/index.js
new file mode 100644
index 0000000..f61f080
--- /dev/null
+++ b/modules/templates/index.js
@@ -0,0 +1,434 @@
+// templates — Campaign Templates module for the Marketing Command Center.
+// A curated library of 8 pre-built luxury DW email campaigns. Each template has
+// a brand-voice subject + body (3 short editorial paragraphs + a CTA), a set of
+// named {{placeholders}} the user can fill in, and a default value for each.
+//
+// STAGED ONLY. Nothing here sends. POST /:id/use returns the filled subject + body
+// for the user to copy/paste into Constant Contact (where Steve gates the send).
+//
+// Routes (mounted at /api/templates by the shell):
+// GET /meta — module metadata + the gate message
+// GET /list — all templates (no body if `?summary=1`)
+// GET /:id — one template (full subject + body + placeholders)
+// POST /:id/use {fields} — returns filled template + a CAN-SPAM reminder
+const brand = require('../../lib/brand.js');
+
+// ── Templates ────────────────────────────────────────────────────────────────
+// Each template is fully self-contained — no nested partials, no shared chrome.
+// Bodies are written in the DW voice (elevated, editorial, tactile, confident,
+// never gimmicky). Subjects sit in the 28–55 char editorial sweet spot when the
+// placeholders are filled with the default values below.
+const TEMPLATES = [
+ {
+ id: 'welcome-designer-1',
+ title: 'Welcome — Designer (Part 1 of 2)',
+ category: 'Welcome Series',
+ sequencePosition: 'Day 0',
+ description: 'Sent the moment a designer\'s trade account is approved. Warm, atelier-toned welcome that frames DW as a house, not a catalog.',
+ audience: 'interior designers',
+ productLines: ['grasscloth', 'silk', 'cork', 'linen'],
+ cta: 'Request memo samples',
+ placeholders: [
+ { key: 'first_name', label: 'Designer first name', default: 'Designer' },
+ { key: 'studio_name', label: 'Studio / firm name', default: 'your studio' },
+ { key: 'sender_name', label: 'Sender (from)', default: 'Steve' },
+ { key: 'cta_url', label: 'CTA link', default: 'https://designerwallcoverings.com/pages/trade' },
+ ],
+ subject: 'Welcome to the Designer Wallcoverings atelier',
+ preheader: 'Trade access, memo samples, and a curated archive for {{studio_name}}.',
+ body:
+`Hello {{first_name}},
+
+Welcome to Designer Wallcoverings. We built this house for designers who specify with intention — the studios that treat the wall as the most generous surface in the room. {{studio_name}} now has full trade access to the archive: grasscloth, silk, cork, linen, glass bead, flocked, mural and metallic, sourced from the makers we trust.
+
+You'll find every pattern carries a real width, a real fire rating, and a real memo at our cost — because a sample held against actual light, at the right scale, is the only honest way to specify. Order what you'd like to see; we ship same day.
+
+A good first move is a curated memo box around the project on your board. Tell us the room, the light, and the mood, and we'll pull six to ten options worth living with.
+
+[ {{cta_url}} — Request memo samples ]
+
+With our regards,
+{{sender_name}}
+Designer Wallcoverings`,
+ },
+
+ {
+ id: 'welcome-designer-2',
+ title: 'Welcome — Designer (Part 2 of 2)',
+ category: 'Welcome Series',
+ sequencePosition: 'Day 3',
+ description: 'Follow-up to the welcome email — gives a designer three concrete starting points (rooms × wallcoverings) to demystify the archive.',
+ audience: 'interior designers',
+ productLines: ['grasscloth', 'mural', 'metallic'],
+ cta: 'Explore the collection',
+ placeholders: [
+ { key: 'first_name', label: 'Designer first name', default: 'Designer' },
+ { key: 'collection_name',label: 'Featured collection', default: 'the new arrivals' },
+ { key: 'sender_name', label: 'Sender (from)', default: 'Steve' },
+ { key: 'cta_url', label: 'CTA link', default: 'https://designerwallcoverings.com/collections/new-arrivals' },
+ ],
+ subject: 'Three rooms, three wallcoverings — a place to start',
+ preheader: 'Concrete pairings from {{collection_name}} so the archive feels smaller.',
+ body:
+`{{first_name}},
+
+The archive is large; the project on your desk is specific. To make the first specification easier, three pairings we'd reach for this season.
+
+For the entry that should feel like an arrival — a deep, hand-troweled grasscloth, weighed so the seams disappear and the room hums at low light. For the library that wants to read more like a study — a textural mural with a quiet repeat, scaled to the long wall. For the powder room that should earn a photograph — a soft metallic, listed for damp service, that turns honest gold under the sconces.
+
+Each of these lives in {{collection_name}}. If one feels right, send the room and we'll pull memos at the size you need.
+
+[ {{cta_url}} — Explore {{collection_name}} ]
+
+Warmly,
+{{sender_name}}
+Designer Wallcoverings`,
+ },
+
+ {
+ id: 'memo-followup-day0',
+ title: 'Memo Follow-Up — Day 0 (Shipped)',
+ category: 'Memo Sample Sequence',
+ sequencePosition: 'Day 0 (ship confirmation)',
+ description: 'Transactional confirmation the moment memos ship. Sets the right reading conditions (light, distance, time of day) so the samples are evaluated fairly.',
+ audience: 'interior designers',
+ productLines: [],
+ cta: 'View tracking',
+ placeholders: [
+ { key: 'first_name', label: 'Recipient first name', default: 'Designer' },
+ { key: 'sample_count', label: 'Sample count', default: '6' },
+ { key: 'tracking_url', label: 'Tracking URL', default: 'https://designerwallcoverings.com/account/orders' },
+ { key: 'sender_name', label: 'Sender (from)', default: 'Steve' },
+ ],
+ subject: 'Your memo samples are on the way',
+ preheader: '{{sample_count}} memos shipped today — and how to read them at scale.',
+ body:
+`{{first_name}},
+
+Your {{sample_count}} memos shipped today. A note on reading them — a swatch in hand is closer to a paint chip than to the finished wall, so a little staging pays back.
+
+Tape each memo to the wall at the height it will live. Look at it in the morning, the late afternoon, and under the sconces you actually use; texture and metallic read very differently across the day. Stand at the distance the room is normally seen from, not at arm's length.
+
+If two patterns earn a second look, ask for a larger drop. We'll cut a meter and ship overnight — no charge on the swap.
+
+[ {{tracking_url}} — View tracking ]
+
+{{sender_name}}
+Designer Wallcoverings`,
+ },
+
+ {
+ id: 'memo-followup-day7',
+ title: 'Memo Follow-Up — Day 7 (Check-in)',
+ category: 'Memo Sample Sequence',
+ sequencePosition: 'Day 7',
+ description: 'Soft check-in a week after memos arrive — never pushy. Offers a larger drop, a swap, or a trade consult; positions the studio as a partner, not a vendor.',
+ audience: 'interior designers',
+ productLines: [],
+ cta: 'Book a trade consultation',
+ placeholders: [
+ { key: 'first_name', label: 'Recipient first name', default: 'Designer' },
+ { key: 'sender_name', label: 'Sender (from)', default: 'Steve' },
+ { key: 'cta_url', label: 'CTA link', default: 'https://designerwallcoverings.com/pages/trade-consultation' },
+ ],
+ subject: 'How are the samples reading at scale?',
+ preheader: 'A larger drop, a swap, or a quick call — whatever helps the decision.',
+ body:
+`{{first_name}},
+
+Wanted to check on the memos. A week is usually enough time to know which one keeps catching your eye and which two were never quite right.
+
+If a pattern is close — within reach for the room but not yet certain — let us cut a meter so you can see the repeat in full. If none of them are, send the project notes and we'll re-curate against the brief. Either swap is on us.
+
+For the rooms where a wallcovering is doing more than dressing the wall (acoustics, light, a registered repeat across two surfaces), a fifteen-minute call with the studio often saves a full sampling round.
+
+[ {{cta_url}} — Book a trade consultation ]
+
+{{sender_name}}
+Designer Wallcoverings`,
+ },
+
+ {
+ id: 'memo-followup-day21',
+ title: 'Memo Follow-Up — Day 21 (Final)',
+ category: 'Memo Sample Sequence',
+ sequencePosition: 'Day 21',
+ description: 'Final touch in the memo sequence — graceful, no pressure. Re-opens the door, recommends one related collection, exits cleanly if the project moved on.',
+ audience: 'interior designers',
+ productLines: [],
+ cta: 'Explore the collection',
+ placeholders: [
+ { key: 'first_name', label: 'Recipient first name', default: 'Designer' },
+ { key: 'collection_name',label: 'Related collection', default: 'the Atelier Edit' },
+ { key: 'sender_name', label: 'Sender (from)', default: 'Steve' },
+ { key: 'cta_url', label: 'CTA link', default: 'https://designerwallcoverings.com/collections' },
+ ],
+ subject: 'Still considering? A note from the atelier',
+ preheader: 'No pressure — and one related collection worth a second look.',
+ body:
+`{{first_name}},
+
+A last note on the memos. Projects move — sometimes the room changes, sometimes the wallcovering does, sometimes the client surprises everyone. No pressure either way.
+
+If the brief shifted and the original options no longer fit, the closest neighbours to that conversation now live in {{collection_name}}. The textures are quieter, the colour story is warmer, and the widths run a little broader for the larger walls you mentioned.
+
+When the project picks back up — this one or the next — we'll be here, and a fresh memo box is always one note away.
+
+[ {{cta_url}} — Explore {{collection_name}} ]
+
+With our regards,
+{{sender_name}}
+Designer Wallcoverings`,
+ },
+
+ {
+ id: 'vip-preview',
+ title: 'New Collection — VIP Preview',
+ category: 'Collection Launch',
+ sequencePosition: 'Pre-launch (7 days before public)',
+ description: 'Exclusive preview to top-tier designers + commercial spec contacts before a collection ships to the trade at large. Frames the access as earned, not gimmicky.',
+ audience: 'interior designers',
+ productLines: ['grasscloth', 'silk', 'mural'],
+ cta: 'Reserve memo samples',
+ placeholders: [
+ { key: 'first_name', label: 'Recipient first name', default: 'Designer' },
+ { key: 'collection_name',label: 'Collection name', default: 'the Marais Collection' },
+ { key: 'designer_name', label: 'Featured designer/house', default: 'the studio' },
+ { key: 'launch_date', label: 'Public launch date', default: 'next Tuesday' },
+ { key: 'sender_name', label: 'Sender (from)', default: 'Steve' },
+ { key: 'cta_url', label: 'CTA link', default: 'https://designerwallcoverings.com/collections/preview' },
+ ],
+ subject: 'Preview: {{collection_name}} — before it ships to the trade',
+ preheader: 'Seven days early access. Memos open this morning.',
+ body:
+`{{first_name}},
+
+A small note before {{collection_name}} reaches the wider trade on {{launch_date}}. Because of how often this list specifies our work, we're opening the archive seven days early.
+
+{{collection_name}} was developed with {{designer_name}} and reads like a single, sustained idea — a tighter palette than usual, larger repeats, and a few patterns sized for the proportions modern projects actually have. The grounds are unusually quiet; the highlights do all of the heavy lifting.
+
+Memos are live this morning. Order what you'd like to see, and the patterns will land before the collection is announced anywhere else.
+
+[ {{cta_url}} — Reserve memo samples ]
+
+{{sender_name}}
+Designer Wallcoverings`,
+ },
+
+ {
+ id: 'seasonal-inspiration',
+ title: 'Seasonal Inspiration — Editorial',
+ category: 'Editorial / Newsletter',
+ sequencePosition: 'Quarterly',
+ description: 'Quarterly editorial letter — what the studio is reading, specifying, and noticing this season. Sells nothing directly; builds the brand voice that sells everything later.',
+ audience: 'interior designers',
+ productLines: ['grasscloth', 'mural', 'linen'],
+ cta: 'Explore the collection',
+ placeholders: [
+ { key: 'first_name', label: 'Recipient first name', default: 'Designer' },
+ { key: 'season', label: 'Season', default: 'Spring' },
+ { key: 'year', label: 'Year', default: '2026' },
+ { key: 'theme', label: 'Season theme', default: 'quieter rooms, deeper texture' },
+ { key: 'sender_name',label: 'Sender (from)', default: 'Steve' },
+ { key: 'cta_url', label: 'CTA link', default: 'https://designerwallcoverings.com/collections/new-arrivals' },
+ ],
+ subject: 'Notes from the studio — {{season}} {{year}}',
+ preheader: 'What we\'re reading, specifying, and watching this {{season}}.',
+ body:
+`{{first_name}},
+
+Notes from the studio for {{season}} {{year}} — what we're watching this quarter is {{theme}}.
+
+The projects coming across the desk are quieter than they were a year ago. Designers are pulling back on contrast and leaning into surface — wider grasscloth seams disguised against deeper grounds, murals scaled to the long wall rather than the feature wall, linens treated more like a painted finish than a paper. The room is doing less, but doing it more carefully.
+
+If the brief on your board is in that conversation, three places in the archive worth a look: the grasscloth weights above 9-oz, the new mural drops in the 12-foot width, and any of the linen colourways with a hand-dyed ground. Memos for any of them ship same day.
+
+[ {{cta_url}} — Explore the collection ]
+
+Until next quarter,
+{{sender_name}}
+Designer Wallcoverings`,
+ },
+
+ {
+ id: 're-engagement',
+ title: 'Re-engagement — Quiet Contact',
+ category: 'Lifecycle',
+ sequencePosition: 'Triggered (90+ days inactive)',
+ description: 'Gentle re-engagement for contacts who have been quiet for 90+ days. Never guilty, never gimmicky — restates the trade benefit and offers one easy first step.',
+ audience: 'interior designers',
+ productLines: [],
+ cta: 'Request memo samples',
+ placeholders: [
+ { key: 'first_name', label: 'Recipient first name', default: 'Designer' },
+ { key: 'sender_name',label: 'Sender (from)', default: 'Steve' },
+ { key: 'cta_url', label: 'CTA link', default: 'https://designerwallcoverings.com/pages/memo-samples' },
+ ],
+ subject: 'We saved a place on your moodboard',
+ preheader: 'A new archive, the same trade pricing, and memos on us.',
+ body:
+`{{first_name}},
+
+It's been a while, and we wanted to send a quiet note rather than another launch.
+
+A lot of the archive has turned over since we last spoke — the grasscloth weights are heavier, the mural drops are wider, and a handful of collections we knew you liked have new colourways. Your trade pricing and same-day memo shipping are still in place; nothing about your account has changed.
+
+If a project on your desk could use a fresh starting point, send the brief and we'll curate a small memo box around it. No commitment, no follow-up cadence — just a useful first look.
+
+[ {{cta_url}} — Request memo samples ]
+
+Glad to hear from you whenever the time is right,
+{{sender_name}}
+Designer Wallcoverings`,
+ },
+];
+
+// ── Placeholder fill ────────────────────────────────────────────────────────
+// Replace every {{key}} in `text` with fields[key]. Unknown placeholders are
+// left intact (with the {{}} preserved) so the user immediately sees what still
+// needs to be filled in before they paste into Constant Contact.
+function fill(text, fields) {
+ return String(text || '').replace(/\{\{\s*([a-z0-9_]+)\s*\}\}/gi, (m, key) => {
+ const v = fields && Object.prototype.hasOwnProperty.call(fields, key) ? fields[key] : undefined;
+ if (v === undefined || v === null || String(v).trim() === '') return m;
+ return String(v);
+ });
+}
+
+// Build the default-fields object from a template's placeholders array, then
+// overlay anything the caller provided.
+function defaultsFor(tpl) {
+ const out = {};
+ for (const p of tpl.placeholders || []) out[p.key] = p.default || '';
+ return out;
+}
+
+// Return the count of placeholders still unfilled in the rendered text (i.e.
+// `{{key}}` tokens the caller didn't supply). Used as a hint in the UI so the
+// designer knows whether the template is ready to copy.
+function unfilledTokens(text) {
+ const set = new Set();
+ const re = /\{\{\s*([a-z0-9_]+)\s*\}\}/gi;
+ let m;
+ while ((m = re.exec(String(text || ''))) !== null) set.add(m[1]);
+ return Array.from(set);
+}
+
+function summarize(tpl) {
+ return {
+ id: tpl.id,
+ title: tpl.title,
+ category: tpl.category,
+ sequencePosition: tpl.sequencePosition,
+ description: tpl.description,
+ audience: tpl.audience,
+ productLines: tpl.productLines || [],
+ cta: tpl.cta,
+ subject: tpl.subject,
+ preheader: tpl.preheader || '',
+ placeholders: tpl.placeholders || [],
+ };
+}
+
+// ── Module ──────────────────────────────────────────────────────────────────
+module.exports = {
+ id: 'templates',
+ title: 'Campaign Templates',
+ icon: '📋',
+
+ // exposed for unit testing / introspection only
+ _templates: TEMPLATES,
+ _fill: fill,
+
+ mount(router) {
+ const guard = (handler) => async (req, res) => {
+ try { await handler(req, res); }
+ catch (e) { res.status(500).json({ error: e.message }); }
+ };
+
+ // Module metadata — categories, brand voice context, gate copy.
+ router.get('/meta', guard(async (_req, res) => {
+ const categories = Array.from(new Set(TEMPLATES.map(t => t.category)));
+ res.json({
+ staged: true,
+ count: TEMPLATES.length,
+ categories,
+ brand: {
+ name: brand.name,
+ tagline: brand.tagline,
+ voice: brand.voice,
+ compliance: brand.compliance,
+ },
+ gateMessage: 'Campaign Templates are STAGED — this module drafts subject + body in the DW voice for you to copy into Constant Contact. Steve gates the actual send. CAN-SPAM footer (physical mailing address + working unsubscribe) must be added before any campaign ships.',
+ });
+ }));
+
+ // Full list with subject + body — the front-end uses this to render cards
+ // and let the user open one for the "Use this" flow. `?summary=1` returns
+ // a lighter shape (no body) for any future list-only views.
+ router.get('/list', guard(async (req, res) => {
+ const summary = String(req.query.summary || '') === '1';
+ res.json({
+ staged: true,
+ count: TEMPLATES.length,
+ templates: TEMPLATES.map(t => summary
+ ? summarize(t)
+ : { ...summarize(t), body: t.body }),
+ });
+ }));
+
+ // Single template — full subject + body + placeholders.
+ router.get('/:id', guard(async (req, res) => {
+ const tpl = TEMPLATES.find(t => t.id === req.params.id);
+ if (!tpl) return res.status(404).json({ error: 'template not found' });
+ res.json({ staged: true, template: { ...summarize(tpl), body: tpl.body } });
+ }));
+
+ // Fill a template. Body: { fields: { key: value, ... } }.
+ // Returns the filled subject + body, plus the placeholders still unfilled
+ // and a CAN-SPAM reminder. NEVER sends — this is the "Use this" return.
+ router.post('/:id/use', guard(async (req, res) => {
+ const tpl = TEMPLATES.find(t => t.id === req.params.id);
+ if (!tpl) return res.status(404).json({ error: 'template not found' });
+
+ const incoming = (req.body && typeof req.body === 'object' && req.body.fields && typeof req.body.fields === 'object')
+ ? req.body.fields : {};
+ // Defaults first so an empty form still produces a fully-readable draft.
+ const fields = { ...defaultsFor(tpl), ...incoming };
+
+ // Strip undefined / empty-string from incoming so defaults win for blanks.
+ for (const k of Object.keys(incoming)) {
+ const v = incoming[k];
+ if (v === undefined || v === null || String(v).trim() === '') {
+ fields[k] = defaultsFor(tpl)[k] || '';
+ }
+ }
+
+ const subject = fill(tpl.subject, fields);
+ const preheader = fill(tpl.preheader || '', fields);
+ const body = fill(tpl.body, fields);
+
+ const unfilled = Array.from(new Set([
+ ...unfilledTokens(subject),
+ ...unfilledTokens(body),
+ ...unfilledTokens(preheader),
+ ]));
+
+ res.json({
+ staged: true,
+ templateId: tpl.id,
+ title: tpl.title,
+ category: tpl.category,
+ cta: tpl.cta,
+ fields,
+ subject,
+ preheader,
+ body,
+ unfilled,
+ complianceReminder: brand.compliance,
+ sent: false,
+ sendNote: 'STAGED draft only — paste into Constant Contact, add the CAN-SPAM footer, and route through the gated send flow. This endpoint never delivers.',
+ });
+ }));
+ },
+};
diff --git a/public/panels/ab-tests.html b/public/panels/ab-tests.html
new file mode 100644
index 0000000..bbbb1f4
--- /dev/null
+++ b/public/panels/ab-tests.html
@@ -0,0 +1,95 @@
+<div id="ab-banner"></div>
+
+<div class="card">
+ <div class="row" style="justify-content:space-between;align-items:baseline;gap:12px">
+ <div>
+ <h2>A/B Tests</h2>
+ <div class="muted" style="font-size:12px">
+ Plan two campaign variants — subject + body/angle — set a holdout %, and
+ see predicted opens, clicks, and a winner call. <b>STAGED</b>: this module
+ plans and tracks; it never triggers a live send.
+ </div>
+ </div>
+ <span class="pill" id="ab-count">0 tests</span>
+ </div>
+</div>
+
+<div class="grid" style="grid-template-columns:420px 1fr;align-items:start">
+
+ <!-- new test form -->
+ <div class="card" id="ab-form-card">
+ <h2>New A/B test</h2>
+ <div class="muted" style="font-size:12px">Drafting only — no email leaves the building.</div>
+
+ <div style="margin-top:12px">
+ <label>Test name</label>
+ <input id="ab-name" placeholder="e.g. Grasscloth lookbook — subject test" />
+ </div>
+
+ <div class="row" style="margin-top:10px;gap:10px">
+ <div style="flex:1;min-width:120px">
+ <label>Audience size</label>
+ <input id="ab-audience" type="number" min="100" value="2400" />
+ </div>
+ <div style="flex:1;min-width:120px">
+ <label>Holdout %</label>
+ <input id="ab-holdout" type="number" min="0" max="50" step="1" value="10" />
+ </div>
+ </div>
+
+ <!-- Variant A -->
+ <div style="margin-top:14px;border-top:1px solid var(--line);padding-top:12px">
+ <div class="row" style="justify-content:space-between;align-items:baseline">
+ <label style="margin:0">Variant A</label>
+ <span class="pill" style="background:#efe7d6">A</span>
+ </div>
+ <div style="margin-top:6px">
+ <input id="ab-a-subject" placeholder="Subject line" maxlength="140" />
+ </div>
+ <div style="margin-top:8px">
+ <textarea id="ab-a-body" rows="3" placeholder="Body / preview text — what's the angle?"></textarea>
+ </div>
+ <div style="margin-top:8px">
+ <select id="ab-a-angle"></select>
+ </div>
+ </div>
+
+ <!-- Variant B -->
+ <div style="margin-top:14px;border-top:1px solid var(--line);padding-top:12px">
+ <div class="row" style="justify-content:space-between;align-items:baseline">
+ <label style="margin:0">Variant B</label>
+ <span class="pill" style="background:var(--cream)">B</span>
+ </div>
+ <div style="margin-top:6px">
+ <input id="ab-b-subject" placeholder="Subject line" maxlength="140" />
+ </div>
+ <div style="margin-top:8px">
+ <textarea id="ab-b-body" rows="3" placeholder="Body / preview text — what's the angle?"></textarea>
+ </div>
+ <div style="margin-top:8px">
+ <select id="ab-b-angle"></select>
+ </div>
+ </div>
+
+ <div class="row" style="margin-top:14px;justify-content:space-between;align-items:center">
+ <span class="muted" id="ab-form-msg" style="font-size:12px"> </span>
+ <button class="btn gold" id="ab-create">Stage A/B test</button>
+ </div>
+ </div>
+
+ <!-- tests list + detail -->
+ <div>
+ <div class="card">
+ <div class="row" style="justify-content:space-between;align-items:baseline">
+ <h2 style="margin:0">Staged tests</h2>
+ <button class="btn ghost" id="ab-refresh" style="padding:6px 12px;font-size:12.5px">Refresh</button>
+ </div>
+ <div id="ab-list" class="loading" style="margin-top:12px">Loading…</div>
+ </div>
+
+ <div class="card" id="ab-detail-card" style="display:none">
+ <div id="ab-detail"></div>
+ </div>
+ </div>
+
+</div>
diff --git a/public/panels/ab-tests.js b/public/panels/ab-tests.js
new file mode 100644
index 0000000..89de684
--- /dev/null
+++ b/public/panels/ab-tests.js
@@ -0,0 +1,287 @@
+// A/B Tests panel — author two variants, set a holdout %, stage the test,
+// and review predicted opens/clicks + a winner call. STAGED only — no send
+// action is exposed in the UI.
+window.MCC_PANELS = window.MCC_PANELS || {};
+window.MCC_PANELS['ab-tests'] = {
+ init(root) {
+ const $ = s => root.querySelector(s);
+ const $$ = s => Array.from(root.querySelectorAll(s));
+ const api = (p, opts) => fetch(`/api/ab-tests${p}`, opts).then(r => r.json());
+ const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c =>
+ ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
+ const pct = (n, d = 1) => (n == null ? '—' : (n * 100).toFixed(d) + '%');
+ const fmtDate = d => d ? new Date(d).toLocaleString(undefined,
+ { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '—';
+
+ let META = null;
+ let SELECTED = null;
+
+ function banner() {
+ $('#ab-banner').innerHTML = `<div class="muted-banner">
+ <b>STAGED only.</b> This module plans and tracks A/B tests with predicted
+ (mock) open/click rates. It does <b>not</b> send email. Approved variants
+ ship through Constant Contact, where Steve gates the live send.
+ </div>`;
+ }
+
+ function fillAngles() {
+ const angles = (META && META.angles) || [];
+ const options = ['<option value="">— angle (optional) —</option>']
+ .concat(angles.map(a => `<option value="${esc(a)}">${esc(a)}</option>`)).join('');
+ $('#ab-a-angle').innerHTML = options;
+ $('#ab-b-angle').innerHTML = options;
+ }
+
+ // ── list ─────────────────────────────────────────────────────────────
+ async function loadList() {
+ $('#ab-list').classList.add('loading');
+ $('#ab-list').textContent = 'Loading…';
+ const data = await api('/tests');
+ $('#ab-count').textContent = `${data.count} test${data.count === 1 ? '' : 's'}`;
+ const target = $('#ab-list');
+ target.classList.remove('loading');
+ if (!data.tests || !data.tests.length) {
+ target.innerHTML = '<div class="muted">No tests staged yet. Use the form to draft variant A and variant B.</div>';
+ return;
+ }
+ target.innerHTML = `
+ <table style="border-collapse:collapse;width:100%;font-size:13px">
+ <thead>
+ <tr style="text-align:left;font-size:11px;color:var(--mut)">
+ <th style="padding:6px 8px">Name</th>
+ <th style="padding:6px 8px">Status</th>
+ <th style="padding:6px 8px">Audience</th>
+ <th style="padding:6px 8px">Hold</th>
+ <th style="padding:6px 8px">Winner</th>
+ <th style="padding:6px 8px">Created</th>
+ <th></th>
+ </tr>
+ </thead>
+ <tbody>
+ ${data.tests.map(t => `
+ <tr data-id="${esc(t.id)}" style="border-top:1px solid var(--line);cursor:pointer"
+ class="ab-row${SELECTED === t.id ? ' ab-row-active' : ''}">
+ <td style="padding:7px 8px">
+ <div style="font-weight:600">${esc(t.name)}</div>
+ <div class="muted" style="font-size:11px">A: ${esc((t.subjectA || '').slice(0, 40))}${t.subjectA && t.subjectA.length > 40 ? '…' : ''}</div>
+ </td>
+ <td style="padding:7px 8px">
+ <span class="pill" style="background:${t.status === 'concluded' ? 'var(--cream)' : '#efe7d6'};
+ color:${t.status === 'concluded' ? 'var(--sage)' : 'var(--accent)'}">${esc(t.status)}</span>
+ </td>
+ <td style="padding:7px 8px">${Number(t.audienceSize || 0).toLocaleString()}</td>
+ <td style="padding:7px 8px">${esc(String(t.holdoutPct))}%</td>
+ <td style="padding:7px 8px">${t.winner ? `<b>${esc(t.winner)}</b>` : '<span class="muted">—</span>'}</td>
+ <td style="padding:7px 8px" class="muted" style="font-size:11px">${esc(fmtDate(t.createdAt))}</td>
+ <td style="padding:7px 8px;text-align:right">
+ <button class="btn ghost ab-view" data-id="${esc(t.id)}" style="padding:4px 10px;font-size:12px">View</button>
+ </td>
+ </tr>
+ `).join('')}
+ </tbody>
+ </table>
+ `;
+ target.querySelectorAll('.ab-view').forEach(b => b.onclick = (e) => {
+ e.stopPropagation();
+ loadDetail(b.dataset.id);
+ });
+ target.querySelectorAll('.ab-row').forEach(r => r.onclick = () => loadDetail(r.dataset.id));
+ }
+
+ // ── detail ───────────────────────────────────────────────────────────
+ async function loadDetail(id) {
+ SELECTED = id;
+ const data = await api(`/tests/${encodeURIComponent(id)}`);
+ if (data.error) {
+ $('#ab-detail-card').style.display = 'none';
+ return;
+ }
+ const t = data.test;
+ $('#ab-detail-card').style.display = '';
+
+ const r = t.results || {};
+ const w = t.winner || {};
+ const a = r.variant_a || {};
+ const b = r.variant_b || {};
+
+ const variantCard = (v, k, res, isWinner) => `
+ <div class="card" style="margin:0;padding:14px;
+ background:${isWinner ? 'var(--cream)' : '#fff'};
+ border-color:${isWinner ? 'var(--gold)' : 'var(--line)'}">
+ <div class="row" style="justify-content:space-between;align-items:baseline">
+ <h2 style="margin:0;font-size:15px">Variant ${esc(k)}</h2>
+ ${isWinner ? `<span class="pill" style="background:var(--gold);color:var(--ink)">winner</span>`
+ : `<span class="pill">${esc(v.angle || '—')}</span>`}
+ </div>
+ <div style="margin-top:8px">
+ <div class="muted" style="font-size:11px">Subject</div>
+ <div style="font-weight:600">${esc(v.subject)}</div>
+ </div>
+ <div style="margin-top:8px">
+ <div class="muted" style="font-size:11px">Body / angle</div>
+ <div style="font-size:13px;color:var(--ink)">${esc(v.body)}</div>
+ </div>
+ <div class="row" style="margin-top:12px;gap:18px">
+ <div><div class="muted" style="font-size:11px">Sent</div>
+ <div style="font-weight:700;font-size:18px">${Number(res.sent || 0).toLocaleString()}</div></div>
+ <div><div class="muted" style="font-size:11px">Opens</div>
+ <div style="font-weight:700;font-size:18px">${Number(res.opens || 0).toLocaleString()}
+ <span style="font-size:12px;color:var(--mut);font-weight:400">(${pct(res.openRate, 2)})</span></div></div>
+ <div><div class="muted" style="font-size:11px">Clicks</div>
+ <div style="font-weight:700;font-size:18px">${Number(res.clicks || 0).toLocaleString()}
+ <span style="font-size:12px;color:var(--mut);font-weight:400">(${pct(res.clickRate, 2)})</span></div></div>
+ </div>
+ ${(res.factors || []).length ? `
+ <div style="margin-top:10px">
+ <div class="muted" style="font-size:11px">Copy-quality factors</div>
+ <div style="margin-top:4px">${
+ res.factors.map(f => `<span class="pill" style="margin:2px 4px 0 0;background:#fbf9f5;border:1px solid var(--line);color:var(--ink)">${esc(f.name)}</span>`).join('')
+ }</div>
+ </div>` : ''}
+ </div>`;
+
+ const isAWin = w.winner === 'A';
+ const isBWin = w.winner === 'B';
+
+ const winnerCallout = w.winner
+ ? `<div style="background:#fff;border:1px solid var(--line);border-radius:10px;padding:14px;margin-top:14px">
+ <div class="row" style="justify-content:space-between;align-items:baseline">
+ <div>
+ <div class="muted" style="font-size:11px">Winner call</div>
+ <div style="font:600 22px/1.2 'Cormorant Garamond',Georgia,serif;color:var(--accent)">
+ Variant ${esc(w.winner)} wins on ${esc(w.metric)}
+ </div>
+ </div>
+ <span class="pill" style="background:${w.confidence === 'high' ? 'var(--sage)' : w.confidence === 'med' ? 'var(--gold)' : 'var(--cream)'};
+ color:${w.confidence === 'high' || w.confidence === 'med' ? 'var(--ink)' : 'var(--mut)'}">
+ ${esc(w.confidence)} confidence
+ </span>
+ </div>
+ <div class="row" style="gap:18px;margin-top:10px;font-size:13px">
+ <div><span class="muted">Lift abs:</span> <b>${pct(w.liftAbsolute, 2)}</b></div>
+ <div><span class="muted">Lift rel:</span> <b>${(w.liftRelative * 100).toFixed(1)}%</b></div>
+ <div><span class="muted">z-score:</span> <b>${esc(String(w.z))}</b></div>
+ <div><span class="muted">Composite A/B:</span> <b>${esc(String(w.compositeA))}</b> / <b>${esc(String(w.compositeB))}</b></div>
+ </div>
+ <div class="muted" style="font-size:11px;margin-top:8px">
+ Winner is chosen by a blended 60/40 open-rate × click-rate composite; confidence
+ comes from a two-proportion z-test on open-rate.
+ </div>
+ </div>`
+ : '<div class="muted" style="margin-top:12px">No winner — variants tied or audience empty.</div>';
+
+ $('#ab-detail').innerHTML = `
+ <div class="row" style="justify-content:space-between;align-items:baseline">
+ <div>
+ <h2 style="margin:0">${esc(t.name)}</h2>
+ <div class="muted" style="font-size:12px">
+ ${esc(t.audienceSize.toLocaleString())} contacts · ${esc(String(t.holdoutPct))}% holdout
+ (${Number(r.holdout && r.holdout.held || 0).toLocaleString()} held) ·
+ status <b>${esc(t.status)}</b> · simulated ${esc(fmtDate(r.simulatedAt))}
+ </div>
+ </div>
+ <div class="row" style="gap:8px">
+ <button class="btn ghost" data-act="simulate" data-id="${esc(t.id)}"
+ ${t.status === 'concluded' ? 'disabled style="opacity:.5;cursor:not-allowed"' : ''}>Re-simulate</button>
+ <button class="btn" data-act="conclude" data-id="${esc(t.id)}"
+ ${t.status === 'concluded' ? 'disabled style="opacity:.5;cursor:not-allowed"' : ''}>Freeze winner</button>
+ <button class="btn ghost" data-act="delete" data-id="${esc(t.id)}"
+ style="color:var(--accent)">Delete</button>
+ </div>
+ </div>
+
+ ${winnerCallout}
+
+ <div class="grid" style="grid-template-columns:1fr 1fr;margin-top:14px">
+ ${variantCard(t.variants[0], 'A', a, isAWin)}
+ ${variantCard(t.variants[1], 'B', b, isBWin)}
+ </div>
+
+ <div class="muted" style="font-size:11px;margin-top:12px">
+ Mock numbers — predicted using a deterministic copy-quality heuristic
+ over DW historical baselines (open ${pct((META && META.baseline.openRate) || 0.22)},
+ click ${pct((META && META.baseline.clickRate) || 0.032)}). Real reporting
+ can flow back through <code>PATCH /api/ab-tests/tests/${esc(t.id)}/results</code> later.
+ </div>
+ `;
+
+ $('#ab-detail').querySelectorAll('button[data-act]').forEach(btn => {
+ btn.onclick = async () => {
+ const act = btn.dataset.act;
+ if (act === 'delete') {
+ if (!confirm('Delete this staged A/B test? This cannot be undone.')) return;
+ await api(`/tests/${encodeURIComponent(btn.dataset.id)}`, { method: 'DELETE' });
+ $('#ab-detail-card').style.display = 'none';
+ SELECTED = null;
+ await loadList();
+ return;
+ }
+ if (act === 'conclude') {
+ if (!confirm('Freeze this test? Status becomes "concluded" and results stop changing.')) return;
+ }
+ const url = `/tests/${encodeURIComponent(btn.dataset.id)}/${act}`;
+ const res = await api(url, { method: 'POST' });
+ if (res.error) { alert(res.error); return; }
+ await loadList();
+ await loadDetail(btn.dataset.id);
+ };
+ });
+ }
+
+ // ── form ─────────────────────────────────────────────────────────────
+ $('#ab-create').onclick = async () => {
+ const body = {
+ name: $('#ab-name').value.trim(),
+ audienceSize: Number($('#ab-audience').value),
+ holdoutPct: Number($('#ab-holdout').value),
+ variants: [
+ {
+ subject: $('#ab-a-subject').value.trim(),
+ body: $('#ab-a-body').value.trim(),
+ angle: $('#ab-a-angle').value,
+ },
+ {
+ subject: $('#ab-b-subject').value.trim(),
+ body: $('#ab-b-body').value.trim(),
+ angle: $('#ab-b-angle').value,
+ },
+ ],
+ };
+ $('#ab-form-msg').textContent = 'Staging…';
+ $('#ab-form-msg').style.color = 'var(--mut)';
+
+ const res = await api('/tests', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ if (res.error) {
+ $('#ab-form-msg').style.color = 'var(--accent)';
+ $('#ab-form-msg').textContent = res.error + (res.issues ? ' — ' + res.issues.join(', ') : '');
+ return;
+ }
+ $('#ab-form-msg').style.color = 'var(--sage)';
+ $('#ab-form-msg').textContent = `Staged: ${res.test.id}`;
+ // soft reset
+ ['ab-name', 'ab-a-subject', 'ab-a-body', 'ab-b-subject', 'ab-b-body'].forEach(id => $('#' + id).value = '');
+ await loadList();
+ await loadDetail(res.test.id);
+ };
+
+ $('#ab-refresh').onclick = loadList;
+
+ (async () => {
+ banner();
+ META = await api('/meta');
+ fillAngles();
+ // Sensible defaults that show off the heuristic on first render.
+ $('#ab-a-subject').value = 'Discover the grasscloth archive — designer';
+ $('#ab-a-body').value = 'A fresh memo of Phillip Jeffries & Innovations grasscloths from the new archive. Request memo samples to feel the texture.';
+ $('#ab-a-angle').value = 'editorial';
+ $('#ab-b-subject').value = 'FREE samples!!! click here';
+ $('#ab-b-body').value = 'Get free wallpaper now act fast last chance.';
+ $('#ab-b-angle').value = 'promo';
+ await loadList();
+ })();
+ },
+};
diff --git a/public/panels/browse-abandon.html b/public/panels/browse-abandon.html
new file mode 100644
index 0000000..93c5164
--- /dev/null
+++ b/public/panels/browse-abandon.html
@@ -0,0 +1,91 @@
+<div id="ba-banner"></div>
+
+<div class="card">
+ <div class="row" style="justify-content:space-between;align-items:baseline;gap:12px">
+ <div>
+ <h2>Browse Abandonment</h2>
+ <div class="muted" style="font-size:12px">
+ A designer views a product but never asks for a memo sample. The drip stages a follow-up:
+ <b>Day 1 storytelling</b> → <b>Day 14 room-setting</b> → <b>Day 30 suppress</b>.
+ <b>PLANNING ONLY</b> — nothing here triggers a live send.
+ </div>
+ </div>
+ <div class="row" style="gap:8px;align-items:center">
+ <span class="pill" id="ba-summary-pill">— browse events</span>
+ </div>
+ </div>
+</div>
+
+<div class="grid" style="grid-template-columns:repeat(5,1fr);gap:12px;margin-bottom:18px">
+ <div class="card" style="margin:0;padding:14px 16px">
+ <div class="muted" style="font-size:11px;text-transform:uppercase;letter-spacing:.6px">Total events</div>
+ <div id="ba-stat-total" style="font:600 22px/1.2 'Cormorant Garamond',Georgia,serif;margin-top:4px">—</div>
+ </div>
+ <div class="card" style="margin:0;padding:14px 16px">
+ <div class="muted" style="font-size:11px;text-transform:uppercase;letter-spacing:.6px">Cooling window</div>
+ <div id="ba-stat-cooling" style="font:600 22px/1.2 'Cormorant Garamond',Georgia,serif;margin-top:4px">—</div>
+ </div>
+ <div class="card" style="margin:0;padding:14px 16px">
+ <div class="muted" style="font-size:11px;text-transform:uppercase;letter-spacing:.6px">In drip</div>
+ <div id="ba-stat-indrip" style="font:600 22px/1.2 'Cormorant Garamond',Georgia,serif;margin-top:4px;color:var(--gold)">—</div>
+ </div>
+ <div class="card" style="margin:0;padding:14px 16px">
+ <div class="muted" style="font-size:11px;text-transform:uppercase;letter-spacing:.6px">Would fire today</div>
+ <div id="ba-stat-today" style="font:600 22px/1.2 'Cormorant Garamond',Georgia,serif;margin-top:4px;color:var(--accent)">—</div>
+ </div>
+ <div class="card" style="margin:0;padding:14px 16px">
+ <div class="muted" style="font-size:11px;text-transform:uppercase;letter-spacing:.6px">Suppressed / sampled</div>
+ <div id="ba-stat-out" style="font:600 22px/1.2 'Cormorant Garamond',Georgia,serif;margin-top:4px;color:var(--sage)">—</div>
+ </div>
+</div>
+
+<div class="grid" style="grid-template-columns:1fr 1fr;align-items:start">
+
+ <!-- Left: drip config editor -->
+ <div class="card">
+ <div class="row" style="justify-content:space-between;align-items:baseline">
+ <h2 style="margin:0">Drip configuration</h2>
+ <span class="pill" style="background:#fff0e6;color:var(--accent)">staged · planning only</span>
+ </div>
+ <div id="ba-config" class="loading" style="margin-top:10px">Loading…</div>
+ </div>
+
+ <!-- Right: 7-day schedule projection -->
+ <div class="card">
+ <div class="row" style="justify-content:space-between;align-items:baseline">
+ <h2 style="margin:0">Next 7 days — what WOULD fire</h2>
+ <span class="pill" style="background:var(--cream)">dry-run</span>
+ </div>
+ <div id="ba-schedule" class="loading" style="margin-top:10px">Loading…</div>
+ </div>
+
+</div>
+
+<div class="card">
+ <div class="row" style="justify-content:space-between;align-items:baseline;gap:12px">
+ <div>
+ <h2 style="margin:0">Browse events</h2>
+ <div class="muted" style="font-size:12px">Mock browse activity. Click an event to preview what the next-due step would render.</div>
+ </div>
+ <div class="row" style="gap:8px">
+ <select id="ba-filter" style="width:auto;min-width:160px">
+ <option value="all">All stages</option>
+ <option value="in-drip">In drip</option>
+ <option value="cooling">Cooling window</option>
+ <option value="suppressed">Suppressed</option>
+ <option value="sample-requested">Sample requested</option>
+ <option value="would-fire">Would fire today</option>
+ </select>
+ <button class="btn ghost" id="ba-reseed" style="padding:7px 12px;font-size:12.5px">↻ Reseed mock events</button>
+ </div>
+ </div>
+ <div id="ba-events" class="loading" style="margin-top:10px">Loading…</div>
+</div>
+
+<div class="card" id="ba-preview-card" style="display:none">
+ <div class="row" style="justify-content:space-between;align-items:baseline">
+ <h2 style="margin:0">Dry-run preview</h2>
+ <span class="pill" style="background:#fff0e6;color:var(--accent)">NOT sent</span>
+ </div>
+ <div id="ba-preview" style="margin-top:10px"></div>
+</div>
diff --git a/public/panels/browse-abandon.js b/public/panels/browse-abandon.js
new file mode 100644
index 0000000..fb53703
--- /dev/null
+++ b/public/panels/browse-abandon.js
@@ -0,0 +1,392 @@
+// browse-abandon panel — Browse Abandonment drip designer + dry-run dashboard.
+// PLANNING ONLY. Talks to /api/browse-abandon; never triggers a live send.
+window.MCC_PANELS = window.MCC_PANELS || {};
+window.MCC_PANELS['browse-abandon'] = {
+ async init(root) {
+ const $ = s => root.querySelector(s);
+ const $$ = s => Array.from(root.querySelectorAll(s));
+ const api = (p, opts) => fetch(`/api/browse-abandon${p}`, opts).then(r => r.json());
+ const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c =>
+ ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
+ const fmtDate = d => d ? new Date(d).toLocaleString(undefined,
+ { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '—';
+
+ let META = null;
+ let CONFIG = null;
+ let EVENTS = [];
+ let SUMMARY = null;
+ let FILTER = 'all';
+
+ function banner() {
+ $('#ba-banner').innerHTML = `<div class="muted-banner">
+ 🔁 <b>STAGED — planning only.</b> ${esc(META && META.gateMessage || 'This module never sends.')}
+ </div>`;
+ }
+
+ // ── load ────────────────────────────────────────────────────────────────
+ try {
+ META = await api('/meta');
+ } catch (e) {
+ $('#ba-config').innerHTML = '<div class="muted-banner">Could not reach the browse-abandon service.</div>';
+ return;
+ }
+ banner();
+
+ async function loadConfig() {
+ const data = await api('/config');
+ CONFIG = data.config;
+ renderConfig();
+ }
+
+ async function loadEvents() {
+ const data = await api('/events');
+ EVENTS = data.events || [];
+ SUMMARY = data.summary || {};
+ renderStats();
+ renderEvents();
+ }
+
+ async function loadSchedule() {
+ const data = await api('/schedule?days=7');
+ renderSchedule(data);
+ }
+
+ // ── stats ───────────────────────────────────────────────────────────────
+ function renderStats() {
+ const s = SUMMARY || {};
+ $('#ba-summary-pill').textContent = `${s.total || 0} browse event${s.total === 1 ? '' : 's'}`;
+ $('#ba-stat-total').textContent = (s.total || 0).toLocaleString();
+ $('#ba-stat-cooling').textContent = (s.cooling || 0).toLocaleString();
+ $('#ba-stat-indrip').textContent = (s.inDrip || 0).toLocaleString();
+ $('#ba-stat-today').textContent = (s.wouldFireToday || 0).toLocaleString();
+ $('#ba-stat-out').textContent = ((s.sampleRequested || 0) + (s.suppressed || 0)).toLocaleString();
+ }
+
+ // ── config editor ───────────────────────────────────────────────────────
+ function renderConfig() {
+ const target = $('#ba-config');
+ target.classList.remove('loading');
+ if (!CONFIG) { target.innerHTML = '<div class="muted">No config.</div>'; return; }
+ target.innerHTML = `
+ <div class="grid" style="grid-template-columns:1fr 1fr;gap:10px;margin-bottom:12px">
+ <div>
+ <label for="ba-enabled">Drip status</label>
+ <select id="ba-enabled">
+ <option value="true" ${CONFIG.enabled ? 'selected' : ''}>Enabled (would evaluate events)</option>
+ <option value="false" ${!CONFIG.enabled ? 'selected' : ''}>Paused</option>
+ </select>
+ </div>
+ <div>
+ <label for="ba-threshold">No-sample window (days)</label>
+ <input id="ba-threshold" type="number" min="0" max="90" value="${Number(CONFIG.noSampleThresholdDays) || 1}" />
+ </div>
+ <div>
+ <label for="ba-cap">Daily send cap</label>
+ <input id="ba-cap" type="number" min="0" max="10000" value="${Number(CONFIG.dailySendCap) || 0}" />
+ </div>
+ <div>
+ <label> </label>
+ <div class="muted" style="font-size:11.5px;padding:8px 0">
+ Tokens you can use in subject + body: ${(META.tokens || []).map(t => `<code style="background:var(--paper);padding:1px 4px;border-radius:4px">{${esc(t)}}</code>`).join(' ')}
+ </div>
+ </div>
+ </div>
+
+ <div id="ba-steps"></div>
+
+ <div class="row" style="justify-content:space-between;align-items:center;margin-top:10px;border-top:1px solid var(--line);padding-top:12px">
+ <span class="muted" id="ba-msg" style="font-size:12px"> </span>
+ <div class="row" style="gap:8px">
+ <button class="btn ghost" id="ba-reset" style="padding:7px 12px">↺ Reset to defaults</button>
+ <button class="btn gold" id="ba-save" style="padding:7px 14px">💾 Save drip</button>
+ </div>
+ </div>
+ `;
+
+ renderSteps();
+ $('#ba-enabled').onchange = e => { CONFIG.enabled = e.target.value === 'true'; };
+ $('#ba-threshold').oninput = e => { CONFIG.noSampleThresholdDays = Number(e.target.value) || 0; };
+ $('#ba-cap').oninput = e => { CONFIG.dailySendCap = Number(e.target.value) || 0; };
+ $('#ba-save').onclick = saveConfig;
+ $('#ba-reset').onclick = resetConfig;
+ }
+
+ function renderSteps() {
+ const target = $('#ba-steps');
+ if (!target) return;
+ const steps = (CONFIG.steps || []).slice().sort((a, b) => a.dayOffset - b.dayOffset);
+ target.innerHTML = steps.map((s, i) => stepCard(s, i)).join('');
+ $$('[data-step-id]').forEach(card => {
+ const id = card.dataset.stepId;
+ const step = CONFIG.steps.find(x => x.id === id);
+ if (!step) return;
+ card.querySelectorAll('[data-field]').forEach(input => {
+ const f = input.dataset.field;
+ input.oninput = () => {
+ if (input.type === 'number') step[f] = Number(input.value) || 0;
+ else if (input.type === 'checkbox') step[f] = input.checked;
+ else step[f] = input.value;
+ };
+ input.onchange = input.oninput;
+ });
+ });
+ }
+
+ function stepCard(step, idx) {
+ const fieldHtml = step.kind === 'email' ? `
+ <div style="grid-column:1/-1">
+ <label>Subject line</label>
+ <input type="text" data-field="subject" value="${esc(step.subject)}" />
+ </div>
+ <div style="grid-column:1/-1">
+ <label>Body template <span class="muted" style="font-weight:400">— supports {tokens}</span></label>
+ <textarea rows="6" data-field="bodyTemplate">${esc(step.bodyTemplate)}</textarea>
+ </div>
+ <div>
+ <label>CTA label</label>
+ <input type="text" data-field="cta" value="${esc(step.cta || '')}" />
+ </div>
+ <div>
+ <label>Day offset</label>
+ <input type="number" min="0" max="365" data-field="dayOffset" value="${Number(step.dayOffset) || 0}" />
+ </div>
+ ` : `
+ <div>
+ <label>Suppress cohort</label>
+ <input type="text" data-field="suppressList" value="${esc(step.suppressList || '')}" />
+ </div>
+ <div>
+ <label>Day offset</label>
+ <input type="number" min="0" max="365" data-field="dayOffset" value="${Number(step.dayOffset) || 0}" />
+ </div>
+ <div style="grid-column:1/-1">
+ <label>Internal note</label>
+ <textarea rows="2" data-field="note">${esc(step.note || '')}</textarea>
+ </div>
+ `;
+ const kindIcon = step.kind === 'email' ? '✉️' : '⏹';
+ return `
+ <div data-step-id="${esc(step.id)}"
+ style="border:1px solid var(--line);border-radius:11px;padding:12px 14px;margin-bottom:10px;background:#fff">
+ <div class="row" style="justify-content:space-between;align-items:center;margin-bottom:10px">
+ <div class="row" style="gap:10px;align-items:center">
+ <span style="font:600 14px/1.2 'Cormorant Garamond',Georgia,serif;color:var(--mut)">${idx + 1}.</span>
+ <span style="font-size:18px">${kindIcon}</span>
+ <input type="text" data-field="title" value="${esc(step.title || '')}" style="font-weight:700;max-width:300px" />
+ <span class="pill" style="background:var(--cream)">Day ${Number(step.dayOffset) || 0}</span>
+ <span class="pill" style="background:#efece5;color:var(--ink)">${esc(step.kind)}</span>
+ </div>
+ <label style="display:inline-flex;gap:6px;align-items:center;margin:0;font-weight:600">
+ <input type="checkbox" data-field="enabled" ${step.enabled !== false ? 'checked' : ''} style="width:auto" />
+ <span style="font-size:12px">Enabled</span>
+ </label>
+ </div>
+ <div class="grid" style="grid-template-columns:repeat(2,1fr);gap:10px">
+ ${fieldHtml}
+ </div>
+ </div>
+ `;
+ }
+
+ function setMsg(text, kind) {
+ const el = $('#ba-msg'); if (!el) return;
+ el.textContent = text || '';
+ el.style.color = kind === 'err' ? 'var(--accent)' : kind === 'ok' ? 'var(--sage)' : 'var(--mut)';
+ }
+
+ async function saveConfig() {
+ setMsg('Saving…');
+ const res = await fetch('/api/browse-abandon/config', {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(CONFIG),
+ }).then(r => r.json());
+ if (res.error) {
+ setMsg(res.error + (res.issues ? ' — ' + res.issues.join(', ') : ''), 'err');
+ return;
+ }
+ CONFIG = res.config;
+ setMsg('Saved · drip re-evaluating mock events…', 'ok');
+ await Promise.all([loadEvents(), loadSchedule()]);
+ renderSteps();
+ }
+
+ async function resetConfig() {
+ if (!confirm('Reset the drip to default Day 1 / Day 14 / Day 30?')) return;
+ const res = await api('/config/reset', { method: 'POST' });
+ CONFIG = res.config;
+ renderConfig();
+ await Promise.all([loadEvents(), loadSchedule()]);
+ }
+
+ // ── schedule projection ─────────────────────────────────────────────────
+ function renderSchedule(data) {
+ const target = $('#ba-schedule');
+ target.classList.remove('loading');
+ const cap = Number(data.dailySendCap) || 0;
+ const proj = data.projection || [];
+ const max = Math.max(1, ...proj.map(d => d.totalWouldFire || 0));
+ target.innerHTML = `
+ <div class="muted" style="font-size:11.5px;margin-bottom:10px">
+ Daily cap: <b>${cap.toLocaleString()}</b> ·
+ <span class="muted">Bars show how many mock events WOULD trigger a drip step each day.</span>
+ </div>
+ ${proj.map(day => {
+ const pct = Math.round(((day.totalWouldFire || 0) / max) * 100);
+ const overCap = day.hitCap;
+ const barColor = overCap ? 'var(--accent)' : 'var(--gold)';
+ return `
+ <div style="display:grid;grid-template-columns:90px 1fr 70px;gap:10px;align-items:center;padding:6px 0;border-top:1px solid var(--line)">
+ <div style="font-weight:600;font-size:12.5px">${esc(day.label)}</div>
+ <div>
+ <div style="background:var(--paper);border:1px solid var(--line);border-radius:99px;height:10px;overflow:hidden">
+ <div style="background:${barColor};height:100%;width:${pct}%"></div>
+ </div>
+ <div class="muted" style="font-size:10.5px;margin-top:3px">
+ ${(day.byStep || []).map(b => `<span style="margin-right:8px">D${b.dayOffset} ${esc(b.title)}: <b>${b.count}</b></span>`).join('') || 'no steps due'}
+ </div>
+ </div>
+ <div style="text-align:right;font-weight:700">${(day.totalWouldFire || 0).toLocaleString()}${overCap ? ' <span class="pill" style="background:#fff0e6;color:var(--accent);font-size:9px">cap</span>' : ''}</div>
+ </div>
+ `;
+ }).join('')}
+ <div class="muted-banner" style="margin-top:12px">${esc(data.sendNote || 'Planning only — no sends.')}</div>
+ `;
+ }
+
+ // ── events table ────────────────────────────────────────────────────────
+ function renderEvents() {
+ const target = $('#ba-events');
+ target.classList.remove('loading');
+ const filtered = EVENTS.filter(e => {
+ if (FILTER === 'all') return true;
+ if (FILTER === 'would-fire') return !!e.wouldFire;
+ return e.stage === FILTER;
+ });
+ if (!filtered.length) {
+ target.innerHTML = '<div class="muted">No events match this filter.</div>';
+ return;
+ }
+ target.innerHTML = `
+ <div style="overflow-x:auto">
+ <table style="width:100%;border-collapse:collapse;font-size:12.5px">
+ <thead>
+ <tr style="text-align:left;color:var(--mut);font-weight:600">
+ <th style="padding:8px 6px;border-bottom:1px solid var(--line)">Contact</th>
+ <th style="padding:8px 6px;border-bottom:1px solid var(--line)">Product</th>
+ <th style="padding:8px 6px;border-bottom:1px solid var(--line)">Browsed</th>
+ <th style="padding:8px 6px;border-bottom:1px solid var(--line)">Stage</th>
+ <th style="padding:8px 6px;border-bottom:1px solid var(--line)">Would fire</th>
+ <th style="padding:8px 6px;border-bottom:1px solid var(--line)"></th>
+ </tr>
+ </thead>
+ <tbody>
+ ${filtered.map(e => `
+ <tr data-event-id="${esc(e.id)}" style="border-bottom:1px solid var(--line)">
+ <td style="padding:9px 6px;vertical-align:top">
+ <div style="font-weight:600">${esc(e.contactFirstName)}</div>
+ <div class="muted" style="font-size:11px">${esc(e.contactEmail)}</div>
+ <div class="muted" style="font-size:10.5px">${esc(e.segment || '')}</div>
+ </td>
+ <td style="padding:9px 6px;vertical-align:top">
+ <div style="font-weight:600">${esc(e.productPattern)} — ${esc(e.productColor)}</div>
+ <div class="muted" style="font-size:11px">${esc(e.vendor)} · ${esc(e.productSku)}</div>
+ </td>
+ <td style="padding:9px 6px;vertical-align:top">
+ <div>${e.browsedDaysAgo == null ? '—' : 'Day ' + e.browsedDaysAgo}</div>
+ <div class="muted" style="font-size:10.5px">${fmtDate(e.browsedAt)}</div>
+ </td>
+ <td style="padding:9px 6px;vertical-align:top">
+ ${stagePill(e.stage)}
+ ${e.currentStep ? `<div class="muted" style="font-size:10.5px;margin-top:3px">at ${esc(e.currentStep.title)}</div>` : ''}
+ </td>
+ <td style="padding:9px 6px;vertical-align:top">
+ ${e.wouldFire
+ ? `<span class="pill" style="background:#fff0e6;color:var(--accent)">${esc(e.wouldFire.title)}</span>`
+ : `<span class="muted" style="font-size:11px">—</span>`}
+ </td>
+ <td style="padding:9px 6px;vertical-align:top;text-align:right">
+ <button class="btn ghost ba-preview" data-id="${esc(e.id)}" style="padding:4px 9px;font-size:11.5px">👁 Preview</button>
+ </td>
+ </tr>
+ `).join('')}
+ </tbody>
+ </table>
+ </div>
+ `;
+ target.querySelectorAll('.ba-preview').forEach(b => b.onclick = () => previewEvent(b.dataset.id));
+ }
+
+ function stagePill(stage) {
+ const map = {
+ 'pending': { bg: '#efece5', fg: 'var(--ink)', label: 'pending' },
+ 'cooling': { bg: '#eef2eb', fg: 'var(--sage)', label: 'cooling' },
+ 'in-drip': { bg: 'var(--cream)', fg: 'var(--accent)', label: 'in drip' },
+ 'sample-requested': { bg: '#e7f4ea', fg: 'var(--sage)', label: 'sample requested' },
+ 'suppressed': { bg: '#f2eee5', fg: 'var(--mut)', label: 'suppressed' },
+ };
+ const s = map[stage] || map.pending;
+ return `<span class="pill" style="background:${s.bg};color:${s.fg}">${esc(s.label)}</span>`;
+ }
+
+ async function previewEvent(id) {
+ const res = await fetch(`/api/browse-abandon/fire/${encodeURIComponent(id)}`, { method: 'POST' }).then(r => r.json());
+ $('#ba-preview-card').style.display = '';
+ const target = $('#ba-preview');
+ if (res.error) { target.innerHTML = `<div class="muted-banner">${esc(res.error)}</div>`; return; }
+ if (!res.preview && !res.suppress) {
+ target.innerHTML = `
+ <div class="muted-banner">${esc(res.reason || 'No step is due for this event today.')}</div>
+ <div class="muted" style="font-size:12px">Stage: <b>${esc(res.stage)}</b></div>
+ `;
+ return;
+ }
+ if (res.preview) {
+ const p = res.preview;
+ target.innerHTML = `
+ <div class="row" style="gap:8px;align-items:center;margin-bottom:10px">
+ <span class="pill" style="background:var(--cream)">${esc(res.step.title)}</span>
+ <span class="pill" style="background:#efece5;color:var(--ink)">Day ${res.step.dayOffset}</span>
+ <span class="pill" style="background:#fff0e6;color:var(--accent)">NOT sent · dry-run</span>
+ </div>
+ <div style="border:1px solid var(--line);border-radius:11px;padding:14px 16px;background:#fff">
+ <div class="muted" style="font-size:11px;letter-spacing:.4px;text-transform:uppercase">To</div>
+ <div style="font-weight:600;margin-bottom:8px">${esc(p.to)}</div>
+ <div class="muted" style="font-size:11px;letter-spacing:.4px;text-transform:uppercase">Subject</div>
+ <div style="font:600 16px/1.3 'Cormorant Garamond',Georgia,serif;margin-bottom:10px">${esc(p.subject)}</div>
+ <pre style="white-space:pre-wrap;font:13px/1.55 Georgia,serif;background:var(--paper);border:1px solid var(--line);border-radius:8px;padding:12px;margin:0">${esc(p.body)}</pre>
+ ${p.cta ? `<div style="margin-top:10px"><button class="btn gold" style="padding:7px 14px" disabled title="Dry-run only">${esc(p.cta)}</button></div>` : ''}
+ <div class="muted" style="font-size:10.5px;margin-top:14px;border-top:1px solid var(--line);padding-top:8px">${esc(p.canSpamFooter)}</div>
+ </div>
+ <div class="muted-banner" style="margin-top:10px">${esc(res.note || 'Planning only — no send.')}</div>
+ `;
+ } else if (res.suppress) {
+ target.innerHTML = `
+ <div class="row" style="gap:8px;align-items:center;margin-bottom:10px">
+ <span class="pill" style="background:var(--cream)">${esc(res.step.title)}</span>
+ <span class="pill" style="background:#efece5;color:var(--ink)">Day ${res.step.dayOffset}</span>
+ <span class="pill" style="background:#fff0e6;color:var(--accent)">NO ACTION · dry-run</span>
+ </div>
+ <div style="border:1px solid var(--line);border-radius:11px;padding:14px 16px;background:#fff">
+ <div style="font-weight:600;margin-bottom:6px">Would suppress to: <code>${esc(res.suppress.list)}</code></div>
+ <div class="muted" style="font-size:12px;line-height:1.55">${esc(res.suppress.note || '')}</div>
+ </div>
+ <div class="muted-banner" style="margin-top:10px">${esc(res.note || 'Planning only — no action.')}</div>
+ `;
+ }
+ $('#ba-preview-card').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
+ }
+
+ // ── controls ────────────────────────────────────────────────────────────
+ $('#ba-filter').onchange = e => { FILTER = e.target.value; renderEvents(); };
+ $('#ba-reseed').onclick = async () => {
+ const btn = $('#ba-reseed'); btn.disabled = true; btn.textContent = 'Reseeding…';
+ await api('/events/refresh-mock', { method: 'POST' });
+ await Promise.all([loadEvents(), loadSchedule()]);
+ btn.disabled = false; btn.textContent = '↻ Reseed mock events';
+ };
+
+ // ── initial render ──────────────────────────────────────────────────────
+ await Promise.all([loadConfig(), loadEvents(), loadSchedule()]);
+ },
+};
diff --git a/public/panels/copy.html b/public/panels/copy.html
index afce9f1..d41783a 100644
--- a/public/panels/copy.html
+++ b/public/panels/copy.html
@@ -40,3 +40,34 @@
</div>
<div id="copy-out" class="grid"></div>
+
+<div class="card">
+ <h2>Score a Subject Line or Copy Block</h2>
+ <p class="muted" style="margin:0 0 14px">Predicted open potential, luxury-tone breakdown, and merge-tag coverage. Heuristic by default; uses Gemini for a second opinion when <b>GEMINI_API_KEY</b> is configured.</p>
+
+ <div id="score-banner"></div>
+
+ <div class="grid" style="grid-template-columns:1fr 2fr;align-items:flex-start">
+ <div>
+ <label for="score-kind">Format</label>
+ <select id="score-kind">
+ <option value="subject-lines">Email subject line</option>
+ <option value="email">Email body</option>
+ <option value="instagram">Instagram caption</option>
+ <option value="tiktok">TikTok caption</option>
+ <option value="sms">SMS / text</option>
+ <option value="headline">Web / ad headline</option>
+ </select>
+ <div style="margin-top:14px">
+ <button id="score-go" class="btn gold" style="width:100%">📊 Score this copy</button>
+ </div>
+ <div class="muted" style="margin-top:10px;font-size:12px">Tip: paste a subject line OR a full email body. Merge tags like <code>{{first_name}}</code>, <code>[firstName]</code>, <code>*|FNAME|*</code> all count.</div>
+ </div>
+ <div>
+ <label for="score-text">Copy to score</label>
+ <textarea id="score-text" rows="8" placeholder="Inside the archive: {{first_name}}, a closer look at our new grasscloth…"></textarea>
+ </div>
+ </div>
+
+ <div id="score-out" style="margin-top:18px"></div>
+</div>
diff --git a/public/panels/copy.js b/public/panels/copy.js
index 752f83a..417648f 100644
--- a/public/panels/copy.js
+++ b/public/panels/copy.js
@@ -131,5 +131,139 @@ window.MCC_PANELS['copy'] = {
if (presets && presets.llm === false) {
banner.innerHTML = '<div class="muted-banner">Templates mode — add <b>ANTHROPIC_API_KEY</b> for AI-written copy.</div>';
}
+
+ // ── inline copy scoring ──────────────────────────────────────────────────
+ const scoreOut = $('#score-out');
+ const scoreBanner = $('#score-banner');
+ const scoreBtn = $('#score-go');
+ const scoreKind = $('#score-kind');
+ const scoreText = $('#score-text');
+ if (presets && presets.llm === false) {
+ scoreBanner.innerHTML = '<div class="muted-banner">Heuristic mode — add <b>GEMINI_API_KEY</b> for a model-graded second opinion.</div>';
+ }
+
+ function classColor(cls) {
+ return { excellent:'#3f7a4d', strong:'#6f7a63', fair:'#b8925a', weak:'#a26b48', poor:'#8a4a3a' }[cls] || '#8a8275';
+ }
+
+ function bar(label, pct, color) {
+ const p = Math.max(0, Math.min(100, +pct || 0));
+ return `<div style="margin-bottom:10px">
+ <div class="row" style="justify-content:space-between;margin-bottom:4px;font-size:12.5px">
+ <span style="font-weight:600">${esc(label)}</span><span class="muted">${p}%</span>
+ </div>
+ <div style="height:10px;background:#efe9dc;border-radius:999px;overflow:hidden">
+ <div style="width:${p}%;height:100%;background:${color}"></div>
+ </div>
+ </div>`;
+ }
+
+ function renderScore(data) {
+ if (!data || data.error) {
+ scoreOut.innerHTML = `<div class="muted-banner">${esc((data && data.error) || 'Could not score that copy.')}</div>`;
+ return;
+ }
+ const c = classColor(data.openClass);
+ const tone = data.tone || { reassurance:0, storytelling:0, urgency:0 };
+ const pers = data.personalization || { tokens:[], recommended:[], missing:[], coverage:0 };
+ const recList = (pers.recommended || []).map(r =>
+ `<span class="pill" style="background:${r.present ? '#e8f1ea' : '#fff0e6'};color:${r.present ? '#3f7a4d' : '#8a5a44'};margin-right:6px">${r.present ? '✓' : '○'} ${esc(r.label)}</span>`
+ ).join('') || '<span class="muted">No personalization recommended for this format.</span>';
+ const tokenList = (pers.tokens || []).length
+ ? '<div style="margin-top:6px">' + pers.tokens.map(t =>
+ `<code style="background:#f4efe7;border:1px solid var(--line);border-radius:6px;padding:2px 7px;margin:2px 4px 2px 0;display:inline-block;font-size:12px">${esc(t.raw)}<span class="muted" style="margin-left:5px">${esc(t.label)}</span></code>`
+ ).join('') + '</div>'
+ : '<div class="muted" style="margin-top:6px;font-size:12.5px">No merge tags detected in this copy.</div>';
+ const flags = (data.flags || []).length
+ ? '<ul style="margin:6px 0 0 18px;padding:0">' + data.flags.map(f => `<li style="color:#8a5a44">${esc(f)}</li>`).join('') + '</ul>'
+ : '';
+ const reasons = (data.reasons || []).slice(0, 6).map(r =>
+ `<li><b style="color:${r.d >= 0 ? '#3f7a4d' : '#8a4a3a'}">${r.d >= 0 ? '+' : ''}${r.d}</b> — ${esc(r.why)}</li>`
+ ).join('');
+ const detail = data.detail
+ ? `<div class="muted" style="font-size:12px;margin-top:6px">Heuristic: ${data.detail.heuristic.score} · Gemini: ${data.detail.gemini.score}</div>`
+ : '';
+ const sourceLabel = data.source === 'gemini+heuristic' ? '🧠 Gemini + heuristic' : '📐 Heuristic';
+
+ scoreOut.innerHTML = `
+ <div class="grid" style="grid-template-columns:1fr 2fr;align-items:flex-start;gap:20px">
+ <div class="card" style="margin:0;text-align:center;padding:24px 18px;border-color:${c}">
+ <div class="muted" style="font-size:11px;letter-spacing:1px;text-transform:uppercase">Predicted open</div>
+ <div style="font:600 56px/1 'Cormorant Garamond',Georgia,serif;color:${c};margin:6px 0 4px">${data.score}</div>
+ <div class="pill" style="background:${c};color:#fff">${esc(data.openClass)}</div>
+ ${detail}
+ <div class="muted" style="margin-top:12px;font-size:11px">${sourceLabel}</div>
+ </div>
+ <div class="card" style="margin:0">
+ <div style="font-weight:600;margin-bottom:10px">Luxury tone breakdown</div>
+ ${bar('Reassurance', tone.reassurance, '#6f7a63')}
+ ${bar('Storytelling', tone.storytelling, '#b8925a')}
+ ${bar('Urgency', tone.urgency, '#8a5a44')}
+ ${tone.confidence === 'low' ? '<div class="muted" style="font-size:12px;margin-top:4px">⚠ Low signal — try adding more brand-voice words.</div>' : ''}
+ </div>
+ </div>
+
+ <div class="card" style="margin-top:16px">
+ <div style="font-weight:600;margin-bottom:6px">Personalization coverage <span class="muted" style="font-weight:400">— ${pers.coverage}%</span></div>
+ <div>${recList}</div>
+ ${tokenList}
+ </div>
+
+ ${(data.flags && data.flags.length) || reasons ? `
+ <div class="card" style="margin-top:16px">
+ ${data.flags && data.flags.length ? `<div style="font-weight:600;margin-bottom:4px">Flags</div>${flags}` : ''}
+ ${reasons ? `<div style="font-weight:600;margin:${data.flags && data.flags.length ? '12px' : '0'} 0 4px">Why this score</div><ul style="margin:0 0 0 18px;padding:0;font-size:13px">${reasons}</ul>` : ''}
+ </div>` : ''}
+
+ ${data.notes ? `<div class="muted" style="font-size:12.5px;margin-top:10px">${esc(data.notes)}</div>` : ''}
+ `;
+ }
+
+ async function score() {
+ const text = scoreText.value;
+ if (!text.trim()) {
+ scoreOut.innerHTML = '<div class="muted-banner">Paste some copy to score.</div>';
+ return;
+ }
+ scoreBtn.disabled = true;
+ const orig = scoreBtn.textContent;
+ scoreBtn.textContent = 'Scoring…';
+ scoreOut.innerHTML = '<div class="loading">Reading the copy…</div>';
+ try {
+ const r = await fetch('/api/copy/score', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ kind: scoreKind.value, text }),
+ });
+ const data = await r.json();
+ if (!r.ok) {
+ scoreOut.innerHTML = `<div class="muted-banner">${esc(data.error || ('Score failed: HTTP ' + r.status))}</div>`;
+ } else {
+ renderScore(data);
+ }
+ } catch (e) {
+ scoreOut.innerHTML = '<div class="muted-banner">Could not reach the scoring service.</div>';
+ } finally {
+ scoreBtn.disabled = false;
+ scoreBtn.textContent = orig;
+ }
+ }
+
+ scoreBtn.addEventListener('click', score);
+ // Lightweight "use last generated variant" — if the user clicks a generated
+ // card's text, drop it straight into the score box. (Wires in generically
+ // after each render via event delegation on the output container.)
+ out.addEventListener('click', e => {
+ const card = e.target.closest('.card');
+ if (!card || e.target.closest('.copy-btn')) return;
+ const txt = card.querySelector('[style*="white-space:pre-wrap"]');
+ if (!txt) return;
+ scoreText.value = txt.textContent || '';
+ const kindVal = $('#copy-kind').value;
+ if (Array.from(scoreKind.options).some(o => o.value === kindVal)) {
+ scoreKind.value = kindVal;
+ }
+ scoreText.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ });
},
};
diff --git a/public/panels/journeys.html b/public/panels/journeys.html
new file mode 100644
index 0000000..74846aa
--- /dev/null
+++ b/public/panels/journeys.html
@@ -0,0 +1,53 @@
+<div id="journeys-banner"></div>
+
+<div class="card">
+ <div class="row" style="justify-content:space-between;align-items:baseline;gap:12px">
+ <div>
+ <h2>Cross-Channel Journeys</h2>
+ <div class="muted" style="font-size:12px">
+ Plan a multi-step journey across email, SMS, social, and retargeting for one segment.
+ Add steps, set conditions, and review the recommended cross-channel ladder.
+ <b>PLANNING ONLY</b> — nothing here triggers a live send.
+ </div>
+ </div>
+ <span class="pill" id="journeys-count">0 journeys</span>
+ </div>
+</div>
+
+<div class="grid" style="grid-template-columns:320px 1fr;align-items:start">
+
+ <!-- Left rail: journey list + new -->
+ <div>
+ <div class="card">
+ <div class="row" style="justify-content:space-between;align-items:baseline">
+ <h2 style="margin:0;font-size:15px">Journeys</h2>
+ <button class="btn ghost" id="journeys-new" style="padding:5px 11px;font-size:12.5px">+ New</button>
+ </div>
+ <div id="journeys-list" class="loading" style="margin-top:10px">Loading…</div>
+ </div>
+ </div>
+
+ <!-- Right pane: editor / recommendations / dry-run -->
+ <div>
+ <div class="card" id="journeys-editor-card" style="display:none">
+ <div id="journeys-editor"></div>
+ </div>
+
+ <div class="card" id="journeys-recs-card" style="display:none">
+ <div class="row" style="justify-content:space-between;align-items:baseline">
+ <h2 style="margin:0;font-size:15px">Recommended next steps</h2>
+ <span class="pill" style="background:var(--cream)">cross-channel ladder</span>
+ </div>
+ <div id="journeys-recs" style="margin-top:10px"></div>
+ </div>
+
+ <div class="card" id="journeys-run-card" style="display:none">
+ <div class="row" style="justify-content:space-between;align-items:baseline">
+ <h2 style="margin:0;font-size:15px">Dry-run simulation</h2>
+ <span class="pill" style="background:#fff0e6;color:var(--accent)">no sends · planning only</span>
+ </div>
+ <div id="journeys-run" style="margin-top:10px"></div>
+ </div>
+ </div>
+
+</div>
diff --git a/public/panels/journeys.js b/public/panels/journeys.js
new file mode 100644
index 0000000..64637a4
--- /dev/null
+++ b/public/panels/journeys.js
@@ -0,0 +1,427 @@
+// journeys panel — Cross-Channel Journeys builder + recommendations + dry-run.
+// PLANNING ONLY. The panel persists the journey to data/journeys.json via the
+// /api/journeys endpoints; nothing here triggers a live send.
+window.MCC_PANELS = window.MCC_PANELS || {};
+window.MCC_PANELS['journeys'] = {
+ async init(root) {
+ const $ = s => root.querySelector(s);
+ const $$ = s => Array.from(root.querySelectorAll(s));
+ const api = (p, opts) => fetch(`/api/journeys${p}`, opts).then(r => r.json());
+ const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c =>
+ ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
+ const fmtDate = d => d ? new Date(d).toLocaleString(undefined,
+ { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '—';
+
+ let META = null;
+ let REFS = { segments: [], templates: [] };
+ let SELECTED = null; // currently-open journey id (or 'draft')
+ let DRAFT = null; // working copy {name, description, segmentId, ..., steps:[]}
+
+ function banner() {
+ $('#journeys-banner').innerHTML = `<div class="muted-banner">
+ 🗺️ <b>STAGED — planning only.</b> ${esc(META && META.gateMessage || 'This module never sends.')}
+ </div>`;
+ }
+
+ // ── load ────────────────────────────────────────────────────────────────
+ try {
+ [META, REFS] = await Promise.all([
+ api('/meta'),
+ api('/refs'),
+ ]);
+ } catch (e) {
+ $('#journeys-list').innerHTML = '<div class="muted-banner">Could not reach the journeys service.</div>';
+ return;
+ }
+ banner();
+
+ // ── list ────────────────────────────────────────────────────────────────
+ async function loadList() {
+ const data = await api('/journeys');
+ $('#journeys-count').textContent = `${data.count} journey${data.count === 1 ? '' : 's'}`;
+ const target = $('#journeys-list');
+ target.classList.remove('loading');
+ if (!data.journeys || !data.journeys.length) {
+ target.innerHTML = '<div class="muted">No journeys yet. Click + New to plan one.</div>';
+ return;
+ }
+ target.innerHTML = data.journeys.map(j => `
+ <div class="jny-item${SELECTED === j.id ? ' active' : ''}" data-id="${esc(j.id)}"
+ style="border:1px solid ${SELECTED === j.id ? 'var(--gold)' : 'var(--line)'};
+ background:${SELECTED === j.id ? 'var(--cream)' : '#fff'};
+ border-radius:10px;padding:10px 12px;margin-bottom:8px;cursor:pointer">
+ <div style="font-weight:600;font-size:13.5px;line-height:1.25">${esc(j.name)}</div>
+ <div class="muted" style="font-size:11.5px;margin-top:3px">
+ ${esc(j.segmentName || '— no segment —')} · ${Number(j.audienceSize || 0).toLocaleString()} contacts
+ </div>
+ <div class="row" style="gap:4px;margin-top:6px;flex-wrap:wrap">
+ <span class="pill" style="font-size:10px">${j.stepCount} step${j.stepCount === 1 ? '' : 's'}</span>
+ ${(j.kinds || []).map(k => `<span class="pill" style="font-size:10px;background:#efece5;color:var(--ink)">${esc(k)}</span>`).join('')}
+ </div>
+ </div>
+ `).join('');
+ target.querySelectorAll('.jny-item').forEach(el => el.onclick = () => openJourney(el.dataset.id));
+ }
+
+ // ── new (draft) ─────────────────────────────────────────────────────────
+ $('#journeys-new').onclick = () => {
+ SELECTED = 'draft';
+ DRAFT = {
+ name: '',
+ description: '',
+ segmentId: '',
+ segmentName: '',
+ audienceSize: 500,
+ steps: [],
+ };
+ renderEditor();
+ loadList();
+ };
+
+ // ── open existing ───────────────────────────────────────────────────────
+ async function openJourney(id) {
+ SELECTED = id;
+ const data = await api(`/journeys/${encodeURIComponent(id)}`);
+ if (data.error) { alert(data.error); return; }
+ DRAFT = JSON.parse(JSON.stringify(data.journey));
+ renderEditor();
+ loadList();
+ loadRecommendations();
+ $('#journeys-run-card').style.display = 'none';
+ }
+
+ // ── editor ──────────────────────────────────────────────────────────────
+ function renderEditor() {
+ if (!DRAFT) { $('#journeys-editor-card').style.display = 'none'; return; }
+ $('#journeys-editor-card').style.display = '';
+ const isNew = SELECTED === 'draft';
+
+ const segmentOptions = ['<option value="">— pick a segment —</option>'].concat(
+ (REFS.segments || []).map(s =>
+ `<option value="${esc(s.id)}" data-name="${esc(s.name)}" ${DRAFT.segmentId === s.id ? 'selected' : ''}>${esc(s.name)}${s.estimatedSize != null ? ` (~${s.estimatedSize})` : ''}</option>`
+ )).join('');
+
+ $('#journeys-editor').innerHTML = `
+ <div class="row" style="justify-content:space-between;align-items:baseline">
+ <h2 style="margin:0">${isNew ? 'New journey' : esc(DRAFT.name || 'Untitled journey')}</h2>
+ <span class="pill" style="background:#fff0e6;color:var(--accent)">staged · planning only</span>
+ </div>
+
+ <div class="grid" style="grid-template-columns:1fr 1fr;margin-top:14px">
+ <div>
+ <label for="j-name">Journey name</label>
+ <input id="j-name" type="text" placeholder="e.g. Designer Welcome — Cross-Channel" value="${esc(DRAFT.name)}" />
+ </div>
+ <div>
+ <label for="j-segment">Segment</label>
+ <select id="j-segment">${segmentOptions}</select>
+ </div>
+ <div style="grid-column:1/-1">
+ <label for="j-desc">Description</label>
+ <textarea id="j-desc" rows="2" placeholder="What is this journey for?">${esc(DRAFT.description)}</textarea>
+ </div>
+ <div>
+ <label for="j-audience">Audience size (estimated)</label>
+ <input id="j-audience" type="number" min="0" value="${Number(DRAFT.audienceSize) || 0}" />
+ </div>
+ <div>
+ <label> </label>
+ <div class="muted" style="font-size:12px;padding:9px 0">
+ Used by the dry-run simulation. Does not affect a live send.
+ </div>
+ </div>
+ </div>
+
+ <div style="border-top:1px solid var(--line);margin:14px 0 10px;padding-top:12px">
+ <div class="row" style="justify-content:space-between;align-items:baseline">
+ <h3 style="margin:0;font:600 14px/1.2 'Cormorant Garamond',Georgia,serif">Steps</h3>
+ <div class="row" style="gap:6px">
+ ${(META.kinds || []).map(k =>
+ `<button class="btn ghost j-add" data-kind="${esc(k.key)}" style="padding:5px 10px;font-size:12px">+ ${esc(k.icon)} ${esc(k.label)}</button>`
+ ).join('')}
+ </div>
+ </div>
+ <div id="j-steps" style="margin-top:10px"></div>
+ </div>
+
+ <div class="row" style="justify-content:space-between;align-items:center;margin-top:10px;border-top:1px solid var(--line);padding-top:12px">
+ <span class="muted" id="j-msg" style="font-size:12px"> </span>
+ <div class="row" style="gap:8px">
+ ${isNew ? '' : `<button class="btn ghost" id="j-delete" style="color:var(--accent);padding:7px 14px">Delete</button>`}
+ <button class="btn ghost" id="j-run" ${isNew ? 'disabled style="opacity:.5;cursor:not-allowed"' : ''} style="padding:7px 14px">▶ Dry-run</button>
+ <button class="btn gold" id="j-save" style="padding:7px 14px">${isNew ? '💾 Save journey' : '💾 Save changes'}</button>
+ </div>
+ </div>
+ `;
+
+ renderSteps();
+
+ $('#j-name').oninput = e => { DRAFT.name = e.target.value; };
+ $('#j-desc').oninput = e => { DRAFT.description = e.target.value; };
+ $('#j-audience').oninput = e => { DRAFT.audienceSize = Number(e.target.value) || 0; };
+ $('#j-segment').onchange = e => {
+ const opt = e.target.selectedOptions[0];
+ DRAFT.segmentId = e.target.value || '';
+ DRAFT.segmentName = opt ? (opt.dataset.name || opt.textContent.replace(/\s*\(~\d+\)\s*$/, '')) : '';
+ };
+
+ $$('#j-steps + *, .j-add').forEach(() => {});
+ $$('.j-add').forEach(b => b.onclick = () => addStep(b.dataset.kind));
+ $('#j-save').onclick = saveJourney;
+ $('#j-run').onclick = dryRun;
+ const delBtn = $('#j-delete');
+ if (delBtn) delBtn.onclick = deleteJourney;
+ }
+
+ function renderSteps() {
+ const target = $('#j-steps');
+ if (!target) return;
+ if (!DRAFT.steps.length) {
+ target.innerHTML = '<div class="muted-banner" style="margin:0">No steps yet — start with an email, then layer cross-channel follow-up below.</div>';
+ return;
+ }
+ target.innerHTML = DRAFT.steps.map((step, i) => stepCard(step, i)).join('');
+ // Wire up field bindings, condition select, remove, move
+ $$('[data-step-idx]').forEach(card => {
+ const idx = Number(card.dataset.stepIdx);
+ card.querySelectorAll('[data-field]').forEach(input => {
+ input.oninput = () => { DRAFT.steps[idx][input.dataset.field] = input.value; };
+ input.onchange = () => { DRAFT.steps[idx][input.dataset.field] = input.value; };
+ });
+ const condSel = card.querySelector('[data-condition]');
+ if (condSel) condSel.onchange = () => { DRAFT.steps[idx].condition = condSel.value; };
+ const tplSel = card.querySelector('[data-template]');
+ if (tplSel) {
+ tplSel.onchange = () => {
+ const v = tplSel.value;
+ DRAFT.steps[idx].templateId = v;
+ if (v) {
+ const tpl = (REFS.templates || []).find(t => t.id === v);
+ if (tpl && !DRAFT.steps[idx].subject) {
+ DRAFT.steps[idx].subject = tpl.subject;
+ renderSteps();
+ }
+ }
+ };
+ }
+ const rm = card.querySelector('.step-remove');
+ if (rm) rm.onclick = () => { DRAFT.steps.splice(idx, 1); renderSteps(); };
+ const up = card.querySelector('.step-up');
+ if (up) up.onclick = () => { if (idx > 0) { const s = DRAFT.steps[idx]; DRAFT.steps[idx] = DRAFT.steps[idx - 1]; DRAFT.steps[idx - 1] = s; renderSteps(); } };
+ const dn = card.querySelector('.step-down');
+ if (dn) dn.onclick = () => { if (idx < DRAFT.steps.length - 1) { const s = DRAFT.steps[idx]; DRAFT.steps[idx] = DRAFT.steps[idx + 1]; DRAFT.steps[idx + 1] = s; renderSteps(); } };
+ });
+ }
+
+ function kindSpec(key) {
+ return (META && META.kinds || []).find(k => k.key === key) || null;
+ }
+
+ function stepCard(step, idx) {
+ const spec = kindSpec(step.kind);
+ if (!spec) return '';
+ const conditions = (META.conditions || []);
+ const tpls = (REFS.templates || []);
+ const fieldHtml = spec.fields.map(f => {
+ const val = step[f.key] == null ? '' : step[f.key];
+ if (f.enum) {
+ const opts = ['<option value="">— pick —</option>'].concat(
+ f.enum.map(o => `<option value="${esc(o)}" ${val === o ? 'selected' : ''}>${esc(o)}</option>`)).join('');
+ return `<div><label>${esc(f.label)}</label><select data-field="${esc(f.key)}">${opts}</select></div>`;
+ }
+ if (f.type === 'number') {
+ return `<div><label>${esc(f.label)}</label><input type="number" min="0" data-field="${esc(f.key)}" value="${esc(val)}" /></div>`;
+ }
+ if (step.kind === 'email' && f.key === 'templateId') {
+ const opts = ['<option value="">— pick template (optional) —</option>'].concat(
+ tpls.map(t => `<option value="${esc(t.id)}" ${val === t.id ? 'selected' : ''}>${esc(t.title)} · ${esc(t.category)}</option>`)).join('');
+ return `<div><label>${esc(f.label)}</label><select data-field="${esc(f.key)}" data-template>${opts}</select></div>`;
+ }
+ if (f.key === 'caption' || f.key === 'message' || f.key === 'note') {
+ return `<div style="grid-column:1/-1"><label>${esc(f.label)}</label><textarea rows="2" data-field="${esc(f.key)}">${esc(val)}</textarea></div>`;
+ }
+ return `<div><label>${esc(f.label)}</label><input type="text" data-field="${esc(f.key)}" value="${esc(val)}" /></div>`;
+ }).join('');
+
+ const condHtml = spec.supportsCondition
+ ? `<div><label>Condition</label><select data-condition>${
+ conditions.map(c => `<option value="${esc(c.key)}" ${(step.condition || 'always') === c.key ? 'selected' : ''}>${esc(c.label)}</option>`).join('')
+ }</select></div>`
+ : '';
+
+ return `
+ <div data-step-idx="${idx}"
+ style="border:1px solid var(--line);border-radius:11px;padding:12px 14px;margin-bottom:10px;background:#fff">
+ <div class="row" style="justify-content:space-between;align-items:center;margin-bottom:10px">
+ <div class="row" style="gap:8px;align-items:center">
+ <span style="font:600 14px/1.2 'Cormorant Garamond',Georgia,serif;color:var(--mut)">${idx + 1}.</span>
+ <span style="font-size:18px">${esc(spec.icon)}</span>
+ <span style="font-weight:700">${esc(spec.label)}</span>
+ ${step.kind !== 'wait' && (step.condition && step.condition !== 'always')
+ ? `<span class="pill" style="background:#fff0e6;color:var(--accent)">${esc(step.condition)}</span>`
+ : ''}
+ </div>
+ <div class="row" style="gap:4px">
+ <button class="btn ghost step-up" style="padding:3px 8px;font-size:11px" title="Move up">↑</button>
+ <button class="btn ghost step-down" style="padding:3px 8px;font-size:11px" title="Move down">↓</button>
+ <button class="btn ghost step-remove" style="padding:3px 8px;font-size:11px;color:var(--accent)" title="Remove">✕</button>
+ </div>
+ </div>
+ <div class="grid" style="grid-template-columns:repeat(2,1fr);gap:10px">
+ ${fieldHtml}
+ ${condHtml}
+ </div>
+ </div>`;
+ }
+
+ function addStep(kind) {
+ const spec = kindSpec(kind);
+ if (!spec) return;
+ const step = { kind };
+ // sensible defaults
+ if (kind === 'wait') { step.hours = 24; step.label = '24 hours'; }
+ if (kind === 'email') { step.subject = ''; step.templateId = ''; step.condition = 'always'; }
+ if (kind === 'sms') { step.message = ''; step.condition = 'if-no-click'; }
+ if (kind === 'social'){ step.platform = 'instagram'; step.caption = ''; step.condition = 'if-no-open'; }
+ if (kind === 'retarget'){ step.platform = 'meta'; step.audienceType = 'non-openers'; step.condition = 'if-no-open'; }
+ DRAFT.steps.push(step);
+ renderSteps();
+ }
+
+ function setMsg(text, kind) {
+ const el = $('#j-msg'); if (!el) return;
+ el.textContent = text || '';
+ el.style.color = kind === 'err' ? 'var(--accent)' : kind === 'ok' ? 'var(--sage)' : 'var(--mut)';
+ }
+
+ async function saveJourney() {
+ if (!DRAFT.name || !DRAFT.name.trim()) { setMsg('Name is required.', 'err'); return; }
+ if (!DRAFT.steps.length) { setMsg('Add at least one step before saving.', 'err'); return; }
+ setMsg('Saving…');
+ const isNew = SELECTED === 'draft';
+ const url = isNew ? '/journeys' : `/journeys/${encodeURIComponent(SELECTED)}`;
+ const method = isNew ? 'POST' : 'PUT';
+ const res = await fetch(`/api/journeys${url}`, {
+ method,
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(DRAFT),
+ }).then(r => r.json());
+ if (res.error) {
+ setMsg(res.error + (res.issues ? ' — ' + res.issues.join(', ') : ''), 'err');
+ return;
+ }
+ setMsg(isNew ? `Created: ${res.journey.id}` : 'Saved.', 'ok');
+ SELECTED = res.journey.id;
+ DRAFT = JSON.parse(JSON.stringify(res.journey));
+ await loadList();
+ await loadRecommendations();
+ renderEditor();
+ }
+
+ async function deleteJourney() {
+ if (!confirm('Delete this journey? This cannot be undone.')) return;
+ await api(`/journeys/${encodeURIComponent(SELECTED)}`, { method: 'DELETE' });
+ SELECTED = null;
+ DRAFT = null;
+ $('#journeys-editor-card').style.display = 'none';
+ $('#journeys-recs-card').style.display = 'none';
+ $('#journeys-run-card').style.display = 'none';
+ await loadList();
+ }
+
+ async function dryRun() {
+ if (SELECTED === 'draft' || !SELECTED) return;
+ const data = await fetch(`/api/journeys/${encodeURIComponent(SELECTED)}/run`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ audienceSize: DRAFT.audienceSize }),
+ }).then(r => r.json());
+ if (data.error) { alert(data.error); return; }
+ renderDryRun(data);
+ }
+
+ function renderDryRun(data) {
+ $('#journeys-run-card').style.display = '';
+ const target = $('#journeys-run');
+ const rows = (data.stepResults || []).map(r => {
+ const flowPct = data.audienceSize > 0 ? Math.round((r.proceeded / data.audienceSize) * 100) : 0;
+ return `
+ <div style="display:grid;grid-template-columns:30px 1fr 100px 100px 120px;gap:10px;align-items:center;padding:8px 0;border-top:1px solid var(--line)">
+ <div style="font-weight:700;color:var(--mut)">${r.index + 1}.</div>
+ <div>
+ <div style="font-weight:600">${esc(r.kind)} <span class="muted" style="font-weight:400;font-size:11px">${esc(r.condition)}</span></div>
+ <div class="muted" style="font-size:11.5px">${esc(r.note || '')}</div>
+ </div>
+ <div style="font-size:12.5px"><span class="muted">in</span> <b>${Number(r.entry || 0).toLocaleString()}</b></div>
+ <div style="font-size:12.5px"><span class="muted">out</span> <b>${Number(r.proceeded || 0).toLocaleString()}</b></div>
+ <div>
+ <div style="background:var(--paper);border:1px solid var(--line);border-radius:99px;height:8px;overflow:hidden">
+ <div style="background:var(--gold);height:100%;width:${flowPct}%"></div>
+ </div>
+ <div class="muted" style="font-size:10.5px;margin-top:2px">${flowPct}% of start</div>
+ </div>
+ </div>
+ `;
+ }).join('');
+ target.innerHTML = `
+ <div class="row" style="gap:18px;margin-bottom:10px;flex-wrap:wrap">
+ <div><div class="muted" style="font-size:11px">Audience</div>
+ <div style="font-weight:700;font-size:17px">${Number(data.audienceSize || 0).toLocaleString()}</div></div>
+ <div><div class="muted" style="font-size:11px">Emails</div>
+ <div style="font-weight:700;font-size:17px">${data.totalEmails || 0}</div></div>
+ <div><div class="muted" style="font-size:11px">SMS</div>
+ <div style="font-weight:700;font-size:17px">${data.totalSms || 0}</div></div>
+ <div><div class="muted" style="font-size:11px">Social</div>
+ <div style="font-weight:700;font-size:17px">${data.totalSocial || 0}</div></div>
+ <div><div class="muted" style="font-size:11px">Retargets</div>
+ <div style="font-weight:700;font-size:17px">${data.totalRetargets || 0}</div></div>
+ <div><div class="muted" style="font-size:11px">Reach final step</div>
+ <div style="font-weight:700;font-size:17px">${Number(data.finalActive || 0).toLocaleString()}</div></div>
+ </div>
+ ${rows}
+ <div class="muted-banner" style="margin-top:12px">${esc(data.sendNote || 'Planning only — no sends.')}</div>
+ `;
+ }
+
+ // ── recommendations ─────────────────────────────────────────────────────
+ async function loadRecommendations() {
+ $('#journeys-recs-card').style.display = '';
+ const target = $('#journeys-recs');
+ target.innerHTML = '<div class="muted">Loading recommendations…</div>';
+ const id = SELECTED === 'draft' ? 'draft' : SELECTED;
+ const res = await fetch(`/api/journeys/${encodeURIComponent(id)}/recommend`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ steps: DRAFT.steps || [] }),
+ }).then(r => r.json());
+ if (!res.recommendations || !res.recommendations.length) {
+ target.innerHTML = '<div class="muted">No recommendations right now — the journey looks complete.</div>';
+ return;
+ }
+ target.innerHTML = res.recommendations.map((rec, i) => `
+ <div style="border:1px solid var(--line);border-radius:11px;padding:12px 14px;margin-bottom:10px;background:var(--paper)">
+ <div class="row" style="justify-content:space-between;align-items:baseline">
+ <h3 style="margin:0;font:600 14px/1.2 'Cormorant Garamond',Georgia,serif">${esc(rec.title)}</h3>
+ <button class="btn ghost rec-apply" data-i="${i}" style="padding:5px 11px;font-size:12px">+ Append to journey</button>
+ </div>
+ <div class="muted" style="font-size:12px;margin:5px 0 10px;line-height:1.5">${esc(rec.reason)}</div>
+ <div class="row" style="gap:6px;flex-wrap:wrap">
+ ${(rec.steps || []).map(s => {
+ const spec = kindSpec(s.kind);
+ return `<span class="pill" style="background:#fff;border:1px solid var(--line);color:var(--ink)">${esc(spec ? spec.icon : '')} ${esc(spec ? spec.label : s.kind)}${s.condition && s.condition !== 'always' ? ` · ${esc(s.condition)}` : ''}${s.kind === 'wait' ? ` · ${s.hours}h` : ''}</span>`;
+ }).join('')}
+ </div>
+ </div>
+ `).join('');
+ target.querySelectorAll('.rec-apply').forEach(b => b.onclick = () => {
+ const rec = res.recommendations[Number(b.dataset.i)];
+ for (const s of rec.steps || []) DRAFT.steps.push(JSON.parse(JSON.stringify(s)));
+ renderSteps();
+ setMsg('Appended — review and save.', 'ok');
+ });
+ }
+
+ // ── initial render ──────────────────────────────────────────────────────
+ await loadList();
+ },
+};
diff --git a/public/panels/playbook.html b/public/panels/playbook.html
new file mode 100644
index 0000000..f82b77c
--- /dev/null
+++ b/public/panels/playbook.html
@@ -0,0 +1,77 @@
+<div id="pb-banner"></div>
+
+<div class="card">
+ <div class="row" style="justify-content:space-between;align-items:baseline;gap:12px">
+ <div>
+ <h2>Monthly Playbook</h2>
+ <div class="muted" style="font-size:12px">
+ A 4-week marketing plan synthesised from the calendar, segment health, and recent performance gaps.
+ <b>SUGGESTIONS ONLY</b> — nothing here sends or schedules. Promote an entry to the Calendar to make it real.
+ </div>
+ </div>
+ <div class="row" style="gap:8px;align-items:center">
+ <span class="pill" id="pb-mode-pill">—</span>
+ <span class="pill" id="pb-month-pill">—</span>
+ </div>
+ </div>
+</div>
+
+<div class="grid" style="grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:18px">
+ <div class="card" style="margin:0;padding:14px 16px">
+ <div class="muted" style="font-size:11px;text-transform:uppercase;letter-spacing:.6px">Entries</div>
+ <div id="pb-stat-count" style="font:600 22px/1.2 'Cormorant Garamond',Georgia,serif;margin-top:4px">—</div>
+ </div>
+ <div class="card" style="margin:0;padding:14px 16px">
+ <div class="muted" style="font-size:11px;text-transform:uppercase;letter-spacing:.6px">Calendar moments</div>
+ <div id="pb-stat-calendar" style="font:600 22px/1.2 'Cormorant Garamond',Georgia,serif;margin-top:4px;color:var(--gold)">—</div>
+ </div>
+ <div class="card" style="margin:0;padding:14px 16px">
+ <div class="muted" style="font-size:11px;text-transform:uppercase;letter-spacing:.6px">Performance gaps</div>
+ <div id="pb-stat-gaps" style="font:600 22px/1.2 'Cormorant Garamond',Georgia,serif;margin-top:4px;color:var(--accent)">—</div>
+ </div>
+ <div class="card" style="margin:0;padding:14px 16px">
+ <div class="muted" style="font-size:11px;text-transform:uppercase;letter-spacing:.6px">Healthy segments</div>
+ <div id="pb-stat-healthy" style="font:600 22px/1.2 'Cormorant Garamond',Georgia,serif;margin-top:4px;color:var(--sage)">—</div>
+ </div>
+</div>
+
+<div class="card">
+ <div class="row" style="justify-content:space-between;align-items:baseline;gap:12px">
+ <div>
+ <h2 style="margin:0">Playbook</h2>
+ <div class="muted" style="font-size:12px" id="pb-gen-meta">—</div>
+ </div>
+ <div class="row" style="gap:8px;align-items:center">
+ <label style="margin:0;align-self:center">Month</label>
+ <input type="month" id="pb-month" style="width:auto;min-width:160px" />
+ <button class="btn ghost" id="pb-regen" style="padding:7px 12px;font-size:12.5px">↻ Regenerate</button>
+ <button class="btn" id="pb-add" style="padding:7px 12px;font-size:12.5px">+ Add entry</button>
+ </div>
+ </div>
+ <div id="pb-list" class="loading" style="margin-top:12px">Loading…</div>
+</div>
+
+<div class="grid" style="grid-template-columns:1fr 1fr;align-items:start">
+ <div class="card">
+ <div class="row" style="justify-content:space-between;align-items:baseline">
+ <h2 style="margin:0">Signals — Calendar window</h2>
+ <span class="pill" style="background:var(--cream)">curated</span>
+ </div>
+ <div id="pb-signals-cal" class="loading" style="margin-top:10px">Loading…</div>
+ </div>
+ <div class="card">
+ <div class="row" style="justify-content:space-between;align-items:baseline">
+ <h2 style="margin:0">Signals — Segment health</h2>
+ <span class="pill" style="background:#fff0e6;color:var(--accent)">last 30d</span>
+ </div>
+ <div id="pb-signals-seg" class="loading" style="margin-top:10px">Loading…</div>
+ </div>
+</div>
+
+<div class="card" id="pb-edit-card" style="display:none">
+ <div class="row" style="justify-content:space-between;align-items:baseline">
+ <h2 style="margin:0" id="pb-edit-title">Edit entry</h2>
+ <button class="btn ghost" id="pb-edit-close" style="padding:5px 10px;font-size:12px">✕ Close</button>
+ </div>
+ <div id="pb-edit" style="margin-top:12px"></div>
+</div>
diff --git a/public/panels/playbook.js b/public/panels/playbook.js
new file mode 100644
index 0000000..02aa4ab
--- /dev/null
+++ b/public/panels/playbook.js
@@ -0,0 +1,310 @@
+// playbook panel — Monthly Marketing Playbook viewer/editor.
+// Suggestions only — no live sends. Talks to /api/playbook.
+window.MCC_PANELS = window.MCC_PANELS || {};
+window.MCC_PANELS['playbook'] = {
+ async init(root) {
+ const $ = s => root.querySelector(s);
+ const api = (p, opts) => fetch(`/api/playbook${p}`, opts).then(r => r.json());
+ const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c =>
+ ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
+ const fmtDate = d => d ? new Date(d + 'T00:00:00').toLocaleDateString(undefined,
+ { weekday:'short', month:'short', day:'numeric' }) : '—';
+ const pad = n => String(n).padStart(2,'0');
+
+ let META = null;
+ let MONTH = null;
+ let PB = null;
+ let SIGNALS = null;
+
+ function bandClass(b){ return b === 'good' ? 'sage' : b === 'ok' ? 'gold' : b === 'bad' ? 'accent' : 'mut'; }
+ function bandLabel(b){ return b === 'good' ? 'HEALTHY' : b === 'ok' ? 'WATCH' : b === 'bad' ? 'NEEDS HELP' : '—'; }
+ function sourcePill(src){
+ const map = { calendar:['var(--cream)','var(--accent)','calendar'],
+ 'segment-health':['#eef4ec','var(--sage)','segment'],
+ 'performance-gap':['#fff0e6','var(--accent)','gap'],
+ 'fallback-editorial':['var(--paper)','var(--mut)','editorial'],
+ manual:['#f0ece4','var(--ink)','manual'],
+ gemini:['#eaf0fb','#3556a5','gemini'] };
+ const [bg, fg, label] = map[src] || map.manual;
+ return `<span class="pill" style="background:${bg};color:${fg}">${esc(label)}</span>`;
+ }
+
+ function thisMonthId(){ const d = new Date(); return `${d.getFullYear()}-${pad(d.getMonth()+1)}`; }
+
+ function banner(){
+ const live = META && META.geminiKeyed;
+ $('#pb-banner').innerHTML = `<div class="muted-banner">
+ 📅 <b>SUGGESTIONS ONLY.</b> ${esc(META && META.gateMessage || '')}
+ ${live ? '' : '<br><b>Mock mode:</b> Gemini key absent — using deterministic template generator.'}
+ </div>`;
+ }
+
+ // ── load ────────────────────────────────────────────────────────────────
+ try { META = await api('/meta'); }
+ catch { $('#pb-list').innerHTML = '<div class="muted-banner">Could not reach the playbook service.</div>'; return; }
+
+ MONTH = thisMonthId();
+ $('#pb-month').value = MONTH;
+ banner();
+
+ async function loadAll(){
+ $('#pb-list').classList.add('loading'); $('#pb-list').textContent = 'Loading…';
+ $('#pb-signals-cal').classList.add('loading'); $('#pb-signals-cal').textContent = 'Loading…';
+ $('#pb-signals-seg').classList.add('loading'); $('#pb-signals-seg').textContent = 'Loading…';
+
+ const [pbRes, sigRes] = await Promise.all([
+ api(`/playbook?month=${encodeURIComponent(MONTH)}`),
+ api(`/signals?month=${encodeURIComponent(MONTH)}`),
+ ]);
+ SIGNALS = sigRes.signals || null;
+ PB = pbRes.playbook || null;
+ $('#pb-month-pill').textContent = MONTH;
+ renderSignals();
+ renderStats();
+ renderList();
+ renderGenMeta();
+ }
+
+ function renderGenMeta(){
+ const m = $('#pb-mode-pill');
+ if (!PB){ m.textContent = 'not generated'; m.style.background = 'var(--paper)'; m.style.color = 'var(--mut)'; }
+ else if (PB.mode === 'gemini'){ m.textContent = 'gemini'; m.style.background = '#eaf0fb'; m.style.color = '#3556a5'; }
+ else if (PB.mode === 'template'){ m.textContent = 'template'; m.style.background = 'var(--cream)'; m.style.color = 'var(--accent)'; }
+ else if (PB.mode === 'template-fallback'){ m.textContent = 'template (gemini failed)'; m.style.background = '#fff0e6'; m.style.color = 'var(--accent)'; }
+ else { m.textContent = PB.mode || '—'; m.style.background = 'var(--paper)'; m.style.color = 'var(--mut)'; }
+
+ const meta = $('#pb-gen-meta');
+ if (!PB) meta.textContent = 'No playbook yet for this month — hit Regenerate to seed one from current signals.';
+ else {
+ const when = PB.generatedAt ? new Date(PB.generatedAt).toLocaleString() : '—';
+ const err = PB.generatorError ? ` · <span style="color:var(--accent)">gemini error: ${esc(PB.generatorError)}</span>` : '';
+ meta.innerHTML = `${esc(PB.entries.length)} ideas · generated ${esc(when)} · window starts ${esc(PB.startIso || '—')}${err}`;
+ }
+ }
+
+ function renderStats(){
+ $('#pb-stat-count').textContent = PB ? PB.entries.length : '—';
+ $('#pb-stat-calendar').textContent = SIGNALS ? SIGNALS.dates.length : '—';
+ $('#pb-stat-gaps').textContent = SIGNALS ? SIGNALS.gaps.length : '—';
+ $('#pb-stat-healthy').textContent = SIGNALS ? SIGNALS.segments.filter(s => s.band === 'good').length : '—';
+ }
+
+ function renderSignals(){
+ const cal = $('#pb-signals-cal'); cal.classList.remove('loading');
+ const seg = $('#pb-signals-seg'); seg.classList.remove('loading');
+
+ if (!SIGNALS){ cal.innerHTML = '<div class="muted">No signal data.</div>'; seg.innerHTML = '<div class="muted">No signal data.</div>'; return; }
+
+ const dates = SIGNALS.dates || [];
+ if (!dates.length){
+ cal.innerHTML = '<div class="muted">No curated marketing moments in this window.</div>';
+ } else {
+ cal.innerHTML = `<div style="display:flex;flex-direction:column;gap:6px">` + dates.slice(0, 10).map(d => `
+ <div style="display:grid;grid-template-columns:88px 1fr auto;gap:10px;padding:7px 0;border-bottom:1px dashed var(--line)">
+ <div class="muted" style="font-size:11.5px">${esc(fmtDate(d.date))}</div>
+ <div>
+ <div style="font-weight:600;font-size:13px">${esc(d.title)}</div>
+ <div class="muted" style="font-size:11.5px;margin-top:2px">${esc(d.suggestion || '')}</div>
+ </div>
+ <span class="pill" style="background:var(--cream);align-self:start">${esc(d.type)}</span>
+ </div>
+ `).join('') + `</div>`;
+ }
+
+ const segs = SIGNALS.segments || [];
+ seg.innerHTML = `<div style="display:flex;flex-direction:column;gap:6px">` + segs.map(s => `
+ <div style="display:grid;grid-template-columns:1fr auto auto;gap:10px;padding:7px 0;border-bottom:1px dashed var(--line);align-items:center">
+ <div>
+ <div style="font-weight:600;font-size:13px">${esc(s.icon || '')} ${esc(s.name)}</div>
+ <div class="muted" style="font-size:11.5px">${(s.openRate*100).toFixed(1)}% open · trend ${s.pctChange30d}%</div>
+ </div>
+ <span class="pill" style="background:var(--paper);color:var(--${bandClass(s.band)})">${bandLabel(s.band)}</span>
+ <span class="muted" style="font-size:11.5px">${esc((s.listSize||0).toLocaleString())} contacts</span>
+ </div>
+ `).join('') +
+ (SIGNALS.gaps && SIGNALS.gaps.length
+ ? `<div style="margin-top:10px;padding-top:8px;border-top:1px solid var(--line)">
+ <div class="muted" style="font-size:11px;text-transform:uppercase;letter-spacing:.6px;margin-bottom:5px">Performance gaps</div>
+ ${SIGNALS.gaps.map(g => `<div style="font-size:12px;margin-bottom:4px">⚠ ${esc(g.note)}</div>`).join('')}
+ </div>`
+ : '') + `</div>`;
+ }
+
+ function renderList(){
+ const list = $('#pb-list'); list.classList.remove('loading');
+ if (!PB || !PB.entries.length){
+ list.innerHTML = `<div class="muted-banner">
+ No playbook yet for <b>${esc(MONTH)}</b>. Press <b>↻ Regenerate</b> above to draft one from current signals.
+ </div>`;
+ return;
+ }
+ // group by week
+ const byWeek = {1:[],2:[],3:[],4:[]};
+ for (const e of PB.entries){ const w = e.week || 1; (byWeek[w] || byWeek[1]).push(e); }
+
+ const html = [1,2,3,4].map(w => {
+ const items = byWeek[w];
+ if (!items.length) return `
+ <div style="margin-bottom:14px">
+ <div class="muted" style="font-size:11.5px;text-transform:uppercase;letter-spacing:.6px;margin-bottom:6px">Week ${w}</div>
+ <div class="muted" style="font-size:12.5px;padding:8px 12px;border:1px dashed var(--line);border-radius:9px">Open slot — no plays scheduled.</div>
+ </div>`;
+ return `
+ <div style="margin-bottom:14px">
+ <div class="muted" style="font-size:11.5px;text-transform:uppercase;letter-spacing:.6px;margin-bottom:6px">Week ${w}</div>
+ <div style="display:flex;flex-direction:column;gap:8px">
+ ${items.map(e => `
+ <div style="border:1px solid var(--line);border-radius:11px;padding:11px 14px;background:#fff">
+ <div class="row" style="justify-content:space-between;align-items:baseline;gap:10px">
+ <div style="min-width:0">
+ <div style="font:600 15px/1.25 'Cormorant Garamond',Georgia,serif;color:var(--ink)">${esc(e.title)}</div>
+ <div class="muted" style="font-size:11.5px;margin-top:2px">
+ ${esc(fmtDate(e.date))} · ${esc(e.audienceLabel || e.audience)} · ${esc(e.channel)} · <span style="text-transform:uppercase;letter-spacing:.4px">${esc(e.type)}</span>
+ </div>
+ </div>
+ <div class="row" style="gap:6px;flex-shrink:0">
+ ${sourcePill(e.source)}
+ <span class="pill" style="background:var(--paper);color:var(--mut)">${esc(e.status || 'suggested')}</span>
+ </div>
+ </div>
+ <div style="font-size:12.5px;margin-top:7px;color:var(--ink)">${esc(e.rationale || '')}</div>
+ ${e.notes ? `<div class="muted" style="font-size:11.5px;margin-top:6px"><b>Notes:</b> ${esc(e.notes)}</div>` : ''}
+ <div class="row" style="gap:6px;margin-top:10px;justify-content:flex-end">
+ <button class="btn ghost" data-act="edit" data-id="${esc(e.id)}" style="padding:5px 10px;font-size:11.5px">Edit</button>
+ <button class="btn ghost" data-act="calendar" data-id="${esc(e.id)}" style="padding:5px 10px;font-size:11.5px">→ Add to Calendar</button>
+ <button class="btn ghost" data-act="delete" data-id="${esc(e.id)}" style="padding:5px 10px;font-size:11.5px;color:var(--accent)">Delete</button>
+ </div>
+ </div>
+ `).join('')}
+ </div>
+ </div>`;
+ }).join('');
+ list.innerHTML = html;
+ }
+
+ function renderEdit(entry){
+ const card = $('#pb-edit-card'); card.style.display = 'block';
+ $('#pb-edit-title').textContent = entry ? 'Edit entry' : 'Add entry';
+ const e = entry || {
+ date: PB ? PB.startIso : MONTH + '-01', title: '', audience: 'designers',
+ channel: 'email', type: 'campaign', rationale: '', notes: '', status: 'suggested', source: 'manual',
+ };
+ const audOpts = (META.audiences || []).map(a =>
+ `<option value="${esc(a.id)}" ${a.id === e.audience ? 'selected' : ''}>${esc(a.label)}</option>`).join('');
+ const chOpts = (META.channels || ['email']).map(c =>
+ `<option value="${esc(c)}" ${c === e.channel ? 'selected' : ''}>${esc(c)}</option>`).join('');
+ const stOpts = (META.statuses || ['suggested']).map(s =>
+ `<option value="${esc(s)}" ${s === e.status ? 'selected' : ''}>${esc(s)}</option>`).join('');
+
+ $('#pb-edit').innerHTML = `
+ <div class="grid" style="grid-template-columns:1fr 1fr;gap:10px">
+ <div><label>Title</label><input id="pe-title" value="${esc(e.title)}" /></div>
+ <div><label>Date</label><input id="pe-date" type="date" value="${esc(e.date)}" /></div>
+ <div><label>Audience</label><select id="pe-audience">${audOpts}</select></div>
+ <div><label>Channel</label><select id="pe-channel">${chOpts}</select></div>
+ <div><label>Type</label><input id="pe-type" value="${esc(e.type)}" /></div>
+ <div><label>Status</label><select id="pe-status">${stOpts}</select></div>
+ </div>
+ <div style="margin-top:8px"><label>Rationale</label><textarea id="pe-rationale" rows="3">${esc(e.rationale)}</textarea></div>
+ <div style="margin-top:8px"><label>Notes</label><textarea id="pe-notes" rows="2">${esc(e.notes)}</textarea></div>
+ <div class="row" style="justify-content:flex-end;gap:8px;margin-top:12px">
+ <span class="muted" id="pe-msg" style="font-size:12px;align-self:center;flex:1"> </span>
+ <button class="btn ghost" id="pe-cancel" style="padding:7px 12px">Cancel</button>
+ <button class="btn" id="pe-save" style="padding:7px 12px" data-id="${esc(e.id || '')}">${entry ? 'Save changes' : 'Add to playbook'}</button>
+ </div>
+ `;
+
+ $('#pe-cancel').onclick = () => { card.style.display = 'none'; };
+ $('#pe-save').onclick = async () => {
+ const body = {
+ month: MONTH,
+ id: entry ? entry.id : undefined,
+ title: $('#pe-title').value.trim(),
+ date: $('#pe-date').value,
+ audience: $('#pe-audience').value,
+ channel: $('#pe-channel').value,
+ type: $('#pe-type').value.trim(),
+ status: $('#pe-status').value,
+ rationale: $('#pe-rationale').value.trim(),
+ notes: $('#pe-notes').value.trim(),
+ };
+ const msg = $('#pe-msg');
+ if (!body.title) { msg.textContent = 'Title is required.'; return; }
+ if (!body.date) { msg.textContent = 'Date is required.'; return; }
+ msg.textContent = 'Saving…';
+ try {
+ let res;
+ if (entry) {
+ res = await api(`/entry/${encodeURIComponent(entry.id)}?month=${encodeURIComponent(MONTH)}`, {
+ method:'PUT', headers:{ 'Content-Type':'application/json' }, body: JSON.stringify(body),
+ });
+ } else {
+ res = await api(`/entry`, {
+ method:'POST', headers:{ 'Content-Type':'application/json' }, body: JSON.stringify(body),
+ });
+ }
+ if (res && res.playbook) { PB = res.playbook; renderList(); renderStats(); renderGenMeta(); }
+ card.style.display = 'none';
+ } catch (e) { msg.textContent = 'Save failed: ' + e.message; }
+ };
+ }
+
+ // ── event wiring ────────────────────────────────────────────────────────
+ $('#pb-month').addEventListener('change', e => { MONTH = e.target.value; loadAll(); });
+
+ $('#pb-regen').addEventListener('click', async () => {
+ const btn = $('#pb-regen'); const orig = btn.textContent;
+ btn.disabled = true; btn.textContent = '… generating';
+ try {
+ const res = await api('/generate', {
+ method:'POST', headers:{ 'Content-Type':'application/json' },
+ body: JSON.stringify({ month: MONTH }),
+ });
+ if (res && res.playbook) { PB = res.playbook; SIGNALS = PB.signals || SIGNALS; }
+ renderSignals(); renderStats(); renderList(); renderGenMeta();
+ } catch (e) { alert('Regenerate failed: ' + e.message); }
+ finally { btn.disabled = false; btn.textContent = orig; }
+ });
+
+ $('#pb-add').addEventListener('click', () => renderEdit(null));
+ $('#pb-edit-close').addEventListener('click', () => { $('#pb-edit-card').style.display = 'none'; });
+
+ $('#pb-list').addEventListener('click', async ev => {
+ const btn = ev.target.closest('button[data-act]');
+ if (!btn) return;
+ const id = btn.getAttribute('data-id');
+ const entry = PB && PB.entries.find(e => e.id === id);
+ const act = btn.getAttribute('data-act');
+ if (act === 'edit') return renderEdit(entry);
+ if (act === 'delete') {
+ if (!confirm(`Delete "${entry && entry.title}" from the playbook? This does not affect the calendar.`)) return;
+ const res = await api(`/delete?month=${encodeURIComponent(MONTH)}&id=${encodeURIComponent(id)}`, { method:'POST' });
+ if (res && res.playbook) { PB = res.playbook; renderList(); renderStats(); renderGenMeta(); }
+ return;
+ }
+ if (act === 'calendar') {
+ if (!entry) return;
+ if (!confirm(`Add "${entry.title}" to the marketing Calendar as a planned campaign on ${entry.date}? It will appear in /calendar — no email is sent.`)) return;
+ try {
+ const r = await fetch('/api/calendar/schedule', {
+ method:'POST', headers:{ 'Content-Type':'application/json' },
+ body: JSON.stringify({
+ date: entry.date, title: entry.title,
+ channel: (entry.channel || '').includes('instagram') ? 'instagram' : 'email',
+ status: 'planned',
+ notes: `From playbook · audience: ${entry.audienceLabel || entry.audience} · ${entry.rationale || ''}`,
+ }),
+ }).then(r => r.json());
+ if (r && r.ok) {
+ btn.textContent = '✓ Added to calendar';
+ btn.disabled = true;
+ } else {
+ alert('Could not add to calendar: ' + (r && r.error || 'unknown error'));
+ }
+ } catch (e) { alert('Could not add to calendar: ' + e.message); }
+ }
+ });
+
+ await loadAll();
+ },
+};
diff --git a/public/panels/profiles.html b/public/panels/profiles.html
new file mode 100644
index 0000000..46bccb4
--- /dev/null
+++ b/public/panels/profiles.html
@@ -0,0 +1,27 @@
+<div id="prof-banner"></div>
+
+<div class="grid" style="grid-template-columns:minmax(280px,340px) 1fr;align-items:start">
+
+ <!-- Left: search + recent list -->
+ <div class="card" id="prof-list-card">
+ <h2>Find a contact</h2>
+ <div class="muted" style="font-size:12px">Search email, name, company, role or tag.</div>
+ <div style="margin-top:12px">
+ <input id="prof-q" placeholder="e.g. atelier-noir, grasscloth, principal" autocomplete="off">
+ </div>
+ <div class="row" style="margin-top:8px;justify-content:space-between;align-items:center">
+ <span class="muted" id="prof-list-meta" style="font-size:12px">—</span>
+ <button class="btn ghost" id="prof-list-refresh" style="padding:5px 11px;font-size:12px">Refresh</button>
+ </div>
+ <div id="prof-list" class="loading" style="margin-top:12px">Loading contacts…</div>
+ </div>
+
+ <!-- Right: 360 profile card -->
+ <div class="card" id="prof-detail-card">
+ <div id="prof-detail-empty" class="muted" style="padding:30px 0;text-align:center">
+ Pick a contact on the left to see their 360° profile.
+ </div>
+ <div id="prof-detail" style="display:none"></div>
+ </div>
+
+</div>
diff --git a/public/panels/profiles.js b/public/panels/profiles.js
new file mode 100644
index 0000000..e5e3c67
--- /dev/null
+++ b/public/panels/profiles.js
@@ -0,0 +1,296 @@
+// Contact Profiles panel — left: searchable contact list; right: a 360° card
+// for the selected contact (lifecycle, lists, tags, segment memberships, email
+// engagement timeline, cross-channel placeholders). Read-only.
+window.MCC_PANELS = window.MCC_PANELS || {};
+window.MCC_PANELS['profiles'] = {
+ init(root) {
+ const $ = s => root.querySelector(s);
+ const api = (p) => fetch(`/api/profiles${p}`).then(r => r.json());
+ const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c =>
+ ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
+ const fmtDate = d => d ? new Date(d).toLocaleDateString(undefined,
+ { year: 'numeric', month: 'short', day: 'numeric' }) : '—';
+ const fmtDateTime = d => d ? new Date(d).toLocaleString(undefined,
+ { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : '—';
+ const fmtRelative = d => {
+ if (!d) return 'never';
+ const days = Math.round((Date.now() - Date.parse(d)) / 86400000);
+ if (days <= 0) return 'today';
+ if (days === 1) return 'yesterday';
+ if (days < 30) return `${days}d ago`;
+ if (days < 365) return `${Math.round(days / 30)}mo ago`;
+ return `${Math.round(days / 365)}y ago`;
+ };
+
+ let anyMock = false;
+ let selectedEmail = null;
+ let listCache = [];
+ let listIsSearch = false;
+
+ function setMock(m) {
+ if (m) {
+ anyMock = true;
+ $('#prof-banner').innerHTML =
+ `<div class="muted-banner">Running on a <b>mock contact pool</b> (~240 luxury-design contacts).
+ Paste your <code>CTCT_ACCESS_TOKEN</code> in <code>.env</code> to power profiles from your live Constant Contact roster.</div>`;
+ }
+ }
+
+ // ── list rendering ────────────────────────────────────────────────────────
+ function renderList(payload) {
+ const el = $('#prof-list');
+ listCache = payload.contacts || [];
+ const meta = $('#prof-list-meta');
+ if (payload.query) {
+ meta.textContent = `${Number(payload.count).toLocaleString()} match${payload.count === 1 ? '' : 'es'} for “${payload.query}” · ${Number(payload.total).toLocaleString()} contacts`;
+ } else {
+ meta.textContent = `${Number(payload.total).toLocaleString()} contacts · sorted by most recent open`;
+ }
+
+ if (!listCache.length) {
+ el.classList.remove('loading');
+ el.innerHTML = `<div class="muted" style="padding:18px 0">No contacts match.</div>`;
+ return;
+ }
+ el.classList.remove('loading');
+ el.innerHTML = listCache.map(c => {
+ const isSel = selectedEmail && c.email.toLowerCase() === selectedEmail.toLowerCase();
+ return `<div class="prof-row" data-email="${esc(c.email)}"
+ style="border-top:1px solid var(--line);padding:10px 0;cursor:pointer;${isSel ? 'background:#f7f2e8' : ''}">
+ <div class="row" style="justify-content:space-between;align-items:baseline;gap:8px">
+ <div style="font-weight:600">${esc(c.name || c.email)}</div>
+ <span class="pill">${esc(c.type)}</span>
+ </div>
+ <div class="muted" style="font-size:12px">${esc(c.email)}</div>
+ <div class="muted" style="font-size:11.5px;margin-top:3px">
+ ${esc(c.company || '')}${c.role ? ` · ${esc(c.role)}` : ''}
+ </div>
+ <div class="row" style="gap:6px;margin-top:5px;align-items:center">
+ <span class="pill" style="background:#eee5d4">${esc(c.lifecycle)}</span>
+ <span class="muted" style="font-size:11px">last open ${fmtRelative(c.lastOpen)}</span>
+ </div>
+ </div>`;
+ }).join('');
+
+ el.querySelectorAll('.prof-row').forEach(row => {
+ row.onclick = () => loadDetail(row.dataset.email);
+ });
+ }
+
+ function loadList() {
+ $('#prof-list').classList.add('loading');
+ $('#prof-list').textContent = 'Loading contacts…';
+ listIsSearch = false;
+ return api('/list?limit=25').then(p => { setMock(p.mock); renderList(p); })
+ .catch(e => { $('#prof-list').innerHTML = `<div class="muted">Error: ${esc(e.message)}</div>`; });
+ }
+
+ function runSearch(q) {
+ listIsSearch = true;
+ if (!q) return loadList();
+ $('#prof-list').classList.add('loading');
+ $('#prof-list').textContent = 'Searching…';
+ return api(`/search?q=${encodeURIComponent(q)}`)
+ .then(p => { setMock(p.mock); renderList(p); })
+ .catch(e => { $('#prof-list').innerHTML = `<div class="muted">Error: ${esc(e.message)}</div>`; });
+ }
+
+ // ── detail rendering ───────────────────────────────────────────────────────
+ function renderTimeline(events) {
+ if (!events || !events.length) {
+ return `<div class="muted" style="padding:14px 0">No email events recorded.</div>`;
+ }
+ // Limit to 60 newest so the card doesn't get unwieldy
+ const visible = events.slice(0, 60);
+ const iconFor = t => t === 'sent' ? '✉️' : t === 'open' ? '👁️' : t === 'click' ? '🔗' : '•';
+ const colorFor = t => t === 'open' ? '#6f7a63' : t === 'click' ? '#8a5a44' : '#8a8275';
+ const rows = visible.map(e => `
+ <div class="row" style="gap:10px;align-items:baseline;border-top:1px solid var(--line);padding:8px 0">
+ <span style="width:22px;text-align:center" title="${esc(e.type)}">${iconFor(e.type)}</span>
+ <div style="flex:1;min-width:0">
+ <div style="font-weight:600;font-size:13px;color:${colorFor(e.type)}">
+ ${e.type === 'sent' ? 'Sent' : e.type === 'open' ? 'Opened' : 'Clicked'}
+ <span style="color:var(--ink);font-weight:500">· ${esc(e.campaignName || '(campaign)')}</span>
+ </div>
+ <div class="muted" style="font-size:11.5px">
+ ${esc(fmtDateTime(e.at))}${e.link ? ` · <code style="font-size:11px">${esc(e.link)}</code>` : ''}
+ ${e.derived ? ' · <span title="Derived from CC aggregate fields — true per-event streams require ETL">derived</span>' : ''}
+ </div>
+ </div>
+ </div>`).join('');
+ return `<div>${rows}</div>
+ ${events.length > visible.length
+ ? `<div class="muted" style="font-size:12px;margin-top:8px">Showing ${visible.length} of ${events.length} events.</div>`
+ : ''}`;
+ }
+
+ function lifecyclePill(stage, tone) {
+ const bg = tone === 'hot' ? '#fde6d8' : tone === 'warm' ? '#fff1d2' :
+ tone === 'cold' ? '#e6e2da' : '#eee5d4';
+ const fg = tone === 'hot' ? '#8a5a44' : tone === 'warm' ? '#7a5a2a' :
+ tone === 'cold' ? '#5a5347' : '#5a5347';
+ return `<span class="pill" style="background:${bg};color:${fg}">${esc(stage)}</span>`;
+ }
+
+ function renderDetail(payload) {
+ setMock(payload.mock);
+ $('#prof-detail-empty').style.display = 'none';
+ const c = payload.contact;
+ const lc = payload.lifecycle || { stage: '—', tone: 'neutral' };
+ const segs = payload.segments || [];
+ const cx = payload.crossChannel || {};
+ const lastVisit = cx.lastSiteVisit;
+ const social = cx.social;
+ const counts = payload.timelineCounts || { sent: 0, open: 0, click: 0 };
+ const tags = (c.tags || []).map(t =>
+ `<span class="pill" style="margin:0 4px 4px 0">${esc(t)}</span>`).join('') ||
+ '<span class="muted" style="font-size:12px">no tags</span>';
+ const lists = (c.lists || []).map(l =>
+ `<span class="pill" style="margin:0 4px 4px 0;background:#eee5d4">${esc(l)}</span>`).join('') ||
+ '<span class="muted" style="font-size:12px">no list memberships</span>';
+ const segChips = segs.length
+ ? segs.map(s => `<span class="pill" title="${esc(s.description)}" style="margin:0 4px 4px 0;background:#e6dec8">${esc(s.name)}</span>`).join('')
+ : '<span class="muted" style="font-size:12px">Does not match any saved segment.</span>';
+
+ const loc = c.location || {};
+ const locStr = [loc.city, loc.region, loc.country].filter(Boolean).join(', ') || '—';
+
+ const detail = $('#prof-detail');
+ detail.style.display = '';
+ detail.innerHTML = `
+ <div class="row" style="justify-content:space-between;align-items:baseline;gap:12px">
+ <div style="min-width:0">
+ <h2 style="margin:0">${esc(c.name || c.email)}</h2>
+ <div class="muted" style="font-size:12.5px">${esc(c.email)}</div>
+ </div>
+ <div class="row" style="gap:6px;align-items:center">
+ ${lifecyclePill(lc.stage, lc.tone)}
+ <span class="pill">${esc(c.type)}</span>
+ </div>
+ </div>
+
+ <div class="grid" style="margin-top:14px;grid-template-columns:repeat(4,1fr);gap:10px">
+ <div><label>Company</label><div style="font-weight:600">${esc(c.company || '—')}</div></div>
+ <div><label>Role</label><div>${esc(c.role || '—')}</div></div>
+ <div><label>Location</label><div>${esc(locStr)}</div></div>
+ <div><label>Contact since</label><div>${esc(fmtDate(c.since))}</div></div>
+ </div>
+
+ <div class="grid" style="margin-top:14px;grid-template-columns:repeat(4,1fr);gap:10px">
+ <div style="border:1px solid var(--line);border-radius:10px;padding:10px 12px">
+ <div class="muted" style="font-size:11px;text-transform:uppercase;letter-spacing:.5px">Lifetime opens</div>
+ <div style="font:600 24px/1 'Cormorant Garamond',Georgia,serif">${Number(c.opens || 0).toLocaleString()}</div>
+ </div>
+ <div style="border:1px solid var(--line);border-radius:10px;padding:10px 12px">
+ <div class="muted" style="font-size:11px;text-transform:uppercase;letter-spacing:.5px">Lifetime clicks</div>
+ <div style="font:600 24px/1 'Cormorant Garamond',Georgia,serif">${Number(c.clicks || 0).toLocaleString()}</div>
+ </div>
+ <div style="border:1px solid var(--line);border-radius:10px;padding:10px 12px">
+ <div class="muted" style="font-size:11px;text-transform:uppercase;letter-spacing:.5px">Last open</div>
+ <div style="font-weight:600">${esc(fmtRelative(c.lastOpen))}</div>
+ <div class="muted" style="font-size:11px">${esc(fmtDate(c.lastOpen))}</div>
+ </div>
+ <div style="border:1px solid var(--line);border-radius:10px;padding:10px 12px">
+ <div class="muted" style="font-size:11px;text-transform:uppercase;letter-spacing:.5px">Last click</div>
+ <div style="font-weight:600">${esc(fmtRelative(c.lastClick))}</div>
+ <div class="muted" style="font-size:11px">${esc(fmtDate(c.lastClick))}</div>
+ </div>
+ </div>
+
+ <div class="grid" style="margin-top:18px;grid-template-columns:1fr 1fr;gap:16px;align-items:start">
+
+ <div>
+ <label style="margin-bottom:6px">List memberships</label>
+ <div>${lists}</div>
+ <label style="margin:14px 0 6px">Tags</label>
+ <div>${tags}</div>
+ <label style="margin:14px 0 6px">Segment memberships</label>
+ <div>${segChips}</div>
+ </div>
+
+ <div>
+ <label style="margin-bottom:6px">Cross-channel</label>
+ <div style="border:1px solid var(--line);border-radius:10px;padding:10px 12px">
+ <div class="row" style="justify-content:space-between;align-items:baseline">
+ <div style="font-weight:600;font-size:13px">Last site visit</div>
+ <span class="muted" style="font-size:11px">placeholder</span>
+ </div>
+ ${lastVisit ? `
+ <div style="margin-top:4px;font-size:13px">${esc(lastVisit.pageTitle || lastVisit.page)}</div>
+ <div class="muted" style="font-size:11.5px">${esc(fmtRelative(lastVisit.at))} · ${esc(fmtDate(lastVisit.at))} · source: ${esc(lastVisit.source || '—')}</div>
+ <div class="muted" style="font-size:11px;margin-top:3px"><code>/${esc(lastVisit.page)}</code></div>
+ ` : `<div class="muted" style="font-size:12px;margin-top:4px">No tracked site visits.</div>`}
+ </div>
+ <div style="border:1px solid var(--line);border-radius:10px;padding:10px 12px;margin-top:10px">
+ <div class="row" style="justify-content:space-between;align-items:baseline">
+ <div style="font-weight:600;font-size:13px">Social</div>
+ <span class="muted" style="font-size:11px">placeholder</span>
+ </div>
+ ${social ? `
+ <div style="margin-top:4px;font-size:13px">
+ <span class="pill" style="margin-right:6px">${esc(social.platform)}</span>
+ <code style="font-size:12px">${esc(social.handle)}</code>
+ </div>
+ <div class="muted" style="font-size:11.5px;margin-top:3px">${esc(social.action || 'last touch')} · ${esc(fmtRelative(social.lastTouch))}</div>
+ ` : `<div class="muted" style="font-size:12px;margin-top:4px">No social signal on file.</div>`}
+ </div>
+ ${cx.note ? `<div class="muted" style="font-size:11.5px;margin-top:8px">${esc(cx.note)}</div>` : ''}
+ </div>
+
+ </div>
+
+ <div style="margin-top:18px">
+ <div class="row" style="justify-content:space-between;align-items:baseline">
+ <label style="margin:0">Email engagement timeline</label>
+ <span class="muted" style="font-size:11.5px">
+ ${Number(counts.sent || 0)} sent · ${Number(counts.open || 0)} opens · ${Number(counts.click || 0)} clicks
+ </span>
+ </div>
+ <div style="margin-top:6px">${renderTimeline(payload.timeline || [])}</div>
+ </div>
+ `;
+ }
+
+ function loadDetail(email) {
+ selectedEmail = email;
+ // re-style the list row selection
+ root.querySelectorAll('.prof-row').forEach(r => {
+ r.style.background = (r.dataset.email.toLowerCase() === email.toLowerCase()) ? '#f7f2e8' : '';
+ });
+ $('#prof-detail-empty').style.display = 'none';
+ $('#prof-detail').style.display = '';
+ $('#prof-detail').innerHTML = '<div class="loading" style="padding:30px 0">Loading profile…</div>';
+ return api(`/contact?email=${encodeURIComponent(email)}`)
+ .then(r => {
+ if (r.error) {
+ $('#prof-detail').innerHTML = `<div class="muted-banner">${esc(r.error)}</div>`;
+ return;
+ }
+ renderDetail(r);
+ })
+ .catch(e => {
+ $('#prof-detail').innerHTML = `<div class="muted-banner">Error: ${esc(e.message)}</div>`;
+ });
+ }
+
+ // ── wire up ────────────────────────────────────────────────────────────────
+ let searchTimer = null;
+ $('#prof-q').addEventListener('input', (e) => {
+ const q = e.target.value.trim();
+ clearTimeout(searchTimer);
+ searchTimer = setTimeout(() => runSearch(q), 220);
+ });
+ $('#prof-q').addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') {
+ clearTimeout(searchTimer);
+ runSearch(e.target.value.trim());
+ }
+ });
+ $('#prof-list-refresh').addEventListener('click', () => {
+ $('#prof-q').value = '';
+ loadList();
+ });
+
+ loadList();
+ },
+};
diff --git a/public/panels/segment-perf.html b/public/panels/segment-perf.html
new file mode 100644
index 0000000..49b40de
--- /dev/null
+++ b/public/panels/segment-perf.html
@@ -0,0 +1,30 @@
+<div id="sp-banner"></div>
+
+<div class="card">
+ <div class="row" style="justify-content:space-between;align-items:baseline;gap:12px">
+ <div>
+ <h2>Segment Performance</h2>
+ <div class="muted" style="font-size:12px">
+ Email KPIs split by audience segment, colour-coded against luxury B2B
+ benchmarks. 30-day headline averages with a 90-day per-segment trend.
+ Read-only — never triggers a send.
+ </div>
+ </div>
+ <div class="row" style="gap:8px;align-items:center">
+ <label for="sp-metric" style="margin:0">Trend metric</label>
+ <select id="sp-metric" style="min-width:200px;width:auto">
+ <option value="openRate">Open rate</option>
+ <option value="ctor">CTOR (clicks / opens)</option>
+ <option value="clickRate">Click rate</option>
+ <option value="revPerRecipient">Est. revenue / recipient</option>
+ </select>
+ <button class="btn ghost" id="sp-refresh" style="padding:6px 12px;font-size:12.5px">Refresh</button>
+ </div>
+ </div>
+
+ <div class="row" id="sp-legend" style="gap:14px;margin-top:14px;align-items:center;font-size:11.5px"></div>
+</div>
+
+<div id="sp-cards" class="grid" style="grid-template-columns:repeat(auto-fit,minmax(380px,1fr));gap:18px">
+ <div class="loading" style="grid-column:1 / -1">Loading segments…</div>
+</div>
diff --git a/public/panels/segment-perf.js b/public/panels/segment-perf.js
new file mode 100644
index 0000000..14772ed
--- /dev/null
+++ b/public/panels/segment-perf.js
@@ -0,0 +1,294 @@
+// Segment Performance panel — fetches /api/segment-perf/summary, renders one
+// card per segment with KPI tiles colour-coded by health band, then fetches a
+// 90-day per-segment trend (currently-selected metric) and draws a hand-rolled
+// SVG bar chart inside each card. No external charting lib.
+window.MCC_PANELS = window.MCC_PANELS || {};
+window.MCC_PANELS['segment-perf'] = {
+ init(root) {
+ const $ = s => root.querySelector(s);
+ const $$ = s => Array.from(root.querySelectorAll(s));
+ const api = (p) => fetch(`/api/segment-perf${p}`).then(r => r.json());
+ const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c =>
+ ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
+
+ // ── formatting helpers ───────────────────────────────────────────────────
+ const fmtPct = v => (v * 100).toFixed(1) + '%';
+ const fmtMoney = v => '$' + (v >= 10
+ ? v.toFixed(2)
+ : v >= 1 ? v.toFixed(2) : v.toFixed(3));
+ const fmtNum = v => Number(v || 0).toLocaleString();
+ const fmtTrend = v => {
+ if (v == null) return '';
+ const sign = v > 0 ? '▲' : v < 0 ? '▼' : '·';
+ return `${sign} ${Math.abs(v).toFixed(1)}%`;
+ };
+
+ const fmtValue = (metric, v) =>
+ metric === 'revPerRecipient' ? fmtMoney(v) : fmtPct(v);
+
+ const METRIC_LABELS = {
+ openRate: { short: 'Open rate', hint: 'opens / sends' },
+ ctor: { short: 'CTOR', hint: 'clicks / opens' },
+ clickRate: { short: 'Click rate', hint: 'clicks / sends' },
+ revPerRecipient:{ short: '$ / recip.', hint: 'orders × AOV / sends' },
+ };
+ // Colour mapping reuses the existing CSS palette tokens so segment perf
+ // tiles share the look of the rest of the Command Center.
+ // good = sage (calm green-grey)
+ // ok = gold (warm hold)
+ // bad = accent (the brick / clay accent used for gated buttons)
+ const BAND = {
+ good: { bg: 'rgba(111,122,99,0.12)', fg: 'var(--sage)', label: 'Healthy' },
+ ok: { bg: 'rgba(184,146,90,0.16)', fg: 'var(--gold)', label: 'Watch' },
+ bad: { bg: 'rgba(138,90,68,0.16)', fg: 'var(--accent)', label: 'At risk' },
+ unknown: { bg: 'var(--cream)', fg: 'var(--mut)', label: '—' },
+ };
+
+ let benchmarks = null;
+ let summaryCache = null;
+ let anyMock = false;
+
+ function banner() {
+ $('#sp-banner').innerHTML = anyMock
+ ? `<div class="muted-banner">Running on <b>deterministic mock data</b> (CTCT
+ reporting + Shopify revenue attribution not yet wired in). Numbers are stable
+ so screenshots stay reproducible.</div>`
+ : '';
+ }
+
+ function renderLegend() {
+ const target = $('#sp-legend');
+ if (!benchmarks) { target.innerHTML = ''; return; }
+ const metric = $('#sp-metric').value;
+ const b = benchmarks[metric];
+ const isMoney = metric === 'revPerRecipient';
+ const fmt = isMoney ? fmtMoney : fmtPct;
+ target.innerHTML = `
+ <span class="muted">Benchmarks (${esc(METRIC_LABELS[metric].short)}):</span>
+ <span style="display:inline-flex;gap:6px;align-items:center">
+ <span style="width:10px;height:10px;border-radius:50%;background:var(--accent)"></span>
+ at risk < ${fmt(b.bad)}
+ </span>
+ <span style="display:inline-flex;gap:6px;align-items:center">
+ <span style="width:10px;height:10px;border-radius:50%;background:var(--gold)"></span>
+ watch ${fmt(b.bad)}–${fmt(b.good)}
+ </span>
+ <span style="display:inline-flex;gap:6px;align-items:center">
+ <span style="width:10px;height:10px;border-radius:50%;background:var(--sage)"></span>
+ healthy ≥ ${fmt(b.good)}
+ </span>
+ `;
+ }
+
+ // ── SVG bar chart (hand-rolled) ──────────────────────────────────────────
+ // Renders a 90-day trend as vertical bars with three reference bands
+ // (bad / ok / good) drawn as horizontal stripes underneath, plus the mean
+ // line. Campaign send days are drawn slightly taller / saturated.
+ function renderTrendSvg(trend, metric) {
+ const W = 360, H = 110;
+ const padL = 4, padR = 4, padT = 10, padB = 14;
+ const innerW = W - padL - padR;
+ const innerH = H - padT - padB;
+ const series = trend.series || [];
+ if (!series.length) return '<div class="muted">No data</div>';
+
+ // y-domain: stretch from 0 to max(value, benchmark.good × 1.15)
+ const benchmark = trend.benchmark || { bad: 0, good: 0 };
+ let maxV = 0;
+ for (const p of series) if (p.value > maxV) maxV = p.value;
+ const yMax = Math.max(maxV, benchmark.good * 1.15, 0.0001);
+ const yToPx = v => padT + innerH - (v / yMax) * innerH;
+
+ const barW = innerW / series.length;
+ const drawW = Math.max(1.5, barW - 0.6);
+
+ // colour each bar by which band its value falls in (gives instant visual
+ // health signal across the timeline, not just at the latest point)
+ const colorFor = v => {
+ if (v < benchmark.bad) return 'var(--accent)';
+ if (v < benchmark.good) return 'var(--gold)';
+ return 'var(--sage)';
+ };
+
+ // health bands as stacked translucent stripes
+ const badTop = yToPx(benchmark.bad);
+ const goodTop = yToPx(benchmark.good);
+ const baseY = padT + innerH;
+ const bands = `
+ <rect x="${padL}" y="${badTop}" width="${innerW}" height="${baseY - badTop}"
+ fill="rgba(138,90,68,0.06)" />
+ <rect x="${padL}" y="${goodTop}" width="${innerW}" height="${badTop - goodTop}"
+ fill="rgba(184,146,90,0.07)" />
+ <rect x="${padL}" y="${padT}" width="${innerW}" height="${goodTop - padT}"
+ fill="rgba(111,122,99,0.07)" />
+ `;
+
+ const bars = series.map((p, i) => {
+ const x = padL + i * barW;
+ const y = yToPx(p.value);
+ const h = baseY - y;
+ const fill = colorFor(p.value);
+ const opacity = p.isCampaign ? 1 : 0.55;
+ return `<rect x="${x.toFixed(2)}" y="${y.toFixed(2)}" width="${drawW.toFixed(2)}"
+ height="${Math.max(0.5, h).toFixed(2)}" fill="${fill}" opacity="${opacity}">
+ <title>${esc(p.date)} — ${metric === 'revPerRecipient' ? fmtMoney(p.value) : fmtPct(p.value)}${p.isCampaign ? ' (campaign day)' : ''}</title>
+ </rect>`;
+ }).join('');
+
+ // mean line
+ const meanY = yToPx(trend.mean || 0);
+ const meanLine = `
+ <line x1="${padL}" x2="${W - padR}" y1="${meanY}" y2="${meanY}"
+ stroke="var(--ink)" stroke-width="0.7" stroke-dasharray="2 3" opacity="0.5" />
+ <text x="${W - padR - 2}" y="${Math.max(8, meanY - 2)}" text-anchor="end"
+ font-size="9" fill="var(--mut)">mean ${metric === 'revPerRecipient' ? fmtMoney(trend.mean) : fmtPct(trend.mean)}</text>
+ `;
+
+ // x-axis: tick at first / mid / last date
+ const firstDate = series[0].date;
+ const midDate = series[Math.floor(series.length / 2)].date;
+ const lastDate = series[series.length - 1].date;
+ const tickStyle = 'font-size:9px;fill:var(--mut)';
+ const axis = `
+ <text x="${padL}" y="${H - 2}" style="${tickStyle}">${esc(firstDate)}</text>
+ <text x="${W / 2}" y="${H - 2}" text-anchor="middle" style="${tickStyle}">${esc(midDate)}</text>
+ <text x="${W - padR}" y="${H - 2}" text-anchor="end" style="${tickStyle}">${esc(lastDate)}</text>
+ `;
+
+ return `<svg viewBox="0 0 ${W} ${H}" width="100%" height="${H}" preserveAspectRatio="none"
+ style="display:block;background:#fff;border:1px solid var(--line);border-radius:8px">
+ ${bands}
+ ${bars}
+ ${meanLine}
+ ${axis}
+ </svg>`;
+ }
+
+ // ── KPI tile ─────────────────────────────────────────────────────────────
+ function kpiTile(metric, kpi, isSelected) {
+ const band = BAND[kpi.band] || BAND.unknown;
+ const label = METRIC_LABELS[metric];
+ const selectedRing = isSelected
+ ? 'box-shadow:0 0 0 2px var(--ink)'
+ : '';
+ const trendColor = kpi.trend > 0 ? 'var(--sage)' : kpi.trend < 0 ? 'var(--accent)' : 'var(--mut)';
+ return `
+ <div data-metric="${esc(metric)}" class="sp-tile" role="button" tabindex="0"
+ style="background:${band.bg};border-radius:10px;padding:10px 12px;cursor:pointer;
+ transition:transform .12s ease;${selectedRing}">
+ <div class="row" style="justify-content:space-between;align-items:baseline">
+ <span style="font-size:10.5px;letter-spacing:.5px;text-transform:uppercase;
+ color:${band.fg};font-weight:700">${esc(label.short)}</span>
+ <span style="font-size:10px;color:${trendColor};font-weight:700">${esc(fmtTrend(kpi.trend))}</span>
+ </div>
+ <div style="font:600 22px/1.1 'Cormorant Garamond',Georgia,serif;color:var(--ink);margin-top:2px">
+ ${esc(fmtValue(metric, kpi.value))}
+ </div>
+ <div class="muted" style="font-size:10.5px;margin-top:1px">${esc(label.hint)}</div>
+ </div>`;
+ }
+
+ function badge(seg) {
+ const b = BAND[seg.overallBand] || BAND.unknown;
+ return `<span class="pill" style="background:${b.bg};color:${b.fg}">${esc(b.label)}</span>`;
+ }
+
+ // ── render one segment card ──────────────────────────────────────────────
+ function renderCard(seg, trend, selectedMetric) {
+ const tiles = ['openRate', 'ctor', 'clickRate', 'revPerRecipient']
+ .map(m => kpiTile(m, seg.kpis[m], m === selectedMetric))
+ .join('');
+ const trendChart = trend
+ ? renderTrendSvg(trend, selectedMetric)
+ : '<div class="loading" style="padding:30px">Loading trend…</div>';
+ const trendLabel = METRIC_LABELS[selectedMetric].short;
+ const trendDelta = trend ? fmtTrend(trend.pctChange) : '';
+ const trendDeltaColor = trend && trend.pctChange > 0 ? 'var(--sage)'
+ : trend && trend.pctChange < 0 ? 'var(--accent)' : 'var(--mut)';
+ return `
+ <div class="card" data-segment="${esc(seg.id)}">
+ <div class="row" style="justify-content:space-between;align-items:baseline">
+ <div>
+ <h2 style="display:flex;gap:8px;align-items:center">
+ <span style="font-size:18px">${esc(seg.icon || '◆')}</span>
+ ${esc(seg.name)}
+ </h2>
+ <div class="muted" style="font-size:11.5px">${esc(seg.description)}</div>
+ </div>
+ ${badge(seg)}
+ </div>
+
+ <div class="row" style="gap:14px;margin:10px 0 0;font-size:11.5px;color:var(--mut)">
+ <span>List: <b style="color:var(--ink)">${fmtNum(seg.listSize)}</b></span>
+ <span>Sends / campaign: <b style="color:var(--ink)">${fmtNum(seg.sendsPerCampaign)}</b></span>
+ </div>
+
+ <div class="grid sp-tiles" style="grid-template-columns:repeat(2,1fr);gap:8px;margin-top:12px">
+ ${tiles}
+ </div>
+
+ <div style="margin-top:14px">
+ <div class="row" style="justify-content:space-between;align-items:baseline">
+ <label style="margin:0">90-day trend · ${esc(trendLabel)}</label>
+ <span style="font-size:11px;color:${trendDeltaColor};font-weight:700">${esc(trendDelta)} 30d vs 30d</span>
+ </div>
+ <div class="sp-chart" style="margin-top:6px">${trendChart}</div>
+ </div>
+ </div>`;
+ }
+
+ // ── orchestration ────────────────────────────────────────────────────────
+ async function fetchAllTrends(segments, metric) {
+ const out = {};
+ await Promise.all(segments.map(async s => {
+ const t = await api(`/trend?segmentId=${encodeURIComponent(s.id)}&metric=${encodeURIComponent(metric)}&days=90`);
+ if (t.mock) anyMock = true;
+ out[s.id] = t;
+ }));
+ return out;
+ }
+
+ async function render() {
+ const metric = $('#sp-metric').value;
+ const target = $('#sp-cards');
+ target.innerHTML = '<div class="loading" style="grid-column:1 / -1">Loading segments…</div>';
+
+ const summary = await api('/summary');
+ summaryCache = summary;
+ if (summary.mock) anyMock = true;
+ benchmarks = summary.benchmarks;
+ renderLegend();
+ banner();
+
+ // paint cards first with no chart, then fill in trends as they arrive
+ target.innerHTML = summary.segments.map(s => renderCard(s, null, metric)).join('');
+ wireTileClicks();
+
+ const trends = await fetchAllTrends(summary.segments, metric);
+ banner();
+ target.innerHTML = summary.segments
+ .map(s => renderCard(s, trends[s.id], metric))
+ .join('');
+ wireTileClicks();
+ }
+
+ // Click a KPI tile in any card → switch the selected trend metric and
+ // re-render. Saves the user from hunting for the dropdown.
+ function wireTileClicks() {
+ $$('.sp-tile').forEach(el => {
+ el.onclick = () => {
+ const m = el.getAttribute('data-metric');
+ if (!m) return;
+ $('#sp-metric').value = m;
+ render();
+ };
+ el.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); el.click(); } };
+ });
+ }
+
+ $('#sp-metric').onchange = render;
+ $('#sp-refresh').onclick = render;
+
+ render();
+ },
+};
diff --git a/public/panels/segments.html b/public/panels/segments.html
index 1299c9b..286cfc4 100644
--- a/public/panels/segments.html
+++ b/public/panels/segments.html
@@ -1,5 +1,24 @@
<div id="seg-banner"></div>
+<!-- RFM auto-segmentation: computed from email engagement -->
+<div class="card" id="seg-rfm-card">
+ <div class="row" style="justify-content:space-between;align-items:baseline;gap:12px">
+ <div>
+ <h2>RFM auto-segments</h2>
+ <div class="muted" style="font-size:12px">
+ Recency (last open) · Frequency (open count) · Monetary proxy (clicks).
+ Every contact lands in one bucket — totals reconcile to the pool.
+ </div>
+ </div>
+ <div class="row" style="gap:6px;align-items:center">
+ <span class="pill" id="seg-rfm-total">—</span>
+ <button class="btn ghost" id="seg-rfm-refresh" style="padding:6px 12px;font-size:12.5px">Refresh</button>
+ </div>
+ </div>
+ <div class="muted" id="seg-rfm-summary" style="font-size:12px;margin-top:6px"></div>
+ <div id="seg-rfm-grid" class="loading" style="margin-top:12px">Loading RFM…</div>
+</div>
+
<div class="grid" style="grid-template-columns:minmax(280px,360px) 1fr;align-items:start">
<!-- Left: saved segments -->
diff --git a/public/panels/segments.js b/public/panels/segments.js
index 8d35ce7..44d96ce 100644
--- a/public/panels/segments.js
+++ b/public/panels/segments.js
@@ -219,6 +219,70 @@ window.MCC_PANELS['segments'] = {
finally { $('#seg-save').disabled = false; }
}
+ // ── RFM auto-segments ───────────────────────────────────────────────────────
+ const BUCKET_COLOR = {
+ champions: '#b8925a', // brand gold
+ loyal: '#6f7a63', // sage
+ 'at-risk': '#c47a4a', // warm warning
+ hibernating: '#8a8275', // muted
+ promising: '#7a8b9a', // cool fresh
+ };
+ function rfmCard(b) {
+ const pct = (b.share * 100).toFixed(1);
+ const accent = BUCKET_COLOR[b.id] || 'var(--accent)';
+ const samp = (b.sample || []).slice(0, 5).map(s => {
+ const tail = `R${s.r}·F${s.f}·M${s.m}`;
+ return `<div style="display:flex;justify-content:space-between;gap:8px;
+ border-top:1px solid var(--line);padding:5px 0;font-size:12px">
+ <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1">${esc(s.email)}</span>
+ <span class="muted" style="font-size:11px;flex-shrink:0">${tail}</span>
+ </div>`;
+ }).join('') || '<div class="muted" style="font-size:12px;padding:6px 0">No contacts in this bucket.</div>';
+ const moreCount = Math.max(0, b.count - 5);
+ return `<div class="card" style="margin:0;border-top:3px solid ${accent}">
+ <div class="row" style="justify-content:space-between;align-items:baseline">
+ <div style="font:600 16px/1.2 'Cormorant Garamond',Georgia,serif">
+ <span style="font-size:18px;margin-right:6px">${esc(b.icon)}</span>${esc(b.name)}
+ </div>
+ <span class="pill" style="background:${accent};color:#fff">${b.count.toLocaleString()}</span>
+ </div>
+ <div class="muted" style="font-size:11.5px;margin:4px 0 2px">${esc(b.description)}</div>
+ <div class="muted" style="font-size:11px;margin-bottom:8px">
+ <code style="font-size:10.5px">${esc(b.criteria)}</code> · ${pct}% of pool
+ </div>
+ <div style="margin-top:6px">${samp}
+ ${moreCount > 0 ? `<div class="muted" style="font-size:11px;padding-top:5px">+ ${moreCount.toLocaleString()} more…</div>` : ''}
+ </div>
+ <div class="muted" style="font-size:11.5px;border-top:1px solid var(--line);
+ padding-top:8px;margin-top:8px;font-style:italic">${esc(b.nextStep || '')}</div>
+ </div>`;
+ }
+ function renderRfm(r) {
+ markMock(r.mock);
+ $('#seg-rfm-total').textContent = `${Number(r.total || 0).toLocaleString()} contacts`;
+ const s = r.summary || {};
+ $('#seg-rfm-summary').innerHTML =
+ `Pool averages — R <b>${s.avgR ?? '—'}</b> · F <b>${s.avgF ?? '—'}</b> · M <b>${s.avgM ?? '—'}</b>`;
+ const grid = $('#seg-rfm-grid');
+ grid.classList.remove('loading');
+ const buckets = r.buckets || [];
+ if (!buckets.length) {
+ grid.innerHTML = '<div class="muted">No RFM data.</div>';
+ return;
+ }
+ grid.style.cssText =
+ 'margin-top:12px;display:grid;gap:14px;' +
+ 'grid-template-columns:repeat(auto-fit,minmax(220px,1fr))';
+ grid.innerHTML = buckets.map(rfmCard).join('');
+ }
+ function loadRfm() {
+ $('#seg-rfm-grid').classList.add('loading');
+ $('#seg-rfm-grid').innerHTML = 'Computing RFM…';
+ return api('/rfm').then(renderRfm)
+ .catch(e => { $('#seg-rfm-grid').innerHTML = `<div class="muted">Error: ${esc(e.message)}</div>`; });
+ }
+ $('#seg-rfm-refresh').onclick = loadRfm;
+
// ── wire up ──────────────────────────────────────────────────────────────────
$('#seg-add-rule').onclick = () => addRule({});
$('#seg-new').onclick = () => loadIntoBuilder(null);
@@ -230,6 +294,7 @@ window.MCC_PANELS['segments'] = {
SCHEMA = s || SCHEMA;
loadIntoBuilder(null);
loadList();
+ loadRfm();
}).catch(e => setMsg(`Error loading schema: ${e.message}`, true));
},
};
diff --git a/public/panels/send-times.html b/public/panels/send-times.html
new file mode 100644
index 0000000..ec756f8
--- /dev/null
+++ b/public/panels/send-times.html
@@ -0,0 +1,64 @@
+<div id="st-banner"></div>
+
+<div class="card">
+ <div class="row" style="justify-content:space-between;align-items:baseline;gap:12px">
+ <div>
+ <h2>Send-Time Optimization</h2>
+ <div class="muted" style="font-size:12px">
+ Mines historical email-open timestamps to find the best day×hour window per
+ audience. Read-only analysis — does not trigger a send.
+ </div>
+ </div>
+ <div class="row" style="gap:8px;align-items:center">
+ <label for="st-segment" style="margin:0">Audience</label>
+ <select id="st-segment" style="min-width:260px;width:auto"></select>
+ <button class="btn ghost" id="st-refresh" style="padding:6px 12px;font-size:12.5px">Refresh</button>
+ </div>
+ </div>
+</div>
+
+<div class="grid" style="grid-template-columns:1fr 320px;align-items:start">
+
+ <!-- heatmap -->
+ <div class="card" id="st-heatmap-card">
+ <div class="row" style="justify-content:space-between;align-items:baseline">
+ <div>
+ <h2 id="st-heatmap-title">Open heatmap · day × hour</h2>
+ <div class="muted" id="st-heatmap-sub" style="font-size:12px">Loading…</div>
+ </div>
+ <span class="pill" id="st-total">—</span>
+ </div>
+ <div id="st-heatmap" class="loading" style="margin-top:14px">Loading heatmap…</div>
+ <div class="muted" style="font-size:11px;margin-top:8px">
+ Each cell counts opens by weekday × hour-of-day (UTC). Darker = more opens.
+ </div>
+ </div>
+
+ <!-- recommendation panel -->
+ <div class="card" id="st-rec-card">
+ <h2>Recommended window</h2>
+ <div class="muted" style="font-size:12px">Best send slot for this audience</div>
+ <div id="st-rec" class="loading" style="margin-top:12px">Loading…</div>
+
+ <div style="margin-top:14px">
+ <label>Best hour per weekday</label>
+ <div id="st-perday" class="muted">—</div>
+ </div>
+ </div>
+
+</div>
+
+<!-- per-contact table -->
+<div class="card">
+ <div class="row" style="justify-content:space-between;align-items:baseline">
+ <div>
+ <h2>Per-contact predicted peak</h2>
+ <div class="muted" style="font-size:12px">
+ Each contact's modal open window across their history. High-confidence
+ rows first (≥35% of opens land in the peak cell).
+ </div>
+ </div>
+ <span class="pill" id="st-contact-count">—</span>
+ </div>
+ <div id="st-contacts" class="loading" style="margin-top:14px">Loading contacts…</div>
+</div>
diff --git a/public/panels/send-times.js b/public/panels/send-times.js
new file mode 100644
index 0000000..0cc5e3f
--- /dev/null
+++ b/public/panels/send-times.js
@@ -0,0 +1,208 @@
+// Send-Time Optimization panel — picks a segment, fetches a day×hour heatmap of
+// historical opens, and renders: (1) the heatmap, (2) the recommended send
+// window pill + backup slots, (3) a per-contact table of predicted peak windows.
+// Always read-only; no send action lives here.
+window.MCC_PANELS = window.MCC_PANELS || {};
+window.MCC_PANELS['send-times'] = {
+ init(root) {
+ const $ = s => root.querySelector(s);
+ const api = (p) => fetch(`/api/send-times${p}`).then(r => r.json());
+ const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c =>
+ ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
+ const fmtHour = h => {
+ if (h == null) return '—';
+ const am = h < 12, h12 = h % 12 || 12;
+ return `${h12}:00 ${am ? 'AM' : 'PM'}`;
+ };
+ const fmtDate = d => d ? new Date(d).toLocaleDateString(undefined,
+ { year: 'numeric', month: 'short', day: 'numeric' }) : '—';
+
+ let anyMock = false;
+ function banner() {
+ $('#st-banner').innerHTML = anyMock
+ ? `<div class="muted-banner">Running on a <b>mock open-history pool</b> (~200 contacts,
+ 12–48 synthetic opens each, persona-shaped distributions). Wire up a CTCT reporting
+ token or GA4 events to mine real open timestamps.</div>`
+ : '';
+ }
+
+ // ── segment picker ─────────────────────────────────────────────────────
+ async function loadSegments() {
+ const data = await api('/segments');
+ if (data.mock) { anyMock = true; banner(); }
+ const sel = $('#st-segment');
+ sel.innerHTML = (data.segments || []).map(s =>
+ `<option value="${esc(s.id)}">${esc(s.name)} (${Number(s.estimatedSize || 0).toLocaleString()})</option>`
+ ).join('');
+ sel.onchange = () => render();
+ }
+
+ // ── heatmap rendering ──────────────────────────────────────────────────
+ function renderHeatmap(data) {
+ const card = $('#st-heatmap');
+ const M = data.matrix || [];
+ const days = data.dayLabels || ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
+
+ // find max for normalization
+ let max = 0;
+ for (const row of M) for (const v of row) if (v > max) max = v;
+ const scale = max || 1;
+
+ // recommended cell highlight
+ const recDay = data.recommendation ? data.recommendation.day : null;
+ const recHour = data.recommendation ? data.recommendation.hour : null;
+
+ // table-based heatmap (7 rows × 24 cols)
+ const hourHeader = Array.from({ length: 24 }, (_, h) =>
+ `<th style="font-weight:500;color:var(--mut);font-size:10px;padding:2px 0;text-align:center">${h}</th>`).join('');
+ const rows = M.map((row, d) => {
+ const cells = row.map((v, h) => {
+ const t = v / scale;
+ // gold gradient over the cream background
+ const alpha = v === 0 ? 0 : 0.10 + 0.85 * t;
+ const isRec = (d === recDay && h === recHour);
+ const ring = isRec ? 'outline:2px solid var(--accent);outline-offset:-2px' : '';
+ return `<td title="${esc(days[d])} ${fmtHour(h)} — ${v} open${v === 1 ? '' : 's'}"
+ style="background:rgba(184,146,90,${alpha.toFixed(3)});width:22px;height:22px;
+ border:1px solid #f0eadf;padding:0;${ring}"></td>`;
+ }).join('');
+ return `<tr>
+ <th style="font-weight:600;color:var(--ink);font-size:11px;text-align:right;padding-right:8px;width:34px">${esc(days[d])}</th>
+ ${cells}
+ </tr>`;
+ }).join('');
+
+ card.classList.remove('loading');
+ card.innerHTML = `
+ <div style="overflow-x:auto">
+ <table style="border-collapse:collapse;font-size:11px">
+ <thead><tr><th></th>${hourHeader}</tr></thead>
+ <tbody>${rows}</tbody>
+ </table>
+ </div>
+ <div class="row" style="gap:14px;margin-top:10px;align-items:center;font-size:11px;color:var(--mut)">
+ <span>Hour of day (UTC, 0–23)</span>
+ <span class="row" style="gap:4px;align-items:center;margin-left:auto">
+ <span>fewer</span>
+ <span style="width:90px;height:10px;border:1px solid var(--line);border-radius:4px;
+ background:linear-gradient(to right, rgba(184,146,90,0.1), rgba(184,146,90,0.95))"></span>
+ <span>more</span>
+ </span>
+ </div>`;
+ }
+
+ function renderRecommendation(data) {
+ const rec = data.recommendation;
+ const target = $('#st-rec');
+ if (!rec) {
+ target.classList.remove('loading');
+ target.innerHTML = '<div class="muted">No open history in this audience yet.</div>';
+ return;
+ }
+ const backups = (data.backups || []).map(b =>
+ `<li><b>${esc(b.dayLabel)}</b> · ${esc(b.hourLabel)} <span class="muted">(${b.count} opens)</span></li>`
+ ).join('');
+ target.classList.remove('loading');
+ target.innerHTML = `
+ <div style="background:var(--cream);border:1px solid var(--line);border-radius:10px;padding:14px;text-align:center">
+ <div style="font:600 28px/1.1 'Cormorant Garamond',Georgia,serif;color:var(--accent)">
+ ${esc(rec.dayLabel)} · ${esc(rec.hourLabel)}
+ </div>
+ <div class="muted" style="font-size:12px;margin-top:4px">${rec.count} opens in this window</div>
+ </div>
+ ${backups ? `<div style="margin-top:10px"><label>Backup slots</label>
+ <ul style="margin:6px 0 0;padding-left:18px;font-size:13px">${backups}</ul></div>` : ''}`;
+ }
+
+ function renderPerDay(data) {
+ const target = $('#st-perday');
+ const rows = (data.bestHourPerDay || []).filter(r => r.count > 0);
+ if (!rows.length) { target.textContent = '—'; return; }
+ target.style.fontSize = '12.5px';
+ target.style.color = 'var(--ink)';
+ target.innerHTML = rows.map(r =>
+ `<div class="row" style="justify-content:space-between;border-top:1px solid var(--line);padding:4px 0">
+ <span style="font-weight:600">${esc(r.dayLabel)}</span>
+ <span>${esc(fmtHour(r.hour))} <span class="muted" style="font-size:11px">(${r.count})</span></span>
+ </div>`).join('');
+ }
+
+ // ── per-contact table ──────────────────────────────────────────────────
+ function renderContacts(data) {
+ const target = $('#st-contacts');
+ $('#st-contact-count').textContent =
+ `${data.returned}/${data.total} contacts`;
+ if (!data.contacts || !data.contacts.length) {
+ target.classList.remove('loading');
+ target.innerHTML = '<div class="muted">No contacts in this audience.</div>';
+ return;
+ }
+ const confColor = c => c === 'high' ? 'var(--sage)' : c === 'med' ? 'var(--gold)' : 'var(--mut)';
+ const rows = data.contacts.map(c => `
+ <tr style="border-top:1px solid var(--line)">
+ <td style="padding:7px 8px;font-size:12.5px"><code style="font-size:12px">${esc(c.email)}</code></td>
+ <td style="padding:7px 8px"><span class="pill" style="background:${c.type === 'trade' ? '#efe7d6' : 'var(--cream)'}">${esc(c.type)}</span></td>
+ <td style="padding:7px 8px;font-size:12.5px">
+ ${c.peak.dayLabel ? `<b>${esc(c.peak.dayLabel)}</b> · ${esc(c.peak.hourLabel)}` : '<span class="muted">insufficient data</span>'}
+ </td>
+ <td style="padding:7px 8px;font-size:11.5px;color:${confColor(c.peak.confidence)};font-weight:600;text-transform:uppercase">
+ ${esc(c.peak.confidence)}${c.peak.share != null ? ` <span class="muted" style="font-weight:400;text-transform:none">(${Math.round(c.peak.share * 100)}%)</span>` : ''}
+ </td>
+ <td style="padding:7px 8px;font-size:12px;text-align:right">${c.peak.samples}</td>
+ <td style="padding:7px 8px;font-size:12px;text-align:right" class="muted">${esc(fmtDate(c.lastOpen))}</td>
+ </tr>`).join('');
+ target.classList.remove('loading');
+ target.innerHTML = `
+ <div style="overflow-x:auto">
+ <table style="border-collapse:collapse;width:100%">
+ <thead>
+ <tr style="text-align:left;font-size:11px;color:var(--mut)">
+ <th style="padding:6px 8px">Email</th>
+ <th style="padding:6px 8px">Type</th>
+ <th style="padding:6px 8px">Predicted peak</th>
+ <th style="padding:6px 8px">Confidence</th>
+ <th style="padding:6px 8px;text-align:right">Opens</th>
+ <th style="padding:6px 8px;text-align:right">Last open</th>
+ </tr>
+ </thead>
+ <tbody>${rows}</tbody>
+ </table>
+ </div>`;
+ }
+
+ // ── orchestration ──────────────────────────────────────────────────────
+ async function render() {
+ const segId = $('#st-segment').value || '__all__';
+ $('#st-heatmap').classList.add('loading');
+ $('#st-heatmap').textContent = 'Loading heatmap…';
+ $('#st-rec').classList.add('loading');
+ $('#st-rec').textContent = 'Loading…';
+ $('#st-contacts').classList.add('loading');
+ $('#st-contacts').textContent = 'Loading contacts…';
+
+ const [heat, cts] = await Promise.all([
+ api(`/heatmap?segmentId=${encodeURIComponent(segId)}`),
+ api(`/contacts?segmentId=${encodeURIComponent(segId)}&limit=50`),
+ ]);
+ if (heat.mock || cts.mock) { anyMock = true; banner(); }
+
+ const segLabel = heat.segment ? heat.segment.name : 'All contacts';
+ $('#st-heatmap-title').textContent = `Open heatmap · ${segLabel}`;
+ $('#st-heatmap-sub').textContent =
+ `${heat.contactCount} contacts · ${heat.contactsWithHistory} with open history · ${heat.totalOpens.toLocaleString()} opens`;
+ $('#st-total').textContent = `${heat.totalOpens.toLocaleString()} opens`;
+
+ renderHeatmap(heat);
+ renderRecommendation(heat);
+ renderPerDay(heat);
+ renderContacts(cts);
+ }
+
+ $('#st-refresh').onclick = render;
+
+ (async () => {
+ await loadSegments();
+ await render();
+ })();
+ },
+};
diff --git a/public/panels/templates.html b/public/panels/templates.html
new file mode 100644
index 0000000..9865cbe
--- /dev/null
+++ b/public/panels/templates.html
@@ -0,0 +1,26 @@
+<div class="card">
+ <h2>Campaign Templates</h2>
+ <p class="muted" style="margin:0 0 14px">Pre-built luxury DW email campaigns in the house voice — Welcome, memo-sample sequence, VIP previews, seasonal editorials, re-engagement. Pick one, fill the fields, copy into Constant Contact.</p>
+
+ <div id="templates-banner"></div>
+
+ <div class="row" style="align-items:center;gap:14px">
+ <div style="flex:1;min-width:240px">
+ <label for="templates-search">Search</label>
+ <input id="templates-search" type="text" placeholder="welcome, memo, VIP, seasonal…" />
+ </div>
+ <div style="min-width:220px">
+ <label for="templates-category">Category</label>
+ <select id="templates-category"></select>
+ </div>
+ </div>
+</div>
+
+<div id="templates-out" class="grid" style="grid-template-columns:repeat(auto-fill, minmax(360px, 1fr))"></div>
+
+<div id="templates-modal" style="display:none;position:fixed;inset:0;background:rgba(20,17,15,0.6);z-index:50;align-items:flex-start;justify-content:center;padding:40px 20px;overflow:auto">
+ <div class="card" style="max-width:760px;width:100%;margin:0;position:relative">
+ <button id="templates-modal-close" class="btn ghost" style="position:absolute;top:14px;right:14px;padding:5px 12px">✕ Close</button>
+ <div id="templates-modal-body"></div>
+ </div>
+</div>
diff --git a/public/panels/templates.js b/public/panels/templates.js
new file mode 100644
index 0000000..7443b9f
--- /dev/null
+++ b/public/panels/templates.js
@@ -0,0 +1,251 @@
+// templates panel — Campaign Templates browser + filler.
+// Loads /api/templates/list, renders card-per-template, opens a modal with a
+// placeholder form on "Use this", POSTs /api/templates/:id/use to get the
+// filled template, and provides copy-to-clipboard for subject and body.
+window.MCC_PANELS = window.MCC_PANELS || {};
+window.MCC_PANELS['templates'] = {
+ async init(root) {
+ const $ = s => root.querySelector(s);
+ const $$ = s => Array.from(root.querySelectorAll(s));
+ const esc = s => String(s == null ? '' : s)
+ .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
+
+ const banner = $('#templates-banner');
+ const out = $('#templates-out');
+ const search = $('#templates-search');
+ const catSel = $('#templates-category');
+ const modal = $('#templates-modal');
+ const modalBody = $('#templates-modal-body');
+ const modalClose = $('#templates-modal-close');
+
+ let TEMPLATES = [];
+ let META = null;
+
+ // ── load ─────────────────────────────────────────────────────────────────
+ try {
+ const [metaRes, listRes] = await Promise.all([
+ fetch('/api/templates/meta').then(r => r.json()),
+ fetch('/api/templates/list').then(r => r.json()),
+ ]);
+ META = metaRes;
+ TEMPLATES = Array.isArray(listRes.templates) ? listRes.templates : [];
+ } catch (e) {
+ out.innerHTML = '<div class="muted-banner">Could not reach the campaign templates service.</div>';
+ return;
+ }
+
+ if (META && META.gateMessage) {
+ banner.innerHTML = `<div class="muted-banner">📋 STAGED — ${esc(META.gateMessage)}</div>`;
+ }
+
+ // ── category select ─────────────────────────────────────────────────────
+ const cats = ['All categories', ...(META && META.categories ? META.categories : [])];
+ catSel.innerHTML = cats.map((c, i) =>
+ `<option value="${i === 0 ? '' : esc(c)}">${esc(c)}</option>`).join('');
+
+ // ── render ──────────────────────────────────────────────────────────────
+ function visible() {
+ const q = (search.value || '').trim().toLowerCase();
+ const cat = catSel.value;
+ return TEMPLATES.filter(t => {
+ if (cat && t.category !== cat) return false;
+ if (!q) return true;
+ const hay = `${t.title} ${t.category} ${t.description} ${t.subject} ${t.audience} ${(t.productLines || []).join(' ')}`.toLowerCase();
+ return hay.includes(q);
+ });
+ }
+
+ function render() {
+ const list = visible();
+ if (!list.length) {
+ out.innerHTML = '<div class="muted-banner">No templates match that filter.</div>';
+ return;
+ }
+ out.innerHTML = list.map(t => `
+ <div class="card" style="margin-bottom:0;display:flex;flex-direction:column">
+ <div class="row" style="justify-content:space-between;align-items:flex-start;gap:8px;margin-bottom:8px">
+ <span class="pill">${esc(t.category || '—')}</span>
+ <span class="muted" style="font-size:11px;font-weight:600;letter-spacing:.5px;text-transform:uppercase">${esc(t.sequencePosition || '')}</span>
+ </div>
+ <h3 style="margin:0 0 6px;font:600 16px/1.25 'Cormorant Garamond',Georgia,serif">${esc(t.title)}</h3>
+ <div style="font-size:13px;font-weight:600;color:var(--ink);margin-bottom:6px">${esc(t.subject)}</div>
+ <div class="muted" style="font-size:12.5px;line-height:1.5;margin-bottom:12px;flex:1">${esc(t.description)}</div>
+ <div class="row" style="gap:6px;margin-bottom:12px;flex-wrap:wrap">
+ ${esc(t.audience) ? `<span class="pill" style="background:#efece5;color:var(--ink)">${esc(t.audience)}</span>` : ''}
+ ${(t.productLines || []).slice(0, 3).map(p => `<span class="pill" style="background:#f4efe7;color:var(--accent)">${esc(p)}</span>`).join('')}
+ </div>
+ <div class="row" style="justify-content:flex-end;gap:8px;margin-top:auto">
+ <button class="btn ghost preview-btn" data-id="${esc(t.id)}" style="padding:7px 14px">👁 Preview</button>
+ <button class="btn gold use-btn" data-id="${esc(t.id)}" style="padding:7px 14px">📋 Use this</button>
+ </div>
+ </div>`).join('');
+
+ out.querySelectorAll('.preview-btn').forEach(b =>
+ b.addEventListener('click', () => openModal(b.dataset.id, 'preview')));
+ out.querySelectorAll('.use-btn').forEach(b =>
+ b.addEventListener('click', () => openModal(b.dataset.id, 'use')));
+ }
+
+ search.addEventListener('input', render);
+ catSel.addEventListener('change', render);
+ render();
+
+ // ── modal ────────────────────────────────────────────────────────────────
+ function showModal() { modal.style.display = 'flex'; document.body.style.overflow = 'hidden'; }
+ function hideModal() { modal.style.display = 'none'; modalBody.innerHTML = ''; document.body.style.overflow = ''; }
+ modalClose.addEventListener('click', hideModal);
+ modal.addEventListener('click', (e) => { if (e.target === modal) hideModal(); });
+
+ function openModal(id, mode) {
+ const tpl = TEMPLATES.find(t => t.id === id);
+ if (!tpl) return;
+ if (mode === 'preview') renderPreview(tpl);
+ else renderUseForm(tpl);
+ showModal();
+ }
+
+ function renderPreview(tpl) {
+ modalBody.innerHTML = `
+ <span class="pill">${esc(tpl.category)}</span>
+ <h2 style="margin:10px 0 4px">${esc(tpl.title)}</h2>
+ <div class="muted" style="font-size:12.5px;margin-bottom:14px">${esc(tpl.description)}</div>
+
+ <label>Subject (template)</label>
+ <div style="background:var(--paper);border:1px solid var(--line);border-radius:9px;padding:10px 12px;font-weight:600;margin-bottom:14px">${esc(tpl.subject)}</div>
+
+ ${tpl.preheader ? `
+ <label>Preheader (template)</label>
+ <div style="background:var(--paper);border:1px solid var(--line);border-radius:9px;padding:10px 12px;margin-bottom:14px;font-size:13px;color:var(--mut)">${esc(tpl.preheader)}</div>
+ ` : ''}
+
+ <label>Body (template — with {{placeholders}})</label>
+ <pre style="white-space:pre-wrap;font:13px/1.6 'Inter',sans-serif;background:var(--paper);border:1px solid var(--line);border-radius:9px;padding:14px;margin:0 0 14px;color:var(--ink)">${esc(tpl.body)}</pre>
+
+ <div class="muted-banner" style="margin:0 0 14px">⚠ Add CAN-SPAM footer (physical mailing address + working unsubscribe) before this template is queued in Constant Contact.</div>
+
+ <div class="row" style="justify-content:flex-end">
+ <button class="btn gold" id="from-preview-use" style="padding:8px 16px">📋 Use this template</button>
+ </div>`;
+ modalBody.querySelector('#from-preview-use').addEventListener('click', () => renderUseForm(tpl));
+ }
+
+ function renderUseForm(tpl) {
+ const placeholders = tpl.placeholders || [];
+ modalBody.innerHTML = `
+ <span class="pill">${esc(tpl.category)}</span>
+ <h2 style="margin:10px 0 4px">${esc(tpl.title)} — fill in</h2>
+ <div class="muted" style="font-size:12.5px;margin-bottom:14px">Fill the fields and click <b>Generate draft</b>. STAGED only — nothing sends.</div>
+
+ ${placeholders.length === 0
+ ? '<div class="muted-banner" style="margin-bottom:14px">This template has no placeholders — it\'s ready to copy as-is.</div>'
+ : `<div class="grid" style="grid-template-columns:repeat(2,1fr);margin-bottom:16px">
+ ${placeholders.map(p => `
+ <div>
+ <label for="ph-${esc(p.key)}">${esc(p.label || p.key)}</label>
+ <input id="ph-${esc(p.key)}" data-key="${esc(p.key)}" type="text" placeholder="${esc(p.default || '')}" value="" />
+ </div>`).join('')}
+ </div>`}
+
+ <div class="row" style="justify-content:flex-end;gap:8px;margin-bottom:14px">
+ <button class="btn ghost" id="use-cancel" style="padding:7px 14px">Cancel</button>
+ <button class="btn gold" id="use-generate" style="padding:7px 14px">📋 Generate draft</button>
+ </div>
+
+ <div id="use-result"></div>`;
+
+ modalBody.querySelector('#use-cancel').addEventListener('click', hideModal);
+ modalBody.querySelector('#use-generate').addEventListener('click', async () => {
+ const fields = {};
+ modalBody.querySelectorAll('input[data-key]').forEach(el => {
+ fields[el.dataset.key] = el.value;
+ });
+ const btn = modalBody.querySelector('#use-generate');
+ btn.disabled = true; const orig = btn.textContent; btn.textContent = 'Drafting…';
+ try {
+ const r = await fetch(`/api/templates/${encodeURIComponent(tpl.id)}/use`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ fields }),
+ });
+ const data = await r.json();
+ renderUseResult(tpl, data);
+ } catch (e) {
+ modalBody.querySelector('#use-result').innerHTML =
+ '<div class="muted-banner">Could not generate the draft. Try again.</div>';
+ } finally {
+ btn.disabled = false; btn.textContent = orig;
+ }
+ });
+ }
+
+ function renderUseResult(tpl, data) {
+ const target = modalBody.querySelector('#use-result');
+ if (!target) return;
+ const unfilledNote = (data.unfilled && data.unfilled.length)
+ ? `<div class="muted-banner" style="margin:14px 0 0">Still unfilled: ${data.unfilled.map(esc).join(', ')} — the placeholders are kept in the draft so you can finish them in Constant Contact.</div>`
+ : '';
+ target.innerHTML = `
+ <div style="margin-top:8px;padding-top:14px;border-top:1px solid var(--line)">
+ <div class="row" style="justify-content:space-between;align-items:center;margin-bottom:8px">
+ <h3 style="margin:0;font:600 15px/1.2 'Cormorant Garamond',Georgia,serif">Filled draft (STAGED)</h3>
+ <span class="pill" style="background:#fff0e6;color:var(--accent)">staged · no send</span>
+ </div>
+
+ <label>Subject</label>
+ <div class="row" style="align-items:center;gap:8px;margin-bottom:14px">
+ <div style="flex:1;background:var(--paper);border:1px solid var(--line);border-radius:9px;padding:10px 12px;font-weight:600">${esc(data.subject)}</div>
+ <button class="btn ghost copy-subject" style="padding:7px 12px">📋 Copy</button>
+ </div>
+
+ ${data.preheader ? `
+ <label>Preheader</label>
+ <div class="row" style="align-items:center;gap:8px;margin-bottom:14px">
+ <div style="flex:1;background:var(--paper);border:1px solid var(--line);border-radius:9px;padding:10px 12px;font-size:13px;color:var(--mut)">${esc(data.preheader)}</div>
+ <button class="btn ghost copy-preheader" style="padding:7px 12px">📋 Copy</button>
+ </div>` : ''}
+
+ <label>Body</label>
+ <pre style="white-space:pre-wrap;font:13px/1.6 'Inter',sans-serif;background:var(--paper);border:1px solid var(--line);border-radius:9px;padding:14px;margin:0 0 8px;max-height:400px;overflow:auto">${esc(data.body)}</pre>
+ <div class="row" style="justify-content:flex-end;gap:8px;margin-bottom:14px">
+ <button class="btn ghost copy-body" style="padding:7px 12px">📋 Copy body</button>
+ <button class="btn ghost copy-all" style="padding:7px 12px">📋 Copy subject + body</button>
+ </div>
+
+ <div class="muted-banner" style="margin:0">⚠ Before this ships in Constant Contact: ${esc(data.complianceReminder || 'add CAN-SPAM footer (physical mailing address + working unsubscribe).')}</div>
+ ${unfilledNote}
+ </div>`;
+
+ const setCopier = (sel, text) => {
+ const b = target.querySelector(sel);
+ if (!b) return;
+ b.addEventListener('click', async () => {
+ const ok = await copyText(text);
+ const prev = b.textContent;
+ b.textContent = ok ? '✓ Copied' : 'Copy failed';
+ setTimeout(() => { b.textContent = prev; }, 1400);
+ });
+ };
+ setCopier('.copy-subject', data.subject || '');
+ setCopier('.copy-preheader', data.preheader || '');
+ setCopier('.copy-body', data.body || '');
+ setCopier('.copy-all', `Subject: ${data.subject || ''}\n\n${data.body || ''}`);
+ }
+
+ 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; }
+ }
+ },
+};
← 57a572a vendors module: prefer headless-verified follower counts + v
·
back to Marketing Command Center
·
social: Ayrshare live-publish + scheduler scaffold (gated, o cfea2d7 →