← back to Wallco Ai

public/theme-variants/theme-section-rearrange.js

440 lines

/* theme-section-rearrange.js — universal PDP THEME CUSTOMIZER (user-facing).
 *
 * Drop-in (ABSOLUTE path), already referenced by all 17 theme variants and
 * injected into the classic monolith PDP via the /design/:id middleware:
 *   <script src="/theme-variants/theme-section-rearrange.js" defer></script>
 *
 * What it gives EVERY theme (the 17 variants + the classic monolith):
 *   1. 🛋  Render a new room      — POSTs /api/room (the existing, auth-gated,
 *         $/call renderer), rotates the room type, injects the fresh render into
 *         the page's room area, and remembers it per design so it persists.
 *   2. ✎  Customize layout       — edit mode: every recognised module gets a
 *         drag handle. Drag it ANYWHERE on screen (free 2-D position) OR drop it
 *         over a sibling module to reorder / relocate across columns.
 *   3. 💾  Save                   — persists the arrangement (order + free
 *         offsets) + the rendered rooms to localStorage, per THEME (slug).
 *   4. ↺  Reset                  — clears this theme's saved layout.
 *
 * User-facing (NOT admin-gated). Per-visitor (localStorage). Re-applies after a
 * re-render (#root rebuilds, e.g. the v11 Wallpaper⇄Mural toggle). Idempotent ·
 * zero deps · pointer-events (mouse + touch).
 */
(function () {
  'use strict';
  if (window.__themeCustomizerLoaded) return;
  window.__themeCustomizerLoaded = true;

  // ?admin=1 still sets the admin flag for OTHER tools, but we no longer gate on it.
  try {
    var q = new URLSearchParams(location.search);
    if (q.get('admin') === '1') localStorage.setItem('dwAdmin', '1');
    if (q.get('admin') === '0') localStorage.removeItem('dwAdmin');
  } catch (e) { /* ignore */ }

  // ---- identity: theme slug (persistence key) + design id (room render) ----
  // slug = theme name for persistence. /design/:id/<slug> → that slug; the bare
  // /design/:id monolith has no slug segment → 'classic' (so the layout is keyed
  // PER-THEME, not per design id).
  var slugMatch = location.pathname.match(/^\/design\/[^/]+\/([^/?#]+)/);
  var SLUG = slugMatch ? slugMatch[1] : 'classic';
  var idMatch = location.pathname.match(/\/design\/(\d+)/);
  var DESIGN_ID = idMatch ? idMatch[1] : null;
  var LKEY = 'wallco:layout:' + SLUG;                 // {order:{zoneKey:[keys]}, off:{modKey:{x,y}}}
  var RKEY = 'wallco:rooms:' + (DESIGN_ID || SLUG);   // [{url,room}]

  var ROOM_TYPES = ['living_room', 'bedroom', 'office', 'dining_room'];

  // ---- which elements count as draggable "modules" (union over 18 layouts) ----
  var MOD_SEL = [
    // variant families
    '.cta-row', '.preview-row', '.preview', '.roomprev', '.tab-bar', '.tabs', '.fmt-tabs',
    '.calc', '.calculator', '.specs', '.spec-table', '.spectab',
    '.room', '.room-main', '.room-thumbs', '.room-cap', '.atscale',
    '.info', '.meta', '.palette', '.color-dots', '.card', '.acc', '.cell',
    // classic monolith in-flow blocks
    'details.collapse-pane', '.tool-group', '.room-toggle', '.detail-image-col',
    'section', 'figure',
  ].join(',');
  // never treat site chrome / our own UI / drawer headers as modules
  var EXCLUDE_ANCESTOR = '#site-header, header.site-header, nav, footer, .site-footer, ' +
    '.tsr-dock, .tsr-toast, .tsr-room-results, .buyer-sheet-head, .admin-sheet, .edit-modal, .hb-submenu, #page-theme-toggle';

  var DRAG_CLASS = 'tsr-dragging';
  var editing = false, observer = null, applying = false;
  var pending = { order: null, off: null };  // staged from live DOM, written on 💾 Save

  // ---------- storage ----------
  function loadLayout() { try { return JSON.parse(localStorage.getItem(LKEY)) || {}; } catch (e) { return {}; } }
  function saveLayout(o) { try { localStorage.setItem(LKEY, JSON.stringify(o)); } catch (e) {} }
  function loadRooms() { try { return JSON.parse(localStorage.getItem(RKEY)) || []; } catch (e) { return []; } }
  function saveRooms(a) { try { localStorage.setItem(RKEY, JSON.stringify(a)); } catch (e) {} }

  // ---------- discovery ----------
  function containers() {
    var c = [];
    ['root', 'detail-main'].forEach(function (id) { var e = document.getElementById(id); if (e) c.push(e); });
    if (!c.length) { var m = document.querySelector('main'); if (m) c.push(m); }
    if (!c.length) c.push(document.body);
    return c;
  }
  function isModule(el) {
    if (!el || el.nodeType !== 1 || !el.matches) return false;
    if (!el.matches(MOD_SEL)) return false;
    if (el.closest(EXCLUDE_ANCESTOR)) return false;
    if (el.classList.contains('tsr-handle')) return false;
    var r = el.getBoundingClientRect();
    if (r.width < 40 || r.height < 24) return false;       // ignore slivers
    return true;
  }
  // top-level modules only (a module not nested inside another module)
  function topModules() {
    var out = [], seen = new Set();
    containers().forEach(function (root) {
      Array.prototype.slice.call(root.querySelectorAll(MOD_SEL)).forEach(function (el) {
        if (seen.has(el) || !isModule(el)) return;
        var p = el.parentElement, nested = false;
        while (p && p !== root) {
          if (p.matches && p.matches(MOD_SEL) && !p.closest(EXCLUDE_ANCESTOR)) { nested = true; break; }
          p = p.parentElement;
        }
        if (nested) return;
        seen.add(el); out.push(el);
      });
    });
    return out;
  }

  // ---------- stable keys ----------
  function rawKey(el) {
    if (el.id) return '#' + el.id;
    var c = (el.getAttribute && el.getAttribute('class')) || '';
    c = String(c).replace(/\btsr-[\w-]+/g, '').trim().split(/\s+/).filter(Boolean).sort().join('.');
    return (c ? '.' + c : el.tagName.toLowerCase());
  }
  function dedupeKeys(els) {
    var seen = {}, out = [];
    for (var i = 0; i < els.length; i++) {
      var k = rawKey(els[i]); seen[k] = (seen[k] || 0) + 1;
      out.push(seen[k] > 1 ? k + '~' + seen[k] : k);
    }
    return out;
  }
  function modKey(el) {
    var p = el.parentElement;
    var sibs = p ? Array.prototype.slice.call(p.children).filter(function (x) { return x.matches && x.matches(MOD_SEL); }) : [el];
    var idx = sibs.indexOf(el);
    var keys = dedupeKeys(sibs);
    return rawKey(p || document.body) + '>' + (keys[idx] || rawKey(el));
  }

  // ---------- zones (parents with >=2 top-level modules) for reorder ----------
  function zones() {
    var byParent = new Map();
    topModules().forEach(function (m) { var p = m.parentElement; if (!p) return; if (!byParent.has(p)) byParent.set(p, 0); byParent.set(p, byParent.get(p) + 1); });
    var z = []; byParent.forEach(function (n, p) { if (n >= 2) z.push(p); });
    return z;
  }
  function zoneKey(zone, i) { return rawKey(zone) + '#z' + i; }

  // ---------- apply saved layout ----------
  function applySaved() {
    var L = loadLayout();
    if (L.order) {
      zones().forEach(function (zone, i) {
        var order = L.order[zoneKey(zone, i)];
        if (!order || !order.length) return;
        var kids = Array.prototype.slice.call(zone.children).filter(function (x) { return x.matches && x.matches(MOD_SEL); });
        var keys = dedupeKeys(kids);
        var paired = kids.map(function (el, j) { return { el: el, k: keys[j] }; });
        paired.sort(function (a, b) {
          var ia = order.indexOf(a.k), ib = order.indexOf(b.k);
          if (ia === -1 && ib === -1) return 0; if (ia === -1) return 1; if (ib === -1) return -1; return ia - ib;
        });
        paired.forEach(function (o) { zone.appendChild(o.el); });
      });
    }
    if (L.off) {
      topModules().forEach(function (m) {
        var o = L.off[modKey(m)];
        if (o) { m.style.transform = 'translate(' + o.x + 'px,' + o.y + 'px)'; m.dataset.tsrOff = o.x + ',' + o.y; }
      });
    }
  }

  // ---------- handles + drag (pointer events: mouse + touch) ----------
  function ensureHandle(m) {
    if (m.querySelector(':scope > .tsr-handle')) return;
    if (getComputedStyle(m).position === 'static') m.style.position = 'relative';
    m.classList.add('tsr-sec');
    var h = document.createElement('div');
    h.className = 'tsr-handle'; h.title = 'Drag to move or reorder'; h.innerHTML = '⠿';
    m.appendChild(h);
    wireDrag(h, m);
  }

  var drag = null, hintEl = null;
  function wireDrag(handle, m) {
    handle.addEventListener('pointerdown', function (e) {
      if (!editing) return;
      e.preventDefault(); e.stopPropagation();
      var off = (m.dataset.tsrOff || '0,0').split(',').map(Number);
      drag = { m: m, sx: e.clientX, sy: e.clientY, bx: off[0] || 0, by: off[1] || 0, moved: false };
      m.classList.add(DRAG_CLASS);
      try { handle.setPointerCapture(e.pointerId); } catch (x) {}
    });
    handle.addEventListener('pointermove', function (e) {
      if (!drag) return;
      var dx = e.clientX - drag.sx, dy = e.clientY - drag.sy;
      if (!drag.moved && Math.abs(dx) + Math.abs(dy) < 4) return;
      drag.moved = true;
      drag.m.style.transform = 'translate(' + (drag.bx + dx) + 'px,' + (drag.by + dy) + 'px)';
      reorderHint(e.clientX, e.clientY);
    });
    function end(e) {
      if (!drag) return;
      var m2 = drag.m; m2.classList.remove(DRAG_CLASS); clearHint();
      if (drag.moved) {
        if (tryReorder(e.clientX, e.clientY, m2)) {
          m2.style.transform = ''; delete m2.dataset.tsrOff;   // reorder wins → drop free offset
          stageOrder();
        } else {
          var nx = drag.bx + (e.clientX - drag.sx), ny = drag.by + (e.clientY - drag.sy);
          m2.dataset.tsrOff = nx + ',' + ny;
          m2.style.transform = 'translate(' + nx + 'px,' + ny + 'px)';
          stageOffsets();
        }
        toast('Layout changed — press 💾 Save to keep it');
      }
      drag = null;
    }
    handle.addEventListener('pointerup', end);
    handle.addEventListener('pointercancel', end);
  }
  function reorderHint(x, y) {
    clearHint();
    var t = moduleUnder(x, y, drag.m); if (!t) return;
    var r = t.getBoundingClientRect(), before = y < r.top + r.height / 2;
    hintEl = document.createElement('div'); hintEl.className = 'tsr-hint';
    document.body.appendChild(hintEl);
    hintEl.style.left = r.left + 'px'; hintEl.style.width = r.width + 'px';
    hintEl.style.top = (before ? r.top - 2 : r.bottom) + 'px';
  }
  function clearHint() { if (hintEl) { hintEl.remove(); hintEl = null; } }
  function moduleUnder(x, y, exclude) {
    // The dragged module follows the pointer (transform) and sits ON TOP of the
    // drop target — so hide it from hit-testing, else elementsFromPoint returns
    // the dragged element / its background and misses a short target beneath it.
    var prevPE = exclude && exclude.style.pointerEvents;
    if (exclude) exclude.style.pointerEvents = 'none';
    var els = document.elementsFromPoint(x, y);
    if (exclude) exclude.style.pointerEvents = prevPE || '';
    for (var i = 0; i < els.length; i++) {
      var el = els[i];
      if (el === exclude || el.classList.contains('tsr-handle')) continue;
      var mod = el.closest && el.closest(MOD_SEL);
      if (mod && mod !== exclude && isModule(mod) && !mod.contains(exclude) && !exclude.contains(mod)) return mod;
    }
    return null;
  }
  function tryReorder(x, y, m) {
    var t = moduleUnder(x, y, m);
    if (!t || !t.parentElement) return false;
    var r = t.getBoundingClientRect();
    if (y < r.top + r.height / 2) t.parentElement.insertBefore(m, t);
    else t.parentElement.insertBefore(m, t.nextSibling);
    return true;
  }

  // ---------- stage live DOM → pending; commit on 💾 ----------
  function stageOrder() {
    var o = {}; zones().forEach(function (zone, i) {
      var kids = Array.prototype.slice.call(zone.children).filter(function (x) { return x.matches && x.matches(MOD_SEL); });
      o[zoneKey(zone, i)] = dedupeKeys(kids);
    });
    pending.order = o;
  }
  function stageOffsets() {
    var o = {}; topModules().forEach(function (m) {
      if (m.dataset.tsrOff) { var p = m.dataset.tsrOff.split(',').map(Number); o[modKey(m)] = { x: p[0], y: p[1] }; }
    });
    pending.off = o;
  }
  function commitSave() {
    stageOrder(); stageOffsets();
    var L = loadLayout();
    L.order = pending.order; L.off = pending.off;
    saveLayout(L);
    toast('✓ Layout saved for this theme');
  }
  function resetLayout() {
    try { localStorage.removeItem(LKEY); } catch (e) {}
    toast('Layout reset'); setTimeout(function () { location.reload(); }, 400);
  }

  // ---------- render a new room ----------
  var roomBusy = false;
  function nextRoomType() {
    var used = loadRooms().map(function (r) { return r.room; });
    for (var i = 0; i < ROOM_TYPES.length; i++) if (used.indexOf(ROOM_TYPES[i]) === -1) return ROOM_TYPES[i];
    return ROOM_TYPES[loadRooms().length % ROOM_TYPES.length];
  }
  function roomAnchor() {
    var sel = '.room, .room-main, .roomprev, .room-toggle, [class*="room"]';
    var cands = [];
    containers().forEach(function (root) { cands = cands.concat(Array.prototype.slice.call(root.querySelectorAll(sel))); });
    cands = cands.filter(function (e) { return !e.closest(EXCLUDE_ANCESTOR); });
    return cands.length ? cands[0] : (containers()[0] || document.body);
  }
  function resultsBox() {
    var box = document.getElementById('tsr-room-results');
    if (box) return box;
    box = document.createElement('div');
    box.id = 'tsr-room-results'; box.className = 'tsr-room-results';
    box.innerHTML = '<div class="tsr-rr-h">Your rendered rooms</div><div class="tsr-rr-grid"></div>';
    var anchor = roomAnchor();
    if (anchor.parentNode) anchor.parentNode.insertBefore(box, anchor.nextSibling);
    else document.body.appendChild(box);
    return box;
  }
  function addRoomImg(url, room, persist) {
    var grid = resultsBox().querySelector('.tsr-rr-grid');
    var fig = document.createElement('figure');
    fig.className = 'tsr-rr-item';
    fig.innerHTML = '<img loading="lazy" src="' + url + (url.indexOf('?') < 0 ? '?t=' + Date.now() : '') + '" alt="Rendered room">' +
      '<figcaption>' + String(room || 'room').replace(/_/g, ' ') + '</figcaption>';
    grid.appendChild(fig);
    if (persist) { var a = loadRooms(); a.push({ url: url, room: room }); saveRooms(a); }
  }
  function renderNewRoom() {
    if (roomBusy) return;
    if (!DESIGN_ID) { toast('Could not detect the design id', true); return; }
    roomBusy = true;
    var room = nextRoomType();
    toast('🛋 Rendering your ' + room.replace(/_/g, ' ') + '… (10–30s)');
    fetch('/api/room', {
      method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin',
      body: JSON.stringify({ design_id: DESIGN_ID, roomType: room, angle: 'straight_on', cameraDistance: 5 }),
    }).then(function (r) { return r.json().then(function (j) { return { status: r.status, j: j }; }); })
      .then(function (res) {
        roomBusy = false;
        var j = res.j || {};
        if (res.status === 401 || j.error === 'sign_in_required') {
          toast('Sign in to render a room', true, j.sign_in_url || '/trade/login?return=' + encodeURIComponent(location.pathname));
          return;
        }
        if (j.ok && j.image_url) {
          addRoomImg(j.image_url, room, true);
          toast('✓ Room ready');
          resultsBox().scrollIntoView({ behavior: 'smooth', block: 'center' });
        } else {
          toast('Room render failed: ' + (j.error || 'try again'), true);
        }
      }).catch(function (e) { roomBusy = false; toast('Room render error: ' + e.message, true); });
  }
  function restoreRooms() { loadRooms().forEach(function (r) { addRoomImg(r.url, r.room, false); }); }

  // ---------- UI: dock + toasts ----------
  function toast(msg, isErr, link) {
    var t = document.createElement('div');
    t.className = 'tsr-toast' + (isErr ? ' err' : '');
    t.innerHTML = msg + (link ? ' <a href="' + link + '">Sign in →</a>' : '');
    document.body.appendChild(t);
    requestAnimationFrame(function () { t.classList.add('show'); });
    setTimeout(function () { t.classList.remove('show'); setTimeout(function () { t.remove(); }, 300); }, link ? 6000 : 3000);
  }
  function buildDock() {
    if (document.getElementById('tsr-dock')) return;
    var d = document.createElement('div');
    d.id = 'tsr-dock'; d.className = 'tsr-dock';
    d.innerHTML =
      '<button class="tsr-room" title="Render this design in a new room">🛋 <span>Render a room</span></button>' +
      '<button class="tsr-edit" title="Move &amp; rearrange the sections of this page">✎ <span>Customize</span></button>' +
      '<span class="tsr-edit-tools" hidden>' +
        '<button class="tsr-save" title="Save this layout">💾 Save</button>' +
        '<button class="tsr-reset" title="Reset this theme\'s layout">↺ Reset</button>' +
        '<button class="tsr-done" title="Exit edit mode">✓ Done</button>' +
      '</span>';
    document.body.appendChild(d);
    d.querySelector('.tsr-room').addEventListener('click', renderNewRoom);
    d.querySelector('.tsr-edit').addEventListener('click', function () { setEditing(true); });
    d.querySelector('.tsr-done').addEventListener('click', function () { setEditing(false); });
    d.querySelector('.tsr-save').addEventListener('click', commitSave);
    d.querySelector('.tsr-reset').addEventListener('click', resetLayout);
  }
  function setEditing(on) {
    editing = on;
    document.body.classList.toggle('tsr-editing', on);
    var d = document.getElementById('tsr-dock');
    if (d) { d.querySelector('.tsr-edit').hidden = on; d.querySelector('.tsr-edit-tools').hidden = !on; }
    if (on) { topModules().forEach(ensureHandle); toast('Edit mode — drag ⠿ to move sections anywhere, drop on another to reorder'); }
  }
  function styles() {
    if (document.getElementById('tsr-css')) return;
    var css = document.createElement('style'); css.id = 'tsr-css';
    css.textContent = [
      '.tsr-handle{position:absolute;top:6px;left:6px;z-index:60;width:24px;height:24px;display:none;align-items:center;justify-content:center;',
        'font-size:14px;line-height:1;color:#fff;background:rgba(20,20,20,.62);border:1px solid rgba(255,255,255,.3);border-radius:6px;cursor:grab;',
        'touch-action:none;user-select:none}',
      'body.tsr-editing .tsr-handle{display:flex}',
      'body.tsr-editing .tsr-sec{outline:1px dashed rgba(120,120,120,.35);outline-offset:3px}',
      'body.tsr-editing .tsr-sec:hover{outline-color:rgba(63,208,154,.7)}',
      '.tsr-handle:hover{background:rgba(20,20,20,.85)}.tsr-handle:active{cursor:grabbing}',
      '.' + DRAG_CLASS + '{opacity:.7;z-index:9990!important;outline:2px solid rgba(63,208,154,.95)!important;box-shadow:0 12px 36px rgba(0,0,0,.35)}',
      '.tsr-hint{position:fixed;height:3px;background:#3fd09a;z-index:9991;border-radius:2px;pointer-events:none;box-shadow:0 0 8px rgba(63,208,154,.8)}',
      '.tsr-dock{position:fixed;left:16px;bottom:16px;z-index:99996;display:flex;align-items:center;gap:6px;',
        'background:rgba(12,12,12,.86);backdrop-filter:blur(10px);border:1px solid rgba(245,241,232,.18);border-radius:999px;',
        'padding:6px 8px;font:600 12px/1 -apple-system,system-ui,sans-serif;color:#f5f1e8;box-shadow:0 8px 26px rgba(0,0,0,.45)}',
      '.tsr-dock button{background:transparent;border:0;color:#f5f1e8;cursor:pointer;font:inherit;padding:7px 11px;border-radius:999px;',
        'display:inline-flex;align-items:center;gap:5px;white-space:nowrap;transition:background .15s}',
      '.tsr-dock button:hover{background:rgba(245,241,232,.16)}',
      '.tsr-dock .tsr-room{background:rgba(63,208,154,.16)}',
      '.tsr-dock .tsr-edit-tools{display:inline-flex;align-items:center;gap:4px}',
      '.tsr-dock .tsr-done{background:rgba(63,208,154,.22)}',
      '@media(max-width:560px){.tsr-dock button span{display:none}}',
      '.tsr-toast{position:fixed;left:50%;bottom:74px;transform:translate(-50%,12px);z-index:99997;max-width:88vw;',
        'background:rgba(18,18,18,.94);color:#f5f1e8;border:1px solid rgba(245,241,232,.18);border-radius:10px;padding:10px 16px;',
        'font:500 13px/1.35 -apple-system,system-ui,sans-serif;box-shadow:0 10px 30px rgba(0,0,0,.5);opacity:0;transition:all .28s}',
      '.tsr-toast.show{opacity:1;transform:translate(-50%,0)}',
      '.tsr-toast.err{background:#5a1f1f;border-color:#a33}',
      '.tsr-toast a{color:#7fe3bd;font-weight:700;text-decoration:none;margin-left:4px}',
      '.tsr-room-results{margin:18px 0;padding:0}',
      '.tsr-room-results .tsr-rr-h{font:600 13px/1 -apple-system,system-ui,sans-serif;letter-spacing:.04em;text-transform:uppercase;opacity:.7;margin:0 0 10px}',
      '.tsr-rr-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px}',
      '.tsr-rr-item{margin:0;border-radius:10px;overflow:hidden;background:rgba(0,0,0,.05);border:1px solid rgba(0,0,0,.08)}',
      '.tsr-rr-item img{display:block;width:100%;height:auto}',
      '.tsr-rr-item figcaption{font:12px/1 -apple-system,system-ui,sans-serif;padding:7px 10px;opacity:.7;text-transform:capitalize}',
    ].join('');
    document.head.appendChild(css);
  }

  // ---------- lifecycle ----------
  function setup() {
    if (applying) return; applying = true;
    if (observer) observer.disconnect();
    try {
      applySaved();
      if (editing) topModules().forEach(ensureHandle);
    } finally {
      applying = false;
      if (observer) observer.observe(containers()[0] || document.body, { childList: true, subtree: true });
    }
  }
  function start() {
    styles(); buildDock(); restoreRooms(); applySaved();
    observer = new MutationObserver(function () { if (!applying) { clearTimeout(window.__tsrT); window.__tsrT = setTimeout(setup, 140); } });
    observer.observe(containers()[0] || document.body, { childList: true, subtree: true });
  }
  function waitForRender() {
    var root = document.getElementById('root');
    if (!root) return start();  // monolith is server-rendered
    function ready() { return root.children.length > 0 && !/Loading design/i.test(document.body.innerText || ''); }
    if (ready()) return start();
    var mo = new MutationObserver(function () { if (ready()) { mo.disconnect(); start(); } });
    mo.observe(root, { childList: true, subtree: true });
    setTimeout(function () { try { mo.disconnect(); } catch (e) {} start(); }, 4000);
  }

  if (document.body) waitForRender();
  else document.addEventListener('DOMContentLoaded', waitForRender);
})();