/* BuilderForm — the Spoonity Pass Studio editing panel. Drives the live pass. */
const BF = window.SpoonityWalletPassDesignSystem_de20b8;
const I18N = window.SpoonityI18n;
const T = (k) => I18N.T(k); // studio interface string for the current language

// Renders children into document.body so position:fixed overlays always cover
// the full viewport, regardless of any CSS transform/perspective/will-change
// stacking contexts in the sidebar ancestry.
function Modal({ children }) {
  return ReactDOM.createPortal(children, document.body);
}

// Save pass JSON to the Firestore `passes` collection. Persists/reuses a
// draft_id in localStorage so repeated saves update the same doc. Returns the
// share_id. (Name kept for backward-compat with existing callers.)
window.saveDraftToSupabase = async function saveDraftToSupabase(pass, platform = "apple") {
  const db = window.SpoonityDrafts;
  if (!db) throw new Error("Firestore not initialised");
  const storageKey = `spoonity_draft_id_${platform}`;
  let draftId = localStorage.getItem(storageKey);
  const shareId = draftId
    ? (localStorage.getItem(`spoonity_share_id_${platform}`) || crypto.randomUUID())
    : crypto.randomUUID();
  if (!draftId) draftId = crypto.randomUUID();
  await db.save({ id: draftId, share_id: shareId, platform, pass_json: pass });
  localStorage.setItem(storageKey, draftId);
  localStorage.setItem(`spoonity_share_id_${platform}`, shareId);
  return shareId;
}

function LoadModal({ onLoad, onClose }) {
  const [drafts, setDrafts] = React.useState(null);
  const [error, setError] = React.useState(null);
  const [confirmDelete, setConfirmDelete] = React.useState(null); // draft object pending deletion
  const [deleting, setDeleting] = React.useState(false);

  const load = () => {
    const db = window.SpoonityDrafts;
    if (!db) { setError("Firestore not initialised"); return; }
    db.list()
      .then((data) => setDrafts(data || []))
      .catch((e) => setError(e.message));
  };

  React.useEffect(load, []);

  const fmt = (iso) => {
    const d = new Date(iso);
    return d.toLocaleDateString(undefined, { month: "short", day: "numeric" }) + " · " +
      d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
  };

  const handleDelete = async () => {
    if (!confirmDelete) return;
    setDeleting(true);
    const db = window.SpoonityDrafts;
    try {
      await db.remove(confirmDelete.id);
    } catch (e) { setError(e.message); setDeleting(false); return; }
    // Clear localStorage if this was the active draft
    ["apple", "google"].forEach(p => {
      if (localStorage.getItem(`spoonity_draft_id_${p}`) === confirmDelete.id) {
        localStorage.removeItem(`spoonity_draft_id_${p}`);
        localStorage.removeItem(`spoonity_share_id_${p}`);
      }
    });
    setConfirmDelete(null);
    setDeleting(false);
    load(); // refresh list
  };

  return ReactDOM.createPortal(
    <div
      onClick={(e) => { if (e.target === e.currentTarget && !confirmDelete) onClose(); }}
      style={{ position: "fixed", inset: 0, zIndex: 3000, background: "rgba(0,0,0,.45)", display: "flex", alignItems: "center", justifyContent: "center" }}
    >
      <div style={{ background: "var(--surface-base)", borderRadius: 18, width: 480, maxWidth: "90vw", maxHeight: "70vh", display: "flex", flexDirection: "column", boxShadow: "0 24px 60px -12px rgba(0,0,0,.35)" }}>
        <div style={{ padding: "20px 24px 16px", display: "flex", alignItems: "center", justifyContent: "space-between", borderBottom: "1px solid var(--border-primary)" }}>
          <span style={{ fontSize: 16, fontWeight: 700, color: "var(--typography-primary)" }}>Load saved draft</span>
          <button onClick={onClose} style={{ border: "none", background: "none", cursor: "pointer", fontSize: 18, color: "var(--typography-tertiary)", lineHeight: 1, padding: 4 }}>✕</button>
        </div>
        <div style={{ flex: 1, overflowY: "auto", padding: "12px 16px" }}>
          {!drafts && !error && (
            <div style={{ padding: "32px 0", textAlign: "center", color: "var(--typography-tertiary)", fontSize: 13 }}>Loading…</div>
          )}
          {error && (
            <div style={{ padding: "32px 0", textAlign: "center", color: "var(--critical,#c3223f)", fontSize: 13 }}>{error}</div>
          )}
          {drafts && drafts.length === 0 && (
            <div style={{ padding: "32px 0", textAlign: "center", color: "var(--typography-tertiary)", fontSize: 13 }}>No saved drafts yet.</div>
          )}
          {drafts && drafts.map((d) => {
            const name = d.pass_json?.draft_name || d.pass_json?.name || d.pass_json?.cardTitle || "Untitled pass";
            const bg = d.pass_json?.backgroundColor || "#7B2FF7";
            return (
              <div key={d.id} style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 8 }}>
                <button onClick={() => onLoad(d)}
                  onMouseEnter={e => e.currentTarget.style.background = "var(--surface-secondary)"}
                  onMouseLeave={e => e.currentTarget.style.background = "var(--surface-base)"}
                  style={{
                    flex: 1, display: "flex", alignItems: "center", gap: 14, padding: "12px 14px",
                    borderRadius: 12, border: "1px solid var(--border-primary)", background: "var(--surface-base)",
                    cursor: "pointer", textAlign: "left", transition: "background .12s",
                  }}
                >
                  <div style={{ width: 36, height: 36, borderRadius: 9, background: bg, flexShrink: 0 }} />
                  <div style={{ flex: 1, minWidth: 0 }}>
                    <div style={{ fontSize: 13, fontWeight: 600, color: "var(--typography-primary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{name}</div>
                    <div style={{ fontSize: 11, color: "var(--typography-tertiary)", marginTop: 2 }}>
                      {d.platform === "google" ? "Google Wallet" : "Apple Wallet"} · {fmt(d.updated_at)}
                    </div>
                  </div>
                  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--typography-tertiary)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
                </button>
                <button
                  onClick={() => setConfirmDelete(d)}
                  title="Delete this draft"
                  onMouseEnter={e => { e.currentTarget.style.background = "var(--critical,#c3223f)"; e.currentTarget.style.color = "#fff"; e.currentTarget.style.borderColor = "var(--critical,#c3223f)"; }}
                  onMouseLeave={e => { e.currentTarget.style.background = "var(--surface-base)"; e.currentTarget.style.color = "var(--typography-tertiary)"; e.currentTarget.style.borderColor = "var(--border-primary)"; }}
                  style={{
                    flexShrink: 0, width: 36, height: 36, borderRadius: 10, border: "1px solid var(--border-primary)",
                    background: "var(--surface-base)", cursor: "pointer", display: "flex", alignItems: "center",
                    justifyContent: "center", color: "var(--typography-tertiary)", transition: "all .15s",
                  }}
                >
                  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
                </button>
              </div>
            );
          })}
        </div>
      </div>

      {/* Delete confirmation — rendered on top of the load modal */}
      {confirmDelete && (
        <div style={{ position: "absolute", inset: 0, zIndex: 10, display: "flex", alignItems: "center", justifyContent: "center" }}
          onClick={(e) => { if (e.target === e.currentTarget) setConfirmDelete(null); }}>
          <div style={{ background: "var(--surface-base)", borderRadius: 16, width: 360, maxWidth: "90vw", padding: "24px", boxShadow: "0 24px 60px -12px rgba(0,0,0,.45)" }}>
            <div style={{ width: 44, height: 44, borderRadius: "50%", background: "rgba(195,34,63,.12)", display: "flex", alignItems: "center", justifyContent: "center", marginBottom: 16 }}>
              <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="var(--critical,#c3223f)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
            </div>
            <div style={{ fontSize: 15, fontWeight: 700, color: "var(--typography-primary)", marginBottom: 8 }}>Delete this draft?</div>
            <div style={{ fontSize: 13, color: "var(--typography-secondary)", lineHeight: 1.5, marginBottom: 20 }}>
              <strong>"{confirmDelete.pass_json?.draft_name || confirmDelete.pass_json?.name || confirmDelete.pass_json?.cardTitle || "Untitled pass"}"</strong> will be permanently deleted from the database. This cannot be undone.
            </div>
            <div style={{ display: "flex", gap: 8 }}>
              <button onClick={() => setConfirmDelete(null)} style={{ flex: 1, padding: "9px", borderRadius: 9, border: "1px solid var(--border-primary)", background: "var(--surface-secondary)", cursor: "pointer", fontFamily: "var(--font-sans)", fontSize: 13, fontWeight: 600, color: "var(--typography-secondary)" }}>Cancel</button>
              <button onClick={handleDelete} disabled={deleting} style={{ flex: 1, padding: "9px", borderRadius: 9, border: "none", background: "var(--critical,#c3223f)", cursor: deleting ? "default" : "pointer", fontFamily: "var(--font-sans)", fontSize: 13, fontWeight: 600, color: "#fff", opacity: deleting ? 0.7 : 1 }}>
                {deleting ? "Deleting…" : "Yes, delete"}
              </button>
            </div>
          </div>
        </div>
      )}
    </div>,
    document.body
  );
}

function SaveAsModal({ pass, platform, accent, onSaved, onClose }) {
  const [name, setName] = React.useState(pass.name || pass.cardTitle || "");
  const [error, setError] = React.useState(null);
  const [saving, setSaving] = React.useState(false);

  const handleSave = async () => {
    const trimmed = name.trim();
    if (!trimmed) { setError("Please enter a name."); return; }
    const db = window.SpoonityDrafts;
    if (!db) { setError("Firestore not initialised."); return; }
    setSaving(true);
    setError(null);
    // Check for duplicate name
    let dupes;
    try {
      dupes = await db.findByName(trimmed);
    } catch (e) { setError(e.message); setSaving(false); return; }
    if (dupes && dupes.length > 0) {
      setError(`A draft named "${trimmed}" already exists. Choose a different name.`);
      setSaving(false);
      return;
    }
    // Create a brand-new draft
    const newId = crypto.randomUUID();
    const newShareId = crypto.randomUUID();
    const savedPass = { ...pass, draft_name: trimmed };
    try {
      await db.save({ id: newId, share_id: newShareId, platform, pass_json: savedPass });
    } catch (insertError) { setError(insertError.message); setSaving(false); return; }
    localStorage.setItem(`spoonity_draft_id_${platform}`, newId);
    localStorage.setItem(`spoonity_share_id_${platform}`, newShareId);
    onSaved(newShareId, savedPass);
    onClose();
  };

  return ReactDOM.createPortal(
    <div
      onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
      style={{ position: "fixed", inset: 0, zIndex: 3000, background: "rgba(0,0,0,.45)", display: "flex", alignItems: "center", justifyContent: "center" }}
    >
      <div style={{ background: "var(--surface-base)", borderRadius: 18, width: 400, maxWidth: "90vw", boxShadow: "0 24px 60px -12px rgba(0,0,0,.35)", padding: "24px" }}>
        <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 18 }}>
          <span style={{ fontSize: 16, fontWeight: 700, color: "var(--typography-primary)" }}>Save as new draft</span>
          <button onClick={onClose} style={{ border: "none", background: "none", cursor: "pointer", fontSize: 18, color: "var(--typography-tertiary)", lineHeight: 1, padding: 4 }}>✕</button>
        </div>
        <label style={{ display: "block", fontSize: 12, fontWeight: 600, color: "var(--typography-secondary)", marginBottom: 6, textTransform: "uppercase", letterSpacing: ".06em" }}>Draft name</label>
        <input
          autoFocus
          value={name}
          onChange={(e) => { setName(e.target.value); setError(null); }}
          onKeyDown={(e) => { if (e.key === "Enter") handleSave(); if (e.key === "Escape") onClose(); }}
          placeholder="e.g. Summer loyalty pass"
          style={{
            width: "100%", boxSizing: "border-box", padding: "9px 12px", borderRadius: 9,
            border: `1.5px solid ${error ? "var(--critical,#c3223f)" : "var(--border-primary)"}`,
            background: "var(--surface-secondary)", fontFamily: "var(--font-sans)", fontSize: 13,
            color: "var(--typography-primary)", outline: "none",
          }}
        />
        {error && <div style={{ marginTop: 6, fontSize: 12, color: "var(--critical,#c3223f)" }}>{error}</div>}
        <div style={{ display: "flex", gap: 8, marginTop: 18 }}>
          <button onClick={onClose} style={{ flex: 1, padding: "9px", borderRadius: 9, border: "1px solid var(--border-primary)", background: "var(--surface-secondary)", cursor: "pointer", fontFamily: "var(--font-sans)", fontSize: 13, fontWeight: 600, color: "var(--typography-secondary)" }}>Cancel</button>
          <button onClick={handleSave} disabled={saving} style={{ flex: 1, padding: "9px", borderRadius: 9, border: "none", background: accent, cursor: saving ? "default" : "pointer", fontFamily: "var(--font-sans)", fontSize: 13, fontWeight: 600, color: "#fff", opacity: saving ? 0.7 : 1 }}>
            {saving ? "Saving…" : "Save as new"}
          </button>
        </div>
      </div>
    </div>,
    document.body
  );
}

function Section({ title, hint, children, open, onToggle }) {
  // Collapsible (accordion) when onToggle is provided; otherwise always open.
  const collapsible = typeof onToggle === "function";
  const isOpen = collapsible ? !!open : true;
  return (
    <div style={{ display: "flex", flexDirection: "column", gap: 10, padding: "18px 0", borderBottom: "1px solid var(--border-primary)" }}>
      <div
        onClick={collapsible ? onToggle : undefined}
        role={collapsible ? "button" : undefined}
        aria-expanded={collapsible ? isOpen : undefined}
        style={{ display: "flex", alignItems: "center", justifyContent: "space-between", cursor: collapsible ? "pointer" : "default", userSelect: "none" }}
      >
        <div style={{ display: "flex", alignItems: "center", gap: 9, minWidth: 0 }}>
          {collapsible && (
            <span aria-hidden="true" style={{
              width: 0, height: 0, flexShrink: 0, borderTop: "5px solid transparent", borderBottom: "5px solid transparent",
              borderLeft: "6px solid var(--typography-tertiary)", transform: isOpen ? "rotate(90deg)" : "rotate(0deg)", transition: "transform .15s ease",
            }} />
          )}
          <span style={{ fontSize: 12, fontWeight: 700, color: "var(--typography-primary)", letterSpacing: ".07em", textTransform: "uppercase" }}>{title}</span>
        </div>
        {hint && <span style={{ fontSize: 11, color: "var(--typography-tertiary)", flexShrink: 0 }}>{hint}</span>}
      </div>
      {isOpen && children}
    </div>
  );
}
//commit
function FieldLabel({ children, required }) {
  // Labels stay quiet so the entered values read as the primary content:
  // sentence case, medium weight, muted color — no shouting uppercase.
  return (
    <span style={{ fontSize: 11.5, fontWeight: 500, letterSpacing: ".005em", color: "var(--typography-tertiary)" }}>
      {children}
      {required && <span aria-hidden="true" title="Required" style={{ marginLeft: 3, fontWeight: 600, color: "var(--critical, #c3223f)" }}>*</span>}
    </span>
  );
}

function Segmented({ options, value, onChange }) {
  return (
    <div style={{ display: "flex", gap: 4, background: "var(--surface-secondary)", padding: 3, borderRadius: 10, flexWrap: "wrap" }}>
      {options.map((o) => {
        const active = o.value === value;
        return (
          <button key={o.value} onClick={() => onChange(o.value)} style={{
            flex: "1 1 auto", border: "none", cursor: "pointer", padding: "7px 8px", borderRadius: 8,
            fontFamily: "var(--font-sans)", fontSize: 12, fontWeight: 500, whiteSpace: "nowrap",
            background: active ? "var(--surface-primary)" : "transparent",
            color: active ? "var(--typography-primary)" : "var(--typography-secondary)",
            boxShadow: active ? "var(--shadow-popup)" : "none", transition: "all .15s ease",
          }}>{o.label}</button>
        );
      })}
    </div>
  );
}

/* ---- HSV <-> hex helpers ---- */
function hsvToRgb(h, s, v) {
  const c = v * s, x = c * (1 - Math.abs((h / 60) % 2 - 1)), m = v - c;
  let r = 0, g = 0, b = 0;
  if (h < 60) [r, g, b] = [c, x, 0];
  else if (h < 120) [r, g, b] = [x, c, 0];
  else if (h < 180) [r, g, b] = [0, c, x];
  else if (h < 240) [r, g, b] = [0, x, c];
  else if (h < 300) [r, g, b] = [x, 0, c];
  else [r, g, b] = [c, 0, x];
  return [r + m, g + m, b + m].map((n) => Math.round(n * 255));
}
function hsvToHex(h, s, v) {
  const [r, g, b] = hsvToRgb(h, s, v);
  return "#" + [r, g, b].map((n) => n.toString(16).padStart(2, "0")).join("");
}
function hexToHsv(hex) {
  let m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex || "");
  if (!m) return null;
  const r = parseInt(m[1], 16) / 255, g = parseInt(m[2], 16) / 255, b = parseInt(m[3], 16) / 255;
  const max = Math.max(r, g, b), min = Math.min(r, g, b), d = max - min;
  let h = 0;
  if (d !== 0) {
    if (max === r) h = ((g - b) / d) % 6;
    else if (max === g) h = (b - r) / d + 2;
    else h = (r - g) / d + 4;
    h *= 60; if (h < 0) h += 360;
  }
  return { h, s: max === 0 ? 0 : d / max, v: max };
}

function ColorPicker({ value, onChange }) {
  const [hsv, setHsv] = React.useState(() => hexToHsv(value) || { h: 285, s: 0.85, v: 0.43 });
  const [hexText, setHexText] = React.useState(value);
  const lastHex = React.useRef(value);
  const svRef = React.useRef(null);
  const hueRef = React.useRef(null);

  // Sync from external changes (e.g. preset swatch) without clobbering live hue.
  React.useEffect(() => {
    if (value && value.toLowerCase() !== (lastHex.current || "").toLowerCase()) {
      const next = hexToHsv(value);
      if (next) { setHsv((p) => (next.s === 0 || next.v === 0) ? { ...next, h: p.h } : next); lastHex.current = value; setHexText(value); }
    }
  }, [value]);

  const emit = (next) => {
    setHsv(next);
    const hex = hsvToHex(next.h, next.s, next.v);
    lastHex.current = hex;
    setHexText(hex);
    onChange(hex);
  };

  const drag = (ref, handler) => (e) => {
    e.preventDefault();
    const el = ref.current;
    const move = (ev) => {
      const r = el.getBoundingClientRect();
      const cx = (ev.touches ? ev.touches[0].clientX : ev.clientX);
      const cy = (ev.touches ? ev.touches[0].clientY : ev.clientY);
      handler(clamp((cx - r.left) / r.width), clamp((cy - r.top) / r.height));
    };
    move(e);
    const up = () => {
      window.removeEventListener("pointermove", move);
      window.removeEventListener("pointerup", up);
    };
    window.addEventListener("pointermove", move);
    window.addEventListener("pointerup", up);
  };
  const clamp = (n) => Math.max(0, Math.min(1, n));

  const hueColor = hsvToHex(hsv.h, 1, 1);
  const curHex = hsvToHex(hsv.h, hsv.s, hsv.v);

  return (
    <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
      {/* Saturation / value square */}
      <div
        ref={svRef}
        onPointerDown={drag(svRef, (x, y) => emit({ ...hsv, s: x, v: 1 - y }))}
        style={{
          position: "relative", width: "100%", height: 150, borderRadius: 12, cursor: "crosshair",
          background: `linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, ${hueColor})`,
          border: "1px solid rgba(0,0,0,.12)", touchAction: "none",
        }}
      >
        <div style={{
          position: "absolute", left: `${hsv.s * 100}%`, top: `${(1 - hsv.v) * 100}%`,
          width: 16, height: 16, borderRadius: "50%", transform: "translate(-50%, -50%)",
          background: curHex, border: "2px solid #fff", boxShadow: "0 0 0 1px rgba(0,0,0,.35), 0 1px 3px rgba(0,0,0,.4)",
          pointerEvents: "none",
        }} />
      </div>

      {/* Hue slider */}
      <div
        ref={hueRef}
        onPointerDown={drag(hueRef, (x) => emit({ ...hsv, h: x * 360 }))}
        style={{
          position: "relative", width: "100%", height: 16, borderRadius: 8, cursor: "ew-resize", touchAction: "none",
          background: "linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%)",
          border: "1px solid rgba(0,0,0,.12)",
        }}
      >
        <div style={{
          position: "absolute", left: `${(hsv.h / 360) * 100}%`, top: "50%",
          width: 18, height: 18, borderRadius: "50%", transform: "translate(-50%, -50%)",
          background: hueColor, border: "2px solid #fff", boxShadow: "0 0 0 1px rgba(0,0,0,.35), 0 1px 3px rgba(0,0,0,.4)",
          pointerEvents: "none",
        }} />
      </div>

      {/* Readout */}
      <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
        <div style={{ width: 28, height: 28, borderRadius: 7, background: curHex, border: "1px solid rgba(0,0,0,.12)", flexShrink: 0 }} />
        <BF.Input
          value={hexText}
          spellCheck={false}
          onChange={(e) => {
            let t = e.target.value;
            if (t && t[0] !== "#") t = "#" + t;
            t = "#" + t.slice(1).replace(/[^0-9a-fA-F]/g, "").slice(0, 6);
            setHexText(t);
            const next = hexToHsv(t);
            if (next) {
              lastHex.current = t;
              onChange(t);
              setHsv((p) => (next.s === 0 || next.v === 0) ? { ...next, h: p.h } : next);
            }
          }}
          onBlur={() => setHexText(curHex.toUpperCase())}
        />
      </div>
    </div>
  );
}

function ColorSwatches({ value, onChange }) {
  const colors = ["#640c6f", "#ff7e3d", "#111827", "#2557cb", "#3f7d58", "#c3223f", "#cc6716", "#f8f6f4"];
  return (
    <div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
      {colors.map((c) => (
        <button key={c} onClick={() => onChange(c)} aria-label={c} style={{
          width: 32, height: 32, borderRadius: 9, background: c, cursor: "pointer",
          border: value === c ? "2px solid var(--typography-primary)" : "1px solid rgba(0,0,0,.1)",
          outline: value === c ? "2px solid var(--surface-base)" : "none", outlineOffset: -4,
        }} />
      ))}
    </div>
  );
}

function TwoCol({ children }) {
  return <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>{children}</div>;
}

/* Wallet-platform indicator. Apple is the active target; the Google button
   highlights on hover but is not wired up yet. */
function PlatformToggle({ onGoogle }) {
  const [hover, setHover] = React.useState(false);
  const cell = { width: 36, height: 30, borderRadius: 8, display: "flex", alignItems: "center", justifyContent: "center", boxSizing: "border-box" };
  return (
    <div style={{ display: "flex", gap: 6, alignItems: "center" }}>
      <div title="Apple Wallet" style={{ ...cell, background: "var(--surface-secondary)", border: "1px solid var(--border-primary)" }}>
        <img src="../../assets/apple-mark.svg" alt="Apple Wallet" style={{ height: 16 }} />
      </div>
      <button
        type="button"
        title="Design for Google Wallet"
        onClick={() => onGoogle ? onGoogle() : window.open("../google-pass-studio/index.html", "_blank")}
        onMouseEnter={() => setHover(true)}
        onMouseLeave={() => setHover(false)}
        style={{
          ...cell, cursor: "pointer", transition: "background .15s ease, border-color .15s ease, opacity .15s ease",
          background: hover ? "var(--surface-secondary)" : "transparent",
          border: `1px solid ${hover ? "var(--border-primary)" : "transparent"}`,
          opacity: hover ? 1 : 0.55,
        }}
      >
        <img src="../../assets/google-g.svg" alt="Google Wallet" style={{ height: 16 }} />
      </button>
    </div>
  );
}

function CropModal({ src, crops, title = "Crop image", onCancel, onConfirm }) {
  const list = Array.isArray(crops) ? crops : [{ w: crops.w, h: crops.h }];
  const [idx, setIdx] = React.useState(0);
  const sel = list[idx];
  const ar = sel.w / sel.h;
  let frameW = 360, frameH = Math.round(frameW / ar);
  if (frameH > 280) { frameH = 280; frameW = Math.round(frameH * ar); }
  if (frameW > 360) { frameW = 360; frameH = Math.round(frameW / ar); }

  const [img, setImg] = React.useState(null);
  const [zoom, setZoom] = React.useState(1);
  const [pos, setPos] = React.useState({ x: 0, y: 0 });

  React.useEffect(() => {
    const im = new Image();
    im.onload = () => setImg(im);
    im.src = src;
  }, [src]);

  const cover = img ? Math.max(frameW / img.naturalWidth, frameH / img.naturalHeight) : 1;
  const dispW = img ? img.naturalWidth * cover * zoom : frameW;
  const dispH = img ? img.naturalHeight * cover * zoom : frameH;

  const clampPos = (p) => ({
    x: Math.min(0, Math.max(frameW - dispW, p.x)),
    y: Math.min(0, Math.max(frameH - dispH, p.y)),
  });
  React.useEffect(() => { setZoom(1); setPos({ x: 0, y: 0 }); }, [idx]);
  React.useEffect(() => { setPos((p) => clampPos(p)); /* eslint-disable-next-line */ }, [zoom, img, idx]);

  const onPointerDown = (e) => {
    e.preventDefault();
    const start = { x: e.clientX, y: e.clientY, px: pos.x, py: pos.y };
    const move = (ev) => setPos(clampPos({ x: start.px + (ev.clientX - start.x), y: start.py + (ev.clientY - start.y) }));
    const up = () => { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); };
    window.addEventListener("pointermove", move);
    window.addEventListener("pointerup", up);
  };

  const confirm = () => {
    const canvas = document.createElement("canvas");
    canvas.width = sel.w; canvas.height = sel.h;
    const ctx = canvas.getContext("2d");
    const s = sel.w / frameW;
    ctx.drawImage(img, pos.x * s, pos.y * s, dispW * s, dispH * s);
    onConfirm(canvas.toDataURL("image/png"));
  };

  return (<Modal>
    <div style={{ position: "fixed", inset: 0, zIndex: 1000, background: "rgba(17,24,39,.55)", display: "flex", alignItems: "center", justifyContent: "center" }}>
      <div style={{ background: "var(--surface-base)", borderRadius: 16, padding: 20, width: 400, boxShadow: "var(--shadow-popup)" }}>
        <div style={{ fontSize: 15, fontWeight: 600, color: "var(--typography-primary)", marginBottom: 4 }}>{title}</div>
        <div style={{ fontSize: 12, color: "var(--typography-tertiary)", marginBottom: 12 }}>Crops to {sel.w} × {sel.h} px · drag to reposition, slider to zoom</div>
        {list.length > 1 && (
          <div style={{ marginBottom: 12 }}>
            <Segmented options={list.map((c, i) => ({ value: i, label: c.label || `${c.w}×${c.h}` }))} value={idx} onChange={setIdx} />
          </div>
        )}
        <div style={{ display: "flex", justifyContent: "center" }}>
          <div onPointerDown={onPointerDown} style={{ position: "relative", width: frameW, height: frameH, overflow: "hidden", borderRadius: 10, background: "#000", cursor: "grab", touchAction: "none" }}>
            {img && <img src={src} draggable={false} alt="" style={{ position: "absolute", left: pos.x, top: pos.y, width: dispW, height: dispH, userSelect: "none", pointerEvents: "none" }} />}
          </div>
        </div>
        <input type="range" min="1" max="3" step="0.01" value={zoom} onChange={(e) => setZoom(parseFloat(e.target.value))} style={{ width: "100%", margin: "14px 0", accentColor: "var(--border-focused, #ff7e3d)" }} />
        <div style={{ display: "flex", gap: 10, justifyContent: "flex-end" }}>
          <BF.Button variant="tertiary" size="sm" onClick={onCancel}>Cancel</BF.Button>
          <BF.Button size="sm" onClick={confirm} disabled={!img}>Use image</BF.Button>
        </div>
      </div>
    </div>
  </Modal>);
}

function ImageUpload({ src, onUpload, onClear, label, hint, previewH = 60, cropTo }) {
  const inputRef = React.useRef(null);
  const [pending, setPending] = React.useState(null);
  const [dragging, setDragging] = React.useState(false);
  const onFile = (e) => {
    const file = e.target.files && e.target.files[0];
    if (!file) return;
    const reader = new FileReader();
    reader.onload = () => { if (cropTo) setPending(reader.result); else onUpload(reader.result); };
    reader.readAsDataURL(file);
    e.target.value = "";
  };
  return (
    <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
      <div
        onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
        onDragLeave={() => setDragging(false)}
        onDrop={(e) => { e.preventDefault(); setDragging(false); const file = e.dataTransfer.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = () => { if (cropTo) setPending(reader.result); else onUpload(reader.result); }; reader.readAsDataURL(file); }}
        style={{
          width: "100%", height: previewH, borderRadius: 10, overflow: "hidden",
          border: dragging ? "2px dashed var(--border-focused, #ff7e3d)" : src ? "1px solid var(--border-primary)" : "1px dashed var(--border-primary)",
          background: "var(--surface-secondary)", display: "flex", alignItems: "center", justifyContent: "center",
        }}>
        {src
          ? <img src={src} alt="" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
          : <span style={{ fontSize: 11, color: "var(--typography-tertiary)" }}>{dragging ? "Drop to upload" : (hint || T("noImage"))}</span>}
      </div>
      <div style={{ display: "flex", gap: 8 }}>
        <BF.Button variant="tertiary" size="sm" onClick={() => inputRef.current && inputRef.current.click()}>
          {src ? T("replace") : T("upload")}
        </BF.Button>
        {src && <BF.Button variant="ghost" size="sm" onClick={onClear}>{T("remove")}</BF.Button>}
      </div>
      <input ref={inputRef} type="file" accept="image/*" onChange={onFile} style={{ display: "none" }} />
      {pending && cropTo && (
        <CropModal src={pending} crops={cropTo} title="Crop banner"
          onCancel={() => setPending(null)}
          onConfirm={(dataUrl) => { onUpload(dataUrl); setPending(null); }} />
      )}
    </div>
  );
}

/* A trash affordance that fades in over an image thumbnail on hover — removal
   is the rare action, so it stays out of the way until wanted. */
function ThumbRemoveButton({ show, onClear, title }) {
  return (
    <button type="button" title={title || T("remove")} aria-label={title || T("remove")}
      onClick={(e) => { e.stopPropagation(); onClear(); }}
      style={{
        position: "absolute", top: 3, right: 3, width: 20, height: 20, borderRadius: 6, border: "none", padding: 0,
        cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center",
        background: "rgba(17,24,39,.72)", color: "#fff",
        opacity: show ? 1 : 0, pointerEvents: show ? "auto" : "none", transition: "opacity .15s ease",
      }}>
      <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" /></svg>
    </button>
  );
}

function LogoUpload({ src, onUpload, onClear }) {
  const inputRef = React.useRef(null);
  const [pending, setPending] = React.useState(null);
  const [dragging, setDragging] = React.useState(false);
  const [hover, setHover] = React.useState(false);
  const onFile = (e) => {
    const file = e.target.files && e.target.files[0];
    if (!file) return;
    const reader = new FileReader();
    reader.onload = () => setPending(reader.result);
    reader.readAsDataURL(file);
    e.target.value = "";
  };
  return (
    <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
      <FieldLabel>{T("logo")}</FieldLabel>
      <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
        <div
          onMouseEnter={() => setHover(true)}
          onMouseLeave={() => setHover(false)}
          onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
          onDragLeave={() => setDragging(false)}
          onDrop={(e) => { e.preventDefault(); setDragging(false); const file = e.dataTransfer.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = () => setPending(reader.result); reader.readAsDataURL(file); }}
          style={{
          position: "relative", width: 76, height: 44, flexShrink: 0, borderRadius: 9, overflow: "hidden",
          border: dragging ? "2px dashed var(--border-focused, #ff7e3d)" : "1px solid var(--border-primary)", background: "var(--surface-secondary)",
          display: "flex", alignItems: "center", justifyContent: "center",
        }}>
          {src
            ? <img src={src} alt="" style={{ maxWidth: 62, maxHeight: 32, objectFit: "contain" }} />
            : <span style={{ fontSize: 10, color: "var(--typography-tertiary)" }}>{T("noLogo")}</span>}
          {src && <ThumbRemoveButton show={hover} onClear={onClear} />}
        </div>
        <div style={{ display: "flex", gap: 8 }}>
          <BF.Button variant="tertiary" size="sm" onClick={() => inputRef.current && inputRef.current.click()}>
            {src ? T("replace") : T("upload")}
          </BF.Button>
        </div>
        <input ref={inputRef} type="file" accept="image/*" onChange={onFile} style={{ display: "none" }} />
      </div>
      {pending && (
        <CropModal
          src={pending}
          title="Crop logo"
          crops={[{ label: "Square 660×660", w: 660, h: 660 }, { label: "Wide 480×150", w: 480, h: 150 }]}
          onCancel={() => setPending(null)}
          onConfirm={(dataUrl) => { onUpload(dataUrl); setPending(null); }}
        />
      )}
    </div>
  );
}

/* ---- color math: auto-contrast, blend, WCAG contrast ratio ----
   Used to (a) derive sensible default value/label colors from the background,
   and (b) warn when the chosen colors won't be legible. */
function hexToRgbArr(hex) {
  if (!hex || hex[0] !== "#") return [0, 0, 0];
  const h = hex.length === 4 ? hex.slice(1).split("").map((c) => c + c).join("") : hex.slice(1, 7);
  return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16));
}
function autoFgHex(bg) {
  const [r, g, b] = hexToRgbArr(bg);
  const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
  return lum > 0.62 ? "#1d1d1f" : "#ffffff";
}
function blendHex(a, b, t) {
  const [ar, ag, ab] = hexToRgbArr(a), [br, bg, bb] = hexToRgbArr(b);
  const mix = (x, y) => Math.round(x + (y - x) * t).toString(16).padStart(2, "0");
  return "#" + mix(ar, br) + mix(ag, bg) + mix(ab, bb);
}
function autoLabelHex(bg, fg) { return blendHex(fg || autoFgHex(bg), bg, 0.42); }
function relLum(hex) {
  const f = hexToRgbArr(hex).map((v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); });
  return 0.2126 * f[0] + 0.7152 * f[1] + 0.0722 * f[2];
}
function contrastRatio(a, b) {
  const l1 = relLum(a), l2 = relLum(b);
  return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
function dataUrlToBase64(d) { return typeof d === "string" && d.indexOf(",") >= 0 ? d.slice(d.indexOf(",") + 1) : ""; }

/* Resolve the three colors the backend needs, filling value/label defaults from
   the background when the designer hasn't overridden them. */
function effectiveColors(pass) {
  const background = pass.backgroundColor || "#640c6f";
  const foreground = pass.foregroundColor || autoFgHex(background);
  const label = pass.labelColor || autoLabelHex(background, foreground);
  return { background, foreground, label };
}

/* Square icon upload (180×180). Apple Wallet REQUIRES an icon — it shows in
   notifications — so this is a first-class, validated control. */
function IconUpload({ src, onUpload, onClear }) {
  const inputRef = React.useRef(null);
  const [pending, setPending] = React.useState(null);
  const [dragging, setDragging] = React.useState(false);
  const [hover, setHover] = React.useState(false);
  const onFile = (e) => {
    const file = e.target.files && e.target.files[0];
    if (!file) return;
    const reader = new FileReader();
    reader.onload = () => setPending(reader.result);
    reader.readAsDataURL(file);
    e.target.value = "";
  };
  return (
    <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
      <div
        onMouseEnter={() => setHover(true)}
        onMouseLeave={() => setHover(false)}
        onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
        onDragLeave={() => setDragging(false)}
        onDrop={(e) => { e.preventDefault(); setDragging(false); const file = e.dataTransfer.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = () => setPending(reader.result); reader.readAsDataURL(file); }}
        style={{
        position: "relative", width: 44, height: 44, flexShrink: 0, borderRadius: 11, overflow: "hidden",
        border: dragging ? "2px dashed var(--border-focused, #ff7e3d)" : src ? "1px solid var(--border-primary)" : "1px dashed var(--border-primary)",
        background: "var(--surface-secondary)", display: "flex", alignItems: "center", justifyContent: "center",
      }}>
        {src ? <img src={src} alt="" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
             : <span style={{ fontSize: 9, color: "var(--typography-tertiary)" }}>180²</span>}
        {src && <ThumbRemoveButton show={hover} onClear={onClear} />}
      </div>
      <div style={{ display: "flex", gap: 8 }}>
        <BF.Button variant="tertiary" size="sm" onClick={() => inputRef.current && inputRef.current.click()}>{src ? T("replace") : T("uploadIcon")}</BF.Button>
      </div>
      <input ref={inputRef} type="file" accept="image/*" onChange={onFile} style={{ display: "none" }} />
      {pending && (
        <CropModal src={pending} title="Crop icon" crops={[{ label: "Square 180×180", w: 180, h: 180 }]}
          onCancel={() => setPending(null)} onConfirm={(d) => { onUpload(d); setPending(null); }} />
      )}
    </div>
  );
}

/* Sample the dominant colors of one image (data URL) by downscaling it to a
   small canvas and bucketing pixels. Returns buckets sorted by frequency. */
function sampleImageColors(dataUrl) {
  return new Promise((resolve) => {
    if (!dataUrl) return resolve([]);
    const img = new Image();
    img.onload = () => {
      try {
        const S = 44;
        const cv = document.createElement("canvas"); cv.width = S; cv.height = S;
        const ctx = cv.getContext("2d");
        ctx.drawImage(img, 0, 0, S, S);
        const d = ctx.getImageData(0, 0, S, S).data;
        const buckets = new Map();
        for (let i = 0; i < d.length; i += 4) {
          if (d[i + 3] < 128) continue;                              // skip transparent
          const r = d[i], g = d[i + 1], b = d[i + 2];
          if (r >= 250 && g >= 250 && b >= 250) continue;            // skip white padding
          const key = (r >> 4) + "," + (g >> 4) + "," + (b >> 4);
          let e = buckets.get(key);
          if (!e) { e = { r: 0, g: 0, b: 0, n: 0 }; buckets.set(key, e); }
          e.r += r; e.g += g; e.b += b; e.n++;
        }
        const arr = [...buckets.values()].map((e) => ({ r: e.r / e.n, g: e.g / e.n, b: e.b / e.n, n: e.n }));
        arr.sort((x, y) => y.n - x.n);
        resolve(arr);
      } catch (e) { resolve([]); }
    };
    img.onerror = () => resolve([]);
    img.src = dataUrl;
  });
}

/* Merge dominant colors across the brand images into a diverse palette of hex
   swatches (most-frequent first, near-duplicates dropped). */
function extractBrandPalette(sources) {
  const list = sources.filter(Boolean);
  if (!list.length) return Promise.resolve([]);
  return Promise.all(list.map(sampleImageColors)).then((lists) => {
    const all = [];
    lists.forEach((l) => { for (let i = 0; i < l.length; i++) all.push(l[i]); });
    all.sort((a, b) => b.n - a.n);
    const dist = (a, b) => Math.abs(a.r - b.r) + Math.abs(a.g - b.g) + Math.abs(a.b - b.b);
    const chosen = [];
    for (const c of all) {
      if (chosen.every((x) => dist(x, c) > 50)) chosen.push(c);
      if (chosen.length >= 8) break;
    }
    return chosen.map((c) => "#" + [c.r, c.g, c.b].map((v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, "0")).join(""));
  });
}

/* One picker that edits background / value / label colors via a role tab, and
   warns when value or label contrast against the background is too low. */
function RoleColorPicker({ pass, set, accent = "#ff7e3d" }) {
  const [extracted, setExtracted] = React.useState([]);
  // Re-derive a brand palette whenever the uploaded imagery changes.
  React.useEffect(() => {
    let alive = true;
    const srcs = [pass.logoSrc, pass.iconSrc, pass.stripSrc, pass.heroSrc].filter(Boolean);
    if (!srcs.length) { setExtracted([]); return; }
    extractBrandPalette(srcs).then((hexes) => { if (alive) setExtracted(hexes); });
    return () => { alive = false; };
  }, [pass.logoSrc, pass.iconSrc, pass.stripSrc, pass.heroSrc]);
  const brandSwatches = React.useMemo(() => {
    const seen = new Set(); const out = [];
    [...(pass.brandColors || []), ...extracted].forEach((c) => {
      const k = (c || "").toLowerCase();
      if (k && !seen.has(k)) { seen.add(k); out.push(c); }
    });
    return out.slice(0, 10);
  }, [pass.brandColors, extracted]);
  const [role, setRole] = React.useState("background");
  const eff = effectiveColors(pass);
  const roleVal = { background: eff.background, foreground: eff.foreground, label: eff.label }[role];
  const roleKey = { background: "backgroundColor", foreground: "foregroundColor", label: "labelColor" }[role];
  const cFg = contrastRatio(eff.foreground, eff.background);
  const cLabel = contrastRatio(eff.label, eff.background);
  const warnValues = cFg < 3;
  const warnLabels = cLabel < 1.7;
  return (
    <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
      <Segmented value={role} onChange={setRole} options={[
        { value: "background", label: T("background") },
        { value: "foreground", label: T("values") },
        { value: "label", label: T("labels") },
      ]} />
      <ColorPicker value={roleVal} onChange={(v) => set({ [roleKey]: v })} />
      {brandSwatches.length > 0 && (
        <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
          <div style={{ display: "flex", alignItems: "center", gap: 7 }}>
            <FieldLabel>From your brand</FieldLabel>
            <span style={{ fontSize: 10, fontWeight: 600, letterSpacing: ".02em", color: accent, background: accent + "1f", borderRadius: 99, padding: "1px 7px" }}>Auto</span>
          </div>
          <div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
            {brandSwatches.map((c, i) => (
              <button key={c + i} onClick={() => set({ [roleKey]: c })} aria-label={c} title={c} className="spoonity-swatch-in" style={{
                width: 32, height: 32, borderRadius: 9, background: c, cursor: "pointer",
                border: roleVal === c ? "2px solid var(--typography-primary)" : "1px solid rgba(0,0,0,.1)",
                outline: roleVal === c ? "2px solid var(--surface-base)" : "none", outlineOffset: -4,
                animationDelay: (i * 0.04) + "s",
              }} />
            ))}
          </div>
        </div>
      )}
      {(warnValues || warnLabels) && (
        <div style={{ display: "flex", gap: 8, alignItems: "flex-start", padding: "8px 10px", borderRadius: 9, background: "var(--surface-secondary)", border: "1px solid var(--border-primary)" }}>
          <span style={{ color: "var(--warning, #cc6716)", fontWeight: 700, fontSize: 13, lineHeight: "16px" }}>!</span>
          <span style={{ fontSize: 11, color: "var(--typography-secondary)", lineHeight: 1.4 }}>
            Low contrast — {warnValues ? "values" : "labels"} may be hard to read on this background. Pick colors with clearer contrast.
          </span>
        </div>
      )}
    </div>
  );
}

/* ---- Generate Design from URL (Brandfetch API) ---- */
function GenerateDesignModal({ accent, onApply, onClose }) {
  const [url, setUrl] = React.useState("");
  const [status, setStatus] = React.useState("idle"); // idle | loading | done | error
  const [error, setError] = React.useState("");

  const extract = (input) => {
    try {
      let s = input.trim();
      if (!/^https?:\/\//i.test(s)) s = "https://" + s;
      return new URL(s).hostname.replace(/^www\./, "");
    } catch (e) { return null; }
  };

  const toDataUrl = (src) =>
    fetch(src)
      .then((r) => r.blob())
      .then((blob) => new Promise((res, rej) => {
        const reader = new FileReader();
        reader.onload = () => res(reader.result);
        reader.onerror = rej;
        reader.readAsDataURL(blob);
      }));

  // Extract dominant non-white color from a data URL via canvas sampling
  const extractColor = (imgUrl) => new Promise((resolve) => {
    const img = new Image();
    // No crossOrigin needed — data URLs are same-origin and never taint the canvas
    img.onload = () => {
      try {
        const size = 64;
        const canvas = document.createElement("canvas");
        canvas.width = size; canvas.height = size;
        const ctx = canvas.getContext("2d");
        ctx.drawImage(img, 0, 0, size, size);
        const { data } = ctx.getImageData(0, 0, size, size);
        // Collect RGB pixels with decent saturation (skip near-white, near-black, near-grey)
        const buckets = {};
        for (let i = 0; i < data.length; i += 4) {
          const r = data[i], g = data[i+1], b = data[i+2], a = data[i+3];
          if (a < 128) continue; // skip transparent
          const max = Math.max(r,g,b), min = Math.min(r,g,b), diff = max - min;
          const brightness = (0.299*r + 0.587*g + 0.114*b) / 255;
          if (brightness > 0.90 || brightness < 0.08 || diff < 30) continue; // skip white/black/grey
          const key = `${Math.round(r/16)*16},${Math.round(g/16)*16},${Math.round(b/16)*16}`;
          buckets[key] = (buckets[key] || 0) + 1;
        }
        const best = Object.entries(buckets).sort((a,b) => b[1]-a[1])[0];
        if (!best) { resolve(null); return; }
        const [r,g,b] = best[0].split(",").map(Number);
        resolve("#" + [r,g,b].map((n) => n.toString(16).padStart(2,"0")).join(""));
      } catch (e) { resolve(null); }
    };
    img.onerror = () => resolve(null);
    img.src = imgUrl;
  });

  const generate = async () => {
    const domain = extract(url);
    if (!domain) { setError("Enter a valid website URL."); return; }
    setStatus("loading"); setError("");
    try {
      // Brand lookup goes through the backend proxy — the Brandfetch key stays server-side.
      const base = (window.SpoonityAuth && window.SpoonityAuth.getEndpoint()) || DEFAULT_ENDPOINT;
      const res = await fetch(`${base}/api/brand/${encodeURIComponent(domain)}`, {
        credentials: "include",
      });
      if (!res.ok) throw new Error("Brand lookup responded " + res.status);
      const data = await res.json();

      // Collect non-white brand colors; pick one at random for the background
      const brightnessPct = (hex) => {
        if (!hex || hex[0] !== "#") return 0;
        const h = hex.length === 4 ? hex.slice(1).split("").map((c) => c + c).join("") : hex.slice(1, 7);
        const [r, g, b] = [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16));
        return 0.299 * r + 0.587 * g + 0.114 * b;
      };
      const palette = (data.colors || []).filter((c) => c.hex);
      const brandColorHexes = palette.map((c) => c.hex);
      // Random pick for background — exclude near-white so the pass is readable
      const nonWhite = brandColorHexes.filter((h) => brightnessPct(h) < 230);
      const bgColor = (nonWhite.length ? nonWhite : brandColorHexes)[Math.floor(Math.random() * (nonWhite.length || brandColorHexes.length))] || null;

      // All logo formats — split into square icons vs wide logos by aspect ratio
      const allFormats = (data.logos || []).flatMap((l) => l.formats || []).filter((f) => f.src);
      const isSquarish = (f) => f.width && f.height && (f.width / f.height) < 1.5;
      const isWide     = (f) => !f.width || !f.height || (f.width / f.height) >= 1.5;
      const prefer = (arr, fmt) => arr.find((f) => f.format === fmt) || arr[0];

      const iconFormat  = prefer(allFormats.filter(isSquarish), "png") || prefer(allFormats, "png");
      const logoFormat  = prefer(allFormats.filter(isWide).filter((f) => f.format === "png" || f.format === "svg"), "png");

      // Use the actual images[] array for the banner (widest by pixel area)
      const imageFormats = (data.images || []).flatMap((img) => img.formats || []).filter((f) => f.src);
      const bannerFormat = imageFormats.sort((a, b) => ((b.width || 0) * (b.height || 1)) - ((a.width || 0) * (a.height || 1)))[0];

      const tryDataUrl = (src) => src ? toDataUrl(src).catch(() => null) : Promise.resolve(null);

      const [logoData, iconData, bannerData] = await Promise.all([
        tryDataUrl(logoFormat && logoFormat.src),
        tryDataUrl(iconFormat && iconFormat.src),
        tryDataUrl(bannerFormat && bannerFormat.src),
      ]);

      const brandName = (data.name || "").trim();
      const patch = {};
      if (bgColor)              patch.backgroundColor = bgColor;
      if (brandColorHexes.length) patch.brandColors = brandColorHexes;
      if (logoData)             patch.logoSrc  = logoData;
      if (iconData)             patch.iconSrc  = iconData;
      if (bannerData)           patch.stripSrc = bannerData;
      if (brandName)            { patch.company = brandName; patch.name = brandName + " Rewards"; }

      onApply(patch);
      setStatus("done");
    } catch (e) {
      setError((e && e.message) || "Failed to fetch brand data."); setStatus("error");
    }
  };

  if (status === "done") {
    onClose();
    return null;
  }

  return (<Modal>
    <div onClick={onClose} style={{ position: "fixed", inset: 0, zIndex: 1300, background: "rgba(17,24,39,.55)", display: "flex", alignItems: "center", justifyContent: "center", padding: 20 }}>
      <div onClick={(e) => e.stopPropagation()} style={{ background: "var(--surface-base)", borderRadius: 16, padding: 22, width: 420, maxWidth: "100%", boxShadow: "var(--shadow-popup)" }}>
        <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 4 }}>
          <span style={{ fontSize: 17, fontWeight: 600, color: "var(--typography-primary)" }}>Generate design</span>
          <button type="button" onClick={onClose} aria-label="Close" style={{ border: "none", background: "transparent", cursor: "pointer", fontSize: 17, color: "var(--typography-tertiary)" }}>✕</button>
        </div>
        <div style={{ fontSize: 12, color: "var(--typography-tertiary)", marginBottom: 16 }}>
          Enter a website URL to pull in the brand's dominant color, logo, and icon automatically.
        </div>
        <BF.Input
          value={url}
          onChange={(e) => setUrl(e.target.value)}
          placeholder="https://yoursite.com"
          spellCheck={false}
          onKeyDown={(e) => { if (e.key === "Enter") generate(); }}
        />
        {error && <div style={{ marginTop: 8, fontSize: 12, color: "var(--critical, #c3223f)" }}>{error}</div>}
        <div style={{ display: "flex", gap: 10, justifyContent: "flex-end", marginTop: 16 }}>
          <BF.Button variant="tertiary" size="sm" onClick={onClose}>Cancel</BF.Button>
          <BF.Button size="sm" disabled={status === "loading" || !url.trim()} style={{ background: accent, color: "#fff" }} onClick={generate}>
            {status === "loading" ? "Fetching…" : "Generate"}
          </BF.Button>
        </div>
      </div>
    </div>
  </Modal>);
}

/* Build the exact JSON contract the backend's POST /design expects. */
const ENDPOINT_KEY = "spoonity_design_endpoint";
const DEFAULT_ENDPOINT = "https://wallet-api.spoonity.com";
// Customer-facing landing page, and the same page in design mode. Used as
// fallbacks unless the backend returns landingUrl / designerUrl.
const LANDING_CUSTOMER_URL = "https://spoonity-wallets.web.app";
const LANDING_DESIGNER_URL = "https://spoonity-wallets.web.app/?design=1";
function buildDesignPayload(pass) {
  const eff = effectiveColors(pass);
  // Apple uses company/name/stripSrc; Google uses programName/cardTitle/heroSrc.
  // Map both onto the one contract the backend expects.
  const payload = {
    company: (pass.company || pass.programName || "").trim(),
    name: (pass.name || pass.cardTitle || "").trim(),
    colors: { background: eff.background, foreground: eff.foreground, label: eff.label },
    logo: dataUrlToBase64(pass.logoSrc),
    icon: dataUrlToBase64(pass.iconSrc),
  };
  const strip = pass.stripSrc || pass.heroSrc;
  if (strip) payload.strip = dataUrlToBase64(strip);

  // Per-store Spoonity vendor id — comes from the authenticated session (set at login),
  // not user input. The backend re-derives/validates it from the session too; we send it so
  // the returned enrollUrl carries the right ?v=<vendorId>.
  const v = (window.SpoonityAuth && window.SpoonityAuth.activeVendorId()) || String(pass.vendorId || "").trim();
  if (v) payload.vendorId = v;

  // Exact name of this store's points in Spoonity (e.g. "Cutters Points").
  const lc = String(pass.loyaltyCurrency || "").trim();
  if (lc) payload.loyaltyCurrency = lc;

  // Optional geofencing: pin up to 10 stores so the pass surfaces on the
  // customer's lock screen when they're physically near one (Apple PassKit
  // location relevance). Omitted entirely unless enabled with valid coords.
  const locs = (pass.locations || [])
    .map((l) => ({
      lat: Number(l.lat), lon: Number(l.lon),
      ...(l.relevantText ? { relevantText: String(l.relevantText).slice(0, 120) } : {}),
      ...(l.name ? { name: String(l.name).slice(0, 60) } : {}),
    }))
    .filter((l) => Number.isFinite(l.lat) && Number.isFinite(l.lon)
      && Math.abs(l.lat) <= 90 && Math.abs(l.lon) <= 180)
    .slice(0, 10);
  if (pass.geofencingEnabled && locs.length) payload.locations = locs;

  // Custom field titles: let the merchant rename the pass field labels (e.g.
  // POINTS -> STARS) while the field's KEY and VALUE stay backend-driven. Only
  // fields the merchant actually renamed are sent, keyed by their stable key.
  const fieldLabels = {};
  ["headerFields", "primaryFields", "secondaryFields", "auxiliaryFields"].forEach((g) =>
    (pass[g] || []).forEach((f) => {
      const custom = (f && f.labelText ? String(f.labelText) : "").trim();
      if (f && f.key && custom) fieldLabels[f.key] = custom.slice(0, 40);
    })
  );
  if (Object.keys(fieldLabels).length) payload.fieldLabels = fieldLabels;

  return payload;
}

/* "Create my pass" flow: validate, show the outgoing JSON, POST it to the
   backend's /design endpoint, then surface the returned templateId + enrollUrl. */
/* In-app JSON viewer — shows the JSON in an overlay with download + copy,
   instead of opening a separate browser tab. */
function JsonOverlay({ data, filename, onClose }) {
  const json = React.useMemo(() => JSON.stringify(data, null, 2), [data]);
  const [copied, setCopied] = React.useState(false);
  const mono = { fontFamily: "var(--font-mono, 'Source Code Pro', monospace)", fontSize: 12 };
  const download = () => {
    const b = new Blob([json], { type: "application/json" });
    const a = document.createElement("a");
    a.href = URL.createObjectURL(b); a.download = filename; a.click();
    setTimeout(() => URL.revokeObjectURL(a.href), 1000);
  };
  const copyJson = () => {
    try { navigator.clipboard.writeText(json); setCopied(true); setTimeout(() => setCopied(false), 1500); } catch (e) {}
  };
  return (<Modal>
    <div onClick={(e) => { e.stopPropagation(); onClose(); }} style={{ position: "fixed", inset: 0, zIndex: 1400, background: "rgba(17,24,39,.55)", display: "flex", alignItems: "center", justifyContent: "center", padding: 20 }}>
      <div onClick={(e) => e.stopPropagation()} style={{ background: "var(--surface-base)", borderRadius: 16, padding: 22, width: 640, maxWidth: "100%", maxHeight: "90vh", display: "flex", flexDirection: "column", boxShadow: "var(--shadow-popup)" }}>
        <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 14 }}>
          <span style={{ fontSize: 16, fontWeight: 600, color: "var(--typography-primary)" }}>{filename}</span>
          <button type="button" onClick={onClose} aria-label="Close" style={{ border: "none", background: "transparent", cursor: "pointer", fontSize: 17, color: "var(--typography-tertiary)" }}>✕</button>
        </div>
        <div style={{ display: "flex", gap: 10, marginBottom: 14 }}>
          <BF.Button size="sm" onClick={download}>Download JSON</BF.Button>
          <BF.Button variant="tertiary" size="sm" onClick={copyJson}>{copied ? "Copied" : "Copy JSON"}</BF.Button>
        </div>
        <pre style={{ ...mono, margin: 0, padding: "14px 16px", borderRadius: 10, background: "var(--surface-secondary)", border: "1px solid var(--border-primary)", overflow: "auto", whiteSpace: "pre-wrap", wordBreak: "break-word", color: "var(--typography-secondary)", flex: 1, minHeight: 0, lineHeight: 1.55 }}>{json}</pre>
      </div>
    </div>
  </Modal>);
}

function PublishModal({ pass, accent, onClose }) {
  const [endpoint, setEndpoint] = React.useState(() => {
    try {
      const saved = localStorage.getItem(ENDPOINT_KEY);
      // Ignore a stale *.run.app value — cookie auth needs the same-site wallet-api.spoonity.com.
      if (saved && !/run\.app/.test(saved)) return saved;
    } catch (e) {}
    return DEFAULT_ENDPOINT;
  });
  const [status, setStatus] = React.useState("idle"); // idle | sending | finishing | done | error
  const [result, setResult] = React.useState(null);
  const [error, setError] = React.useState("");
  const [copied, setCopied] = React.useState("");
  const [jsonView, setJsonView] = React.useState(null); // { data, filename } | null
  const [showAdvanced, setShowAdvanced] = React.useState(false);
  const [progress, setProgress] = React.useState(0);
  const resultRef = React.useRef(null);

  // Trickle the progress bar toward ~92% with a living, varying speed while the
  // backend works (Render cold starts can take ~20s).
  React.useEffect(() => {
    if (status !== "sending") return;
    let raf, last = performance.now();
    const loop = (now) => {
      const dt = Math.min(0.05, (now - last) / 1000); last = now;
      setProgress((prev) => {
        const cap = 92;
        // Combine a slow swell with sharp occasional bursts so it sometimes
        // crawls and sometimes surges forward.
        const swell = 0.5 + 0.5 * Math.sin(now / 900);
        const burst = Math.pow(0.5 + 0.5 * Math.sin(now / 190), 4);
        const speed = 0.35 + 0.55 * swell + 1.6 * burst;
        const next = prev + (cap - prev) * Math.min(0.9, speed * dt);
        return next < cap ? next : cap;
      });
      raf = requestAnimationFrame(loop);
    };
    raf = requestAnimationFrame(loop);
    return () => cancelAnimationFrame(raf);
  }, [status]);

  // Response arrived: race the bar to 100%, then reveal the result.
  React.useEffect(() => {
    if (status !== "finishing") return;
    setProgress(100);
    const id = setTimeout(() => { setResult(resultRef.current); setStatus("done"); }, 620);
    return () => clearTimeout(id);
  }, [status]);

  const payload = React.useMemo(() => buildDesignPayload(pass), [pass]);
  const problems = [];
  if (!payload.company) problems.push("Company name");
  if (!payload.name) problems.push("Program name");
  if (!payload.icon) problems.push("Icon image (Apple requires it)");
  if (!payload.vendorId) problems.push("Sign in to select a vendor");
  if (!payload.loyaltyCurrency) problems.push("Loyalty currency name");
  const blocked = problems.length > 0;

  // Human-readable preview — real base64 is huge, so show its size instead.
  const sizeTag = (b64) => `«base64 PNG · ${Math.max(1, Math.round((b64.length * 0.75) / 1024))} KB»`;
  const prettyJson = React.useMemo(() => {
    const p = { ...payload, colors: payload.colors };
    p.logo = payload.logo ? sizeTag(payload.logo) : "";
    p.icon = payload.icon ? sizeTag(payload.icon) : "";
    if (payload.strip) p.strip = sizeTag(payload.strip);
    return JSON.stringify(p, null, 2);
  }, [payload]);

  const copy = (text, tag) => {
    try { navigator.clipboard.writeText(text); setCopied(tag); setTimeout(() => setCopied(""), 1500); } catch (e) {}
  };

  const openJson = (data, filename) => setJsonView({ data, filename });

  const submit = async () => {
    if (blocked || !endpoint.trim()) return;
    try { localStorage.setItem(ENDPOINT_KEY, endpoint.trim()); } catch (e) {}
    setProgress(0); setStatus("sending"); setError("");
    try {
      const url = endpoint.trim().replace(/\/+$/, "") + "/design";
      const res = await fetch(url, {
        method: "POST",
        credentials: "include",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(payload),
      });
      if (res.status === 401) {
        window.dispatchEvent(new Event("spoonity-auth-expired")); // session lost -> show login
        throw new Error("Your session expired — please sign in again.");
      }
      if (!res.ok) throw new Error("Backend responded " + res.status + " " + res.statusText);
      resultRef.current = await res.json();
      setStatus("finishing"); // let the bar complete before revealing the link
    } catch (e) {
      setError((e && e.message) || "Request failed — check the endpoint URL and CORS."); setStatus("error");
    }
  };

  const fieldLabel = { fontSize: 11, fontWeight: 600, letterSpacing: ".05em", textTransform: "uppercase", color: "var(--typography-tertiary)" };
  const mono = { fontFamily: "var(--font-mono, 'Source Code Pro', monospace)", fontSize: 12 };

  return (<Modal>
    <div onClick={onClose} style={{ position: "fixed", inset: 0, zIndex: 1300, background: "rgba(17,24,39,.55)", display: "flex", alignItems: "center", justifyContent: "center", padding: 20 }}>
      <div onClick={(e) => e.stopPropagation()} style={{ background: "var(--surface-base)", borderRadius: 16, padding: 22, width: 480, maxWidth: "100%", maxHeight: "90vh", overflowY: "auto", boxShadow: "var(--shadow-popup)" }}>
        <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 4 }}>
          <span style={{ fontSize: 17, fontWeight: 600, color: "var(--typography-primary)" }}>Create my pass</span>
          <button type="button" onClick={onClose} aria-label="Close" style={{ border: "none", background: "transparent", cursor: "pointer", fontSize: 17, color: "var(--typography-tertiary)" }}>✕</button>
        </div>
        <div style={{ fontSize: 12, color: "var(--typography-tertiary)", marginBottom: 16 }}>Sends this design to the backend, which builds the real Apple / Google Wallet pass.</div>

        {status === "done" && result ? (
          <div style={{ display: "flex", flexDirection: "column", gap: 18, padding: "2px 0 4px" }}>
            <div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 10, textAlign: "center" }}>
              <div style={{ width: 48, height: 48, borderRadius: "50%", background: "var(--success, #3f7d58)", color: "#fff", display: "flex", alignItems: "center", justifyContent: "center", fontSize: 24, lineHeight: 1, boxShadow: "0 6px 18px -6px var(--success, #3f7d58)" }}>✓</div>
              <span style={{ fontSize: 18, fontWeight: 600, color: "var(--typography-primary)" }}>Your pass is ready</span>
              <span style={{ fontSize: 12.5, color: "var(--typography-tertiary)", lineHeight: 1.45, maxWidth: 300 }}>Share this link so people can add your pass to their wallet.</span>
            </div>

            {result.enrollUrl && (
              <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
                <a href={result.enrollUrl} target="_blank" rel="noreferrer"
                  style={{ ...mono, display: "block", padding: "13px 14px", borderRadius: 12, background: "var(--surface-secondary)", border: "1px solid var(--border-primary)", color: accent, wordBreak: "break-all", textDecoration: "none", textAlign: "center" }}>
                  {result.enrollUrl}
                </a>
                <div style={{ display: "flex", gap: 10 }}>
                  <BF.Button onClick={() => copy(result.enrollUrl, "enroll")} style={{ flex: 2, background: accent, color: "#fff", padding: "12px 0", fontWeight: 600 }}>
                    {copied === "enroll" ? "Copied!" : "Copy link"}
                  </BF.Button>
                  <BF.Button variant="tertiary" onClick={() => window.open(result.enrollUrl, "_blank")} style={{ flex: 1, padding: "12px 0" }}>Open</BF.Button>
                </div>
              </div>
            )}

            {/* Customer landing page + the link to design it. */}
            <div style={{ display: "flex", flexDirection: "column", gap: 10, paddingTop: 16, borderTop: "1px solid var(--border-primary)" }}>
              <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
                <span style={fieldLabel}>Customer landing page</span>
                <div style={{ display: "flex", gap: 8, alignItems: "center" }}>
                  <a href={result.landingUrl || LANDING_CUSTOMER_URL} target="_blank" rel="noreferrer"
                    style={{ ...mono, flex: 1, padding: "10px 12px", borderRadius: 10, background: "var(--surface-secondary)", border: "1px solid var(--border-primary)", color: "var(--typography-secondary)", wordBreak: "break-all", textDecoration: "none" }}>
                    {result.landingUrl || LANDING_CUSTOMER_URL}
                  </a>
                  <BF.Button variant="tertiary" size="sm" onClick={() => copy(result.landingUrl || LANDING_CUSTOMER_URL, "landing")}>{copied === "landing" ? "Copied" : "Copy"}</BF.Button>
                </div>
                <span style={{ fontSize: 11, color: "var(--typography-tertiary)", lineHeight: 1.4 }}>The branded page where your customers sign up.</span>
              </div>
              <BF.Button variant="tertiary" size="sm" onClick={() => window.open(result.designerUrl || LANDING_DESIGNER_URL, "_blank")} style={{ alignSelf: "flex-start" }}>Design your landing page →</BF.Button>
            </div>

            <div style={{ display: "flex", gap: 10, justifyContent: "space-between", alignItems: "center" }}>
              <BF.Button variant="ghost" size="sm" onClick={() => openJson({ ...payload, response: result }, "pass-design.json")}>Export JSON</BF.Button>
              <button type="button" onClick={onClose} style={{ border: "none", background: "transparent", cursor: "pointer", fontFamily: "var(--font-sans)", fontSize: 13, color: "var(--typography-tertiary)", padding: "2px 8px" }}>Done</button>
            </div>
          </div>
        ) : (status === "sending" || status === "finishing") ? (
          <div style={{ display: "flex", flexDirection: "column", gap: 14, padding: "12px 0 18px" }}>
            <div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between" }}>
              <span style={{ fontSize: 14, fontWeight: 600, color: "var(--typography-primary)" }}>
                {status === "finishing" ? "Finishing up"
                  : progress < 25 ? "Uploading your design…"
                  : progress < 55 ? "Building the pass template…"
                  : progress < 80 ? "Registering with the wallet…"
                  : "Almost there…"}
              </span>
              <span style={{ ...mono, color: "var(--typography-tertiary)" }}>{Math.round(progress)}%</span>
            </div>
            <div style={{ position: "relative", height: 8, borderRadius: 99, background: "var(--surface-secondary)", border: "1px solid var(--border-primary)", overflow: "hidden" }}>
              <div
                className="spoonity-bar-shine"
                style={{
                  position: "absolute", left: 0, top: 0, bottom: 0, width: progress + "%", borderRadius: 99,
                  background: `linear-gradient(90deg, ${accent}cc, ${accent})`,
                  boxShadow: `0 0 14px ${accent}66`,
                  transition: status === "finishing" ? "width .55s cubic-bezier(.2,.8,.2,1)" : "none",
                }}
              />
            </div>
            <div style={{ fontSize: 11, color: "var(--typography-tertiary)", lineHeight: 1.4 }}>
              Building a real wallet pass. The first request after a while can take ~20s while the backend wakes up.
            </div>
          </div>
        ) : (
          <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
            {blocked && (
              <div style={{ padding: "10px 12px", borderRadius: 9, background: "var(--surface-secondary)", border: "1px solid var(--border-primary)", fontSize: 12, color: "var(--typography-secondary)" }}>
                Add before sending: {problems.join(", ")}.
              </div>
            )}

            {/* Developer details — endpoint + request body, collapsed by default. */}
            <div style={{ borderRadius: 10, border: "1px solid var(--border-primary)", overflow: "hidden" }}>
              <button
                type="button"
                aria-expanded={showAdvanced}
                onClick={() => setShowAdvanced((v) => !v)}
                style={{ width: "100%", display: "flex", alignItems: "center", gap: 9, padding: "10px 12px", background: "var(--surface-secondary)", border: "none", cursor: "pointer", fontFamily: "var(--font-sans)" }}
              >
                <span aria-hidden="true" style={{ width: 0, height: 0, borderTop: "5px solid transparent", borderBottom: "5px solid transparent", borderLeft: "6px solid var(--typography-tertiary)", transform: showAdvanced ? "rotate(90deg)" : "rotate(0deg)", transition: "transform .15s ease" }} />
                <span style={{ fontSize: 12, fontWeight: 600, color: "var(--typography-secondary)" }}>Developer details</span>
                <span style={{ marginLeft: "auto", fontSize: 11, color: "var(--typography-tertiary)" }}>endpoint · request body</span>
              </button>
              {showAdvanced && (
                <div style={{ display: "flex", flexDirection: "column", gap: 12, padding: 12, borderTop: "1px solid var(--border-primary)" }}>
                  <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
                    <span style={fieldLabel}>Backend endpoint</span>
                    <BF.Input value={endpoint} placeholder="https://your-backend.example.com" spellCheck={false} onChange={(e) => setEndpoint(e.target.value)} />
                    <span style={{ fontSize: 11, color: "var(--typography-tertiary)" }}>POSTs to <code style={mono}>{(endpoint.trim().replace(/\/+$/, "") || "<endpoint>") + "/design"}</code></span>
                  </div>
                  <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
                    <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
                      <span style={fieldLabel}>Request body</span>
                      <button
                        type="button"
                        onClick={() => copy(JSON.stringify(payload, null, 2), "body")}
                        style={{ display: "inline-flex", alignItems: "center", gap: 5, border: "1px solid var(--border-primary)", background: "var(--surface-base)", borderRadius: 7, padding: "4px 9px", cursor: "pointer", fontFamily: "var(--font-sans)", fontSize: 11, fontWeight: 600, color: "var(--typography-secondary)" }}
                      >
                        <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
                        {copied === "body" ? "Copied" : "Copy"}
                      </button>
                    </div>
                    <pre style={{ ...mono, margin: 0, padding: "11px 12px", borderRadius: 9, background: "var(--surface-secondary)", border: "1px solid var(--border-primary)", maxHeight: 180, overflow: "auto", whiteSpace: "pre-wrap", wordBreak: "break-word", color: "var(--typography-secondary)" }}>{prettyJson}</pre>
                    <span style={{ fontSize: 11, color: "var(--typography-tertiary)" }}>Image data is abbreviated here for readability — Copy and Export JSON include the full base64.</span>
                  </div>
                  <BF.Button variant="ghost" size="sm" onClick={() => openJson(payload, "pass-config.json")}>Export JSON</BF.Button>
                </div>
              )}
            </div>

            {status === "error" && <div style={{ fontSize: 12, color: "var(--critical, #c3223f)" }}>{error}</div>}

            {/* Primary action. */}
            <BF.Button disabled={blocked || !endpoint.trim() || status === "sending"} style={{ width: "100%", background: accent, color: "#fff", padding: "13px 0", fontSize: 15, fontWeight: 600 }} onClick={submit}>
              {status === "sending" ? "Sending…" : "Send to backend"}
            </BF.Button>
            <button type="button" onClick={onClose} style={{ alignSelf: "center", border: "none", background: "transparent", cursor: "pointer", fontFamily: "var(--font-sans)", fontSize: 13, color: "var(--typography-tertiary)", padding: "2px 8px" }}>Cancel</button>
          </div>
        )}
        {jsonView && (
          <JsonOverlay data={jsonView.data} filename={jsonView.filename} onClose={() => setJsonView(null)} />
        )}
      </div>
    </div>
  </Modal>);
}

function LanguageSelect({ value, onChange }) {
  return (
    <div style={{ display: "flex", alignItems: "center", gap: 9 }}>
      <span style={{ fontSize: 11.5, fontWeight: 500, letterSpacing: ".005em", color: "var(--typography-tertiary)", whiteSpace: "nowrap" }}>{T("language")}</span>
      <div style={{ position: "relative", flex: 1 }}>
        <select
          value={value}
          onChange={(e) => onChange && onChange(e.target.value)}
          aria-label={T("language")}
          style={{
            width: "100%", appearance: "none", WebkitAppearance: "none", MozAppearance: "none", cursor: "pointer",
            fontFamily: "var(--font-sans)", fontSize: 13, color: "var(--typography-primary)",
            background: "var(--surface-primary)", border: "1px solid var(--border-secondary)",
            borderRadius: 10, padding: "9px 30px 9px 11px", height: 38,
          }}
        >
          {I18N.LANGS.map((l) => <option key={l.code} value={l.code}>{l.label}</option>)}
        </select>
        <span aria-hidden="true" style={{ position: "absolute", right: 11, top: "50%", transform: "translateY(-50%)", width: 0, height: 0, borderLeft: "4px solid transparent", borderRight: "4px solid transparent", borderTop: "5px solid var(--typography-tertiary)", pointerEvents: "none" }} />
      </div>
    </div>
  );
}

/* ---- Store locations (Apple Wallet geofencing) ----
   Collect up to 10 store coordinates so the pass surfaces on a customer's lock
   screen when they're near one. Three ways to set coords, in order of ease:
   address search (free OSM Nominatim, no key), or manual lat/lon entry. */
const GEO_MAX = 10;
const defaultRelevantText = (company) => `You're near ${(company || "us").trim()} — open your rewards pass`;

function geoNum(v) { const n = Number(v); return Number.isFinite(n) ? n : NaN; }
function rowValid(l) {
  const lat = geoNum(l.lat), lon = geoNum(l.lon);
  return Number.isFinite(lat) && Number.isFinite(lon) && Math.abs(lat) <= 90 && Math.abs(lon) <= 180;
}

/* One store row: address lookup + resolved/manual coords + label + message. */
function LocationRow({ index, loc, company, accent, onChange, onRemove }) {
  const [query, setQuery] = React.useState(loc.address || "");
  const [searching, setSearching] = React.useState(false);
  const [searchMsg, setSearchMsg] = React.useState("");

  // Debounced free-text geocode via OpenStreetMap Nominatim (no API key).
  // Note: browsers forbid setting User-Agent on fetch; Nominatim accepts the
  // automatic Referer/User-Agent the browser sends. Keep volume low + debounced.
  React.useEffect(() => {
    const q = query.trim();
    if (q.length < 4) { setSearchMsg(""); return; }
    if (q === (loc.address || "").trim() && rowValid(loc)) return; // already resolved
    let alive = true;
    const id = setTimeout(() => {
      setSearching(true); setSearchMsg("");
      fetch(`https://nominatim.openstreetmap.org/search?format=json&limit=1&q=${encodeURIComponent(q)}`, {
        headers: { "Accept-Language": "en" },
      })
        .then((r) => r.json())
        .then((data) => {
          if (!alive) return;
          setSearching(false);
          if (data && data[0]) {
            const hit = data[0];
            onChange({
              address: q,
              lat: parseFloat(hit.lat).toFixed(6),
              lon: parseFloat(hit.lon).toFixed(6),
              name: loc.name || (hit.display_name || "").split(",")[0] || "",
            });
            setSearchMsg("Found: " + (hit.display_name || "").slice(0, 70));
          } else {
            setSearchMsg("No match — refine the address or enter coordinates below.");
          }
        })
        .catch(() => { if (alive) { setSearching(false); setSearchMsg("Lookup failed — enter coordinates manually."); } });
    }, 700);
    return () => { alive = false; clearTimeout(id); };
    // eslint-disable-next-line
  }, [query]);

  const valid = rowValid(loc);
  const hasCoords = String(loc.lat || "").length > 0 || String(loc.lon || "").length > 0;
  const rt = loc.relevantText || "";

  return (
    <div style={{ display: "flex", flexDirection: "column", gap: 8, padding: 12, borderRadius: 10, border: "1px solid var(--border-primary)", background: "var(--surface-secondary)" }}>
      <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
        <span style={{ fontSize: 11, fontWeight: 700, letterSpacing: ".04em", color: "var(--typography-tertiary)" }}>STORE {index + 1}</span>
        <button type="button" onClick={onRemove} aria-label="Remove store" style={{ border: "none", background: "transparent", cursor: "pointer", fontSize: 13, color: "var(--typography-tertiary)", padding: "2px 6px" }}>Remove</button>
      </div>

      <FieldLabel>Find by address</FieldLabel>
      <BF.Input value={query} placeholder="123 Main St, City" spellCheck={false} onChange={(e) => setQuery(e.target.value)} />
      {(searching || searchMsg) && (
        <span style={{ fontSize: 11, color: "var(--typography-tertiary)", lineHeight: 1.4 }}>{searching ? "Searching…" : searchMsg}</span>
      )}

      <TwoCol>
        <div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
          <FieldLabel>Latitude</FieldLabel>
          <BF.Input value={loc.lat ?? ""} placeholder="-90 to 90" spellCheck={false} onChange={(e) => onChange({ lat: e.target.value })} />
        </div>
        <div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
          <FieldLabel>Longitude</FieldLabel>
          <BF.Input value={loc.lon ?? ""} placeholder="-180 to 180" spellCheck={false} onChange={(e) => onChange({ lon: e.target.value })} />
        </div>
      </TwoCol>
      {hasCoords && !valid && (
        <span style={{ fontSize: 11, color: "var(--critical, #c3223f)", lineHeight: 1.4 }}>
          Enter finite coordinates in range (lat −90…90, lon −180…180). This store won't be sent until fixed.
        </span>
      )}

      <FieldLabel>Store label · optional</FieldLabel>
      <BF.Input value={loc.name || ""} placeholder="Downtown" onChange={(e) => onChange({ name: e.target.value })} />

      <FieldLabel>Lock-screen message · optional</FieldLabel>
      <BF.Input value={rt} placeholder={defaultRelevantText(company)} onChange={(e) => onChange({ relevantText: e.target.value })} />
      <span style={{ fontSize: 11, color: rt.length > 90 ? "var(--warning, #cc6716)" : "var(--typography-tertiary)" }}>
        {rt.length > 90 ? "Keep it short — long messages get truncated on the lock screen." : `${rt.length}/90 chars · shown when the customer is near this store.`}
      </span>
    </div>
  );
}

function StoreLocations({ pass, set, accent = "#ff7e3d", open, onToggle }) {
  const enabled = !!pass.geofencingEnabled;
  const locations = pass.locations || [];
  const company = pass.company || pass.programName || "";

  const updateLoc = (i, patch) => set({ locations: locations.map((l, idx) => (idx === i ? { ...l, ...patch } : l)) });
  const removeLoc = (i) => set({ locations: locations.filter((_, idx) => idx !== i) });
  const addLoc = () => {
    if (locations.length >= GEO_MAX) return;
    set({ locations: [...locations, { name: "", address: "", lat: "", lon: "", relevantText: defaultRelevantText(company) }] });
  };

  const validCount = locations.filter(rowValid).length;
  const hint = enabled ? `${locations.length}/${GEO_MAX}` : T("off");

  return (
    <Section title="Store locations" hint={hint} open={open} onToggle={onToggle}>
      <span style={{ fontSize: 12, color: "var(--typography-tertiary)", lineHeight: 1.45 }}>
        Your pass shows up on a customer's lock screen when they're near one of these stores.
      </span>
      <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
        <FieldLabel>Enable lock-screen geofencing</FieldLabel>
        <BF.Switch checked={enabled} onChange={(on) => set({ geofencingEnabled: on })} />
      </div>

      {enabled && (
        <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
          {locations.map((loc, i) => (
            <LocationRow
              key={i}
              index={i}
              loc={loc}
              company={company}
              accent={accent}
              onChange={(patch) => updateLoc(i, patch)}
              onRemove={() => removeLoc(i)}
            />
          ))}

          <BF.Button variant="tertiary" size="sm" disabled={locations.length >= GEO_MAX} onClick={addLoc}>
            {locations.length === 0 ? "Add a store" : "Add another store"}
          </BF.Button>

          {locations.length >= GEO_MAX && (
            <span style={{ fontSize: 11, color: "var(--typography-tertiary)" }}>Apple allows up to {GEO_MAX} locations per pass.</span>
          )}
          {locations.length > 0 && validCount < locations.length && (
            <span style={{ fontSize: 11, color: "var(--typography-tertiary)" }}>
              {validCount} of {locations.length} {locations.length === 1 ? "store has" : "stores have"} valid coordinates — only valid ones are sent.
            </span>
          )}

          <div style={{ display: "flex", gap: 8, alignItems: "flex-start", padding: "8px 10px", borderRadius: 9, background: "var(--surface-secondary)", border: "1px solid var(--border-primary)" }}>
            <span style={{ color: "var(--typography-tertiary)", fontWeight: 700, fontSize: 13, lineHeight: "16px" }}>i</span>
            <span style={{ fontSize: 11, color: "var(--typography-secondary)", lineHeight: 1.45 }}>
              Apple controls the radius (~100m) and shows a lock-screen <em>suggestion</em>, not a guaranteed push. The customer needs Location Services and Wallet enabled (on by default). These {GEO_MAX === 10 ? "up to 10" : GEO_MAX} stores apply to everyone holding this pass. Apple Wallet today; Google support is a later step.
            </span>
          </div>
        </div>
      )}
    </Section>
  );
}

function BuilderForm({ pass, setPass, accent = "#ff7e3d", onGoogle, lang = "en", onLang, onUndo, onRedo, canUndo, canRedo }) {
  const set = (patch) => setPass({ ...pass, ...patch });
  const setField = (group, i, key, val) => {
    const arr = (pass[group] || []).map((f, idx) => idx === i ? { ...f, [key]: val } : f);
    set({ [group]: arr });
  };
  const [publishing, setPublishing] = React.useState(false);
  const [generating, setGenerating] = React.useState(false);
  const [showSecondary, setShowSecondary] = React.useState(false);
  const [draftStatus, setDraftStatus] = React.useState("idle"); // idle | saving | saved | error
  const [shareUrl, setShareUrl] = React.useState(() => {
    const id = localStorage.getItem("spoonity_share_id_apple");
    return id ? `${location.origin}${location.pathname}?share=${id}` : null;
  });
  const [copied, setCopied] = React.useState(false);
  const [loadOpen, setLoadOpen] = React.useState(false);
  const [saveAsOpen, setSaveAsOpen] = React.useState(false);

  const handleSaveDraft = async () => {
    setDraftStatus("saving");
    try {
      const shareId = await window.saveDraftToSupabase(pass, "apple");
      const url = `${location.origin}${location.pathname}?share=${shareId}`;
      setShareUrl(url);
      setDraftStatus("saved");
      setTimeout(() => setDraftStatus("idle"), 3000);
    } catch (e) {
      console.error("Save draft failed:", e);
      setDraftStatus("error");
      setTimeout(() => setDraftStatus("idle"), 3000);
    }
  };

  const handleCopy = () => {
    if (!shareUrl) return;
    navigator.clipboard.writeText(shareUrl);
    setCopied(true);
    setTimeout(() => setCopied(false), 2000);
  };

  // Accordion: all sections collapsed by default; opening one closes the rest.
  const [openSection, setOpenSection] = React.useState(null);
  const sec = (id) => ({ open: openSection === id, onToggle: () => setOpenSection((cur) => (cur === id ? null : id)) });

  // Pin value/label colors to explicit hex once a background is known, so the
  // live preview and the JSON we send the backend always agree.
  React.useEffect(() => {
    if (pass.foregroundColor && pass.labelColor) return;
    const eff = effectiveColors(pass);
    set({ foregroundColor: eff.foreground, labelColor: eff.label });
    // eslint-disable-next-line
  }, [pass.backgroundColor]);
  const styles = [
    { value: "storeCard", label: T("ptStore") },
    { value: "coupon", label: T("ptCoupon") },
    { value: "eventTicket", label: T("ptTicket") },
    { value: "boardingPass", label: T("ptBoarding") },
    { value: "generic", label: T("ptGeneric") },
  ];
  const isBoarding = pass.passStyle === "boardingPass";
  const stripStyle = ["storeCard", "coupon", "eventTicket"].includes(pass.passStyle);

  return (
    <div style={{ width: "100%", height: "100%", overflowY: "auto", padding: "0 28px", boxSizing: "border-box", background: "var(--surface-base)" }}>
      <div style={{ padding: "20px 0 4px", display: "flex", alignItems: "center", justifyContent: "space-between" }}>
        <div style={{ display: "flex", alignItems: "center", gap: 9 }}>
          <img src="../../assets/apple-mark.svg" alt="Apple Wallet" title="Apple Wallet" style={{ height: 18 }} />
          <span style={{ fontSize: 17, fontWeight: 600, color: "var(--typography-primary)" }}>{T("editPass")}</span>
        </div>
        <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
          <div style={{ display: "flex", gap: 4 }}>
            <button onClick={onUndo} disabled={!canUndo} title="Undo" style={{ border: "none", background: "none", cursor: canUndo ? "pointer" : "default", opacity: canUndo ? 1 : 0.3, padding: "4px 6px", borderRadius: 7, color: "var(--typography-secondary)" }}>
              <svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M2 6h7a4 4 0 0 1 0 8H5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/><path d="M2 6l3-3M2 6l3 3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
            </button>
            <button onClick={onRedo} disabled={!canRedo} title="Redo" style={{ border: "none", background: "none", cursor: canRedo ? "pointer" : "default", opacity: canRedo ? 1 : 0.3, padding: "4px 6px", borderRadius: 7, color: "var(--typography-secondary)" }}>
              <svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M14 6H7a4 4 0 0 0 0 8h4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/><path d="M14 6l-3-3M14 6l-3 3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
            </button>
          </div>
          <div style={{ width: 1, height: 18, background: "var(--border-primary)" }} />
          <PlatformToggle onGoogle={onGoogle} />
        </div>
      </div>

      {onLang && (
        <div style={{ padding: "12px 0 4px" }}>
          <LanguageSelect value={lang} onChange={onLang} />
        </div>
      )}

      <div style={{ padding: "8px 0 4px" }}>
        <button
          type="button"
          onClick={() => setGenerating(true)}
          style={{
            width: "100%", display: "flex", alignItems: "center", justifyContent: "center", gap: 7,
            padding: "9px 14px", borderRadius: 10, border: `1.5px solid ${accent}`,
            background: "transparent", cursor: "pointer", fontFamily: "var(--font-sans)",
            fontSize: 13, fontWeight: 600, color: accent, transition: "background .15s ease",
          }}
          onMouseEnter={(e) => { e.currentTarget.style.background = accent + "18"; }}
          onMouseLeave={(e) => { e.currentTarget.style.background = "transparent"; }}
        >
          <svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
            <path d="M7.5 1v13M1 7.5h13M3.5 3.5l8 8M11.5 3.5l-8 8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
          </svg>
          {T("generateDesign")}
        </button>
      </div>

      <Section title={T("brand")} {...sec("brand")}>
        <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
          <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
            <FieldLabel>{T("company")}</FieldLabel>
            <BF.Input value={pass.company || ""} onChange={(e) => set({ company: e.target.value })} placeholder="Breakfast Bonanza" />
          </div>
          <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
            <FieldLabel>{T("programName")}</FieldLabel>
            <BF.Input value={pass.name || ""} onChange={(e) => set({ name: e.target.value })} placeholder="Breakfast Bonanza Rewards" />
          </div>
          <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
            <FieldLabel>Spoonity Vendor</FieldLabel>
            <div style={{ padding: "10px 12px", borderRadius: 8, background: "var(--surface-secondary, #f4f4f7)", border: "1px solid var(--border, #e3e3ec)", fontSize: 14 }}>
              {(() => {
                const s = window.SpoonityAuth && window.SpoonityAuth.getSession();
                return s && s.vendor ? `${s.vendor.name} · ${s.vendor.id}` : "Not signed in";
              })()}
            </div>
            <span style={{ fontSize: 11, color: "var(--typography-tertiary)", lineHeight: 1.35 }}>
              Passes are created for your signed-in Spoonity vendor.
            </span>
          </div>
          <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
            <FieldLabel required>Loyalty currency name</FieldLabel>
            <BF.Input
              value={pass.loyaltyCurrency || ""}
              spellCheck={false}
              placeholder="Cutters Points"
              onChange={(e) => set({ loyaltyCurrency: e.target.value })}
            />
            <span style={{ fontSize: 11, color: "var(--typography-tertiary)", lineHeight: 1.35 }}>
              Exact name of this store's points in Spoonity.
            </span>
          </div>
        </div>
      </Section>

      <Section title={T("passType")} {...sec("passType")}>
        <Segmented options={styles} value={pass.passStyle} onChange={(v) => set({ passStyle: v })} />
      </Section>

      <Section title={T("identity")} {...sec("identity")}>
        <LogoUpload
          src={pass.logoSrc}
          onUpload={(dataUrl) => set({ logoSrc: dataUrl })}
          onClear={() => set({ logoSrc: null })}
        />
        <div style={{ height: 6 }} />
        <FieldLabel>{T("iconRequired")}</FieldLabel>
        <IconUpload
          src={pass.iconSrc}
          onUpload={(dataUrl) => set({ iconSrc: dataUrl })}
          onClear={() => set({ iconSrc: null })}
        />
      </Section>

      <Section title={T("bannerImage")} hint={stripStyle ? T("bannerFull") : T("bannerNA")} {...sec("banner")}>
        {stripStyle ? (
          <ImageUpload
            src={pass.stripSrc}
            hint={T("bannerHint")}
            previewH={64}
            cropTo={{ w: 1125, h: 432 }}
            onUpload={(dataUrl) => set({ stripSrc: dataUrl })}
            onClear={() => set({ stripSrc: null })}
          />
        ) : (
          <span style={{ fontSize: 12, color: "var(--typography-tertiary)" }}>
            {T("bannerHelp")}
          </span>
        )}
      </Section>

      <Section title={T("colors")} {...sec("colors")}>
        <RoleColorPicker pass={pass} set={set} accent={accent} />
      </Section>

      <Section title="Field titles" {...sec("fieldTitles")}>
        <span style={{ fontSize: 11, color: "var(--typography-tertiary)", lineHeight: 1.4 }}>
          Rename the titles shown on the pass (e.g. POINTS → STARS). The values stay linked to your data, so the backend is unchanged.
        </span>
        <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
          {[
            { group: "headerFields", index: 0 },
            { group: "secondaryFields", index: 0 },
            { group: "secondaryFields", index: 1 },
          ].map(({ group, index }) => {
            const f = (pass[group] || [])[index];
            if (!f) return null;
            return (
              <TwoCol key={group + index}>
                <BF.Input
                  value={f.labelText ?? I18N.PL(f.label)}
                  placeholder={I18N.PL(f.label)}
                  onChange={(e) => setField(group, index, "labelText", e.target.value)}
                />
                <BF.Input value={f.value || ""} disabled readOnly title="The value comes from your loyalty data" />
              </TwoCol>
            );
          })}
        </div>
      </Section>

      <Section title={T("barcode")} hint={pass.barcode ? T("on") : T("off")} {...sec("barcode")}>
        <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
          <FieldLabel>{T("showBarcode")}</FieldLabel>
          <BF.Switch checked={!!pass.barcode} onChange={(on) => set({ barcode: on ? { format: "qr", message: "SPN-4821-0093", altText: "4821 0093 5567" } : null })} />
        </div>
        {pass.barcode && (
          <Segmented
            options={[{ value: "qr", label: "QR" }, { value: "aztec", label: "Aztec" }, { value: "pdf417", label: "PDF417" }, { value: "code128", label: "Code128" }]}
            value={pass.barcode.format}
            onChange={(v) => set({ barcode: { ...pass.barcode, format: v } })}
          />
        )}
      </Section>

      <StoreLocations pass={pass} set={set} accent={accent} {...sec("locations")} />

      <div style={{ display: "flex", gap: 10, padding: "20px 0 10px" }}>
        <BF.Button style={{ flex: 1, background: accent, color: "#fff" }} onClick={() => setPublishing(true)}>{T("createPass")}</BF.Button>
        <BF.Button variant="tertiary" onClick={handleSaveDraft} disabled={draftStatus === "saving"}>
          {draftStatus === "saving" ? "Saving…" : draftStatus === "saved" ? "Saved ✓" : draftStatus === "error" ? "Error" : T("saveDraft")}
        </BF.Button>
        <BF.Button variant="tertiary" onClick={() => setSaveAsOpen(true)}>Save as</BF.Button>
      </div>
      <div style={{ display: "flex", gap: 8, paddingBottom: 24 }}>
        {/* Share */}
        <button onClick={shareUrl ? handleCopy : handleSaveDraft} disabled={draftStatus === "saving"} style={{
          flex: 1, display: "flex", alignItems: "center", justifyContent: "center", gap: 6,
          padding: "8px 12px", borderRadius: 9, border: "1px solid var(--border-primary)",
          background: "var(--surface-secondary)", cursor: "pointer",
          fontFamily: "var(--font-sans)", fontSize: 12, fontWeight: 600,
          color: "var(--typography-secondary)", transition: "background .15s",
        }}
          onMouseEnter={e => e.currentTarget.style.background = "var(--surface-base)"}
          onMouseLeave={e => e.currentTarget.style.background = "var(--surface-secondary)"}
        >
          {copied
            ? <><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6L9 17l-5-5"/></svg> Copied!</>
            : shareUrl
              ? <><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg> Copy share link</>
              : <><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg> Share</>
          }
        </button>
        {/* Load */}
        <button onClick={() => setLoadOpen(true)} style={{
          flex: 1, display: "flex", alignItems: "center", justifyContent: "center", gap: 6,
          padding: "8px 12px", borderRadius: 9, border: "1px solid var(--border-primary)",
          background: "var(--surface-secondary)", cursor: "pointer",
          fontFamily: "var(--font-sans)", fontSize: 12, fontWeight: 600,
          color: "var(--typography-secondary)", transition: "background .15s",
        }}
          onMouseEnter={e => e.currentTarget.style.background = "var(--surface-base)"}
          onMouseLeave={e => e.currentTarget.style.background = "var(--surface-secondary)"}
        >
          <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
          Load draft
        </button>
      </div>
      {loadOpen && <LoadModal onClose={() => setLoadOpen(false)} onLoad={(draft) => { setPass(draft.pass_json); localStorage.setItem("spoonity_draft_id_apple", draft.id); localStorage.setItem("spoonity_share_id_apple", draft.share_id); setShareUrl(`${location.origin}${location.pathname}?share=${draft.share_id}`); setLoadOpen(false); }} />}
      {saveAsOpen && <SaveAsModal pass={pass} platform="apple" accent={accent} onClose={() => setSaveAsOpen(false)} onSaved={(shareId, savedPass) => { setPass(savedPass); setShareUrl(`${location.origin}${location.pathname}?share=${shareId}`); }} />}

      {publishing && <PublishModal pass={pass} accent={accent} onClose={() => setPublishing(false)} />}
      {generating && (
        <GenerateDesignModal
          accent={accent}
          onApply={(patch) => { set(patch); setGenerating(false); }}
          onClose={() => setGenerating(false)}
        />
      )}
    </div>
  );
}
window.BuilderForm = BuilderForm;

/* Animated editor overlay: a slot-editing panel that scales/fades in on top. */
function StudioModal({ title, onClose, children }) {
  const [shown, setShown] = React.useState(false);
  React.useEffect(() => { const id = requestAnimationFrame(() => setShown(true)); return () => cancelAnimationFrame(id); }, []);
  return (<Modal>
    <div onClick={onClose} style={{
      position: "fixed", inset: 0, zIndex: 1200, display: "flex", alignItems: "center", justifyContent: "center",
      background: shown ? "rgba(17,24,39,.45)" : "rgba(17,24,39,0)", transition: "background .2s ease", padding: 20,
    }}>
      <div onClick={(e) => e.stopPropagation()} style={{
        background: "var(--surface-base)", borderRadius: 16, width: 340, maxWidth: "100%", boxShadow: "var(--shadow-popup)", padding: 20,
        transform: shown ? "translateY(0) scale(1)" : "translateY(14px) scale(.96)", opacity: shown ? 1 : 0,
        transition: "transform .22s cubic-bezier(.2,.8,.2,1), opacity .2s ease",
      }}>
        <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 14 }}>
          <span style={{ fontSize: 16, fontWeight: 600, color: "var(--typography-primary)" }}>{title}</span>
          <button type="button" onClick={onClose} aria-label="Close" style={{ border: "none", background: "transparent", cursor: "pointer", fontSize: 17, lineHeight: 1, color: "var(--typography-tertiary)" }}>✕</button>
        </div>
        {children}
      </div>
    </div>
  </Modal>);
}

/* Edits one pass slot (image / single text / label+value field) in a modal. */
function SlotEditor({ slot, pass, setPass, onClose }) {
  if (!slot) return null;
  let body;
  if (slot.kind === "image") {
    body = (
      <ImageUpload
        src={pass[slot.target]}
        cropTo={slot.crops}
        previewH={76}
        hint="Upload an image"
        onUpload={(d) => setPass({ ...pass, [slot.target]: d })}
        onClear={() => setPass({ ...pass, [slot.target]: null })}
      />
    );
  } else if (slot.kind === "text") {
    body = (
      <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
        <FieldLabel>{slot.title}</FieldLabel>
        <BF.Input value={pass[slot.target] || ""} placeholder={slot.placeholder || ""} onChange={(e) => setPass({ ...pass, [slot.target]: e.target.value })} />
      </div>
    );
  } else if (slot.kind === "field") {
    const arr = pass[slot.group] || [];
    const f = arr[slot.index] || { label: "", value: "" };
    const upd = (key, val) => {
      const a = arr.slice();
      while (a.length <= slot.index) a.push({ label: "", value: "" });
      a[slot.index] = { ...a[slot.index], [key]: val };
      setPass({ ...pass, [slot.group]: a });
    };
    const clear = () => upd("value", "");
    body = (
      <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
        <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
          <FieldLabel>Title</FieldLabel>
          <BF.Input
            value={f.labelText ?? I18N.PL(f.label)}
            placeholder={I18N.PL(f.label)}
            onChange={(e) => upd("labelText", e.target.value)}
          />
          <span style={{ fontSize: 11, color: "var(--typography-tertiary)", lineHeight: 1.4 }}>Display title only — the value stays linked to your data.</span>
        </div>
        <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
          <FieldLabel>Value</FieldLabel>
          <BF.Input value={f.value || ""} placeholder="Value" onChange={(e) => upd("value", e.target.value)} />
        </div>
        {f.value && (
          <BF.Button variant="ghost" size="sm" onClick={clear}>Clear value</BF.Button>
        )}
      </div>
    );
  }
  return (
    <StudioModal title={slot.title} onClose={onClose}>
      {body}
      <div style={{ display: "flex", justifyContent: "flex-end", marginTop: 16 }}>
        <BF.Button size="sm" onClick={onClose}>Done</BF.Button>
      </div>
    </StudioModal>
  );
}

// Shared studio building-blocks reused by sibling studios (e.g. Google).
Object.assign(window, {
  StudioSection: Section, StudioFieldLabel: FieldLabel, StudioSegmented: Segmented,
  StudioTwoCol: TwoCol, StudioColorPicker: ColorPicker, StudioColorSwatches: ColorSwatches,
  StudioCropModal: CropModal, StudioImageUpload: ImageUpload, StudioPlatformToggle: PlatformToggle,
  StudioModal: StudioModal, StudioSlotEditor: SlotEditor,
  StudioRoleColorPicker: RoleColorPicker, StudioIconUpload: IconUpload,
  StudioPublishModal: PublishModal, studioEffectiveColors: effectiveColors,
  StudioGenerateDesignModal: GenerateDesignModal,
  StudioStoreLocations: StoreLocations,
  StudioThumbRemoveButton: ThumbRemoveButton,
});
