[object Object]

← back to 4square Agentabrams

4Square v2: live-hero iframe + server-backed 4-grid sync — drag products into the squares, save pushes to live site

75c0907dd881ddfa256e8c657970a7712ca7c54d · 2026-05-14 09:45:43 -0700 · Steve

- Adds /api/site/:domain/hero-grid GET/POST endpoints (reads + writes /var/www/<site>.com/public/hero-4grid.json on prod, ~/Projects/<site>/public/ on Mac2)
- Frontend: iframe of https://<domain>/ above the search bar shows the live front page, reloads after each push so the rotator picks up changes
- Moodboard now hydrates from the site's real hero-4grid.json on site-select (no more stale localStorage)
- saveLayout() POSTs to /api/site/:domain/hero-grid for wired sites; falls back to localStorage for non-wired (e.g. agentabrams.com subdomains)
- nginx dw-frame-allow.conf snippet extended to allow 4square.agentabrams.com in frame-ancestors
- Push-status pill shows real-time push state per drop

Files touched

Diff

commit 75c0907dd881ddfa256e8c657970a7712ca7c54d
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu May 14 09:45:43 2026 -0700

    4Square v2: live-hero iframe + server-backed 4-grid sync — drag products into the squares, save pushes to live site
    
    - Adds /api/site/:domain/hero-grid GET/POST endpoints (reads + writes /var/www/<site>.com/public/hero-4grid.json on prod, ~/Projects/<site>/public/ on Mac2)
    - Frontend: iframe of https://<domain>/ above the search bar shows the live front page, reloads after each push so the rotator picks up changes
    - Moodboard now hydrates from the site's real hero-4grid.json on site-select (no more stale localStorage)
    - saveLayout() POSTs to /api/site/:domain/hero-grid for wired sites; falls back to localStorage for non-wired (e.g. agentabrams.com subdomains)
    - nginx dw-frame-allow.conf snippet extended to allow 4square.agentabrams.com in frame-ancestors
    - Push-status pill shows real-time push state per drop
---
 .yolo-loop.sh     |   2 +-
 public/index.html | 176 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 server.js         |  84 ++++++++++++++++++++++++++
 3 files changed, 261 insertions(+), 1 deletion(-)

diff --git a/.yolo-loop.sh b/.yolo-loop.sh
index a8d9572..527dbf6 100755
--- a/.yolo-loop.sh
+++ b/.yolo-loop.sh
@@ -22,7 +22,7 @@ while true; do
     PROMPT+="\n\n## State from previous iterations\n$(cat "$STATE")"
   fi
 
-  echo "$PROMPT" | /usr/bin/claude --print --dangerously-skip-permissions --max-turns 80 >> "$LOG" 2>&1
+  echo "$PROMPT" | /Users/stevestudio2/.local/bin/claude --print --dangerously-skip-permissions --max-turns 80 >> "$LOG" 2>&1
   EXIT=$?
   print -- "$(date -u +%Y-%m-%dT%H:%M:%SZ) === iter $ITER END exit=$EXIT ===" >> "$LOG"
 
diff --git a/public/index.html b/public/index.html
index 6eb2c6d..2acc8f9 100644
--- a/public/index.html
+++ b/public/index.html
@@ -212,6 +212,47 @@
     .slot.filled .clear { display: flex; }
     .slot .clear:hover { background: #c54; color: #fff; }
 
+    /* ─ Live hero preview ─ */
+    .live-hero {
+      position: relative;
+      border-bottom: 1px solid var(--rule);
+      background: #0a0908;
+      min-height: 240px;
+      display: flex; flex-direction: column;
+    }
+    .live-hero-header {
+      display: flex; align-items: center; gap: 12px;
+      padding: 8px 18px; border-bottom: 1px solid var(--rule);
+      font: 0.78rem var(--font-ui);
+    }
+    .live-hero-header .domain-pill {
+      color: var(--accent); font-weight: 600; letter-spacing: 0.04em;
+      padding: 4px 10px; border: 1px solid var(--accent-dim); border-radius: 999px;
+    }
+    .live-hero-header .hint { color: var(--muted); flex: 1 1 auto; }
+    .live-hero-header .push-status { color: var(--muted); font-size: 0.72rem; }
+    .live-hero-header .push-status.ok { color: #7dd29e; }
+    .live-hero-header .push-status.err { color: #e57373; }
+    .live-hero iframe {
+      width: 100%; height: 360px; border: 0; background: #15140f;
+      flex: 0 0 auto;
+    }
+    .live-hero .no-preview {
+      padding: 28px 20px; color: var(--muted); text-align: center;
+      font: 0.86rem var(--font-ui); line-height: 1.5;
+    }
+    .live-hero .no-preview h3 {
+      font-family: var(--font-display); color: var(--fg);
+      margin: 0 0 4px; font-size: 1.05rem;
+    }
+    .live-hero.collapsed iframe { display: none; }
+    .live-hero-toggle {
+      background: transparent; border: 1px solid var(--rule); color: var(--muted);
+      padding: 3px 9px; border-radius: 6px; font: 0.7rem var(--font-ui);
+      cursor: pointer; letter-spacing: 0.04em;
+    }
+    .live-hero-toggle:hover { color: var(--accent); border-color: var(--accent-dim); }
+
     @media (max-width: 880px) {
       .panes { flex-direction: column; }
       .rail { width: 100% !important; max-width: none; max-height: 36vh; }
@@ -220,6 +261,7 @@
       .search-bar .count { display: none; }
       .grid-scroll { padding: 12px 14px; }
       .moodboard-body { grid-template-columns: repeat(2, 1fr); }
+      .live-hero iframe { height: 220px; }
     }
   </style>
 </head>
@@ -247,6 +289,21 @@
       <div class="splitter" id="splitter" aria-label="Drag to resize"></div>
 
       <main class="main">
+        <section class="live-hero" id="live-hero" style="display:none">
+          <div class="live-hero-header">
+            <span class="domain-pill" id="hero-domain">—</span>
+            <span class="hint" id="hero-hint">Live preview of the front page. Drop products into the 4 squares below to publish.</span>
+            <span class="push-status" id="push-status"></span>
+            <a id="open-live" class="live-hero-toggle" target="_blank" rel="noopener">Open ↗</a>
+            <button class="live-hero-toggle" id="hero-toggle">hide preview</button>
+            <button class="live-hero-toggle" id="hero-reload" title="Reload iframe">↻</button>
+          </div>
+          <iframe id="hero-iframe" src="about:blank" loading="lazy" referrerpolicy="no-referrer"></iframe>
+          <div class="no-preview" id="no-preview" style="display:none">
+            <h3>This site doesn't have the 2×2 hero installed yet.</h3>
+            <p>Front page renders but isn't wired into the rotator. Drop products into the squares below to seed it, then deploy.</p>
+          </div>
+        </section>
         <div class="search-bar">
           <span class="search-icon" aria-hidden="true">⌕</span>
           <input id="q" type="search" placeholder="Type anything to bring a product up…" autocomplete="off">
@@ -300,6 +357,11 @@
     function saveLayout() {
       try { localStorage.setItem(STORAGE_KEY, JSON.stringify(layout)); flashSaved(); }
       catch (e) { document.getElementById('save-status').textContent = 'autosave failed'; }
+      // If this site is wired, ALSO push to the live site's hero-4grid.json.
+      // The local-state save is the optimistic UI; the network push is the truth.
+      if (selectedSite && selectedSite.source && selectedSite.source.endpoint) {
+        pushHeroGridToServer();
+      }
     }
     function flashSaved() {
       const el = document.getElementById('save-status');
@@ -456,9 +518,123 @@
       localStorage.setItem(SITES_KEY, s.domain);
       document.getElementById('source-label').textContent = s.domain;
       renderSites();
+      showLiveHero(s);
+      hydrateMoodboardFromServer(s);
       fetchProducts();
     }
 
+    // ── Live hero preview iframe ───────────────────────────────────
+    function showLiveHero(s) {
+      const wrap = document.getElementById('live-hero');
+      const pill = document.getElementById('hero-domain');
+      const hint = document.getElementById('hero-hint');
+      const iframe = document.getElementById('hero-iframe');
+      const noPrev = document.getElementById('no-preview');
+      const openA = document.getElementById('open-live');
+      wrap.style.display = '';
+      pill.textContent = s.domain;
+      openA.href = 'https://' + s.domain + '/';
+      const isWired = !!(s.source && s.source.endpoint);
+      hint.textContent = isWired
+        ? 'Live preview. Drop products into the 4 squares below — saves push to ' + s.domain + ' in real time.'
+        : 'Front page is live. Product drag-drop is local-only until ' + s.domain + ' has /api/products wired.';
+      noPrev.style.display = 'none';
+      iframe.style.display = '';
+      // Bust both browser + CF cache for the live preview
+      iframe.src = 'https://' + s.domain + '/?fsq=' + Date.now();
+    }
+    document.getElementById('hero-reload').addEventListener('click', () => {
+      const iframe = document.getElementById('hero-iframe');
+      if (selectedSite) iframe.src = 'https://' + selectedSite.domain + '/?fsq=' + Date.now();
+    });
+    document.getElementById('hero-toggle').addEventListener('click', () => {
+      const wrap = document.getElementById('live-hero');
+      const btn = document.getElementById('hero-toggle');
+      wrap.classList.toggle('collapsed');
+      btn.textContent = wrap.classList.contains('collapsed') ? 'show preview' : 'hide preview';
+    });
+
+    // ── Server-backed hero-grid sync ───────────────────────────────
+    // For wired sites, the moodboard mirrors /api/site/:domain/hero-grid
+    // (which is the same hero-4grid.json the live site renders).
+    // For non-wired sites, fall back to the legacy localStorage layout.
+    async function hydrateMoodboardFromServer(s) {
+      if (!s || !s.source || !s.source.endpoint) {
+        // Non-wired: keep localStorage layout
+        layout = loadLayout();
+        renderSlots();
+        return;
+      }
+      try {
+        const r = await fetch('/api/site/' + encodeURIComponent(s.domain) + '/hero-grid?cb=' + Date.now(), { cache: 'no-store' });
+        const j = await r.json();
+        if (!j || !j.grid || !Array.isArray(j.grid.cells)) {
+          layout = { 1:null, 2:null, 3:null, 4:null };
+        } else {
+          layout = { 1:null, 2:null, 3:null, 4:null };
+          j.grid.cells.forEach((c, i) => {
+            if (!c || !c.image_url) return;
+            layout[String(i+1)] = {
+              id: c.sku || ('cell-' + i),
+              title: c.title || c.kind || '',
+              artist: c.kind || '',
+              imageProxy: c.image_url,
+              sku: c.sku || null,
+              kind: c.kind || (i % 2 === 0 ? 'room' : 'pattern'),
+            };
+          });
+        }
+        renderSlots();
+      } catch (e) {
+        layout = loadLayout();
+        renderSlots();
+      }
+    }
+
+    async function pushHeroGridToServer() {
+      if (!selectedSite || !selectedSite.source || !selectedSite.source.endpoint) return false;
+      const cells = ['1','2','3','4'].map((n, i) => {
+        const item = layout[n];
+        return {
+          kind: (item && item.kind) || (i % 2 === 0 ? 'room' : 'pattern'),
+          sku: (item && item.sku) || null,
+          image_url: (item && item.imageProxy) || null,
+          title: (item && item.title) || null,
+        };
+      });
+      // Pull rotation_pool from the current product rail (newest first)
+      const rotation_pool = (railItems || []).slice(0, 16).filter(p => p.image_url).map(p => ({
+        sku: p.sku || p.handle || null,
+        image_url: p.image_url,
+        title: p.title || '',
+      }));
+      const grid = { cells, rotation_pool, autocycle_seconds: 6 };
+      const $ps = document.getElementById('push-status');
+      $ps.textContent = 'pushing…'; $ps.className = 'push-status';
+      try {
+        const r = await fetch('/api/site/' + encodeURIComponent(selectedSite.domain) + '/hero-grid', {
+          method: 'POST', headers: { 'content-type': 'application/json' },
+          body: JSON.stringify(grid),
+        });
+        const j = await r.json();
+        if (j.ok) {
+          $ps.textContent = '✓ live on ' + selectedSite.domain; $ps.className = 'push-status ok';
+          // Reload iframe after a beat so the new JSON serves
+          setTimeout(() => {
+            const iframe = document.getElementById('hero-iframe');
+            iframe.src = 'https://' + selectedSite.domain + '/?fsq=' + Date.now();
+          }, 700);
+          return true;
+        } else {
+          $ps.textContent = '✗ ' + (j.error || 'push failed'); $ps.className = 'push-status err';
+          return false;
+        }
+      } catch (e) {
+        $ps.textContent = '✗ ' + e.message; $ps.className = 'push-status err';
+        return false;
+      }
+    }
+
     // ── Product grid ───────────────────────────────────────────────
     const $grid = document.getElementById('grid');
     const $count = document.getElementById('result-count');
diff --git a/server.js b/server.js
index fa6e318..a3c9818 100644
--- a/server.js
+++ b/server.js
@@ -152,6 +152,90 @@ app.get('/img', (req, res) => {
   upstream.end();
 });
 
+// ── Hero 4-grid read/write for a wired site ───────────────────────────────
+// Domain → on-disk path. Prefers /var/www/<domain>/public/ (Kamatera prod),
+// falls back to ~/Projects/<basename>/public/ for Mac2 local dev.
+function heroGridPath(domain) {
+  const safe = String(domain || '').replace(/[^a-z0-9.-]/gi, '');
+  if (!/^[a-z0-9-]+\.[a-z.]+$/i.test(safe)) return null;
+  const prod = `/var/www/${safe}/public/hero-4grid.json`;
+  if (fs.existsSync(`/var/www/${safe}`)) return prod;
+  const proj = path.join(process.env.HOME || '/root', 'Projects', safe.replace(/\.com$/, ''), 'public', 'hero-4grid.json');
+  return proj;
+}
+
+function defaultGridFromProducts(products) {
+  const items = (products || []).filter(p => p.image_url && /^https/.test(p.image_url));
+  const cells = [0,1,2,3].map(i => {
+    const p = items[i];
+    return {
+      kind: i % 2 === 0 ? 'room' : 'pattern',
+      sku: p?.sku || null,
+      image_url: p?.image_url || null,
+      title: p?.title || null,
+    };
+  });
+  const rotation_pool = items.slice(0, 16).map(p => ({ sku: p.sku, image_url: p.image_url, title: p.title }));
+  return { cells, rotation_pool, autocycle_seconds: 6, updated_at: null };
+}
+
+app.get('/api/site/:domain/hero-grid', async (req, res) => {
+  const domain = req.params.domain;
+  const cfg = wiredSites[domain];
+  if (!cfg) return res.status(404).json({ error: 'not wired: ' + domain });
+  const gp = heroGridPath(domain);
+  // Try to read existing grid
+  try {
+    const buf = fs.readFileSync(gp, 'utf8');
+    return res.json({ ok: true, grid: JSON.parse(buf), path: gp });
+  } catch (e) {
+    // No grid yet — synthesize from /api/products
+    try {
+      const url = new URL('https://' + domain + cfg.endpoint + '?limit=120');
+      const lib = url.protocol === 'https:' ? https : http;
+      const upstream = lib.request({
+        method: 'GET', hostname: url.hostname, port: 443,
+        path: url.pathname + url.search,
+        headers: { 'User-Agent': '4square/0.2', 'Accept': 'application/json' },
+      }, (r) => {
+        let raw = '';
+        r.on('data', (c) => raw += c);
+        r.on('end', () => {
+          let products = [];
+          try {
+            const parsed = JSON.parse(raw);
+            products = Array.isArray(parsed) ? parsed : (parsed.items || []);
+          } catch (e2) { /* ignore */ }
+          const grid = defaultGridFromProducts(products);
+          res.json({ ok: true, grid, path: gp, synthesized: true });
+        });
+      });
+      upstream.on('error', (e2) => res.status(502).json({ ok: false, error: 'upstream ' + e2.message }));
+      upstream.end();
+    } catch (e2) {
+      res.status(500).json({ ok: false, error: e2.message });
+    }
+  }
+});
+
+app.post('/api/site/:domain/hero-grid', express.json({ limit: '2mb' }), (req, res) => {
+  const domain = req.params.domain;
+  const cfg = wiredSites[domain];
+  if (!cfg) return res.status(404).json({ error: 'not wired: ' + domain });
+  const grid = req.body || {};
+  if (!grid.cells || !Array.isArray(grid.cells)) return res.status(400).json({ error: 'invalid grid (missing cells[])' });
+  grid.updated_at = new Date().toISOString();
+  const gp = heroGridPath(domain);
+  try {
+    const dir = path.dirname(gp);
+    if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
+    fs.writeFileSync(gp, JSON.stringify(grid, null, 2));
+    return res.json({ ok: true, path: gp, bytes: fs.statSync(gp).size });
+  } catch (e) {
+    return res.status(500).json({ ok: false, error: e.message, path: gp });
+  }
+});
+
 // ── Static frontend ───────────────────────────────────────────────────────
 app.use(express.static(path.join(__dirname, 'public'), { extensions: ['html'] }));
 

← 7bebaa2 4square: bust sites.json cache + re-resolve selectedSite fro  ·  back to 4square Agentabrams  ·  4Square: replace cross-origin iframe with in-DOM 2x2 hero ca c0661b1 →