// App.jsx — top-level wholesale tool, wired to the Express backend.

const DRAFTS_KEY = "sb_wholesale_drafts_v1";
const ORDER_KEY  = "sb_wholesale_order_v2";

function loadDrafts() {
  try { return JSON.parse(localStorage.getItem(DRAFTS_KEY) || "[]"); } catch { return []; }
}
function saveDrafts(d) {
  try { localStorage.setItem(DRAFTS_KEY, JSON.stringify(d)); } catch {}
}
function loadOrderItems() {
  try { return JSON.parse(localStorage.getItem(ORDER_KEY) || "{}"); } catch { return {}; }
}
function saveOrderItems(o) {
  try { localStorage.setItem(ORDER_KEY, JSON.stringify(o)); } catch {}
}

const DEFAULT_META = {
  season: "FW·26",
  title: "the living archive",
  deliveryWindow: "AUG · SEPT 2026",
  bookingWindow: "BOOKING · APR–MAY 26",
  advancePercent: "30",
  currency: "$",
  terms: "NET 30",
  incoterm: "EXW KYOTO",
  // Landing page (gate) copy + cover image — all editable from admin › settings.
  gateImage: "",
  gateHeroA: "a quiet body.",
  gateHeroB: "cut from concrete light.",
  gateLocation: "ATELIER · KYOTO",
  gateLabel: "ENTER · WHOLESALE",
  gateHeading: "private viewing.\nby passcode.",
  gateIntro: "this room holds the FW·26 collection. enter your retailer passcode below. orders open 09:00 jst, close 17:00 jst, monday through thursday.",
  gateButton: "enter the room",
  // Cover-page atelier letter — editable from admin › settings.
  coverBg: null,
  letterHeading: "A NOTE FROM THE ATELIER",
  letterBody: "this season we returned to the threshold — the engawa, the seam between inside and outside. the collection is built around four enclosures and one undoing.\n\nthe silhouettes are wider, the cloth softer, the closures fewer. we have moved further from the body. we ask the cloth to hold itself.\n\neverything is made slowly, in kyoto, porto, and florence. the line is small; runs are limited. we appreciate your patience and your eye.",
  letterSignature: "— the studio",
};

function App() {
  // Inline edit mode (admin opens the storefront with ?edit=1). When on, data is
  // loaded through the admin API (cached basic-auth) and edits save live.
  const EDIT = window.SB_EDIT;
  const [editStatus, setEditStatus] = React.useState("");
  // Which device the editor is tuning (image transforms are stored per-device),
  // and a clean-preview toggle that hides all editor UI.
  const [device, setDevice] = React.useState("desktop"); // "desktop" | "mobile"
  const [preview, setPreview] = React.useState(false);
  const [seqMode, setSeqMode] = React.useState(false);   // lookbook sequence board

  const [retailer, setRetailer] = React.useState(null);
  const [bootChecked, setBootChecked] = React.useState(false);

  const [collection, setCollection] = React.useState([]);
  const [looks, setLooks] = React.useState([]);
  // Lookbook editor state (edit mode only): which look's inspector is open, and
  // whether the draft has unpublished changes vs the live storefront.
  const [selectedLookId, setSelectedLookId] = React.useState(null);
  const [looksDirty, setLooksDirty] = React.useState(false);
  const [meta, setMeta] = React.useState(DEFAULT_META);
  const [dataLoading, setDataLoading] = React.useState(false);
  const [dataError, setDataError] = React.useState(null);

  const [view, setView] = React.useState("lookbook"); // lookbook is the landing page; Cover stays reachable via the wordmark
  // the sequence board only makes sense on the lookbook, outside clean preview
  React.useEffect(() => { if (view !== "lookbook" || preview) setSeqMode(false); }, [view, preview]);
  // Previous view (read during render, updated after commit) — lets the collection
  // filter pill know when it's arriving from the lookbook so it can morph in.
  const prevViewRef = React.useRef("lookbook");
  React.useEffect(() => { prevViewRef.current = view; }, [view]);
  const [collectionLayout, setCollectionLayout] = React.useState("grid"); // "grid" | "line"
  const [orderItems, setOrderItems] = React.useState(loadOrderItems());
  const [pdpProduct, setPdpProduct] = React.useState(null);
  const [activeConcept, setActiveConcept] = React.useState(null);
  const [lookPage, setLookPage] = React.useState(null);   // { lookId, frame } for the full-screen look page
  const [filters, setFilters] = React.useState({ category: "ALL", fabric: "ALL", priceMin: null, priceMax: null });
  const [drafts, setDrafts] = React.useState(loadDrafts());
  const [draftsOpen, setDraftsOpen] = React.useState(false);
  const [panelCollapsed, setPanelCollapsed] = React.useState(true);
  const [headerCollapsed, setHeaderCollapsed] = React.useState(false); // main header shrinks on scroll-down
  const [headerAtTop, setHeaderAtTop] = React.useState(true);          // at the very top → transparent over the hero
  const [toast, setToast] = React.useState(null);
  const [submitting, setSubmitting] = React.useState(false);

  React.useEffect(() => { saveOrderItems(orderItems); }, [orderItems]);
  React.useEffect(() => { saveDrafts(drafts); }, [drafts]);

  // Website name (browser-tab title) is a setting; the HTML <title> is only
  // the pre-boot fallback. Applies on the gate too — meta loads before login.
  React.useEffect(() => {
    if (meta.siteName && String(meta.siteName).trim()) document.title = String(meta.siteName).trim();
  }, [meta.siteName]);
  // Favicon is a setting too (admin › settings — square png/svg upload).
  React.useEffect(() => {
    if (!meta.favicon) return;
    let link = document.querySelector('link[rel="icon"]');
    if (!link) { link = document.createElement("link"); link.rel = "icon"; document.head.appendChild(link); }
    link.href = meta.favicon;
  }, [meta.favicon]);

  // ── Client routing: every page + product gets its own URL ──────────────────
  const sbSlug = (s) => String(s || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
  const collectionRef = React.useRef(collection);
  const metaRef = React.useRef(meta);
  React.useEffect(() => { collectionRef.current = collection; }, [collection]);
  React.useEffect(() => { metaRef.current = meta; }, [meta]);
  const routeReadyRef = React.useRef(false);
  // canonical URL for the current UI state
  const pathForState = () => {
    if (pdpProduct) return "/product/" + pdpProduct.id;
    if (lookPage) return "/look/" + lookPage.lookId + (lookPage.frame ? "/" + lookPage.frame : "");
    if (view === "cover") return "/cover";
    if (view === "gate") return "/gate";
    if (view === "grid") return collectionLayout === "line" ? "/collection/line" : "/collection";
    if (view === "concepts") return "/concepts";
    if (view === "concept") return "/concepts/" + sbSlug(activeConcept);
    if (view === "summary") return "/order";
    return "/lookbook";
  };
  // apply a URL path to the UI (initial restore + back/forward)
  const applyPath = React.useCallback((path) => {
    const [a = "", b = "", c = ""] = String(path || "/").replace(/^\/+|\/+$/g, "").split("/");
    setPdpProduct(null); setLookPage(null);
    if (a === "cover") setView("cover");
    else if (a === "gate") setView(EDIT ? "gate" : "lookbook");   // edit-only surface; retailers meet the real gate pre-login
    else if (a === "collection") { setView("grid"); setCollectionLayout(b === "line" ? "line" : "grid"); }
    else if (a === "concepts") {
      const cn = b && (metaRef.current.series || []).find((s) => sbSlug(s.name) === b);
      if (cn) { setActiveConcept(cn.name); setView("concept"); } else setView("concepts");
    }
    else if (a === "order") setView("summary");
    else if (a === "product") { setView("grid"); const p = collectionRef.current.find((x) => x.id === b); if (p) setPdpProduct(p); }
    else if (a === "look") { setView("lookbook"); const id = Number(b); if (id) setLookPage({ lookId: id, frame: Number(c) || 0 }); }
    else setView("lookbook");
  }, []);
  // restore state from the URL once the retailer + collection are ready
  React.useEffect(() => {
    if (routeReadyRef.current || !retailer || !collection.length) return;
    routeReadyRef.current = true;
    applyPath(window.location.pathname);
  }, [retailer, collection.length, applyPath]);
  // push the URL when the page/product changes. Skips when it already matches —
  // which is exactly the case after a back/forward or the initial restore, so no loop.
  // Browsing WITHIN the floating look page (frame hop, "other looks" tile) REPLACES
  // instead of pushing: the whole look-page session stays ONE history entry, so a
  // single back/✕ always leaves it. Stacked entries used to make close() pop back
  // onto another /look URL — the page re-mounted mid-close and its invisible scrim
  // swallowed every click ("stuck" navigation).
  const prevOverlayRef = React.useRef({ pdp: null, look: null });
  React.useEffect(() => {
    if (!routeReadyRef.current) return;
    const prev = prevOverlayRef.current;
    prevOverlayRef.current = { pdp: pdpProduct, look: lookPage };
    const desired = pathForState(), cur = window.location.pathname;
    if (cur === desired || (desired === "/lookbook" && cur === "/")) return;
    const withinLook = !!(lookPage && prev.look && !pdpProduct && !prev.pdp);
    if (withinLook) window.history.replaceState({ overlay: true }, "", desired);
    else window.history.pushState({ overlay: !!(pdpProduct || lookPage) }, "", desired);
  }, [view, collectionLayout, activeConcept, pdpProduct, lookPage]);
  React.useEffect(() => {
    const onPop = () => applyPath(window.location.pathname);
    window.addEventListener("popstate", onPop);
    return () => window.removeEventListener("popstate", onPop);
  }, [applyPath]);
  // close a product/look overlay the way the back button would, so its URL entry
  // is popped (and a hard-refreshed deep link still closes cleanly).
  const closeOverlay = React.useCallback(() => {
    if (window.history.state && window.history.state.overlay) window.history.back();
    else { setPdpProduct(null); setLookPage(null); }
  }, []);

  // Every page shares ONE window scroll, so switching surfaces used to leave
  // you stranded mid-page (deep in the lookbook → open the collection → it
  // "spawns" in the middle). Per-view scroll memory fixes both directions:
  // each view remembers its own position; a first visit starts at the top;
  // closing a product page puts you back where the view was.
  // (Layout effect: runs before paint AND before PDP's own scroll-to-top.)
  const scrollMemRef = React.useRef({});                 // view -> scrollY
  const prevNavRef = React.useRef({ view: "lookbook", pdp: null });
  // we place the scroll ourselves — the browser's auto-restore fires before the
  // SPA has re-rendered the target view and clamps to 0
  React.useEffect(() => { try { window.history.scrollRestoration = "manual"; } catch (e) {} }, []);
  React.useLayoutEffect(() => {
    const prev = prevNavRef.current;
    prevNavRef.current = { view, pdp: pdpProduct };
    if (prev.view === view && prev.pdp === pdpProduct) return;
    // remember where the OLD surface was (only if a view owned the scroll)
    if (!prev.pdp) scrollMemRef.current[prev.view] = window.scrollY;
    // entering a product page: PDP scrolls itself to the top
    if (pdpProduct) return;
    // arriving on a view (from another view or closing a product page):
    // back to ITS remembered place — 0 on a first visit
    window.scrollTo(0, scrollMemRef.current[view] || 0);
  }, [view, pdpProduct]);

  // Escape = back, everywhere. Overlays (product page, look page, lightboxes)
  // run their own Escape handlers, so this only steps the page hierarchy:
  // concept → concepts, order → collection, collection/concepts → lookbook.
  React.useEffect(() => {
    const onKey = (e) => {
      if (e.key !== "Escape" || e.defaultPrevented) return;
      const t = e.target;
      if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT" || t.isContentEditable)) return;
      if (pdpProduct || lookPage) return;
      if (draftsOpen) { setDraftsOpen(false); return; }
      if (view === "concept") setView("concepts");
      else if (view === "summary") setView("grid");
      else if (view === "grid" || view === "concepts" || view === "gate") setView("lookbook");
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [view, pdpProduct, lookPage, draftsOpen]);

  // Feed the (expanded) header height into --hdr-h so the hero lookbook can pull
  // its first image up to sit exactly under the transparent header. A callback ref
  // measures the moment the header actually mounts (after the data loads).
  const headerRef = React.useRef(null);
  const measureHeader = React.useCallback(() => {
    const el = headerRef.current;
    if (el && !el.classList.contains("is-collapsed")) document.documentElement.style.setProperty("--hdr-h", el.offsetHeight + "px");
  }, []);
  const setHeaderRef = React.useCallback((el) => { headerRef.current = el; if (el && !el.classList.contains("is-collapsed")) document.documentElement.style.setProperty("--hdr-h", el.offsetHeight + "px"); }, []);
  React.useEffect(() => { window.addEventListener("resize", measureHeader); return () => window.removeEventListener("resize", measureHeader); }, [measureHeader]);

  // Collapse the main header when scrolling down, restore it scrolling up (shared
  // by every view). The frosted bar then lets the content/image blur through.
  React.useEffect(() => {
    let lastY = window.scrollY, raf = 0;
    const onScroll = () => {
      if (raf) return;
      raf = requestAnimationFrame(() => {
        raf = 0;
        const y = window.scrollY, dy = y - lastY;
        setHeaderAtTop(y < 8);
        // Short pages (e.g. a near-empty order review) can't absorb the header's
        // height change: collapsing shifts the content, the scroll position
        // bounces, the header re-expands — a visible jitter loop. Only collapse
        // when there's real scroll room beyond that height delta.
        const room = document.documentElement.scrollHeight - window.innerHeight;
        if (y < 24 || room < 160) setHeaderCollapsed(false);
        else if (dy > 4) setHeaderCollapsed(true);
        else if (dy < -4) setHeaderCollapsed(false);
        lastY = y;
      });
    };
    window.addEventListener("scroll", onScroll, { passive: true });
    return () => { window.removeEventListener("scroll", onScroll); if (raf) cancelAnimationFrame(raf); };
  }, []);

  // Boot: pick up an existing session (if any) and load public meta. In edit
  // mode we skip the passcode gate — a successful admin call (cached basic-auth)
  // stands in for a retailer session so the admin can browse + edit the store.
  React.useEffect(() => {
    (async () => {
      try {
        const m = await window.api.get("/api/meta");
        if (m && Object.keys(m).length) setMeta({ ...DEFAULT_META, ...m });
        if (EDIT) {
          await window.sbAdmin.patch("/api/admin/settings", {}); // verifies admin auth (no-op write)
          setRetailer({ id: 0, name: "ADMIN", customName: "ADMIN · EDIT" });
        } else {
          const me = await window.api.get("/api/me");
          if (me?.retailer) setRetailer(me.retailer);
        }
      } catch (err) {
        console.warn("boot fetch failed:", err.message);
        if (EDIT) setDataError("admin sign-in required for edit mode — open /admin and enter the password, then reload this page.");
      } finally {
        setBootChecked(true);
      }
    })();
  }, []);

  // Once a retailer is set, load the collection + looks.
  React.useEffect(() => {
    if (!retailer) return;
    setDataLoading(true);
    setDataError(null);
    Promise.all([
      window.api.get(EDIT ? "/api/admin/skus" : "/api/collection"),
      // In edit mode the lookbook is the private draft (incl. hidden looks);
      // retailers get the published, hidden-filtered list.
      window.api.get(EDIT ? "/api/admin/looks/draft" : "/api/looks"),
      window.api.get("/api/meta"),
    ]).then(([c, l, m]) => {
      setCollection(c);
      if (EDIT) { setLooks(l.looks || []); setLooksDirty(!!l.dirty); }
      else setLooks(l);
      if (m && Object.keys(m).length) setMeta({ ...DEFAULT_META, ...m });
    }).catch((err) => {
      setDataError(err.message);
    }).finally(() => setDataLoading(false));
  }, [retailer]);

  const info = {
    season: meta.season,
    title: meta.title,
    dtc: !!(retailer && retailer.role === "dtc"),   // DTC session: RRP-only wording everywhere
    discountName: (retailer && retailer.discountName) || null,
    discountPercent: (retailer && Number(retailer.discount)) || 0,
    advancePercent: meta.advancePercent,
    retailer: retailer ? (retailer.customName || retailer.name) : "GUEST",
    deliveryWindow: meta.deliveryWindow,
    bookingWindow: meta.bookingWindow,
    minimum: meta.minimum,
    currency: meta.currency,
    terms: meta.terms,
    incoterm: meta.incoterm,
    categories: meta.categories,
    fabrics: meta.fabrics,
    letterHeading: meta.letterHeading,
    letterBody: meta.letterBody,
    letterSignature: meta.letterSignature,
  };

  const showToast = (msg) => {
    setToast(msg);
    setTimeout(() => setToast(null), 2200);
  };

  const setOrderQty = (product, size, n) => {
    setOrderItems(prev => {
      const next = { ...prev };
      const item = next[product.id] || { qtys: {}, productSnapshot: { name: product.name, idx: product.idx, ws: product.ws } };
      item.qtys = { ...item.qtys, [size]: n };
      Object.keys(item.qtys).forEach(k => { if (!item.qtys[k]) delete item.qtys[k]; });
      const totalUnits = Object.values(item.qtys).reduce((a, b) => a + (b || 0), 0);
      if (totalUnits === 0) delete next[product.id];
      else next[product.id] = item;
      return next;
    });
  };

  const clearLine = (id) => {
    setOrderItems(prev => {
      const next = { ...prev };
      delete next[id];
      return next;
    });
    showToast("removed from order");
  };

  const saveCurrentDraft = () => {
    const itemsArr = Object.entries(orderItems).map(([id, item]) => {
      const product = collection.find(p => p.id === id);
      if (!product) return null;
      const units = Object.values(item.qtys).reduce((a, b) => a + (b || 0), 0);
      return { units, line: units * product.ws };
    }).filter(Boolean);
    const units = itemsArr.reduce((s, x) => s + x.units, 0);
    const value = itemsArr.reduce((s, x) => s + x.line, 0);
    const draft = {
      id: "DRAFT-" + Date.now().toString(36).toUpperCase(),
      name: `${info.retailer.toLowerCase()} · ${info.season}`,
      savedAt: Date.now(),
      orderItems: JSON.parse(JSON.stringify(orderItems)),
      units, value,
    };
    setDrafts(prev => [draft, ...prev].slice(0, 12));
    showToast("draft saved");
  };

  const loadDraft = (draft) => {
    setOrderItems(draft.orderItems);
    setDraftsOpen(false);
    showToast("draft loaded");
  };
  const deleteDraft = (id) => setDrafts(prev => prev.filter(d => d.id !== id));

  const submitOrder = async () => {
    const items = Object.entries(orderItems).map(([skuId, item]) => ({ skuId, qtys: item.qtys }));
    if (!items.length) return;
    setSubmitting(true);
    try {
      const res = await window.api.post("/api/orders", { items });
      setOrderItems({});
      setView("cover");
      showToast(`submitted · ${res.order.code}`);
    } catch (err) {
      showToast(`submit failed: ${err.message}`);
    } finally {
      setSubmitting(false);
    }
  };

  const exit = async () => {
    try { await window.api.post("/api/auth/logout"); } catch {}
    setRetailer(null);
    setCollection([]);
    setLooks([]);
    setOrderItems({});
    setView("cover");
  };

  // ── Inline-edit save handlers (edit mode only) ──────────────────────
  // Each saves to the admin API and patches local state with the server's
  // shaped response, so the page reflects the change immediately.
  const flashSaved = () => { setEditStatus("saved ✓"); setTimeout(() => setEditStatus((s) => (s === "saved ✓" ? "" : s)), 1600); };
  const saveSku = async (id, patch) => {
    setEditStatus("saving…");
    try {
      const updated = await window.sbAdmin.patch("/api/admin/skus/" + id, patch);
      setCollection((cs) => cs.map((p) => (p.id === id ? updated : p)));
      setPdpProduct((p) => (p && p.id === id ? updated : p));
      flashSaved();
    } catch (e) { setEditStatus("save failed — " + e.message); }
  };
  const saveSetting = async (patch) => {
    setEditStatus("saving…");
    try { const m = await window.sbAdmin.patch("/api/admin/settings", patch); setMeta({ ...DEFAULT_META, ...m }); flashSaved(); }
    catch (e) { setEditStatus("save failed — " + e.message); }
  };
  const saveSeries = async (nextList) => {
    setEditStatus("saving…");
    try { const res = await window.sbAdmin.put("/api/admin/series", nextList); setMeta((mm) => ({ ...mm, series: res })); flashSaved(); }
    catch (e) { setEditStatus("save failed — " + e.message); }
  };
  // Patch one concept's editorial fields (description / images) by name. Renaming
  // stays in the admin Concepts tab, where it also migrates product references.
  const saveConcept = async (name, patch) => {
    const list = (meta.series || []).map((c) => (c && c.name === name ? { ...c, ...patch } : c));
    await saveSeries(list);
  };
  // ── Lookbook draft editing (edit mode) ──────────────────────────────
  // Undo stack: each mutating action pushes a thunk that reverses it. Cmd/Ctrl+Z
  // pops and runs the last one (handled by a key listener below).
  const undoStack = React.useRef([]);
  const [undoDepth, setUndoDepth] = React.useState(0);
  const pushUndo = (fn) => { undoStack.current.push(fn); setUndoDepth(undoStack.current.length); };
  const looksRef = React.useRef(looks);
  React.useEffect(() => { looksRef.current = looks; }, [looks]);

  // previewLook updates local state only (live canvas feedback while dragging a
  // handle); saveLook persists to the draft and reconciles. Pass {noUndo:true}
  // when applying an undo so we don't record the reversal itself.
  const previewLook = (id, patch) =>
    setLooks((ls) => ls.map((l) => (l.id === id ? { ...l, ...patch } : l)));
  const saveLook = async (id, patch, opts = {}) => {
    if (!opts.noUndo) {
      // For drag/slider commits the live look is already mutated by previewLook, so
      // callers pass opts.prev (the pre-interaction snapshot) as the true undo baseline.
      const cur = opts.prev || looksRef.current.find((l) => l.id === id);
      if (cur) { const prev = {}; for (const k of Object.keys(patch)) prev[k] = cur[k]; pushUndo(() => saveLook(id, prev, { noUndo: true })); }
    }
    setLooks((ls) => ls.map((l) => (l.id === id ? { ...l, ...patch } : l))); // optimistic
    setLooksDirty(true);
    setEditStatus("saving…");
    try {
      const u = await window.sbAdmin.patch("/api/admin/looks/draft/" + id, patch);
      setLooks((ls) => ls.map((l) => (l.id === id ? u : l)));
      flashSaved();
    } catch (e) { setEditStatus("save failed — " + e.message); }
  };
  const nextLookN = () => String(looksRef.current.reduce((m, l) => Math.max(m, parseInt(l.n, 10) || 0), 0) + 1).padStart(2, "0");
  const createLook = async (fields, opts = {}) => {
    setEditStatus("saving…");
    try {
      const body = fields || { n: nextLookN(), name: "new look", sortOrder: looksRef.current.length };
      const u = await window.sbAdmin.post("/api/admin/looks/draft", body);
      setLooks((ls) => [...ls, u]);
      setLooksDirty(true);
      setSelectedLookId(u.id);
      if (!opts.noUndo) pushUndo(() => deleteLook(u.id, { noUndo: true }));
      flashSaved();
      return u;
    } catch (e) { setEditStatus("save failed — " + e.message); }
  };
  const duplicateLook = async (id) => {
    const src = looksRef.current.find((l) => l.id === id);
    if (!src) return;
    await createLook({
      n: src.kind === "chapter" || src.kind === "canvas" ? "" : nextLookN(), name: (src.name || "look") + " copy", body: src.body || null,
      skuIds: src.skuIds || [], images: src.images || [], imageMeta: src.imageMeta || [],
      imgScale: src.imgScale, imgScale2: src.imgScale2, imgSplit: src.imgSplit, imgGap: src.imgGap,
      imgHeight: src.imgHeight, imgHeightMobile: src.imgHeightMobile, imgCols: src.imgCols,
      kind: src.kind, blockMeta: src.blockMeta || {}, hidden: src.hidden, sortOrder: looksRef.current.length,
    });
  };
  // A chapter is a look row with kind:'chapter' — it reuses every draft/publish/
  // reorder/undo path. n is blank (chapters aren't numbered like looks).
  const createChapter = async () =>
    createLook({ n: "", name: "chapter", body: "", kind: "chapter", images: [], imageMeta: [], blockMeta: {}, sortOrder: looksRef.current.length });
  const createCanvas = async () =>
    createLook({ n: "", name: "canvas", body: "", kind: "canvas", images: [], imageMeta: [], blockMeta: { els: [] }, sortOrder: looksRef.current.length });
  // "Add where I'm at": create the block (it lands at the end) then move it to sit
  // right after whatever block is currently selected (the scroll-followed one).
  const insertAfterSelected = async (newId, beforeIds) => {
    const idx = selectedLookId != null ? beforeIds.indexOf(selectedLookId) : -1;
    const ids = beforeIds.filter((id) => id !== newId);
    ids.splice(idx >= 0 ? idx + 1 : ids.length, 0, newId);
    await reorderLooks(ids);
  };
  const addLookHere = async () => {
    const before = looksRef.current.map((l) => l.id);
    const u = await createLook();
    if (u) await insertAfterSelected(u.id, before);
  };
  const addChapterHere = async () => {
    const before = looksRef.current.map((l) => l.id);
    const u = await createChapter();
    if (u) await insertAfterSelected(u.id, before);
  };
  const addCanvasHere = async () => {
    const before = looksRef.current.map((l) => l.id);
    const u = await createCanvas();
    if (u) await insertAfterSelected(u.id, before);
  };
  const deleteLook = async (id, opts = {}) => {
    const removed = looksRef.current.find((l) => l.id === id);
    setEditStatus("saving…");
    try {
      await window.sbAdmin.del("/api/admin/looks/draft/" + id);
      setLooks((ls) => ls.filter((l) => l.id !== id));
      setSelectedLookId((s) => (s === id ? null : s));
      setLooksDirty(true);
      if (!opts.noUndo && removed) {
        pushUndo(() => createLook({
          n: removed.n, name: removed.name, body: removed.body || null,
          skuIds: removed.skuIds || [], images: removed.images || [], imageMeta: removed.imageMeta || [],
          imgScale: removed.imgScale, imgScale2: removed.imgScale2, imgSplit: removed.imgSplit, imgGap: removed.imgGap,
          imgHeight: removed.imgHeight, imgHeightMobile: removed.imgHeightMobile, imgCols: removed.imgCols,
          kind: removed.kind, blockMeta: removed.blockMeta || {}, hidden: removed.hidden, sortOrder: removed.sortOrder,
        }, { noUndo: true }));
      }
      flashSaved();
    } catch (e) { setEditStatus("save failed — " + e.message); }
  };
  const reorderLooks = async (orderedIds, opts = {}) => {
    const prevOrder = looksRef.current.map((l) => l.id);
    setLooks((ls) => orderedIds.map((id) => ls.find((l) => l.id === id)).filter(Boolean)); // optimistic
    setLooksDirty(true);
    setEditStatus("saving…");
    try {
      const r = await window.sbAdmin.put("/api/admin/looks/draft/reorder", orderedIds);
      // the server renumbers looks by position (look 1 = first look) — sync
      if (r && Array.isArray(r.looks)) setLooks(r.looks);
      if (!opts.noUndo) pushUndo(() => reorderLooks(prevOrder, { noUndo: true }));
      flashSaved();
    } catch (e) { setEditStatus("save failed — " + e.message); }
  };
  const undoLast = () => {
    const fn = undoStack.current.pop();
    setUndoDepth(undoStack.current.length);
    if (fn) { setEditStatus("undoing…"); fn(); } else setEditStatus("nothing to undo");
  };

  // ── Collection (product) reorder (edit mode) ────────────────────────
  // Products aren't drafted — reordering saves live (like the other inline
  // product edits). previewCollection reorders locally for smooth dragging;
  // commitCollection persists and reloads (idx/catalogue numbers change).
  const previewCollection = (orderedIds) =>
    setCollection((cs) => orderedIds.map((id) => cs.find((p) => p.id === id)).filter(Boolean));
  const commitCollection = async (orderedIds) => {
    setEditStatus("saving…");
    try {
      await window.sbAdmin.put("/api/admin/skus/reorder", orderedIds);
      const fresh = await window.api.get("/api/admin/skus");
      setCollection(fresh);
      flashSaved();
    } catch (e) { setEditStatus("save failed — " + e.message); }
  };
  const publishLooks = async () => {
    setEditStatus("publishing…");
    try {
      const r = await window.sbAdmin.post("/api/admin/looks/publish", {});
      setLooksDirty(false);
      setEditStatus("published ✓");
      setTimeout(() => setEditStatus((s) => (s === "published ✓" ? "" : s)), 2000);
      return r;
    } catch (e) { setEditStatus("publish failed — " + e.message); }
  };
  const discardLooks = async () => {
    setEditStatus("discarding…");
    try {
      const r = await window.sbAdmin.post("/api/admin/looks/discard", {});
      setLooks(r.looks || []);
      setLooksDirty(false);
      setSelectedLookId(null);
      flashSaved();
    } catch (e) { setEditStatus("discard failed — " + e.message); }
  };
  // Switch the draft book to a saved version (lookbook versions — the draft is
  // replaced wholesale; live stays untouched until the next publish).
  const loadLookVersion = async (vid) => {
    setEditStatus("switching version…");
    try {
      const r = await window.sbAdmin.post("/api/admin/looks/versions/" + vid + "/load", {});
      setLooks(r.looks || []);
      setLooksDirty(true);
      setSelectedLookId(null);
      flashSaved();
    } catch (e) { setEditStatus("version load failed — " + e.message); }
  };
  const uploadEdit = async (file, onPick) => {
    setEditStatus("uploading…");
    try { const { path } = await window.sbAdmin.upload(file); await onPick(path); }
    catch (e) { setEditStatus("upload failed — " + e.message); }
  };
  // Cmd/Ctrl+Z → undo last lookbook edit. Skipped while typing in a field so
  // the browser's native text undo still works there.
  React.useEffect(() => {
    if (!EDIT) return;
    const onKey = (e) => {
      if ((e.metaKey || e.ctrlKey) && !e.shiftKey && (e.key === "z" || e.key === "Z")) {
        const el = document.activeElement;
        const tag = el && el.tagName;
        if (tag === "INPUT" || tag === "TEXTAREA" || (el && el.isContentEditable)) return;
        e.preventDefault();
        undoLast();
      }
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [EDIT]);

  const editCtx = EDIT
    ? { edit: true, saveSku, saveSetting, saveSeries, saveConcept, saveLook, previewLook,
        upload: uploadEdit, selectLook: setSelectedLookId, selectedLookId,
        duplicateLook, deleteLook, undoLast, undoDepth,
        previewCollection, commitCollection, device, preview,
        // effective viewport width so JS-driven responsiveness (grid columns)
        // matches the device frame instead of the real window.
        vw: device === "mobile" ? 430 : null }
    : { edit: false };

  // Only reveal the collection loader if the fetch actually takes a beat —
  // otherwise a fast load flashes "loading…" for a frame. Delay it ~350ms; brief
  // loads render a silent bone screen and never show the text.
  const busy = dataLoading || !collection.length;
  const [showBusy, setShowBusy] = React.useState(false);
  React.useEffect(() => {
    if (!busy) { setShowBusy(false); return; }
    const t = setTimeout(() => setShowBusy(true), 350);
    return () => clearTimeout(t);
  }, [busy]);

  // Loading state during boot — a tiny brand mark while /api/me resolves.
  if (!bootChecked) {
    return (
      <div style={{minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center", background: "var(--bg-1)", color: "var(--fg-3)", fontFamily: "var(--font-display)", fontSize: 22, letterSpacing: "-0.02em"}}>
        subtlebody
      </div>
    );
  }

  const gateContent = {
    image: meta.gateImage, imageTf: meta.gateImageTf, heroA: meta.gateHeroA, heroB: meta.gateHeroB,
    location: meta.gateLocation, label: meta.gateLabel,
    intro: meta.gateIntro, button: meta.gateButton,
  };

  if (!retailer) {
    return (
      <Gate
        onEnter={(r) => { setRetailer(r); setView("lookbook"); }}
        season={meta.season}
        looksCount={looks.length || meta.looksCount || 0}
        content={gateContent}
      />
    );
  }

  if (dataError) {
    return (
      <div style={{minHeight: "100vh", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 24, background: "var(--bg-1)", color: "var(--fg-2)", textAlign: "center", padding: 48}}>
        <div style={{fontFamily: "var(--font-display)", fontSize: 48, letterSpacing: "-0.02em"}}>
          the room is closed.
        </div>
        <div style={{fontFamily: "var(--font-mono)", fontSize: 12, letterSpacing: "0.1em", textTransform: "uppercase", color: "var(--fg-3)"}}>
          {dataError}
        </div>
        <button className="btn btn-outline" onClick={exit}>← return to gate</button>
      </div>
    );
  }

  if (busy) {
    // Under the threshold: a silent bone screen (no flashing text).
    if (!showBusy) return <div style={{minHeight: "100vh", background: "var(--bg-1)"}} />;
    return (
      <div style={{minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center", background: "var(--bg-1)", color: "var(--fg-3)", fontFamily: "var(--font-display)", fontSize: 36, letterSpacing: "-0.02em"}}>
        loading the collection…
      </div>
    );
  }

  const navTabs = [
    { id: "lookbook",  label: "[ LOOKBOOK ]" },
    { id: "grid",      label: "[ COLLECTION ]" },
    { id: "concepts",  label: "[ CONCEPTS ]" },
    { id: "summary",   label: "[ ORDER ]" },
  ];

  const concepts = meta.series || [];
  const openConcept = (name) => { setActiveConcept(name); setView("concept"); };
  // Open the floating look page as an OVERLAY (no view switch) so it can morph out
  // of the indicator bubble, Dynamic-Island style, on top of the lookbook.
  const openLookPage = (lookId, frame) => setLookPage({ lookId, frame: frame || 0 });

  // "open line sheet" (and any legacy linesheet nav) → Collection in line layout.
  const goView = (v) => {
    if (v === "linesheet") { setCollectionLayout("line"); setView("grid"); }
    else setView(v);
  };

  const orderUnits = Object.values(orderItems).reduce((s, it) =>
    s + Object.values(it.qtys || {}).reduce((a, b) => a + (b || 0), 0), 0);

  return (
    <window.EditCtx.Provider value={editCtx}>
    {EDIT && <window.EditBar status={editStatus} onExit={() => { window.location.href = "/admin"; }}
      device={device} onDevice={setDevice} preview={preview} onPreview={() => setPreview((p) => !p)}
      onSequence={view === "lookbook" ? () => setSeqMode((s) => !s) : null} seqOn={seqMode} />}
    <div className={"app" + (panelCollapsed ? " is-collapsed" : "") + (EDIT ? " is-edit" : "")
      + (EDIT && view === "lookbook" && !preview ? " is-look-edit" : "")
      + (EDIT && view === "lookbook" && !preview && selectedLookId != null ? " has-inspector" : "")
      + (EDIT && device === "mobile" ? " is-device-mobile" : "")
      + (view === "lookbook" && !EDIT ? " is-hero" : "")
      + (headerAtTop ? " is-attop" : "")
      + (EDIT && preview ? " is-preview" : "")}>
      {EDIT && view === "lookbook" && !preview && !seqMode && (
        <window.LookbookEditor
          looks={looks} collection={collection}
          selectedId={selectedLookId} onSelect={setSelectedLookId}
          dirty={looksDirty} status={editStatus}
          onCreate={addLookHere} onCreateChapter={addChapterHere} onCreateCanvas={addCanvasHere} onDelete={deleteLook} onReorder={reorderLooks}
          onDuplicate={duplicateLook} device={device}
          onPublish={publishLooks} onDiscard={discardLooks} onLoadVersion={loadLookVersion}
          onUndo={undoLast} undoDepth={undoDepth}
          onPatch={saveLook} onPreview={previewLook} onUpload={uploadEdit} />
      )}
      {/* Sequence mode = its own page: the heavy lookbook canvas + rail are
          UNMOUNTED underneath it, so the board stays light. */}
      {EDIT && view === "lookbook" && !preview && seqMode && (
        <window.SBSequenceMode looks={looks} collection={collection}
          onPatch={saveLook} onReorder={reorderLooks} onCreate={createLook} onDelete={deleteLook}
          onClose={() => setSeqMode(false)}
          onJump={(id) => {
            setSelectedLookId(id); setSeqMode(false);
            setTimeout(() => {
              const el = document.querySelector('.lb-look[data-look-id="' + id + '"], .lb-chapter[data-look-id="' + id + '"], .lb-canvas[data-look-id="' + id + '"]');
              if (el) el.scrollIntoView({ block: "center" });
            }, 400);
          }} />
      )}
      <main className="main">
        <header ref={setHeaderRef} className={"main-header" + (headerCollapsed ? " is-collapsed" : "")}>
          <button className={"wm" + (view === "lookbook" ? " is-active" : "")}
            onClick={() => { setPdpProduct(null); setLookPage(null); setView("lookbook"); }} title="lookbook">
            <span className="wm-text">subtlebody</span>
            <svg className="wm-icon" viewBox="0 0 717 712" fill="currentColor" aria-hidden="true">
              <path d="M227.838 229.892C221.17 233.08 218.712 240.259 222.707 245.818C227.738 252.815 230.511 260.436 230.511 268.417C230.511 302.504 180.192 330.123 118.158 330.123C56.1228 330.123 5.8501 302.504 5.8501 268.417C5.8501 249.887 20.7454 233.258 44.306 221.97C50.9601 218.783 53.66 211.586 49.6512 206.04C44.5797 199.026 41.7885 191.359 41.7885 183.328C41.7885 149.277 92.1069 121.662 154.142 121.662C216.176 121.662 266.449 149.282 266.449 183.328C266.449 201.912 251.504 218.577 227.834 229.892H227.838Z" />
              <path d="M706.225 380.029C717.677 249.155 605.944 146.37 467.458 175.769C378.096 194.737 306.834 282.813 288.956 372.764C288.026 377.443 283.37 380.987 278.704 380.65C273.026 380.239 267.129 380.034 261.656 380.034C154.178 380.034 58.4716 461.123 37.465 565.477C34.8973 578.238 43.9412 589.407 56.7842 589.407H665.147C677.028 589.407 687.499 579.775 688.539 567.894C688.539 567.894 705.774 385.192 706.225 380.034V380.029Z" />
            </svg>
          </button>
          <nav className="nav-tabs">
            {navTabs.map(t => (
              <button key={t.id}
                className={"nav-tab" + ((view === t.id || (t.id === "concepts" && view === "concept")) ? " is-active" : "")}
                onClick={() => { setPdpProduct(null); setLookPage(null); setView(t.id); }}>{t.label}</button>
            ))}
          </nav>
          <div className="meta-right">
            <span className="meta-pill">{info.retailer}  ·  {info.season}</span>
            <span className="meta-pill" style={{color: "var(--fg-1)"}}>● {orderUnits} u</span>
            <button className="btn btn-ghost" onClick={exit}>[ exit ]</button>
          </div>
        </header>

        {/* The product page REPLACES the current view (full page, own URL) —
            back (button or browser) pops history and restores the view below. */}
        <div className="view-anim" key={pdpProduct ? "product-" + pdpProduct.id : (view === "concept" ? "concept-" + activeConcept : view)}>
        {pdpProduct ? (
          <PDPSheet product={pdpProduct} onClose={closeOverlay}
            orderItem={orderItems[pdpProduct.id]} setOrderQty={setOrderQty}
            collection={collection} onOpenSku={setPdpProduct} concepts={concepts} looks={looks}
            onOpenConcept={(name) => { setPdpProduct(null); openConcept(name); }} />
        ) : (<React.Fragment>
        {view === "cover" && (
          <Cover collection={collection} info={info} onEnter={goView}
            coverBg={meta.coverBg} />
        )}
        {view === "lookbook" && !(EDIT && seqMode && !preview) && (
          <LookbookView looks={looks} collection={collection} onOpenSku={setPdpProduct}
            onOpenLook={openLookPage} lookPageOpen={!!lookPage} />
        )}
        {view === "grid" && (
          <GridView collection={collection} filters={filters} setFilters={setFilters}
            onOpenSku={setPdpProduct} orderItems={orderItems} info={info} concepts={concepts}
            layout={collectionLayout} setLayout={setCollectionLayout} setOrderQty={setOrderQty}
            onOpenConcept={openConcept} fromLookbook={prevViewRef.current === "lookbook"} />
        )}
        {view === "concepts" && (
          <ConceptsView concepts={concepts} onOpen={openConcept} info={info} />
        )}
        {view === "concept" && (
          <ConceptPage concept={concepts.find((c) => c.name === activeConcept)}
            onBack={() => setView("concepts")}
            collection={collection} onOpenSku={setPdpProduct} />
        )}
        {view === "summary" && (
          <Summary collection={collection} orderItems={orderItems} info={info}
            onSubmit={submitOrder} onBack={() => setView("grid")} submitting={submitting} />
        )}
        {view === "gate" && EDIT && (
          /* the passcode gate as an editable page — reached from EditBar › ◫ gate */
          <Gate editable onEnter={() => {}} season={meta.season}
            looksCount={looks.length || meta.looksCount || 0} content={gateContent} />
        )}
        </React.Fragment>)}
        </div>
      </main>

      <OrderPanel orderItems={orderItems} collection={collection} info={info}
        onOpenSku={setPdpProduct}
        onSubmit={() => { setPdpProduct(null); setLookPage(null); setView("summary"); }}
        onOpenDrafts={() => setDraftsOpen(true)}
        onClear={clearLine} setOrderQty={setOrderQty}
        collapsed={panelCollapsed}
        setCollapsed={setPanelCollapsed} />

      {lookPage && (() => {
        const lk = looks.find((l) => l.id === lookPage.lookId);
        if (!lk) return null;
        return (
          <window.LookPage look={lk} looks={looks} collection={collection} info={info}
            frame={lookPage.frame} orderItems={orderItems} setOrderQty={setOrderQty}
            onClose={closeOverlay}
            onOpenSku={(p) => { setLookPage(null); setPdpProduct(p); }}
            onOpenLook={openLookPage}
            onOpenConcept={(name) => { setLookPage(null); openConcept(name); }} />
        );
      })()}

      <Drafts open={draftsOpen} onClose={() => setDraftsOpen(false)}
        drafts={drafts} onLoad={loadDraft} onSaveCurrent={saveCurrentDraft}
        onDelete={deleteDraft} currentItems={orderItems} />

      {toast && <div className="toast">{toast}</div>}
    </div>
    </window.EditCtx.Provider>
  );
}

window.App = App;
