[object Object]

← 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

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 =>
+      ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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 &amp; 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 =>
+      ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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 =>
+      ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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 →