← back to Marketing Command Center
assets: full-catalog search via Shopify predictive search (suggest.json)
7207cc9bde6ae60ac2d628c6857c3511c1366e50 · 2026-06-10 08:42:45 -0700 · SteveStudio2
q= now hits /search/suggest.json (whole storefront) so deep lines like
grasscloth/silk/cork are findable; falls back to cached browse-filter on
rate-limit/non-JSON. normImg() handles protocol-relative cdn URLs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
A data/segments.jsonA data/social-queue.jsonM modules/assets/index.jsA modules/performance/index.jsA modules/segments/index.jsA modules/social/index.jsA public/panels/performance.htmlA public/panels/performance.jsA public/panels/segments.htmlA public/panels/segments.jsA public/panels/social.htmlA public/panels/social.js
Diff
commit 7207cc9bde6ae60ac2d628c6857c3511c1366e50
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Wed Jun 10 08:42:45 2026 -0700
assets: full-catalog search via Shopify predictive search (suggest.json)
q= now hits /search/suggest.json (whole storefront) so deep lines like
grasscloth/silk/cork are findable; falls back to cached browse-filter on
rate-limit/non-JSON. normImg() handles protocol-relative cdn URLs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
data/segments.json | 65 +++++++
data/social-queue.json | 1 +
modules/assets/index.js | 51 +++++-
modules/performance/index.js | 385 +++++++++++++++++++++++++++++++++++++++
modules/segments/index.js | 395 +++++++++++++++++++++++++++++++++++++++++
modules/social/index.js | 189 ++++++++++++++++++++
public/panels/performance.html | 111 ++++++++++++
public/panels/performance.js | 263 +++++++++++++++++++++++++++
public/panels/segments.html | 49 +++++
public/panels/segments.js | 235 ++++++++++++++++++++++++
public/panels/social.html | 129 ++++++++++++++
public/panels/social.js | 238 +++++++++++++++++++++++++
12 files changed, 2104 insertions(+), 7 deletions(-)
diff --git a/data/segments.json b/data/segments.json
new file mode 100644
index 0000000..2e90c4c
--- /dev/null
+++ b/data/segments.json
@@ -0,0 +1,65 @@
+[
+ {
+ "id": "seg_trade_engaged",
+ "name": "Trade & Designers — engaged",
+ "description": "Interior designers / architects on the trade list who opened within 30 days.",
+ "rules": [
+ {
+ "field": "type",
+ "op": "is",
+ "value": "trade"
+ },
+ {
+ "field": "engagement",
+ "op": "is",
+ "value": "opened-last-30d"
+ }
+ ]
+ },
+ {
+ "id": "seg_retail_lapsed",
+ "name": "Retail — lapsed 90d",
+ "description": "Retail newsletter subscribers who have not opened in over 90 days — a win-back audience.",
+ "rules": [
+ {
+ "field": "type",
+ "op": "is",
+ "value": "retail"
+ },
+ {
+ "field": "engagement",
+ "op": "is",
+ "value": "lapsed-90d"
+ }
+ ]
+ },
+ {
+ "id": "seg_high_intent_memo",
+ "name": "High-intent — clicked memo CTA",
+ "description": "Anyone tagged with the memo-sample CTA who has clicked — ready for a trade consult ask.",
+ "rules": [
+ {
+ "field": "tag",
+ "op": "has",
+ "value": "memo-cta"
+ },
+ {
+ "field": "engagement",
+ "op": "is",
+ "value": "clicked"
+ }
+ ]
+ },
+ {
+ "id": "seg_hospitality_spec",
+ "name": "Hospitality / Commercial spec",
+ "description": "Contacts on the hospitality & commercial spec list — large-scale project audience.",
+ "rules": [
+ {
+ "field": "list",
+ "op": "in",
+ "value": "Hospitality / Commercial Spec"
+ }
+ ]
+ }
+]
\ No newline at end of file
diff --git a/data/social-queue.json b/data/social-queue.json
new file mode 100644
index 0000000..0637a08
--- /dev/null
+++ b/data/social-queue.json
@@ -0,0 +1 @@
+[]
\ No newline at end of file
diff --git a/modules/assets/index.js b/modules/assets/index.js
index af2d597..bd09a39 100644
--- a/modules/assets/index.js
+++ b/modules/assets/index.js
@@ -27,6 +27,7 @@ const STORE = path.join(DATA_DIR, 'assets.json');
const CAT_CACHE = path.join(DATA_DIR, 'assets-catalog.cache.json'); // matches .gitignore data/*.cache.json
const CAT_TTL_MS = 6 * 60 * 60 * 1000; // 6h
const DW_PRODUCTS_JSON = 'https://designerwallcoverings.com/products.json';
+const DW_SUGGEST = 'https://designerwallcoverings.com/search/suggest.json';
const MIME_EXT = {
'image/jpeg': 'jpg', 'image/jpg': 'jpg', 'image/png': 'png', 'image/gif': 'gif',
@@ -88,6 +89,32 @@ function readCatCache() {
} catch { /* miss */ }
return null;
}
+// Normalize a Shopify image URL (protocol-relative → https, drop query).
+function normImg(u) {
+ if (!u) return '';
+ u = String(u);
+ if (u.startsWith('//')) u = 'https:' + u;
+ return u.split('?')[0];
+}
+
+// Full-catalog server-side search via Shopify predictive search. Searches the
+// WHOLE storefront (not just the cached browse pages), so deep lines like
+// grasscloth/silk/cork are findable. Caps at 10 (endpoint limit). Throws on
+// rate-limit / non-JSON so the caller can fall back to the cached browse filter.
+async function suggestSearch(q, limit) {
+ const lim = Math.min(10, Math.max(4, limit || 10));
+ const url = `${DW_SUGGEST}?q=${encodeURIComponent(q)}&resources[type]=product&resources[limit]=${lim}`;
+ const r = await fetch(url, { headers: { accept: 'application/json' } });
+ const ct = r.headers.get('content-type') || '';
+ if (!r.ok || !ct.includes('json')) throw new Error(`suggest ${r.status}`);
+ const d = await r.json();
+ const ps = (d.resources && d.resources.results && d.resources.results.products) || [];
+ return ps.map(p => {
+ const img = (p.featured_image && (p.featured_image.url || p.featured_image)) || p.image || '';
+ return { title: p.title || p.handle || 'Untitled', handle: p.handle || '', type: p.type || '', url: normImg(img) };
+ }).filter(p => p.url);
+}
+
async function fetchCatalog() {
const cached = readCatCache();
if (cached) return { products: cached.products, cached: true };
@@ -109,7 +136,7 @@ async function fetchCatalog() {
title: p.title || p.handle || 'Untitled',
handle: p.handle || '',
type: p.product_type || '',
- url: String(img.src).split('?')[0],
+ url: normImg(img.src),
});
}
if (prods.length < 250) break;
@@ -176,15 +203,25 @@ module.exports = {
res.json({ asset: withSrc(asset) });
});
- // Live DW catalog search. ?q= filters by title/type, ?limit= caps results.
+ // Live DW catalog. With ?q= → full-catalog predictive search; without →
+ // browse the cached first pages. Search falls back to the cached filter if
+ // the predictive endpoint is rate-limited / unavailable.
router.get('/catalog', async (req, res) => {
+ const q = (req.query.q || '').toString().trim();
+ const limit = Math.max(1, Math.min(200, parseInt(req.query.limit, 10) || 60));
try {
+ if (q) {
+ try {
+ const hits = await suggestSearch(q, limit);
+ if (hits.length) return res.json({ products: hits.slice(0, limit), total: hits.length, cached: false, source: 'search' });
+ } catch { /* rate-limited / down → fall back to cached browse filter below */ }
+ const { products } = await fetchCatalog();
+ const ql = q.toLowerCase();
+ const rows = products.filter(p => (p.title + ' ' + p.type + ' ' + p.handle).toLowerCase().includes(ql));
+ return res.json({ products: rows.slice(0, limit), total: rows.length, cached: true, source: 'browse-filter' });
+ }
const { products, cached } = await fetchCatalog();
- const q = (req.query.q || '').toString().trim().toLowerCase();
- const limit = Math.max(1, Math.min(200, parseInt(req.query.limit, 10) || 60));
- let rows = products;
- if (q) rows = rows.filter(p => (p.title + ' ' + p.type + ' ' + p.handle).toLowerCase().includes(q));
- res.json({ products: rows.slice(0, limit), total: rows.length, cached });
+ res.json({ products: products.slice(0, limit), total: products.length, cached, source: 'browse' });
} catch (e) {
res.status(502).json({ error: 'catalog fetch failed: ' + e.message });
}
diff --git a/modules/performance/index.js b/modules/performance/index.js
new file mode 100644
index 0000000..4bf1837
--- /dev/null
+++ b/modules/performance/index.js
@@ -0,0 +1,385 @@
+// Performance module — a marketing performance dashboard for the Command Center.
+// Email KPIs (sends/opens/clicks + open & click rate, list growth, top campaigns)
+// plus web traffic (sessions/users/conversions) from GA4 when wired.
+//
+// LIVE web path: when GA4_PROPERTY_ID *and* GA4_ACCESS_TOKEN are set, web metrics
+// are pulled from the GA4 Data API (runReport) via global fetch. Otherwise the
+// whole dashboard runs in MOCK MODE and returns realistic, internally-consistent
+// mock data tagged { mock: true } so the panel is fully usable without any creds.
+//
+// Read-only: nothing here sends or schedules. Secrets come from process.env only.
+
+const GA4_API = 'https://analyticsdata.googleapis.com/v1beta';
+const RANGES = { '7d': 7, '30d': 30, '90d': 90 };
+
+const ga4Live = () =>
+ Boolean(process.env.GA4_PROPERTY_ID && process.env.GA4_ACCESS_TOKEN);
+
+// ── date helpers ──────────────────────────────────────────────────────────────
+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;
+}
+function normRange(r) {
+ return RANGES[r] ? r : '30d';
+}
+
+// ── deterministic pseudo-random so mock data is stable per metric+day ──────────
+// (no Math.random → numbers don't jitter between the overview & timeseries calls)
+function seeded(str) {
+ let h = 2166136261;
+ for (let i = 0; i < str.length; i++) {
+ h ^= str.charCodeAt(i);
+ h = Math.imul(h, 16777619);
+ }
+ // 0..1
+ return ((h >>> 0) % 100000) / 100000;
+}
+
+// A plausible single-day value for a metric, with a gentle weekly rhythm + trend.
+// Luxury wallcoverings brand: modest list, healthy engagement, weekday-skewed B2B.
+const METRIC_BASE = {
+ sends: 0, // sends are event-driven (campaign days), handled specially
+ opens: 95,
+ clicks: 22,
+ sessions: 240,
+ users: 190,
+ conversions: 6,
+ signups: 9,
+};
+function dayValue(metric, iso, dayIndex, totalDays) {
+ const base = METRIC_BASE[metric] != null ? METRIC_BASE[metric] : 50;
+ const dow = new Date(iso + 'T00:00:00').getDay(); // 0 Sun..6 Sat
+ // B2B weekday skew: weekends ~55% of a weekday.
+ const weekFactor = dow === 0 || dow === 6 ? 0.55 : 1;
+ // slow upward trend across the window (+18% end-to-start)
+ const trend = 0.91 + 0.18 * (dayIndex / Math.max(1, totalDays - 1));
+ // ±18% day jitter, deterministic
+ const jitter = 0.82 + 0.36 * seeded(metric + iso);
+ return Math.max(0, Math.round(base * weekFactor * trend * jitter));
+}
+
+// ── MOCK campaign cohort (drives email KPIs + sends/opens/clicks spikes) ────────
+// Each campaign "happens" on a date inside the window so timeseries spikes line up.
+function mockCampaigns(days, end = new Date()) {
+ const names = [
+ { name: 'Spring Grasscloth Edit', list: 'Trade & Designers' },
+ { name: 'Trade Memo — Silk & Linen', list: 'Trade & Designers' },
+ { name: 'Metallic Murals Lookbook', list: 'Retail Newsletter' },
+ { name: 'Cork & Linen: The Tactile Issue', list: 'Trade & Designers' },
+ { name: 'Glass Bead — Limited Run', list: 'Retail Newsletter' },
+ { name: 'Hospitality Spec Guide', list: 'Trade & Designers' },
+ { name: 'Flocked Damask Revival', list: 'Retail Newsletter' },
+ { name: 'Summer Mural Preview', list: 'Trade & Designers' },
+ { name: 'Memo Sample Reminder', list: 'Retail Newsletter' },
+ ];
+ // space campaigns roughly evenly across the window (older→newer)
+ const n = Math.max(2, Math.round(days / 9)); // ~1 every 9 days
+ const picks = [];
+ const baseEnd = new Date(end.getFullYear(), end.getMonth(), end.getDate());
+ for (let i = 0; i < n; i++) {
+ const meta = names[i % names.length];
+ const offset = Math.round(((i + 0.5) / n) * (days - 1));
+ const date = isoDay(new Date(baseEnd.getTime() - (days - 1 - offset) * 86400000));
+ const sends = 3200 + Math.round(seeded('snd' + date + meta.name) * 1900); // 3.2k–5.1k
+ const openRate = 0.31 + seeded('opn' + date) * 0.18; // 31%–49%
+ const opens = Math.round(sends * openRate);
+ const clickRate = 0.06 + seeded('clk' + date) * 0.06; // 6%–12%
+ const clicks = Math.round(opens * clickRate * 1.4);
+ picks.push({
+ id: 'mock-cmp-' + pad(i + 1),
+ name: meta.name,
+ list: meta.list,
+ sent_at: date + 'T14:00:00Z',
+ date,
+ sends, opens, clicks,
+ open_rate: +(opens / sends).toFixed(4),
+ click_rate: +(clicks / sends).toFixed(4),
+ });
+ }
+ return picks.sort((a, b) => b.date.localeCompare(a.date));
+}
+
+// Top content/pages a luxury wallcovering site would track in GA4 mock mode.
+function mockTopPages(days) {
+ const pages = [
+ { path: '/collections/grasscloth', title: 'Grasscloth Collection' },
+ { path: '/collections/silk', title: 'Silk Wallcoverings' },
+ { path: '/murals', title: 'Murals & Scenics' },
+ { path: '/trade', title: 'Trade Program' },
+ { path: '/collections/cork', title: 'Cork Wallcoverings' },
+ { path: '/journal/grasscloth-guide', title: 'The Grasscloth Guide' },
+ { path: '/samples', title: 'Request Memo Samples' },
+ ];
+ const scale = days / 30;
+ return pages.map((p, i) => {
+ const views = Math.round((4200 - i * 480) * scale * (0.9 + seeded(p.path) * 0.3));
+ const avgSec = 38 + Math.round(seeded('t' + p.path) * 120);
+ return { ...p, views, avgSeconds: avgSec };
+ }).sort((a, b) => b.views - a.views);
+}
+
+// ── GA4 Data API (runReport) ────────────────────────────────────────────────────
+async function ga4RunReport(body) {
+ const r = await fetch(
+ `${GA4_API}/properties/${process.env.GA4_PROPERTY_ID}:runReport`,
+ {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${process.env.GA4_ACCESS_TOKEN}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(body),
+ }
+ );
+ const text = await r.text();
+ let json;
+ try { json = text ? JSON.parse(text) : {}; } catch { json = { raw: text }; }
+ if (!r.ok) {
+ const err = new Error(`GA4 runReport → ${r.status}`);
+ err.status = r.status;
+ err.detail = json;
+ throw err;
+ }
+ return json;
+}
+
+// pull date-keyed web series (sessions/users/conversions) from GA4
+async function ga4WebSeries(days) {
+ const json = await ga4RunReport({
+ dateRanges: [{ startDate: `${days}daysAgo`, endDate: 'today' }],
+ dimensions: [{ name: 'date' }],
+ metrics: [
+ { name: 'sessions' },
+ { name: 'totalUsers' },
+ { name: 'conversions' },
+ ],
+ orderBys: [{ dimension: { dimensionName: 'date' } }],
+ });
+ const series = { sessions: {}, users: {}, conversions: {} };
+ for (const row of json.rows || []) {
+ const raw = row.dimensionValues[0].value; // YYYYMMDD
+ const iso = `${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)}`;
+ series.sessions[iso] = Number(row.metricValues[0].value || 0);
+ series.users[iso] = Number(row.metricValues[1].value || 0);
+ series.conversions[iso] = Number(row.metricValues[2].value || 0);
+ }
+ return series;
+}
+
+async function ga4TopPages(days) {
+ const json = await ga4RunReport({
+ dateRanges: [{ startDate: `${days}daysAgo`, endDate: 'today' }],
+ dimensions: [{ name: 'pagePath' }, { name: 'pageTitle' }],
+ metrics: [{ name: 'screenPageViews' }, { name: 'averageSessionDuration' }],
+ orderBys: [{ metric: { metricName: 'screenPageViews' }, desc: true }],
+ limit: 8,
+ });
+ return (json.rows || []).map(row => ({
+ path: row.dimensionValues[0].value,
+ title: row.dimensionValues[1].value,
+ views: Number(row.metricValues[0].value || 0),
+ avgSeconds: Math.round(Number(row.metricValues[1].value || 0)),
+ }));
+}
+
+// ── series builders ─────────────────────────────────────────────────────────────
+// email / signup series are always mock-derived (CC not wired here); web series
+// come from GA4 when live. Returns { [iso]: number } maps.
+function emailSeries(days, campaigns) {
+ const ds = dayList(days);
+ const opens = {}, clicks = {}, sends = {}, signups = {};
+ ds.forEach((iso, i) => {
+ opens[iso] = dayValue('opens', iso, i, days); // organic baseline
+ clicks[iso] = dayValue('clicks', iso, i, days);
+ sends[iso] = 0;
+ signups[iso] = dayValue('signups', iso, i, days);
+ });
+ // overlay campaign-day spikes (the bulk of sends/opens/clicks land on send days)
+ for (const c of campaigns) {
+ if (sends[c.date] == null) continue;
+ sends[c.date] += c.sends;
+ opens[c.date] += c.opens;
+ clicks[c.date] += c.clicks;
+ }
+ return { opens, clicks, sends, signups };
+}
+
+function webSeriesMock(days) {
+ const ds = dayList(days);
+ const sessions = {}, users = {}, conversions = {};
+ ds.forEach((iso, i) => {
+ sessions[iso] = dayValue('sessions', iso, i, days);
+ users[iso] = dayValue('users', iso, i, days);
+ conversions[iso] = dayValue('conversions', iso, i, days);
+ });
+ return { sessions, users, conversions };
+}
+
+const sum = obj => Object.values(obj).reduce((a, b) => a + b, 0);
+// trend %: second half vs first half of the window (rounded to 1 decimal)
+function trendPct(seriesMap) {
+ const vals = Object.keys(seriesMap).sort().map(k => seriesMap[k]);
+ if (vals.length < 2) return 0;
+ const mid = Math.floor(vals.length / 2);
+ const first = vals.slice(0, mid).reduce((a, b) => a + b, 0);
+ const second = vals.slice(mid).reduce((a, b) => a + b, 0);
+ if (first === 0) return second > 0 ? 100 : 0;
+ return +(((second - first) / first) * 100).toFixed(1);
+}
+
+// ── module ─────────────────────────────────────────────────────────────────────
+module.exports = {
+ id: 'performance',
+ title: 'Performance',
+ icon: '📊',
+
+ mount(router) {
+ // GET /overview?range=30d — KPI summary (email + list growth + top campaigns
+ // + web traffic when GA4 is wired)
+ router.get('/overview', async (req, res) => {
+ const range = normRange(req.query.range);
+ const days = RANGES[range];
+ const mockWeb = !ga4Live();
+
+ const campaigns = mockCampaigns(days);
+ const email = emailSeries(days, campaigns);
+
+ // web series: GA4 live, else mock
+ let web, webMock = true;
+ try {
+ web = mockWeb ? webSeriesMock(days) : await ga4WebSeries(days);
+ webMock = mockWeb;
+ } catch (e) {
+ web = webSeriesMock(days); // graceful fallback if GA4 errors
+ webMock = true;
+ }
+
+ const sends = sum(email.sends);
+ const opens = sum(email.opens);
+ const clicks = sum(email.clicks);
+ const signups = sum(email.signups);
+
+ // Headline open/click RATES are derived from campaign aggregates (sent mail
+ // only) so they read as believable per-send rates — the opens/clicks totals
+ // above include an organic daily baseline, which would inflate a naive ratio.
+ const campSends = campaigns.reduce((a, c) => a + c.sends, 0);
+ const campOpens = campaigns.reduce((a, c) => a + c.opens, 0);
+ const campClicks = campaigns.reduce((a, c) => a + c.clicks, 0);
+ const openRate = campSends ? +(campOpens / campSends).toFixed(4) : 0;
+ const clickRate = campSends ? +(campClicks / campSends).toFixed(4) : 0;
+
+ // list growth: net new contacts over the window (signups, less light churn)
+ const listEnd = 4218;
+ const grossNew = signups;
+ const churn = Math.round(grossNew * 0.12);
+ const netNew = grossNew - churn;
+ const listStart = listEnd - netNew;
+
+ const sessions = sum(web.sessions);
+ const users = sum(web.users);
+ const conversions = sum(web.conversions);
+
+ const topCampaigns = campaigns
+ .filter(c => c.sends > 0)
+ .slice()
+ .sort((a, b) => b.open_rate - a.open_rate)
+ .slice(0, 5);
+
+ res.json({
+ mock: true, // email side is always mock here
+ web: { live: !webMock, mock: webMock },
+ range,
+ days,
+ generatedAt: new Date().toISOString(),
+ kpis: {
+ sends: { value: sends, trend: trendPct(email.sends) },
+ opens: { value: opens, trend: trendPct(email.opens) },
+ openRate: { value: openRate, trend: trendPct(email.opens) },
+ clicks: { value: clicks, trend: trendPct(email.clicks) },
+ clickRate: { value: clickRate, trend: trendPct(email.clicks) },
+ listSize: { value: listEnd, trend: listStart ? +(((listEnd - listStart) / listStart) * 100).toFixed(1) : 0 },
+ netNewContacts: { value: netNew, churn },
+ sessions: { value: sessions, trend: trendPct(web.sessions) },
+ users: { value: users, trend: trendPct(web.users) },
+ conversions: { value: conversions, trend: trendPct(web.conversions) },
+ },
+ listGrowth: { start: listStart, end: listEnd, grossNew, churn, netNew },
+ topCampaigns,
+ });
+ });
+
+ // GET /timeseries?metric=opens&range=30d — date-keyed series for the chart.
+ // metrics: opens | clicks | sends | sessions | signups (+ users/conversions)
+ router.get('/timeseries', async (req, res) => {
+ const range = normRange(req.query.range);
+ const days = RANGES[range];
+ const metric = String(req.query.metric || 'opens');
+
+ const emailMetrics = new Set(['opens', 'clicks', 'sends', 'signups']);
+ const webMetrics = new Set(['sessions', 'users', 'conversions']);
+
+ let map = null, isMock = true;
+
+ if (emailMetrics.has(metric)) {
+ const campaigns = mockCampaigns(days);
+ map = emailSeries(days, campaigns)[metric];
+ } else if (webMetrics.has(metric)) {
+ if (ga4Live()) {
+ try { map = (await ga4WebSeries(days))[metric]; isMock = false; }
+ catch { map = webSeriesMock(days)[metric]; isMock = true; }
+ } else {
+ map = webSeriesMock(days)[metric];
+ }
+ } else {
+ return res.status(400).json({
+ ok: false,
+ error: `unknown metric "${metric}"`,
+ allowed: [...emailMetrics, ...webMetrics],
+ });
+ }
+
+ const days_ = dayList(days);
+ const series = days_.map(iso => ({ date: iso, value: map[iso] || 0 }));
+ const total = series.reduce((a, p) => a + p.value, 0);
+ res.json({
+ mock: isMock,
+ metric,
+ range,
+ total,
+ trend: trendPct(map),
+ series,
+ });
+ });
+
+ // GET /top?kind=campaigns — top campaigns by open-rate, or top content/pages.
+ router.get('/top', async (req, res) => {
+ const kind = String(req.query.kind || 'campaigns');
+ const range = normRange(req.query.range);
+ const days = RANGES[range];
+
+ if (kind === 'pages' || kind === 'content') {
+ let pages, isMock = true;
+ if (ga4Live()) {
+ try { pages = await ga4TopPages(days); isMock = false; }
+ catch { pages = mockTopPages(days); isMock = true; }
+ } else {
+ pages = mockTopPages(days);
+ }
+ return res.json({ mock: isMock, kind: 'pages', range, top: pages });
+ }
+
+ // default: campaigns (always mock here — CC engagement not wired in)
+ const campaigns = mockCampaigns(days)
+ .filter(c => c.sends > 0)
+ .sort((a, b) => b.open_rate - a.open_rate);
+ res.json({ mock: true, kind: 'campaigns', range, top: campaigns });
+ });
+ },
+};
diff --git a/modules/segments/index.js b/modules/segments/index.js
new file mode 100644
index 0000000..4813634
--- /dev/null
+++ b/modules/segments/index.js
@@ -0,0 +1,395 @@
+// Audience Segments module — build + preview marketing audience segments for the
+// Marketing Command Center. A segment is a named set of RULES over contact fields
+// (list membership, engagement, trade-vs-retail type, tags). Segments persist to
+// data/segments.json; previewing evaluates the rules against a contact pool.
+//
+// The contact pool is the SAME shape the constant-contact module uses. When a CTCT
+// access token is present we pull real contacts from the v3 API (global fetch);
+// otherwise we generate a realistic mock pool of ~300 luxury-design contacts and
+// flag every contact-backed response with { mock: true }.
+//
+// Self-contained per the MODULE CONTRACT (README.md). No outside sends here — this
+// is purely audience definition + preview (read-only against contacts).
+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);
+
+// ── tiny JSON 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));
+}
+
+// ── rule schema ──────────────────────────────────────────────────────────────
+// Fields a rule can target, and the operators each allows. The panel reads this
+// to build its dropdowns so the UI and evaluator never drift apart.
+const FIELDS = {
+ type: { label: 'Contact type', ops: ['is', 'is_not'], values: ['trade', 'retail'] },
+ list: { label: 'List membership', ops: ['in', 'not_in'], values: 'lists' },
+ tag: { label: 'Tag', ops: ['has', 'not_has'], values: 'tags' },
+ engagement: { label: 'Engagement', ops: ['is'],
+ values: ['opened-last-30d', 'clicked-last-30d', 'clicked', 'lapsed-90d', 'never-opened'] },
+};
+const OP_LABELS = {
+ is: 'is', is_not: 'is not', in: 'in', not_in: 'not in',
+ has: 'has', not_has: 'does not have',
+};
+
+// Canonical list + tag vocabularies (used to seed mock contacts AND to populate the
+// rule-builder value dropdowns so the UI offers real choices).
+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'];
+
+// ── mock contact pool (~300 luxury-design contacts) ───────────────────────────
+// Deterministic generator so a given install always evaluates the same pool (no
+// flapping preview counts). Shape mirrors what a CTCT contact maps down to:
+// { email, type, lists:[...], lastOpen:ISO|null, lastClick:ISO|null, tags:[...] }
+let _mockPool = null;
+function mockPool() {
+ if (_mockPool) return _mockPool;
+ // small seeded PRNG (mulberry32) — deterministic across runs
+ let seed = 0x9e3779b9;
+ const rnd = () => {
+ seed |= 0; seed = (seed + 0x6d2b79f5) | 0;
+ let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+ const pick = arr => arr[Math.floor(rnd() * arr.length)];
+ 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 now = Date.now();
+ const DAY = 86400000;
+ const isoDaysAgo = d => d == null ? null : new Date(now - d * DAY).toISOString();
+
+ const pool = [];
+ for (let i = 0; i < 300; i++) {
+ const isTrade = rnd() < 0.58; // trade-leaning house
+ const type = isTrade ? 'trade' : 'retail';
+
+ // list membership
+ 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: lastOpen / lastClick in "days ago" or null (never)
+ // ~22% lapsed (>90d), ~18% never opened, rest engaged within 0-60d
+ let openDaysAgo, clickDaysAgo;
+ const r = rnd();
+ if (r < 0.18) { openDaysAgo = null; clickDaysAgo = null; } // never engaged
+ else if (r < 0.40) { openDaysAgo = 90 + Math.floor(rnd() * 200); clickDaysAgo = rnd() < 0.3 ? openDaysAgo + Math.floor(rnd() * 10) : null; } // lapsed
+ else { openDaysAgo = Math.floor(rnd() * 60); clickDaysAgo = rnd() < 0.55 ? openDaysAgo + Math.floor(rnd() * 8) : null; } // engaged
+
+ // tags
+ 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 (clickDaysAgo != null && clickDaysAgo <= 30 && rnd() < 0.5 && !tags.includes('memo-cta')) tags.push('memo-cta');
+ if (isTrade && rnd() < 0.15 && !tags.includes('vip')) tags.push('vip');
+
+ const studio = studios[i % studios.length];
+ const email = `${pick(['hello', 'studio', 'projects', 'spec', 'design', 'info'])}+${i}@${studio}.${pick(tlds)}`;
+
+ pool.push({
+ email, type, lists,
+ lastOpen: isoDaysAgo(openDaysAgo),
+ lastClick: isoDaysAgo(clickDaysAgo),
+ tags,
+ });
+ }
+ _mockPool = pool;
+ return pool;
+}
+
+// ── live CTCT contact fetch (best-effort, maps down to the same shape) ─────────
+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;
+}
+
+// Map a v3 contact + a list-id→name lookup down to our evaluation shape.
+function mapContact(c, listNameById) {
+ const email = (c.email_address && c.email_address.address) || c.email || '';
+ const lists = (c.list_memberships || []).map(id => listNameById[id]).filter(Boolean);
+ // 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);
+ 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,
+ tags,
+ };
+}
+
+// Pull a live pool (capped). Falls back to mock on any failure so the panel never
+// breaks. Returns { pool, mock }.
+async function contactPool() {
+ if (!live()) return { pool: mockPool(), 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=500&include=list_memberships,taggings');
+ const pool = (data.contacts || []).map(c => mapContact(c, listNameById)).filter(c => c.email);
+ if (!pool.length) return { pool: mockPool(), mock: true };
+ return { pool, mock: false };
+ } catch {
+ return { pool: mockPool(), mock: true };
+ }
+}
+
+// ── rule evaluation ────────────────────────────────────────────────────────────
+function daysSince(iso) {
+ if (!iso) return Infinity;
+ const t = Date.parse(iso);
+ if (Number.isNaN(t)) return Infinity;
+ return (Date.now() - t) / 86400000;
+}
+
+function evalRule(contact, rule) {
+ if (!rule || !rule.field) return true;
+ const { field, op, value } = rule;
+ switch (field) {
+ case 'type':
+ if (op === 'is_not') return contact.type !== value;
+ return 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 openAgo = daysSince(contact.lastOpen);
+ const clickAgo = daysSince(contact.lastClick);
+ switch (value) {
+ case 'opened-last-30d': return openAgo <= 30;
+ case 'clicked-last-30d': return clickAgo <= 30;
+ case 'clicked': return clickAgo !== Infinity;
+ case 'lapsed-90d': return openAgo > 90; // includes never-opened
+ case 'never-opened': return openAgo === Infinity;
+ default: return true;
+ }
+ }
+ default:
+ return true;
+ }
+}
+
+// A contact matches a segment when it satisfies ALL rules (AND). Empty rules = all.
+function matches(contact, rules) {
+ if (!Array.isArray(rules) || !rules.length) return true;
+ return rules.every(r => evalRule(contact, r));
+}
+
+function evaluate(pool, rules, sampleSize = 12) {
+ const hits = pool.filter(c => matches(c, rules));
+ const sample = hits.slice(0, sampleSize).map(c => ({
+ email: c.email, type: c.type,
+ lists: c.lists, tags: c.tags,
+ lastOpen: c.lastOpen, lastClick: c.lastClick,
+ }));
+ return { count: hits.length, total: pool.length, sample };
+}
+
+// ── segment store ────────────────────────────────────────────────────────────
+function defaultSegments() {
+ return [
+ {
+ id: 'seg_trade_engaged',
+ name: 'Trade & Designers — engaged',
+ description: 'Interior designers / architects on the trade list who opened within 30 days.',
+ rules: [
+ { field: 'type', op: 'is', value: 'trade' },
+ { field: 'engagement', op: 'is', value: 'opened-last-30d' },
+ ],
+ },
+ {
+ id: 'seg_retail_lapsed',
+ name: 'Retail — lapsed 90d',
+ description: 'Retail newsletter subscribers who have not opened in over 90 days — a win-back audience.',
+ rules: [
+ { field: 'type', op: 'is', value: 'retail' },
+ { field: 'engagement', op: 'is', value: 'lapsed-90d' },
+ ],
+ },
+ {
+ id: 'seg_high_intent_memo',
+ name: 'High-intent — clicked memo CTA',
+ description: 'Anyone tagged with the memo-sample CTA who has clicked — ready for a trade consult ask.',
+ rules: [
+ { field: 'tag', op: 'has', value: 'memo-cta' },
+ { field: 'engagement', op: 'is', value: 'clicked' },
+ ],
+ },
+ {
+ id: 'seg_hospitality_spec',
+ name: 'Hospitality / Commercial spec',
+ description: 'Contacts on the hospitality & commercial spec list — large-scale project audience.',
+ rules: [
+ { field: 'list', op: 'in', value: 'Hospitality / Commercial Spec' },
+ ],
+ },
+ ];
+}
+
+function loadSegments() {
+ const v = readJson(SEG_FILE, null);
+ if (Array.isArray(v)) return v;
+ const seeded = defaultSegments();
+ writeJson(SEG_FILE, seeded);
+ return seeded;
+}
+function saveSegments(list) { writeJson(SEG_FILE, list); }
+
+// Validate + normalize an incoming rule. Drops anything off-schema.
+function cleanRules(raw) {
+ if (!Array.isArray(raw)) return [];
+ const out = [];
+ for (const r of raw) {
+ if (!r || !FIELDS[r.field]) continue;
+ const spec = FIELDS[r.field];
+ const op = spec.ops.includes(r.op) ? r.op : spec.ops[0];
+ let value = r.value;
+ if (Array.isArray(spec.values) && !spec.values.includes(value)) {
+ // for list/tag the value is free-ish (a list/tag name) — keep as string;
+ // for enumerated fields (type/engagement) reject unknown values.
+ if (r.field === 'type' || r.field === 'engagement') continue;
+ }
+ out.push({ field: r.field, op, value: value == null ? '' : String(value) });
+ }
+ return out;
+}
+
+// Attach a live estimatedSize to each segment (best-effort; uses current pool).
+function withEstimates(segments, pool) {
+ return segments.map(s => ({
+ ...s,
+ estimatedSize: pool ? evaluate(pool, s.rules, 0).count : (s.estimatedSize || 0),
+ }));
+}
+
+// ── module ─────────────────────────────────────────────────────────────────────
+module.exports = {
+ id: 'segments',
+ title: 'Audience Segments',
+ icon: '👥',
+
+ // exported for reuse / testing
+ _evaluate: evaluate,
+ _matches: matches,
+
+ 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 /schema — fields, operators + value vocabularies for the rule builder
+ router.get('/schema', (_req, res) => {
+ const fields = Object.entries(FIELDS).map(([key, spec]) => ({
+ key, label: spec.label,
+ ops: spec.ops.map(o => ({ key: o, label: OP_LABELS[o] || o })),
+ // resolve value source: 'lists'|'tags' → vocab arrays, else inline enum
+ values: spec.values === 'lists' ? LISTS
+ : spec.values === 'tags' ? TAGS
+ : spec.values,
+ }));
+ res.json({ fields, lists: LISTS, tags: TAGS });
+ });
+
+ // GET /segments — saved segments with a live estimatedSize
+ router.get('/segments', guard(async (_req, res) => {
+ const segments = loadSegments();
+ const { pool, mock } = await contactPool();
+ res.json({ mock, segments: withEstimates(segments, pool) });
+ }));
+
+ // POST /segments — create/update (match on id); ?delete=id removes
+ router.post('/segments', guard(async (req, res) => {
+ let segments = loadSegments();
+
+ if (req.query.delete) {
+ const id = String(req.query.delete);
+ segments = segments.filter(s => s.id !== id);
+ saveSegments(segments);
+ const { pool, mock } = await contactPool();
+ return res.json({ ok: true, deleted: id, mock, segments: withEstimates(segments, pool) });
+ }
+
+ const b = req.body || {};
+ if (!b.name || !String(b.name).trim()) {
+ return res.status(400).json({ ok: false, error: 'name is required' });
+ }
+ const rules = cleanRules(b.rules);
+ const { pool, mock } = await contactPool();
+ const estimatedSize = evaluate(pool, rules, 0).count;
+
+ const entry = {
+ id: b.id && segments.some(s => s.id === b.id)
+ ? String(b.id)
+ : 'seg_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
+ name: String(b.name).trim().slice(0, 120),
+ description: String(b.description || '').slice(0, 400),
+ rules,
+ estimatedSize,
+ updatedAt: new Date().toISOString(),
+ };
+
+ const i = segments.findIndex(s => s.id === entry.id);
+ if (i >= 0) segments[i] = { ...segments[i], ...entry };
+ else segments.push(entry);
+
+ saveSegments(segments);
+ res.json({ ok: true, mock, entry, segments: withEstimates(segments, pool) });
+ }));
+
+ // GET /contacts/preview?segmentId= — preview a SAVED segment
+ router.get('/contacts/preview', guard(async (req, res) => {
+ const id = String(req.query.segmentId || '');
+ const seg = loadSegments().find(s => s.id === id);
+ if (!seg) return res.status(404).json({ error: 'segment not found' });
+ const { pool, mock } = await contactPool();
+ const result = evaluate(pool, seg.rules);
+ res.json({ mock, segmentId: id, name: seg.name, rules: seg.rules, ...result });
+ }));
+
+ // POST /preview — preview arbitrary rules (the live rule-builder path)
+ router.post('/preview', guard(async (req, res) => {
+ const rules = cleanRules((req.body || {}).rules);
+ const { pool, mock } = await contactPool();
+ const result = evaluate(pool, rules);
+ res.json({ mock, rules, ...result });
+ }));
+ },
+};
diff --git a/modules/social/index.js b/modules/social/index.js
new file mode 100644
index 0000000..1e34a00
--- /dev/null
+++ b/modules/social/index.js
@@ -0,0 +1,189 @@
+// Social Scheduler module — a post queue/board for Instagram, TikTok & friends,
+// plus curated best-times guidance for a luxury wallcoverings audience.
+// Self-contained per the MODULE CONTRACT (see README.md). Nothing here posts to a
+// live account: /publish is GATED, defaults to dry-run, and ALWAYS stages.
+const fs = require('fs');
+const path = require('path');
+
+const DATA_DIR = path.join(__dirname, '..', '..', 'data');
+const QUEUE_FILE = path.join(DATA_DIR, 'social-queue.json');
+
+const CHANNELS = ['instagram', 'tiktok', 'pinterest', 'facebook'];
+const STATUSES = ['idea', 'drafted', 'scheduled', 'posted'];
+
+// ── 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 loadQueue() {
+ const v = readJson(QUEUE_FILE, null);
+ return Array.isArray(v) ? v : [];
+}
+function saveQueue(list) { writeJson(QUEUE_FILE, list); }
+
+// ── field hygiene ────────────────────────────────────────────────────────────
+const cap = (s, n) => String(s == null ? '' : s).slice(0, n);
+const genId = () => 'post_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
+
+// hashtags accepted as array or "#a #b" / "a, b" string → normalized array of #tags
+function normHashtags(input) {
+ let arr = [];
+ if (Array.isArray(input)) arr = input;
+ else if (typeof input === 'string') arr = input.split(/[\s,]+/);
+ return arr
+ .map(t => String(t).trim().replace(/^#+/, ''))
+ .filter(Boolean)
+ .map(t => '#' + t.replace(/[^A-Za-z0-9_]/g, '').slice(0, 50))
+ .filter(t => t.length > 1)
+ .slice(0, 30);
+}
+
+// Curated optimal posting windows for a trade / luxury-design audience (local time).
+// Static guidance — no analytics call, no creds. Times reflect when interior
+// designers, architects & affluent homeowners tend to be browsing.
+const BEST_TIMES = {
+ instagram: {
+ label: 'Instagram',
+ windows: ['Tue–Thu 11:00–13:00', 'Wed 19:00–21:00', 'Sun 10:00–12:00'],
+ note: 'Editorial carousels and Reels of grasscloth/silk detail land best mid-morning and early evening, when designers pin inspiration.',
+ },
+ tiktok: {
+ label: 'TikTok',
+ windows: ['Tue 18:00–20:00', 'Thu 12:00–14:00', 'Sat 09:00–11:00'],
+ note: 'Install time-lapses and texture close-ups perform in the evening scroll and the weekend project-planning window.',
+ },
+ pinterest: {
+ label: 'Pinterest',
+ windows: ['Sat 20:00–23:00', 'Sun 14:00–17:00', 'Fri 15:00–18:00'],
+ note: 'Pinterest skews to weekend dreaming — room mood-boards and palette pins compound for months, so evergreen wins.',
+ },
+ facebook: {
+ label: 'Facebook',
+ windows: ['Wed 09:00–11:00', 'Thu 13:00–15:00', 'Fri 10:00–12:00'],
+ note: 'Reaches an older homeowner and hospitality-spec audience; midweek daytime drives trade-consultation enquiries.',
+ },
+};
+
+// ── module ─────────────────────────────────────────────────────────────────────
+module.exports = {
+ id: 'social',
+ title: 'Social Scheduler',
+ icon: '📲',
+
+ mount(router) {
+ // GET /posts?status=&channel= — list the queue, newest schedule first
+ router.get('/posts', (req, res) => {
+ let list = loadQueue();
+ const { status, channel } = req.query;
+ if (status && STATUSES.includes(status)) list = list.filter(p => p.status === status);
+ if (channel && CHANNELS.includes(channel)) list = list.filter(p => p.channel === channel);
+ list.sort((a, b) =>
+ (a.date || '').localeCompare(b.date || '') ||
+ (a.time || '').localeCompare(b.time || ''));
+ res.json({ posts: list });
+ });
+
+ // POST /posts — add/update one post (match on id); ?delete=id removes one
+ router.post('/posts', (req, res) => {
+ const list = loadQueue();
+
+ if (req.query.delete) {
+ const id = String(req.query.delete);
+ const next = list.filter(p => p.id !== id);
+ saveQueue(next);
+ return res.json({ ok: true, deleted: id, posts: next });
+ }
+
+ const b = req.body || {};
+ if (!b.channel || !CHANNELS.includes(b.channel)) {
+ return res.status(400).json({ ok: false, error: `channel must be one of ${CHANNELS.join(', ')}` });
+ }
+ if (b.status && !STATUSES.includes(b.status)) {
+ return res.status(400).json({ ok: false, error: `status must be one of ${STATUSES.join(', ')}` });
+ }
+
+ const entry = {
+ id: b.id && list.some(p => p.id === b.id) ? String(b.id) : genId(),
+ channel: b.channel,
+ date: cap(b.date, 10), // YYYY-MM-DD
+ time: cap(b.time, 5), // HH:MM
+ caption: cap(b.caption, 2200), // IG caption ceiling
+ hashtags: normHashtags(b.hashtags),
+ imageUrl: cap(b.imageUrl, 600),
+ status: STATUSES.includes(b.status) ? b.status : 'idea',
+ notes: cap(b.notes, 1000),
+ updatedAt: new Date().toISOString(),
+ };
+
+ const i = list.findIndex(p => p.id === entry.id);
+ if (i >= 0) list[i] = { ...list[i], ...entry };
+ else { entry.createdAt = entry.updatedAt; list.push(entry); }
+
+ saveQueue(list);
+ res.json({ ok: true, entry, posts: list });
+ });
+
+ // POST /publish — GATED. Would post to a live social account, so:
+ // • require body.confirm === true
+ // • default to dry-run (dryRun !== false → just validate + echo)
+ // • there are NO real social API creds → ALWAYS stage, NEVER actually post.
+ router.post('/publish', (req, res) => {
+ const b = req.body || {};
+ const dryRun = b.dryRun !== false; // default true
+
+ const list = loadQueue();
+ const post = b.id ? list.find(p => p.id === String(b.id)) : null;
+ if (b.id && !post) {
+ return res.status(404).json({ ok: false, error: 'post not found' });
+ }
+ if (post && !CHANNELS.includes(post.channel)) {
+ return res.status(400).json({ ok: false, error: 'post has an invalid channel' });
+ }
+
+ const would = post && {
+ id: post.id,
+ channel: post.channel,
+ when: [post.date, post.time].filter(Boolean).join(' ') || 'unspecified',
+ caption: post.caption,
+ hashtags: post.hashtags,
+ imageUrl: post.imageUrl,
+ };
+
+ if (dryRun) {
+ return res.json({
+ ok: true,
+ dryRun: true,
+ staged: false,
+ would,
+ message: 'Dry run — nothing was sent. Set dryRun:false and confirm:true to stage a live publish.',
+ });
+ }
+
+ if (b.confirm !== true) {
+ return res.status(400).json({
+ ok: false,
+ error: 'A live publish requires confirm:true.',
+ });
+ }
+
+ // confirmed + not dry run — but no creds exist anywhere. Always stage.
+ return res.json({
+ ok: true,
+ dryRun: false,
+ staged: true,
+ would,
+ message: 'Staged — connect a social API to enable live posting. No post was sent to any live account.',
+ });
+ });
+
+ // GET /best-times — curated optimal posting windows per channel
+ router.get('/best-times', (req, res) => {
+ res.json({ channels: CHANNELS.map(c => ({ channel: c, ...BEST_TIMES[c] })) });
+ });
+ },
+};
diff --git a/public/panels/performance.html b/public/panels/performance.html
new file mode 100644
index 0000000..dcb7621
--- /dev/null
+++ b/public/panels/performance.html
@@ -0,0 +1,111 @@
+<div id="perf-banner"></div>
+
+<div class="card">
+ <div class="perf-head">
+ <div>
+ <h2>Performance overview</h2>
+ <p class="muted" style="margin:2px 0 0;font-size:12.5px">Email engagement, list growth, and web traffic at a glance.</p>
+ </div>
+ <div class="row" style="gap:6px;flex-wrap:nowrap;align-items:center">
+ <span class="perf-rng" role="group" aria-label="Date range">
+ <button class="btn ghost perf-range" data-range="7d">7d</button>
+ <button class="btn ghost perf-range active" data-range="30d">30d</button>
+ <button class="btn ghost perf-range" data-range="90d">90d</button>
+ </span>
+ </div>
+ </div>
+
+ <div class="perf-kpis" id="perf-kpis"></div>
+</div>
+
+<div class="card">
+ <div class="perf-head">
+ <h2>Trend</h2>
+ <div class="row" style="gap:6px;flex-wrap:wrap;align-items:center">
+ <label for="perf-metric" style="margin:0 6px 0 0">Metric</label>
+ <select id="perf-metric" style="width:auto;min-width:150px">
+ <optgroup label="Email">
+ <option value="opens">Opens</option>
+ <option value="clicks">Clicks</option>
+ <option value="sends">Sends</option>
+ <option value="signups">Signups</option>
+ </optgroup>
+ <optgroup label="Web">
+ <option value="sessions">Sessions</option>
+ <option value="users">Users</option>
+ <option value="conversions">Conversions</option>
+ </optgroup>
+ </select>
+ </div>
+ </div>
+ <div class="perf-chart-meta" id="perf-chart-meta"></div>
+ <div class="perf-chart-wrap">
+ <svg id="perf-chart" class="perf-chart" preserveAspectRatio="none" role="img" aria-label="Trend chart"></svg>
+ <div class="perf-tip" id="perf-tip" hidden></div>
+ </div>
+</div>
+
+<div class="card">
+ <div class="perf-head">
+ <h2>Top campaigns</h2>
+ <p class="muted" style="margin:0;font-size:12.5px">Ranked by open rate</p>
+ </div>
+ <div class="perf-table-wrap">
+ <table class="perf-table" id="perf-campaigns">
+ <thead>
+ <tr>
+ <th>Campaign</th><th>List</th><th class="num">Sends</th>
+ <th class="num">Opens</th><th class="num">Open rate</th><th class="num">Click rate</th>
+ </tr>
+ </thead>
+ <tbody><tr><td colspan="6" class="perf-empty">Loading…</td></tr></tbody>
+ </table>
+ </div>
+</div>
+
+<style>
+.perf-head{display:flex;align-items:flex-end;justify-content:space-between;gap:14px;flex-wrap:wrap;margin-bottom:14px}
+.perf-rng{display:inline-flex;border:1px solid var(--line);border-radius:9px;overflow:hidden}
+.perf-range{border:0;border-radius:0;padding:7px 14px;background:#fff;color:var(--ink)}
+.perf-range+.perf-range{border-left:1px solid var(--line)}
+.perf-range.active{background:var(--ink);color:var(--cream)}
+.perf-range:hover{background:var(--cream)}
+.perf-range.active:hover{background:#000}
+
+.perf-kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px}
+.kpi{border:1px solid var(--line);border-radius:12px;padding:13px 15px;background:var(--paper)}
+.kpi .k-label{font-size:11px;font-weight:700;letter-spacing:.4px;text-transform:uppercase;color:var(--mut)}
+.kpi .k-val{font:600 25px/1.1 "Cormorant Garamond",Georgia,serif;margin:5px 0 2px;letter-spacing:.2px}
+.kpi .k-trend{font-size:12px;font-weight:700;display:inline-flex;align-items:center;gap:3px}
+.kpi .k-trend.up{color:#3f8f5e}.kpi .k-trend.down{color:#c0573f}.kpi .k-trend.flat{color:var(--mut)}
+.kpi .k-sub{font-size:11px;color:var(--mut);margin-left:7px;font-weight:600}
+
+.perf-chart-meta{display:flex;align-items:baseline;gap:12px;margin-bottom:8px;flex-wrap:wrap}
+.perf-chart-meta .cm-total{font:600 22px/1 "Cormorant Garamond",Georgia,serif}
+.perf-chart-meta .cm-label{font-size:12px;color:var(--mut);font-weight:600}
+.perf-chart-wrap{position:relative;width:100%;height:240px}
+.perf-chart{width:100%;height:100%;display:block}
+.perf-chart .grid-line{stroke:var(--line);stroke-width:1}
+.perf-chart .axis-lbl{fill:var(--mut);font-size:10px;font-family:Inter,sans-serif}
+.perf-chart .area{fill:rgba(184,146,90,.14)}
+.perf-chart .line{fill:none;stroke:var(--gold);stroke-width:2;stroke-linejoin:round;stroke-linecap:round}
+.perf-chart .bar{fill:var(--gold)}
+.perf-chart .bar:hover{fill:var(--accent)}
+.perf-chart .dot{fill:var(--gold);stroke:#fff;stroke-width:1.5}
+.perf-chart .hit{fill:transparent;cursor:pointer}
+.perf-tip{position:absolute;pointer-events:none;background:var(--ink);color:var(--cream);font-size:11.5px;
+ padding:5px 9px;border-radius:8px;white-space:nowrap;transform:translate(-50%,-115%);z-index:3;box-shadow:0 4px 14px rgba(0,0,0,.22)}
+.perf-tip b{color:#fff}
+
+.perf-table-wrap{overflow-x:auto}
+.perf-table{width:100%;border-collapse:collapse;font-size:13px}
+.perf-table th{text-align:left;font-size:11px;font-weight:700;letter-spacing:.4px;text-transform:uppercase;
+ color:var(--mut);padding:6px 12px 9px;border-bottom:1px solid var(--line)}
+.perf-table td{padding:11px 12px;border-bottom:1px solid var(--line)}
+.perf-table tr:last-child td{border-bottom:0}
+.perf-table .num{text-align:right;font-variant-numeric:tabular-nums}
+.perf-table .c-name{font-weight:600}
+.perf-table .c-rate{font-weight:700;color:var(--accent)}
+.perf-table tbody tr:hover td{background:var(--cream)}
+.perf-empty{color:var(--mut);text-align:center;padding:18px 0}
+</style>
diff --git a/public/panels/performance.js b/public/panels/performance.js
new file mode 100644
index 0000000..5602e1d
--- /dev/null
+++ b/public/panels/performance.js
@@ -0,0 +1,263 @@
+// Performance panel — KPI cards, a hand-rolled SVG trend chart (no chart libs),
+// and a top-campaigns table. Registers into the shell's MCC_PANELS map.
+window.MCC_PANELS = window.MCC_PANELS || {};
+window.MCC_PANELS['performance'] = {
+ init(root) {
+ const $ = s => root.querySelector(s);
+ const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c =>
+ ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
+ const SVGNS = 'http://www.w3.org/2000/svg';
+
+ const state = { range: '30d', metric: 'opens', overview: null, series: null };
+
+ // ── formatters ──────────────────────────────────────────────────────────────
+ const nf = new Intl.NumberFormat();
+ const fmtInt = n => nf.format(Math.round(n || 0));
+ const fmtPct = r => (r == null ? '—' : (r * 100).toFixed(1) + '%');
+ const fmtTrend = t => (t == null ? '' : (t > 0 ? '+' : '') + t.toFixed(1) + '%');
+ function shortDate(iso) {
+ return new Date(iso + 'T00:00:00')
+ .toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
+ }
+
+ // ── KPI cards ────────────────────────────────────────────────────────────────
+ function trendClass(t) { return t > 0.05 ? 'up' : t < -0.05 ? 'down' : 'flat'; }
+ function trendArrow(t) { return t > 0.05 ? '▲' : t < -0.05 ? '▼' : '–'; }
+
+ function kpiCard(label, valHtml, trend, sub) {
+ const t = trend == null ? null : trend;
+ const trendHtml = t == null ? '' :
+ `<span class="k-trend ${trendClass(t)}">${trendArrow(t)} ${esc(fmtTrend(t))}</span>`;
+ const subHtml = sub ? `<span class="k-sub">${esc(sub)}</span>` : '';
+ return `<div class="kpi">
+ <div class="k-label">${esc(label)}</div>
+ <div class="k-val">${valHtml}</div>
+ ${trendHtml}${subHtml}
+ </div>`;
+ }
+
+ function renderKpis() {
+ const o = state.overview;
+ const box = $('#perf-kpis');
+ if (!o) { box.innerHTML = '<div class="perf-empty" style="grid-column:1/-1">Loading…</div>'; return; }
+ const k = o.kpis;
+ const webNote = o.web && o.web.live ? '' : ' (mock)';
+ box.innerHTML = [
+ kpiCard('Email sends', fmtInt(k.sends.value), k.sends.trend),
+ kpiCard('Opens', fmtInt(k.opens.value), k.opens.trend),
+ kpiCard('Open rate', fmtPct(k.openRate.value), k.openRate.trend),
+ kpiCard('Clicks', fmtInt(k.clicks.value), k.clicks.trend),
+ kpiCard('Click rate', fmtPct(k.clickRate.value), k.clickRate.trend),
+ kpiCard('List size', fmtInt(k.listSize.value), k.listSize.trend,
+ '+' + fmtInt(k.netNewContacts.value) + ' net'),
+ kpiCard('Sessions' + webNote, fmtInt(k.sessions.value), k.sessions.trend),
+ kpiCard('Conversions' + webNote, fmtInt(k.conversions.value), k.conversions.trend),
+ ].join('');
+ }
+
+ // ── campaigns table ──────────────────────────────────────────────────────────
+ function renderCampaigns() {
+ const tbody = $('#perf-campaigns tbody');
+ const rows = (state.overview && state.overview.topCampaigns) || [];
+ if (!rows.length) {
+ tbody.innerHTML = '<tr><td colspan="6" class="perf-empty">No campaigns in this window.</td></tr>';
+ return;
+ }
+ tbody.innerHTML = rows.map(c => `<tr>
+ <td class="c-name">${esc(c.name)}</td>
+ <td>${esc(c.list || '—')}</td>
+ <td class="num">${fmtInt(c.sends)}</td>
+ <td class="num">${fmtInt(c.opens)}</td>
+ <td class="num c-rate">${fmtPct(c.open_rate)}</td>
+ <td class="num">${fmtPct(c.click_rate)}</td>
+ </tr>`).join('');
+ }
+
+ // ── hand-rolled SVG chart ────────────────────────────────────────────────────
+ // Line+area for continuous metrics; bars for sparse/event metrics (sends).
+ const METRIC_LABELS = {
+ opens: 'Opens', clicks: 'Clicks', sends: 'Sends', signups: 'Signups',
+ sessions: 'Sessions', users: 'Users', conversions: 'Conversions',
+ };
+ const el = (name, attrs) => {
+ const n = document.createElementNS(SVGNS, name);
+ for (const k in attrs) n.setAttribute(k, attrs[k]);
+ return n;
+ };
+
+ function renderChart() {
+ const svg = $('#perf-chart');
+ const tip = $('#perf-tip');
+ tip.hidden = true;
+ while (svg.firstChild) svg.removeChild(svg.firstChild);
+
+ const data = state.series;
+ const meta = $('#perf-chart-meta');
+ if (!data || !data.series || !data.series.length) {
+ meta.innerHTML = '<span class="cm-label">No data.</span>';
+ return;
+ }
+ const pts = data.series;
+ const label = METRIC_LABELS[state.metric] || state.metric;
+ meta.innerHTML =
+ `<span class="cm-total">${fmtInt(data.total)}</span>` +
+ `<span class="cm-label">total ${esc(label.toLowerCase())} · ${esc(data.range)}` +
+ (data.trend != null ? ` · ${esc(fmtTrend(data.trend))} vs prior half` : '') + `</span>`;
+
+ // viewBox coordinate space (chart auto-scales to the wrapper via CSS)
+ const W = 800, H = 240;
+ const padL = 44, padR = 12, padT = 14, padB = 26;
+ const plotW = W - padL - padR, plotH = H - padT - padB;
+ svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
+
+ const max = Math.max(1, ...pts.map(p => p.value));
+ // "nice" rounded top so gridlines read cleanly
+ const niceMax = niceCeil(max);
+ const x = i => padL + (pts.length === 1 ? plotW / 2 : (i / (pts.length - 1)) * plotW);
+ const y = v => padT + plotH - (v / niceMax) * plotH;
+
+ // gridlines + y labels (4 bands)
+ for (let g = 0; g <= 4; g++) {
+ const val = (niceMax / 4) * g;
+ const gy = y(val);
+ svg.appendChild(el('line', { class: 'grid-line', x1: padL, x2: W - padR, y1: gy, y2: gy }));
+ const lbl = el('text', { class: 'axis-lbl', x: padL - 8, y: gy + 3, 'text-anchor': 'end' });
+ lbl.textContent = fmtAxis(val);
+ svg.appendChild(lbl);
+ }
+
+ // x labels (first / mid / last)
+ const xticks = pts.length <= 2 ? [0, pts.length - 1] : [0, Math.floor((pts.length - 1) / 2), pts.length - 1];
+ xticks.forEach(i => {
+ const t = el('text', { class: 'axis-lbl', x: x(i), y: H - 8,
+ 'text-anchor': i === 0 ? 'start' : i === pts.length - 1 ? 'end' : 'middle' });
+ t.textContent = shortDate(pts[i].date);
+ svg.appendChild(t);
+ });
+
+ const asBars = state.metric === 'sends';
+ if (asBars) {
+ const slot = plotW / pts.length;
+ const bw = Math.max(2, Math.min(22, slot * 0.6));
+ pts.forEach((p, i) => {
+ const cx = padL + (i + 0.5) * slot;
+ const by = y(p.value);
+ svg.appendChild(el('rect', { class: 'bar', x: cx - bw / 2, y: by, width: bw, height: Math.max(0, padT + plotH - by) }));
+ });
+ } else {
+ // area + line
+ let d = '', area = `M ${x(0)} ${padT + plotH} `;
+ pts.forEach((p, i) => {
+ const px = x(i), py = y(p.value);
+ d += (i ? 'L' : 'M') + ` ${px.toFixed(1)} ${py.toFixed(1)} `;
+ area += `L ${px.toFixed(1)} ${py.toFixed(1)} `;
+ });
+ area += `L ${x(pts.length - 1)} ${padT + plotH} Z`;
+ svg.appendChild(el('path', { class: 'area', d: area }));
+ svg.appendChild(el('path', { class: 'line', d }));
+ }
+
+ // invisible hit-targets for the tooltip (one column per point)
+ const slot = plotW / pts.length;
+ pts.forEach((p, i) => {
+ const cx = asBars ? padL + (i + 0.5) * slot : x(i);
+ const hx = padL + i * slot;
+ const hit = el('rect', { class: 'hit', x: hx, y: padT, width: slot, height: plotH });
+ const show = () => {
+ if (!asBars) {
+ const dot = el('circle', { class: 'dot active-dot', cx, cy: y(p.value), r: 4 });
+ const prev = svg.querySelector('.active-dot'); if (prev) prev.remove();
+ svg.appendChild(dot);
+ }
+ // position tooltip relative to the wrapper using the rendered px geometry
+ const rect = svg.getBoundingClientRect();
+ const wrap = svg.parentElement.getBoundingClientRect();
+ const sx = rect.left - wrap.left + (cx / W) * rect.width;
+ const sy = rect.top - wrap.top + (y(p.value) / H) * rect.height;
+ tip.style.left = sx + 'px';
+ tip.style.top = sy + 'px';
+ tip.innerHTML = `<b>${fmtInt(p.value)}</b> · ${esc(shortDate(p.date))}`;
+ tip.hidden = false;
+ };
+ hit.addEventListener('mouseenter', show);
+ hit.addEventListener('mousemove', show);
+ hit.addEventListener('mouseleave', () => {
+ tip.hidden = true;
+ const d = svg.querySelector('.active-dot'); if (d) d.remove();
+ });
+ svg.appendChild(hit);
+ });
+ }
+
+ function niceCeil(v) {
+ if (v <= 0) return 1;
+ const mag = Math.pow(10, Math.floor(Math.log10(v)));
+ const n = v / mag;
+ const step = n <= 1 ? 1 : n <= 2 ? 2 : n <= 5 ? 5 : 10;
+ return step * mag;
+ }
+ function fmtAxis(v) {
+ if (v >= 1000) return (v / 1000).toFixed(v % 1000 === 0 ? 0 : 1) + 'k';
+ return String(Math.round(v));
+ }
+
+ // ── data loading ─────────────────────────────────────────────────────────────
+ async function loadOverview() {
+ try {
+ const r = await fetch(`/api/performance/overview?range=${state.range}`);
+ state.overview = await r.json();
+ } catch (e) { state.overview = null; }
+ renderBanner();
+ renderKpis();
+ renderCampaigns();
+ }
+
+ async function loadSeries() {
+ try {
+ const r = await fetch(`/api/performance/timeseries?metric=${state.metric}&range=${state.range}`);
+ state.series = await r.json();
+ } catch (e) { state.series = null; }
+ renderChart();
+ }
+
+ function renderBanner() {
+ const o = state.overview;
+ const box = $('#perf-banner');
+ const webMock = !o || !o.web || o.web.mock;
+ if (o && o.mock === false && !webMock) { box.innerHTML = ''; return; }
+ const bits = [];
+ if (!o || o.mock !== false) bits.push('email metrics are sample data (Constant Contact not wired)');
+ if (webMock) bits.push('web traffic is sample data (set GA4_PROPERTY_ID + GA4_ACCESS_TOKEN for live Analytics)');
+ box.innerHTML = bits.length
+ ? `<div class="muted-banner">Showing a fully-usable demo: ${esc(bits.join('; '))}.</div>`
+ : '';
+ }
+
+ // ── controls ─────────────────────────────────────────────────────────────────
+ root.querySelectorAll('.perf-range').forEach(b => {
+ b.addEventListener('click', () => {
+ if (b.dataset.range === state.range) return;
+ state.range = b.dataset.range;
+ root.querySelectorAll('.perf-range').forEach(x =>
+ x.classList.toggle('active', x.dataset.range === state.range));
+ loadOverview();
+ loadSeries();
+ });
+ });
+
+ const metricSel = $('#perf-metric');
+ metricSel.value = state.metric;
+ metricSel.addEventListener('change', () => {
+ state.metric = metricSel.value;
+ loadSeries();
+ });
+
+ // redraw chart on resize (geometry is viewBox-based but tooltip px math isn't)
+ let rt;
+ const onResize = () => { clearTimeout(rt); rt = setTimeout(renderChart, 120); };
+ window.addEventListener('resize', onResize);
+
+ loadOverview();
+ loadSeries();
+ },
+};
diff --git a/public/panels/segments.html b/public/panels/segments.html
new file mode 100644
index 0000000..1299c9b
--- /dev/null
+++ b/public/panels/segments.html
@@ -0,0 +1,49 @@
+<div id="seg-banner"></div>
+
+<div class="grid" style="grid-template-columns:minmax(280px,360px) 1fr;align-items:start">
+
+ <!-- Left: saved segments -->
+ <div class="card" id="seg-list-card">
+ <h2>Saved segments</h2>
+ <div class="muted" style="font-size:12px">Reusable audiences · estimated size</div>
+ <div id="seg-list" class="loading" style="margin-top:12px">Loading…</div>
+ <div class="row" style="margin-top:14px">
+ <button class="btn ghost" id="seg-new">+ New segment</button>
+ </div>
+ </div>
+
+ <!-- Right: rule builder + preview -->
+ <div class="card" id="seg-builder-card">
+ <h2 id="seg-builder-title">Build a segment</h2>
+ <div class="muted" style="font-size:12px">A segment is a set of rules over contact fields, matched with AND.</div>
+
+ <div class="grid" style="margin-top:14px;grid-template-columns:1fr 1fr">
+ <div>
+ <label for="seg-name">Segment name</label>
+ <input id="seg-name" placeholder="Trade & Designers — engaged">
+ </div>
+ <div>
+ <label for="seg-desc">Description</label>
+ <input id="seg-desc" placeholder="Short note on who this audience is">
+ </div>
+ </div>
+
+ <div style="margin-top:16px">
+ <label>Rules</label>
+ <div id="seg-rules"></div>
+ <div class="row" style="margin-top:8px">
+ <button class="btn ghost" id="seg-add-rule">+ Add rule</button>
+ </div>
+ </div>
+
+ <div class="row" style="margin-top:16px;align-items:center">
+ <button class="btn gold" id="seg-preview">Preview audience</button>
+ <button class="btn" id="seg-save">Save segment</button>
+ <span class="pill" id="seg-editing" style="display:none"></span>
+ <span class="muted" id="seg-msg" style="font-size:12.5px"></span>
+ </div>
+
+ <div id="seg-preview-body" style="margin-top:16px"></div>
+ </div>
+
+</div>
diff --git a/public/panels/segments.js b/public/panels/segments.js
new file mode 100644
index 0000000..8d35ce7
--- /dev/null
+++ b/public/panels/segments.js
@@ -0,0 +1,235 @@
+// Audience Segments panel — left: saved segments (size + edit/delete); right: a
+// rule builder with a live "Preview audience" (count + sample table) and Save.
+// Shows a .muted-banner whenever the preview/list ran on the mock contact pool.
+window.MCC_PANELS = window.MCC_PANELS || {};
+window.MCC_PANELS['segments'] = {
+ init(root) {
+ const $ = s => root.querySelector(s);
+ const api = (p, opts) => fetch(`/api/segments${p}`, opts).then(r => r.json());
+ const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c =>
+ ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
+ const fmt = d => d ? new Date(d).toLocaleDateString(undefined,
+ { year: 'numeric', month: 'short', day: 'numeric' }) : 'never';
+
+ let anyMock = false;
+ const markMock = m => { if (m) { anyMock = true; banner(); } };
+ function banner() {
+ $('#seg-banner').innerHTML = anyMock
+ ? `<div class="muted-banner">Running on a <b>mock contact pool</b> (~300 luxury-design contacts).
+ Paste your <code>CTCT_ACCESS_TOKEN</code> in <code>.env</code> to segment your live Constant Contact audience.</div>`
+ : '';
+ }
+
+ let SCHEMA = { fields: [], lists: [], tags: [] };
+ let editingId = null;
+
+ const setMsg = (t, warn) => {
+ const el = $('#seg-msg');
+ el.textContent = t || '';
+ el.style.color = warn ? 'var(--accent)' : 'var(--mut)';
+ };
+
+ // ── rule rows ──────────────────────────────────────────────────────────────
+ function fieldSpec(key) { return SCHEMA.fields.find(f => f.key === key); }
+
+ function valueControl(field, value) {
+ const spec = fieldSpec(field);
+ const vals = (spec && spec.values) || [];
+ if (Array.isArray(vals) && vals.length) {
+ const opts = vals.map(v =>
+ `<option value="${esc(v)}" ${v === value ? 'selected' : ''}>${esc(v)}</option>`).join('');
+ return `<select class="seg-rule-value">${opts}</select>`;
+ }
+ return `<input class="seg-rule-value" value="${esc(value || '')}" placeholder="value">`;
+ }
+
+ function ruleRow(rule) {
+ rule = rule || {};
+ const field = rule.field || (SCHEMA.fields[0] && SCHEMA.fields[0].key) || 'type';
+ const spec = fieldSpec(field) || { ops: [] };
+ const fieldOpts = SCHEMA.fields.map(f =>
+ `<option value="${esc(f.key)}" ${f.key === field ? 'selected' : ''}>${esc(f.label)}</option>`).join('');
+ const op = (spec.ops.some(o => o.key === rule.op) ? rule.op : (spec.ops[0] && spec.ops[0].key)) || 'is';
+ const opOpts = spec.ops.map(o =>
+ `<option value="${esc(o.key)}" ${o.key === op ? 'selected' : ''}>${esc(o.label)}</option>`).join('');
+
+ const wrap = document.createElement('div');
+ wrap.className = 'row seg-rule';
+ wrap.style.cssText = 'gap:8px;align-items:center;margin-top:8px';
+ wrap.innerHTML =
+ `<select class="seg-rule-field" style="flex:1.1">${fieldOpts}</select>
+ <select class="seg-rule-op" style="flex:.8">${opOpts}</select>
+ <span class="seg-rule-valwrap" style="flex:1.3">${valueControl(field, rule.value)}</span>
+ <button class="btn ghost seg-rule-del" title="Remove rule" style="padding:9px 12px">✕</button>`;
+
+ // field change → rebuild op + value controls for the new field
+ wrap.querySelector('.seg-rule-field').onchange = (e) => {
+ const f = fieldSpec(e.target.value) || { ops: [] };
+ wrap.querySelector('.seg-rule-op').innerHTML = f.ops.map(o =>
+ `<option value="${esc(o.key)}">${esc(o.label)}</option>`).join('');
+ const firstVal = Array.isArray(f.values) && f.values.length ? f.values[0] : '';
+ wrap.querySelector('.seg-rule-valwrap').innerHTML = valueControl(e.target.value, firstVal);
+ };
+ wrap.querySelector('.seg-rule-del').onclick = () => wrap.remove();
+ return wrap;
+ }
+
+ function addRule(rule) { $('#seg-rules').appendChild(ruleRow(rule)); }
+
+ function readRules() {
+ return [...$('#seg-rules').querySelectorAll('.seg-rule')].map(row => ({
+ field: row.querySelector('.seg-rule-field').value,
+ op: row.querySelector('.seg-rule-op').value,
+ value: row.querySelector('.seg-rule-value').value,
+ })).filter(r => r.field);
+ }
+
+ function loadIntoBuilder(seg) {
+ editingId = seg ? seg.id : null;
+ $('#seg-builder-title').textContent = seg ? 'Edit segment' : 'Build a segment';
+ $('#seg-name').value = seg ? (seg.name || '') : '';
+ $('#seg-desc').value = seg ? (seg.description || '') : '';
+ $('#seg-rules').innerHTML = '';
+ const rules = (seg && seg.rules && seg.rules.length) ? seg.rules : [{}];
+ rules.forEach(addRule);
+ const pill = $('#seg-editing');
+ if (seg) { pill.style.display = ''; pill.textContent = `editing ${seg.id}`; }
+ else { pill.style.display = 'none'; }
+ $('#seg-preview-body').innerHTML = '';
+ setMsg('');
+ }
+
+ // ── saved segment list ──────────────────────────────────────────────────────
+ function renderList(segments) {
+ const el = $('#seg-list');
+ if (!segments.length) { el.innerHTML = '<div class="muted">No segments yet — build one on the right.</div>'; return; }
+ el.classList.remove('loading');
+ el.innerHTML = segments.map(s => {
+ const nRules = (s.rules || []).length;
+ return `<div class="seg-item" data-id="${esc(s.id)}"
+ style="border-top:1px solid var(--line);padding:11px 0;cursor:pointer">
+ <div class="row" style="justify-content:space-between;align-items:baseline">
+ <div style="font-weight:600">${esc(s.name)}</div>
+ <span class="pill">${Number(s.estimatedSize || 0).toLocaleString()}</span>
+ </div>
+ <div class="muted" style="font-size:12px;margin:3px 0 6px">${esc(s.description || '—')}</div>
+ <div class="row" style="gap:8px;align-items:center">
+ <span class="muted" style="font-size:11px">${nRules} rule${nRules === 1 ? '' : 's'}</span>
+ <button class="btn ghost seg-edit" data-id="${esc(s.id)}" style="padding:5px 11px;font-size:12px">Edit</button>
+ <button class="btn ghost seg-del" data-id="${esc(s.id)}" style="padding:5px 11px;font-size:12px">Delete</button>
+ </div>
+ </div>`;
+ }).join('');
+
+ el.querySelectorAll('.seg-edit').forEach(b => b.onclick = (e) => {
+ e.stopPropagation();
+ const seg = segments.find(s => s.id === b.dataset.id);
+ if (seg) loadIntoBuilder(seg);
+ });
+ el.querySelectorAll('.seg-del').forEach(b => b.onclick = async (e) => {
+ e.stopPropagation();
+ const seg = segments.find(s => s.id === b.dataset.id);
+ if (!seg) return;
+ if (!confirm(`Delete segment “${seg.name}”? This cannot be undone.`)) return;
+ const r = await api(`/segments?delete=${encodeURIComponent(seg.id)}`, { method: 'POST' });
+ markMock(r.mock);
+ renderList(r.segments || []);
+ if (editingId === seg.id) loadIntoBuilder(null);
+ });
+ // clicking the item previews that saved segment
+ el.querySelectorAll('.seg-item').forEach(it => it.onclick = () => previewSaved(it.dataset.id));
+ }
+
+ function loadList() {
+ return api('/segments').then(d => { markMock(d.mock); renderList(d.segments || []); })
+ .catch(e => { $('#seg-list').innerHTML = `<div class="muted">Error: ${esc(e.message)}</div>`; });
+ }
+
+ // ── preview rendering ───────────────────────────────────────────────────────
+ function renderPreview(r) {
+ markMock(r.mock);
+ if (r.error) { $('#seg-preview-body').innerHTML = `<div class="muted-banner">${esc(r.error)}</div>`; return; }
+ const sample = r.sample || [];
+ const rows = sample.map(c => {
+ const tags = (c.tags || []).map(t => `<span class="pill" style="margin:0 3px 3px 0">${esc(t)}</span>`).join('') || '';
+ return `<tr style="border-top:1px solid var(--line)">
+ <td style="padding:7px 8px">${esc(c.email)}</td>
+ <td style="padding:7px 8px"><span class="pill">${esc(c.type)}</span></td>
+ <td style="padding:7px 8px;font-size:12px;color:var(--mut)">${esc((c.lists || []).join(', ') || '—')}</td>
+ <td style="padding:7px 8px;font-size:12px;color:var(--mut)">${fmt(c.lastOpen)}</td>
+ <td style="padding:7px 8px">${tags}</td>
+ </tr>`;
+ }).join('');
+ const pctOfTotal = r.total ? ((r.count / r.total) * 100).toFixed(1) : '0.0';
+ $('#seg-preview-body').innerHTML =
+ `<div class="row" style="align-items:baseline;gap:10px;border-top:1px solid var(--line);padding-top:14px">
+ <span style="font:600 30px/1 'Cormorant Garamond',Georgia,serif">${Number(r.count).toLocaleString()}</span>
+ <span class="muted" style="font-size:12px">matching contacts · ${pctOfTotal}% of ${Number(r.total || 0).toLocaleString()}</span>
+ </div>
+ ${sample.length ? `<div style="overflow:auto;margin-top:10px">
+ <table style="width:100%;border-collapse:collapse;font-size:13px">
+ <thead><tr style="text-align:left;color:var(--mut);font-size:11px">
+ <th style="padding:4px 8px">Email</th><th style="padding:4px 8px">Type</th>
+ <th style="padding:4px 8px">Lists</th><th style="padding:4px 8px">Last open</th>
+ <th style="padding:4px 8px">Tags</th>
+ </tr></thead>
+ <tbody>${rows}</tbody>
+ </table>
+ ${r.count > sample.length ? `<div class="muted" style="font-size:12px;margin-top:8px">Showing first ${sample.length} of ${Number(r.count).toLocaleString()}.</div>` : ''}
+ </div>` : `<div class="muted" style="margin-top:10px">No contacts match these rules.</div>`}`;
+ }
+
+ function previewLive() {
+ setMsg('Previewing…');
+ api('/preview', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ rules: readRules() }),
+ }).then(r => { setMsg(''); renderPreview(r); })
+ .catch(e => setMsg(`Error: ${e.message}`, true));
+ }
+
+ function previewSaved(id) {
+ setMsg('Previewing saved segment…');
+ api(`/contacts/preview?segmentId=${encodeURIComponent(id)}`)
+ .then(r => { setMsg(''); renderPreview(r); })
+ .catch(e => setMsg(`Error: ${e.message}`, true));
+ }
+
+ // ── save ────────────────────────────────────────────────────────────────────
+ async function save() {
+ const name = $('#seg-name').value.trim();
+ if (!name) { setMsg('Give the segment a name first.', true); return; }
+ const body = { id: editingId || undefined, name, description: $('#seg-desc').value.trim(), rules: readRules() };
+ $('#seg-save').disabled = true; setMsg('Saving…');
+ try {
+ const r = await api('/segments', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
+ });
+ markMock(r.mock);
+ if (r.error) { setMsg(r.error, true); }
+ else {
+ editingId = r.entry && r.entry.id;
+ const pill = $('#seg-editing');
+ if (editingId) { pill.style.display = ''; pill.textContent = `editing ${editingId}`; }
+ $('#seg-builder-title').textContent = 'Edit segment';
+ setMsg(`Saved “${r.entry.name}” · est. ${Number(r.entry.estimatedSize || 0).toLocaleString()} contacts.`);
+ renderList(r.segments || []);
+ }
+ } catch (e) { setMsg(`Error: ${e.message}`, true); }
+ finally { $('#seg-save').disabled = false; }
+ }
+
+ // ── wire up ──────────────────────────────────────────────────────────────────
+ $('#seg-add-rule').onclick = () => addRule({});
+ $('#seg-new').onclick = () => loadIntoBuilder(null);
+ $('#seg-preview').onclick = previewLive;
+ $('#seg-save').onclick = save;
+
+ // load schema first (rule builder depends on it), then list + an empty builder
+ api('/schema').then(s => {
+ SCHEMA = s || SCHEMA;
+ loadIntoBuilder(null);
+ loadList();
+ }).catch(e => setMsg(`Error loading schema: ${e.message}`, true));
+ },
+};
diff --git a/public/panels/social.html b/public/panels/social.html
new file mode 100644
index 0000000..e9b8fb1
--- /dev/null
+++ b/public/panels/social.html
@@ -0,0 +1,129 @@
+<div class="muted-banner">
+ Queue and stage social posts for the trade. Group by status, draft captions, and rehearse a
+ publish — but nothing here posts to a live account. “Publish now” is a gated dry-run that
+ stages only; connect a social API to go live.
+</div>
+
+<div class="soc-wrap">
+ <div class="soc-main">
+ <div class="card">
+ <h2>Add a post</h2>
+ <form class="soc-form" id="soc-form">
+ <div class="row" style="gap:10px">
+ <div style="width:160px">
+ <label>Channel</label>
+ <select name="channel">
+ <option value="instagram">📷 Instagram</option>
+ <option value="tiktok">🎵 TikTok</option>
+ <option value="pinterest">📌 Pinterest</option>
+ <option value="facebook">👍 Facebook</option>
+ </select>
+ </div>
+ <div style="width:150px">
+ <label>Date</label>
+ <input type="date" name="date">
+ </div>
+ <div style="width:110px">
+ <label>Time</label>
+ <input type="time" name="time">
+ </div>
+ <div style="width:150px">
+ <label>Status</label>
+ <select name="status">
+ <option value="idea">Idea</option>
+ <option value="drafted">Drafted</option>
+ <option value="scheduled">Scheduled</option>
+ <option value="posted">Posted</option>
+ </select>
+ </div>
+ </div>
+ <div style="margin-top:10px">
+ <label>Caption</label>
+ <textarea name="caption" rows="3" placeholder="Editorial, tactile, confident — e.g. The light moves and so does the silk…"></textarea>
+ </div>
+ <div class="row" style="margin-top:10px;gap:10px">
+ <div style="flex:1;min-width:200px">
+ <label>Hashtags</label>
+ <input name="hashtags" placeholder="#grasscloth #interiordesign #luxuryinteriors">
+ </div>
+ <div style="flex:1;min-width:200px">
+ <label>Image URL</label>
+ <input name="imageUrl" placeholder="https://…">
+ </div>
+ </div>
+ <div style="margin-top:10px">
+ <label>Notes</label>
+ <input name="notes" placeholder="Angle, product line, asset to shoot…">
+ </div>
+ <div class="row" style="margin-top:12px;align-items:center">
+ <button type="submit" class="btn gold" id="soc-save">Add to queue</button>
+ <button type="button" class="btn ghost" id="soc-reset">Clear</button>
+ <span class="muted" id="soc-editing" style="font-size:12px;margin-left:auto"></span>
+ </div>
+ </form>
+ </div>
+
+ <div class="soc-board" id="soc-board"></div>
+ </div>
+
+ <div class="soc-side">
+ <div class="card">
+ <h2>Best times to post</h2>
+ <p class="muted" style="margin:2px 0 12px;font-size:12.5px">Curated windows for a luxury-design audience (your local time).</p>
+ <div id="soc-besttimes"><div class="cal-empty muted">Loading…</div></div>
+ </div>
+ </div>
+</div>
+
+<!-- card template for one queued post -->
+<template id="soc-card-tpl">
+ <div class="soc-card">
+ <div class="sc-top">
+ <span class="sc-chan"></span>
+ <span class="sc-when muted"></span>
+ <button class="sc-del" title="Remove" type="button">✕</button>
+ </div>
+ <div class="sc-cap"></div>
+ <div class="sc-tags"></div>
+ <div class="sc-foot">
+ <button class="btn ghost sc-edit" type="button">Edit</button>
+ <button class="btn gated sc-pub" type="button" title="Gated — staged dry-run only">Publish now…</button>
+ </div>
+ <div class="sc-result" hidden></div>
+ </div>
+</template>
+
+<style>
+.soc-wrap{display:grid;grid-template-columns:minmax(0,1fr) 320px;gap:18px;align-items:start}
+@media(max-width:1080px){.soc-wrap{grid-template-columns:1fr}}
+.soc-form textarea{resize:vertical}
+.soc-board{display:grid;grid-template-columns:repeat(4,1fr);gap:14px}
+@media(max-width:1280px){.soc-board{grid-template-columns:repeat(2,1fr)}}
+@media(max-width:720px){.soc-board{grid-template-columns:1fr}}
+.soc-col{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:14px;min-height:120px}
+.soc-col h3{margin:0 0 10px;font:600 14px/1.2 "Cormorant Garamond",Georgia,serif;
+ display:flex;align-items:center;gap:8px}
+.soc-col h3 .ct{font:700 11px/1 Inter;background:var(--cream);color:var(--accent);border-radius:999px;padding:3px 8px;margin-left:auto}
+.soc-card{border:1px solid var(--line);border-radius:11px;padding:11px 12px;margin-bottom:10px;background:#fff}
+.soc-card:last-child{margin-bottom:0}
+.sc-top{display:flex;align-items:center;gap:8px;margin-bottom:6px}
+.sc-chan{font-size:12px;font-weight:700}
+.sc-when{font-size:11px}
+.sc-del{margin-left:auto;background:transparent;border:0;color:var(--mut);cursor:pointer;font-size:13px;padding:0 2px}
+.sc-del:hover{color:var(--accent)}
+.sc-cap{font-size:12.5px;line-height:1.45;color:#3f3a33;white-space:pre-wrap;word-break:break-word;
+ display:-webkit-box;-webkit-line-clamp:4;-webkit-box-orient:vertical;overflow:hidden}
+.sc-tags{display:flex;flex-wrap:wrap;gap:5px;margin-top:8px}
+.sc-foot{display:flex;gap:7px;margin-top:10px}
+.sc-foot .btn{padding:6px 11px;font-size:12px}
+.sc-result{margin-top:9px;font-size:11.5px;line-height:1.4;border-radius:9px;padding:8px 10px;background:var(--cream);
+ border:1px solid var(--line);color:#4a443c}
+.sc-result.staged{background:#fff8ec;border-color:#f0e0c0;color:#7a5a2a}
+.soc-empty{color:var(--mut);font-size:12px}
+.bt-row{border-bottom:1px solid var(--line);padding:10px 0}
+.bt-row:last-child{border-bottom:0}
+.bt-row .bh{display:flex;align-items:center;gap:8px;font-weight:600;font-size:13px}
+.bt-win{display:flex;flex-wrap:wrap;gap:5px;margin:7px 0}
+.bt-note{font-size:11.5px;color:#5a544b;line-height:1.45}
+.cal-empty{font-size:12.5px;padding:6px 0}
+</style>
diff --git a/public/panels/social.js b/public/panels/social.js
new file mode 100644
index 0000000..56cf130
--- /dev/null
+++ b/public/panels/social.js
@@ -0,0 +1,238 @@
+// Social Scheduler panel — queue board grouped by status + add/edit form,
+// gated dry-run publish, and a curated best-times helper card.
+// Registers into the shell's MCC_PANELS map (see public/app.js).
+window.MCC_PANELS = window.MCC_PANELS || {};
+window.MCC_PANELS['social'] = {
+ init(root) {
+ const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c =>
+ ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
+
+ const CHAN_ICON = { instagram: '📷', tiktok: '🎵', pinterest: '📌', facebook: '👍' };
+ const CHAN_NAME = { instagram: 'Instagram', tiktok: 'TikTok', pinterest: 'Pinterest', facebook: 'Facebook' };
+ const COLUMNS = [
+ { status: 'idea', title: 'Ideas' },
+ { status: 'drafted', title: 'Drafted' },
+ { status: 'scheduled', title: 'Scheduled' },
+ { status: 'posted', title: 'Posted' },
+ ];
+
+ const $ = sel => root.querySelector(sel);
+ const board = $('#soc-board');
+ const form = $('#soc-form');
+ const cardTpl = $('#soc-card-tpl');
+
+ const state = { posts: [], editingId: null };
+
+ async function load() {
+ try {
+ const r = await fetch('/api/social/posts').then(r => r.json());
+ state.posts = (r && r.posts) || [];
+ } catch (_) { state.posts = []; }
+ renderBoard();
+ }
+
+ function whenLabel(p) {
+ if (!p.date && !p.time) return 'no date';
+ let out = '';
+ if (p.date) {
+ const d = new Date(p.date + 'T00:00:00');
+ out = isNaN(d) ? p.date : d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
+ }
+ if (p.time) out = (out ? out + ' · ' : '') + p.time;
+ return out;
+ }
+
+ function renderBoard() {
+ board.innerHTML = '';
+ for (const col of COLUMNS) {
+ const items = state.posts.filter(p => p.status === col.status);
+ const el = document.createElement('div');
+ el.className = 'soc-col';
+ el.innerHTML = `<h3>${esc(col.title)}<span class="ct">${items.length}</span></h3>`;
+ if (!items.length) {
+ const e = document.createElement('div');
+ e.className = 'soc-empty';
+ e.textContent = '—';
+ el.appendChild(e);
+ } else {
+ for (const p of items) el.appendChild(buildCard(p));
+ }
+ board.appendChild(el);
+ }
+ }
+
+ function buildCard(p) {
+ const node = cardTpl.content.firstElementChild.cloneNode(true);
+ node.querySelector('.sc-chan').textContent =
+ `${CHAN_ICON[p.channel] || '•'} ${CHAN_NAME[p.channel] || p.channel}`;
+ node.querySelector('.sc-when').textContent = whenLabel(p);
+
+ const cap = node.querySelector('.sc-cap');
+ cap.textContent = p.caption || '(no caption yet)';
+ if (!p.caption) cap.classList.add('muted');
+
+ const tags = node.querySelector('.sc-tags');
+ (p.hashtags || []).slice(0, 12).forEach(t => {
+ const s = document.createElement('span');
+ s.className = 'pill';
+ s.textContent = t;
+ tags.appendChild(s);
+ });
+
+ const pub = node.querySelector('.sc-pub');
+ // Publish only makes sense for scheduled posts; hide elsewhere.
+ if (p.status !== 'scheduled') pub.remove();
+ else pub.addEventListener('click', () => publishFlow(p, node));
+
+ node.querySelector('.sc-edit').addEventListener('click', () => startEdit(p));
+ node.querySelector('.sc-del').addEventListener('click', () => removePost(p.id));
+ return node;
+ }
+
+ // ── gated publish: dry-run → confirm → stage ─────────────────────────────
+ async function publishFlow(p, node) {
+ const box = node.querySelector('.sc-result');
+ box.hidden = false;
+ box.classList.remove('staged');
+ box.textContent = 'Running dry run…';
+
+ let dry;
+ try {
+ dry = await fetch('/api/social/publish', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ id: p.id, dryRun: true }),
+ }).then(r => r.json());
+ } catch (_) {
+ box.textContent = 'Could not reach the publish endpoint.';
+ return;
+ }
+ if (!dry || !dry.ok) {
+ box.textContent = (dry && dry.error) || 'Dry run failed.';
+ return;
+ }
+
+ const w = dry.would || {};
+ const summary = `Would post to ${CHAN_NAME[w.channel] || w.channel} at ${w.when}.`;
+ if (!confirm(
+ `${summary}\n\nThis is a GATED action. No real social API is connected, so it will be ` +
+ `STAGED only — nothing goes to a live account.\n\nStage this publish?`)) {
+ box.textContent = 'Dry run only — staging cancelled. Nothing was sent.';
+ return;
+ }
+
+ box.textContent = 'Staging…';
+ let res;
+ try {
+ res = await fetch('/api/social/publish', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ id: p.id, dryRun: false, confirm: true }),
+ }).then(r => r.json());
+ } catch (_) {
+ box.textContent = 'Staging request failed.';
+ return;
+ }
+ if (res && res.staged) {
+ box.classList.add('staged');
+ box.textContent = res.message || 'Staged — connect a social API to enable live posting.';
+ } else {
+ box.textContent = (res && (res.message || res.error)) || 'Unexpected response.';
+ }
+ }
+
+ async function removePost(id) {
+ if (!confirm('Remove this post from the queue?')) return;
+ try {
+ await fetch(`/api/social/posts?delete=${encodeURIComponent(id)}`, { method: 'POST' });
+ } catch (_) {}
+ if (state.editingId === id) resetForm();
+ await load();
+ }
+
+ // ── add / edit form ──────────────────────────────────────────────────────
+ function startEdit(p) {
+ state.editingId = p.id;
+ form.channel.value = p.channel || 'instagram';
+ form.date.value = p.date || '';
+ form.time.value = p.time || '';
+ form.status.value = p.status || 'idea';
+ form.caption.value = p.caption || '';
+ form.hashtags.value = (p.hashtags || []).join(' ');
+ form.imageUrl.value = p.imageUrl || '';
+ form.notes.value = p.notes || '';
+ $('#soc-save').textContent = 'Save changes';
+ $('#soc-editing').textContent = 'Editing existing post';
+ form.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ form.caption.focus();
+ }
+
+ function resetForm() {
+ state.editingId = null;
+ form.reset();
+ $('#soc-save').textContent = 'Add to queue';
+ $('#soc-editing').textContent = '';
+ }
+
+ form.addEventListener('submit', async e => {
+ e.preventDefault();
+ const fd = new FormData(form);
+ const body = {
+ id: state.editingId || undefined,
+ channel: fd.get('channel'),
+ date: (fd.get('date') || '').toString(),
+ time: (fd.get('time') || '').toString(),
+ status: fd.get('status'),
+ caption: (fd.get('caption') || '').toString().trim(),
+ hashtags: (fd.get('hashtags') || '').toString(),
+ imageUrl: (fd.get('imageUrl') || '').toString().trim(),
+ notes: (fd.get('notes') || '').toString().trim(),
+ };
+ const btn = $('#soc-save');
+ btn.disabled = true;
+ const prev = btn.textContent;
+ btn.textContent = 'Saving…';
+ try {
+ await fetch('/api/social/posts', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ } catch (_) {}
+ btn.disabled = false;
+ btn.textContent = prev;
+ resetForm();
+ await load();
+ });
+
+ $('#soc-reset').addEventListener('click', resetForm);
+
+ // ── best times helper ────────────────────────────────────────────────────
+ async function loadBestTimes() {
+ const box = $('#soc-besttimes');
+ let data;
+ try {
+ data = await fetch('/api/social/best-times').then(r => r.json());
+ } catch (_) {
+ box.innerHTML = `<div class="cal-empty muted">Could not load guidance.</div>`;
+ return;
+ }
+ const chans = (data && data.channels) || [];
+ if (!chans.length) {
+ box.innerHTML = `<div class="cal-empty muted">No guidance available.</div>`;
+ return;
+ }
+ box.innerHTML = chans.map(c => {
+ const wins = (c.windows || []).map(w => `<span class="pill">${esc(w)}</span>`).join('');
+ return `<div class="bt-row">
+ <div class="bh">${CHAN_ICON[c.channel] || '•'} ${esc(c.label || c.channel)}</div>
+ <div class="bt-win">${wins}</div>
+ <div class="bt-note">${esc(c.note || '')}</div>
+ </div>`;
+ }).join('');
+ }
+
+ load();
+ loadBestTimes();
+ },
+};
← 0696349 assets: image Asset Library module (upload / URL / live DW-c
·
back to Marketing Command Center
·
3 more panels (parallel agents): Performance dashboard (KPIs d54875f →