/* ===========================================================
   DTF.uk · Ops — Orders list (dispatch board) + Order detail
   =========================================================== */
const { useState, useEffect, useRef, useMemo, useLayoutEffect } = React;

/* ---- date helpers ---- */
const DOW = ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"];
const MON = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
function parseYMD(s){ const [y,m,d] = s.split("-").map(Number); return new Date(y, m-1, d); }
function fmtDateLabel(ymd){ const d = parseYMD(ymd); return `${DOW[d.getDay()]} ${d.getDate()} ${MON[d.getMonth()]}`; }
function relDay(ymd){
  const a = parseYMD(ymd), t = parseYMD(DTF.TODAY);
  const diff = Math.round((a - t) / 86400000);
  if (diff === 0) return "Today";
  if (diff === 1) return "Tomorrow";
  if (diff === -1) return "Yesterday";
  if (diff < 0) return Math.abs(diff) + " days ago";
  return "In " + diff + " days";
}
// "YYYY-MM-DD HH:MM" -> { day:"18 Jun", time:"11:10" } for the Placed column
function fmtPlaced(dt){
  const [d, t] = (dt || "").split(" ");
  if (!d) return { day: dt || "—", time: "" };
  const dd = parseYMD(d);
  return { day: `${dd.getDate()} ${MON[dd.getMonth()]}`, time: t || "" };
}
// "YYYY-MM-DD" -> "18 Jun" for the Dispatch-by column
function fmtDay(ymd){ const d = parseYMD(ymd); return `${d.getDate()} ${MON[d.getMonth()]}`; }

const esc = s => String(s == null ? "" : s).replace(/[&<>"]/g, c => ({ "&":"&amp;", "<":"&lt;", ">":"&gt;", '"':"&quot;" }[c]));

const PRINT_CSS = `
    * { box-sizing: border-box; }
    body { font-family: Arial, Helvetica, sans-serif; color:#14161a; margin:0; padding:0; font-size:12px; background:#fff; -webkit-print-color-adjust:exact; print-color-adjust:exact; }
    h1 { font-size:20px; margin:0 0 2px; }
    h2 { font-size:18px; margin:0 0 12px; }
    .sub { color:#666; font-size:12px; margin-bottom:18px; }
    table { width:100%; border-collapse:collapse; font-size:12px; }
    th { text-align:left; border-bottom:2px solid #14161a; padding:7px 8px; font-size:10px; letter-spacing:.04em; text-transform:uppercase; }
    td { border-bottom:1px solid #e3e3df; padding:7px 8px; vertical-align:top; }
    tfoot td { border-top:2px solid #14161a; border-bottom:0; font-weight:700; }
    .num { text-align:right; white-space:nowrap; }
    .muted { color:#666; }
    .lbl { font-size:10px; letter-spacing:.04em; text-transform:uppercase; color:#666; font-weight:700; }
    /* ---- Order summary blocks ---- */
    .ob { border:1px solid #c9c9c4; border-radius:6px; padding:14px 16px; margin-bottom:14px; page-break-inside:avoid; }
    .ob-head { display:flex; justify-content:space-between; gap:24px; align-items:flex-start; margin-bottom:10px; }
    .shipname { font-weight:700; font-size:13px; margin-bottom:2px; }
    table.kv { width:auto; font-size:12px; }
    table.kv td { border:0; padding:1px 0; }
    table.kv td:first-child { color:#666; padding-right:14px; white-space:nowrap; }
    table.kv td:nth-child(2) { font-weight:600; text-align:right; }
    .notes { font-size:12px; margin:0 0 10px; }
    .notes > div { margin:2px 0; }
    .props { margin-top:4px; }
    .props > div { font-size:11px; color:#444; word-break:break-word; overflow-wrap:anywhere; }
    .props .pk { color:#666; }
    .props a { color:#1a5fb4; word-break:break-all; }
    table.li th, table.li td { font-size:12px; }
    table.li .pcol { width:96px; }
    td.pcell { width:96px; padding:5px 8px; }
    .thumb { width:88px; height:88px; object-fit:cover; border-radius:4px; border:1px solid #e3e3df; display:block; }
    .thumb--ph { background:#f0f0ec; }
    /* ---- Packing slip ---- */
    .slip { page-break-after: always; padding-bottom:8px; }
    .slip:last-child { page-break-after: auto; }
    .ps-cols { display:flex; justify-content:space-between; gap:36px; margin:4px 0 14px; }
    .ps-cols > div { font-size:12px; line-height:1.5; }
    .ps-cols .lbl { margin-bottom:3px; }
    table.ps-meta { width:100%; margin-bottom:14px; }
    table.ps-meta td { border:0; padding:2px 8px 2px 0; }
    table.ps-meta td:nth-child(odd) { color:#666; width:1%; white-space:nowrap; }
    table.ps-meta td:nth-child(even) { font-weight:600; padding-right:28px; }
    .ps-barcode { text-align:center; margin-top:28px; padding-top:14px; border-top:1px solid #e3e3df; }
`;

// Render any <svg data-code> placeholders into Code128 barcodes inside `root`.
// To stay scannable we DON'T stretch the SVG (that blurs the bars when html2canvas
// rasterises it). Instead we pick the largest *integer* module width that fits the
// container, so the bars are natively wide and crisp, with a quiet-zone margin.
// The human-readable code is shown separately in HTML.
function renderBarcodes(root){
  if (!window.JsBarcode) return;
  root.querySelectorAll("svg[data-code]").forEach(el => {
    const code = el.getAttribute("data-code");
    const target = (el.parentNode && el.parentNode.clientWidth) || 320;
    const H = 72, MARGIN = 10;
    try {
      // Probe module count at width 1 (svg width = modules + 2*margin).
      JsBarcode(el, code, { format: "CODE128", width: 1, height: H, displayValue: false, margin: MARGIN });
      const modules = Math.max(1, (parseFloat(el.getAttribute("width")) || 0) - 2 * MARGIN);
      let w = Math.floor((target - 2 * MARGIN) / modules);   // integer px per module -> crisp
      w = Math.max(2, Math.min(7, w));
      JsBarcode(el, code, { format: "CODE128", width: w, height: H, displayValue: false, margin: MARGIN });
      // Explicit pixel sizing — the bare width/height attributes JsBarcode writes
      // ("336px") aren't always used for layout, collapsing the SVG to ~16px.
      const fw = parseFloat(el.getAttribute("width")) || (modules * w + 2 * MARGIN);
      const fh = parseFloat(el.getAttribute("height")) || H;
      el.removeAttribute("style");
      el.style.display = "block"; el.style.margin = "0 auto"; el.style.width = fw + "px"; el.style.height = fh + "px"; el.style.maxWidth = "100%";
    } catch(e){}
  });
}

// Open a print HTML window as a fallback (native dialog → Save as PDF / printer).
function openPrintWindow(title, bodyHtml, withBarcodes, extraCss){
  const w = window.open("", "_blank");
  if (!w) { alert("Allow pop-ups for this site to print."); return; }
  w.document.write(
    '<!doctype html><html><head><meta charset="utf-8"><title>' + esc(title) + '</title><style>' + PRINT_CSS + 'body{padding:28px}@media print{@page{margin:14mm}}' + (extraCss || "") + '</style>' +
    (withBarcodes ? '<script src="https://cdn.jsdelivr.net/npm/jsbarcode@3.11.6/dist/JsBarcode.all.min.js"></script>' : "") + '</head><body>' +
    bodyHtml +
    '<scr' + 'ipt>function go(){try{if(window.JsBarcode){document.querySelectorAll("svg[data-code]").forEach(function(el){var code=el.getAttribute("data-code");var target=(el.parentNode&&el.parentNode.clientWidth)||320;var H=72,M=10;JsBarcode(el,code,{format:"CODE128",width:1,height:H,displayValue:false,margin:M});var mod=Math.max(1,(parseFloat(el.getAttribute("width"))||0)-2*M);var w=Math.floor((target-2*M)/mod);w=Math.max(2,Math.min(7,w));JsBarcode(el,code,{format:"CODE128",width:w,height:H,displayValue:false,margin:M});var fw=parseFloat(el.getAttribute("width"))||(mod*w+2*M),fh=parseFloat(el.getAttribute("height"))||H;el.removeAttribute("style");el.style.display="block";el.style.margin="0 auto";el.style.width=fw+"px";el.style.height=fh+"px";el.style.maxWidth="100%";});}}catch(e){}window.focus();setTimeout(function(){window.print();},150);}if(document.readyState==="complete")go();else window.addEventListener("load",go);</scr' + 'ipt>' +
    '</body></html>'
  );
  w.document.close();
}

// Rasterize one HTML fragment (offscreen) to a canvas via html2canvas, awaiting images first.
async function htmlToCanvas(html, opts){
  const host = document.createElement("div");
  host.style.cssText = "position:fixed;left:-10000px;top:0;background:#fff;box-sizing:border-box;font-family:Arial,Helvetica,sans-serif;color:#14161a;";
  host.style.width = (opts.contentWidthPx || 794) + "px";
  host.style.padding = (opts.padPx != null ? opts.padPx : 53) + "px";
  host.style.fontSize = (opts.baseFontPx || 12) + "px";
  host.innerHTML = "<style>" + PRINT_CSS + (opts.extraCss || "") + "</style>" + html;
  document.body.appendChild(host);
  if (opts.withBarcodes) renderBarcodes(host);
  const imgs = Array.from(host.querySelectorAll("img"));
  imgs.forEach(img => { try { img.crossOrigin = "anonymous"; img.src = img.src; } catch(e){} });
  await Promise.all(imgs.map(img => img.complete && img.naturalWidth
    ? Promise.resolve()
    : new Promise(res => { img.onload = img.onerror = res; setTimeout(res, 6000); })));
  try {
    return await window.html2canvas(host, { scale: opts.scale || 2, useCORS: true, backgroundColor: "#ffffff", logging: false });
  } finally { try { document.body.removeChild(host); } catch(e){} }
}

// Build a PDF and open it in a new tab for preview (browser PDF viewer).
// opts: { sections:[html] (one PDF page-group each) OR body:html (continuous, sliced),
//         format ('a4'|[w,h]mm), orientation, contentWidthPx, padPx, baseFontPx, extraCss, withBarcodes }
// Margins are baked into each section via padPx; the rasterized page is placed full-bleed and
// sliced across pages. Falls back to an HTML print window if the libs are missing or rendering fails.
// Rasterize sections into a jsPDF doc and return it (async), or null if the libs
// are missing. Shared by openPdfPreview (view/download) and the email path (base64).
async function renderPdfDoc(opts){
  const jsPDFCtor = window.jspdf && window.jspdf.jsPDF;
  if (!window.html2canvas || !jsPDFCtor) return null;
  const sections = opts.sections || [opts.body || ""];
  const pdf = new jsPDFCtor({ unit: "mm", format: opts.format || "a4", orientation: opts.orientation || "portrait" });
  const pageW = pdf.internal.pageSize.getWidth();
  const pageH = pdf.internal.pageSize.getHeight();
  for (let i = 0; i < sections.length; i++) {
    const canvas = await htmlToCanvas(sections[i], opts);
    // PNG keeps barcode bar edges sharp (JPEG ringing breaks scanning); callers
    // with barcodes pass imgFormat:"png".
    const imgData = opts.imgFormat === "png" ? canvas.toDataURL("image/png") : canvas.toDataURL("image/jpeg", 0.95);
    const imgH = canvas.height * pageW / canvas.width; // mm, full-bleed width
    let heightLeft = imgH, k = 0;
    do {
      if (!(i === 0 && k === 0)) pdf.addPage();
      pdf.addImage(imgData, "JPEG", 0, -k * pageH, pageW, imgH);
      heightLeft -= pageH; k++;
    } while (heightLeft > 0.5);
  }
  return pdf;
}

// Shared cache of connected packing-station agents (populated on load + polled),
// so both the label modal and the packing-slip/summary buttons resolve the same
// "print to" target without each re-fetching.
window.__dtfLabelAgents = window.__dtfLabelAgents || [];
function dtfRefreshLabelAgents(){
  return fetch("/api/labels/agents").then(r => r.json()).then(j => { window.__dtfLabelAgents = (j.agents || []).filter(a => a.online); return window.__dtfLabelAgents; }).catch(() => window.__dtfLabelAgents);
}
try { dtfRefreshLabelAgents(); setInterval(dtfRefreshLabelAgents, 20000); } catch {}
// Resolve the station to print to: a saved choice wins; "browser" forces preview;
// otherwise AUTO — default to the connected station so it "just prints".
function directPrintStation(){
  let saved = ""; try { saved = localStorage.getItem("dtf_labelStation") || ""; } catch {}
  if (saved === "browser") return "";
  const online = window.__dtfLabelAgents || [];
  if (saved && online.some(a => a.id === saved)) return saved;
  return online.length ? online[0].id : "";
}
window.directPrintStation = directPrintStation;

function openPdfPreview(title, opts){
  opts = opts || {};
  const jsPDFCtor = window.jspdf && window.jspdf.jsPDF;
  const sections = opts.sections || [opts.body || ""];
  if (!window.html2canvas || !jsPDFCtor) { openPrintWindow(title, sections.join(""), opts.withBarcodes); return; }
  (async () => {
    try {
      const pdf = await renderPdfDoc(opts);
      // Direct print: if a station is mapped and this doc has a type, send the PDF
      // to the agent (it routes to the printer mapped for that type — e.g. summary
      // → A4, packing → thermal). Silent, no preview. Falls back to preview on error.
      const station = opts.docType ? directPrintStation() : "";
      if (station) {
        try {
          const b64 = pdf.output("datauristring").split(",")[1];
          const r = await fetch("/api/labels/print-doc", { method: "POST", headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ pdf: b64, docType: opts.docType, station, orderRef: opts.orderRef || title }) });
          if (r.ok) return; // printed silently
        } catch { /* fall through to preview */ }
      }
      if (opts.download) pdf.save(opts.download);              // true file download
      else window.open(pdf.output("bloburl"), "_blank");        // preview in a new tab
    } catch (err) {
      console.warn("[print] PDF render failed, falling back to print window", err);
      openPrintWindow(title, sections.join(""), opts.withBarcodes);
    }
  })();
}

// ---- Proforma invoice (client-side PDF, styled like pay.dtf.uk/i/…) ----------
// Remittance / company details shown on the invoice. Kept here (not the server)
// because the whole document renders client-side from the in-memory order via the
// existing jsPDF + html2canvas pipeline (openPdfPreview).
// Defaults; overridden by the server-side company config (editable in Settings).
let INVOICE_CO = {
  name: "DTF.UK",
  addr: ["2 Fensom Place", "Oakgrove", "Milton Keynes MK10 9UH", "United Kingdom"],
  email: "pay@dtf.uk",
  website: "https://dtf.uk",
  legalName: "DTFUK LTD",
  bank: "Revolut Ltd",
  sortCode: "04-29-09",
  accountNo: "98458426",
  companyNo: "15694877",
  vat: "GB 528 4115 00",
};

// VAT registration is effective 01 Jul 2026 — supplies (orders) dated on/after this
// carry 20% VAT (prices are VAT-INCLUSIVE, so VAT = total × 20/120 within the price);
// earlier orders were pre-registration and show "Not applicable". Override date via
// window.DTF_VAT_FROM if HMRC confirms a different effective date.
const VAT_FROM = new Date((typeof window !== "undefined" && window.DTF_VAT_FROM) || "2026-07-01T00:00:00");
function orderHasVat(o) {
  let d = (o && o.date) ? new Date(String(o.date).replace(" ", "T")) : new Date();
  if (isNaN(d)) d = new Date();
  return d >= VAT_FROM;
}
// VAT-inclusive split of a gross total → { vat, net } (2dp).
function vatSplit(total) {
  const vat = Math.round((Number(total) || 0) / 6 * 100) / 100;  // 20/120
  return { vat, net: Math.round(((Number(total) || 0) - vat) * 100) / 100 };
}

// Pull the latest company/bank details before rendering an invoice so edits in
// Settings take effect immediately (no rebuild). Falls back to the defaults above.
async function loadCompany(){
  try { const c = await fetch("/api/company").then(r => r.json()); if (c && !c.error && c.name) INVOICE_CO = c; } catch (e) {}
}

const invDate = (v) => {
  const d = v instanceof Date ? v : (v ? new Date(String(v).replace(" ", "T")) : new Date());
  return (isNaN(d) ? new Date() : d).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" });
};

const PROFORMA_CSS = `
  .pf{ color:#14161a; font-family:Arial,Helvetica,sans-serif; position:relative; }
  .pf-badge.pf-badge--paid{ border-color:#0f8a4f; color:#0f8a4f; background:#e4f4ec; }
  .pf-wm{ position:absolute; top:44%; left:50%; transform:translate(-50%,-50%) rotate(-24deg); font-size:120px; font-weight:800; letter-spacing:.1em; color:rgba(15,138,79,.12); border:9px solid rgba(15,138,79,.13); border-radius:16px; padding:8px 44px; pointer-events:none; white-space:nowrap; }
  .pf-head{ display:flex; justify-content:space-between; align-items:flex-start; margin-bottom:22px; }
  .pf-kicker{ font-size:11px; letter-spacing:.14em; text-transform:uppercase; color:#7a828c; }
  .pf-no{ font-size:26px; font-weight:700; margin-top:2px; }
  .pf-badge{ border:1px solid #d7b400; color:#8a6d00; background:#fff8db; border-radius:999px; padding:4px 12px; font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.06em; }
  .pf-meta{ display:flex; gap:34px; flex-wrap:wrap; padding:14px 0; border-top:1px solid #eceef1; border-bottom:1px solid #eceef1; margin-bottom:22px; }
  .pf-meta > div{ display:flex; flex-direction:column; gap:3px; }
  .pf-meta span{ font-size:10.5px; color:#8a929b; text-transform:uppercase; letter-spacing:.04em; }
  .pf-meta b{ font-size:13px; font-weight:600; }
  .pf-parties{ display:flex; gap:40px; margin-bottom:26px; }
  .pf-party{ flex:1; font-size:12.5px; line-height:1.5; }
  .pf-lbl{ font-size:10.5px; text-transform:uppercase; letter-spacing:.08em; color:#8a929b; margin-bottom:6px; }
  .pf-strong{ font-weight:700; font-size:13.5px; margin-bottom:2px; }
  .pf-tbl{ width:100%; border-collapse:collapse; margin-bottom:18px; font-size:12.5px; }
  .pf-tbl thead th{ text-align:left; font-size:10.5px; text-transform:uppercase; letter-spacing:.04em; color:#8a929b; border-bottom:2px solid #14161a; padding:0 8px 8px; }
  .pf-tbl tbody td{ padding:10px 8px; border-bottom:1px solid #eceef1; vertical-align:top; }
  .pf-tbl .pf-r{ text-align:right; white-space:nowrap; }
  .pf-tbl .pf-idx{ width:24px; color:#9aa2ab; }
  .pf-var{ color:#7a828c; }
  .pf-sku{ font-size:10.5px; color:#9aa2ab; margin-top:2px; }
  .pf-sums{ margin-left:auto; width:262px; font-size:12.5px; }
  .pf-trow{ display:flex; justify-content:space-between; padding:6px 8px; }
  .pf-trow span:first-child{ color:#6b727b; }
  .pf-grand{ border-top:2px solid #14161a; margin-top:4px; padding-top:10px; font-size:15px; font-weight:700; }
  .pf-grand span:first-child{ color:#14161a; }
  .pf-remit{ margin-top:34px; border:1px solid #eceef1; border-radius:10px; padding:16px 18px; background:#fafbfc; }
  .pf-remit-t{ font-size:11px; text-transform:uppercase; letter-spacing:.08em; color:#8a929b; margin-bottom:12px; }
  .pf-remit-g{ display:flex; flex-wrap:wrap; gap:8px 28px; }
  .pf-remit-g > div{ flex:0 0 calc(50% - 14px); display:flex; justify-content:space-between; gap:16px; font-size:12px; border-bottom:1px dashed #e4e7ea; padding-bottom:6px; }
  .pf-remit-g span{ color:#8a929b; }
  .pf-remit-g b{ font-weight:600; text-align:right; }
  .pf-brand{ display:flex; align-items:center; gap:10px; margin-bottom:16px; }
  .pf-logo{ display:inline-grid; place-items:center; width:40px; height:40px; border-radius:10px; background:#18f4af; color:#063d2b; font-weight:700; font-size:13px; letter-spacing:.5px; }
  .pf-wordmark{ font-size:17px; font-weight:700; letter-spacing:-.01em; line-height:1.15; }
  .pf-wordmark small{ display:block; font-size:9.5px; font-weight:400; letter-spacing:.02em; color:#7a828c; }
  .pf-footrow{ display:flex; justify-content:space-between; align-items:flex-start; gap:24px; }
  .pf-pay{ text-align:center; }
  .pf-qr{ width:104px; height:104px; display:block; image-rendering:pixelated; }
  .pf-pay-cap{ margin-top:6px; font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.08em; color:#18f4af; }
  .pf-pay-sub{ font-size:10px; color:#9aa2ab; margin-top:2px; }
`;

// QR as a data-URL via the bundled qrcode-generator (global `qrcode`). Returns null
// if the lib isn't loaded, so the invoice still renders without it.
function qrDataUrl(text){
  try {
    if (!text || typeof window.qrcode !== "function") return null;
    const qr = window.qrcode(0, "M"); // auto version, medium error-correction
    qr.addData(String(text)); qr.make();
    return qr.createDataURL(5, 2);
  } catch (e) { return null; }
}

function proformaInvoiceHtml(o, c, payUrl, paid){
  const money = DTF.fmtGBP;
  const items = (o.items || []).filter(it => !it.removed);
  const hasVat = orderHasVat(o);                 // VAT applies from 01 Jul 2026
  const r2 = (n) => Math.round((Number(n) || 0) * 100) / 100;
  const exVat = (gross) => r2(gross / 1.2);      // gross → net (prices are VAT-inclusive)
  const rows = items.map((it, i) => {
    const qty = it.qty || 0, unitG = +it.price || 0;
    const unit = hasVat ? exVat(unitG) : unitG;              // ex-VAT unit price on VAT invoices
    const amt = hasVat ? r2(qty * unitG / 1.2) : qty * unitG; // ex-VAT line amount
    const variant = it.variant ? ` <span class="pf-var">(${esc(it.variant)})</span>` : "";
    const sku = it.sku ? `<div class="pf-sku">${esc(it.sku)}</div>` : "";
    return `<tr><td class="pf-idx">${i + 1}</td><td>${esc(it.name || "—")}${variant}${sku}</td>` +
      `<td class="pf-r">${qty}</td><td class="pf-r">${money(unit)}</td><td class="pf-r">${money(amt)}</td></tr>`;
  }).join("");

  const subtotal = items.reduce((s, it) => s + (it.qty || 0) * (+it.price || 0), 0);
  const shipping = (o.shipping != null) ? +o.shipping
    : (o.method === "Collection" ? 0 : o.method === "SD" ? 8.50 : o.method === "DPD" ? 6.00 : 4.20);
  const total = (o.total != null) ? +o.total : subtotal + shipping;
  const adjust = +(total - subtotal - shipping).toFixed(2); // discount / rounding so the doc reconciles
  // VAT-inclusive breakdown per component (net + VAT), so the rows reconcile to `total`:
  const itemsNet = exVat(subtotal), shipNet = exVat(shipping), discNet = exVat(adjust);
  const goodsVat = r2((subtotal + adjust) - (itemsNet + discNet)); // VAT on items (incl. any discount)
  const shipVat = r2(shipping - shipNet);                          // VAT on shipping
  const vatTotal = r2(goodsVat + shipVat);

  const billName = (o.ship && o.ship.name) || (c && c.name) || "Customer";
  const billCompany = (o.ship && o.ship.company) || (c && c.company) || "";
  const billLines = addrLines(o.ship, o.address);
  const qr = paid ? null : qrDataUrl(payUrl);
  const due = new Date((o.date ? (isNaN(new Date(String(o.date).replace(" ", "T"))) ? new Date() : new Date(String(o.date).replace(" ", "T"))) : new Date()).getTime() + 7 * 24 * 3600 * 1000);
  const line = (lbl, val) => `<div class="pf-trow"><span>${esc(lbl)}</span><span>${val}</span></div>`;
  const kv = (lbl, val) => `<div><span>${esc(lbl)}</span><b>${esc(val)}</b></div>`;

  return `
  <div class="pf">
    ${paid ? `<div class="pf-wm">PAID</div>` : ""}
    <div class="pf-head">
      <div>
        <div class="pf-brand"><span class="pf-logo">SD</span><span class="pf-wordmark">DTF.UK<small>Same Day DTF Transfers</small></span></div>
        <div class="pf-kicker">${paid ? "Invoice" : "Proforma invoice"}</div>
        <div class="pf-no">${esc(o.id)}</div>
      </div>
      <div class="pf-badge${paid ? " pf-badge--paid" : ""}">${paid ? "Paid" : "Proforma"}</div>
    </div>
    <div class="pf-meta">
      ${kv("Invoice number", recordNo(o))}
      ${kv("Date", invDate(o.date))}
      ${paid ? "" : kv("Due date", invDate(due))}
      ${kv("Status", paid ? "Paid" : "Awaiting payment")}
    </div>
    <div class="pf-parties">
      <div class="pf-party">
        <div class="pf-lbl">From</div>
        <div class="pf-strong">${esc(INVOICE_CO.name)}</div>
        ${INVOICE_CO.addr.map(l => `<div>${esc(l)}</div>`).join("")}
        <div>${esc(INVOICE_CO.email)}</div>
      </div>
      <div class="pf-party">
        <div class="pf-lbl">Bill to</div>
        <div class="pf-strong">${esc(billName)}</div>
        ${billCompany ? `<div>${esc(billCompany)}</div>` : ""}
        ${billLines.map(l => `<div>${esc(l)}</div>`).join("")}
        ${c && c.email ? `<div>${esc(c.email)}</div>` : ""}
      </div>
    </div>
    <table class="pf-tbl">
      <thead><tr><th class="pf-idx">#</th><th>Description</th><th class="pf-r">Quantity</th><th class="pf-r">Unit price${hasVat ? " (ex VAT)" : ""}</th><th class="pf-r">Amount${hasVat ? " (ex VAT)" : ""}</th></tr></thead>
      <tbody>${rows}</tbody>
    </table>
    <div class="pf-footrow">
      <div class="pf-pay">
        ${qr ? `<img class="pf-qr" src="${qr}" alt="Pay online"><div class="pf-pay-cap">Pay online</div><div class="pf-pay-sub">Scan to pay securely</div>` : ""}
      </div>
      <div class="pf-sums">
        ${line("Subtotal" + (hasVat ? " (ex VAT)" : ""), money(hasVat ? itemsNet : subtotal))}
        ${shipping ? line("Shipping" + (hasVat ? " (ex VAT)" : ""), money(hasVat ? shipNet : shipping)) : ""}
        ${adjust ? line((adjust < 0 ? "Discount" : "Adjustment") + (hasVat ? " (ex VAT)" : ""), (adjust < 0 ? "−" : "") + money(Math.abs(hasVat ? discNet : adjust))) : ""}
        ${hasVat ? line("VAT on items @ 20%", money(goodsVat)) : ""}
        ${hasVat && shipping ? line("VAT on shipping @ 20%", money(shipVat)) : ""}
        ${hasVat ? `<div class="pf-trow" style="font-weight:600"><span>VAT total</span><span>${money(vatTotal)}</span></div>` : line("VAT", "Not applicable")}
        <div class="pf-trow pf-grand"><span>Total${hasVat ? " (incl. VAT)" : ""}</span><span>${money(total)}</span></div>
      </div>
    </div>
    <div class="pf-remit">
      <div class="pf-remit-t">Payment details</div>
      <div class="pf-remit-g">
        ${kv("Website", INVOICE_CO.website)}
        ${kv("Email", INVOICE_CO.email)}
        ${kv("Company name", INVOICE_CO.legalName)}
        ${kv("Bank", INVOICE_CO.bank)}
        ${kv("Sort code", INVOICE_CO.sortCode)}
        ${kv("Account number", INVOICE_CO.accountNo)}
        ${kv("Registered in England and Wales No.", INVOICE_CO.companyNo)}
        ${hasVat ? kv("VAT Registration Number", INVOICE_CO.vat) : ""}
      </div>
    </div>
  </div>`;
}

async function downloadInvoice(o, opts){
  opts = opts || {};
  const draft = !!opts.draft;
  await loadCompany();
  // Decide paid vs proforma from the live payment status, then for an unpaid order
  // resolve a "Pay online" link (Revolut Merchant first, Shopify as fallback).
  let paid = !draft && o.pay === "paid", payUrl = null;
  if (draft) {
    payUrl = await resolvePayUrl(o, { draft: true });
  } else {
    try {
      const info = await fetch("/api/orders/" + recordNo(o) + "/payment-link").then(r => r.json());
      if (info && info.fullyPaid) paid = true;
      if (!paid) payUrl = await resolvePayUrl(o, { shopInfo: info });
    } catch (e) {}
  }
  const c = DTF.getCustomer(o.customerId) || o.customer || null;
  openPdfPreview((paid ? "Invoice " : "Proforma invoice ") + o.id, {
    body: proformaInvoiceHtml(o, c, payUrl, paid),
    extraCss: PROFORMA_CSS,
    contentWidthPx: 794, padPx: 56, baseFontPx: 12,
    download: (paid ? "Invoice-" : "Proforma-") + recordNo(o) + ".pdf",
  });
}

// Resolve the "Pay online" URL for an unpaid order: prefer a Revolut Merchant link
// (money into the Revolut Business account on the invoice), fall back to Shopify's.
async function resolvePayUrl(o, opts){
  opts = opts || {};
  // Drafts are addressed by their numeric legacyId; real orders by their #number.
  const ref = opts.draft ? o.legacyId : recordNo(o);
  const base = opts.draft ? "/api/draft-orders/" : "/api/orders/";
  // For drafts, only attempt Revolut when address + shipping are both set — the
  // total isn't final without them, so a Revolut link at this stage is premature.
  const draftRevolutReady = opts.draft ? !!(o.ship && o.shippingTitle) : true;
  if (draftRevolutReady) {
    try { const rv = await fetch(base + ref + "/revolut-link").then(r => r.json()); if (rv && rv.url) return rv.url; } catch (e) {}
  }
  if (opts.draft) {
    // GraphQL invoiceUrl is only set after Shopify admin "Send invoice" — fall back
    // to the REST API which always returns the URL regardless of send state.
    if (o.invoiceUrl) return o.invoiceUrl;
    try { const j = await fetch(`/api/draft-orders/${ref}/invoice-url`).then(r => r.json()); if (j && j.url) return j.url; } catch {}
    return null;
  }
  const info = opts.shopInfo || await fetch("/api/orders/" + ref + "/payment-link").then(r => r.json()).catch(() => null);
  return (info && info.paymentUrl && !info.fullyPaid) ? info.paymentUrl : null;
}

// Build an invoice PDF (paid = PAID-watermarked "Invoice"; else Proforma with a
// Pay-online QR) and return it as a nodemailer base64 attachment, or null if the
// PDF libs aren't available. Used by the Email customer composer.
async function invoicePdfAttachment(o, { paid, draft } = {}){
  await loadCompany();
  const payUrl = paid ? null : await resolvePayUrl(o, { draft: !!draft });
  const body = proformaInvoiceHtml(o, DTF.getCustomer(o.customerId) || o.customer, payUrl, !!paid);
  const pdf = await renderPdfDoc({ body, extraCss: PROFORMA_CSS, contentWidthPx: 794, padPx: 56, baseFontPx: 12 });
  if (!pdf) return null;
  const b64 = pdf.output("datauristring").split(",")[1];
  const kind = paid ? "Invoice" : "Proforma";
  return { filename: kind + "-" + recordNo(o) + ".pdf", content: b64, encoding: "base64", contentType: "application/pdf" };
}

// ---- print formatting helpers ----
const recordNo = o => String(o.id || "").replace(/^#/, "");
function fmtMDY(dt){ const d = String(dt||"").split(" ")[0].split("-"); return d.length===3 ? `${+d[1]}/${+d[2]}/${d[0]}` : ""; }
function fmtDMY(dt){ const d = String(dt||"").split(" ")[0].split("-"); return d.length===3 ? `${+d[2]}/${+d[1]}/${d[0]}` : ""; }
// Address lines per spec: address1 / address2 / "city, PROVINCE zip" / countryCode
function addrLines(ship, fallback){
  if (!ship) return fallback ? [fallback] : [];
  const region = [ship.city, [ (ship.provinceCode || ship.province), ship.zip ].filter(Boolean).join(" ")].filter(Boolean).join(", ");
  return [ship.address1, ship.address2, region, (ship.countryCodeV2 || ship.country)].filter(Boolean);
}
// Line-item custom properties as label/value rows; underscore-prefixed keys (e.g. _Print Ready File) kept, leading underscores stripped from the label.
// Batch picking report — one block per order, line items priced.
// Order summary — a compact, printable table (one row per order) for the
// production floor. Columns: Order ID · Date · Customer · Collection/Delivery ·
// total DTF metres · SKUs (each SKU on its own line as "SKU × qty").
// Customised-garment products (the product customizer) carry a "Customised sides"
// property listing the chosen sides (e.g. "Front" or "Front, Back") and one image
// property per side name holding that side's artwork. Returns [{label, url}] for
// each chosen side so we can show a small preview on the packing summary. Falls
// back to the composed "Design Preview" mockup when a side has no direct artwork.
function customisedSides(it){
  const props = (it && it.properties) || [];
  const raw = (props.find(p => /^customised sides$/i.test((p.name||"").trim())) || {}).value;
  if (!raw) return [];
  const sides = String(raw).split(/[,/&+]+/).map(s => s.trim()).filter(Boolean);
  const preview = (props.find(p => /design preview/i.test(p.name||"") && isImageUrl(p.value)) || {}).value || null;
  const out = sides.map(s => {
    const p = props.find(p => (p.name||"").trim().toLowerCase() === s.toLowerCase() && isImageUrl(p.value));
    return { label: s, url: p ? p.value : null };
  });
  if (out.length && !out.some(x => x.url) && preview) out[0].url = preview;
  return out;
}
// Small preview block for the packing summary — the composed "Design Preview"
// mockup plus one thumbnail per chosen side, each captioned. Thumbnails are
// proxied same-origin so html2canvas can rasterize them into the PDF.
function sidesPreviewHtml(it){
  const sides = customisedSides(it);
  if (!sides.length) return "";
  const props = (it && it.properties) || [];
  const preview = (props.find(p => /design preview/i.test(p.name||"") && isImageUrl(p.value)) || {}).value || null;
  const cell = (url, label, mod) => url
    ? `<figure class="os-side${mod||""}"><img src="${esc(DTF.thumbUrl(url, 160))}" alt="${esc(label)}"><figcaption>${esc(label)}</figcaption></figure>`
    : `<span class="os-side--noimg">${esc(label)}</span>`;
  const cells = [];
  if (preview) cells.push(cell(preview, "Design preview", " os-side--preview"));
  sides.forEach(s => cells.push(cell(s.url, s.label)));
  return `<div class="os-sides">${cells.join("")}</div>`;
}

// Personalisation / print-instruction props the press manager needs (e.g.
// Surname, Name, Number, Size) — plain-text properties only. Excludes artwork
// URLs/files, the chosen sides (already shown as thumbnails), and builder/QC
// metadata noise. Used on the order-summary PDF so garment jobs carry what to print.
function itemPrintProps(it){
  const props = (it && it.properties) || [];
  const sideNames = new Set(customisedSides(it).map(s => s.label.trim().toLowerCase()));
  const NOISE = /^_|admin|^edit|edit_sheet|app[\s_-]?(name|id|version)|^has[\s_-]|pixels?|overlap|low[\s_-]?dpi|low[\s_-]?res|\bdpi\b|customi[sz]er|customised sides|design preview|print[\s_-]?ready|gang[\s_-]?sheet|actual|sheet[\s_-]?size|mockup|token|preview/i;
  const fileOrUrl = v => /^https?:\/\//i.test(v) || /\.(png|jpe?g|webp|gif|svg|pdf|ai|eps|psd|tiff?)(\?|$)/i.test(v);
  return props.map(p => ({ name: String(p.name||"").replace(/^_+/, "").trim(), value: String(p.value==null?"":p.value).trim() }))
    .filter(p => p.name && p.value
      && !sideNames.has(p.name.toLowerCase())
      && !NOISE.test(p.name)
      && !fileOrUrl(p.value))
    .map(p => ({ name: p.name, value: p.value.length > 200 ? p.value.slice(0,200) + "…" : p.value }));
}

// A checkout "Tip" / gratuity line — not something we make or pack, so it's kept
// off the production print-outs (order summary + packing slip).
const isTipItem = (name) => /^(tip|tips|gratuity)$/i.test(String(name || "").trim());
// The customizer's "Customisation Print Fee" is a synthetic pence-priced fee line
// (huge qty = pence), not something we make/pack — keep it off the production
// print-outs (order summary + packing slips), same as tips.
const isFeeItem = (name) => /customi[sz]ation\s*print\s*fee|customi[sz]er\s*fee/i.test(String(name || "").trim());
const isNonProduct = (name) => isTipItem(name) || isFeeItem(name);

async function printOrderSummary(orders){
  // Fold grouped orders into one dispatch unit: parent + children pack into one
  // parcel, so they print as a single block (all items, tagged by source order).
  const byId = new Map(orders.map(o => [o.id, o]));
  const seen = new Set();
  const units = []; // { lead, members:[order,...], isGroup }
  for (const o of orders){
    if (seen.has(o.id)) continue;
    const g = DTF.groupOf(o);
    if (!g){ units.push({ lead:o, members:[o], isGroup:false }); seen.add(o.id); continue; }
    const memberIds = [g.parent, ...g.children];
    const members = memberIds.map(id => byId.get(id) || (DTF.getOrder && DTF.getOrder(id))).filter(Boolean);
    const lead = byId.get(g.parent) || members[0] || o;
    const ordered = [lead, ...members.filter(m => m.id !== lead.id)];
    units.push({ lead, members: ordered, isGroup: ordered.length > 1 });
    ordered.forEach(m => seen.add(m.id));
  }
  // Per-order printing instructions (placement/spec) — fetched once for every order
  // being printed and shown as a highlighted block under that order's items.
  const memberNames = units.flatMap(u => u.members.map(m => String(m.id).replace(/^#/, "")));
  let instr = {};
  try { const r = await fetch("/api/orders/instructions?names=" + encodeURIComponent(memberNames.join(","))); instr = (await r.json()).instructions || {}; } catch {}
  const instrFor = (id) => instr["#" + String(id).replace(/^#/, "")] || null;
  // Refresh the marked gang-sheet lengths so metres reflect the ACTUAL sheet being
  // printed (height ÷ DPI of the "to print" artwork), not the per-line estimate.
  try { const cr = await fetch("/api/artwork/counts"); const cj = await cr.json(); if (cj && cj.printMetres) DTF._printMetres = cj.printMetres; } catch {}
  const sheetM = (id) => (DTF._printMetres && DTF._printMetres[String(id)]) || 0;
  // Refunded / removed lines aren't produced, and tips aren't items — never print them.
  const itemsOf = o => { const its = (o.items || []).filter(it => !it.removed && !isNonProduct(it.name)); return its.length ? its : [{ name: "—", qty: 0 }]; };
  const rows = units.map(u => {
    const { lead, members, isGroup } = u;
    const c = DTF.getCustomer(lead.customerId);
    const name = (lead.ship && lead.ship.name) || (c && c.name) || "—";
    // Show the actual delivery service (SD 1pm / RM24 / RM48 / DPD / Free) rather than
    // a generic "Delivery", so the packer sees the carrier at a glance. Collection stays
    // as-is.
    const mm = DTF.methodMeta[lead.method];
    const method = lead.method === "Collection" ? "Collection" : ((mm && mm.short) || lead.method || "Delivery");
    // Total printed rows this unit spans (all members' items, +1 subtotal for groups).
    const n = members.reduce((a, m) => a + itemsOf(m).length, 0) + (isGroup ? 1 : 0);
    let first = true;
    const memberRows = members.map((m, mi) => {
      // Marked gang-sheet length wins for this order's metres (shown once, on its
      // first row); otherwise fall back to the per-line estimate on each row.
      const ms = sheetM(m.id);
      return itemsOf(m).map((it, ii) => {
      const sku = it.sku ? `<span class="os-sku">${esc(it.sku)}</span> ` : "";
      const nm = esc(it.name || "—");
      const variant = it.variant ? ` <span class="muted">(${esc(it.variant)})</span>` : "";
      const metresCell = ms > 0
        ? (ii === 0 ? `${esc(DTF.fmtMetres(ms))} <span class="os-sheet">sheet</span>` : "")
        : esc(DTF.fmtMetres((DTF.itemMetres ? DTF.itemMetres(it) : 0) || 0));
      const src = (isGroup && mi > 0 && ii === 0) ? `<span class="os-src">${esc(m.id)}</span> ` : "";
      const pp = itemPrintProps(it);
      const propsHtml = pp.length
        ? `<div class="os-props">${pp.map(p => `<span class="os-prop"><span class="os-propk">${esc(p.name)}:</span> <b>${esc(p.value)}</b></span>`).join("")}</div>`
        : "";
      const sides = sidesPreviewHtml(it);
      const lead0 = first
        ? `<td rowspan="${n}" class="os-id">${esc(lead.id)}${isGroup ? `<div class="os-grp">+${members.length - 1} combined</div>` : ""}</td>` +
          `<td rowspan="${n}">${esc(fmtDMY(lead.date))}</td>` +
          `<td rowspan="${n}">${esc(name)}</td>` +
          `<td rowspan="${n}">${esc(method)}</td>`
        : "";
      const cls = first ? "os-top" : (isGroup && mi > 0 && ii === 0) ? "os-memtop" : "os-sub";
      first = false;
      return `<tr class="${cls}">
        ${lead0}
        <td class="os-item">${src}${sku}${nm}${variant}${propsHtml}${sides}</td>
        <td class="num os-m">${metresCell}</td>
        <td class="num os-q">×${it.qty || 0}</td>
      </tr>`;
    }).join("");
    }).join("");
    let sub = "";
    if (isGroup){
      const gm = members.reduce((s, m) => s + (DTF.orderMetres(m) || 0), 0);
      const gu = members.reduce((s, m) => s + (m.items || []).filter(it => !isNonProduct(it.name)).reduce((a, it) => a + (it.qty || 0), 0), 0);
      sub = `<tr class="os-sub os-grpsum">
        <td class="os-item">Combined parcel · ${members.length} orders</td>
        <td class="num os-m">${esc(DTF.fmtMetres(gm))}</td>
        <td class="num os-q">×${gu}</td>
      </tr>`;
    }
    // Printing-instruction blocks (one per member that has any), full width.
    const instrRows = members.map(m => {
      const rec = instrFor(m.id);
      if (!rec || !rec.text) return "";
      const lbl = isGroup ? `Printing instructions · ${esc(m.id)}` : "Printing instructions";
      return `<tr class="os-instr"><td colspan="7"><div class="os-instr-lbl">${lbl}</div><div class="os-instr-txt">${esc(rec.text)}</div></td></tr>`;
    }).join("");
    return memberRows + sub + instrRows;
  }).join("");
  const allMembers = units.flatMap(u => u.members);
  const totalMetres = allMembers.reduce((s, o) => s + (DTF.orderMetres(o) || 0), 0);
  const totalUnits = allMembers.reduce((s, o) => s + (o.items || []).filter(it => !isNonProduct(it.name)).reduce((n, it) => n + (it.qty || 0), 0), 0);
  const groupCount = units.filter(u => u.isGroup).length;
  const parcelNote = groupCount ? ` · ${units.length} parcel${units.length===1?"":"s"}` : "";
  const body =
    `<h1>Order Summary</h1><div class="sub">DTF.uk · ${allMembers.length} order${allMembers.length===1?"":"s"}${parcelNote} · ${totalUnits} unit${totalUnits===1?"":"s"} · printed ${esc(new Date().toLocaleDateString("en-GB"))}</div>` +
    `<table class="os">
      <colgroup><col style="width:60px"><col style="width:66px"><col style="width:118px"><col style="width:78px"><col><col style="width:56px"><col style="width:52px"></colgroup>
      <thead><tr>
        <th>Order ID</th><th>Date</th><th>Customer</th><th>Collection / Delivery</th><th>Item (SKU · name)</th><th class="num">Metres</th><th class="num">Qty</th>
      </tr></thead>
      <tbody>${rows}</tbody>
      <tfoot><tr><td colspan="5">Total</td><td class="num">${esc(DTF.fmtMetres(totalMetres))}</td><td class="num os-q">×${totalUnits}</td></tr></tfoot>
    </table>`;
  const extraCss = `
    table.os { table-layout:fixed; }
    table.os td { word-break:break-word; overflow-wrap:anywhere; }
    /* a new dispatch unit starts at each .os-top row — separate them with a stronger rule */
    table.os tbody tr.os-top > td { border-top:2px solid #c9c9c4; }
    /* child order inside a group — light divider + source tag */
    table.os tbody tr.os-memtop > td { border-top:1px dashed #d7d7d2; }
    .os-src { font-family:'JetBrains Mono',ui-monospace,Menlo,monospace; font-weight:700; font-size:10px; color:#057a54; background:#e6fbf3; padding:0 4px; border-radius:3px; margin-right:2px; }
    .os-grp { font-family:'JetBrains Mono',ui-monospace,Menlo,monospace; font-weight:700; font-size:9px; color:#057a54; margin-top:2px; white-space:nowrap; }
    tr.os-grpsum > td { background:#f3faf7; font-size:10.5px; }
    tr.os-grpsum td.os-item { font-style:italic; color:#555; }
    .os-id { font-family:'JetBrains Mono',ui-monospace,Menlo,monospace; font-weight:700; white-space:nowrap; vertical-align:top; }
    table.os td.os-item { font-size:11.5px; line-height:1.4; }
    .os-sku { font-family:'JetBrains Mono',ui-monospace,Menlo,monospace; font-weight:700; font-size:10.5px; }
    td.os-m { white-space:nowrap; }
    .os-sheet { font-size:8px; font-weight:800; letter-spacing:.04em; text-transform:uppercase; color:#057a54; background:#e6fbf3; padding:0 3px; border-radius:3px; vertical-align:middle; }
    td.os-q { font-size:15px; font-weight:800; white-space:nowrap; }
    /* personalisation / print instructions (Surname, Number, Size…) */
    .os-props { margin-top:3px; display:flex; flex-wrap:wrap; gap:2px 12px; }
    .os-prop { font-size:11px; color:#222; }
    .os-propk { color:#777; font-weight:700; font-size:9px; letter-spacing:.03em; text-transform:uppercase; }
    .os-prop b { font-weight:800; }
    /* customised-garment side previews */
    .os-sides { display:flex; flex-wrap:wrap; gap:8px; margin-top:5px; }
    figure.os-side { margin:0; width:58px; }
    figure.os-side img { width:58px; height:58px; object-fit:contain; display:block; border:1px solid #d7d7d2; border-radius:4px; background:#fff; }
    figure.os-side figcaption { margin-top:2px; font-size:9px; font-weight:700; color:#555; text-align:center; text-transform:capitalize; }
    figure.os-side--preview { width:72px; }
    figure.os-side--preview img { width:72px; height:72px; border-color:#9fd8c2; }
    figure.os-side--preview figcaption { color:#057a54; text-transform:none; }
    .os-side--noimg { display:inline-block; padding:2px 7px; border:1px dashed #c9c9c4; border-radius:4px; font-size:10px; font-weight:600; color:#555; text-transform:capitalize; }
    /* printing instructions block (placement / print spec) */
    tr.os-instr > td { background:#fff8e6; border-top:1px solid #f0e2b8; padding:7px 10px 8px; }
    .os-instr-lbl { font-size:9px; font-weight:800; letter-spacing:.04em; text-transform:uppercase; color:#8a6d1a; margin-bottom:3px; }
    .os-instr-txt { font-size:11.5px; line-height:1.5; white-space:pre-wrap; color:#1c1c1c; font-family:'JetBrains Mono',ui-monospace,Menlo,monospace; }
  `;
  openPdfPreview("Order Summary", { body, format: "a4", orientation: "portrait", contentWidthPx: 794, padPx: 40, baseFontPx: 12, extraCss, docType: "summary", orderRef: units.map(u => u.lead.id).join(", ") });
}

// One slip per order — no prices, barcode footer encodes the order number.
function printPackingSlips(orders){
  const FROM = ["DTF.uk (Ecommerce 360 limited)", "2 Fensom Place", "Milton Keynes, Buckinghamshire MK10 9UH"];
  const sections = orders.map(o => {
    const c = DTF.getCustomer(o.customerId);
    const code = recordNo(o) || String(o.adminId || "");
    const user = String(o.customerId || "").split("/").pop();
    const shipTo = [ (o.ship && o.ship.name) || (c && c.name) || "" ].concat(addrLines(o.ship, o.address)).filter(Boolean).map(esc).join("<br>");
    const packItems = o.items.filter(it => !it.removed && !isNonProduct(it.name));
    const items = packItems.map(it =>
      `<tr><td>${esc(it.name)}${it.variant ? `<div class="muted">${esc(it.variant)}</div>` : ""}</td><td class="num">${it.qty}</td></tr>`).join("");
    const totalQty = packItems.reduce((s, it) => s + (it.qty || 0), 0);
    return `<section class="slip">
      <h2>Packing Slip</h2>
      <div class="ps-cols">
        <div><div class="lbl">From</div>${FROM.map(esc).join("<br>")}</div>
        <div><div class="lbl">Ship To</div>${shipTo}</div>
      </div>
      <table class="ps-meta"><tbody>
        <tr><td>Order #</td><td>${esc(recordNo(o))}</td><td>Date</td><td>${esc(fmtDMY(o.date))}</td></tr>
        <tr><td>User</td><td>${esc(user)}</td><td>Ship Date</td><td>${o.dispatched ? esc(fmtDMY(o.date)) : ""}</td></tr>
      </tbody></table>
      <table class="li"><thead><tr><th>Item Description</th><th class="num">Qty</th></tr></thead><tbody>${items}</tbody><tfoot><tr><td>Total qty</td><td class="num">${totalQty}</td></tr></tfoot></table>
      <div class="ps-barcode"><svg data-code="${esc(code)}"></svg><div class="ps-code">${esc(code)}</div></div>
    </section>`;
  });
  // 4x6" (100x150mm) thermal shipping-label format
  const css46 = `
    h2 { font-size: 14px; margin: 0 0 7px; }
    .ps-cols { gap: 10px; margin: 2px 0 8px; }
    .ps-cols > div { font-size: 8.5px; line-height: 1.35; }
    .ps-cols .lbl, .lbl { font-size: 7px; margin-bottom: 2px; }
    table.ps-meta { margin-bottom: 8px; }
    table.ps-meta td { font-size: 8.5px; padding: 1px 6px 1px 0; }
    table.ps-meta td:nth-child(even) { padding-right: 14px; }
    table.li th { font-size: 7.5px; padding: 4px 5px; }
    table.li td { font-size: 9px; padding: 4px 5px; }
    .ps-barcode { margin-top: 10px; padding-top: 8px; text-align: center; }
    .ps-barcode svg { display: block; margin: 0 auto; max-width: 100%; }
    .ps-code { text-align: center; font-family: monospace; font-size: 11px; letter-spacing: 3px; margin-top: 5px; }
  `;
  openPdfPreview("Packing Slips", { sections, withBarcodes: true, imgFormat: "png", scale: 3, format: [100, 150], orientation: "portrait", contentWidthPx: 378, padPx: 15, baseFontPx: 9, extraCss: css46, docType: "packing", orderRef: orders.map(o => o.id).join(", ") });
}

/* ---- itemssummary ---- */
function itemsSummary(o){
  // Removed (refunded/edited-out) lines don't count toward what we make/ship.
  const active = o.items.filter(i => !i.removed);
  const units = active.reduce((s,i)=>s+i.qty,0);
  return { lines: active.length, units, removed: o.items.length - active.length };
}

/* =================== ORDERS LIST =================== */
// Persist the list view (board/all · filter · search) so returning from an order
// (back button / crumb) lands on the same section you opened it from.
function _ordersViewLoad(){ try { return JSON.parse(sessionStorage.getItem("dtf_ordersview") || "null") || {}; } catch(e){ return {}; } }
function _ordersViewSave(v){ try { sessionStorage.setItem("dtf_ordersview", JSON.stringify(v)); } catch(e){} }

// Statuses offered in the multi-select filter, in pipeline order.
const STATUS_OPTIONS = [
  "new", "awaiting_artwork", "awaiting_garments", "design_queue", "rip_queue", "print_queue", "printing",
  "printed", "press_queue", "ready_dispatch", "ready-collection", "dispatched",
  "collected", "delivered", "refunded", "cancelled", "closed",
];

// Multi-select status filter — a checkbox popover. Pick any set of statuses
// (e.g. Printed + Press queue + Ready to dispatch) to view them together.
function StatusPicker({ picks, onToggle, onClear, statuses }) {
  const [open, setOpen] = useState(false);
  const ref = useRef(null);
  useEffect(() => {
    if (!open) return;
    const h = e => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
    document.addEventListener("mousedown", h);
    return () => document.removeEventListener("mousedown", h);
  }, [open]);
  const n = picks.size;
  const label = n === 0 ? "Statuses" : n === 1 ? ((DTF.statusMeta[[...picks][0]]||{}).label || [...picks][0]) : `${n} statuses`;
  return (
    <div className={"status-multi"+(n?" is-on":"")} ref={ref}>
      <button type="button" className="status-multi__btn" onClick={()=>setOpen(o=>!o)} title="Filter by one or more statuses">
        <Icon name="filter" /> {label} <span className="status-multi__chev">▾</span>
      </button>
      {open && (
        <div className="status-multi__pop">
          <div className="status-multi__head">
            <span>Show statuses</span>
            {n > 0 && <button className="linklike" onClick={onClear}>Clear ({n})</button>}
          </div>
          {statuses.filter(k => DTF.statusMeta[k]).map(k => (
            <label key={k} className="status-multi__opt">
              <input type="checkbox" checked={picks.has(k)} onChange={()=>onToggle(k)} />
              <span className="status-multi__dot" style={{background:(DTF.statusMeta[k]||{}).dot||"#ccc"}} />
              {(DTF.statusMeta[k]||{}).label || k}
            </label>
          ))}
        </div>
      )}
    </div>
  );
}

// Delivery methods offered in the multi-select filter, in urgency order.
const METHOD_OPTIONS = ["SD", "RM24", "DPD", "RM48", "Collection", "Free"];

// Multi-select delivery-method filter — a checkbox popover mirroring StatusPicker.
// Composes with the status filter and search to narrow whatever list is shown.
function MethodPicker({ picks, onToggle, onClear, methods }) {
  const [open, setOpen] = useState(false);
  const ref = useRef(null);
  useEffect(() => {
    if (!open) return;
    const h = e => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
    document.addEventListener("mousedown", h);
    return () => document.removeEventListener("mousedown", h);
  }, [open]);
  const n = picks.size;
  const label = n === 0 ? "Delivery" : n === 1 ? ((DTF.methodMeta[[...picks][0]]||{}).short || [...picks][0]) : `${n} methods`;
  return (
    <div className={"status-multi"+(n?" is-on":"")} ref={ref}>
      <button type="button" className="status-multi__btn" onClick={()=>setOpen(o=>!o)} title="Filter by one or more delivery methods">
        <Icon name="truck" /> {label} <span className="status-multi__chev">▾</span>
      </button>
      {open && (
        <div className="status-multi__pop">
          <div className="status-multi__head">
            <span>Delivery method</span>
            {n > 0 && <button className="linklike" onClick={onClear}>Clear ({n})</button>}
          </div>
          {methods.filter(k => DTF.methodMeta[k]).map(k => (
            <label key={k} className="status-multi__opt">
              <input type="checkbox" checked={picks.has(k)} onChange={()=>onToggle(k)} />
              <span className="status-multi__code">{(DTF.methodMeta[k]||{}).short || k}</span>
              {(DTF.methodMeta[k]||{}).label || k}
            </label>
          ))}
        </div>
      )}
    </div>
  );
}

function OrdersList({ go, board }) {
  const _v0 = _ordersViewLoad();
  const [mode, setMode] = useState(board || _v0.mode || "board"); // board (dispatch) | new | all
  // The dispatch board always lands on awaiting-dispatch only — ignore any status
  // filter left over from a prior session.
  const [statusFilter, setStatusFilter] = useState(board === "board" ? "todispatch" : (_v0.statusFilter || "todispatch")); // todispatch | all | <status>
  // Multi-status filter: when non-empty it overrides the board/new/all mode and
  // shows exactly the picked statuses (e.g. printed + press queue + ready to dispatch).
  const [statusPicks, setStatusPicks] = useState(() => new Set(_v0.statusPicks || []));
  const toggleStatusPick = k => setStatusPicks(prev => { const n = new Set(prev); n.has(k) ? n.delete(k) : n.add(k); return n; });
  const clearStatusPicks = () => setStatusPicks(new Set());
  // "Today's Summary List" — statuses configured in Settings; one click shows those
  // statuses and selects every loaded order in them (no auto-print — the operator
  // prints from the selection toolbar if/when they want).
  const [summaryStatuses, setSummaryStatuses] = useState([]);
  useEffect(() => { fetch("/api/settings/summary-statuses").then(r => r.json()).then(j => setSummaryStatuses(Array.isArray(j.statuses) ? j.statuses : [])).catch(() => {}); }, []);
  const todaysSummary = () => {
    const picks = (summaryStatuses || []).filter(Boolean);
    if (!picks.length) { alert("No statuses configured for the summary list. Set them in Settings → Today's Summary List."); return; }
    const matched = (DTF.orders || []).filter(o => picks.includes(o.status));
    if (!matched.length) { alert("No loaded orders are in the summary statuses right now."); return; }
    setStatusPicks(new Set(picks));                 // show those statuses on the board
    setSel(new Set(matched.map(o => o.id)));          // preselect what's included
  };
  // Multi-method filter: a cross-cutting refinement (unlike status picks it does
  // not override the mode) — it narrows whatever list is shown to the picked
  // delivery methods, and persists across Dispatch / New / All tabs.
  const [methodPicks, setMethodPicks] = useState(() => new Set(_v0.methodPicks || []));
  const toggleMethodPick = k => setMethodPicks(prev => { const n = new Set(prev); n.has(k) ? n.delete(k) : n.add(k); return n; });
  const clearMethodPicks = () => setMethodPicks(new Set());
  // Sidebar entry chooses the board (Dispatch board vs New orders) via the route.
  // Landing on a board must present that board cleanly: a leftover multi-status
  // pick or search query otherwise overrides it (that's why #/dispatch sometimes
  // showed all/other orders). Clearing them makes #/dispatch always the clean
  // awaiting-dispatch view.
  useEffect(() => {
    if (!board) return;
    if (board !== mode) setMode(board);
    setStatusFilter(board === "board" ? "todispatch" : (board === "new" ? "todispatch" : statusFilter));
    clearStatusPicks();
    setQuery("");
  }, [board]);
  const [sel, setSel] = useState(() => new Set(_v0.sel || [])); // persists across reload
  const [query, setQuery] = useState(_v0.query || "");
  const [collecting, setCollecting] = useState(null); // order id being marked collected
  const [ver, setVer] = useState(0); // bump so memos recompute when DTF.orders mutates
  async function markCollectedRow(id){
    const ids = Array.isArray(id) ? id : [id];
    if (!ids.length) return;
    setCollecting(ids[0]);   // spinner sits on the parent row
    try {
      for (const one of ids) await DTF.markCollected(one);
      setVer(v => v + 1);
    }
    catch (e) { alert("Couldn't mark collected: " + e.message); }
    finally { setCollecting(null); }
  }
  const [shipFor, setShipFor] = useState(null); // orders to create labels for
  const [combineFor, setCombineFor] = useState(null); // orders to combine into a group
  const [shipChildren, setShipChildren] = useState({}); // parentId -> [childOrder] for combined dispatch
  const [loadingMore, setLoadingMore] = useState(false);
  // Collapse a dispatch selection to one parcel per group: children are replaced
  // by their parent (one label), and each parent carries its child orders so they
  // get fulfilled with the parent's tracking after the label prints.
  function planDispatch(list){
    const byId = new Map(DTF.orders.map(o => [o.id, o]));
    const parents = new Map();
    const kids = {};
    for (const o of list){
      if (o.method === "Collection") continue;
      const g = DTF.groupOf(o);
      if (!g){ if (!parents.has(o.id)) parents.set(o.id, o); continue; }
      const pOrder = byId.get(g.parent) || o;
      parents.set(pOrder.id, pOrder);
      kids[pOrder.id] = g.children.map(id => byId.get(id)).filter(Boolean).filter(k => k.method !== "Collection");
    }
    return { orders: [...parents.values()], groupChildren: kids };
  }
  function openLabels(list){ const p = planDispatch(list); setShipChildren(p.groupChildren); setShipFor(p.orders); }

  // Remember the current section so a later "back" returns here.
  useEffect(() => { _ordersViewSave({ mode, statusFilter, statusPicks: [...statusPicks], methodPicks: [...methodPicks], query, sel: [...sel] }); }, [mode, statusFilter, statusPicks, methodPicks, query, sel]);

  // Reload orders/customers/workflow when the section opens so it's never stale.
  const [loading, setLoading] = useState(DTF.orders.length === 0);
  useEffect(() => {
    let alive = true;
    if (DTF.reloadData) DTF.reloadData().then(() => { if (alive) { setLoading(false); setVer(v => v + 1); } }).catch(() => alive && setLoading(false));
    else setLoading(false);
    return () => { alive = false; };
  }, []);
  const [reloading, setReloading] = useState(false);
  const doReload = () => { if (reloading || !DTF.reloadData) return; setReloading(true); DTF.reloadData().then(() => { setReloading(false); setVer(v => v + 1); }).catch(() => setReloading(false)); };
  // Live-sync pushed fresh data → recompute the list.
  useEffect(() => { const h = () => setVer(v => v + 1); window.addEventListener("dtf:refreshed", h); window.addEventListener("dtf:print-live", h); return () => { window.removeEventListener("dtf:refreshed", h); window.removeEventListener("dtf:print-live", h); }; }, []);

  const orders = DTF.orders;
  const q = query.trim();

  // Backend (Shopify) order search — covers ALL orders, not just the loaded page.
  const [results, setResults] = useState(null);   // null = browsing; array = search results
  const [searching, setSearching] = useState(false);
  const [sCursor, setSCursor] = useState(null);
  const [sHasMore, setSHasMore] = useState(false);
  // While searching we span ALL orders (the backend search ignores the board's
  // status filter), so auto-select "All statuses" so the dropdown matches what's
  // shown. Remember the prior filter and restore it when the search is cleared.
  const preSearchFilter = useRef(null);
  const preSearchMode = useRef(null);
  useEffect(() => {
    if (!q) {
      setResults(null); setSearching(false);
      if (preSearchFilter.current != null) { setStatusFilter(preSearchFilter.current); preSearchFilter.current = null; }
      if (preSearchMode.current != null) { setMode(preSearchMode.current); preSearchMode.current = null; }
      return;
    }
    if (preSearchFilter.current == null) { preSearchFilter.current = statusFilter; setStatusFilter("all"); }
    // The New-orders board only shows status:"new"; a search spans all orders, so
    // switch to "All orders" while searching to avoid a misleading "New" tab.
    if (mode !== "all" && preSearchMode.current == null) { preSearchMode.current = mode; setMode("all"); }
    let alive = true; setSearching(true);
    const t = setTimeout(() => {
      DTF.searchOrders(q).then(r => { if (!alive) return; setResults(r.orders); setSCursor(r.cursor); setSHasMore(r.hasMore); setSearching(false); })
        .catch(() => { if (alive) { setResults([]); setSearching(false); } });
    }, 350);
    return () => { alive = false; clearTimeout(t); };
  }, [q]);

  async function doLoadMore(){
    setLoadingMore(true);
    try {
      if (results != null) {
        const r = await DTF.searchOrders(q, sCursor);
        setResults(prev => [...prev, ...r.orders]); setSCursor(r.cursor); setSHasMore(r.hasMore);
      } else { await DTF.loadMoreOrders(); setVer(v => v + 1); }
    } catch (e) { alert("Couldn't load more: " + e.message); }
    finally { setLoadingMore(false); }
  }

  const visible = useMemo(() => orders.slice(), [orders, ver]); // loaded orders (non-search view)
  // Delivery-method filter predicate — applied to every displayed list (boards,
  // status picks, and search results) so it composes with the other filters.
  const inMethods = o => !methodPicks.size || methodPicks.has(o.method);

  // stats — orders awaiting dispatch exclude terminal states AND in-production stages.
  const NONDISPATCH = ["closed","delivered","dispatched","collected","refunded","cancelled", ...DTF.PRODUCTION_STAGES, ...(DTF.HOLD_STAGES||[])];
  const isOpen = o => !NONDISPATCH.includes(o.status) && o.fulfil !== "fulfilled";
  // Due to dispatch today = still-open orders whose cutoff-aware dispatch day is
  // today or earlier. Orders placed after the 1pm cutoff roll to tomorrow (via
  // fulfillBy) so they don't count today; yesterday's unshipped orders do.
  const dueToday = orders.filter(o => isOpen(o) && o.fulfillBy <= DTF.TODAY);
  const overdue = dueToday.filter(o => o.fulfillBy < DTF.TODAY).length;
  const printing = orders.filter(o => o.status === "printing" || o.status === "in-progress").length;
  // Per-queue production counts (by derived status, which carries the local stage).
  const PROD_QUEUES = [
    ["awaiting_artwork", "Artwork"],
    ["design_queue",     "Design"],
    ["rip_queue",        "RIP"],
    ["print_queue",      "Print"],
    ["printing",         "Printing"],
    ["printed",          "Printed"],
    ["press_queue",      "Press"],
  ];
  const prodCounts = {}; PROD_QUEUES.forEach(([s]) => prodCounts[s] = orders.filter(o => o.status === s).length);
  const prodTotal = Object.values(prodCounts).reduce((a, b) => a + b, 0);
  const unfulfilled = orders.filter(o => o.fulfil !== "fulfilled" && !["closed","delivered","refunded","cancelled"].includes(o.status)).length;
  const revenueToday = dueToday.reduce((s,o)=>s+o.total,0);

  // Dispatch board — one flat table, sorted by dispatch date (soonest first) then
  // method urgency. NOT split into a table per date (same-date rows stay adjacent
  // via the sort; each row shows its own "Dispatch by" date).
  const boardList = useMemo(() => {
    let list = visible.slice();
    if (statusFilter === "todispatch") list = list.filter(o => o.status === "ready_dispatch" && o.fulfil !== "fulfilled");
    else if (statusFilter !== "all") list = list.filter(o => o.status === statusFilter);
    list = list.filter(inMethods);
    return list.sort((a,b) => {
      if (a.fulfillBy !== b.fulfillBy) return parseYMD(a.fulfillBy) - parseYMD(b.fulfillBy);
      const ra = (DTF.methodMeta[a.method]||{}).rank ?? 99, rb = (DTF.methodMeta[b.method]||{}).rank ?? 99;
      if (ra !== rb) return ra - rb;
      return a.date < b.date ? -1 : 1;
    });
  }, [statusFilter, visible, methodPicks]);

  const newCount = useMemo(() => visible.filter(o => o.status === "new").length, [visible]);
  const flatList = useMemo(() => visible.filter(inMethods).sort((a,b)=> a.date < b.date ? 1 : -1), [visible, methodPicks]);
  // New orders: one flat table (no date grouping), newest order first. Held orders
  // (parked until their hold date) are HIDDEN here — they stay in All orders and
  // re-appear in New on their hold date (status reverts to "new" that day).
  const newList = useMemo(() => visible.filter(o => o.status === "new").filter(inMethods).sort((a,b) => a.date < b.date ? 1 : -1), [visible, methodPicks]);
  // Multi-status view: all loaded orders matching any picked status, dispatch-sorted.
  const pickedList = useMemo(() => {
    if (!statusPicks.size) return [];
    return visible.filter(o => statusPicks.has(o.status)).filter(inMethods).sort((a,b) => {
      if (a.fulfillBy !== b.fulfillBy) return parseYMD(a.fulfillBy) - parseYMD(b.fulfillBy);
      const ra = (DTF.methodMeta[a.method]||{}).rank ?? 99, rb = (DTF.methodMeta[b.method]||{}).rank ?? 99;
      if (ra !== rb) return ra - rb;
      return a.date < b.date ? -1 : 1;
    });
  }, [statusPicks, visible, methodPicks]);
  // Search results, narrowed by any active status picks and/or method picks — so
  // both multi-filters also apply while searching, not only on the boards.
  const resultList = useMemo(() =>
    results == null ? null
    : results.filter(o => (!statusPicks.size || statusPicks.has(o.status)) && (!methodPicks.size || methodPicks.has(o.method)))
  , [results, statusPicks, methodPicks]);

  // Switching tab (Dispatch / New / All) clears any active search. Reset the
  // pre-search refs too, else the search-clear effect would restore the old mode.
  function chooseMode(m){
    preSearchMode.current = null;
    preSearchFilter.current = null;
    setQuery("");
    clearStatusPicks();   // a tab shows its own list; drop any status multi-filter
    setMode(m);
    // Reflect the tab in the URL (#/dispatch · #/neworders · #/orders) so it's
    // shareable / bookmarkable; the hashchange listener re-syncs the route.
    go({ view: "orders", board: m });
  }
  function toggleSel(id){
    setSel(prev => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n; });
  }
  function setSelMany(ids, on){
    setSel(prev => { const n = new Set(prev); ids.forEach(id => on ? n.add(id) : n.delete(id)); return n; });
  }
  // What's on screen: backend search results when searching, else — if statuses
  // are picked — the multi-status list (overrides the mode), else the mode's list.
  const usePicks = results == null && statusPicks.size > 0;
  const shown = results != null ? resultList
    : usePicks ? pickedList
    : (mode === "all" ? flatList : mode === "new" ? newList : boardList);
  const selectedOrders = shown.filter(o => sel.has(o.id));
  // Combine is offered when the operator picks ≥2 ungrouped orders that go together:
  // either the SAME delivery address (guest re-checkout / duplicate profiles) OR the
  // SAME customer (covers an order that's missing an address, or a customer with a
  // couple of orders to combine into one parcel). The CombineModal confirms the parcel.
  const _first = selectedOrders[0];
  const _sameAddr = _first && DTF.combineKey(_first) && selectedOrders.every(o => DTF.combineKey(o) === DTF.combineKey(_first));
  const _sameCust = _first && _first.customerId && selectedOrders.every(o => o.customerId === _first.customerId);
  const canCombine = selectedOrders.length >= 2
    && (_sameAddr || _sameCust)
    && selectedOrders.every(o => !DTF.groupOf(o));
  // Same-destination suggestions on the New / Dispatch / All boards: any
  // un-grouped, still-open order sharing a delivery address (catches duplicate
  // customer profiles / guest re-checkout) — so orders headed to one address can
  // be combined into a single parcel whether they're new or already ready to ship.
  const suggestions = useMemo(() => {
    if (q || usePicks) return [];
    return DTF.groupSuggestions(visible.filter(isOpen));
  }, [mode, q, visible, ver, usePicks]);
  const allShownIds = shown.map(o => o.id);
  const allSelected = allShownIds.length > 0 && allShownIds.every(id => sel.has(id));

  // Bulk hand-over: assign the selected orders to a partner shop (or take them back).
  // The server folds combined-group siblings; the response is the full assignment map.
  async function bulkHandover(slug) {
    const names = selectedOrders.map(o => String(o.id).replace(/^#/, ""));
    if (!names.length) return;
    try {
      const r = slug === "__none__"
        ? await shSend("/api/shops/none/unassign", "POST", { names })
        : await shSend("/api/shops/" + slug + "/assign", "POST", { names });
      if (r && r.orders) DTF.shopAssignments = r.orders;
    } catch (e) { /* best-effort; tag refreshes on next load */ }
    setSel(new Set()); setVer(x => x + 1);
  }

  // Open an order while remembering the exact list it came from, so the order
  // page can offer prev/next through this same board / search / filtered set.
  const navSource = results != null
    ? `Search "${q}"`
    : usePicks
    ? "Status: " + [...statusPicks].map(k => (DTF.statusMeta[k]||{}).label || k).join(", ")
    : (mode === "board" ? "Dispatch board" : mode === "new" ? "New orders" : "All orders")
      + (mode === "board" && statusFilter !== "todispatch" && statusFilter !== "all" ? " · " + (DTF.statusMeta[statusFilter] ? DTF.statusMeta[statusFilter].label : statusFilter)
         : mode === "board" && statusFilter === "todispatch" ? " · to dispatch" : "");
  function openOrder(o){ DTF.setOrderNav(shown.map(x => x.id), { source: navSource, route: { view:"orders", board: mode } }); go({ view:"order", id:o.id }); }

  return (
    <div className="board">
      <div className="stats">
        <div className="stat"><div className="stat__k">Due to dispatch today</div><div className="stat__v">{dueToday.length}</div><div className="stat__d">{overdue>0 ? <><span className="dn">{overdue} overdue</span> · {dueToday.length-overdue} today</> : `${dueToday.length} ${dueToday.length===1?"order":"orders"} today`}</div></div>
        <div className="stat stat--prod" style={{cursor:"pointer"}} onClick={()=>go({view:"production"})} title="Open production">
          <div className="stat__k">In production</div>
          <div className="stat__v">{prodTotal}</div>
          <div className="prod-break">
            {PROD_QUEUES.map(([s,l]) => (
              <span key={s} className={"prod-break__i"+(prodCounts[s]?"":" is-zero")}><b>{prodCounts[s]}</b> {l}</span>
            ))}
          </div>
        </div>
        <div className="stat"><div className="stat__k">Open / unfulfilled</div><div className="stat__v">{unfulfilled}</div><div className="stat__d"><span className="up">▲</span> needs action</div></div>
        <div className="stat"><div className="stat__k">Revenue · today's batch</div><div className="stat__v">{DTF.fmtGBP(revenueToday)}</div><div className="stat__d">incl. VAT</div></div>
      </div>

      <div className="toolbar">
        <button className={"chip-filter"+(allSelected?" is-on":"")} onClick={()=>setSelMany(allShownIds, !allSelected)}>
          {allSelected ? "Deselect all" : "Select all"} ({allShownIds.length})
        </button>
        <div className="seg">
          <button className={mode==="board"?"is-active":""} onClick={()=>chooseMode("board")}>Dispatch board</button>
          <button className={mode==="new"?"is-active":""} onClick={()=>chooseMode("new")}>New orders{newCount?` · ${newCount}`:""}</button>
          <button className={mode==="all"?"is-active":""} onClick={()=>chooseMode("all")}>All orders</button>
        </div>
        <div className="ord-search">
          <Icon name="search" />
          <input type="search" value={query} onChange={e=>setQuery(e.target.value)}
                 placeholder="Search items, SKU, customer, order #, method…" />
          {query && <button className="ord-search__clear" title="Clear" onClick={()=>setQuery("")}>×</button>}
        </div>
        {mode === "board" && statusPicks.size === 0 && (
        <div className={"status-select"+(statusFilter!=="todispatch"&&statusFilter!=="all"?" is-on":"")}>
          <Icon name="filter" />
          <select value={statusFilter} onChange={e=>setStatusFilter(e.target.value)} title="Baseline for the dispatch board">
            <option value="todispatch">To dispatch only</option>
            <option value="all">All statuses</option>
          </select>
        </div>
        )}
        <StatusPicker picks={statusPicks} onToggle={toggleStatusPick} onClear={clearStatusPicks} statuses={STATUS_OPTIONS} />
        <MethodPicker picks={methodPicks} onToggle={toggleMethodPick} onClear={clearMethodPicks} methods={METHOD_OPTIONS} />
        <span className="kick" style={{marginLeft:4}}>
          {q
            ? (searching ? `Searching "${q}"…` : `${resultList ? resultList.length : 0}${sHasMore && !statusPicks.size && !methodPicks.size ? "+" : ""} ${resultList && resultList.length===1?"match":"matches"} for "${q}"${statusPicks.size ? ` in ${statusPicks.size} status${statusPicks.size===1?"":"es"}` : ""}${methodPicks.size ? ` · ${statusPicks.size?"":"filtered to "}${[...methodPicks].map(k=>(DTF.methodMeta[k]||{}).short||k).join(", ")}` : ""}`)
            : usePicks ? `${statusPicks.size} status${statusPicks.size===1?"":"es"} · across all loaded orders`
            : mode === "new" ? "Newest first · by order date"
            : "By dispatch date, then urgency · SD 1pm → RM24 → DPD → RM48 → Collection"}
        </span>
        {!q && (usePicks || mode === "new" || mode === "board") && (() => { const list = usePicks ? pickedList : mode === "new" ? newList : boardList; const m = list.reduce((s,o)=>s+DTF.orderMetres(o),0); return list.length ? <span className="kick">{list.length} orders{m > 0 ? <> · <span className="m-badge m-badge--lg" title="DTF material across the board (linear metres)">{DTF.fmtMetres(m)}</span></> : null}</span> : null; })()}
        <div className="right" style={{display:"flex",gap:8,alignItems:"center"}}>
          {sel.size > 0 && <>
            <span className="kick">{sel.size} selected</span>
            {canCombine && <button className="btn btn--ghost btn--sm" onClick={()=>setCombineFor(selectedOrders)} title="Combine these orders under one parent for a single dispatch"><Icon name="link" /> Combine</button>}
            <button className="btn btn--ghost btn--sm" onClick={()=>printOrderSummary(selectedOrders)}><Icon name="doc" /> Order summary</button>
            <button className="btn btn--ghost btn--sm" onClick={()=>printPackingSlips(selectedOrders)}><Icon name="print" /> Packing slips</button>
            <button className="btn btn--ghost btn--sm" onClick={()=>openLabels(selectedOrders)}><Icon name="truck" /> Create labels</button>
            <select className="rm-in" defaultValue="" onChange={async e=>{ const s=e.target.value; e.target.value=""; if(!s) return; await DTF.setOrdersStage([...sel], s==="__clear__"?null:s); setSel(new Set()); setVer(v=>v+1); }}>
              <option value="">Send to…</option>
              {DTF.STAGE_ROUTES.map(r=><option key={r.stage} value={r.stage}>{r.label}</option>)}
              <option value="__clear__">Clear stage</option>
            </select>
            {(DTF.myShops||[]).length > 0 &&
              <select className="rm-in" defaultValue="" onChange={async e=>{ const v=e.target.value; e.target.value=""; if(!v) return; await bulkHandover(v); }} title="Hand selected orders to a partner shop (location)">
                <option value="">Hand over to…</option>
                {(DTF.myShops||[]).map(s=><option key={s.slug} value={s.slug}>Hand to {s.name}</option>)}
                <option value="__none__">Not handed over</option>
              </select>}
            <button className="btn btn--ghost btn--sm" onClick={()=>setSel(new Set())}>Clear</button>
          </>}
          <button className="btn btn--ghost btn--sm" onClick={todaysSummary} title="Select every loaded order in the configured statuses (statuses set in Settings → Today's Summary List). Print from the selection toolbar if you want a list."><Icon name="doc" /> Today's Summary List</button>
          <button className="btn btn--ghost btn--sm" onClick={doReload} disabled={reloading} title="Reload orders"><Icon name="refresh" /> {reloading ? "Reloading…" : "Reload"}</button>
          <button className="btn btn--dark btn--sm"><Icon name="plus" /> New order</button>
        </div>
      </div>

      {suggestions.length > 0 && sel.size === 0 && (
        <div className="grp-suggests">
          {suggestions.map(s => (
            <div className="grp-suggest" key={s.customerId}>
              <Icon name="link" />
              <span><b>{s.name}</b> has {s.orderIds.length} open orders to the same address — combine into one dispatch?</span>
              <button className="btn btn--ghost btn--sm" onClick={()=>setCombineFor(s.orderIds.map(id=>DTF.getOrder(id)).filter(Boolean))}>Review &amp; combine</button>
            </div>
          ))}
        </div>
      )}

      {(results == null && (reloading || (loading && DTF.orders.length === 0))) ? (
        <SkeletonRows rows={9} />
      ) : results != null ? (
        searching && resultList.length === 0 ? (
          <div className="empty-state"><span className="spin spin--dark" /> Searching…</div>
        ) : resultList.length === 0 ? (
          (statusPicks.size || methodPicks.size) && results.length > 0 ? (
            <div className="empty-state">{results.length}{sHasMore ? "+" : ""} match "{q}", but none pass the active filter{statusPicks.size && methodPicks.size ? "s" : ""}. <button className="linklike" onClick={()=>{clearStatusPicks();clearMethodPicks();}}>Clear filters</button></div>
          ) : (
            <div className="empty-state">No orders match "{q}". <button className="linklike" onClick={()=>setQuery("")}>Clear search</button></div>
          )
        ) : (
          <OrdersTable orders={resultList} go={go} onOpen={openOrder} sel={sel} toggleSel={toggleSel} setSelMany={setSelMany} onCollected={markCollectedRow} collecting={collecting} onCreateLabel={openLabels} />
        )
      ) : usePicks ? (
        pickedList.length === 0 ? (
          <div className="empty-state">No loaded orders in {statusPicks.size===1?"that status":"those statuses"}. <button className="linklike" onClick={clearStatusPicks}>Clear</button></div>
        ) : (
          <OrdersTable orders={pickedList} go={go} onOpen={openOrder} sel={sel} toggleSel={toggleSel} setSelMany={setSelMany} onCollected={markCollectedRow} collecting={collecting} onCreateLabel={openLabels} />
        )
      ) : mode === "all" ? (
        <OrdersTable orders={flatList} go={go} onOpen={openOrder} sel={sel} toggleSel={toggleSel} setSelMany={setSelMany} onCollected={markCollectedRow} collecting={collecting} onCreateLabel={openLabels} />
      ) : mode === "new" ? (
        newList.length === 0 ? (
          <div className="empty-state">No new orders to deal with — all triaged. 🎉</div>
        ) : (
          <OrdersTable orders={newList} go={go} onOpen={openOrder} sel={sel} toggleSel={toggleSel} setSelMany={setSelMany} onCollected={markCollectedRow} collecting={collecting} onCreateLabel={openLabels} />
        )
      ) : boardList.length === 0 ? (
        <div className="empty-state">Nothing awaiting dispatch.</div>
      ) : (
        <OrdersTable orders={boardList} go={go} onOpen={openOrder} sel={sel} toggleSel={toggleSel} setSelMany={setSelMany} onCollected={markCollectedRow} collecting={collecting} onCreateLabel={openLabels} />
      )}

      {shown.length > 0 && (results != null ? sHasMore : DTF._ordersHasMore) && (
        <div className="load-more">
          <button className="btn btn--ghost btn--sm" disabled={loadingMore} onClick={doLoadMore}>
            {loadingMore ? "Loading…" : (results != null ? "Load more results" : "Load more orders")}
          </button>
          <span className="kick">{results != null ? `${resultList.length} shown` : `${DTF.orders.length} loaded`}</span>
        </div>
      )}

      {shipFor && shipFor.length > 0 &&
        <ShipModal orders={shipFor} groupChildren={shipChildren} onClose={()=>{ setShipFor(null); setShipChildren({}); }} onDone={()=>{ setSel(new Set()); }} />}

      {combineFor && combineFor.length >= 2 &&
        <CombineModal orders={combineFor} onClose={()=>setCombineFor(null)}
          onDone={()=>{ setCombineFor(null); setSel(new Set()); setVer(v=>v+1); }} />}
    </div>
  );
}

// Order a flat list into group-aware tree rows: each group is emitted at the
// position of its first-seen member, parent first, children immediately below.
// { o, role:"single"|"parent"|"child", group?, members?, parentId? }
function buildTreeRows(orders){
  const byId = new Map(orders.map(o => [o.id, o]));
  const placed = new Set();
  const rows = [];
  for (const o of orders){
    if (placed.has(o.id)) continue;
    const g = DTF.groupOf(o);
    if (!g){ rows.push({ o, role:"single" }); placed.add(o.id); continue; }
    const members = [g.parent, ...g.children].map(id => byId.get(id)).filter(Boolean);
    const parent = byId.get(g.parent) || members[0];
    if (!parent){ rows.push({ o, role:"single" }); placed.add(o.id); continue; }
    rows.push({ o: parent, role:"parent", group:g, members });
    placed.add(parent.id);
    for (const co of members){ if (co.id === parent.id) continue; rows.push({ o: co, role:"child", group:g, parentId:parent.id, parentMethod: parent.method }); placed.add(co.id); }
  }
  return rows;
}

function shortAddr(a){
  if (!a) return "";
  return [a.name, a.address1, a.city, a.zip].filter(Boolean).join(", ");
}

function OrdersTable({ orders, go, onOpen, sel, toggleSel, setSelMany, onCollected, collecting, onCreateLabel }) {
  const open = onOpen || (o => go({ view:"order", id:o.id }));
  const ids = orders.map(o => o.id);
  const allSel = ids.length > 0 && ids.every(id => sel.has(id));
  const [collapsed, setCollapsed] = useState(() => new Set()); // group ids collapsed
  const [ungrouping, setUngrouping] = useState(null);
  const treeRows = buildTreeRows(orders);
  const toggleGroup = gid => setCollapsed(prev => { const n = new Set(prev); n.has(gid) ? n.delete(gid) : n.add(gid); return n; });
  async function ungroup(gid){
    const g = DTF._workflow && DTF._workflow.groups[gid];
    const credited = DTF.groupCreditTotal(g);
    if (credited > 0 && !confirm(`This group refunded ${DTF.fmtGBP(credited)} of shipping to the customer's store credit. Ungrouping will deduct that back off their account. Continue?`)) return;
    setUngrouping(gid);
    try {
      const res = await DTF.dissolveGroup(gid);
      if (res && res.ok === false) {
        alert("Ungroup halted — couldn't reverse store credit for " + (res.failed||[]).map(f=>f.orderId).join(", ") + ". The group was kept so nothing is lost.\n" + (((res.failed||[])[0]||{}).error || ""));
      } else if (res && (res.reversed||[]).length) {
        const back = res.reversed.reduce((s,r)=>s+(r.amount||0),0);
        alert(`Ungrouped. Reversed ${DTF.fmtGBP(back)} of store credit.`);
      }
    } catch(e){ alert("Couldn't ungroup: "+e.message); }
    finally { setUngrouping(null); }
  }
  // A group is selected as ONE unit: checking the parent checks every member;
  // children have no separate checkbox.
  function toggleRow(row){
    if (row.role === "parent"){
      const memberIds = (row.members || [row.o]).map(m => m.id);
      setSelMany && setSelMany(memberIds, !sel.has(row.o.id));
    } else { toggleSel(row.o.id); }
  }
  return (
    <div className="tbl-wrap">
    <table className="tbl">
      <thead>
        <tr>
          <th style={{width:30}}>
            <span className={"checkbox"+(allSel?" is-on":"")} style={{cursor:"pointer"}} title="Select all"
                  onClick={()=>setSelMany && setSelMany(ids, !allSel)} />
          </th>
          <th style={{width:54}}>Flags</th>
          <th>Order</th>
          <th>Placed</th>
          <th>Dispatch by</th>
          <th>Customer</th>
          <th>Items</th>
          <th>Channel</th>
          <th>Method</th>
          <th>Tracking</th>
          <th className="num">Total</th>
          <th>Payment</th>
          <th>Fulfilment</th>
          <th>Status</th>
          <th style={{width:84}}>Progress</th>
        </tr>
      </thead>
      <tbody>
        {treeRows.map(row => {
          if (row.role === "child" && collapsed.has(row.group.id)) return null;
          return <OrderRow key={row.o.id} row={row} open={open} sel={sel} onToggle={()=>toggleRow(row)}
            onCollected={onCollected} collecting={collecting} go={go} onCreateLabel={onCreateLabel}
            collapsed={row.role === "parent" && collapsed.has(row.group.id)}
            onToggleGroup={toggleGroup} onUngroup={ungroup} ungrouping={ungrouping} />;
        })}
      </tbody>
    </table>
    </div>
  );
}

function OrderRow({ row, open, sel, onToggle, onCollected, collecting, go, collapsed, onToggleGroup, onUngroup, ungrouping, onCreateLabel }) {
  const o = row.o;
  const role = row.role;
  const isParent = role === "parent";
  const isChild = role === "child";
  const members = row.members || [o];
  // The parent row speaks for the whole group: it shows every group item and the
  // combined material total (this is the order you dispatch).
  const dispItems = (isParent ? members.flatMap(m => (m.items||[]).map(it => ({ ...it, _from: m.id }))) : o.items).filter(it => !it.removed);
  const dispMetres = isParent ? members.reduce((s,m)=>s+DTF.orderMetres(m),0) : DTF.orderMetres(o);
  const dispUnits = dispItems.reduce((s,it)=>s+(it.qty||0),0);
  const c = DTF.getCustomer(o.customerId) || {};
  const isSel = sel.has(o.id);
  const hasUV = dispItems.some(it => /\buv\b/i.test(it.name || ""));
  const printing = DTF.printingFor && DTF.printingFor(o.id);
  const otherAddrs = isParent ? members.filter(m => m.id !== o.id && shortAddr(m.ship) && shortAddr(m.ship) !== shortAddr(o.ship)).length : 0;
  const rowCls = (isSel?"is-sel":"")+(hasUV?" has-uv":"")+(printing?" is-printing":"")
    + (isParent?" ord-row--parent":"") + (isChild?" ord-row--child":"");
  // Which partner shop (location) this order is handed over to, if any.
  const shopTag = (() => {
    const map = (DTF && DTF.shopAssignments) || {};
    const slug = map["#" + String(o.id).replace(/^#/, "")] || map[String(o.id)];
    if (!slug) return null;
    const byId = (DTF && DTF.shopsById) || {};
    return byId[slug] || { slug, name: slug };
  })();
  return (
    <tr className={rowCls} onClick={()=>open(o)}>
      <td onClick={e=>{e.stopPropagation(); if(!isChild) onToggle();}}>
        {isChild
          ? <span className="grp-childlink" title="Selected together with the main order">↳</span>
          : <span className={"checkbox"+(isSel?" is-on":"")} title={isParent?"Select the whole group":""} />}
      </td>
      <td><Flags flags={o.flags} /></td>
      <td>
        {isParent && (
          <span className="grp-toggle" title={collapsed?"Show combined orders":"Hide combined orders"}
                onClick={e=>{ e.stopPropagation(); onToggleGroup(row.group.id); }}>{collapsed?"▸":"▾"}</span>
        )}
        {isChild && <span className="grp-childmark" title={"Combined under "+row.parentId}>↳</span>}
        <span className="ordno">{o.id}</span>
        {isParent && (
          <span className="grp-badge" title="Combined order — dispatched as one"><Icon name="link" /> {members.length} combined</span>
        )}
        {isChild && <span className="grp-childof mono" title="Main order for this group">of {row.parentId}</span>}
        {isParent && (
          <button className="grp-ungroup" title="Ungroup" disabled={ungrouping===row.group.id}
                  onClick={e=>{ e.stopPropagation(); onUngroup(row.group.id); }}>{ungrouping===row.group.id?"…":"ungroup"}</button>
        )}
        {shopTag && <span className="loc-tag" title={"Handed to " + shopTag.name} onClick={e=>{ e.stopPropagation(); go({ view:"shop", slug: shopTag.slug, id: String(o.id).replace(/^#/,"") }); }}><Icon name="box" /> {shopTag.name}</span>}
      </td>
      <td style={{whiteSpace:"nowrap"}}>
        <span style={{fontWeight:600,fontSize:12.5}}>{fmtPlaced(o.date).day}</span>
        <div className="muted" style={{fontFamily:"var(--font-mono)",fontSize:10.5}}>{fmtPlaced(o.date).time}</div>
      </td>
      <td style={{whiteSpace:"nowrap"}}>
        <span style={{fontWeight:600,fontSize:12.5}}>{fmtDay(o.fulfillBy)}</span>
        {(() => {
          const stillOpen = !["dispatched","collected","delivered","closed","refunded","cancelled"].includes(o.status);
          const overdue = stillOpen && o.fulfillBy < DTF.TODAY;
          return <div style={{fontFamily:"var(--font-mono)",fontSize:10.5,color:overdue?"var(--c-red)":"var(--txt-3)"}}>{relDay(o.fulfillBy)}</div>;
        })()}
      </td>
      <td>
        <span className="cust">{c.name || (o.ship && o.ship.name) || "—"}</span>
        <div className="muted" style={{fontFamily:"var(--font-mono)",fontSize:10.5}}>{c.location || ""}</div>
        {(isParent || isChild) && shortAddr(o.ship) && <div className="grp-addr mono" title="Ship-to address">{shortAddr(o.ship)}</div>}
        {isParent && otherAddrs > 0 && <div className="grp-addr-more">+{otherAddrs} other {otherAddrs===1?"address":"addresses"} in group</div>}
        {o.note && <div className="ord-note" title={o.note}><Icon name="spark" /><span className="ord-note__t">{o.note.replace(/\s+/g," ")}</span></div>}
        {(() => {
          const ct = DTF.threadsForCustomer(o.customerId);
          if (!ct.length) return null;
          const recent = ct.slice().sort((a,b)=> a.updated < b.updated ? 1 : -1)[0];
          return (
            <a href={"#/inbox/"+encodeURIComponent(recent.id)}
               onClick={e=>{ e.stopPropagation(); if(e.metaKey||e.ctrlKey||e.shiftKey||e.button!==0) return; e.preventDefault(); go({ view:"inbox", id:recent.id }); }}
               title="Open most recent email" className="mono"
               style={{ display:"inline-block", marginTop:3, fontSize:10, color:"var(--mint-ink)", textDecoration:"underline", cursor:"pointer" }}>
              ✉ {ct.length} {ct.length===1 ? "email" : "emails"}
            </a>
          );
        })()}
      </td>
      <td>
        <div className="ord-items">
          {dispItems.map((it, i) => {
            const uv = /\buv\b/i.test(it.name || "");
            return (
            <div className={"ord-items__row"+(uv?" is-uv":"")} key={i} title={`${it.name}${it.variant ? " · " + it.variant : ""}${it.sku ? " · " + it.sku : ""}`}>
              <span className="ord-items__qty">{it.qty}×</span>
              {it.sku
                ? <span className="ord-items__sku mono">{it.sku}</span>
                : <span className="ord-items__name">{it.name}</span>}
              {uv && <span className="uv-tag">UV</span>}
            </div>
            );
          })}
        </div>
        <div className="ord-items__sum muted">{isParent?"combined: ":""}{dispUnits} units · {dispItems.length} {dispItems.length===1?"line":"lines"}
          {dispMetres > 0 ? <> · <span className="m-badge" title="DTF material ordered (linear metres)">{DTF.fmtMetres(dispMetres)}</span></> : null}
        </div>
      </td>
      <td><ChannelBadge channel={o.channel} /></td>
      <td>{isChild && row.parentMethod && row.parentMethod !== o.method
        ? <span style={{display:"inline-flex",alignItems:"center",gap:6}}
                title={`Ships with main order ${row.parentId} by ${(DTF.methodMeta[row.parentMethod]||{}).label || row.parentMethod}. Customer chose ${(DTF.methodMeta[o.method]||{}).label || o.method}.`}>
            <MethodCell method={row.parentMethod} />
            <span className="grp-childof mono" style={{fontSize:10}}>via main</span>
          </span>
        : <MethodCell method={isChild && row.parentMethod ? row.parentMethod : o.method} />}</td>
      <td>{o.tracking && (o.tracking.url || o.tracking.number)
        ? (o.tracking.url
            ? <a href={o.tracking.url} target="_blank" rel="noopener noreferrer" onClick={e=>e.stopPropagation()}
                 className="mono" style={{color:"var(--mint-ink)",fontSize:11,textDecoration:"underline",whiteSpace:"nowrap"}}>{o.tracking.number || "Track"}</a>
            : <span className="mono" style={{fontSize:11}}>{o.tracking.number}</span>)
        : <span className="dim">—</span>}</td>
      <td className="num" style={{fontWeight:600}}>{DTF.fmtGBP(o.total)}</td>
      <td><MetaBadge map={DTF.payMeta} value={o.pay} /></td>
      <td><MetaBadge map={DTF.fulfilMeta} value={o.fulfil} /></td>
      <td>
        <div className="ord-status">
          {printing
            ? <span className="print-badge" title={"Printing on " + (printing.printer || "printer") + (printing.totalMetres ? " · " + DTF.fmtMetres(printing.totalMetres) : "")}><span className="print-badge__dot" />🖨 Printing{printing.percent != null ? " " + printing.percent + "%" : ""}</span>
            : o.status === "held" && o.holdUntil
            ? <span className="badge badge--grey" title={"Held off the boards until " + o.holdUntil + " — re-appears in New orders that day"}><Icon name="clock" /> Held until {o.holdUntil}</span>
            : <StatusBadge status={o.status} />}
          {o.method === "Collection" && !["collected","refunded","cancelled","closed"].includes(o.status) && onCollected && (
            <button className="btn btn--ghost btn--sm"
                    disabled={collecting === o.id}
                    title={members.length > 1 ? "Marks all " + members.length + " combined orders collected" : ""}
                    onClick={e=>{ e.stopPropagation();
                      // A combined order collects as one unit — mark every group member
                      // that isn't already collected/terminal.
                      const ids = members.filter(m => !["collected","refunded","cancelled","closed"].includes(m.status)).map(m => m.id);
                      onCollected(ids.length ? ids : [o.id]); }}>
              <Icon name="check" /> {collecting === o.id ? "…" : (members.length > 1 ? "Mark all collected" : "Mark collected")}
            </button>
          )}
          {onCreateLabel && !isChild && o.method !== "Collection"
            && !["dispatched","collected","delivered","closed","refunded","cancelled"].includes(o.status) && (
            <button className="btn btn--primary btn--sm" title="Create Royal Mail label for this order"
                    onClick={e=>{ e.stopPropagation(); onCreateLabel([o]); }}>
              <Icon name="truck" /> Create label
            </button>
          )}
        </div>
      </td>
      <td><ProgressDots o={o} /></td>
    </tr>
  );
}

function CombineModal({ orders, onClose, onDone }) {
  // Default the main order to the one with the most items (then most material,
  // then earliest) — usually the "biggest" order. The operator can override.
  const scored = orders.slice().sort((a,b) => {
    // The parent drives the parcel's delivery address, so prefer an order that HAS one
    // (some orders — e.g. a fee/collection line — arrive with no shipping address).
    const aa = (a.ship && a.ship.address1) ? 1 : 0, ba = (b.ship && b.ship.address1) ? 1 : 0;
    if (aa !== ba) return ba - aa;
    const au = (a.items||[]).reduce((s,i)=>s+i.qty,0), bu = (b.items||[]).reduce((s,i)=>s+i.qty,0);
    if (au !== bu) return bu - au;
    const am = DTF.orderMetres(a), bm = DTF.orderMetres(b);
    if (am !== bm) return bm - am;
    return a.date < b.date ? -1 : 1;
  });
  const [parent, setParent] = useState(scored[0].id);
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState("");
  const [creditShip, setCreditShip] = useState(false); // refund child shipping → store credit
  const cust = DTF.getCustomer(orders[0].customerId) || {};
  const totalUnits = orders.reduce((s,o)=>s+(o.items||[]).reduce((a,i)=>a+i.qty,0),0);
  const totalMetres = orders.reduce((s,o)=>s+DTF.orderMetres(o),0);
  // The children (everything but the chosen main) ship inside the main order's
  // parcel, so their already-paid shipping can be refunded to store credit.
  const children = orders.filter(o => o.id !== parent);
  const childShip = children.reduce((s,c)=>s+(c.shipping||0),0);
  async function combine(){
    setErr(""); setBusy(true);
    try {
      await DTF.createGroup(parent, children.map(o=>o.id));
      if (creditShip) {
        const toCredit = children.filter(c => (c.shipping||0) > 0);
        const cres = await Promise.allSettled(toCredit.map(c => DTF.creditShipping(c.id)));
        const fail = toCredit.filter((_,i)=>cres[i].status==="rejected");
        if (fail.length) {
          const msg = ((cres.find(r=>r.status==="rejected")||{}).reason||{}).message || "error";
          setErr(`Combined, but store credit failed for ${fail.map(c=>c.id).join(", ")}: ${msg}`);
          setBusy(false); return; // group exists; leave modal open so the error is seen
        }
      }
      onDone && onDone();
    }
    catch(e){ setErr(e.message || "Couldn't combine"); setBusy(false); }
  }
  return (
    <div className="modal-overlay" onClick={onClose}>
      <div className="modal" onClick={e=>e.stopPropagation()}>
        <div className="modal__head">
          <h3>Combine {orders.length} orders{cust.name?` — ${cust.name}`:""}</h3>
          <button className="icon-btn" onClick={onClose}><Icon name="x" /></button>
        </div>
        <div className="modal__body">
          <p className="muted" style={{marginTop:0}}>Choose the <b>main order</b>. It leads the group, shows every item, and is the one you dispatch. The others sit under it as children.</p>
          <div className="combine-list">
            {orders.map(o => {
              const isP = o.id === parent;
              const units = (o.items||[]).reduce((a,i)=>a+i.qty,0);
              const m = DTF.orderMetres(o);
              return (
                <label key={o.id} className={"combine-row"+(isP?" is-parent":"")}>
                  <input type="radio" name="cmb-parent" checked={isP} onChange={()=>setParent(o.id)} />
                  <div className="combine-row__main">
                    <div style={{display:"flex",alignItems:"center",gap:6,flexWrap:"wrap"}}>
                      <span className="ordno">{o.id}</span>
                      <span className="combine-row__method">{o.method || "—"}</span>
                      {isP && <span className="grp-badge">Main</span>}
                    </div>
                    <div className="muted mono" style={{fontSize:11}}>{shortAddr(o.ship) || "No shipping address"}</div>
                  </div>
                  <div className="combine-row__meta">{units} units{m>0?` · ${DTF.fmtMetres(m)}`:""}</div>
                </label>
              );
            })}
          </div>
          {childShip > 0 && (
            <label className="combine-credit">
              <input type="checkbox" checked={creditShip} onChange={e=>setCreditShip(e.target.checked)} />
              <span>Refund the other order{children.length===1?"'s":"s'"} shipping (<b>{DTF.fmtGBP(childShip)}</b>) to {cust.name||"the customer"}'s account credit
                <span className="muted"> — they ship in one parcel; reversed automatically if you ungroup.</span></span>
            </label>
          )}
          {err && <div className="form-err" style={{marginTop:8}}>{err}</div>}
        </div>
        <div className="modal__foot">
          <span className="kick">{orders.length} orders · {totalUnits} units{totalMetres>0?` · ${DTF.fmtMetres(totalMetres)}`:""}</span>
          <div style={{flex:1}} />
          <button className="btn btn--ghost" onClick={onClose} disabled={busy}>Cancel</button>
          <button className="btn btn--dark" onClick={combine} disabled={busy}>{busy?"Combining…":"Combine orders"}</button>
        </div>
      </div>
    </div>
  );
}

// A vector SVG design (e.g. the "Design Preview" of a gang-sheet upload): rasterise
// it to a print-ready PNG at a fixed physical width (cm) — height stays proportional
// to the SVG's aspect ratio — at 300 DPI, then attach to the order's artwork.
const DTF_SHEET_WIDTH_CM = 57; // standard DTF roll width
function SvgConvertProp({ orderId, value }) {
  const v = String(value);
  let fname = "design";
  try { fname = decodeURIComponent(v.split("?")[0].split("/").pop() || "design"); } catch {}
  const [widthCm, setWidthCm] = useState(DTF_SHEET_WIDTH_CM);
  const [busy, setBusy] = useState(false);
  const [done, setDone] = useState(false);
  const [err, setErr] = useState("");
  const [step, setStep] = useState("");
  const [aspect, setAspect] = useState(null); // height / width from the SVG's intrinsic size
  useEffect(() => {
    let alive = true;
    const im = new Image();
    im.onload = () => { if (alive && im.naturalWidth) setAspect(im.naturalHeight / im.naturalWidth); };
    im.src = v;
    return () => { alive = false; };
  }, [v]);
  const heightCm = aspect ? Math.round(widthCm * aspect * 10) / 10 : null;
  const wpx = Math.round(widthCm / 2.54 * 300);
  const hpx = heightCm != null ? Math.round(heightCm / 2.54 * 300) : null;
  async function convert() {
    if (busy) return;
    setBusy(true); setErr(""); setDone(false); setStep("starting");
    try {
      await DTF.svgToPng(orderId, v, widthCm, setStep);
      setDone(true);
      window.dispatchEvent(new CustomEvent("dtf:artwork-updated", { detail: { name: orderId } }));
    } catch (e) { setErr(e.message || "Convert failed"); }
    finally { setBusy(false); }
  }
  return (
    <div className="dpp">
      <a href={v} target="_blank" rel="noopener noreferrer" title="Open SVG" className="dpp__thumb"><img src={v} alt="svg preview" /></a>
      <div className="dpp__body">
        <div className="dpp__namerow">
          <a href={v} target="_blank" rel="noopener noreferrer" className="dpp__name mono" title={fname}>{fname}</a>
        </div>
        <div className="dpp__meta mono">SVG (vector){aspect ? " · " + (Math.round(aspect * 100) / 100) + ":1 h/w" : ""} — scales to any size</div>
        <div className="dpp__actions" style={{ alignItems: "center", gap: 8 }}>
          <label className="kick" style={{ display: "flex", alignItems: "center", gap: 4 }}>Width
            <input type="number" min="1" max="300" value={widthCm} disabled={busy}
              onChange={e => setWidthCm(Math.max(1, Math.min(300, Number(e.target.value) || 0)))}
              className="rm-in" style={{ width: 58 }} /> cm</label>
          <button className="btn btn--sm btn--primary" disabled={busy} onClick={convert} title="Rasterise the SVG to a print-ready PNG at this width (proportional height) @300 DPI and attach to Artwork">
            {busy ? <><span className="spin" /> {step || "Converting…"}</> : done ? <><Icon name="check" /> Added</> : <><Icon name="image" /> Convert to PNG</>}
          </button>
        </div>
        <div className="dpp__meta mono" style={{ color: "var(--mint-ink)" }} title="Output size: fixed width, height proportional to the SVG aspect ratio">
          → {widthCm} × {heightCm != null ? heightCm : "?"} cm @300 DPI ({wpx} × {hpx != null ? hpx : "?"} px)
        </div>
        <div className="dpp__status">
          {done && <span className="kick" style={{ color: "var(--mint-ink)" }}>saved to Artwork below</span>}
          {err && <span style={{ color: "var(--c-red)", fontSize: 12 }}>{err}</span>}
        </div>
      </div>
    </div>
  );
}

// A customer-uploaded PDF design (Design Preview) — download it, or rasterise its
// first page to a 300-DPI print-ready PNG that lands in the order's Artwork.
function PdfConvertProp({ orderId, value }) {
  const v = String(value);
  let fname = "design.pdf";
  try { fname = decodeURIComponent(v.split("?")[0].split("/").pop() || "design.pdf"); } catch {}
  const dlName = dlNameFor(orderId, v);
  const dlHref = "/api/dl?src=" + encodeURIComponent(v) + "&name=" + encodeURIComponent(dlName) + (orderId ? "&order=" + encodeURIComponent(orderId) : "");
  const [busy, setBusy] = useState(false);
  const [done, setDone] = useState(false);
  const [err, setErr] = useState("");
  const [step, setStep] = useState("");
  async function convert() {
    if (busy) return;
    setBusy(true); setErr(""); setDone(false); setStep("starting");
    try {
      await DTF.pdfToPng(orderId, v, setStep);
      setDone(true);
      window.dispatchEvent(new CustomEvent("dtf:artwork-updated", { detail: { name: orderId } }));
    } catch (e) { setErr(e.message || "Convert failed"); }
    finally { setBusy(false); }
  }
  return (
    <span className="fileprop" style={{ flexWrap: "wrap", gap: 8 }}>
      <a href={v} target="_blank" rel="noopener noreferrer" className="fileprop__name mono" title={fname}>{fname}</a>
      <a href={dlHref} className="dpp__dl" title={"Download as " + dlName}><Icon name="download" /> Download</a>
      <button className="btn btn--sm btn--primary" disabled={busy} onClick={convert} title="Rasterise the PDF's first page to a print-ready PNG @300 DPI and attach to Artwork">
        {busy ? <><span className="spin" /> {step || "Converting…"}</> : done ? <><Icon name="check" /> Added to Artwork</> : <><Icon name="image" /> Convert to PNG</>}
      </button>
      {err && <span style={{ color: "var(--c-red)", fontSize: 11 }}>{err}</span>}
    </span>
  );
}

// How far along the pipeline an order is: 0 New → 1 In production → 2 Ready to
// dispatch → 3 Dispatched. Drives the progress dots so they fill dynamically.
function progRank(o) {
  const s = o.status;
  if (o.dispatched || ["dispatched","collected","delivered","closed"].includes(s)) return 3;
  if (["ready_dispatch","ready-collection"].includes(s)) return 2;
  if (s === "new") return 0;
  if (["refunded","cancelled"].includes(s)) return 0;
  const prod = (DTF.PRODUCTION_STAGES || []).concat(DTF.HOLD_STAGES || []);
  if (prod.includes(s) || o.packed) return 1;
  return 1; // any other open state = in progress
}
function ProgressDots({ o }) {
  const rank = progRank(o);
  const steps = [
    { on: rank >= 0, title:"New / placed" },
    { on: rank >= 1, title:"In production" },
    { on: rank >= 2, title:"Ready to dispatch" },
    { on: rank >= 3, title:"Dispatched" },
  ];
  const cur = steps[Math.min(rank, steps.length - 1)];
  return (
    <span className="prog" title={"Stage: " + cur.title}>
      <span className="prog__dots">
        {steps.map((s,i)=> <span key={i} className={"prog__dot"+(s.on?" on":"")+(i===rank?" is-current":"")} title={s.title} />)}
      </span>
    </span>
  );
}

const isImageUrl = v => /^https?:\/\//i.test(v) && /\.(png|jpe?g|webp|gif)(\?|$)/i.test(v);
const isSvgUrl = v => /^https?:\/\//i.test(v) && /\.svg(\?|$)/i.test(v);
const isPdfUrl = v => /^https?:\/\//i.test(v) && /\.pdf(\?|$)/i.test(v);
// Downloadable design/document files (e.g. a PDF "Design Preview") — get a
// rename-on-download CTA rather than a plain open-in-tab link.
const isFileUrl = v => /^https?:\/\//i.test(v) && /\.(pdf|ai|eps|psd|cdr|zip|rar|7z|tiff?)(\?|$)/i.test(v);

// Filename to save a design file under: prefix the order number if it isn't
// already there, so downloads are grouped by order (e.g. 3964-artwork.pdf).
function dlNameFor(orderId, url) {
  let fname = "file";
  try { fname = decodeURIComponent((String(url).split("?")[0].split("/").pop() || "file")); } catch {}
  const bare = String(orderId || "").replace(/^#/, "");
  return (bare && !new RegExp("^#?" + bare + "[-_ ]").test(fname)) ? bare + "-" + fname : fname;
}

// Ordered physical print size (cm) from a line item's "Width (cm)" / "Height (cm)"
// properties (Custom Size DTF uploads). Returns null when not both present.
function readPrintCm(it) {
  const g = nm => { const p = (it.properties || []).find(pp => (pp.name || "").trim().toLowerCase() === nm); return p ? parseFloat(String(p.value).replace(/[^\d.]/g, "")) : NaN; };
  const w = g("width (cm)"), h = g("height (cm)");
  return (w > 0 && h > 0) ? { w, h } : null;
}

// A line-item design image (e.g. the "Design Preview" property): shows a thumbnail
// and a one-click "Make print-ready" that runs bria bg-removal → Real-ESRGAN 10×
// upscale → trim → 300 DPI on the server and attaches it to the order's artwork.
function DesignPreviewProp({ orderId, value, showCta = true, printCm = null, showDownload = true, allowDpiFix = false }) {
  const v = String(value);
  let fname = "design";
  try { fname = decodeURIComponent((v.split("?")[0].split("/").pop() || "design")); } catch {}
  // Download name: prefix the order number if it isn't already there, so saved
  // design files are grouped by order (e.g. 3865-…-dtf.png). Served via /api/dl
  // (a proxy) because a cross-origin `download=` attribute can't rename the file.
  const _bareOrder = String(orderId || "").replace(/^#/, "");
  const dlName = (_bareOrder && !new RegExp("^#?" + _bareOrder + "[-_ ]").test(fname)) ? _bareOrder + "-" + fname : fname;
  const dlHref = "/api/dl?src=" + encodeURIComponent(v) + "&name=" + encodeURIComponent(dlName) + (orderId ? "&order=" + encodeURIComponent(orderId) : "");
  const [busyKind, setBusyKind] = useState(null); // "print" | "upscale" | null
  const [doneKind, setDoneKind] = useState(null);
  const [step, setStep] = useState("");
  const [err, setErr] = useState("");
  // Upscale factor: just enough to reach 300 DPI at the ordered physical size,
  // plus ~10% headroom (better a bit bigger — the RIP downsizes), capped at 10×.
  // Falls back to 10 when the print size or the image's real dimensions are unknown.
  function scaleFor(m) {
    const DEFAULT = 10;
    if (!printCm || !(printCm.w > 0) || !(printCm.h > 0) || !m || !m.width || !m.height) return DEFAULT;
    const DPI = 300, MARGIN = 1.1, ESRGAN_MAX_PX = 900000; // must match server/outreach.js
    const targetW = (printCm.w / 2.54) * DPI, targetH = (printCm.h / 2.54) * DPI;
    // Already at/above the target print resolution → no upscale needed. (Without
    // this, a big image would still get an upscale factor because the pipeline
    // shrinks it to ~900K first — running that on an already-sharp image only
    // degrades it.)
    if (m.width >= targetW && m.height >= targetH) return 1;
    // The upscaler shrinks inputs over ~900K px to fit the GPU BEFORE scaling, so
    // the ×N applies to the downscaled size — compute N from that, not the original.
    const fit = Math.min(1, Math.sqrt(ESRGAN_MAX_PX / (m.width * m.height)));
    const inW = m.width * fit, inH = m.height * fit;
    const need = Math.max(targetW / inW, targetH / inH) * MARGIN;
    return Math.min(DEFAULT, Math.max(1, Math.ceil(need * 10) / 10));
  }

  async function run(kind) {
    if (busyKind) return;
    setBusyKind(kind); setErr(""); setDoneKind(null); setStep("starting");
    if (kind === "setdpi") {
      // Resolution fix only — re-stamp to 300 DPI, no upscale/bg/services.
      try {
        await DTF.processArtwork(orderId, v, { setdpi: true, dpi: 300, onStep: setStep });
        setDoneKind(kind);
        window.dispatchEvent(new CustomEvent("dtf:artwork-updated", { detail: { name: orderId } }));
      } catch (e) { setErr(e.message || "Couldn't fix the resolution"); }
      finally { setBusyKind(null); }
      return;
    }
    if (kind === "trim") {
      // Trim only — crop transparent margins to the artwork + 300 DPI. No bg/upscale.
      try {
        await DTF.processArtwork(orderId, v, { trim: true, dpi: 300, onStep: setStep });
        setDoneKind(kind);
        window.dispatchEvent(new CustomEvent("dtf:artwork-updated", { detail: { name: orderId } }));
      } catch (e) { setErr(e.message || "Couldn't trim"); }
      finally { setBusyKind(null); }
      return;
    }
    let m = meta;
    if (!m && DTF.fetchImageMeta) { try { m = await DTF.fetchImageMeta(v); } catch {} }
    const scale = scaleFor(m);
    // kind → which pipeline steps run; skip the upscaler when it's already big enough
    const opts = { scale, dpi: 300, removeBg: kind !== "upscale", upscale: kind !== "removebg" && scale > 1.05, onStep: setStep };
    try {
      await DTF.processArtwork(orderId, v, opts);
      setDoneKind(kind);
      window.dispatchEvent(new CustomEvent("dtf:artwork-updated", { detail: { name: orderId } }));
    } catch (e) { setErr(e.message || "Processing failed"); }
    finally { setBusyKind(null); }
  }
  const ACTIONS = [
    { kind: "print",    cls: "btn--primary", icon: "image",    label: "Make Print-Ready", title: "Remove background → upscale 10× (Real-ESRGAN) → trim → 300 DPI → attach to artwork" },
    { kind: "upscale",  cls: "btn--ghost",   icon: "spark",    label: "Upscale",          title: "Upscale 10× (Real-ESRGAN) → trim → 300 DPI → attach — keeps the background" },
    { kind: "removebg", cls: "btn--ghost",   icon: "scissors", label: "Remove BG",        title: "Remove background → trim → 300 DPI → attach — no upscale" },
    { kind: "trim",     cls: "btn--ghost",   icon: "crop",     label: "Trim",             title: "Crop transparent AND white margins down to the artwork → 300 DPI → attach — no background removal or upscale" },
  ];
  // Actual pixel dimensions + DPI of the uploaded design (read server-side).
  const [meta, setMeta] = useState(null);
  useEffect(() => {
    let alive = true;
    if (DTF.fetchImageMeta) DTF.fetchImageMeta(v).then(m => { if (alive) setMeta(m); });
    return () => { alive = false; };
  }, [v]);
  const metaLabel = (() => {
    if (!meta || !meta.width) return null;
    let s = meta.width + " × " + meta.height + " px";
    if (meta.dpi) {
      const mm = px => Math.round(px / meta.dpi * 25.4);
      s += " · " + meta.dpi + " DPI · " + mm(meta.width) + " × " + mm(meta.height) + " mm";
    }
    return s;
  })();
  // What the print CTAs will do given the ordered size (credit-saving hint).
  const planLabel = (() => {
    if (!printCm || !meta || !meta.width) return null;
    const s = scaleFor(meta);
    return s > 1.05
      ? `${printCm.w} × ${printCm.h} cm @300 DPI → upscale ${s}×`
      : `${printCm.w} × ${printCm.h} cm @300 DPI → already high-res, no upscale`;
  })();
  return (
    <div className="dpp">
      <a href={DTF.previewUrl(v)} target="_blank" rel="noopener noreferrer" title="Open full design (on a checkerboard so transparency shows)" className="dpp__thumb">
        <img src={DTF.thumbUrl(v, 120)} alt="design preview" />
      </a>
      <div className="dpp__body">
        <div className="dpp__namerow">
          <a href={DTF.previewUrl(v)} target="_blank" rel="noopener noreferrer" className="dpp__name mono" title={fname}>{fname}</a>
          {showDownload && <a href={dlHref} className="dpp__dl" title={"Download as " + dlName}><Icon name="download" /> Download</a>}
        </div>
        {metaLabel && <div className="dpp__meta mono" title="Actual uploaded image dimensions · DPI · physical size at that DPI">{metaLabel}</div>}
        {/* Resolution fix: the "Upload DTF Gang Sheet" product arrives with a missing
            or 72-DPI tag so the print size reads huge — one click re-stamps to 300 DPI
            (pixels untouched) and attaches it. Only for that product; no services. */}
        {allowDpiFix && meta && meta.width && meta.dpi !== 300 && (
          <div className="dpp__meta">
            <button className="btn btn--ghost btn--sm" disabled={!!busyKind}
                    onClick={() => run("setdpi")}
                    title="Re-stamp this file to 300 DPI so its physical size is correct, and attach it — no upscaling or background changes">
              {busyKind === "setdpi" ? <><span className="spin spin--dark" /> {step || "Fixing…"}</> : <><Icon name="spark" /> Fix resolution → 300 DPI</>}
            </button>
            {doneKind === "setdpi" && <span className="dim" style={{ marginLeft: 6, color: "var(--mint-ink)" }}>✓ attached</span>}
          </div>
        )}
        {showCta && planLabel && <div className="dpp__meta mono" style={{ color: "var(--mint-ink)" }} title="Upscale factor needed to hit 300 DPI at the ordered size (+10% headroom); RIP downsizes to exact size">{planLabel}</div>}
        {showCta && <>
          <div className="dpp__actions">
            {ACTIONS.map(a => (
              <button key={a.kind} className={"btn btn--sm " + a.cls} disabled={!!busyKind} onClick={()=>run(a.kind)} title={a.title}>
                {busyKind===a.kind ? <><span className={a.cls==="btn--primary" ? "spin" : "spin spin--dark"} /> {step || "Working…"}</>
                  : doneKind===a.kind ? <><Icon name="check" /> Added</>
                  : <><Icon name={a.icon} /> {a.label}</>}
              </button>
            ))}
          </div>
          <div className="dpp__status">
            {busyKind && <span className="kick">~1 min</span>}
            {doneKind && <span className="kick" style={{ color: "var(--mint-ink)" }}>saved to Artwork below</span>}
            {err && <span style={{ color: "var(--c-red)", fontSize: 12 }}>{err}</span>}
          </div>
        </>}
      </div>
    </div>
  );
}

function PropValue({ value, orderId, download }) {
  const v = String(value);
  const hasExt = /\.[a-z0-9]{2,4}(\?|#|$)/i.test(v);
  // A downloadable file (e.g. a PDF "Design Preview"), a non-preview image
  // (e.g. "_Print Ready File"), or a download-flagged link (e.g. the gang-sheet
  // builder's "_actual_gang_sheet", which 302s to a signed print file): show a
  // "Download" CTA that saves it renamed via the /api/dl proxy — a cross-origin
  // download= attribute can't rename the file, so we proxy it.
  if (isFileUrl(v) || isImageUrl(v) || (download && /^https?:\/\//i.test(v))) {
    let fname = v;
    try { fname = decodeURIComponent((v.split("?")[0].split("/").pop() || v)); } catch {}
    const dlName = dlNameFor(orderId, v);
    // Extension-less links: don't force a name — let /api/dl use the real
    // filename from the resolved file, and show a friendly "Open" label.
    const dlHref = "/api/dl?src=" + encodeURIComponent(v) + (hasExt ? "&name=" + encodeURIComponent(dlName) : "") + (orderId ? "&order=" + encodeURIComponent(orderId) : "");
    return (
      <span className="fileprop">
        <a href={v} target="_blank" rel="noopener noreferrer" className="fileprop__name mono"
           style={{ color:"var(--mint-ink)", textDecoration:"underline", wordBreak:"break-all" }} title={fname}>{hasExt ? fname : "Open"}</a>
        <a href={dlHref} className="dpp__dl" title="Download"><Icon name="download" /> Download</a>
      </span>
    );
  }
  if (/^https?:\/\//i.test(v)) {
    return <a href={v} target="_blank" rel="noopener noreferrer"
              style={{ color:"var(--mint-ink)", textDecoration:"underline", wordBreak:"break-all" }}>{v}</a>;
  }
  if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v)) {
    return <a href={"mailto:" + v} style={{ color:"var(--mint-ink)", textDecoration:"underline" }}>{v}</a>;
  }
  return <span>{v}</span>;
}

// Reusable line-item custom-properties block — the aligned label/value rows shown
// under an item on BOTH the admin order view and the partner shop view. Renders
// design previews with artwork services (DesignPreviewProp), SVG converters,
// downloadable files/images, and plain text/links, all in the .li__props layout.
// Pass the normalised line item (needs .name + .properties) and the order id used
// to name downloads. On the shop view the props are already redacted server-side
// (admin/edit builder links stripped), so partners just see previews + files.
function ItemProps({ it, orderId }) {
  if (!(it && it.properties && it.properties.length)) return null;
  // Chosen sides (Front/Back/sleeves…) on a customised product — each is its own
  // artwork image, so give it the full artwork services + download, like a preview.
  const sideNames = new Set(customisedSides(it).map(s => s.label.trim().toLowerCase()));
  // When the item already carries a print-ready file, the low-res "Preview" is just
  // a thumbnail — no point downloading it, so its Download CTA is suppressed and only
  // the print-ready file keeps one.
  const hasPrintReady = it.properties.some(p => /actual|gang[\s_-]*sheet|print[\s_-]*ready/i.test(String(p.name || "").replace(/^_+/, "")));
  // Only the "Upload DTF Gang Sheet" product gets the 300-DPI resolution fix — those
  // uploads come in tagged 72 DPI (already full-res, just wrong metadata).
  const isGangUpload = /upload\b.*gang\s*sheet/i.test(it.name || "");
  return (
    <div className="li__props">
      {it.properties.map((p, j) => {
        const label = p.name.replace(/^_+/, "");
        const val = String(p.value || "");
        const isHttp = /^https?:\/\//i.test(val);
        // Thumbnail preview only on "Preview"/"Design Preview" props — not
        // "Print Ready File", which shows as a plain download link.
        const isPreview = /preview/i.test(label);
        const isSide = sideNames.has(label.trim().toLowerCase());
        // The customer's raw upload on the Custom Size DTF product ("Uploaded File")
        // is a low-res, low-DPI screen image behind an extension-less redirect URL —
        // downloading it as-is gives a plain, unprintable file. Treat it as a design
        // image so it gets a thumbnail, a real download, and "Make print-ready"
        // (upscale → 300 DPI) to produce an actual print file.
        const isUpload = /uploaded\s*file/i.test(label) && isHttp && !isPdfUrl(val) && !isSvgUrl(val);
        // The "_Print Ready" value points at the upload app's builder PAGE (returns
        // HTML), not a file — never offer it as a download; link out to it instead.
        const isAppBuilder = isHttp && /dripappsserver\.com\/(shopify\/gang|builder)/i.test(val);
        // The headless storefront sends the customer's uploaded artwork on each
        // Custom Size DTF line as a "_artwork_url" property (an https S3 image URL).
        // Treat it exactly like "Design Preview": render the thumbnail + the full
        // print-ready pipeline. Match the raw name or the underscore-stripped label
        // (/^_?artwork/) so it works whether or not the "_" prefix survives upstream.
        const isArtworkUrl = (/^_?artwork/i.test(String(p.name || "").trim()) || /^_?artwork/i.test(label.trim())) && isImageUrl(val);
        // "Make Print-Ready" against the Design Preview / uploaded design of the
        // Custom Size DTF product, and against each chosen side's artwork.
        const cta = isSide || isUpload || isArtworkUrl || (/^design preview$/i.test(label.trim()) && /custom size dtf transfers/i.test(it.name || ""));
        const asArtwork = isUpload || isArtworkUrl || (isImageUrl(val) && (isPreview || isSide));
        // The print-ready output of a gang-sheet builder (e.g. "_actual_gang_sheet")
        // is a plain URL that redirects to the real file — flag it downloadable.
        const dlable = !isAppBuilder && /actual|gang[\s_-]*sheet|print[\s_-]*ready/i.test(label);
        return (
          <div className="li__prop" key={j}>
            <span className="li__propk">{label}</span>
            <span className="li__propv">{asArtwork
              ? <DesignPreviewProp orderId={orderId} value={p.value} showCta={cta} printCm={cta ? readPrintCm(it) : null} showDownload={!(isPreview && hasPrintReady)} allowDpiFix={isGangUpload} />
              : isSvgUrl(val) && isPreview
              ? <SvgConvertProp orderId={orderId} value={p.value} />
              : isPdfUrl(val) && isPreview
              ? <PdfConvertProp orderId={orderId} value={p.value} />
              : isAppBuilder
              ? <a href={p.value} target="_blank" rel="noopener noreferrer" className="dpp__name mono" title="Opens the upload app's print-ready builder (not a file)">Open print-ready builder ↗</a>
              : <PropValue value={p.value} orderId={orderId} download={dlable} />}</span>
          </div>
        );
      })}
    </div>
  );
}

function Tracking({ tracking }) {
  if (!tracking || (!tracking.number && !tracking.url)) return <span className="dim">Not yet tracked</span>;
  const label = tracking.number || "Track parcel";
  const suffix = tracking.company ? " · " + tracking.company : "";
  if (tracking.url) {
    return (
      <a href={tracking.url} target="_blank" rel="noopener noreferrer"
         style={{ color:"var(--mint-ink)", fontWeight:600, fontFamily:"var(--font-mono)", fontSize:12, textDecoration:"underline" }}>
        {label}{suffix}
      </a>
    );
  }
  return <span className="mono" style={{ fontSize:12 }}>{label}{suffix}</span>;
}

/* ---- production routing card ---- */
function fmtBytes(n) {
  n = Number(n) || 0;
  if (n < 1024) return n + " B";
  if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
  return (n / 1024 / 1024).toFixed(1) + " MB";
}
// Artwork file's pixel dims + DPI + physical print size (mm = px / dpi × 25.4).
// DPI + mm are appended only when a DPI is recorded on the file.
// Images past the browser's decode limits (e.g. an 8-metre gang sheet at
// 6732×94636) won't render in a tab — opening the full file shows a blank page.
// For those, "Open" points at a downscaled JPEG preview instead; Download still
// serves the full-resolution file.
function artOversized(f) {
  return !!(f && /^image\//.test(f.contentType || "") && f.width && f.height &&
    (f.width > 8000 || f.height > 8000 || f.width * f.height > 40e6));
}
function artDims(f) {
  if (!f || !f.width || !f.height) return null;
  let s = `${f.width} × ${f.height} px`;
  if (f.dpi) { const mm = px => Math.round(px / f.dpi * 25.4); s += ` · ${f.dpi} DPI · ${mm(f.width)} × ${mm(f.height)} mm`; }
  return s;
}

// Per-order artwork: upload print-ready files when design is done; download from
// any workstation for printing.
// Artwork services runnable per uploaded file (context menu). Each maps to the
// two independent pipeline steps (remove-bg / upscale-at-scale).
// Fetch a public, token-signed artwork URL (same links the email composer uses) and
// copy it to the clipboard — so the operator can paste it to a customer. Falls back
// to a prompt if the clipboard is blocked. `owner` = bare order number or "draft-<id>".
async function copyArtworkPublicUrl(owner, fileId) {
  // Open the preview tab synchronously (still inside the click gesture) so popup
  // blockers don't eat it, then point it at the link once we have it.
  let win = null; try { win = window.open("about:blank", "_blank"); } catch {}
  try {
    const r = await fetch("/api/artwork/links", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ refs: [{ owner, fileId }] }) });
    const url = (((await r.json()) || {}).links || [])[0]?.url || "";
    if (!url) throw new Error("no link returned");
    try { await navigator.clipboard.writeText(url); } catch { window.prompt("Public artwork URL — copy to share:", url); }
    // Warm the chat-preview thumbnail so the first WhatsApp/Telegram unfurl is instant
    // (a big gang sheet can take ~30s to resize the first time).
    try { fetch(url + "&preview=1").catch(() => {}); } catch {}
    if (win) { try { win.location = url; } catch {} } else { try { window.open(url, "_blank", "noopener"); } catch {} }
    return url;
  } catch (e) { if (win) { try { win.close(); } catch {} } alert("Couldn't get the public link: " + (e.message || e)); return ""; }
}

const ART_SERVICES = [
  { key: "rename", label: "Rename", action: "rename" },
  { key: "copylink", label: "Copy public URL (share with customer)", action: "copylink" },
  { key: "print", label: "Make print-ready", opts: { removeBg: true, upscale: true, scale: 10 } },
  { key: "nobg", label: "Remove background", opts: { removeBg: true, upscale: false } },
  // Alternative remover — recraft-ai/recraft-remove-background. Full-res transparent
  // PNG in one pass; often cleaner edges than bria. Keeps the source resolution.
  { key: "recraftbg", label: "Remove background (Recraft)", opts: { recraftbg: true } },
  // Trim only — crop the fully-transparent margins to the actual artwork + stamp 300 DPI.
  { key: "trim", label: "Trim to artwork", opts: { trim: true } },
  { key: "defringe", label: "Shave edge (remove halo)", adjust: { opt: "erode", min: 1, max: 10, hint: v => `Contracts the edge by ${v}px and removes outside (~${(v / 300 * 25.4).toFixed(2)}mm @300 DPI).` }, opts: { removeBg: false, upscale: false } },
  { key: "solid", label: "Remove semi-transparent pixels", adjust: { opt: "harden", min: 1, max: 254, hint: v => `Keeps pixels with alpha ≥ ${v}; lower = keep softer edges.` }, opts: { removeBg: false, upscale: false } },
  { key: "outline", label: "Clean die-cut outline", adjust: { opt: "outline", min: 2, max: 120, hint: v => `Uniform white border ${v}px wide (~${(v / 300 * 25.4).toFixed(1)}mm @300 DPI).` }, opts: { removeBg: false, upscale: false } },
  // Colour-key removal — no AI: pick any colour from the image and delete every pixel
  // within `colortol`% of it. Opens a picker with a live preview (see ColorKeyPicker).
  { key: "colorkey", label: "Remove a colour (pick from image)", action: "pick" },
  // Recolour the whole design to ONE flat colour (keeps the alpha silhouette) — pick a
  // colour with a live preview, then it replaces every opaque pixel with it.
  { key: "recolor", label: "Recolour to one colour (pick)", action: "recolor" },
  // Vectorise (recraft) → recolour the SVG palette → rasterise back to a print-ready PNG.
  { key: "vectorize", label: "Vectorise & recolour", action: "vectorize" },
  // DTF Halftone prep — convert artwork to halftone dot/line pattern for dark garments.
  { key: "halftone", label: "DTF Halftone prep", action: "halftone" },
  // Upscale is one entry with a factor selector (×2 / ×5 / ×10) instead of three
  // separate menu items — pick the factor, then Apply. Keeps the background.
  { key: "upscale", label: "Upscale", choices: { opt: "scale", values: [2, 5, 10], hint: v => `Real-ESRGAN ${v}× → trim → 300 DPI. Keeps the background.` }, opts: { removeBg: false, upscale: true } },
  // AI re-render at 2K/4K via google/nano-banana-pro (keeps aspect ratio). Best for
  // flat/opaque designs — it flattens transparency, so it's not for cutouts.
  { key: "highres", label: "Make high-res (AI)", promptOpt: "highresPrompt", choices: { opt: "highres", values: ["1K", "2K", "4K"], fmt: v => v, hint: v => `Google nano-banana-pro re-render at ${v}, same aspect ratio → 300 DPI. 1K = fewest tokens. Flattens transparency — use on opaque designs.` }, opts: { removeBg: false, upscale: false } },
];

// --- Vectorise & recolour editor -------------------------------------------
// Colour-space + circle-pack + k-means helpers for the recolour editor.
function vrHexToRgb(hex){ const n=String(hex||"").replace("#",""); const s=n.length===3?[...n].map(c=>c+c).join(""):n; return [parseInt(s.slice(0,2),16)||0,parseInt(s.slice(2,4),16)||0,parseInt(s.slice(4,6),16)||0]; }
function vrRgbHex(r,g,b){ return "#"+[r,g,b].map(v=>Math.max(0,Math.min(255,Math.round(v))).toString(16).padStart(2,"0")).join(""); }
function vrHue(r,g,b){ r/=255;g/=255;b/=255; const mx=Math.max(r,g,b),mn=Math.min(r,g,b),d=mx-mn; let h=0; if(d){ if(mx===r)h=((g-b)/d)%6; else if(mx===g)h=(b-r)/d+2; else h=(r-g)/d+4; h*=60; if(h<0)h+=360; } return {h,sat:d,l:(mx+mn)/2}; }
// Greedy spiral circle-pack → organic cluster. items:[{id,r}] → placed:[{id,r,x,y}] + w,h.
function vrPack(items){
  const sorted=items.slice().sort((a,b)=>b.r-a.r), placed=[], pad=4;
  for(const it of sorted){
    if(!placed.length){ placed.push({...it,x:0,y:0}); continue; }
    let best=null;
    for(let ang=0; ang<800; ang+=0.3){
      const rad=it.r+ang*1.9, x=Math.cos(ang)*rad, y=Math.sin(ang)*rad;
      let ok=true; for(const p of placed){ if(Math.hypot(x-p.x,y-p.y) < it.r+p.r+pad){ ok=false; break; } }
      if(ok){ best={x,y}; break; }
    }
    placed.push({...it, x:best?best.x:0, y:best?best.y:placed.length*40});
  }
  let minx=1e9,miny=1e9,maxx=-1e9,maxy=-1e9;
  for(const p of placed){ minx=Math.min(minx,p.x-p.r); miny=Math.min(miny,p.y-p.r); maxx=Math.max(maxx,p.x+p.r); maxy=Math.max(maxy,p.y+p.r); }
  return { placed: placed.map(p=>({...p, x:p.x-minx, y:p.y-miny})), w:Math.max(1,maxx-minx), h:Math.max(1,maxy-miny) };
}
// Weighted k-means over RGB points → {assign, cents}. Groups colours for the presets.
function vrKmeans(pts, w, k, iters){
  k=Math.max(1,Math.min(k, pts.length)); iters=iters||14;
  const lum=p=>0.299*p[0]+0.587*p[1]+0.114*p[2];
  const order=pts.map((_,i)=>i).sort((a,b)=>lum(pts[a])-lum(pts[b]));
  const cents=[]; for(let i=0;i<k;i++) cents.push(pts[order[Math.round(i*(pts.length-1)/Math.max(1,k-1))]].slice());
  const assign=new Array(pts.length).fill(0), d2=(a,b)=>{const x=a[0]-b[0],y=a[1]-b[1],z=a[2]-b[2];return x*x+y*y+z*z;};
  for(let t=0;t<iters;t++){
    for(let i=0;i<pts.length;i++){ let bd=1e18,bj=0; for(let j=0;j<k;j++){ const d=d2(pts[i],cents[j]); if(d<bd){bd=d;bj=j;} } assign[i]=bj; }
    const sum=Array.from({length:k},()=>[0,0,0,0]);
    for(let i=0;i<pts.length;i++){ const a=assign[i],ww=w[i]||1; sum[a][0]+=pts[i][0]*ww;sum[a][1]+=pts[i][1]*ww;sum[a][2]+=pts[i][2]*ww;sum[a][3]+=ww; }
    for(let j=0;j<k;j++) if(sum[j][3]) cents[j]=[sum[j][0]/sum[j][3],sum[j][1]/sum[j][3],sum[j][2]/sum[j][3]];
  }
  return {assign,cents};
}

// Pre-crop step: pick the region of a raster to vectorise (a draggable/resizable frame).
// Crop is kept in fractions of the source (0..1) so it maps regardless of display size.
function PreCropStep({ name, file, crop, setCrop }) {
  const imgRef = useRef(null);
  const [rw, setRw] = useState(0), [rh, setRh] = useState(0);
  const W = file.width || 0, H = file.height || 0;
  const sync = () => { const im = imgRef.current; if (im && im.clientWidth) { setRw(im.clientWidth); setRh(im.clientHeight); } };
  useEffect(() => { if (imgRef.current && imgRef.current.complete) sync(); window.addEventListener("resize", sync); return () => window.removeEventListener("resize", sync); }, []);
  // Drag with window-level listeners so a fast drag / leaving the box never drops it.
  function beginDrag(mode, e, anchor) {
    e.preventDefault(); e.stopPropagation();
    const startX = e.clientX, startY = e.clientY, c = { ...crop };
    const iw = () => { const r = imgRef.current.getBoundingClientRect(); return r; };
    const onMove = (ev) => {
      const r = iw();
      if (mode === "draw") {
        const nx = (ev.clientX - r.left) / r.width, ny = (ev.clientY - r.top) / r.height;
        const x = Math.max(0, Math.min(anchor.ax, nx)), y = Math.max(0, Math.min(anchor.ay, ny));
        const x2 = Math.min(1, Math.max(anchor.ax, nx)), y2 = Math.min(1, Math.max(anchor.ay, ny));
        setCrop({ x, y, w: Math.max(0.02, x2 - x), h: Math.max(0.02, y2 - y) });
        return;
      }
      const dx = (ev.clientX - startX) / r.width, dy = (ev.clientY - startY) / r.height;
      let { x, y, w, h } = c;
      if (mode === "move") { x = Math.min(1 - w, Math.max(0, c.x + dx)); y = Math.min(1 - h, Math.max(0, c.y + dy)); }
      else {
        if (mode.includes("w")) { const nx = Math.min(c.x + c.w - 0.03, Math.max(0, c.x + dx)); w = c.x + c.w - nx; x = nx; }
        if (mode.includes("e")) { w = Math.min(1 - c.x, Math.max(0.03, c.w + dx)); }
        if (mode.includes("n")) { const ny = Math.min(c.y + c.h - 0.03, Math.max(0, c.y + dy)); h = c.y + c.h - ny; y = ny; }
        if (mode.includes("s")) { h = Math.min(1 - c.y, Math.max(0.03, c.h + dy)); }
      }
      setCrop({ x, y, w, h });
    };
    const onUp = () => { window.removeEventListener("mousemove", onMove); window.removeEventListener("mouseup", onUp); };
    window.addEventListener("mousemove", onMove);
    window.addEventListener("mouseup", onUp);
  }
  // Mouse-down on the image (not the frame) starts drawing a fresh selection.
  function startDraw(e) {
    if (e.target !== imgRef.current || !rw) return;
    const r = imgRef.current.getBoundingClientRect();
    const ax = (e.clientX - r.left) / r.width, ay = (e.clientY - r.top) / r.height;
    setCrop({ x: ax, y: ay, w: 0.005, h: 0.005 });
    beginDrag("draw", e, { ax, ay });
  }
  const cw = Math.round(crop.w * W), ch = Math.round(crop.h * H), mp = cw * ch / 1e6;
  const overMax = cw > 4096 || ch > 4096 || mp > 16;
  const px = { left: crop.x * rw, top: crop.y * rh, width: crop.w * rw, height: crop.h * rh };
  const handle = (mode, style) => <span onMouseDown={e => beginDrag(mode, e)} style={{ position: "absolute", width: 16, height: 16, background: "#fff", border: "1.5px solid var(--mint-ink,#0a7)", borderRadius: 3, cursor: mode + "-resize", ...style }} />;
  return (
    <div style={{ display: "flex", flex: "1 1 auto", minHeight: 0, height: "100%" }}>
      <div style={{ flex: "1 1 auto", display: "flex", alignItems: "center", justifyContent: "center", background: "repeating-conic-gradient(#e9edf2 0% 25%, #fff 0% 50%) 50%/22px 22px", overflow: "hidden", padding: 24, minHeight: 0 }}>
        <div style={{ position: "relative", display: "inline-block", maxWidth: "100%", maxHeight: "calc(94vh - 176px)" }} onMouseDown={startDraw}>
          {/* Viewport-based cap so a tall image scales to FIT the canvas (an inline-block
              wrapper has auto height, so maxHeight:100% wouldn't constrain it). */}
          <img ref={imgRef} src={DTF.artThumbUrl(name, file.id, 1600)} onLoad={sync} draggable={false}
               style={{ maxWidth: "100%", maxHeight: "calc(94vh - 176px)", display: "block", userSelect: "none", cursor: "crosshair" }} />
          {rw > 0 && (
            <div style={{ position: "absolute", ...px, border: "2px solid var(--mint-ink,#0a7)", boxShadow: "0 0 0 9999px rgba(0,0,0,.45)", cursor: "move", boxSizing: "border-box" }} onMouseDown={e => beginDrag("move", e)}>
              {handle("nw", { left: -9, top: -9 })}{handle("ne", { right: -9, top: -9 })}{handle("sw", { left: -9, bottom: -9 })}{handle("se", { right: -9, bottom: -9 })}
            </div>
          )}
        </div>
      </div>
      <div style={{ width: 288, flex: "0 0 288px", borderLeft: "1px solid var(--line,#dfe4ea)", padding: 16, fontSize: 12.5 }}>
        <p className="muted" style={{ marginTop: 0 }}>Drag on the image to draw a selection, or drag the frame / its corners to adjust. Full resolution is kept; only selections over recraft's limit (16 MP / 4096 px) are scaled to fit.</p>
        <div style={{ marginTop: 12 }}><b>Source image</b><div className="mono dim" style={{ marginTop: 2 }}>{W} × {H} px · {(W * H / 1e6).toFixed(1)} MP</div></div>
        <div style={{ marginTop: 10 }}><b>Selection</b><div className="mono" style={{ marginTop: 2, color: overMax ? "var(--c-red,#c0392b)" : "var(--txt-3)" }}>{cw} × {ch} px · {mp.toFixed(1)} MP</div></div>
        {overMax && <div style={{ marginTop: 8, fontSize: 11.5, color: "var(--txt-3)" }}>Selection exceeds recraft's 16 MP / 4096 px limit and will be scaled to fit.</div>}
        <button className="linklike" style={{ marginTop: 12, fontSize: 12 }} onClick={() => setCrop({ x: 0, y: 0, w: 1, h: 1 })}>Select whole image</button>
      </div>
    </div>
  );
}

// Vectorise a raster artwork to SVG (recraft), then recolour its flat-colour palette in a
// zoom/pan editor: colours become usage-sized bubbles grouped by family; click one to
// Magic-eraser flood fill: from each seed point (normalised 0..1), clear the
// connected run of similar-coloured pixels to transparent on the given 2D context.
// One getImageData/putImageData for all seeds. Tolerance is squared-distance in RGB.
function vrFloodErase(ctx, W, H, seeds, tol) {
  if (!seeds || !seeds.length || !W || !H) return;
  const img = ctx.getImageData(0, 0, W, H), d = img.data;
  const seen = new Uint8Array(W * H);
  const t2 = tol * tol * 3;
  for (const s of seeds) {
    const sx = Math.round(s.nx * (W - 1)), sy = Math.round(s.ny * (H - 1));
    if (sx < 0 || sy < 0 || sx >= W || sy >= H) continue;
    const p0 = sy * W + sx, i0 = p0 * 4;
    if (d[i0 + 3] === 0) continue;                 // clicked empty space
    const r0 = d[i0], g0 = d[i0 + 1], b0 = d[i0 + 2];
    const stack = [p0];
    while (stack.length) {
      const p = stack.pop(); if (seen[p]) continue; seen[p] = 1;
      const i = p * 4; if (d[i + 3] === 0) continue;
      const dr = d[i] - r0, dg = d[i + 1] - g0, db = d[i + 2] - b0;
      if (dr * dr + dg * dg + db * db > t2) continue;
      d[i + 3] = 0;                                 // erase to transparent
      const x = p - Math.floor(p / W) * W;
      if (x > 0) stack.push(p - 1);
      if (x < W - 1) stack.push(p + 1);
      if (p >= W) stack.push(p - W);
      if (p < (H - 1) * W) stack.push(p + W);
    }
  }
  ctx.putImageData(img, 0, 0);
}
const VR_ERASE_TOL = 48;

// highlight it on the canvas and recolour/hide it; drag one onto another to merge; presets
// quantise to N colours. Save → /svg-to-png at 300 DPI (trimmed).
// recraft SVGs carry a small intrinsic width/height, so drawing them onto a canvas
// rasterises at that size and upscales → pixelated edges. Force the SVG to render at
// a target width by rewriting its <svg> width/height (aspect from the viewBox).
function vrSvgAtSize(svg, wPx) {
  let aspect = 1;
  const n = ((svg || "").match(/viewBox="[^"]+"/) || [])[0]?.match(/[-\d.]+/g);
  if (n && n.length === 4 && +n[2] > 0) aspect = (+n[3]) / (+n[2]);
  const hPx = Math.max(1, Math.round(wPx * aspect));
  const sized = String(svg || "").replace(/<svg\b([^>]*?)>/i, (m, a) => `<svg${a.replace(/\s(width|height)="[^"]*"/gi, "")} width="${wPx}" height="${hPx}">`);
  return { sized, dataUrl: "data:image/svg+xml;charset=utf-8," + encodeURIComponent(sized), wPx, hPx, aspect };
}
// Render an SVG string to a canvas at a target width (vector → crisp raster).
function vrRasterizeSvg(svgStr, wPx) {
  return new Promise((resolve, reject) => {
    const { dataUrl, wPx: W, hPx: H } = vrSvgAtSize(svgStr, wPx);
    const img = new Image();
    img.onload = () => { const cv = document.createElement("canvas"); cv.width = W; cv.height = H; cv.getContext("2d").drawImage(img, 0, 0, W, H); resolve({ cv, W, H }); };
    img.onerror = () => reject(new Error("Couldn't render the SVG"));
    img.src = dataUrl;
  });
}
// Render the recoloured artwork with hidden colours removed. Instead of a per-pixel
// (1-bit) knockout — which turns anti-aliased edges jagged — we paint the kept
// colours, then ERASE the hidden colours as a smooth vector layer (destination-out),
// so counters (holes in 0/6, etc.) stay smooth. `outSvg` already has final colours.
async function vrCompositeRecolored(outSvg, keepHexes, hideHexes, wPx) {
  const paintSvg = (hideHexes || []).reduce((s, h) => s.split(h).join("none"), outSvg); // kept colours only
  const { cv, W, H } = await vrRasterizeSvg(paintSvg, wPx);
  if (hideHexes && hideHexes.length) {
    const eraseSvg = (keepHexes || []).reduce((s, h) => s.split(h).join("none"), outSvg); // hidden colours only
    const { cv: ec } = await vrRasterizeSvg(eraseSvg, wPx);
    const ctx = cv.getContext("2d");
    ctx.globalCompositeOperation = "destination-out";
    ctx.drawImage(ec, 0, 0);
    ctx.globalCompositeOperation = "source-over";
  }
  return { cv, W, H };
}
function VectorizeRecolorModal({ name: _nameProp, owner, file, onCancel, onDone }) {
  // Accept an order name OR a {type,id} owner (draft) — DTF artwork helpers take either.
  const name = owner || _nameProp;
  const [svg, setSvg] = useState(null);
  const [base, setBase] = useState("design");
  const [loading, setLoading] = useState(true);
  const [err, setErr] = useState("");
  const [remap, setRemap] = useState({});    // root token -> "#rrggbb" | "none"
  const [merged, setMerged] = useState({});  // token -> parent token
  const autoMergedRef = useRef(false);       // auto-merge near-identical colours once, on load
  const [past, setPast] = useState([]);
  const [future, setFuture] = useState([]);
  const [selected, setSelected] = useState(null);
  const [dragOver, setDragOver] = useState(null);
  const [usage, setUsage] = useState({});    // original token -> pixel count
  const [widthMm, setWidthMm] = useState(200);
  const [widthText, setWidthText] = useState("200"); // raw input string (clamped on blur)
  const [saving, setSaving] = useState(false);
  const [step, setStep] = useState("");
  const [phase, setPhase] = useState(null);  // "crop" (raster, pre-vectorise) | "edit"
  const [crop, setCrop] = useState({ x: 0, y: 0, w: 1, h: 1 }); // whole artwork by default
  const [engine, setEngine] = useState("recraft"); // tracer: recraft (crop-aware) or vectorizer.ai (full image, premium)
  const [zoom, setZoom] = useState(1);
  const [pan, setPan] = useState({ x: 0, y: 0 });
  const [fit, setFit] = useState(1);
  const [eraseMode, setEraseMode] = useState(false);   // magic-eraser: click to flood-erase a colour region
  const [eraseSeeds, setEraseSeeds] = useState([]);    // click points {nx,ny} (0..1) flood-filled to transparent
  const previewRef = useRef(null);
  const workRef = useRef(null);
  const dragRef = useRef(null);
  const movedRef = useRef(false);
  const natRef = useRef({ w: 1, h: 1 });
  const [natWH, setNatWH] = useState("1x1"); // canvas dims signature → re-fit when they change
  const lastTag = useRef(null);

  const isSvgFile = /svg/i.test(file.contentType || "") || /\.svg$/i.test(file.filename || "");
  useEffect(() => {
    let dead = false;
    (async () => {
      try {
        if (isSvgFile) {
          const res = await fetch(DTF.artworkUrl(name, file.id), { credentials: "include" });
          if (!res.ok) throw new Error("Couldn't load the SVG");
          const text = await res.text(); if (dead) return;
          setSvg(text); setEraseSeeds([]); setBase(String(file.filename || "design").replace(/\.svg$/i, "")); setPhase("edit");
        } else {
          setPhase("crop"); // raster: show the crop frame first, then vectorise on confirm
        }
        if (file.width) { const mm = Math.round(file.width / (file.dpi || 300) * 25.4); if (mm >= 10 && mm <= 3000) { setWidthMm(mm); setWidthText(String(mm)); } }
      } catch (e) { if (!dead) setErr(e.message || "Couldn't load the vector"); }
      finally { if (!dead) setLoading(false); }
    })();
    return () => { dead = true; };
  }, [name, file.id]);

  // Vectorise the chosen crop, then move to the recolour editor.
  async function doVectorise() {
    setLoading(true); setErr("");
    try {
      // vectorizer.ai traces the full image (handles transparency itself) — crop is recraft-only.
      const r = await DTF.vectorize(name, file.id, engine === "vectorizer" ? null : crop, engine);
      setSvg(r.svg); setEraseSeeds([]); setBase(r.base || "design"); setPhase("edit");
      window.dispatchEvent(new CustomEvent("dtf:artwork-updated", { detail: { name } }));
    } catch (e) { setErr(e.message || "Vectorise failed"); }
    finally { setLoading(false); }
  }

  const palette = useMemo(() => {
    if (!svg) return [];
    const set = new Set();
    const re = /(?:fill|stroke)\s*[=:]\s*["']?\s*(rgb\(\d+,\d+,\d+\)|#[0-9a-fA-F]{6}|#[0-9a-fA-F]{3})/g;
    let m; while ((m = re.exec(svg))) { if (!/none/i.test(m[1])) set.add(m[1]); }
    return [...set];
  }, [svg]);

  function toHex(c) {
    if (c[0] === "#") return c.length === 4 ? "#" + [...c.slice(1)].map(x => x + x).join("") : c;
    const m = c.match(/rgb\((\d+),(\d+),(\d+)\)/);
    return m ? "#" + [1, 2, 3].map(i => Math.min(255, +m[i]).toString(16).padStart(2, "0")).join("") : "#000000";
  }
  function rootOf(t) { let x = t, seen = new Set(); while (merged[x] && !seen.has(x)) { seen.add(x); x = merged[x]; } return x; }
  const roots = palette.filter(t => rootOf(t) === t);
  const childrenOf = (rt) => palette.filter(t => t !== rt && rootOf(t) === rt);
  const isTransparent = (rt) => remap[rt] === "none";
  const effHex = (rt) => (remap[rt] && remap[rt] !== "none") ? remap[rt] : toHex(rt);
  const rootUsage = (rt) => palette.reduce((s, c) => rootOf(c) === rt ? s + (usage[c] || 0) : s, 0);
  const sel = (selected && roots.includes(selected)) ? selected : null;

  // Commit a change to history (coalesce same-tag edits, e.g. dragging the colour picker).
  // Snapshots carry eraseSeeds too so undo/redo covers colour edits AND magic-erases.
  function commit(nr, nm, tag) {
    if (tag && tag === lastTag.current) { setRemap(nr); setMerged(nm); }
    else { setPast(p => [...p, { remap, merged, seeds: eraseSeeds }].slice(-100)); setRemap(nr); setMerged(nm); setFuture([]); }
    lastTag.current = tag || null;
  }
  // Magic-erase click: push current state, then add the new seed point.
  function commitErase(newSeeds) {
    setPast(p => [...p, { remap, merged, seeds: eraseSeeds }].slice(-100));
    setEraseSeeds(newSeeds); setFuture([]); lastTag.current = null;
  }
  function undo() { if (!past.length) return; const prev = past[past.length - 1]; setPast(p => p.slice(0, -1)); setFuture(f => [{ remap, merged, seeds: eraseSeeds }, ...f]); setRemap(prev.remap); setMerged(prev.merged); setEraseSeeds(prev.seeds || []); lastTag.current = null; }
  function redo() { if (!future.length) return; const nx = future[0]; setFuture(f => f.slice(1)); setPast(p => [...p, { remap, merged, seeds: eraseSeeds }]); setRemap(nx.remap); setMerged(nx.merged); setEraseSeeds(nx.seeds || []); lastTag.current = null; }
  function setColor(rt, hex) { commit({ ...remap, [rt]: hex }, merged, "col:" + rt); }
  function toggleHide(rt) { const n = { ...remap }; if (n[rt] === "none") delete n[rt]; else n[rt] = "none"; commit(n, merged, null); }
  function resetColor(rt) { const n = { ...remap }; delete n[rt]; commit(n, merged, null); }
  function mergeInto(from, to) { if (!from || !to || from === to || rootOf(to) === from) return; commit(remap, { ...merged, [from]: to }, null); setSelected(to); }
  function unmerge(t) { const n = { ...merged }; delete n[t]; commit(remap, n, null); }
  function resetAll() { commit({}, {}, null); setSelected(null); }
  function quantizeTo(n) {
    const pts = palette.map(c => vrHexToRgb(toHex(c))), w = palette.map(c => Math.max(1, usage[c] || 1));
    const { assign, cents } = vrKmeans(pts, w, n);
    const groups = {}; palette.forEach((c, i) => { (groups[assign[i]] = groups[assign[i]] || []).push(c); });
    const nm = {}, nr = {};
    for (const k in groups) { const mem = groups[k].slice().sort((a, b) => (usage[b] || 0) - (usage[a] || 0)); const root = mem[0]; nr[root] = vrRgbHex(...cents[k]); for (const c of mem) if (c !== root) nm[c] = root; }
    commit(nr, nm, null); setSelected(null);
  }
  // Merge near-identical colours (recraft often emits several almost-equal shades on
  // anti-aliased edges) into their most-used neighbour, so the palette starts clean —
  // like vectorizer.ai's default. `thr` = RGB distance under which two are "the same".
  function autoMergeClose(thr = 26) {
    const cols = palette.slice().sort((a, b) => (usage[b] || 0) - (usage[a] || 0));
    const keep = [], nm = {};
    for (const c of cols) {
      const rgb = vrHexToRgb(toHex(c));
      let best = null, bd = thr;
      for (const r of keep) { const rr = vrHexToRgb(toHex(r)); const d = Math.hypot(rgb[0] - rr[0], rgb[1] - rr[1], rgb[2] - rr[2]); if (d < bd) { bd = d; best = r; } }
      if (best) nm[c] = best; else keep.push(c);
    }
    return nm;
  }
  // Apply the auto-merge once, after the colour usage has been measured.
  useEffect(() => {
    if (autoMergedRef.current || !svg || !palette.length || !Object.keys(usage).length) return;
    autoMergedRef.current = true;
    const nm = autoMergeClose();
    if (Object.keys(nm).length) setMerged(nm);
  }, [svg, palette.join(","), usage]);

  const outSvg = useMemo(() => {
    if (!svg) return "";
    let s = svg;
    for (const c of palette) { const rt = rootOf(c); const val = (remap[rt] && remap[rt] !== "none") ? remap[rt] : toHex(rt); s = s.split(c).join(val); }
    return s;
  }, [svg, remap, merged, palette]);
  const hideHexes = roots.filter(rt => remap[rt] === "none").map(rt => toHex(rt));
  const keepHexes = roots.filter(rt => remap[rt] !== "none").map(rt => effHex(rt));
  const dataUrl = outSvg ? "data:image/svg+xml;charset=utf-8," + encodeURIComponent(outSvg) : null;
  const origUrl = svg ? "data:image/svg+xml;charset=utf-8," + encodeURIComponent(svg) : null;

  // Usage (pixel area) per original colour — renders the original vector small and buckets
  // each pixel to its nearest palette colour. Drives bubble sizes.
  useEffect(() => {
    if (!origUrl || !palette.length) return;
    let dead = false; const img = new Image();
    img.onload = () => {
      if (dead) return;
      const nw = img.naturalWidth || 300, nh = img.naturalHeight || 300;
      const W = Math.min(nw, 260), H = Math.max(1, Math.round(nh * W / nw));
      const cv = document.createElement("canvas"); cv.width = W; cv.height = H;
      const ctx = cv.getContext("2d"); ctx.drawImage(img, 0, 0, W, H);
      try {
        const px = ctx.getImageData(0, 0, W, H).data, pr = palette.map(c => vrHexToRgb(toHex(c))), cnt = new Array(palette.length).fill(0);
        for (let i = 0; i < px.length; i += 4) { if (px[i + 3] < 128) continue; let bd = 1e9, bj = -1; for (let j = 0; j < pr.length; j++) { const dr = px[i] - pr[j][0], dg = px[i + 1] - pr[j][1], db = px[i + 2] - pr[j][2]; const d = dr * dr + dg * dg + db * db; if (d < bd) { bd = d; bj = j; } } if (bj >= 0) cnt[bj]++; }
        const u = {}; palette.forEach((c, j) => u[c] = cnt[j]); if (!dead) setUsage(u);
      } catch (e) {}
    };
    img.src = origUrl;
    return () => { dead = true; };
  }, [origUrl, palette.join(",")]);

  // Preview: draw recoloured SVG, knock out hidden colours, and (if a colour is selected)
  // dim everything else so the selection is highlighted on the canvas.
  // Re-render the preview at a resolution that tracks the zoom, so zooming in stays
  // crisp (a fixed raster would pixelate). Bucketed by zoom to avoid re-render churn.
  const zoomStep = Math.min(4, Math.max(1, Math.ceil(zoom)));
  useEffect(() => {
    const cv = previewRef.current; if (!cv || !outSvg) return;
    let dead = false;
    const timer = setTimeout(async () => {
      try {
        const renderW = Math.min(5000, 1500 * zoomStep);   // higher res as you zoom in
        // Paint kept colours + erase hidden colours as a smooth vector layer — keeps
        // anti-aliased edges (no 1-bit knockout that made counters jagged).
        const { cv: comp, W, H } = await vrCompositeRecolored(outSvg, keepHexes, hideHexes, renderW);
        if (dead) return;
        natRef.current = { w: W, h: H }; setNatWH(W + "x" + H);
        cv.width = W; cv.height = H;
        const ctx = cv.getContext("2d"); ctx.clearRect(0, 0, W, H); ctx.drawImage(comp, 0, 0);
        // Selection highlight: dim everything not the selected colour.
        const hi = sel && !isTransparent(sel) ? vrHexToRgb(effHex(sel)) : null;
        if (hi) {
          try {
            const d = ctx.getImageData(0, 0, W, H), px = d.data, thr = 64;
            for (let i = 0; i < px.length; i += 4) { if (px[i + 3] === 0) continue; const match = Math.max(Math.abs(px[i] - hi[0]), Math.abs(px[i + 1] - hi[1]), Math.abs(px[i + 2] - hi[2])) <= thr; if (!match) px[i + 3] = Math.round(px[i + 3] * 0.12); }
            ctx.putImageData(d, 0, 0);
          } catch (e) {}
        }
        try { vrFloodErase(ctx, W, H, eraseSeeds, VR_ERASE_TOL); } catch (e) {}
      } catch (e) {}
    }, 60);
    return () => { dead = true; clearTimeout(timer); };
  }, [outSvg, keepHexes.join(","), hideHexes.join(","), sel, eraseSeeds, zoomStep]);

  // Fit-to-window: scale the canvas so the whole artwork fits the working area.
  // Runs after layout (rAF) and on any panel/window resize — a stale/early read is
  // why a tall image overflowed. Uses natRef (the current canvas pixel dims).
  useEffect(() => {
    const wa = workRef.current; if (!wa) return;
    let raf = 0;
    const fitNow = () => {
      const n = natRef.current;
      const cw = wa.clientWidth - 24, ch = wa.clientHeight - 24;
      if (!n || !n.w || !n.h || cw <= 0 || ch <= 0) return;
      const f = Math.min(cw / n.w, ch / n.h);
      if (f > 0 && isFinite(f)) setFit(f);
    };
    const kick = () => { cancelAnimationFrame(raf); raf = requestAnimationFrame(fitNow); };
    const ro = window.ResizeObserver ? new ResizeObserver(kick) : null;
    if (ro) ro.observe(wa);
    kick(); const t = setTimeout(kick, 120);   // also refit shortly after the modal lays out
    return () => { if (ro) ro.disconnect(); cancelAnimationFrame(raf); clearTimeout(t); };
  }, [outSvg, phase, natWH]);

  // Non-passive wheel zoom on the working area.
  useEffect(() => {
    const wa = workRef.current; if (!wa) return;
    const onWheel = (e) => { e.preventDefault(); setZoom(z => Math.max(0.2, Math.min(8, z * (e.deltaY < 0 ? 1.12 : 1 / 1.12)))); };
    wa.addEventListener("wheel", onWheel, { passive: false });
    return () => wa.removeEventListener("wheel", onWheel);
  }, [loading]);

  function onPanStart(e) { movedRef.current = false; dragRef.current = { sx: e.clientX, sy: e.clientY, px: pan.x, py: pan.y }; }
  function onPanMove(e) { const d = dragRef.current; if (!d) return; if (Math.abs(e.clientX - d.sx) + Math.abs(e.clientY - d.sy) > 3) movedRef.current = true; setPan({ x: d.px + (e.clientX - d.sx), y: d.py + (e.clientY - d.sy) }); }
  function onPanEnd() { dragRef.current = null; }

  // Find the palette root colour under a click on the preview canvas, or null for
  // empty/transparent space. Drag (pan) suppresses the click.
  function colourAt(e) {
    if (movedRef.current) return undefined;             // was a pan, ignore
    const cv = previewRef.current; if (!cv || !cv.width) return null;
    const rect = cv.getBoundingClientRect();
    const x = Math.floor((e.clientX - rect.left) / rect.width * cv.width);
    const y = Math.floor((e.clientY - rect.top) / rect.height * cv.height);
    if (x < 0 || y < 0 || x >= cv.width || y >= cv.height) return null;
    try {
      const p = cv.getContext("2d").getImageData(x, y, 1, 1).data;
      if (p[3] === 0) return null;                       // transparent/empty
      let best = null, bd = Infinity;
      for (const rt of roots) {
        if (isTransparent(rt)) continue;
        const c = vrHexToRgb(effHex(rt));
        const dd = (p[0] - c[0]) ** 2 + (p[1] - c[1]) ** 2 + (p[2] - c[2]) ** 2;
        if (dd < bd) { bd = dd; best = rt; }
      }
      return best;
    } catch (err) { return null; }
  }
  // Magic-eraser click: record the click as a normalised seed point. The preview
  // (and save) flood-fills the connected same-colour region from each seed to
  // transparent — so clicking a colour clears just that touching region, leaving
  // the same colour elsewhere and everything beneath untouched. Returns true if
  // the click landed on a non-empty pixel.
  function erasePathAt(e) {
    const cv = previewRef.current; if (!cv || !cv.width) return false;
    const rect = cv.getBoundingClientRect();
    const nx = (e.clientX - rect.left) / rect.width, ny = (e.clientY - rect.top) / rect.height;
    if (nx < 0 || ny < 0 || nx > 1 || ny > 1) return false;
    try {
      const px = Math.min(cv.width - 1, Math.max(0, Math.round(nx * (cv.width - 1))));
      const py = Math.min(cv.height - 1, Math.max(0, Math.round(ny * (cv.height - 1))));
      const a = cv.getContext("2d").getImageData(px, py, 1, 1).data;
      if (a[3] === 0) return false;                        // clicked empty space
    } catch (err) {}
    commitErase([...eraseSeeds, { nx, ny }]);
    return true;
  }
  // Find the palette root colour under a click on the preview canvas, or null.
  function colourAt(e) {
    const cv = previewRef.current; if (!cv || !cv.width) return null;
    const rect = cv.getBoundingClientRect();
    const x = Math.floor((e.clientX - rect.left) / rect.width * cv.width);
    const y = Math.floor((e.clientY - rect.top) / rect.height * cv.height);
    if (x < 0 || y < 0 || x >= cv.width || y >= cv.height) return null;
    try {
      const p = cv.getContext("2d").getImageData(x, y, 1, 1).data;
      if (p[3] === 0) return null;                       // transparent/empty
      let best = null, bd = Infinity;
      for (const rt of roots) {
        if (isTransparent(rt)) continue;
        const c = vrHexToRgb(effHex(rt));
        const dd = (p[0] - c[0]) ** 2 + (p[1] - c[1]) ** 2 + (p[2] - c[2]) ** 2;
        if (dd < bd) { bd = dd; best = rt; }
      }
      return best;
    } catch (err) { return null; }
  }
  // Click behaviour: in erase mode, remove the exact path clicked. Otherwise select
  // that colour's bubble (recolour / merge). Clicking empty space deselects. A drag
  // (pan) suppresses the click.
  function onWorkClick(e) {
    if (movedRef.current) return;
    if (eraseMode) { erasePathAt(e); return; }
    setSelected(colourAt(e));
  }

  async function save() {
    if (saving || !outSvg) return;
    setSaving(true); setErr("");
    const wCm = Math.min(300, Math.max(1, (widthMm || 2000) / 10));
    try {
      // Rasterise the recoloured SVG in THIS browser — Chrome renders recraft's outline
      // paths correctly, where the server's rsvg/resvg silently drop them. The server then
      // flattens to the palette + trims + stamps DPI on the uploaded PNG.
      setStep("rendering");
      const wPx = Math.round(wCm / 2.54 * 300);
      const hPx = vrSvgAtSize(outSvg, wPx).hPx;
      const MAX = 16384;
      if (wPx > MAX || hPx > MAX) throw new Error("Too large to render in-browser (max ~" + Math.floor(MAX / 300 * 2.54) + " cm/side) — reduce the width");
      // Paint kept colours + erase hidden colours as a smooth vector layer (keeps the
      // anti-aliased edges). Already recoloured + counters cleanly removed here, so we
      // DON'T ask the server to flatten to a palette (that binarises edges → jagged).
      const { cv } = await vrCompositeRecolored(outSvg, keepHexes, hideHexes, wPx);
      const sctx = cv.getContext("2d");
      // Magic-eraser: flood-clear each seed's colour region to transparent at save res.
      try { vrFloodErase(sctx, wPx, hPx, eraseSeeds, VR_ERASE_TOL); } catch (e) {}
      const blob = await new Promise((res, rej) => cv.toBlob(b => b ? res(b) : rej(new Error("Render failed")), "image/png"));
      setStep("uploading");
      await DTF.attachPng(name, blob, base + "-recoloured-" + wCm + "cm", { trim: true, dpi: 300, onProgress: p => setStep("uploading " + Math.round(p * 100) + "%") });
      onDone && onDone();
    } catch (e) { setErr(e.message || "Save failed"); setSaving(false); }
  }

  // Bubble cluster: roots sized by usage, ordered by family (hue; neutrals last), packed.
  const bubbles = useMemo(() => {
    if (!roots.length) return { placed: [], w: 1, h: 1 };
    const tot = roots.reduce((s, rt) => s + Math.max(1, rootUsage(rt)), 0) || 1;
    const ord = roots.slice().sort((a, b) => {
      const A = vrHue(...vrHexToRgb(effHex(a))), B = vrHue(...vrHexToRgb(effHex(b)));
      const na = A.sat < 0.12, nb = B.sat < 0.12; if (na !== nb) return na ? 1 : -1; if (na && nb) return A.l - B.l; return A.h - B.h;
    });
    const items = ord.map(rt => ({ id: rt, r: Math.max(15, Math.min(58, 13 + Math.sqrt(Math.max(1, rootUsage(rt)) / tot) * 200)) }));
    return vrPack(items);
  }, [roots.join(","), usage, remap, merged]);
  const bScale = Math.min(300 / bubbles.w, 190 / bubbles.h, 1.5);

  const presets = [];
  for (let n = 2; n <= Math.min(palette.length - 1, 24); n++) presets.push(n);

  const btn = { width: 30, height: 26, borderRadius: 6, border: "1px solid var(--line,#dfe4ea)", background: "#fff", cursor: "pointer", fontSize: 14, lineHeight: 1, display: "inline-flex", alignItems: "center", justifyContent: "center" };

  return (
    <div className="modal-overlay" onClick={onCancel}>
      <div className="modal vr-modal" onClick={e => e.stopPropagation()} style={{ width: "96vw", height: "94vh", maxWidth: "none", maxHeight: "none", display: "flex", flexDirection: "column" }}>
        <div className="modal__head" style={{ gap: 10 }}>
          <h3 style={{ margin: 0 }}>{phase === "crop" ? "Vectorise · choose area" : "Vectorise & recolour"}</h3>
          {phase === "edit" && !loading && !err && <span className="badge" title="Current number of colours">{roots.length} colour{roots.length === 1 ? "" : "s"}</span>}
          <div style={{ marginLeft: "auto", display: "flex", gap: 6, alignItems: "center" }}>
            {phase === "edit" && !loading && !err && <>
              <button style={{ ...btn, opacity: past.length ? 1 : 0.4 }} disabled={!past.length} onClick={undo} title="Undo">↶</button>
              <button style={{ ...btn, opacity: future.length ? 1 : 0.4 }} disabled={!future.length} onClick={redo} title="Redo">↷</button>
              <span style={{ width: 1, height: 20, background: "var(--line,#dfe4ea)", margin: "0 2px" }} />
              <button style={{ ...btn, width: "auto", padding: "0 8px", gap: 5, background: eraseMode ? "#e11" : "#fff", color: eraseMode ? "#fff" : "inherit", borderColor: eraseMode ? "#e11" : "var(--line,#dfe4ea)" }}
                      onClick={() => setEraseMode(m => !m)} title="Magic eraser — click a colour to clear that connected area to transparent (undoable). The same colour elsewhere stays.">
                <Icon name="scissors" /> Erase{eraseMode ? " ✓" : ""}
              </button>
              <span style={{ width: 1, height: 20, background: "var(--line,#dfe4ea)", margin: "0 2px" }} />
              <button style={btn} onClick={() => setZoom(z => Math.max(0.2, z / 1.25))} title="Zoom out">−</button>
              <button style={btn} onClick={() => setZoom(z => Math.min(8, z * 1.25))} title="Zoom in">+</button>
              <button style={btn} onClick={() => { setZoom(1); setPan({ x: 0, y: 0 }); }} title="Fit">⤢</button>
            </>}
            <button className="icon-btn" onClick={onCancel}><Icon name="x" /></button>
          </div>
        </div>
        <div className="modal__body" style={{ padding: 0, flex: "1 1 auto", minHeight: 0, display: "flex", flexDirection: "column" }}>
          {loading ? <div className="muted" style={{ textAlign: "center", padding: "60px 0" }}><span className="spin" /> {phase === "edit" ? "Vectorising…" : isSvgFile ? "Loading vector…" : "Preparing…"}</div>
          : err ? <div className="form-err" style={{ margin: 16 }}>{err}</div>
          : phase === "crop" ? <PreCropStep name={name} file={file} crop={crop} setCrop={setCrop} />
          : (
            <div style={{ display: "flex", flex: "1 1 auto", minHeight: 0 }}>
              {/* working area */}
              <div ref={workRef}
                   style={{ position: "relative", flex: "1 1 auto", minWidth: 300, overflow: "hidden", background: "repeating-conic-gradient(#e9edf2 0% 25%, #fff 0% 50%) 50%/22px 22px", cursor: eraseMode ? "crosshair" : (dragRef.current ? "grabbing" : "grab") }}
                   onMouseDown={onPanStart} onMouseMove={onPanMove} onMouseUp={onPanEnd} onMouseLeave={onPanEnd} onClick={onWorkClick}>
                <canvas ref={previewRef} style={{ position: "absolute", left: "50%", top: "50%", transformOrigin: "center", transform: `translate(-50%,-50%) translate(${pan.x}px,${pan.y}px) scale(${fit * zoom})`, imageRendering: "auto", maxWidth: "none" }} />
                <div style={{ position: "absolute", left: 10, bottom: 8, fontSize: 11, color: "var(--txt-3)", background: "rgba(255,255,255,.7)", borderRadius: 5, padding: "1px 6px" }}>{Math.round(fit * zoom * 100)}% · {eraseMode ? "click a colour to erase that area · drag to pan" : "scroll to zoom, drag to pan"}</div>
              </div>
              {/* sidebar */}
              <div style={{ width: 344, flex: "0 0 344px", borderLeft: "1px solid var(--line,#dfe4ea)", overflowY: "auto", padding: 14 }}>
                {/* bubble cluster — drop a bubble on the empty area to un-merge (split) it */}
                <div style={{ position: "relative", width: "100%", minHeight: 220, margin: "0 auto 8px" }}
                     onDragOver={e => e.preventDefault()}
                     onDrop={e => { e.preventDefault(); const t = e.dataTransfer.getData("text/plain"); if (t && merged[t]) unmerge(t); }}>
                  <div style={{ position: "relative", width: bubbles.w * bScale, height: bubbles.h * bScale, margin: "0 auto" }}>
                  {bubbles.placed.map(b => {
                    const transp = isTransparent(b.id), on = sel === b.id, D = b.r * 2 * bScale;
                    const kids = childrenOf(b.id);
                    return (
                      <span key={b.id} draggable
                            onDragStart={e => e.dataTransfer.setData("text/plain", b.id)}
                            onDragOver={e => { e.preventDefault(); if (dragOver !== b.id) setDragOver(b.id); }}
                            onDragLeave={() => setDragOver(o => o === b.id ? null : o)}
                            onDrop={e => { e.preventDefault(); e.stopPropagation(); setDragOver(null); mergeInto(e.dataTransfer.getData("text/plain"), b.id); }}
                            onClick={() => setSelected(s => s === b.id ? null : b.id)}
                            onDoubleClick={() => toggleHide(b.id)}
                            title={effHex(b.id) + (transp ? " (hidden)" : "") + " — click to highlight, double-click to hide, drag to merge" + (kids.length ? " · " + kids.length + " merged inside (drag one out to split)" : "")}
                            style={{ position: "absolute", left: (b.x - b.r) * bScale, top: (b.y - b.r) * bScale, width: D, height: D, borderRadius: "50%", cursor: "pointer",
                              background: transp ? "repeating-conic-gradient(#ccc 0% 25%, #fff 0% 50%) 50%/8px 8px" : effHex(b.id),
                              boxShadow: on ? "0 0 0 3px var(--mint-ink,#0a7)" : (dragOver === b.id ? "0 0 0 3px #f6c" : "inset 0 -3px 6px rgba(0,0,0,.25), 0 1px 2px rgba(0,0,0,.2)"),
                              border: "1px solid rgba(0,0,0,.15)" }}>
                        {kids.map((k, ci) => {
                          const cr = Math.max(6, Math.min(b.r * 0.30 * bScale, D / 2 - 4));
                          const ring = kids.length > 1 ? Math.max(0, D / 2 - cr - 3) : 0;
                          const ang = (ci / Math.max(1, kids.length)) * 2 * Math.PI - Math.PI / 2;
                          return (
                            <span key={k} draggable
                                  onDragStart={e => { e.stopPropagation(); e.dataTransfer.setData("text/plain", k); }}
                                  onClick={e => { e.stopPropagation(); setSelected(b.id); }}
                                  title={"Merged " + toHex(k) + " — drag out to split off, or onto another colour to move it"}
                                  style={{ position: "absolute", left: D / 2 + Math.cos(ang) * ring - cr, top: D / 2 + Math.sin(ang) * ring - cr, width: cr * 2, height: cr * 2, borderRadius: "50%", cursor: "grab", background: toHex(k), border: "1.5px solid #fff", boxShadow: "0 1px 2px rgba(0,0,0,.4)" }} />
                          );
                        })}
                      </span>
                    );
                  })}
                  </div>
                  <div style={{ position: "absolute", bottom: 2, left: 0, right: 0, textAlign: "center", fontSize: 10.5, color: "var(--txt-3)", pointerEvents: "none" }}>drag a bubble into the empty space to split it out</div>
                </div>

                {/* selected colour panel or instructions */}
                {sel ? (
                  <div style={{ border: "1px solid var(--line,#dfe4ea)", borderRadius: 8, padding: 10, marginBottom: 10 }}>
                    <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 8 }}>
                      <span style={{ width: 24, height: 24, borderRadius: "50%", border: "1px solid var(--line,#dfe4ea)", background: isTransparent(sel) ? "repeating-conic-gradient(#ccc 0% 25%, #fff 0% 50%) 50%/8px 8px" : effHex(sel) }} />
                      <b style={{ fontSize: 12.5 }}>Selected colour</b>
                      <span style={{ marginLeft: "auto", fontSize: 11, color: "var(--txt-3)" }}>highlighted on canvas</span>
                    </div>
                    <div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
                      <input type="color" value={effHex(sel)} onChange={e => setColor(sel, e.target.value)} style={{ width: 34, height: 28, padding: 0, border: "1px solid var(--line,#dfe4ea)", borderRadius: 6, background: "none", cursor: "pointer" }} title="Recolour" />
                      <input type="text" value={isTransparent(sel) ? "transparent" : effHex(sel)} onChange={e => { const v = e.target.value.trim(); if (/^#?[0-9a-fA-F]{6}$/.test(v)) setColor(sel, v[0] === "#" ? v : "#" + v); }} className="mono" style={{ width: 96, fontSize: 12 }} />
                      <button className={"btn btn--sm " + (isTransparent(sel) ? "btn--primary" : "btn--ghost")} onClick={() => toggleHide(sel)} title="Toggle visibility (transparent)">{isTransparent(sel) ? "👁 Show" : "⊘ Hide"}</button>
                      {(remap[sel] || childrenOf(sel).length) ? <button className="btn btn--ghost btn--sm" onClick={() => resetColor(sel)} title="Reset colour">↺</button> : null}
                    </div>
                    {childrenOf(sel).length > 0 && (
                      <div style={{ marginTop: 8, display: "flex", flexWrap: "wrap", gap: 6, alignItems: "center" }}>
                        <span style={{ fontSize: 11, color: "var(--txt-3)" }}>merged:</span>
                        {childrenOf(sel).map(k => (
                          <span key={k} title={"Merged " + k} style={{ display: "inline-flex", alignItems: "center", gap: 2 }}>
                            <span style={{ width: 14, height: 14, borderRadius: "50%", border: "1px solid var(--line,#dfe4ea)", background: toHex(k) }} />
                            <button className="linklike" style={{ fontSize: 13, lineHeight: 1 }} onClick={() => unmerge(k)}>×</button>
                          </span>
                        ))}
                      </div>
                    )}
                  </div>
                ) : (
                  <div className="dim" style={{ fontSize: 11.5, marginBottom: 10, lineHeight: 1.5 }}>
                    <b>Click a bubble</b> to highlight where it's used and recolour or hide it (or <b>double-click</b> to hide). <b>Drag one bubble onto another</b> to merge — merged colours sit inside as small bubbles; <b>drag a small bubble out</b> to split it off. Bubble size = how much of the artwork uses that colour.
                  </div>
                )}

                {/* palette presets */}
                {presets.length > 0 && (
                  <div style={{ marginBottom: 12 }}>
                    <div className="dim" style={{ fontSize: 12, marginBottom: 6 }}>Palette presets <span style={{ opacity: .7 }}>(reduce to N colours)</span></div>
                    <div style={{ display: "flex", flexWrap: "wrap", gap: 5 }}>
                      {presets.map(n => <button key={n} style={{ ...btn, width: 30, fontSize: 12 }} onClick={() => quantizeTo(n)} title={"Quantise to " + n + " colours"}>{n}</button>)}
                      <button className="btn btn--ghost btn--sm" onClick={resetAll} title="Back to the full palette">Original ({palette.length})</button>
                    </div>
                  </div>
                )}

                {/* width + save note */}
                <div style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 4 }}>
                  <span className="dim" style={{ fontSize: 12, width: 46 }}>Width</span>
                  <input type="number" min={10} max={3000} step={1} value={widthText}
                         onChange={e => { setWidthText(e.target.value); const n = Number(e.target.value); if (e.target.value !== "" && !isNaN(n)) setWidthMm(n); }}
                         onFocus={e => e.target.select()}
                         onBlur={() => { const n = Math.min(3000, Math.max(10, Number(widthText) || 200)); setWidthMm(n); setWidthText(String(n)); }}
                         style={{ width: 78 }} />
                  <span className="dim" style={{ fontSize: 12 }}>mm @300 DPI</span>
                </div>
                <div className="dim" style={{ fontSize: 11, marginTop: 6 }}>Saved as a new PNG in Artwork (trimmed); the original is untouched.</div>
              </div>
            </div>
          )}
        </div>
        <div className="modal__foot">
          <div style={{ flex: 1, display: "flex", alignItems: "center", gap: 10 }}>
            {saving && <span className="muted" style={{ fontSize: 12 }}><span className="spin" /> {step || "saving…"}</span>}
          </div>
          {phase === "crop" && (
            <span className="seg seg--xs" title="Vectorise engine" style={{ marginRight: 2 }}>
              <button className={engine === "recraft" ? "is-active" : ""} onClick={() => setEngine("recraft")}>recraft</button>
              <button className={engine === "vectorizer" ? "is-active" : ""} onClick={() => setEngine("vectorizer")} title="vectorizer.ai — premium tracer, handles transparency, traces the whole image (crop ignored)">vectorizer.ai</button>
            </span>
          )}
          <button className="btn btn--ghost" onClick={onCancel}>Cancel</button>
          {phase === "crop"
            ? <button className="btn btn--dark" disabled={loading || !!err} onClick={doVectorise}>{engine === "vectorizer" ? "Vectorise (vectorizer.ai)" : "Vectorise selection"}</button>
            : <button className="btn btn--dark" disabled={loading || !!err || saving || !outSvg} onClick={save}>Save as PNG</button>}
        </div>
      </div>
    </div>
  );
}

// Colour / area removal tool. Loads the artwork same-origin into a canvas and offers
// three modes, all previewed LIVE over a checkerboard (transparent = removed):
//   • "everywhere" — global colour key (Chebyshev distance), same as colorkey.py.
//   • "remove area" — magic-wand flood fill: click connected region(s) to delete only
//     what you click (e.g. the outer background) without touching same-coloured areas
//     enclosed inside the artwork.
//   • "keep area"   — inverse wand: click the shape(s) to keep; everything else goes.
// The wand modes match wand.py (4-connected, L1 colour distance <= tol from the seed).
function ColorKeyPicker({ name: _nameProp, owner, file, onApply, onCancel }) {
  const name = owner || _nameProp;   // order name OR {type,id} owner (draft)
  const canvasRef = React.useRef(null);
  const srcRef = React.useRef(null); // { data: Uint8ClampedArray, w, h }
  const [loaded, setLoaded] = useState(false);
  const [err, setErr] = useState("");
  const [mode, setMode] = useState("all"); // "all" | "remove" | "keep"
  const [picked, setPicked] = useState(null); // { r, g, b } — "all" mode
  const [seeds, setSeeds] = useState([]); // [{ x, y, r, g, b }] in source px — wand modes
  const [tol, setTol] = useState(15);
  const MAXW = 460, MAXH = 380;
  const isWand = mode !== "all";
  const hex = picked ? "#" + [picked.r, picked.g, picked.b].map(n => n.toString(16).padStart(2, "0")).join("") : "";

  // Load the image (same-origin thumb → clean canvas) and cache its pixels once.
  useEffect(() => {
    let dead = false;
    const img = new Image();
    img.onload = () => {
      if (dead) return;
      const off = document.createElement("canvas");
      off.width = img.naturalWidth; off.height = img.naturalHeight;
      const oc = off.getContext("2d");
      oc.drawImage(img, 0, 0);
      try {
        const d = oc.getImageData(0, 0, off.width, off.height);
        srcRef.current = { data: d.data, w: off.width, h: off.height };
        setLoaded(true);
      } catch (e) { setErr("Couldn't read the image pixels (" + (e.message || "canvas blocked") + ")"); }
    };
    img.onerror = () => { if (!dead) setErr("Couldn't load the image"); };
    img.src = DTF.artThumbUrl(name, file.id, 800);
    return () => { dead = true; };
  }, [name, file.id]);

  // Switching mode clears the current selection so the two tools don't get confused.
  function switchMode(m) { setMode(m); setPicked(null); setSeeds([]); }

  // Fit the source into the display box, preserving aspect ratio.
  const src = srcRef.current;
  const scale = src ? Math.min(MAXW / src.w, MAXH / src.h, 1) : 1;
  const dispW = src ? Math.max(1, Math.round(src.w * scale)) : MAXW;
  const dispH = src ? Math.max(1, Math.round(src.h * scale)) : 240;

  // Contiguous flood fill (matches wand.py): from each seed, select the connected run
  // of pixels within L1 tolerance of that seed's colour. Returns a Uint8Array mask.
  function floodUnion(sd, w, h, pts, thr) {
    const sel = new Uint8Array(w * h);
    for (const seed of pts) {
      const sr = seed.r, sg = seed.g, sb = seed.b;
      const inMask = (x, y) => { const p = (y * w + x) * 4; return (Math.abs(sd[p] - sr) + Math.abs(sd[p + 1] - sg) + Math.abs(sd[p + 2] - sb)) <= thr; };
      if (!inMask(seed.x, seed.y)) continue;
      const stack = [[seed.x, seed.y]];
      while (stack.length) {
        const [x, y] = stack.pop();
        const base = y * w;
        if (sel[base + x] || !inMask(x, y)) continue;
        let xl = x; while (xl > 0 && !sel[base + xl - 1] && inMask(xl - 1, y)) xl--;
        let xr = x; while (xr < w - 1 && !sel[base + xr + 1] && inMask(xr + 1, y)) xr++;
        for (let ix = xl; ix <= xr; ix++) sel[base + ix] = 1;
        for (const ny of [y - 1, y + 1]) {
          if (ny < 0 || ny >= h) continue;
          const nb = ny * w;
          let run = false;
          for (let ix = xl; ix <= xr; ix++) {
            const ok = !sel[nb + ix] && inMask(ix, ny);
            if (ok && !run) { stack.push([ix, ny]); run = true; } else if (!ok) run = false;
          }
        }
      }
    }
    return sel;
  }

  // Re-render the preview whenever the selection, mode or tolerance changes.
  useEffect(() => {
    const cv = canvasRef.current, s = srcRef.current;
    if (!cv || !s) return;
    cv.width = s.w; cv.height = s.h;
    const ctx = cv.getContext("2d");
    // Checkerboard so transparency reads clearly (like the artwork preview links).
    const sq = Math.max(6, Math.round(Math.min(s.w, s.h) / 28));
    for (let y = 0; y < s.h; y += sq) for (let x = 0; x < s.w; x += sq) {
      ctx.fillStyle = ((x / sq + y / sq) % 2 === 0) ? "#e9edf2" : "#ffffff";
      ctx.fillRect(x, y, sq, sq);
    }
    const out = ctx.createImageData(s.w, s.h);
    const sd = s.data, od = out.data;
    const thr = Math.round(tol / 100 * 255);
    const sel = isWand && seeds.length ? floodUnion(sd, s.w, s.h, seeds, thr) : null;
    const p = picked;
    for (let i = 0, px = 0; i < sd.length; i += 4, px++) {
      od[i] = sd[i]; od[i + 1] = sd[i + 1]; od[i + 2] = sd[i + 2]; od[i + 3] = sd[i + 3];
      if (mode === "all") {
        if (p) { const cheb = Math.max(Math.abs(sd[i] - p.r), Math.abs(sd[i + 1] - p.g), Math.abs(sd[i + 2] - p.b)); if (cheb <= thr) od[i + 3] = 0; }
      } else if (sel) {
        // remove → drop the selected region; keep → drop everything NOT selected.
        if (mode === "remove" ? sel[px] : !sel[px]) od[i + 3] = 0;
      }
    }
    // Draw the keyed pixels over the checkerboard via a temporary canvas.
    const tmp = document.createElement("canvas");
    tmp.width = s.w; tmp.height = s.h;
    tmp.getContext("2d").putImageData(out, 0, 0);
    ctx.drawImage(tmp, 0, 0);
    // Mark where the user clicked (wand modes) so multi-point selections are visible.
    if (isWand) for (const seed of seeds) {
      ctx.beginPath(); ctx.arc(seed.x, seed.y, Math.max(3, sq * 0.5), 0, 2 * Math.PI);
      ctx.lineWidth = Math.max(1.5, sq * 0.15); ctx.strokeStyle = "#18f4af"; ctx.stroke();
    }
  }, [mode, picked, seeds, tol, loaded]);

  function clickAt(e) {
    const s = srcRef.current, cv = canvasRef.current;
    if (!s || !cv) return;
    const rect = cv.getBoundingClientRect();
    const x = Math.floor((e.clientX - rect.left) / rect.width * s.w);
    const y = Math.floor((e.clientY - rect.top) / rect.height * s.h);
    if (x < 0 || y < 0 || x >= s.w || y >= s.h) return;
    const i = (y * s.w + x) * 4;
    const col = { r: s.data[i], g: s.data[i + 1], b: s.data[i + 2] };
    if (mode === "all") setPicked(col);
    else setSeeds(prev => [...prev, { x, y, ...col }]);
  }

  const ready = mode === "all" ? !!picked : seeds.length > 0;
  const applyLabel = mode === "all" ? "Remove this colour" : mode === "keep" ? "Keep clicked area" : "Remove clicked area";
  function apply() {
    if (mode === "all") { onApply({ mode: "all", hex, tol }); return; }
    const s = srcRef.current; if (!s) return;
    const norm = seeds.map(sd => ({ x: s.w > 1 ? sd.x / (s.w - 1) : 0, y: s.h > 1 ? sd.y / (s.h - 1) : 0 }));
    onApply({ mode, tol, seeds: norm });
  }
  const hint = mode === "all"
    ? "Click the background to pick its colour — every pixel near it is removed, anywhere in the image."
    : mode === "remove"
      ? "Click a background area to remove only that connected region. Click more spots to remove several. Enclosed same-coloured areas inside the art are kept."
      : "Click the shape(s) to KEEP — everything else is removed. Click more spots to keep several regions.";

  return (
    <div className="modal-overlay" onClick={onCancel}>
      <div className="modal" onClick={e => e.stopPropagation()} style={{ maxWidth: MAXW + 48 }}>
        <div className="modal__head">
          <h3>Remove colour / area</h3>
          <button className="icon-btn" onClick={onCancel}><Icon name="x" /></button>
        </div>
        <div className="modal__body">
          <div className="seg" style={{ marginBottom: 10 }}>
            <button className={mode === "all" ? "is-active" : ""} onClick={() => switchMode("all")}>Colour everywhere</button>
            <button className={mode === "remove" ? "is-active" : ""} onClick={() => switchMode("remove")}>Remove area</button>
            <button className={mode === "keep" ? "is-active" : ""} onClick={() => switchMode("keep")}>Keep area</button>
          </div>
          <p className="muted" style={{ marginTop: 0, fontSize: 12.5 }}>{hint} The preview shows the result (checkerboard = transparent).</p>
          {err && <div className="form-err" style={{ marginBottom: 8 }}>{err}</div>}
          <div style={{ display: "flex", justifyContent: "center" }}>
            <canvas ref={canvasRef} onClick={clickAt}
              style={{ width: dispW, height: dispH, cursor: loaded ? "crosshair" : "wait", borderRadius: 8, border: "1px solid var(--line,#dfe4ea)", maxWidth: "100%" }} />
          </div>
          {!loaded && !err && <div className="muted" style={{ textAlign: "center", fontSize: 12, marginTop: 6 }}>Loading image…</div>}
          {mode === "all" ? (
            <div style={{ display: "flex", alignItems: "center", gap: 10, marginTop: 12 }}>
              <span style={{ width: 22, height: 22, borderRadius: 5, border: "1px solid var(--line,#dfe4ea)", background: picked ? hex : "repeating-conic-gradient(#ccc 0% 25%, #fff 0% 50%) 50%/10px 10px", flex: "0 0 auto" }} title={picked ? hex : "no colour picked"} />
              <span className="mono" style={{ fontSize: 12, minWidth: 70 }}>{picked ? hex : "pick a colour"}</span>
            </div>
          ) : (
            <div style={{ display: "flex", alignItems: "center", gap: 10, marginTop: 12, fontSize: 12 }}>
              <span className="mono">{seeds.length} point{seeds.length === 1 ? "" : "s"} selected</span>
              {seeds.length > 0 && <button className="linklike" onClick={() => setSeeds([])}>reset</button>}
              {seeds.length > 0 && <button className="linklike" onClick={() => setSeeds(s => s.slice(0, -1))}>undo</button>}
            </div>
          )}
          <div style={{ marginTop: 10 }}>
            <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
              <span className="dim" style={{ fontSize: 12, width: 68 }}>Tolerance</span>
              <input type="range" min={0} max={100} value={tol} onChange={e => setTol(Number(e.target.value))} style={{ flex: 1 }} />
              <span className="mono" style={{ fontSize: 12, width: 34, textAlign: "right" }}>{tol}%</span>
            </div>
            <div className="dim" style={{ fontSize: 11.5, marginTop: 4 }}>Higher spreads the selection to more nearby shades. Removal is all-or-nothing per pixel (DTF can't print partial opacity) — chain "Shave edge" to tidy a fringe.</div>
          </div>
        </div>
        <div className="modal__foot">
          <div style={{ flex: 1 }} />
          <button className="btn btn--ghost" onClick={onCancel}>Cancel</button>
          <button className="btn btn--dark" disabled={!ready} onClick={apply}>{applyLabel}</button>
        </div>
      </div>
    </div>
  );
}

// Recolour the whole design to ONE flat colour, keeping the alpha silhouette. Live
// preview mirrors the server (recolor_logo.py: every opaque pixel → the chosen colour).
function RecolorPicker({ name: _nameProp, owner, file, onApply, onCancel }) {
  const name = owner || _nameProp;   // order name OR {type,id} owner (draft)
  const canvasRef = React.useRef(null);
  const srcRef = React.useRef(null);
  const [loaded, setLoaded] = useState(false);
  const [err, setErr] = useState("");
  const [hex, setHex] = useState("#18f4af");
  const MAXW = 460, MAXH = 380;

  useEffect(() => {
    let dead = false;
    const img = new Image();
    img.onload = () => {
      if (dead) return;
      const off = document.createElement("canvas");
      off.width = img.naturalWidth; off.height = img.naturalHeight;
      off.getContext("2d").drawImage(img, 0, 0);
      try { const d = off.getContext("2d").getImageData(0, 0, off.width, off.height); srcRef.current = { data: d.data, w: off.width, h: off.height }; setLoaded(true); }
      catch (e) { setErr("Couldn't read the image pixels (" + (e.message || "canvas blocked") + ")"); }
    };
    img.onerror = () => { if (!dead) setErr("Couldn't load the image"); };
    img.src = DTF.artThumbUrl(name, file.id, 800);
    return () => { dead = true; };
  }, [name, file.id]);

  const src = srcRef.current;
  const scale = src ? Math.min(MAXW / src.w, MAXH / src.h, 1) : 1;
  const dispW = src ? Math.max(1, Math.round(src.w * scale)) : MAXW;
  const dispH = src ? Math.max(1, Math.round(src.h * scale)) : 240;
  const validHex = /^#[0-9a-fA-F]{6}$/.test(hex);

  useEffect(() => {
    const cv = canvasRef.current, s = srcRef.current;
    if (!cv || !s) return;
    cv.width = s.w; cv.height = s.h;
    const ctx = cv.getContext("2d");
    const sq = Math.max(6, Math.round(Math.min(s.w, s.h) / 28));
    for (let y = 0; y < s.h; y += sq) for (let x = 0; x < s.w; x += sq) {
      ctx.fillStyle = ((x / sq + y / sq) % 2 === 0) ? "#e9edf2" : "#ffffff";
      ctx.fillRect(x, y, sq, sq);
    }
    const [r, g, b] = vrHexToRgb(validHex ? hex : "#18f4af");
    const out = ctx.createImageData(s.w, s.h);
    const sd = s.data, od = out.data;
    for (let i = 0; i < sd.length; i += 4) { od[i] = r; od[i + 1] = g; od[i + 2] = b; od[i + 3] = sd[i + 3]; }
    const tmp = document.createElement("canvas"); tmp.width = s.w; tmp.height = s.h;
    tmp.getContext("2d").putImageData(out, 0, 0);
    ctx.drawImage(tmp, 0, 0);
  }, [hex, loaded]);

  return (
    <div className="modal-overlay" onClick={onCancel}>
      <div className="modal" onClick={e => e.stopPropagation()} style={{ maxWidth: MAXW + 48 }}>
        <div className="modal__head">
          <h3>Recolour to one colour</h3>
          <button className="icon-btn" onClick={onCancel}><Icon name="x" /></button>
        </div>
        <div className="modal__body">
          <p className="muted" style={{ marginTop: 0, fontSize: 12.5 }}>Pick a colour — the whole design is replaced with it, keeping its shape (checkerboard = transparent). Remove the background first if it isn't already cut out.</p>
          {err && <div className="form-err" style={{ marginBottom: 8 }}>{err}</div>}
          <div style={{ display: "flex", justifyContent: "center" }}>
            <canvas ref={canvasRef} style={{ width: dispW, height: dispH, borderRadius: 8, border: "1px solid var(--line,#dfe4ea)", maxWidth: "100%" }} />
          </div>
          {!loaded && !err && <div className="muted" style={{ textAlign: "center", fontSize: 12, marginTop: 6 }}>Loading image…</div>}
          <div style={{ display: "flex", alignItems: "center", gap: 10, marginTop: 12 }}>
            <input type="color" value={validHex ? hex : "#18f4af"} onChange={e => setHex(e.target.value)} title="Pick a colour" style={{ width: 40, height: 32, border: "1px solid var(--line,#dfe4ea)", borderRadius: 6, background: "none", cursor: "pointer", padding: 0 }} />
            <input className="rm-in" value={hex} onChange={e => { let v = e.target.value.trim(); if (v && v[0] !== "#") v = "#" + v; setHex(v.slice(0, 7)); }} style={{ width: 120, fontSize: 13, fontFamily: "var(--font-mono)" }} />
            {!validHex && <span className="dim" style={{ fontSize: 11.5 }}>enter a colour like #18f4af</span>}
          </div>
        </div>
        <div className="modal__foot">
          <div style={{ flex: 1 }} />
          <button className="btn btn--ghost" onClick={onCancel}>Cancel</button>
          <button className="btn btn--dark" disabled={!validHex} onClick={() => onApply({ hex })}>Recolour</button>
        </div>
      </div>
    </div>
  );
}

// Text breakdown of a produced gang sheet (images + exact sizes + qty), mirroring
// the public /g/ preview page, with a cm/inches toggle. Shown under the gang-sheet
// row in the artwork list so you can see each artwork's size without opening it.
function GangSheetInfo({ owner, fileId }) {
  const [comp, setComp] = useState(undefined); // undefined=loading, null=none, obj=data
  const [unit, setUnit] = useState("cm");
  useEffect(() => {
    let live = true;
    DTF.gangComposition(owner, fileId)
      .then(c => { if (live) setComp(c && c.found ? c : null); })
      .catch(() => { if (live) setComp(null); });
    return () => { live = false; };
  }, [owner.id, owner.type, fileId]);
  if (!comp) return null;
  const uLabel = unit === "in" ? "in" : "cm";
  const val = (mm) => unit === "in" ? (mm / 25.4).toFixed(1) : (mm / 10).toFixed(1);
  const dpi = comp.dpi || 300;
  return (
    <div className="gs-info" style={{ margin: "0 0 10px 56px", fontSize: 11.5, fontFamily: "var(--font-mono)", color: "var(--txt-3,#666)" }}>
      <div style={{ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap", marginBottom: 4 }}>
        <span>{comp.totalImages} images · {comp.distinctImages} design{comp.distinctImages === 1 ? "" : "s"}{comp.lengthM != null ? ` · ${comp.lengthM} m` : ""} · {val(comp.sheetWidthMm)} {uLabel} roll · {dpi} DPI</span>
        <span className="seg gs-seg" onClick={e => e.stopPropagation()}>
          <button className={unit === "cm" ? "is-active" : ""} onClick={() => setUnit("cm")}>cm</button>
          <button className={unit === "in" ? "is-active" : ""} onClick={() => setUnit("in")}>inches</button>
        </span>
      </div>
      <ul style={{ margin: 0, paddingLeft: 16 }}>
        {(comp.images || []).map((im, i) => (
          <li key={i}>{im.name} — {im.qty}pcs, {val(im.wMm)}×{val(im.hMm)} {uLabel}, {dpi}dpi</li>
        ))}
      </ul>
    </div>
  );
}

function ArtworkCard({ o, canMark, craft }) {
  // Craft-aware wording: embroidery shops must never see "print" language. The
  // underlying flag is still `f.print` (the "make this file" marker) — only the
  // labels change.
  const emb = craft === "embroidery";
  const markVerb = emb ? "Mark to do" : "Mark to print";
  const markedVerb = emb ? "To do ✓" : "To print ✓";
  const doBadge = emb ? "TO DO" : "PRINT THIS";
  const doWord = emb ? "make" : "print";
  const [files, setFiles] = useState(null);   // null = loading
  const [busy, setBusy] = useState(false);
  const [prog, setProg] = useState(0);
  const [err, setErr] = useState("");
  const [drag, setDrag] = useState(false);
  const [menuFor, setMenuFor] = useState(null); // file id with an open services menu
  const [copiedFor, setCopiedFor] = useState(null); // file id showing a "public link copied" tick
  const menuBtnRef = useRef(null); // the open menu's trigger button (for floating placement)
  const menuElRef = useRef(null);  // the open menu element
  // Float the services menu as a fixed dropdown at its natural size, shifting up so
  // it respects the window's bottom edge (only scrolls if taller than the screen).
  const positionMenu = () => {
    if (!menuBtnRef.current || !menuElRef.current) return;
    const btn = menuBtnRef.current.getBoundingClientRect();
    const el = menuElRef.current, st = el.style;
    const vw = window.innerWidth, vh = window.innerHeight, pad = 8, topMin = 64; // topMin clears the fixed top bar
    st.maxHeight = ""; st.overflowY = ""; st.position = "fixed"; st.right = "auto"; // reset so we measure natural size
    const w = Math.min(el.offsetWidth || 220, vw - 2 * pad);
    st.left = Math.max(pad, Math.min(btn.right - w, vw - w - pad)) + "px";
    const h = el.offsetHeight;
    let top = btn.bottom + 4;
    if (top + h > vh - pad) top = vh - pad - h;   // would overflow the bottom → shift the whole menu up
    if (top < topMin) { top = topMin; st.maxHeight = (vh - topMin - pad) + "px"; st.overflowY = "auto"; } // keep clear of the header + scroll if taller than the screen
    st.top = top + "px"; st.bottom = "auto";
  };
  useLayoutEffect(() => { if (menuFor) positionMenu(); }, [menuFor]);
  const [pickerFor, setPickerFor] = useState(null); // file object with an open colour-key picker
  const [recolorFor, setRecolorFor] = useState(null); // file object with an open recolour picker
  const [vecFor, setVecFor] = useState(null); // file object with an open vectorise-recolour modal
  const [halftoneFor, setHalftoneFor] = useState(null); // file object with an open halftone modal
  const [htPending, setHtPending] = useState(() => {
    try {
      const enc = encodeURIComponent(o.id);
      return JSON.parse(localStorage.getItem(HT_QUEUE_KEY) || "[]").filter(j => j.pollBase && j.pollBase.includes(enc));
    } catch { return []; }
  });
  const [adjVals, setAdjVals] = useState({ erode: 1, harden: 128, outline: 16, scale: 10, colortol: 15, highres: "1K", highresPrompt: "Upscale to high resolution, keep the design identical - do not change anything" }); // per-service slider / selector values
  const [procById, setProcById] = useState({}); // fileId -> { step } | { error } — per-file so jobs run in parallel
  const [sel, setSel] = useState(() => new Set()); // selected file ids for gang-sheet
  const [gsOpen, setGsOpen] = useState(false);
  const [gsEdit, setGsEdit] = useState(null); // { files, rows } — re-open the builder pre-filled to edit an existing sheet
  const [libOpen, setLibOpen] = useState(false);   // customer artwork library modal
  const [savingLib, setSavingLib] = useState(false);
  const [textOpen, setTextOpen] = useState(false);
  const inputRef = React.useRef(null);
  const name = o.id;
  const cust = DTF.getCustomer(o.customerId) || o.customer || {};
  const custName = cust.name || [cust.firstName, cust.lastName].filter(Boolean).join(" ") || cust.displayName || cust.email || "";
  // Background halftone queue — poll pending jobs every 5 s, refresh artwork when done
  useEffect(() => {
    if (!htPending.length) return;
    let active = true;
    const poll = async () => {
      const doneIds = [];
      for (const job of htPending) {
        try {
          const r = await fetch(`${job.pollBase}/process-artwork/${job.jobId}`);
          if (!r.ok) continue;
          const j = await r.json();
          if (j.status === "done" || j.status === "error") doneIds.push(job.jobId);
        } catch {}
      }
      if (!active || !doneIds.length) return;
      setHtPending(prev => prev.filter(j => !doneIds.includes(j.jobId)));
      try {
        const q = JSON.parse(localStorage.getItem(HT_QUEUE_KEY) || "[]");
        localStorage.setItem(HT_QUEUE_KEY, JSON.stringify(q.filter(j => !doneIds.includes(j.jobId))));
      } catch {}
      await refresh();
      window.dispatchEvent(new CustomEvent("dtf:artwork-updated", { detail: { name } }));
    };
    poll();
    const timer = setInterval(poll, 5000);
    return () => { active = false; clearInterval(timer); };
  }, [htPending.length]);

  // Which of this order's artworks are already in the customer's library — matched by
  // filename (that's what "Add to library" stores as the asset name). Drives the badge.
  const [libNames, setLibNames] = useState(null); // Set<filename> | null
  const loadLibNames = React.useCallback(() => {
    if (!o.customerId) { setLibNames(null); return; }
    DTF.customerLibrary(o.customerId).then(d => setLibNames(new Set((d.assets || []).map(a => a.name)))).catch(() => setLibNames(null));
  }, [o.customerId]);
  useEffect(() => { loadLibNames(); }, [loadLibNames]);
  // In the library if the artwork carries a direct link (added from / published to
  // the library), or — for legacy files without the link — its name matches a library
  // asset. Order artwork names are prefixed with the order number, so also try the
  // de-prefixed name against the library asset names.
  const inLibrary = (f) => {
    if (f.libraryAssetId) return true;
    if (!libNames || !f.filename) return false;
    if (libNames.has(f.filename)) return true;
    const num = String(name || "").replace(/^#/, "");
    const bare = num && f.filename.startsWith(num + "-") ? f.filename.slice(num.length + 1) : f.filename;
    return libNames.has(bare);
  };
  async function addSelToLibrary() {
    if (!o.customerId || !sel.size) return;
    setSavingLib(true);
    try { for (const id of sel) await DTF.libFromArtwork(o.customerId, { type: "order", id: name }, id); setSel(new Set()); loadLibNames(); }
    catch (e) { alert("Couldn't add to library: " + e.message); }
    finally { setSavingLib(false); }
  }
  const toggleSel = (id) => setSel(s => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
  const refresh = () => DTF.artworkFor(name, true).then(setFiles);
  // On mount: show any cached list instantly, then re-fetch FRESH so files added by a
  // background artwork-service job (while we were on another page) show up on return.
  useEffect(() => {
    const cached = DTF.artworkCached(name);
    if (cached) setFiles(cached);
    DTF.artworkFor(name, true).then(setFiles).catch(() => {});
  }, [name]);
  // Close on outside click / Escape. On page scroll, REPOSITION (don't close) so the
  // fixed menu follows its button; scrolling INSIDE the menu is ignored.
  useEffect(() => {
    if (!menuFor) return;
    const close = () => setMenuFor(null);
    const key = e => { if (e.key === "Escape") setMenuFor(null); };
    const onScroll = e => { if (menuElRef.current && menuElRef.current.contains(e.target)) return; positionMenu(); };
    window.addEventListener("click", close); window.addEventListener("keydown", key);
    window.addEventListener("scroll", onScroll, true); window.addEventListener("resize", positionMenu);
    return () => { window.removeEventListener("click", close); window.removeEventListener("keydown", key); window.removeEventListener("scroll", onScroll, true); window.removeEventListener("resize", positionMenu); };
  }, [menuFor]);
  async function runService(f, opts) {
    setMenuFor(null);
    const cur = procById[f.id];
    if (cur && !cur.error) return; // this file already has a job running
    setProcById(m => ({ ...m, [f.id]: { step: "starting" } }));
    try {
      await DTF.processArtwork(name, null, { fileId: f.id, ...opts, onStep: s => setProcById(m => ({ ...m, [f.id]: { step: s } })) });
      setProcById(m => { const n = { ...m }; delete n[f.id]; return n; });
      await refresh();
      window.dispatchEvent(new CustomEvent("dtf:artwork-updated", { detail: { name } }));
    } catch (e) { setProcById(m => ({ ...m, [f.id]: { error: e.message || "Processing failed" } })); }
  }
  // Rasterise an uploaded PDF artwork file to a print-ready PNG (page 1) and attach it.
  async function convertPdf(f) {
    const cur = procById[f.id];
    if (cur && !cur.error) return;
    setProcById(m => ({ ...m, [f.id]: { step: "starting" } }));
    try {
      await DTF.pdfToPng(name, { fileId: f.id }, s => setProcById(m => ({ ...m, [f.id]: { step: s } })));
      setProcById(m => { const n = { ...m }; delete n[f.id]; return n; });
      await refresh();
      window.dispatchEvent(new CustomEvent("dtf:artwork-updated", { detail: { name } }));
    } catch (e) { setProcById(m => ({ ...m, [f.id]: { error: e.message || "Conversion failed" } })); }
  }
  // Refresh when a design preview is processed into artwork elsewhere on the page.
  useEffect(() => {
    const h = e => { if (!e.detail || e.detail.name === name) refresh(); };
    window.addEventListener("dtf:artwork-updated", h);
    return () => window.removeEventListener("dtf:artwork-updated", h);
  }, [name]);

  async function upload(list) {
    list = [...(list || [])].filter(Boolean);
    if (!list.length) return;
    setErr(""); setBusy(true);
    try { for (const f of list) { setProg(0); await DTF.uploadArtwork(name, f, p => setProg(p)); } await refresh(); }
    catch (ex) { setErr(ex.message || "Upload failed"); }
    setBusy(false); setProg(0);
  }

  // Re-open the gang-sheet builder pre-filled with an existing sheet's layout.
  const isGang = (f) => /gang.?sheet/i.test(f.filename || "");
  async function editGang(f) {
    try {
      const comp = await DTF.gangComposition({ type: "order", id: name }, f.id);
      if (!comp || !comp.found || !(comp.images || []).length) { alert("Couldn't load this gang sheet's layout to edit it."); return; }
      const have = new Set((files || []).map(ff => ff.id));
      const rows = comp.images.filter(im => have.has(im.id)).map(im => ({ fid: im.id, dim: "w", mm: im.wMm, qty: im.qty }));
      if (!rows.length) { alert("The source images for this gang sheet aren't on the order anymore, so it can't be edited."); return; }
      const compFiles = (files || []).filter(ff => rows.some(r => r.fid === ff.id));
      setGsEdit({ files: compFiles, rows, replaceId: f.id });
    } catch (e) { alert(e.message || "Couldn't open the gang sheet for editing."); }
  }
  async function del(id) { setFiles(await DTF.deleteArtwork(name, id)); }
  // Studio marks the one file the shop should print (radio). Clicking the marked
  // one again clears it.
  async function togglePrint(f) {
    try { setFiles(await DTF.setPrintFile(name, f.id, !f.print)); window.dispatchEvent(new CustomEvent("dtf:artwork-updated", { detail: { name } })); }
    catch (e) { setErr(e.message || "Couldn't set the print file"); }
  }

  return (
    <div className="card">
      <div className="card__head"><Icon name="image" /><h3>Artwork</h3>
        <div className="right" style={{ display: "flex", alignItems: "center", gap: 10 }}>
          {files && files.length > 0 && <span className="kick">{files.length} file{files.length === 1 ? "" : "s"}</span>}
          <button className="btn btn--ghost btn--sm" disabled={!o.customerId} title={o.customerId ? "Browse this customer's artwork library" : "This order has no linked customer account"} onClick={() => setLibOpen(true)}><Icon name="image" /> Customer library</button>
        </div></div>
      <div className="card__body">
        <div className="dim" style={{ fontSize: 12.5, marginBottom: 10 }}>{emb ? "Design files" : "Print-ready files"} for this order. Upload once design is done — they can be downloaded on the {emb ? "workstation" : "printing workstation"}.{canMark && (files && files.length > 1) ? ` Use "${markVerb}" so the shop knows which file(s) to run — you can mark more than one.` : ""}</div>
        <div
          className={"art-drop" + (drag ? " is-drag" : "")}
          onDragOver={e => { e.preventDefault(); setDrag(true); }}
          onDragLeave={() => setDrag(false)}
          onDrop={e => { e.preventDefault(); setDrag(false); upload(e.dataTransfer.files); }}
          onClick={() => inputRef.current && inputRef.current.click()}
          style={{ marginBottom: 12 }}
        >
          {busy
            ? <span>Uploading… {Math.round(prog * 100)}%</span>
            : <span><Icon name="plus" /> Drop files or click to upload artwork</span>}
        </div>
        <input ref={inputRef} type="file" multiple style={{ display: "none" }} onChange={e => { const fs = [...e.target.files]; e.target.value = ""; upload(fs); }} />
        {(() => { const pfs = (files || []).filter(f => f.print); return pfs.length ? (
          <div className="art-printbanner" style={{ flexWrap: "wrap", gap: 8 }}>
            <Icon name="print" /> <span><b>{emb ? "Make" : "Print"} {pfs.length > 1 ? "these " + pfs.length + " files" : "this file"}:</b></span>
            {pfs.map(pf => (
              <a key={pf.id} className="btn btn--primary btn--sm" href={DTF.artworkUrl(name, pf.id, true)} title={"Download " + pf.filename}><Icon name="download" /> {pf.filename}</a>
            ))}
          </div>
        ) : null; })()}
        {(() => {
          const selectable = (files || []).filter(f => /^image\//.test(f.contentType));
          if (selectable.length < 2) return null;
          const allSel = selectable.every(f => sel.has(f.id));
          return (
            <label onClick={e => e.stopPropagation()} style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 8, cursor: "pointer", fontSize: 12.5 }} title="Select every image on this order">
              <input type="checkbox" checked={allSel} onChange={() => setSel(allSel ? new Set() : new Set(selectable.map(f => f.id)))} />
              <span>{allSel ? "Deselect all" : "Select all"} ({selectable.length}){sel.size ? ` · ${sel.size} selected` : ""}</span>
            </label>
          );
        })()}
        {files === null ? <div className="dim" style={{ fontSize: 12.5 }}>Loading…</div>
          : files.length === 0 ? null
          : <div className="art-list">{files.slice().sort((a, b) => (b.print ? 1 : 0) - (a.print ? 1 : 0)).map(f => {
              const isImg = /^image\//.test(f.contentType);
              const isPdf = /pdf/i.test(f.contentType || "") || /\.pdf$/i.test(f.filename || "");
              const isSvg = /svg/i.test(f.contentType || "") || /\.svg$/i.test(f.filename || "");
              const p = procById[f.id];
              const running = p && !p.error;
              const failed = p && p.error;
              const openMenu = e => { e.preventDefault(); e.stopPropagation(); if (!running) setMenuFor(m => m === f.id ? null : f.id); };
              return (
              <React.Fragment key={f.id}>
              <div className={"art-row" + (sel.has(f.id) ? " is-sel" : "") + (f.print ? " art-row--print" : "")} onContextMenu={isImg ? openMenu : undefined}>
                {isImg && <label className="art-check" onClick={e => e.stopPropagation()} title="Select for gang sheet"><input type="checkbox" checked={sel.has(f.id)} onChange={() => toggleSel(f.id)} /></label>}
                <a className="art-thumb" href={artOversized(f) ? DTF.artThumbUrl(name, f.id, 1400) : (isImg ? DTF.artworkPreviewUrl(name, f.id) : DTF.artworkUrl(name, f.id))} target="_blank" rel="noopener" title={artOversized(f) ? "Open preview (downscaled — the full sheet is too large for a browser tab)" : "Open (on a checkerboard so transparency shows)"}>
                  {isImg ? <img src={DTF.artThumbUrl(name, f.id, 96)} alt="" /> : <Icon name="doc" />}
                </a>
                <div className="art-meta">
                  <a className="art-name" href={artOversized(f) ? DTF.artThumbUrl(name, f.id, 1400) : (isImg ? DTF.artworkPreviewUrl(name, f.id) : DTF.artworkUrl(name, f.id))} target="_blank" rel="noopener" title={artOversized(f) ? ("Preview " + f.filename + " (downscaled)") : ("Open " + f.filename)}>{f.filename}</a>
                  {f.print && <span className="badge badge--print" title={"This is the file the shop should " + doWord}><Icon name="print" /> {doBadge}</span>}
                  {artOversized(f) && <span className="badge" style={{ marginLeft: 6, background: "var(--mint-wash)", color: "var(--mint-ink)", borderColor: "transparent" }} title="Too large to render full-size in a browser — Open shows a downscaled preview; Download gives the full file">preview</span>}
                  {inLibrary(f)
                    ? <span className="badge badge--mint" style={{ marginLeft: 6 }} title="This artwork is in the customer's library"><Icon name="check" /> In library</span>
                    : (o.customerId && libNames) ? <span className="badge" style={{ marginLeft: 6, background: "#f2f1ec", color: "var(--txt-3)", borderColor: "transparent" }} title={`Not yet in the customer's library — tick it and use "Add to library"`}>not in library</span> : null}
                  <div className="mono dim art-sub">{artDims(f) ? artDims(f) + " · " : ""}{fmtBytes(f.size)} · {window.fmtEventTime ? window.fmtEventTime(f.uploadedAt) : ""}</div>
                  {running && <div className="art-proc"><span className="spin spin--dark" /> {p.step || "working…"} · ~1 min</div>}
                  {failed && <div className="art-proc art-proc--err">{p.error}</div>}
                </div>
                <div className="art-actions">
                  {copiedFor === f.id && <span className="kick" style={{ color: "var(--mint-ink)", alignSelf: "center" }}>Public link copied ✓</span>}
                  {isImg && (
                    <div className="art-menu-wrap" onClick={e => e.stopPropagation()}>
                      <button ref={menuFor === f.id ? menuBtnRef : null} className="btn btn--ghost btn--sm" disabled={!!running} onClick={openMenu} title="Artwork services (or right-click the row)">
                        <Icon name="spark" /> Services ▾
                      </button>
                      {menuFor === f.id && (
                        <div className="art-menu" ref={menuElRef}>
                          {ART_SERVICES.map(s => s.adjust ? (() => {
                            const val = adjVals[s.adjust.opt];
                            return (
                            <div key={s.key} className="art-menu__adj">
                              <div className="art-menu__adj-label">{s.label}</div>
                              <div className="art-menu__adj-row">
                                <input type="range" min={s.adjust.min} max={s.adjust.max} value={val} onChange={e => setAdjVals(m => ({ ...m, [s.adjust.opt]: Number(e.target.value) }))} />
                                <span className="art-menu__adj-val mono">{val}</span>
                                <button className="btn btn--sm" onClick={() => runService(f, { ...s.opts, [s.adjust.opt]: val })}>Apply</button>
                              </div>
                              <div className="art-menu__adj-hint">{s.adjust.hint(val)}</div>
                            </div>
                            );
                          })() : s.choices ? (() => {
                            const val = adjVals[s.choices.opt];
                            return (
                            <div key={s.key} className="art-menu__adj">
                              <div className="art-menu__adj-label">{s.label}</div>
                              {s.promptOpt && (
                                <input className="rm-in" style={{ width: "100%", boxSizing: "border-box", marginBottom: 6, fontSize: 12 }}
                                  value={adjVals[s.promptOpt] ?? ""} placeholder="AI prompt" title="Prompt sent to the AI — edit to change the result"
                                  onChange={e => setAdjVals(m => ({ ...m, [s.promptOpt]: e.target.value }))} />
                              )}
                              <div className="art-menu__adj-row">
                                <div className="seg art-menu__seg">
                                  {s.choices.values.map(v => (
                                    <button key={v} className={val === v ? "is-active" : ""} onClick={() => setAdjVals(m => ({ ...m, [s.choices.opt]: v }))}>{s.choices.fmt ? s.choices.fmt(v) : "×" + v}</button>
                                  ))}
                                </div>
                                <button className="btn btn--sm" onClick={() => runService(f, { ...s.opts, [s.choices.opt]: val, ...(s.promptOpt ? { [s.promptOpt]: adjVals[s.promptOpt] } : {}) })}>Apply</button>
                              </div>
                              <div className="art-menu__adj-hint">{s.choices.hint(val)}</div>
                            </div>
                            );
                          })() : s.action === "pick" ? (
                            <button key={s.key} className="art-menu__item" onClick={() => { setMenuFor(null); setPickerFor(f); }}>{s.label}</button>
                          ) : s.action === "recolor" ? (
                            <button key={s.key} className="art-menu__item" onClick={() => { setMenuFor(null); setRecolorFor(f); }}>{s.label}</button>
                          ) : s.action === "vectorize" ? (
                            <button key={s.key} className="art-menu__item" onClick={() => { setMenuFor(null); setVecFor(f); }}>{s.label}</button>
                          ) : s.action === "halftone" ? (
                            <button key={s.key} className="art-menu__item" onClick={() => { setMenuFor(null); setHalftoneFor(f); }}>{s.label}</button>
                          ) : s.action === "rename" ? (
                            <button key={s.key} className="art-menu__item" onClick={async () => {
                              setMenuFor(null);
                              const nn = prompt("Rename artwork", f.filename || "");
                              if (nn == null) return; const v = nn.trim(); if (!v || v === f.filename) return;
                              try { setFiles(await DTF.renameArtwork(name, f.id, v)); window.dispatchEvent(new CustomEvent("dtf:artwork-updated", { detail: { name } })); }
                              catch (e) { alert("Couldn't rename: " + (e.message || e)); }
                            }}>{s.label}</button>
                          ) : s.action === "copylink" ? (
                            <button key={s.key} className="art-menu__item" onClick={async () => { setMenuFor(null); const bare = String(name).replace(/^#/, ""); const u = await copyArtworkPublicUrl(/^\d+$/.test(bare) ? "#" + bare : bare, f.id); if (u) { setCopiedFor(f.id); setTimeout(() => setCopiedFor(c => c === f.id ? null : c), 1800); } }}>{s.label}</button>
                          ) : (
                            <button key={s.key} className="art-menu__item" onClick={() => runService(f, s.opts)}>{s.label}</button>
                          ))}
                        </div>
                      )}
                    </div>
                  )}
                  {isGang(f) && <button className="btn btn--ghost btn--sm" disabled={!!running} onClick={() => editGang(f)} title="Re-open the gang-sheet builder pre-filled with this sheet's images, sizes and quantities"><Icon name="image" /> Edit gang sheet</button>}
                  {isPdf && <button className="btn btn--ghost btn--sm" disabled={!!running} onClick={() => convertPdf(f)} title="Rasterise the PDF's first page to a print-ready transparent PNG @300 DPI and attach to Artwork"><Icon name="image" /> Convert to PNG</button>}
                  {isSvg && <button className="btn btn--ghost btn--sm" disabled={!!running} onClick={() => setVecFor(f)} title="Recolour this vector's palette (no vectorising credits) and save as PNG"><Icon name="spark" /> Recolour</button>}
                  {canMark && <button className={"btn btn--sm " + (f.print ? "btn--primary" : "btn--ghost")} onClick={() => togglePrint(f)} title={f.print ? `This is the file the shop ${doWord}s — click to unset` : `Mark this as the file the shop should ${doWord}`}><Icon name="print" /> {f.print ? markedVerb : markVerb}</button>}
                  <a className="btn btn--ghost btn--sm" href={DTF.artworkUrl(name, f.id, true)}><Icon name="download" /> Download</a>
                  <button className="btn btn--ghost btn--sm" onClick={() => del(f.id)}>Delete</button>
                </div>
              </div>
              {isGang(f) && <GangSheetInfo owner={{ type: "order", id: name }} fileId={f.id} />}
              {htPending.filter(j => j.filename === f.filename).map(job => (
                <div key={job.jobId} style={{ display:"flex", alignItems:"center", gap:8, padding:"6px 10px 6px 14px", background:"#111122", borderRadius:6, marginBottom:4, border:"1px solid #2a2a3a", marginTop:2 }}>
                  <span style={{ display:"inline-block", width:11, height:11, border:"2px solid #18f4af", borderTopColor:"transparent", borderRadius:"50%", animation:"spin 0.8s linear infinite", flexShrink:0 }} />
                  <span style={{ fontSize:12, color:"#999", flex:1 }}>Halftoning{job.upscale ? " + AI upscale" : ""} of "{job.filename}" in progress — will appear when done</span>
                  <button onClick={() => { setHtPending(p => p.filter(j => j.jobId !== job.jobId)); try { const q = JSON.parse(localStorage.getItem(HT_QUEUE_KEY)||"[]"); localStorage.setItem(HT_QUEUE_KEY, JSON.stringify(q.filter(j => j.jobId !== job.jobId))); } catch {} }}
                    style={{ background:"none", border:"none", color:"#555", fontSize:14, cursor:"pointer", padding:"0 2px", flexShrink:0, lineHeight:1 }} title="Dismiss">×</button>
                </div>
              ))}
              </React.Fragment>
              );
            })}</div>}
        {htPending.filter(j => !(files || []).some(f => f.filename === j.filename)).map(job => (
          <div key={job.jobId} style={{ display:"flex", alignItems:"center", gap:8, padding:"6px 10px", background:"#111122", borderRadius:6, marginBottom:4, border:"1px solid #2a2a3a" }}>
            <span style={{ display:"inline-block", width:11, height:11, border:"2px solid #18f4af", borderTopColor:"transparent", borderRadius:"50%", animation:"spin 0.8s linear infinite", flexShrink:0 }} />
            <span style={{ fontSize:12, color:"#999", flex:1 }}>Halftoning{job.upscale ? " + AI upscale" : ""} of "{job.filename}" in progress — will appear when done</span>
            <button onClick={() => { setHtPending(p => p.filter(j => j.jobId !== job.jobId)); try { const q = JSON.parse(localStorage.getItem(HT_QUEUE_KEY)||"[]"); localStorage.setItem(HT_QUEUE_KEY, JSON.stringify(q.filter(j => j.jobId !== job.jobId))); } catch {} }}
              style={{ background:"none", border:"none", color:"#555", fontSize:14, cursor:"pointer", padding:"0 2px", flexShrink:0, lineHeight:1 }} title="Dismiss">×</button>
          </div>
        ))}
        {files && files.some(f => /^image\//.test(f.contentType) && f.width) && (() => {
          const gsFiles = files.filter(f => /^image\//.test(f.contentType) && f.width);
          const allSel = gsFiles.length > 0 && gsFiles.every(f => sel.has(f.id));
          return (
          <div className="gs-bar" style={{ display: "flex", gap: 10, alignItems: "center", marginTop: 10 }}>
            <button className="btn btn--ghost btn--sm" disabled={sel.size < 1} onClick={() => setGsOpen(true)}>
              <Icon name="image" /> Make gang sheet{sel.size ? ` (${sel.size})` : ""}
            </button>
            {o.customerId && <button className="btn btn--ghost btn--sm" disabled={sel.size < 1 || savingLib} onClick={addSelToLibrary} title="Copy the ticked artwork into this customer's library">
              {savingLib ? <span className="spin spin--dark" /> : <Icon name="image" />} Add to library{sel.size ? ` (${sel.size})` : ""}
            </button>}
            <button className="linklike" onClick={() => setSel(allSel ? new Set() : new Set(gsFiles.map(f => f.id)))}>{allSel ? "deselect all" : `select all (${gsFiles.length})`}</button>
            {sel.size > 0 && !allSel && <button className="linklike" onClick={() => setSel(new Set())}>clear</button>}
            <span className="dim" style={{ fontSize: 11.5 }}>tick artworks to place them on a 57 cm roll</span>
          </div>
          );
        })()}
        {gsOpen && <GangSheetBuilder owner={{ type: "order", id: name }} customerId={o && o.customerId} files={(files || []).filter(f => sel.has(f.id))}
          onCancel={() => setGsOpen(false)}
          onDone={async () => { setGsOpen(false); setSel(new Set()); await refresh(); window.dispatchEvent(new CustomEvent("dtf:artwork-updated", { detail: { name } })); }} />}
        {gsEdit && <GangSheetBuilder owner={{ type: "order", id: name }} customerId={o && o.customerId} files={gsEdit.files} initialRows={gsEdit.rows}
          onCancel={() => setGsEdit(null)}
          onDone={async (newFile) => { const rid = gsEdit && gsEdit.replaceId; setGsEdit(null); try { if (newFile && rid) await DTF.deleteArtwork(name, rid); } catch {} await refresh(); window.dispatchEvent(new CustomEvent("dtf:artwork-updated", { detail: { name } })); }} />}
        {libOpen && <CustomerLibraryModal customerId={o.customerId} customerName={custName} owner={{ type: "order", id: name }}
          onClose={() => { setLibOpen(false); loadLibNames(); }}
          onAssigned={(fs) => { if (fs && fs.length) setFiles(fs); refresh(); window.dispatchEvent(new CustomEvent("dtf:artwork-updated", { detail: { name } })); }} />}
        {pickerFor && <ColorKeyPicker name={name} file={pickerFor}
          onCancel={() => setPickerFor(null)}
          onApply={(payload) => {
            const f = pickerFor; setPickerFor(null);
            if (payload.mode === "all") runService(f, { removeBg: false, upscale: false, colorkey: payload.hex, colortol: payload.tol });
            else runService(f, { removeBg: false, upscale: false, wandmode: payload.mode, wandseeds: payload.seeds, colortol: payload.tol });
          }} />}
        {recolorFor && <RecolorPicker name={name} file={recolorFor}
          onCancel={() => setRecolorFor(null)}
          onApply={({ hex }) => { const f = recolorFor; setRecolorFor(null); runService(f, { removeBg: false, upscale: false, recolor: hex }); }} />}
        {vecFor && <VectorizeRecolorModal name={name} file={vecFor}
          onCancel={() => setVecFor(null)}
          onDone={async () => { setVecFor(null); await refresh(); window.dispatchEvent(new CustomEvent("dtf:artwork-updated", { detail: { name } })); }} />}
        {halftoneFor && <HalftonePrepModal name={name} file={halftoneFor}
          onCancel={() => setHalftoneFor(null)}
          onQueued={job => { setHalftoneFor(null); setHtPending(prev => [...prev, job]); }} />}
        <div style={{ marginTop: 12 }}>
          <button className="btn btn--ghost btn--sm" onClick={() => setTextOpen(true)} title="Type text, pick a font & colour, set the print size → renders a print-ready PNG"><Icon name="spark" /> Add text artwork</button>
        </div>
        {textOpen && <TextArtworkBuilder owner={{ type: "order", id: name }}
          onCancel={() => setTextOpen(false)}
          onDone={async () => { setTextOpen(false); await refresh(); window.dispatchEvent(new CustomEvent("dtf:artwork-updated", { detail: { name } })); }} />}
        {err && <div style={{ color: "var(--c-red,#c0392b)", fontSize: 12, marginTop: 8 }}>{err}</div>}
      </div>
    </div>
  );
}
// ── DTF Halftone Prep modal ──────────────────────────────────────────────────
// Converts colour artwork to a halftone dot/line pattern for printing on dark garments.
// Interactive 2-axis widget: drag endpoint to rotate (angle), drag length to adjust
function HtSliderRow({ label, value, min, max, step, setter, fmtFn, onCommit }) {
  const fmt = fmtFn || (v => String(v));
  const [localVal, setLocalVal] = React.useState(value);
  const [txt, setTxt] = React.useState(() => fmt(value));
  const draggingRef = React.useRef(false);
  const editRef = React.useRef(false);

  // Sync from parent (e.g. Reset defaults) only when not mid-drag
  React.useEffect(() => {
    if (draggingRef.current) return;
    setLocalVal(value);
    if (!editRef.current) setTxt(fmt(value));
  }, [value]);

  const commitText = () => {
    editRef.current = false;
    const n = parseFloat(txt);
    const clamped = isNaN(n) ? value : Math.max(min, Math.min(max, n));
    setter(clamped);
    setLocalVal(clamped);
    setTxt(fmt(clamped));
    onCommit?.();
  };

  return (
    <div style={{ display: "flex", alignItems: "center", gap: 6, marginBottom: 4, minWidth: 0, overflow: "hidden" }}
      onWheel={e => {
        e.stopPropagation();
        e.preventDefault();
        const s = step || 1;
        const dir = e.deltaY < 0 ? 1 : -1;
        const newVal = Math.max(min, Math.min(max, +(localVal + dir * s).toFixed(10)));
        setLocalVal(newVal); setTxt(fmt(newVal)); setter(newVal); onCommit?.();
      }}>
      <span style={{ width: 90, fontSize: 11, color: "#aaa", flexShrink: 0 }}>{label}</span>
      <input type="range" min={min} max={max} step={step || 1} value={localVal}
        onMouseDown={() => { draggingRef.current = true; }}
        onChange={e => {
          const v = parseFloat(e.target.value);
          setLocalVal(v);
          if (!editRef.current) setTxt(fmt(v));
        }}
        onMouseUp={e => {
          draggingRef.current = false;
          const v = parseFloat(e.target.value);
          setter(v);
          setTxt(fmt(v));
          onCommit?.();
        }}
        onTouchEnd={() => {
          draggingRef.current = false;
          setter(localVal);
          setTxt(fmt(localVal));
          onCommit?.();
        }}
        style={{ flex: 1, minWidth: 0, accentColor: "#18f4af", height: 4 }} />
      <input type="text" value={txt}
        onChange={e => { editRef.current = true; setTxt(e.target.value); }}
        onBlur={commitText}
        onKeyDown={e => { if (e.key === "Enter") e.currentTarget.blur(); }}
        style={{ width: 38, fontSize: 11, color: "#18f4af", background: "rgba(24,244,175,0.05)", border: "1px solid #18f4af33", borderRadius: 3, padding: "1px 4px", textAlign: "right", flexShrink: 0, outline: "none" }} />
    </div>
  );
}

// ── WebGL halftone preview ──────────────────────────────────────────────────

const HT_VERT = `
attribute vec2 aPos;
varying vec2 vUV;
void main() {
  vUV = vec2(aPos.x * 0.5 + 0.5, 1.0 - (aPos.y * 0.5 + 0.5));
  gl_Position = vec4(aPos, 0.0, 1.0);
}`;

const HT_FRAG = `
precision highp float;
varying vec2 vUV;
uniform sampler2D uTex;
uniform vec2  uSize;
uniform float uCellPx;
uniform float uAngle;
uniform float uBlack;
uniform float uMid;
uniform float uWhite;
uniform float uOutMin;
uniform float uOutMax;
uniform int   uView;
uniform int   uPattern;
uniform vec3  uShirt;
uniform int   uKoOn;
uniform vec3  uKoCol;
uniform float uKoTol;
uniform float uShadowBoost;
uniform int   uHalftoneEnabled;
uniform float uAlphaThresh;
const float PI = 3.14159265;
void main() {
  vec4 src = texture2D(uTex, vUV);
  if (uView == 3) { gl_FragColor = src; return; }
  // Threshold semi-transparent pixels to fully opaque or fully transparent.
  // Semi-transparent pixels print as faint white dots on dark DTF garments.
  if (uAlphaThresh > 0.0) {
    src.a = (src.a >= uAlphaThresh) ? 1.0 : 0.0;
  }
  // Knockout-only mode: pass through original pixels, apply only color knockout
  if (uHalftoneEnabled == 0) {
    float a = (src.a > 0.0) ? 1.0 : 0.0;
    if (uKoOn == 1) {
      vec3 d = abs(src.rgb - uKoCol);
      if (max(max(d.r,d.g),d.b) <= uKoTol) a = 0.0;
    }
    if (uView == 2)      { gl_FragColor = vec4(a,a,a,1.0); }
    else if (uView == 1) { gl_FragColor = vec4(mix(uShirt,src.rgb,a),1.0); }
    else                 { gl_FragColor = vec4(src.rgb, a); }
    return;
  }
  vec2 px = vUV * uSize;
  float rad = uAngle * PI / 180.0;
  float ca = cos(rad), sa = sin(rad);
  vec2 rot = vec2(ca*px.x - sa*px.y, sa*px.x + ca*px.y);
  vec2 cellId = floor(rot / uCellPx);
  vec2 ccr = (cellId + 0.5) * uCellPx;
  vec2 cpx = vec2(ca*ccr.x + sa*ccr.y, -sa*ccr.x + ca*ccr.y);
  vec2 cuv = clamp(cpx / uSize, 0.0, 1.0);
  vec4 cell = texture2D(uTex, cuv);
  float r = cell.r, g = cell.g, b = cell.b;
  float cmax = max(max(r,g),b);
  float bl = uBlack/255.0, wh = uWhite/255.0;
  float vN = clamp((cmax - bl) / max(wh - bl, 0.001), 0.0, 1.0);
  vN = pow(max(vN, 0.0), uMid);
  vN = uOutMin/255.0 + vN*((uOutMax-uOutMin)/255.0);
  vN = vN * (1.0 - uShadowBoost * (1.0 - vN));
  // Dot coverage from V only — alpha gates presence but must not shrink dot size.
  // Semi-transparent sky pixels need full-brightness-sized dots, not alpha×V-sized.
  float eff = (cell.a > 0.004) ? vN : 0.0;
  float inside;
  if (uPattern == 0) {
    // Scale 0.75 capped at 0.70×cellPx: circles merge at eff≈0.44, giving dense fill
    // through mid-tones. Cap stays below the cell diagonal (0.707) so corners stay
    // uncovered and the pattern reads as round, not square.
    float dotR = min(sqrt(max(eff, 0.0)) * uCellPx * 0.75, uCellPx * 0.70);
    inside = (eff > 0.005 && length(rot - ccr) <= dotR) ? 1.0 : 0.0;
  } else {
    float hw = (eff >= 0.78) ? uCellPx*0.5 : sqrt(max(eff,0.0))*uCellPx*0.48;
    inside = (eff > 0.005 && abs(rot.y - ccr.y) <= hw) ? 1.0 : 0.0;
  }
  // Dots should be fully opaque — source alpha tones the dot SIZE (via eff) not its opacity.
  // Semi-transparent sky/shadow pixels otherwise produce nearly-invisible faint dots.
  float alpha = (src.a > 0.0) ? inside : 0.0;
  if (uKoOn == 1) {
    vec3 d = abs(src.rgb - uKoCol);
    if (max(max(d.r,d.g),d.b) <= uKoTol) alpha = 0.0;
  }
  if (uView == 2)      { gl_FragColor = vec4(alpha,alpha,alpha,1.0); }
  else if (uView == 1) { gl_FragColor = vec4(mix(uShirt,src.rgb,alpha),1.0); }
  else                 { gl_FragColor = vec4(src.rgb, alpha); }
}`;

// Pass-2 shader: MinFilter erosion (cleanup) + defringe + view compositing.
// Reads the raw halftone RGBA from the FBO written by pass 1.
const HT_CLEANUP_FRAG = `
precision highp float;
varying vec2 vUV;
uniform sampler2D uTex;
uniform vec2  uPixel;
uniform int   uCleanup;
uniform int   uDefringe;
uniform int   uView;
uniform vec3  uShirt;
void main() {
  // FBO was rendered in GL framebuffer space (y=0 at bottom), but vUV has y=0
  // at screen top (flipped by HT_VERT). Correct by inverting Y when sampling FBO.
  vec2 fuv = vec2(vUV.x, 1.0 - vUV.y);
  vec4 c = texture2D(uTex, fuv);
  float alpha = c.a;
  if (uCleanup >= 1) {
    float m = alpha;
    m = min(m, texture2D(uTex, fuv+vec2(-uPixel.x,-uPixel.y)).a);
    m = min(m, texture2D(uTex, fuv+vec2( 0.0,     -uPixel.y)).a);
    m = min(m, texture2D(uTex, fuv+vec2( uPixel.x,-uPixel.y)).a);
    m = min(m, texture2D(uTex, fuv+vec2(-uPixel.x,  0.0    )).a);
    m = min(m, texture2D(uTex, fuv+vec2( uPixel.x,  0.0    )).a);
    m = min(m, texture2D(uTex, fuv+vec2(-uPixel.x, uPixel.y)).a);
    m = min(m, texture2D(uTex, fuv+vec2( 0.0,      uPixel.y)).a);
    m = min(m, texture2D(uTex, fuv+vec2( uPixel.x, uPixel.y)).a);
    alpha = m;
  }
  if (uCleanup >= 2) {
    float m = alpha;
    vec2 p2 = uPixel * 2.0;
    m = min(m, texture2D(uTex, fuv+vec2(-p2.x,-p2.y)).a);
    m = min(m, texture2D(uTex, fuv+vec2( 0.0, -p2.y)).a);
    m = min(m, texture2D(uTex, fuv+vec2( p2.x,-p2.y)).a);
    m = min(m, texture2D(uTex, fuv+vec2(-p2.x,  0.0)).a);
    m = min(m, texture2D(uTex, fuv+vec2( p2.x,  0.0)).a);
    m = min(m, texture2D(uTex, fuv+vec2(-p2.x, p2.y)).a);
    m = min(m, texture2D(uTex, fuv+vec2( 0.0,  p2.y)).a);
    m = min(m, texture2D(uTex, fuv+vec2( p2.x, p2.y)).a);
    alpha = m;
  }
  if (uDefringe == 1) {
    float bl =
      0.0751*(texture2D(uTex,fuv+vec2(-uPixel.x,-uPixel.y)).a
             +texture2D(uTex,fuv+vec2( uPixel.x,-uPixel.y)).a
             +texture2D(uTex,fuv+vec2(-uPixel.x, uPixel.y)).a
             +texture2D(uTex,fuv+vec2( uPixel.x, uPixel.y)).a) +
      0.1238*(texture2D(uTex,fuv+vec2( 0.0,     -uPixel.y)).a
             +texture2D(uTex,fuv+vec2(-uPixel.x,  0.0    )).a
             +texture2D(uTex,fuv+vec2( uPixel.x,  0.0    )).a
             +texture2D(uTex,fuv+vec2( 0.0,      uPixel.y)).a) +
      0.2042*alpha;
    alpha = (bl > (180.0/255.0)) ? min(alpha*1.05,1.0) : alpha*0.72;
  }
  if (uView == 2)      { gl_FragColor = vec4(alpha,alpha,alpha,1.0); }
  else if (uView == 1) { gl_FragColor = vec4(mix(uShirt,c.rgb,alpha),1.0); }
  else                 { gl_FragColor = vec4(c.rgb, alpha); }
}`;

function parseHexColor(hex) {
  const h = (hex||'#000000').replace('#','');
  const f = h.length===3 ? h.split('').map(c=>c+c).join('') : h.padEnd(6,'0');
  return [parseInt(f.slice(0,2),16)||0, parseInt(f.slice(2,4),16)||0, parseInt(f.slice(4,6),16)||0];
}

function makeHalftoneGL(canvas, imgSrc, origW, origH) {
  const gl = canvas.getContext('webgl', { alpha: true, premultipliedAlpha: false });
  if (!gl) return null;
  const mkS = (type, src) => {
    const s = gl.createShader(type);
    gl.shaderSource(s, src); gl.compileShader(s);
    if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) { console.error('[htGL]', gl.getShaderInfoLog(s)); return null; }
    return s;
  };
  // Pass 1: halftone shader
  const vs1 = mkS(gl.VERTEX_SHADER, HT_VERT);
  const fs1 = mkS(gl.FRAGMENT_SHADER, HT_FRAG);
  if (!vs1 || !fs1) return null;
  const prog1 = gl.createProgram();
  gl.bindAttribLocation(prog1, 0, 'aPos');
  gl.attachShader(prog1, vs1); gl.attachShader(prog1, fs1);
  gl.linkProgram(prog1);
  if (!gl.getProgramParameter(prog1, gl.LINK_STATUS)) { console.error('[htGL p1]', gl.getProgramInfoLog(prog1)); return null; }
  // Pass 2: cleanup/defringe + view composite shader
  const vs2 = mkS(gl.VERTEX_SHADER, HT_VERT);
  const fs2 = mkS(gl.FRAGMENT_SHADER, HT_CLEANUP_FRAG);
  if (!vs2 || !fs2) return null;
  const prog2 = gl.createProgram();
  gl.bindAttribLocation(prog2, 0, 'aPos');
  gl.attachShader(prog2, vs2); gl.attachShader(prog2, fs2);
  gl.linkProgram(prog2);
  if (!gl.getProgramParameter(prog2, gl.LINK_STATUS)) { console.error('[htGL p2]', gl.getProgramInfoLog(prog2)); return null; }
  // Shared vertex buffer at attrib 0 (both programs bound to 0 above)
  const buf = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, buf);
  gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 1,-1, -1,1, 1,1]), gl.STATIC_DRAW);
  gl.enableVertexAttribArray(0);
  gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
  // Source image texture (tex unit 0)
  const tex = gl.createTexture();
  gl.bindTexture(gl.TEXTURE_2D, tex);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, imgSrc);
  // FBO texture for pass-1 output
  const fboTex = gl.createTexture();
  gl.bindTexture(gl.TEXTURE_2D, fboTex);
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, canvas.width, canvas.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
  const fbo = gl.createFramebuffer();
  gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
  gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, fboTex, 0);
  gl.bindFramebuffer(gl.FRAMEBUFFER, null);
  // Prog1 uniforms
  gl.useProgram(prog1);
  const U1 = Object.fromEntries(
    ['uTex','uSize','uCellPx','uAngle','uBlack','uMid','uWhite','uOutMin','uOutMax',
     'uView','uPattern','uShirt','uKoOn','uKoCol','uKoTol','uShadowBoost','uHalftoneEnabled','uAlphaThresh']
    .map(n => [n, gl.getUniformLocation(prog1, n)])
  );
  gl.uniform1i(U1.uTex, 0);
  // Prog2 uniforms
  gl.useProgram(prog2);
  const U2 = Object.fromEntries(
    ['uTex','uPixel','uCleanup','uDefringe','uView','uShirt']
    .map(n => [n, gl.getUniformLocation(prog2, n)])
  );
  gl.uniform1i(U2.uTex, 0);

  const setP1Uniforms = (p, viewIdx) => {
    const cellPx = (300.0 / p.frequency) * (canvas.width / origW);
    gl.uniform2f(U1.uSize, canvas.width, canvas.height);
    gl.uniform1f(U1.uCellPx, Math.max(2, cellPx));
    gl.uniform1f(U1.uAngle,  p.angle);
    gl.uniform1f(U1.uBlack,  p.levelsBlack);
    gl.uniform1f(U1.uMid,    p.levelsMid);
    gl.uniform1f(U1.uWhite,  p.levelsWhite);
    gl.uniform1f(U1.uOutMin, p.outputMin);
    gl.uniform1f(U1.uOutMax, p.outputMax);
    gl.uniform1i(U1.uView,   viewIdx);
    gl.uniform1i(U1.uPattern, p.pattern === 'line' ? 1 : 0);
    const sc = parseHexColor(p.shirtColor);
    gl.uniform3f(U1.uShirt, sc[0]/255, sc[1]/255, sc[2]/255);
    gl.uniform1i(U1.uKoOn, p.knockoutEnabled ? 1 : 0);
    const kc = parseHexColor(p.knockoutColor);
    gl.uniform3f(U1.uKoCol, kc[0]/255, kc[1]/255, kc[2]/255);
    gl.uniform1f(U1.uKoTol, p.knockoutTol / 100.0);
    gl.uniform1f(U1.uShadowBoost, (p.shadowBoost || 0) / 100.0);
    gl.uniform1i(U1.uHalftoneEnabled, p.halftoneEnabled !== false ? 1 : 0);
    gl.uniform1f(U1.uAlphaThresh, p.alphaThreshEnabled ? (p.alphaThreshVal || 50) / 100.0 : 0.0);
    return sc;
  };

  return {
    origW, origH,
    render(p) {
      const cleanupN = p.cleanup === 'high' ? 2 : p.cleanup === 'standard' ? 1 : 0;
      const needsPass2 = (cleanupN > 0 || p.defringe) && p.view !== 'original';
      const viewIdx = ['alpha','shirt','mask','original'].indexOf(p.view);
      gl.viewport(0, 0, canvas.width, canvas.height);
      if (needsPass2) {
        // Pass 1 → FBO with raw alpha (no view compositing)
        gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
        gl.clearColor(0,0,0,0); gl.clear(gl.COLOR_BUFFER_BIT);
        gl.useProgram(prog1);
        gl.bindTexture(gl.TEXTURE_2D, tex);
        const sc = setP1Uniforms(p, 0); // view=alpha (raw)
        gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
        // Pass 2 → canvas: cleanup + defringe + view composite
        gl.bindFramebuffer(gl.FRAMEBUFFER, null);
        gl.clearColor(0,0,0,0); gl.clear(gl.COLOR_BUFFER_BIT);
        gl.useProgram(prog2);
        gl.bindTexture(gl.TEXTURE_2D, fboTex);
        gl.uniform2f(U2.uPixel, 1/canvas.width, 1/canvas.height);
        gl.uniform1i(U2.uCleanup,  cleanupN);
        gl.uniform1i(U2.uDefringe, p.defringe ? 1 : 0);
        gl.uniform1i(U2.uView,     viewIdx);
        gl.uniform3f(U2.uShirt,    sc[0]/255, sc[1]/255, sc[2]/255);
        gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
      } else {
        // Single pass directly to canvas
        gl.bindFramebuffer(gl.FRAMEBUFFER, null);
        gl.clearColor(0,0,0,0); gl.clear(gl.COLOR_BUFFER_BIT);
        gl.useProgram(prog1);
        gl.bindTexture(gl.TEXTURE_2D, tex);
        setP1Uniforms(p, viewIdx);
        gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
      }
    },
    destroy() {
      gl.deleteTexture(tex); gl.deleteTexture(fboTex);
      gl.deleteFramebuffer(fbo); gl.deleteBuffer(buf);
      gl.deleteProgram(prog1); gl.deleteProgram(prog2);
    }
  };
}

// ── Apply progress panel ────────────────────────────────────────────────────
const HT_APPLY_STEPS = [
  { key: "downloading",        label: "Downloading",       pct: 18 },
  { key: "applying halftone",  label: "Applying halftone", pct: 58 },
  { key: "trimming + 300 DPI", label: "Trimming + DPI",   pct: 88 },
];
const HT_APPLY_STEPS_UPSCALE = [
  { key: "upscaling",          label: "AI upscaling",      pct: 38 },
  { key: "applying halftone",  label: "Applying halftone", pct: 72 },
  { key: "trimming + 300 DPI", label: "Trimming + DPI",   pct: 92 },
];
const HT_TOTAL_EST_S = 40;
const HT_TOTAL_EST_UPSCALE_S = 120;

function HtApplyProgress({ step, elapsed, steps, totalEst }) {
  const _steps = steps || HT_APPLY_STEPS;
  const _total = totalEst || HT_TOTAL_EST_S;
  const si  = _steps.findIndex(s => s.key === step);
  const pct = si >= 0 ? _steps[si].pct : 8;
  const lbl = si >= 0 ? _steps[si].label : "Queuing…";
  const etaSec = elapsed < _total ? Math.max(0, _total - elapsed) : null;
  return (
    <div style={{ padding: "2px 0" }}>
      <div style={{ height: 3, background: "#1a1a2a", borderRadius: 2, overflow: "hidden", marginBottom: 8 }}>
        <div style={{ height: "100%", width: pct + "%", background: "#18f4af", borderRadius: 2, transition: "width 1.2s ease" }} />
      </div>
      <div style={{ display: "flex", alignItems: "center", marginBottom: 7 }}>
        {_steps.map((s, i) => {
          const done = si > i, active = si === i;
          return (
            <React.Fragment key={s.key}>
              {i > 0 && <div style={{ flex: 1, height: 1, background: done ? "#18f4af55" : "#222", transition: "background 0.4s" }} />}
              <div style={{ width: 8, height: 8, borderRadius: "50%", flexShrink: 0,
                background: active ? "#18f4af" : done ? "#0fa870" : "#2a2a3a",
                boxShadow: active ? "0 0 6px #18f4af" : "none", transition: "all 0.4s" }} />
            </React.Fragment>
          );
        })}
      </div>
      <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
        <span style={{ fontSize: 12, color: "#18f4af", fontWeight: 600 }}>{lbl}</span>
        <span style={{ fontSize: 11, color: "#888" }}>
          {elapsed}s{etaSec !== null ? ` · ~${etaSec}s left` : " · finalising…"}
        </span>
      </div>
    </div>
  );
}

// Phase 1 asks for output size (cm), Phase 2 is the full halftone editor.
// Settings persist in localStorage; axis widget overlays the preview and controls angle+frequency.
const HT_QUEUE_KEY = "dtf-halftone-queue";

function HalftonePrepModal({ name, file, onCancel, onQueued }) {
  const isDraft = /^gid:\/\/shopify\/DraftOrder\//.test(name) || (typeof name === "string" && name.startsWith("D-"));
  const previewBase = isDraft ? `/api/draft-orders/${encodeURIComponent(name)}` : `/api/orders/${encodeURIComponent(name)}`;
  const isMac = /mac/i.test(typeof navigator !== "undefined" ? (navigator.platform || navigator.userAgent || "") : "");
  const modKey = isMac ? "⌘" : "Ctrl";

  // ── Persist settings via localStorage ──────────────────────────────────────
  const PREF_KEY = "dtf-halftone-prefs";
  const prefs = useMemo(() => { try { return JSON.parse(localStorage.getItem(PREF_KEY) || "{}"); } catch { return {}; } }, []);

  // ── Settings state (loaded from prefs on mount) ─────────────────────────────
  const [pattern, setPattern]         = useState(prefs.pattern || "round");
  const [frequency, setFrequency]     = useState(prefs.frequency ?? 30);
  const [angle, setAngle]             = useState(prefs.angle ?? 45);
  // Black/White stored as 0-255 (matching Photoshop DTPrep convention).
  // Old stored values were 0-100 (%); migrate by multiplying by 2.55.
  const _b0 = prefs.levelsBlack !== undefined ? Number(prefs.levelsBlack) : undefined;
  const _w0 = prefs.levelsWhite !== undefined ? Number(prefs.levelsWhite) : undefined;
  const [levelsBlack, setLevelsBlack] = useState(_b0 !== undefined ? (_b0 <= 100 ? Math.round(_b0 * 2.55) : _b0) : 7);
  const [levelsMid, setLevelsMid]     = useState(prefs.levelsMid ?? 1.0);
  const [levelsWhite, setLevelsWhite] = useState(_w0 !== undefined ? (_w0 <= 100 ? Math.round(_w0 * 2.55) : _w0) : 100);
  const [outputMin, setOutputMin]     = useState(prefs.outputMin ?? 0);
  const [outputMax, setOutputMax]     = useState(prefs.outputMax ?? 255);
  const [shadowBoost, setShadowBoost] = useState(prefs.shadowBoost ?? 0);
  const [koEnabled, setKoEnabled]     = useState(prefs.koEnabled !== undefined ? prefs.koEnabled : true);
  const [koColor, setKoColor]         = useState(prefs.koColor || "#000000");
  const [koTol, setKoTol]             = useState(prefs.koTol ?? 15);
  const [shirtColor, setShirtColor]   = useState(prefs.shirtColor || "#000000");
  const [cleanup, setCleanup]         = useState(prefs.cleanup || "none");
  const [defringe, setDefringe]       = useState(prefs.defringe ?? false);
  const [applyHalftone, setApplyHalftone] = useState(prefs.applyHalftone !== false);
  const [alphaThreshEnabled, setAlphaThreshEnabled] = useState(prefs.alphaThreshEnabled ?? false);
  const [alphaThreshVal, setAlphaThreshVal]         = useState(prefs.alphaThreshVal ?? 50);

  // Non-persisted
  const [view, setView]             = useState("alpha");
  const [compareMode, setCompareMode] = useState(false);
  const [showEditHt, setShowEditHt] = useState(false);
  const [showAxis, setShowAxis]     = useState(true);
  const [phase, setPhase]           = useState("size");
  // Size picker: linked W×H with proportional lock
  const _initRatio = (file.width && file.height) ? file.width / file.height : null;
  // Default to the image's natural print size at 300 DPI
  const _origWcm = file.width  ? +(file.width  / 300 * 2.54).toFixed(1) : 30;
  const _origHcm = file.height ? +(file.height / 300 * 2.54).toFixed(1) : 30;
  const [linked, setLinked]             = useState(true);
  const [ratio, setRatio]               = useState(_initRatio);
  const [targetWidthCm, setTargetWidthCm]   = useState(_origWcm);
  const [targetHeightCm, setTargetHeightCm] = useState(_origHcm);
  const [wInput, setWInput]             = useState(String(_origWcm));
  const [hInput, setHInput]             = useState(String(_origHcm));

  // Fetch actual pixel dimensions from image header to correct stored metadata (which may be wrong/square).
  // Runs once when the modal mounts; updates ratio + default size if different from stored values.
  useEffect(() => {
    let cancelled = false;
    fetch(`${previewBase}/halftone-source?fileId=${encodeURIComponent(file.id)}`, { method: "HEAD" })
      .then(r => {
        if (cancelled) return;
        const w = parseInt(r.headers.get("X-Image-Width")  || "0", 10);
        const h = parseInt(r.headers.get("X-Image-Height") || "0", 10);
        if (w > 0 && h > 0 && (w !== file.width || h !== file.height)) {
          const newRatio = w / h;
          const newWcm = +(w / 300 * 2.54).toFixed(1);
          const newHcm = +(h / 300 * 2.54).toFixed(1);
          setRatio(newRatio);
          setTargetWidthCm(newWcm); setTargetHeightCm(newHcm);
          setWInput(String(newWcm)); setHInput(String(newHcm));
        }
      })
      .catch(() => {});
    return () => { cancelled = true; };
  }, []);
  const [zoom, setZoom]             = useState(1);
  const [pan, setPan]               = useState({ x: 0, y: 0 });
  const [spaceDown, setSpaceDown]   = useState(false);
  const spaceRef                    = useRef(false);
  const panDragRef                  = useRef(null);

  // ── Axis: controls frequency (length) + angle (direction) ──────────────────
  // axisLen 30→110 maps to frequency 60→15 lpi (longer arrow = coarser dots)
  const freqToLen = (f) => 30 + (1 - (Math.max(15, Math.min(60, f)) - 15) / 45) * 80;
  const lenToFreq = (l) => Math.round(60 - (Math.max(30, Math.min(110, l)) - 30) / 80 * 45);
  const [axisLen, setAxisLen]         = useState(() => Math.round(freqToLen(prefs.frequency ?? 30)));
  const [canvasCursor, setCanvasCursor] = useState("crosshair");
  const canvasRef = useRef(null);
  const dragRef   = useRef(null);

  // ── WebGL preview state ─────────────────────────────────────────────────────
  const glRef          = useRef(null);
  const glCanvasRef    = useRef(null);
  const previewAreaRef = useRef(null);
  const [glLoading, setGlLoading] = useState(false);
  const [glErr,     setGlErr]     = useState(null);
  const [showHotkeys, setShowHotkeys] = useState(false);
  const [applying, setApplying]       = useState(false);
  const [applyErr, setApplyErr]       = useState(null);
  const [applyStep, setApplyStep]     = useState(null);
  const [applyElapsed, setApplyElapsed] = useState(0);
  const [applyWithUpscale, setApplyWithUpscale] = useState(false);
  const applyTimerRef = useRef(null);

  // ── Save prefs whenever settings change ────────────────────────────────────
  useEffect(() => {
    try { localStorage.setItem(PREF_KEY, JSON.stringify({ pattern, frequency, angle, levelsBlack, levelsMid, levelsWhite, outputMin, outputMax, shadowBoost, koEnabled, koColor, koTol, shirtColor, cleanup, defringe, applyHalftone, alphaThreshEnabled, alphaThreshVal })); } catch {}
  }, [pattern, frequency, angle, levelsBlack, levelsMid, levelsWhite, outputMin, outputMax, shadowBoost, koEnabled, koColor, koTol, shirtColor, cleanup, defringe, applyHalftone, alphaThreshEnabled, alphaThreshVal]);

  const halftoneParams = useCallback(() => ({
    pattern, frequency: Number(frequency), angle: Number(angle),
    levelsBlack: Number(levelsBlack), levelsMid: Number(levelsMid), levelsWhite: Number(levelsWhite),
    outputMin: Number(outputMin), outputMax: Number(outputMax), shadowBoost: Number(shadowBoost),
    knockoutEnabled: koEnabled, knockoutColor: koColor, knockoutTol: Number(koTol),
    halftoneEnabled: applyHalftone,
    alphaThreshEnabled, alphaThreshVal: Number(alphaThreshVal),
    shirtColor, cleanup, defringe, view, targetWidthCm: Number(targetWidthCm), targetHeightCm: Number(targetHeightCm) || 0,
  }), [pattern, frequency, angle, levelsBlack, levelsMid, levelsWhite, outputMin, outputMax, shadowBoost, koEnabled, koColor, koTol, shirtColor, cleanup, defringe, applyHalftone, alphaThreshEnabled, alphaThreshVal, view, targetWidthCm, targetHeightCm]);

  const renderGL = useCallback(() => {
    if (!glRef.current) return;
    // In compare mode the canvas shows the shirt view; the original is a separate <img>
    const p = halftoneParams();
    glRef.current.render(compareMode ? { ...p, view: 'shirt' } : p);
  }, [halftoneParams, compareMode]);

  // Load source image once on edit phase enter; destroy GL context on exit
  useEffect(() => {
    if (phase !== "edit") return;
    let cancelled = false;
    setGlLoading(true); setGlErr(null);
    (async () => {
      try {
        const r = await fetch(`${previewBase}/halftone-source?fileId=${encodeURIComponent(file.id)}`);
        if (!r.ok) throw new Error("HTTP " + r.status);
        const blob = await r.blob();
        if (cancelled) return;
        const bmp = await createImageBitmap(blob, { premultiplyAlpha: 'none', colorSpaceConversion: 'none' });
        if (cancelled) return;
        const MAX = 4096;
        const scale = Math.min(1, MAX / Math.max(bmp.width, bmp.height));
        const cW = Math.round(bmp.width * scale), cH = Math.round(bmp.height * scale);
        let imgSrc = bmp;
        if (scale < 1) {
          const tmp = document.createElement("canvas");
          tmp.width = cW; tmp.height = cH;
          tmp.getContext("2d").drawImage(bmp, 0, 0, cW, cH);
          imgSrc = tmp;
        }
        const cvs = glCanvasRef.current;
        if (!cvs || cancelled) return;
        cvs.width = cW; cvs.height = cH;
        if (glRef.current) glRef.current.destroy();
        glRef.current = makeHalftoneGL(cvs, imgSrc, bmp.width, bmp.height);
        if (!glRef.current) throw new Error("WebGL not supported in this browser");
        setGlLoading(false);
        if (!cancelled) glRef.current.render(halftoneParams());
      } catch (e) { if (!cancelled) { setGlErr(e.message); setGlLoading(false); } }
    })();
    return () => {
      cancelled = true;
      if (glRef.current) { glRef.current.destroy(); glRef.current = null; }
    };
  }, [phase]);

  // Re-render on every param change (instant — just updates uniforms + drawArrays)
  useEffect(() => {
    if (phase !== "edit") return;
    renderGL();
  }, [renderGL, phase]);

  // ── Keyboard shortcuts (Space pan, Cmd+0 fit, Cmd+1 1:1) ───────────────────
  useEffect(() => {
    if (phase !== "edit") return;
    const onDown = (e) => {
      const meta = e.metaKey || e.ctrlKey;
      // Cmd+0 — fit to area
      if (meta && e.key === "0") {
        e.preventDefault();
        setZoom(1); setPan({ x: 0, y: 0 });
        return;
      }
      // Cmd+1 — 1:1 pixels (actual canvas resolution)
      if (meta && e.key === "1") {
        e.preventDefault();
        const cvs = glCanvasRef.current;
        const area = previewAreaRef.current;
        if (cvs && area) {
          const fitScale = Math.min(area.clientWidth / cvs.width, area.clientHeight / cvs.height, 1);
          setZoom(Math.min(8, +(1 / fitScale).toFixed(2)));
          setPan({ x: 0, y: 0 });
        }
        return;
      }
      if (e.code !== "Space") return;
      if (["INPUT","TEXTAREA","SELECT"].includes(e.target.tagName)) return;
      e.preventDefault();
      spaceRef.current = true; setSpaceDown(true);
    };
    const onUp = (e) => {
      if (e.code !== "Space") return;
      spaceRef.current = false; setSpaceDown(false); panDragRef.current = null;
    };
    window.addEventListener("keydown", onDown); window.addEventListener("keyup", onUp);
    return () => { window.removeEventListener("keydown", onDown); window.removeEventListener("keyup", onUp); };
  }, [phase]);

  const onPreviewAreaMouseDown = useCallback((e) => {
    if (!spaceRef.current) return;
    e.preventDefault(); e.stopPropagation();
    panDragRef.current = { sx: e.clientX, sy: e.clientY, px: pan.x, py: pan.y };
  }, [pan]);

  const onPreviewAreaMouseMove = useCallback((e) => {
    if (!panDragRef.current) return;
    setPan({ x: panDragRef.current.px + (e.clientX - panDragRef.current.sx),
              y: panDragRef.current.py + (e.clientY - panDragRef.current.sy) });
  }, []);

  const onPreviewAreaMouseUp = useCallback(() => { panDragRef.current = null; }, []);

  // ── Canvas draw: positive arrows only (angle=direction, len=frequency) ──────
  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas || phase !== "edit") return;
    const ctx = canvas.getContext("2d");
    const W = 180, H = 180, cx = W / 2, cy = H / 2;
    ctx.clearRect(0, 0, W, H);
    // Translucent circle background
    ctx.fillStyle = "rgba(10,10,25,0.58)";
    ctx.beginPath(); ctx.arc(cx, cy, cx - 1, 0, Math.PI * 2); ctx.fill();

    const rad  = (angle * Math.PI) / 180;
    const perp = rad + Math.PI / 2;
    const len  = axisLen * 0.72;

    // Subtle dashed rotation ring
    ctx.globalAlpha = 0.18; ctx.strokeStyle = "#18f4af"; ctx.lineWidth = 1.5;
    ctx.setLineDash([4, 6]);
    ctx.beginPath(); ctx.arc(cx, cy, len + 18, 0, Math.PI * 2); ctx.stroke();
    ctx.setLineDash([]);
    ctx.globalAlpha = 1;

    const drawArrow = (r, alpha) => {
      const x2 = cx + Math.cos(r) * len, y2 = cy + Math.sin(r) * len;
      ctx.globalAlpha = alpha;
      // Shaft
      ctx.strokeStyle = "#18f4af"; ctx.lineWidth = 2.5;
      ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(x2, y2); ctx.stroke();
      // Arrowhead
      const hl = 11, ha = 0.55;
      ctx.beginPath(); ctx.moveTo(x2, y2);
      ctx.lineTo(x2 - hl * Math.cos(r - ha), y2 - hl * Math.sin(r - ha));
      ctx.lineTo(x2 - hl * Math.cos(r + ha), y2 - hl * Math.sin(r + ha));
      ctx.closePath(); ctx.fillStyle = "#18f4af"; ctx.fill();
      // Draggable tip circle
      ctx.beginPath(); ctx.arc(x2, y2, 8, 0, Math.PI * 2);
      ctx.fillStyle = "#18f4af33"; ctx.fill();
      ctx.strokeStyle = "#18f4af"; ctx.lineWidth = 1.5; ctx.stroke();
      ctx.globalAlpha = 1;
    };
    drawArrow(rad, 1.0);
    drawArrow(perp, 0.45);

    // Centre rotation handle
    ctx.fillStyle = "#fff"; ctx.beginPath(); ctx.arc(cx, cy, 5, 0, Math.PI * 2); ctx.fill();
    ctx.strokeStyle = "#18f4af44"; ctx.lineWidth = 2;
    ctx.beginPath(); ctx.arc(cx, cy, 10, 0, Math.PI * 2); ctx.stroke();

    // Labels
    ctx.globalAlpha = 0.85; ctx.fillStyle = "#18f4af";
    ctx.font = "bold 11px monospace"; ctx.textAlign = "center"; ctx.textBaseline = "middle";
    const lx = cx + Math.cos(rad) * (len + 22), ly = cy + Math.sin(rad) * (len + 22);
    const clampedLx = Math.max(22, Math.min(W - 22, lx)), clampedLy = Math.max(10, Math.min(H - 10, ly));
    ctx.fillText(frequency + " lpi", clampedLx, clampedLy);
    ctx.globalAlpha = 0.5; ctx.font = "10px monospace";
    ctx.fillText(angle + "°", cx + Math.cos(rad) * (len * 0.45), cy + Math.sin(rad) * (len * 0.45) - 8);
    ctx.globalAlpha = 1;
  }, [angle, axisLen, frequency, phase]);

  const onCanvasMouseDown = useCallback((e) => {
    e.stopPropagation();
    const rect = canvasRef.current.getBoundingClientRect();
    const mx = e.clientX - rect.left - 90, my = e.clientY - rect.top - 90;
    const dist = Math.sqrt(mx * mx + my * my);
    if (dist > 87) return;
    // Detect if click is near either arrow tip (tip drag = angle + freq, else rotate only)
    const len = axisLen * 0.72;
    const rad = (angle * Math.PI) / 180;
    const perp = rad + Math.PI / 2;
    const nearTip = [rad, perp].some(r => {
      const dx = mx - Math.cos(r) * len, dy = my - Math.sin(r) * len;
      return Math.sqrt(dx * dx + dy * dy) < 20;
    });
    dragRef.current = nearTip ? "tip" : "rotate";
  }, [angle, axisLen]);

  const onCanvasMouseMove = useCallback((e) => {
    const rect = canvasRef.current.getBoundingClientRect();
    const mx = e.clientX - rect.left - 90, my = e.clientY - rect.top - 90;
    const dist = Math.sqrt(mx * mx + my * my);

    if (!dragRef.current) {
      // Hover cursor feedback
      if (dist > 87) { setCanvasCursor("default"); return; }
      const len = axisLen * 0.72;
      const rad = (angle * Math.PI) / 180;
      const perp = rad + Math.PI / 2;
      const nearTip = [rad, perp].some(r => {
        const dx = mx - Math.cos(r) * len, dy = my - Math.sin(r) * len;
        return Math.sqrt(dx * dx + dy * dy) < 20;
      });
      setCanvasCursor(nearTip ? "nesw-resize" : "grab");
      return;
    }

    if (dist < 5) return;
    const newAngle = (Math.atan2(my, mx) * 180 / Math.PI + 360) % 180;
    setAngle(Math.round(newAngle));
    if (dragRef.current === "tip") {
      const newLen = Math.max(30, Math.min(110, dist / 0.72));
      setAxisLen(Math.round(newLen));
      setFrequency(lenToFreq(newLen));
      setCanvasCursor("nesw-resize");
    } else {
      setCanvasCursor("grabbing");
    }
  }, [angle, axisLen]);

  const onCanvasMouseUp = useCallback(() => { dragRef.current = null; setCanvasCursor("crosshair"); }, []);

  const onPreviewWheel = useCallback((e) => {
    if (!e.metaKey && !e.ctrlKey) return;
    e.preventDefault();
    setZoom(z => Math.max(0.25, Math.min(8, +(z + (e.deltaY < 0 ? 0.25 : -0.25)).toFixed(2))));
  }, []);

  // ── Apply ───────────────────────────────────────────────────────────────────
  const handleApply = async () => {
    const tWpx = Math.round(targetWidthCm / 2.54 * 300);
    const tHpx = Math.round(targetHeightCm / 2.54 * 300);
    const needsUpscale = !!(file.width && file.height && (tWpx > file.width || tHpx > file.height));
    const upscaleScale = needsUpscale
      ? Math.min(4, Math.max(2, Math.ceil(Math.max(tWpx / file.width, tHpx / file.height))))
      : null;
    setApplyWithUpscale(needsUpscale);
    setApplying(true); setApplyErr(null); setApplyStep("queuing"); setApplyElapsed(0);
    try {
      const params = { ...halftoneParams(), view: "alpha" };
      if (needsUpscale) { params.upscaleBeforeHalftone = true; params.upscaleScale = upscaleScale; }
      const r = await fetch(`${previewBase}/process-artwork`, {
        method: "POST", headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ fileId: file.id, halftoneParams: params }),
      });
      if (!r.ok) { const t = await r.text().catch(() => ""); throw new Error(t || ("HTTP " + r.status)); }
      const { jobId } = await r.json();
      // Store in background queue and close immediately — artwork appears when done
      try {
        const q = JSON.parse(localStorage.getItem(HT_QUEUE_KEY) || "[]");
        q.push({ jobId, pollBase: previewBase, filename: file.filename || file.id, startedAt: Date.now(), upscale: needsUpscale });
        localStorage.setItem(HT_QUEUE_KEY, JSON.stringify(q.slice(-30)));
      } catch {}
      onQueued({ jobId, pollBase: previewBase, filename: file.filename || file.id, startedAt: Date.now(), upscale: needsUpscale });
    } catch (e) { setApplyErr(e.message); setApplying(false); }
  };

  const [chkDark, setChkDark] = useState(false);
  const previewBg = view === "shirt" ? shirtColor
    : view === "mask" ? "#ffffff"
    : view === "alpha" ? "transparent"
    : "#1a1a2e";
  const chkLight = "repeating-conic-gradient(#c8cdd4 0% 25%, #f0f2f5 0% 50%) 0/24px 24px";
  const chkDarkBg = "repeating-conic-gradient(#2a2a3a 0% 25%, #1a1a2e 0% 50%) 0/24px 24px";
  const previewAreaBg = view === "alpha" ? (chkDark ? chkDarkBg : chkLight) : previewBg;

  const SliderRow = (props) => <HtSliderRow {...props} />;

  // ── Size picker helpers ─────────────────────────────────────────────────────
  const SIZE_RECENT_KEY = "dtf-halftone-recent-sizes";
  const [recentSizes, setRecentSizes] = useState(() => {
    try { const r = JSON.parse(localStorage.getItem(SIZE_RECENT_KEY) || "[]"); return Array.isArray(r) ? r : []; } catch { return []; }
  });
  const defaultPresets = [20, 30, 40, 57];
  // Merge recent into presets (recent first, fill gaps with defaults, max 4)
  const sizePresets = [...new Set([...recentSizes, ...defaultPresets])].slice(0, 4);

  const applyPresetW = (w) => {
    setTargetWidthCm(w); setWInput(String(w));
    const r = ratio || (targetWidthCm > 0 && targetHeightCm > 0 ? targetWidthCm / targetHeightCm : null);
    if (linked && r) { const h = +(w / r).toFixed(2); setTargetHeightCm(h); setHInput(String(h)); }
  };
  const onWChange = (raw) => {
    setWInput(raw);
    const v = parseFloat(raw);
    if (!isNaN(v) && v > 0) {
      setTargetWidthCm(v);
      const r = ratio || (targetWidthCm > 0 && targetHeightCm > 0 ? targetWidthCm / targetHeightCm : null);
      if (linked && r) { const h = +(v / r).toFixed(2); setTargetHeightCm(h); setHInput(String(h)); }
    }
  };
  const onHChange = (raw) => {
    setHInput(raw);
    const v = parseFloat(raw);
    if (!isNaN(v) && v > 0) {
      setTargetHeightCm(v);
      const r = ratio || (targetWidthCm > 0 && targetHeightCm > 0 ? targetWidthCm / targetHeightCm : null);
      if (linked && r) { const w = +(v * r).toFixed(2); setTargetWidthCm(w); setWInput(String(w)); }
    }
  };
  const toggleLink = () => {
    if (!linked && targetWidthCm > 0 && targetHeightCm > 0) setRatio(targetWidthCm / targetHeightCm);
    setLinked(l => !l);
  };
  const confirmSize = () => {
    // Save used width to recents
    const w = Math.round(targetWidthCm * 10) / 10;
    const next = [w, ...recentSizes.filter(x => x !== w)].slice(0, 4);
    setRecentSizes(next);
    try { localStorage.setItem(SIZE_RECENT_KEY, JSON.stringify(next)); } catch {}
    setPhase("edit");
  };

  // ── Phase 1: Size picker ────────────────────────────────────────────────────
  if (phase === "size") {
    const wPx = Math.round(targetWidthCm / 2.54 * 300);
    const hPx = Math.round(targetHeightCm / 2.54 * 300);
    const sizNeedsUpscale = !!(file.width && file.height && (wPx > file.width || hPx > file.height));
    const sizUpscaleScale = sizNeedsUpscale
      ? Math.min(4, Math.max(2, Math.ceil(Math.max(wPx / file.width, hPx / file.height)))) : null;
    // Effective DPI: source DPI relative to the chosen target size
    const effectiveDpi = file.width && targetWidthCm > 0 ? Math.round(file.width / (targetWidthCm / 2.54)) : null;
    const dpiWarn = !sizNeedsUpscale && effectiveDpi !== null && effectiveDpi < 250;
    const inStyle = { width: "100%", padding: "7px 10px", background: "#222", border: "1px solid #444", color: "#fff", borderRadius: 6, fontSize: 14, boxSizing: "border-box" };
    return (
      <div style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.8)", zIndex: 9999, display: "flex", alignItems: "center", justifyContent: "center" }}>
        <div style={{ background: "#181824", borderRadius: 12, padding: 28, width: 360, boxShadow: "0 8px 40px #000a" }}>
          <div style={{ fontWeight: 700, fontSize: 16, color: "#fff", marginBottom: 4 }}>Set Output Size</div>
          {file.width && file.height && (
            <div style={{ fontSize: 11, color: "#555", marginBottom: 4 }}>
              Original: {file.width.toLocaleString()} × {file.height.toLocaleString()} px
              {" · "}{_origWcm} × {_origHcm} cm @ 300 DPI
            </div>
          )}
          <div style={{ fontSize: 12, color: "#666", marginBottom: 18, lineHeight: 1.5 }}>
            {sizNeedsUpscale
              ? "Target is larger than source — Real-ESRGAN will upscale first, then halftone."
              : "Source image will be scaled to this print size at 300 DPI before halftoning."}
          </div>
          {/* W × H inputs with link button */}
          <div style={{ display: "flex", alignItems: "stretch", gap: 0, marginBottom: 10 }}>
            <div style={{ flex: 1, display: "flex", flexDirection: "column", gap: 6 }}>
              <div>
                <div style={{ fontSize: 10, color: "#666", marginBottom: 3 }}>WIDTH (cm)</div>
                <input type="text" inputMode="decimal" value={wInput}
                  onChange={e => onWChange(e.target.value)}
                  onBlur={() => { if (!parseFloat(wInput)) { setWInput(String(targetWidthCm)); } }}
                  style={inStyle} />
              </div>
              <div>
                <div style={{ fontSize: 10, color: "#666", marginBottom: 3 }}>HEIGHT (cm)</div>
                <input type="text" inputMode="decimal" value={hInput}
                  onChange={e => onHChange(e.target.value)}
                  onBlur={() => { if (!parseFloat(hInput)) { setHInput(String(targetHeightCm)); } }}
                  style={inStyle} />
              </div>
            </div>
            {/* Link button */}
            <div style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", width: 36, gap: 0 }}>
              <div style={{ width: 1, flex: 1, background: linked ? "#18f4af44" : "#2a2a3a" }} />
              <button onClick={toggleLink} title={linked ? "Unlink dimensions" : "Link proportionally"}
                style={{ width: 28, height: 28, borderRadius: "50%", border: "1px solid " + (linked ? "#18f4af" : "#333"), background: linked ? "#18f4af22" : "#1e1e2e", color: linked ? "#18f4af" : "#555", fontSize: 13, cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
                {linked ? "⛓" : "✂"}
              </button>
              <div style={{ width: 1, flex: 1, background: linked ? "#18f4af44" : "#2a2a3a" }} />
            </div>
          </div>
          {/* Quick presets (recent-first, defaults fill gaps) */}
          <div style={{ fontSize: 10, color: "#555", marginBottom: 4 }}>Quick widths (cm)</div>
          <div style={{ display: "flex", gap: 4, marginBottom: 12 }}>
            {sizePresets.map(w => (
              <button key={w} onClick={() => applyPresetW(w)}
                style={{ flex: 1, padding: "5px 0", fontSize: 12, borderRadius: 4, border: "1px solid " + (Math.round(targetWidthCm * 10) === Math.round(w * 10) ? "#18f4af" : "#333"), background: Math.round(targetWidthCm * 10) === Math.round(w * 10) ? "#18f4af22" : "#1a1a2e", color: Math.round(targetWidthCm * 10) === Math.round(w * 10) ? "#18f4af" : "#888", cursor: "pointer" }}>
                {w}
              </button>
            ))}
          </div>
          <div style={{ fontSize: 11, marginBottom: 20 }}>
            <span style={{ color: "#3a3a4a" }}>Output: {wPx.toLocaleString()} × {hPx.toLocaleString()} px @ 300 DPI</span>
            {sizNeedsUpscale ? (
              <span style={{ marginLeft: 8, color: "#a78bfa" }}>⚡ will AI upscale {sizUpscaleScale}× → 300 DPI</span>
            ) : effectiveDpi !== null ? (
              <span style={{ marginLeft: 8, color: dpiWarn ? "#f59e0b" : "#18f4af" }}>
                {dpiWarn ? "⚠" : "✓"} source {effectiveDpi} DPI{dpiWarn ? " — consider upscaling first" : ""}
              </span>
            ) : null}
          </div>
          <div style={{ display: "flex", gap: 8 }}>
            <button onClick={onCancel} style={{ flex: 1, padding: "9px 0", background: "#222", border: "1px solid #444", color: "#bbb", borderRadius: 6, fontSize: 13, cursor: "pointer" }}>Cancel</button>
            <button onClick={confirmSize}
              style={{ flex: 2, padding: "9px 0", background: "#18f4af", border: "none", color: "#000", borderRadius: 6, fontSize: 13, fontWeight: 700, cursor: "pointer" }}>
              Continue →
            </button>
          </div>
        </div>
      </div>
    );
  }

  // ── Phase 2: Full-screen halftone editor ────────────────────────────────────
  return (
    <div style={{ position: "fixed", inset: 0, zIndex: 9999, display: "flex", flexDirection: "column", background: "#181824" }}>
      {/* Header */}
      <div style={{ display: "flex", alignItems: "center", padding: "10px 16px", borderBottom: "1px solid #2a2a3a", flexShrink: 0, gap: 8 }}>
        <button onClick={() => setPhase("size")}
          style={{ background: "#222", border: "1px solid #333", color: "#aaa", borderRadius: 6, padding: "4px 10px", fontSize: 12, cursor: "pointer" }}>← Back</button>
        <span style={{ fontWeight: 700, fontSize: 14, color: "#fff", flex: 1 }}>DTF Halftone Prep</span>
        <span style={{ fontSize: 11, color: "#555" }}>{targetWidthCm} × {targetHeightCm}cm @ 300 DPI · {file.filename || file.id}</span>
        <button onClick={onCancel} style={{ background: "none", border: "none", color: "#666", fontSize: 20, cursor: "pointer", padding: "0 4px", lineHeight: 1 }}>×</button>
      </div>

      {/* Body */}
      <div style={{ display: "flex", flex: 1, minHeight: 0, overflow: "hidden" }}>
        {/* ── Left settings panel ── */}
        <div style={{ width: 250, flexShrink: 0, display: "flex", flexDirection: "column", borderRight: "1px solid #2a2a3a", overflowY: "auto", overflowX: "hidden" }}>
          <div style={{ padding: "12px 12px", display: "flex", flexDirection: "column", gap: 8 }}>

            {/* View */}
            <div>
              <div style={{ fontSize: 10, color: "#555", marginBottom: 4, textTransform: "uppercase", letterSpacing: 1 }}>View</div>
              <div style={{ display: "flex", gap: 3, flexWrap: "wrap", marginBottom: compareMode ? 0 : 4 }}>
                {["alpha","shirt","mask","original"].map(v => (
                  <button key={v} onClick={() => { setView(v); setCompareMode(false); }}
                    style={{ padding: "2px 9px", fontSize: 11, borderRadius: 4, border: "1px solid " + (!compareMode && view === v ? "#18f4af" : "#333"), background: !compareMode && view === v ? "#18f4af22" : "transparent", color: !compareMode && view === v ? "#18f4af" : "#888", cursor: "pointer" }}>
                    {v}
                  </button>
                ))}
              </div>
              <label style={{ display: "flex", alignItems: "center", gap: 6, marginTop: 5, cursor: "pointer" }}>
                <input type="checkbox" checked={compareMode} onChange={e => setCompareMode(e.target.checked)} style={{ accentColor: "#18f4af" }} />
                <span style={{ fontSize: 11, color: compareMode ? "#18f4af" : "#888" }}>Compare: shirt vs original</span>
              </label>
            </div>

            {/* Shirt color */}
            <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
              <span style={{ fontSize: 11, color: "#777", width: 72, flexShrink: 0 }}>Shirt color</span>
              <input type="color" value={shirtColor} onChange={e => setShirtColor(e.target.value)}
                style={{ width: 28, height: 22, border: "none", background: "none", cursor: "pointer", padding: 0 }} />
              <span style={{ fontSize: 11, color: "#555" }}>{shirtColor}</span>
            </div>

            {/* Edit Halftones */}
            <div style={{ border: "1px solid #2a2a3a", borderRadius: 6, overflow: "hidden" }}>
              <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", background: "#1e1e2e", padding: "6px 10px" }}>
                <span style={{ fontSize: 12, color: "#bbb" }}>Edit Halftones</span>
                <button onClick={() => {
                  setLevelsBlack(7); setLevelsMid(1.12); setLevelsWhite(100);
                  setOutputMin(0); setOutputMax(255); setShadowBoost(0);
                  setFrequency(30); setAngle(33); setAxisLen(Math.round(freqToLen(30)));
                }} title="Reset to DTF defaults (matches DTPrep: Black=7, White=100, Midtone=1.12)"
                  style={{ padding: "3px 8px", background: "none", border: "1px solid #2a2a3a", color: "#555", fontSize: 10, cursor: "pointer", whiteSpace: "nowrap", borderRadius: 4 }}>
                  Reset defaults
                </button>
              </div>
              <div style={{ padding: "10px 10px 6px", display: "flex", flexDirection: "column", gap: 7, background: "#13131d" }}>
                <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
                  <span style={{ fontSize: 11, color: "#888", width: 60, flexShrink: 0 }}>Pattern</span>
                  <select value={pattern} onChange={e => setPattern(e.target.value)}
                    style={{ flex: 1, background: "#222", color: "#fff", border: "1px solid #333", borderRadius: 4, padding: "3px 6px", fontSize: 12 }}>
                    <option value="round">Round</option>
                    <option value="line">Line</option>
                  </select>
                </div>
                <SliderRow label="Frequency (lpi)" value={frequency} min={15} max={60}
                  setter={f => { const v = Math.round(f); setFrequency(v); setAxisLen(Math.round(freqToLen(v))); }} />
                <SliderRow label="Angle (°)" value={angle} min={0} max={179} setter={v => setAngle(Math.round(v))} />
              </div>
            </div>

            {/* Levels */}
            <div style={{ border: "1px solid #2a2a3a", borderRadius: 6, padding: "8px 10px 6px", background: "#13131d" }}>
              <div style={{ fontSize: 10, color: "#555", marginBottom: 5, textTransform: "uppercase", letterSpacing: 1 }}>Levels</div>
              <SliderRow label="Black (0-255)" value={levelsBlack} min={0} max={254} setter={v => setLevelsBlack(Math.round(v))} />
              <SliderRow label="Midtone (γ)" value={levelsMid} min={0.1} max={3} step={0.05} setter={setLevelsMid} fmtFn={v => Number(v).toFixed(2)} />
              <SliderRow label="White (0-255)" value={levelsWhite} min={1} max={255} setter={v => setLevelsWhite(Math.round(v))} />
              <div style={{ height: 1, background: "#2a2a3a", margin: "5px 0" }} />
              <div style={{ fontSize: 10, color: "#444", marginBottom: 3 }}>Output levels</div>
              <SliderRow label="Out min" value={outputMin} min={0} max={254} setter={setOutputMin} />
              <SliderRow label="Out max" value={outputMax} min={1} max={255} setter={setOutputMax} />
              <SliderRow label="Shadow boost" value={shadowBoost} min={0} max={100} setter={setShadowBoost} />
            </div>

            {/* Semi-transparent pixel removal */}
            <div style={{ border: "1px solid #2a2a3a", borderRadius: 6, padding: "8px 10px 6px", background: "#13131d" }}>
              <div style={{ display: "flex", alignItems: "center", gap: 7, marginBottom: alphaThreshEnabled ? 6 : 0 }}>
                <input type="checkbox" id="at-en" checked={alphaThreshEnabled} onChange={e => {
                  const v = e.target.checked;
                  setAlphaThreshEnabled(v);
                  if (glRef.current) glRef.current.render({ ...halftoneParams(), alphaThreshEnabled: v });
                }} style={{ accentColor: "#18f4af" }} />
                <label htmlFor="at-en" style={{ fontSize: 12, color: "#bbb", cursor: "pointer" }}>Remove semi-transparent pixels</label>
              </div>
              {alphaThreshEnabled && (
                <>
                  <div style={{ fontSize: 10, color: "#f59e0b", marginBottom: 5, lineHeight: 1.4 }}>
                    Semi-transparent pixels print as faint white dots on dark DTF garments.
                    Pixels above the threshold become fully opaque; below become transparent.
                  </div>
                  <SliderRow label="Threshold %" value={alphaThreshVal} min={1} max={99} setter={v => { const val = Math.round(v); setAlphaThreshVal(val); if (glRef.current) glRef.current.render({ ...halftoneParams(), alphaThreshVal: val }); }} />
                </>
              )}
            </div>

            {/* Apply Halftone + Color Knockout */}
            <div style={{ border: "1px solid #2a2a3a", borderRadius: 6, padding: "8px 10px 6px", background: "#13131d" }}>
              <div style={{ display: "flex", alignItems: "center", gap: 7, marginBottom: 7 }}>
                <input type="checkbox" id="ht-apply" checked={applyHalftone} onChange={e => setApplyHalftone(e.target.checked)} style={{ accentColor: "#18f4af" }} />
                <label htmlFor="ht-apply" style={{ fontSize: 12, color: "#bbb", cursor: "pointer", fontWeight: 600 }}>Apply Halftone</label>
                <span style={{ marginLeft: "auto", fontSize: 10, color: "#555" }}>uncheck for bg-remove only</span>
              </div>
              <div style={{ height: 1, background: "#2a2a3a", margin: "0 0 7px" }} />
              <div style={{ display: "flex", alignItems: "center", gap: 7, marginBottom: 5 }}>
                <input type="checkbox" id="ko-en" checked={koEnabled} onChange={e => setKoEnabled(e.target.checked)} style={{ accentColor: "#18f4af" }} />
                <label htmlFor="ko-en" style={{ fontSize: 12, color: "#bbb", cursor: "pointer" }}>Color Knockout</label>
              </div>
              {koEnabled && (
                <>
                  <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 5 }}>
                    <span style={{ fontSize: 11, color: "#888", width: 60, flexShrink: 0 }}>BG color</span>
                    <input type="color" value={koColor} onChange={e => setKoColor(e.target.value)}
                      style={{ width: 28, height: 22, border: "none", background: "none", cursor: "pointer", padding: 0 }} />
                    <span style={{ fontSize: 11, color: "#555" }}>{koColor}</span>
                  </div>
                  <SliderRow label="Tolerance" value={koTol} min={0} max={100} setter={setKoTol} />
                </>
              )}
            </div>

            {/* Post Cleanup */}
            <div style={{ border: "1px solid #2a2a3a", borderRadius: 6, padding: "8px 10px 6px", background: "#13131d" }}>
              <div style={{ fontSize: 10, color: "#555", marginBottom: 5, textTransform: "uppercase", letterSpacing: 1 }}>Post Cleanup</div>
              <div style={{ display: "flex", gap: 3, marginBottom: 7 }}>
                {["none","standard","high"].map(v => (
                  <button key={v} onClick={() => setCleanup(v)}
                    style={{ flex: 1, padding: "3px 0", fontSize: 11, borderRadius: 4, border: "1px solid " + (cleanup === v ? "#18f4af" : "#333"), background: cleanup === v ? "#18f4af22" : "transparent", color: cleanup === v ? "#18f4af" : "#888", cursor: "pointer" }}>
                    {v.charAt(0).toUpperCase() + v.slice(1)}
                  </button>
                ))}
              </div>
              <div style={{ display: "flex", alignItems: "center", gap: 7 }}>
                <input type="checkbox" id="defringe" checked={defringe} onChange={e => setDefringe(e.target.checked)} style={{ accentColor: "#18f4af" }} />
                <label htmlFor="defringe" style={{ fontSize: 12, color: "#bbb", cursor: "pointer" }}>De-Fringe Edges</label>
              </div>
            </div>

            {/* Axis toggle */}
            <div style={{ display: "flex", alignItems: "center", gap: 7 }}>
              <input type="checkbox" id="show-axis" checked={showAxis} onChange={e => setShowAxis(e.target.checked)} style={{ accentColor: "#18f4af" }} />
              <label htmlFor="show-axis" style={{ fontSize: 12, color: "#888", cursor: "pointer" }}>Show axis overlay</label>
            </div>
          </div>

          {/* Sticky apply buttons at bottom */}
          <div style={{ padding: "10px 12px", borderTop: "1px solid #2a2a3a", marginTop: "auto" }}>
            {applying ? (
              <div style={{ display: "flex", alignItems: "center", gap: 10, padding: "8px 0" }}>
                <span style={{ display: "inline-block", width: 14, height: 14, border: "2px solid #18f4af", borderTopColor: "transparent", borderRadius: "50%", animation: "spin 0.8s linear infinite", flexShrink: 0 }} />
                <span style={{ fontSize: 13, color: "#18f4af" }}>Submitting to queue…</span>
              </div>
            ) : (
              <div style={{ display: "flex", gap: 8 }}>
                <button onClick={onCancel} style={{ flex: 1, padding: "8px 0", background: "#222", border: "1px solid #444", color: "#bbb", borderRadius: 6, fontSize: 13, cursor: "pointer" }}>Cancel</button>
                {!glLoading && !glErr && (
                  <button onClick={handleApply}
                    style={{ flex: 2, padding: "8px 0", background: "#18f4af", border: "none", color: "#000", borderRadius: 6, fontSize: 13, fontWeight: 700, cursor: "pointer" }}>
                    Apply & Save
                  </button>
                )}
              </div>
            )}
            {applyErr && <div style={{ fontSize: 11, color: "#f87171", marginTop: 6 }}>{applyErr}</div>}
          </div>
        </div>

        {/* ── Right: preview area ── */}
        <div ref={previewAreaRef} style={{ flex: 1, minWidth: 0, position: "relative", background: previewAreaBg, overflow: "hidden", display: "flex", alignItems: "center", justifyContent: "center",
          cursor: spaceDown ? (panDragRef.current ? "grabbing" : "grab") : "default" }}
          onWheel={onPreviewWheel}
          onMouseDown={onPreviewAreaMouseDown}
          onMouseMove={onPreviewAreaMouseMove}
          onMouseUp={onPreviewAreaMouseUp}
          onMouseLeave={onPreviewAreaMouseUp}>

          {/* Zoom controls */}
          <div style={{ position: "absolute", bottom: 14, right: 14, zIndex: 10, display: "flex", gap: 6, alignItems: "center" }}>
            {view === "alpha" && (
              <div style={{ display: "flex", background: "rgba(0,0,0,0.55)", borderRadius: 7, overflow: "hidden", border: "1px solid #333" }}>
                <button onClick={() => setChkDark(false)}
                  style={{ padding: "3px 10px", fontSize: 11, border: "none", cursor: "pointer", background: !chkDark ? "#f0f2f5" : "transparent", color: !chkDark ? "#222" : "#888" }}>Light</button>
                <button onClick={() => setChkDark(true)}
                  style={{ padding: "3px 10px", fontSize: 11, border: "none", cursor: "pointer", background: chkDark ? "#2a2a3a" : "transparent", color: chkDark ? "#ccc" : "#888" }}>Dark</button>
              </div>
            )}
            <div style={{ position: "relative" }}>
              {showHotkeys && (
                <div style={{ position: "absolute", bottom: "calc(100% + 8px)", right: 0, background: "#1a1a2e", border: "1px solid #2a2a3a", borderRadius: 8, padding: "10px 14px", minWidth: 240, zIndex: 20, boxShadow: "0 4px 20px rgba(0,0,0,0.6)" }}>
                  <div style={{ fontSize: 11, color: "#18f4af", fontWeight: 600, marginBottom: 8 }}>Keyboard shortcuts</div>
                  {[
                    [modKey+"+0",       "Fit image to area"],
                    [modKey+"+1",       "1:1 pixels (actual size)"],
                    [modKey+"+scroll",  "Zoom in / out"],
                    ["Space+drag",      "Pan image"],
                    ["Axis centre",     "Drag to rotate angle"],
                    ["Axis tip",        "Drag to change frequency"],
                  ].map(([key, desc]) => (
                    <div key={key} style={{ display: "flex", justifyContent: "space-between", gap: 16, marginBottom: 5 }}>
                      <span style={{ fontSize: 11, color: "#aaa" }}>{desc}</span>
                      <kbd style={{ fontSize: 10, color: "#18f4af", background: "rgba(24,244,175,0.08)", border: "1px solid #18f4af33", borderRadius: 3, padding: "1px 5px", whiteSpace: "nowrap", flexShrink: 0 }}>{key}</kbd>
                    </div>
                  ))}
                </div>
              )}
              <button onClick={() => setShowHotkeys(h => !h)}
                title="Keyboard shortcuts"
                style={{ background: showHotkeys ? "rgba(24,244,175,0.15)" : "rgba(0,0,0,0.55)", border: "1px solid #333", color: showHotkeys ? "#18f4af" : "#666", fontSize: 12, cursor: "pointer", padding: "3px 7px", borderRadius: 7, lineHeight: 1 }}>?</button>
            </div>
            <div style={{ display: "flex", gap: 4, alignItems: "center", background: "rgba(0,0,0,0.55)", borderRadius: 7, padding: "4px 8px" }}>
              <button onClick={() => setZoom(z => Math.max(0.25, +(z - 0.25).toFixed(2)))}
                style={{ background: "none", border: "none", color: "#ccc", fontSize: 18, cursor: "pointer", lineHeight: 1, padding: "0 3px" }} title={`Zoom out (${modKey}+scroll)`}>−</button>
              <span style={{ fontSize: 11, color: "#aaa", minWidth: 38, textAlign: "center" }}>{Math.round(zoom * 100)}%</span>
              <button onClick={() => setZoom(z => Math.min(8, +(z + 0.25).toFixed(2)))}
                style={{ background: "none", border: "none", color: "#ccc", fontSize: 18, cursor: "pointer", lineHeight: 1, padding: "0 3px" }} title={`Zoom in (${modKey}+scroll)`}>+</button>
              <button onClick={() => { setZoom(1); setPan({ x: 0, y: 0 }); }}
                style={{ background: "none", border: "1px solid #333", color: "#666", fontSize: 10, cursor: "pointer", padding: "1px 5px", borderRadius: 3, marginLeft: 2 }} title={`Fit to area (${modKey}+0)`}>Fit</button>
              <button onClick={() => {
                const cvs = glCanvasRef.current; const area = previewAreaRef.current;
                if (cvs && area) { const s = Math.min(area.clientWidth/cvs.width, area.clientHeight/cvs.height, 1); setZoom(Math.min(8, +(1/s).toFixed(2))); setPan({x:0,y:0}); }
              }} style={{ background: "none", border: "1px solid #333", color: "#666", fontSize: 10, cursor: "pointer", padding: "1px 5px", borderRadius: 3 }} title={`Actual pixels (${modKey}+1)`}>1px</button>
            </div>
          </div>

          {/* Loading overlay */}
          {glLoading && (
            <div style={{ position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center", background: "rgba(0,0,0,0.35)", zIndex: 6, pointerEvents: "none" }}>
              <span style={{ color: "#18f4af", fontSize: 13 }}>Loading image…</span>
            </div>
          )}

          {/* WebGL canvas (pans + zooms); in compare mode sits next to the original img */}
          <div style={{ display: compareMode ? "flex" : "inline-block", gap: compareMode ? 6 : 0, alignItems: "flex-start", transform: `translate(${pan.x}px, ${pan.y}px)` }}>
            {/* Shirt (or current view) — WebGL canvas */}
            <div style={{ position: "relative", display: "inline-block" }}>
              {compareMode && !glLoading && !glErr && (
                <div style={{ position: "absolute", top: 6, left: 0, right: 0, textAlign: "center", fontSize: 11, fontWeight: 600, color: "rgba(255,255,255,0.75)", pointerEvents: "none", textShadow: "0 1px 4px rgba(0,0,0,0.9)", zIndex: 2, letterSpacing: 0.5 }}>Shirt</div>
              )}
              <canvas ref={glCanvasRef}
                style={{ display: "block",
                  maxWidth: compareMode ? "calc((100vw - 262px) / 2)" : "calc(100vw - 250px)",
                  maxHeight: "calc(100vh - 56px)",
                  transform: `scale(${zoom})`, transformOrigin: "center", userSelect: "none", pointerEvents: "none",
                  visibility: glLoading || glErr ? "hidden" : "visible" }} />
            </div>
            {/* Original — plain img, only in compare mode */}
            {compareMode && !glLoading && !glErr && (
              <div style={{ position: "relative", display: "inline-block" }}>
                <div style={{ position: "absolute", top: 6, left: 0, right: 0, textAlign: "center", fontSize: 11, fontWeight: 600, color: "rgba(255,255,255,0.75)", pointerEvents: "none", textShadow: "0 1px 4px rgba(0,0,0,0.9)", zIndex: 2, letterSpacing: 0.5 }}>Original</div>
                <img
                  src={`${previewBase}/halftone-source?fileId=${encodeURIComponent(file.id)}`}
                  style={{ display: "block",
                    maxWidth: "calc((100vw - 262px) / 2)",
                    maxHeight: "calc(100vh - 56px)",
                    transform: `scale(${zoom})`, transformOrigin: "center",
                    userSelect: "none", pointerEvents: "none" }}
                  alt=""
                />
              </div>
            )}
          </div>

          {/* Axis overlay — always centred in the preview area, independent of pan/zoom */}
          {showAxis && !glLoading && !glErr && (
            <div style={{ position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center", pointerEvents: "none", zIndex: 5 }}>
              <canvas ref={canvasRef} width={180} height={180}
                style={{ cursor: canvasCursor, pointerEvents: "all", borderRadius: "50%", opacity: 0.92 }}
                onMouseDown={onCanvasMouseDown} onMouseMove={onCanvasMouseMove}
                onMouseUp={onCanvasMouseUp} onMouseLeave={onCanvasMouseUp} />
            </div>
          )}
          {glErr && (
            <div style={{ position: "absolute", inset: 0, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 10 }}>
              <div style={{ color: "#f87171", fontSize: 13 }}>{glErr}</div>
              <button onClick={() => { setGlErr(null); setGlLoading(false); }}
                style={{ padding: "6px 16px", background: "#222", border: "1px solid #444", color: "#ccc", borderRadius: 6, fontSize: 12, cursor: "pointer" }}>Retry</button>
            </div>
          )}
        </div>
      </div>
    </div>
  );
}

// Type text, pick a font from the global library + a colour, set the print size (cm),
// and render a print-ready PNG (server, 300 DPI) attached to the owner's artwork.
// Live preview draws the text in the actual font via the FontFace API, mirroring the
// server's stretch/fit scaling. `owner` is { type:'order'|'draft', id }.
function TextArtworkBuilder({ owner, onCancel, onDone }) {
  const [fonts, setFonts] = useState(null);
  const [text, setText] = useState("");
  const [fontId, setFontId] = useState("");
  const [color, setColor] = useState("#680000");
  const [widthCm, setWidthCm] = useState(20);
  const [heightCm, setHeightCm] = useState(10);
  const [mode, setMode] = useState("stretch");
  const [bulk, setBulk] = useState(false);
  const [prog, setProg] = useState(null);   // { done, total } while bulk-generating
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState("");
  const [ready, setReady] = useState(false);
  const canvasRef = React.useRef(null);

  useEffect(() => { DTF.listFonts().then(fs => { setFonts(fs); if (fs && fs[0]) setFontId(fs[0].id); }); }, []);
  const font = (fonts || []).find(f => f.id === fontId);
  const family = font ? "dtf-font-" + font.id : "";
  // In bulk mode each non-empty line becomes its own artwork; the preview shows the first.
  const lines = text.split("\n").map(s => s.trim()).filter(Boolean);
  const previewText = bulk ? (lines[0] || "") : text;
  const needW = mode !== "height";   // width is used unless we're pinning height
  const needH = mode !== "width";    // height is used unless we're pinning width

  useEffect(() => {
    if (!font) { setReady(false); return; }
    let dead = false; setReady(false);
    const ff = new FontFace(family, "url(" + DTF.fontFileUrl(font.id) + ")");
    ff.load().then(f => { if (dead) return; document.fonts.add(f); setReady(true); }).catch(() => { if (!dead) setErr("Couldn't load that font for preview"); });
    return () => { dead = true; };
  }, [fontId]);

  useEffect(() => {
    const cv = canvasRef.current; if (!cv) return;
    const DISP = 560;
    const ctx = cv.getContext("2d");
    if (!ready || !previewText.trim()) { cv.width = DISP; cv.height = 120; ctx.clearRect(0, 0, DISP, 120); return; }
    const base = 200;
    // Measure first (canvas resize below resets context state, so re-set the font after).
    ctx.font = base + 'px "' + family + '"';
    ctx.textBaseline = "alphabetic";
    const plines = bulk ? [previewText] : (previewText.split("\n").filter(l => l.length));
    if (!plines.length) plines.push(previewText);
    const ms = plines.map(ln => ctx.measureText(ln));
    const tw = Math.max(1, ...ms.map(m => (m.actualBoundingBoxLeft || 0) + (m.actualBoundingBoxRight || m.width)));
    const asc = Math.max(...ms.map(m => m.actualBoundingBoxAscent || base * 0.8));
    const desc = Math.max(...ms.map(m => m.actualBoundingBoxDescent || base * 0.2));
    const lineH = asc + desc, gap = base * 0.15;
    const th = Math.max(1, lineH * plines.length + gap * (plines.length - 1));
    const w = Number(widthCm) || 1, h = Number(heightCm) || 1;
    let bw, bh, sx, sy;
    if (mode === "stretch") { bw = DISP; bh = Math.max(40, Math.min(340, Math.round(DISP * h / w))); sx = bw / tw; sy = bh / th; }
    else if (mode === "fit") { bw = DISP; bh = Math.max(40, Math.min(340, Math.round(DISP * h / w))); sx = sy = Math.min(bw / tw, bh / th); }
    else { bw = DISP; bh = Math.max(40, Math.min(340, Math.round(DISP * th / tw))); sx = sy = bw / tw; } // width/height → natural aspect
    cv.width = bw; cv.height = bh;
    ctx.clearRect(0, 0, bw, bh);
    ctx.font = base + 'px "' + family + '"';       // re-apply: resizing the canvas cleared it
    ctx.fillStyle = color; ctx.textAlign = "center"; ctx.textBaseline = "alphabetic";
    ctx.save();
    ctx.translate((bw - tw * sx) / 2, (bh - th * sy) / 2);
    ctx.scale(sx, sy);
    let y = asc;
    for (const ln of plines) { ctx.fillText(ln, tw / 2, y); y += lineH + gap; }
    ctx.restore();
  }, [previewText, color, widthCm, heightCm, mode, ready, family, bulk]);

  async function create() {
    if (busy) return;
    if (!font) { setErr("Pick a font"); return; }
    const items = bulk ? lines : (text.trim() ? [text] : []);
    if (!items.length) { setErr(bulk ? "Add at least one line" : "Type some text"); return; }
    setBusy(true); setErr(""); setProg(bulk ? { done: 0, total: items.length } : null);
    const opts = { fontId: font.id, color, widthCm: Number(widthCm) || 10, heightCm: Number(heightCm) || 10, mode };
    try {
      for (let i = 0; i < items.length; i++) {
        await DTF.createTextArtwork(owner, { ...opts, text: items[i] });
        if (bulk) setProg({ done: i + 1, total: items.length });
      }
      onDone && onDone();
    } catch (e) { setErr(e.message || "Couldn't create the text artwork"); setBusy(false); setProg(null); }
  }

  const lbl = { display: "flex", flexDirection: "column", fontSize: 11.5, gap: 3 };
  return (
    <div className="modal-overlay" onClick={onCancel}>
      <div className="modal" onClick={e => e.stopPropagation()} style={{ maxWidth: 640 }}>
        <div className="modal__head"><h3>Text artwork</h3><button className="icon-btn" onClick={onCancel}><Icon name="x" /></button></div>
        <div className="modal__body">
          {fonts && fonts.length === 0 && (
            <div className="form-err" style={{ marginBottom: 10 }}>No fonts yet — add one in <a href="#/settings" onClick={onCancel}>Settings → Fonts</a>, then reopen this.</div>
          )}
          <div style={{ borderRadius: 8, border: "1px solid var(--line,#dfe4ea)", background: "repeating-conic-gradient(#e9edf2 0% 25%, #fff 0% 50%) 50%/20px 20px", display: "flex", justifyContent: "center", alignItems: "center", padding: 8, minHeight: 120, marginBottom: 12, overflow: "hidden" }}>
            <canvas ref={canvasRef} style={{ maxWidth: "100%", maxHeight: 340 }} />
          </div>
          <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 4 }}>
            <label style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 12, cursor: "pointer" }} title="Turn each line into its own artwork file (names/numbers list).">
              <input type="checkbox" checked={bulk} onChange={e => setBulk(e.target.checked)} /> One artwork per line
            </label>
            {bulk && <span className="muted" style={{ fontSize: 12 }}>{lines.length} item{lines.length === 1 ? "" : "s"}</span>}
          </div>
          <textarea value={text} onChange={e => setText(e.target.value)} placeholder={bulk ? "One name/number per line, e.g.\nIzzy\nFiona & Tess\nToby & Hercules" : "Type text (e.g. Aditi & Tyler)"} rows={bulk ? 7 : 2} style={{ width: "100%", fontSize: 14, padding: 8, resize: "vertical", boxSizing: "border-box" }} />
          <div style={{ display: "flex", gap: 12, flexWrap: "wrap", marginTop: 10, alignItems: "flex-end" }}>
            <label style={lbl}>Font
              <select value={fontId} onChange={e => setFontId(e.target.value)} style={{ minWidth: 150 }}>
                {(fonts || []).map(f => <option key={f.id} value={f.id}>{f.name}</option>)}
              </select>
            </label>
            <label style={lbl}>Colour
              <span style={{ display: "flex", alignItems: "center", gap: 6 }}>
                <input type="color" value={/^#[0-9a-fA-F]{6}$/.test(color) ? color : "#000000"} onChange={e => setColor(e.target.value)} style={{ width: 34, height: 28, padding: 0, border: "1px solid var(--line,#dfe4ea)", borderRadius: 6 }} />
                <input type="text" value={color} onChange={e => setColor(e.target.value)} className="mono" style={{ width: 84 }} />
              </span>
            </label>
            <label style={{ ...lbl, opacity: needW ? 1 : 0.4 }}>Width cm<input type="number" min={0.5} max={300} step={0.5} value={widthCm} disabled={!needW} onChange={e => setWidthCm(e.target.value)} style={{ width: 76 }} /></label>
            <label style={{ ...lbl, opacity: needH ? 1 : 0.4 }}>Height cm<input type="number" min={0.5} max={300} step={0.5} value={heightCm} disabled={!needH} onChange={e => setHeightCm(e.target.value)} style={{ width: 76 }} /></label>
            <label style={lbl}>Scaling
              <select value={mode} onChange={e => setMode(e.target.value)}>
                <option value="stretch">Fill exact W×H</option>
                <option value="fit">Fit inside W×H</option>
                <option value="width">Width, auto height</option>
                <option value="height">Height, auto width</option>
              </select>
            </label>
          </div>
          {err && <div className="form-err" style={{ marginTop: 10 }}>{err}</div>}
        </div>
        <div className="modal__foot">
          <div style={{ flex: 1 }}>{busy && <span className="muted" style={{ fontSize: 12 }}><span className="spin" /> {prog ? `rendering ${prog.done}/${prog.total}…` : "rendering @300 DPI…"}</span>}</div>
          <button className="btn btn--ghost" onClick={onCancel}>Cancel</button>
          <button className="btn btn--dark" disabled={busy || !fontId || (bulk ? !lines.length : !text.trim())} onClick={create}>
            {bulk ? `Create ${lines.length || ""} artwork${lines.length === 1 ? "" : "s"}`.replace("  ", " ") : "Create artwork"}
          </button>
        </div>
      </div>
    </div>
  );
}
window.TextArtworkBuilder = TextArtworkBuilder;
window.ArtworkCard = ArtworkCard;

// Per-line-item fulfilment — mirrors Shopify's "fulfil per item". Reads each
// line item's fulfillable quantity from the order's fulfillment orders and lets
// the user fulfil selected items (real Shopify mutation; optional notification).
function FulfillmentCard({ o }) {
  const [data, setData] = useState(null);   // null = loading
  const [sel, setSel] = useState({});       // lineItemId -> qty
  const [notify, setNotify] = useState(false);
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState("");
  const [done, setDone] = useState("");

  function load() { setErr(""); DTF.fetchOrderFulfillment(o.id).then(setData).catch(e => { setData({ items: [], fulfillmentStatus: "—" }); setErr(e.message || "Couldn't load fulfilment"); }); }
  useEffect(() => { load(); }, [o.id]);

  if (["refunded", "cancelled"].includes(o.status)) return null;
  if (o.method === "Collection") return null; // collection uses the collect flow

  if (!data) return (
    <div className="card"><div className="card__head"><Icon name="truck" /><h3>Fulfil items</h3></div>
      <div className="card__body dim" style={{ fontSize: 12.5 }}>Loading fulfilment…</div></div>
  );

  const fulfillable = data.items.filter(i => i.fulfillableQty > 0);
  const toggle = i => setSel(s => { const n = { ...s }; if (n[i.lineItemId] != null) delete n[i.lineItemId]; else n[i.lineItemId] = i.fulfillableQty; return n; });
  const setQty = (i, q) => setSel(s => ({ ...s, [i.lineItemId]: Math.max(1, Math.min(i.fulfillableQty, q || 1)) }));
  const selCount = Object.keys(sel).length;
  const selUnits = Object.values(sel).reduce((a, b) => a + b, 0);

  async function submit() {
    const items = data.items.filter(i => sel[i.lineItemId] != null && i.foLineItemId)
      .map(i => ({ foId: i.foId, foLineItemId: i.foLineItemId, quantity: sel[i.lineItemId] }));
    if (!items.length) return;
    if (!confirm(`Mark ${selUnits} unit(s) across ${items.length} item(s) as fulfilled in Shopify${notify ? " and email the customer" : " (no notification)"}?`)) return;
    setBusy(true); setErr(""); setDone("");
    try {
      await DTF.fulfillItems(o.id, items, notify);
      setSel({});
      const fresh = await DTF.fetchOrderFulfillment(o.id);
      setData(fresh);
      setDone(`Fulfilled ${items.length} item${items.length > 1 ? "s" : ""}${notify ? " · customer notified" : ""}.`);
      // Reflect order-level status when everything is now fulfilled.
      if (!fresh.anyFulfillable) { const ord = DTF.getOrder(o.id); if (ord) { ord.fulfil = "fulfilled"; ord.status = "dispatched"; ord.dispatched = true; } }
    } catch (e) { setErr(e.message || "Fulfilment failed"); }
    setBusy(false);
  }

  return (
    <div className="card">
      <div className="card__head"><Icon name="truck" /><h3>Fulfil items</h3>
        <span className="right kick">{String(data.fulfillmentStatus || "").replace(/_/g, " ").toLowerCase()}</span></div>
      <div className="card__body">
        {!data.items.length
          ? <div className="dim" style={{ fontSize: 12.5 }}>No line items.</div>
          : <div className="ff-list">
              {data.items.map(i => {
                const can = i.fulfillableQty > 0;
                const checked = sel[i.lineItemId] != null;
                return (
                  <div key={i.lineItemId} className={"ff-row" + (can ? "" : " is-done")}>
                    {can
                      ? <button className={"checkbox" + (checked ? " is-on" : "")} onClick={() => toggle(i)} aria-label="Select item" />
                      : <span className="ff-tick"><Icon name="check" /></span>}
                    <div className="ff-thumb">{i.image ? <img src={i.image} alt="" /> : <Icon name="shirt" />}</div>
                    <div style={{ minWidth: 0, flex: 1 }}>
                      <div className="ff-name" title={i.title}>{i.title}</div>
                      <div className="mono dim" style={{ fontSize: 10.5 }}>{[i.variantTitle, i.sku].filter(Boolean).join(" · ") || "—"}</div>
                    </div>
                    <div className="ff-qty">
                      {can ? <>
                        {checked && <input type="number" min="1" max={i.fulfillableQty} value={sel[i.lineItemId]} onChange={e => setQty(i, parseInt(e.target.value, 10))} className="ff-qtyin" />}
                        <span className="mono dim" style={{ fontSize: 11, whiteSpace: "nowrap" }}>{i.fulfilledQty > 0 ? `${i.fulfilledQty}/${i.quantity} done · ` : ""}{i.fulfillableQty} to fulfil</span>
                      </> : <span className="badge badge--green">Fulfilled · {i.quantity}</span>}
                    </div>
                  </div>
                );
              })}
            </div>}
        {err && <div style={{ color: "var(--c-red)", fontSize: 12, marginTop: 8 }}>{err}</div>}
        {done && <div style={{ color: "var(--mint-ink)", fontSize: 12, marginTop: 8 }}>{done}</div>}
        {fulfillable.length > 0 &&
          <div className="ff-foot">
            <label className="ff-notify"><input type="checkbox" checked={notify} onChange={e => setNotify(e.target.checked)} /> Email customer a shipping notification</label>
            <button className="btn btn--primary btn--sm" disabled={busy || !selCount} onClick={submit}>
              {busy ? "Fulfilling…" : selCount ? `Fulfil ${selUnits} unit${selUnits > 1 ? "s" : ""}` : "Fulfil selected"}
            </button>
          </div>}
        {!fulfillable.length && data.items.length > 0 && !done &&
          <div className="dim" style={{ fontSize: 12.5, marginTop: 8 }}>All items fulfilled.</div>}
      </div>
    </div>
  );
}
window.FulfillmentCard = FulfillmentCard;

// Payment card — shown only for orders with an outstanding payment. Provides a
// shareable "pay now" link (to send over chat) and a resend-invoice email.
function PaymentCard({ o }) {
  const [info, setInfo] = useState(null);
  const [busy, setBusy] = useState(false);
  const [msg, setMsg] = useState("");
  const [copied, setCopied] = useState(false);
  const [rvUrl, setRvUrl] = useState("");      // Revolut Merchant pay link (on demand)
  const [rvBusy, setRvBusy] = useState(false);
  const [rvCopied, setRvCopied] = useState(false);
  const [rvSending, setRvSending] = useState(false);
  const [rvMsg, setRvMsg] = useState("");
  const [paying, setPaying] = useState(false);
  const [payMsg, setPayMsg] = useState("");
  const nm = encodeURIComponent(String(o.id).replace(/^#/, ""));
  useEffect(() => {
    let alive = true;
    fetch("/api/orders/" + nm + "/payment-link").then(r => r.json()).then(d => { if (alive) setInfo(d); }).catch(() => {});
    return () => { alive = false; };
  }, [o.id]);
  // Shopify returns a payment URL even for paid orders, so gate on the status:
  // only show when money is actually owed.
  const owes = info && info.paymentUrl && !info.fullyPaid
    && ["PENDING", "AUTHORIZED", "PARTIALLY_PAID", "EXPIRED"].includes(info.financialStatus);
  if (!owes) return null;
  const copy = () => navigator.clipboard.writeText(info.paymentUrl).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1600); });
  const getRevolut = async () => {
    setRvBusy(true);
    try { const j = await fetch("/api/orders/" + nm + "/revolut-link").then(r => r.json()); if (j && j.url) setRvUrl(j.url); }
    catch (e) {} finally { setRvBusy(false); }
  };
  const copyRevolut = () => navigator.clipboard.writeText(rvUrl).then(() => { setRvCopied(true); setTimeout(() => setRvCopied(false), 1600); });
  // Resend the proforma invoice: email the customer the proforma PDF + Revolut pay link.
  const resendRevolut = async () => {
    setRvSending(true); setRvMsg("");
    try {
      const to = (info && info.email) || (DTF.getCustomer(o.customerId) || {}).email;
      if (!to) throw new Error("No customer email on this order.");
      const link = rvUrl || (await resolvePayUrl(o));
      const att = await invoicePdfAttachment(o, { paid: false });
      const html = `<div style="font-family:Arial,Helvetica,sans-serif;font-size:15px;line-height:1.6;color:#111;">`
        + `<p>Hi,</p><p>Please find attached the proforma invoice for your DTF.UK order <strong>${o.id}</strong>.</p>`
        + (link ? `<p>You can pay securely online here:</p><p><a href="${link}" style="display:inline-block;background:#18f4af;color:#063d2b;font-weight:700;padding:11px 20px;border-radius:6px;text-decoration:none;">Pay for order ${o.id}</a></p><p style="color:#666;font-size:13px;">Or paste this into your browser:<br>${link}</p>` : "")
        + `<p>Thank you,<br>DTF.UK</p></div>`;
      const payload = { to, subject: `Proforma invoice for your DTF.UK order ${o.id}`, html, text: `Proforma invoice for order ${o.id}.${link ? " Pay online: " + link : ""}`, attachments: att ? [att] : [] };
      const r = await fetch("/api/send", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) });
      const j = await r.json(); if (!r.ok) throw new Error(j.error || "Send failed");
      setRvMsg("Proforma invoice emailed to " + to);
    } catch (e) { setRvMsg(e.message); }
    finally { setRvSending(false); }
  };
  const resend = () => {
    setBusy(true); setMsg("");
    fetch("/api/orders/" + nm + "/resend-invoice", { method: "POST" })
      .then(async r => { const j = await r.json(); if (!r.ok) throw new Error(j.error || "Send failed"); return j; })
      .then(j => setMsg("Invoice emailed to " + j.sentTo))
      .catch(e => setMsg(e.message)).finally(() => setBusy(false));
  };
  // Manually record payment received outside Shopify (bank transfer / cash).
  const markPaid = async () => {
    if (!confirm("Mark order " + o.id + " as PAID in Shopify?\n\nUse this only when payment was received outside Shopify (e.g. bank transfer straight to the account).")) return;
    setPaying(true); setPayMsg("");
    try {
      const r = await fetch("/api/orders/" + nm + "/mark-paid", { method: "POST" });
      const j = await r.json(); if (!r.ok) throw new Error(j.error || "Failed");
      setPayMsg("Marked paid ✓ refreshing…");
      setTimeout(() => location.reload(), 800);
    } catch (e) { setPayMsg(e.message); setPaying(false); }
  };
  return (
    <div className="card">
      <div className="card__head"><Icon name="doc" /><h3>Payment pending</h3>
        <span className="right"><span className="badge badge--amber">{info.financialStatus || "PENDING"}</span></span></div>
      <div className="card__body">
        <div className="dim" style={{ fontSize: 12.5, marginBottom: 10 }}>Not paid yet — share a secure payment link with the customer (e.g. over chat).</div>
        {/* Record a payment taken outside Shopify (bank transfer / cash into the account). */}
        <div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap", marginBottom: 12, paddingBottom: 12, borderBottom: "1px solid var(--rule)" }}>
          <button className="btn btn--primary btn--sm" disabled={paying} onClick={markPaid} title="Record this order as paid in Shopify — for payments received outside Shopify (bank transfer, cash)"><Icon name="check" /> {paying ? "Marking…" : "Mark as paid"}</button>
          <span className="kick">Paid directly (bank transfer / cash)? Record it as paid.</span>
          {payMsg && <span className="kick" style={{ color: "var(--mint-ink)" }}>{payMsg}</span>}
        </div>
        <div style={{ fontSize: 11, fontWeight: 700, textTransform: "uppercase", letterSpacing: ".04em", color: "#8a929b", marginBottom: 6 }}>Shopify checkout</div>
        <div style={{ display: "flex", gap: 6, alignItems: "center", marginBottom: 10 }}>
          <input className="rm-in mono" style={{ flex: 1, minWidth: 0 }} readOnly value={info.paymentUrl} onFocus={e => e.target.select()} />
          <a className="btn btn--ghost btn--sm" href={info.paymentUrl} target="_blank" rel="noopener noreferrer" title="Open payment page"><Icon name="external" /></a>
        </div>
        <div style={{ display: "flex", gap: 8, flexWrap: "wrap", alignItems: "center" }}>
          <button className="btn btn--primary btn--sm" onClick={copy}>{copied ? "Copied ✓" : "Copy payment link"}</button>
          <button className="btn btn--ghost btn--sm" disabled={busy} onClick={resend} title="Email the Shopify payment link to the customer (from orders@dtf.uk)">{busy ? "Sending…" : "Resend payment link"}</button>
          {info.email && <span className="kick">{info.email}</span>}
          {msg && <span className="kick" style={{ color: "var(--mint-ink)" }}>{msg}</span>}
        </div>

        {/* Branded pay page (pay.dtf.uk) — customer pays into the Revolut Business account. */}
        <div style={{ borderTop: "1px solid var(--rule)", marginTop: 14, paddingTop: 12 }}>
          <div style={{ fontSize: 11, fontWeight: 700, textTransform: "uppercase", letterSpacing: ".04em", color: "#8a929b", marginBottom: 6 }}>Payment page</div>
          <div className="dim" style={{ fontSize: 12.5, marginBottom: 8 }}>Branded pay.dtf.uk page showing the invoice; customer pays into the Revolut business account.</div>
          {rvUrl ? (
            <>
              <div style={{ display: "flex", gap: 6, alignItems: "center", marginBottom: 10 }}>
                <input className="rm-in mono" style={{ flex: 1, minWidth: 0 }} readOnly value={rvUrl} onFocus={e => e.target.select()} />
                <a className="btn btn--ghost btn--sm" href={rvUrl} target="_blank" rel="noopener noreferrer" title="Open payment page"><Icon name="external" /></a>
              </div>
              <div style={{ display: "flex", gap: 8, flexWrap: "wrap", alignItems: "center" }}>
                <button className="btn btn--primary btn--sm" onClick={copyRevolut}>{rvCopied ? "Copied ✓" : "Copy pay link"}</button>
                <button className="btn btn--ghost btn--sm" disabled={rvSending} onClick={resendRevolut} title="Email the proforma invoice (PDF) + pay link to the customer">{rvSending ? "Sending…" : "Resend proforma invoice"}</button>
                {rvMsg && <span className="kick" style={{ color: "var(--mint-ink)" }}>{rvMsg}</span>}
              </div>
            </>
          ) : (
            <button className="btn btn--ghost btn--sm" disabled={rvBusy} onClick={getRevolut}><Icon name="external" /> {rvBusy ? "Generating…" : "Get payment link"}</button>
          )}
        </div>
      </div>
    </div>
  );
}
window.PaymentCard = PaymentCard;

// Prefill templates for the order → customer email.
function orderEmailTemplates(o, c) {
  const firstName = ((c && c.name) || (o.ship && o.ship.name) || "").trim().split(/\s+/)[0] || "there";
  const itemsLine = (o.items || []).filter(i => !i.removed).map(i => `  ${i.qty} × ${i.name}`).join("\n");
  const sig = `Kind regards,\n${DTF.userName ? DTF.userName + "\n" : ""}DTF.UK`;
  // Invoice template mirrors the download button: a paid order sends the paid
  // invoice (no payment link); an unpaid one sends the proforma with a pay link
  // (injected by pickTpl, which also ticks the matching attachment checkbox).
  // The closing line reflects where the order actually is — don't say "now in
  // production" for an order that's already shipped/collected/delivered.
  const paidClosing =
    o.status === "delivered" ? "your order has been delivered"
    : o.status === "collected" ? "your order has been collected"
    : (o.dispatched || ["dispatched", "closed"].includes(o.status)) ? "your order has been dispatched"
    : "your order is now in production";
  const invoiceTpl = o.pay === "paid"
    ? { key: "paid-invoice", label: "Send paid invoice",
        subject: `Invoice for your DTF.UK order ${o.id}`,
        body: `Hi ${firstName},\n\nPlease find attached the paid invoice for your order ${o.id} (total ${DTF.fmtGBP(o.total)}).\n\nThank you for your payment — ${paidClosing}.\n\n${sig}` }
    : { key: "proforma", label: "Send proforma invoice",
        subject: `Proforma invoice for your DTF.UK order ${o.id}`,
        body: `Hi ${firstName},\n\nPlease find attached the proforma invoice for your order ${o.id} (total ${DTF.fmtGBP(o.total)}).\n\nYou can pay securely online here:\n{{PAYLINK}}\n\nAs soon as payment is received we'll get your order straight into production.\n\n${sig}` };
  return [
    { key: "order", label: "Order details",
      subject: `Your DTF.UK order ${o.id}`,
      body: `Hi ${firstName},\n\nRegarding your order ${o.id} (placed ${o.date}):\n${itemsLine}\nTotal: ${DTF.fmtGBP(o.total)}\n\n\n${sig}` },
    { key: "halftone", label: "Halftone suggestion",
      subject: `Quick question about your DTF.UK order ${o.id}`,
      body: `Hi ${firstName},\n\nBefore we print your order ${o.id}, one quick question about the artwork.\n\nWhere are you planning to apply the transfer — is it going on a black or dark garment? If so, we can halftone the design for you so it prints better on a dark background.\n\nJust let us know and we'll get it sorted before printing.\n\n${sig}` },
    { key: "feedback", label: "Feedback request",
      subject: `DTF.UK order ${o.id} / feedback`,
      body: `Hi ${firstName},\n\nThanks for your recent order ${o.id} with DTF.UK — we hope the transfers arrived well and printed perfectly.\n\nWe'd really value a quick bit of feedback: how did everything go, and is there anything we could do better? If you have a moment, a short review would mean a lot to us.\n\nThanks again,\n${DTF.userName ? DTF.userName + "\n" : ""}DTF.UK` },
    { key: "holiday-custom", label: "Custom-garment holiday",
      subject: `Your DTF.UK order ${o.id} — custom garments update`,
      body: `Hi ${firstName},\n\nI hope you're doing well!\n\nI wanted to let you know that our team responsible for custom garments is currently on holiday, and we've postponed this service until the 24th.\n\nIf you're happy to wait, we can hold your order for you. If not, we're more than happy to cancel it.\n\nOur printing service is still working as usual, so we can fulfil the transfers if you need them, but we won't be able to provide custom t-shirts at this time.\n\nJust let me know how you'd like to proceed!\n\n${sig}` },
    { key: "white-bg", label: "Artwork: white background",
      subject: `Your DTF.UK order ${o.id} — artwork needs a transparent background`,
      body: `Hi ${firstName},\n\nI hope you're doing well! I've attached your artwork for order ${o.id}. It seems like it has a white background. Could you please re-upload it with the background removed?\n\nFeel free to use the link below to send the updated file:\nhttps://dtf.uk/pages/send-files\n\nLooking forward to your response!\n\n${sig}` },
    { key: "artwork-confirm", label: "Send artwork for confirmation",
      subject: `Artwork for your DTF.UK order ${o.id} — please confirm`,
      body: `Hi ${firstName},\n\nHere's the artwork for your order ${o.id} — please take a look and let me know if you're happy with it before we go to print.\n\nIf anything needs changing, just reply and we'll sort it out.\n\nWhen you're ready to pay:\n{{PAYLINK}}\n\n${sig}` },
    invoiceTpl,
  ];
}
// Render a plain/markdown-ish email body to safe HTML: escape, turn markdown
// links [text](url) and bare URLs into real <a> links, keep line breaks. This
// stops raw "[url](url)" markdown showing up in the recipient's inbox.
function emailBodyToHtml(s) {
  const escHtml = (x) => String(x).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
  const escAttr = (x) => String(x).replace(/&/g, "&amp;").replace(/"/g, "&quot;");
  const src = String(s || "");
  const re = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)|(https?:\/\/[^\s<]+)/g;
  let out = "", i = 0, m;
  while ((m = re.exec(src))) {
    out += escHtml(src.slice(i, m.index));
    if (m[2]) { const label = m[1] === m[2] ? m[2] : m[1]; out += `<a href="${escAttr(m[2])}">${escHtml(label)}</a>`; }
    else { out += `<a href="${escAttr(m[3])}">${escHtml(m[3])}</a>`; }
    i = re.lastIndex;
  }
  out += escHtml(src.slice(i));
  return `<div style="white-space:pre-wrap;font-family:Arial,Helvetica,sans-serif;font-size:15px;line-height:1.5;color:#111;">${out}</div>`;
}
// Plain-text fallback: unwrap markdown links to "text (url)" (or just the url).
function emailBodyToText(s) {
  return String(s || "").replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, (_m, t, u) => (t === u ? u : `${t} (${u})`));
}

// Downscale a pasted screenshot to a small JPEG for the AI vision call — the model
// only needs to SEE the artwork, not print from it, so this keeps the request well
// under the JSON body limit and cheap on tokens. The full-res image is still what
// gets attached to the actual email. Falls back to the original on any failure.
function shrinkForAI(dataUrl, maxPx = 1024) {
  return new Promise((resolve) => {
    const img = new Image();
    img.onload = () => {
      const scale = Math.min(1, maxPx / Math.max(img.width || 1, img.height || 1));
      const w = Math.max(1, Math.round((img.width || 1) * scale));
      const h = Math.max(1, Math.round((img.height || 1) * scale));
      try {
        const cv = document.createElement("canvas"); cv.width = w; cv.height = h;
        const ctx = cv.getContext("2d");
        ctx.fillStyle = "#fff"; ctx.fillRect(0, 0, w, h); // flatten transparency so the model sees the design, not black
        ctx.drawImage(img, 0, 0, w, h);
        resolve(cv.toDataURL("image/jpeg", 0.82));
      } catch { resolve(dataUrl); }
    };
    img.onerror = () => resolve(dataUrl);
    img.src = dataUrl;
  });
}

// Inline email composer — a SECTION at the top of the order (not a modal) so you
// can still copy links from the order while writing. Supports AI drafting from
// your notes + the order's items/note, and pasting images into the body to send.
function OrderEmailComposer({ o, c, onClose, draft }) {
  const tpls = orderEmailTemplates(o, c);
  const [tpl, setTpl] = useState("order");
  const [to, setTo] = useState((c && c.email) || "");
  const [aiContext, setAiContext] = useState("");
  const [subject, setSubject] = useState(tpls[0].subject);
  const [body, setBody] = useState(tpls[0].body);
  const [images, setImages] = useState([]); // { dataUrl, type, name }
  const [artFiles, setArtFiles] = useState(null); // null = not yet loaded; array once loaded
  const [selArt, setSelArt] = useState(new Set()); // artwork file ids selected to attach
  const [linkMode, setLinkMode] = useState(new Set()); // ids the operator chose to send as a preview LINK
  const insertedLinksRef = React.useRef("");           // the links block currently injected in the body
  // Files bigger than this can't be emailed as attachments (providers cap ~25 MB),
  // so a big gang sheet goes out as a public preview link instead.
  const ATTACH_CAP = 20 * 1024 * 1024;
  // Must match how the server keys artwork: drafts = "draft-<digits>", orders =
  // ordName (a numeric name gets a leading "#"). Bare numbers won't match getArtwork.
  const artOwner = () => { if (draft) return "draft-" + String(o.legacyId).replace(/\D/g, ""); const bare = String(o.id).replace(/^#/, ""); return /^\d+$/.test(bare) ? "#" + bare : bare; };
  const effectiveLink = (f) => linkMode.has(f.id) || (f.size || 0) > ATTACH_CAP; // link, not attach
  const toggleLinkMode = (id) => setLinkMode(s => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
  // Fetch public preview links for the selected link-mode files and inject a
  // "Preview your artwork" block into the body (replacing any earlier block).
  // Returns the resulting body text (so send() can use it even if never clicked).
  async function ensureArtLinks(curBody) {
    const files = (artFiles || []).filter(f => selArt.has(f.id) && effectiveLink(f));
    if (!files.length) return curBody;
    const refs = files.map(f => ({ owner: artOwner(), fileId: f.id }));
    let links = [];
    try {
      const r = await fetch("/api/artwork/links", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ refs }) });
      links = ((await r.json()) || {}).links || [];
    } catch {}
    if (!links.length) return curBody;
    const block = "Preview your artwork:\n" + links.map(l => `[${l.filename}](${l.url})`).join("\n");
    let base = curBody;
    if (insertedLinksRef.current && base.includes(insertedLinksRef.current)) {
      base = base.replace(insertedLinksRef.current, "").replace(/\n{3,}/g, "\n\n");
    }
    const next = base.replace(/\s+$/, "") + "\n\n" + block + "\n";
    insertedLinksRef.current = block;
    setBody(next);
    return next;
  }
  // Invoice attachments. The proforma (carries the pay-online link) defaults ON
  // for unpaid orders to help collect payment; the paid invoice is just a receipt,
  // so it defaults OFF — the operator opts in when they actually want to send it.
  const isPaid = o.pay === "paid";
  const [attachPaid, setAttachPaid] = useState(false);
  const [attachProforma, setAttachProforma] = useState(!isPaid);
  const pickTpl = async (t) => {
    setTpl(t.key); setSubject(t.subject);
    if (t.key === "proforma") {
      // Attach the proforma PDF and drop the Revolut pay-online link into the body
      // + AI context, so both a manual send and an "AI write" include it.
      setAttachProforma(true);
      let link = "";
      try { link = (await resolvePayUrl(o, { draft })) || ""; } catch (e) {}
      if (link) {
        setBody(t.body.replace("{{PAYLINK}}", link));
        setAiContext(`Send the customer their proforma invoice (PDF attached) for order ${o.id}. Ask them politely to pay securely online via this link: ${link}`);
      } else {
        // No pay link available — drop the "pay online" block rather than show a placeholder.
        setBody(t.body.replace(/\nYou can pay securely online here:\n\{\{PAYLINK\}\}\n/, "\n"));
        setAiContext(`Send the customer their proforma invoice (PDF attached) for order ${o.id}.`);
      }
    } else if (t.key === "paid-invoice") {
      setAttachPaid(true);
      setBody(t.body);
      setAiContext(`Send the customer their paid invoice (PDF attached) for order ${o.id}, thanking them for their payment.`);
    } else if (t.key === "artwork-confirm") {
      setAiContext(`Send the customer the artwork for order ${o.id} as attachments, ask them to confirm they're happy before we print, and include the payment link.`);
      let link = "";
      try { link = (await resolvePayUrl(o, { draft })) || ""; } catch {}
      setBody(link
        ? t.body.replace("{{PAYLINK}}", link)
        : t.body.replace(/\nWhen you're ready to pay:\n\{\{PAYLINK\}\}\n/, "\n"));
      // Lazily load artwork list for selection on first pick of this template
      if (artFiles === null) {
        try {
          const owner = draft ? { type: "draft", id: o.legacyId } : null;
          const loaded = owner ? await DTF.artworkForOwner(owner) : await DTF.artworkFor(o.id);
          setArtFiles(loaded || []);
          setSelArt(new Set());
        } catch { setArtFiles([]); }
      }
    } else if (t.key === "holiday-custom" || t.key === "white-bg") {
      // Seed the AI context with the notice so "AI write" polishes + personalises it,
      // and pre-fill the body so it can also be sent/edited directly.
      setBody(t.body);
      setAiContext(t.body);
    } else {
      setBody(t.body);
    }
  };
  const [sending, setSending] = useState(false);
  const [genning, setGenning] = useState(false);
  const [err, setErr] = useState("");
  const [done, setDone] = useState("");
  const kbd = (navigator.platform || "").toLowerCase().includes("mac") ? "⌘" : "Ctrl";
  const genAI = async () => {
    if (genning) return;
    setGenning(true); setErr("");
    const items = (o.items || []).filter(i => !i.removed).map(i => `${i.qty} × ${i.name}`);
    // Pass the pasted artwork screenshots to the vision model so it can SEE the
    // design and write about it (cap a few + downscale, keeps the request sane).
    let imgs = [];
    try { imgs = (await Promise.all(images.slice(0, 4).map(im => shrinkForAI(im.dataUrl)))).filter(Boolean); } catch {}
    fetch("/api/ai/order-email", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ context: aiContext || body, order: o.id, customerName: c && c.name, userName: DTF.userName, items, note: o.note || "", attachments: images.length, images: imgs }) })
      .then(async r => { const j = await r.json().catch(() => ({})); if (!r.ok) throw new Error(j.error || "AI failed"); return j; })
      .then(j => { if (j.subject) setSubject(j.subject); if (j.body) setBody(j.body); })
      .catch(e => setErr(e.message)).finally(() => setGenning(false));
  };
  // Paste images from the clipboard into the body → attach (sent inline).
  const onPaste = (e) => {
    const items = (e.clipboardData && e.clipboardData.items) || [];
    for (const it of items) {
      if (it.type && it.type.startsWith("image/")) {
        const file = it.getAsFile(); if (!file) continue;
        const reader = new FileReader();
        reader.onload = () => setImages(p => [...p, { dataUrl: reader.result, type: file.type || "image/png", name: file.name || "pasted.png" }]);
        reader.readAsDataURL(file);
      }
    }
  };
  const send = async () => {
    if (!to.trim()) { setErr("Add a recipient email"); return; }
    if (tpl === "artwork-confirm" && selArt.size === 0) { setErr("Select at least one artwork file to attach"); return; }
    setSending(true); setErr(""); setDone("");
    try {
      const attachments = [];
      if (images.length) {
        // Real file attachments (no cid → not inlined in the body); the customer gets
        // the artwork as downloadable files rather than embedded pictures.
        const ext = (im) => (im.type && im.type.split("/")[1]) || "png";
        images.forEach((im, i) => attachments.push({ filename: im.name || `artwork-${i + 1}.${ext(im)}`, content: im.dataUrl.split(",")[1], encoding: "base64", contentType: im.type || "image/png" }));
      }
      // Invoice PDFs (generated client-side via the same pipeline as the download button).
      if (attachPaid)     { const a = await invoicePdfAttachment(o, { paid: true, draft });  if (a) attachments.push(a); }
      if (attachProforma) { const a = await invoicePdfAttachment(o, { paid: false, draft }); if (a) attachments.push(a); }
      // Large / link-mode files go out as a public preview link in the body; only
      // the smaller attach-mode files are fetched + attached server-side (avoids
      // pushing big print files through email, which fails over the size cap).
      let outBody = body;
      if (tpl === "artwork-confirm") outBody = await ensureArtLinks(body);
      const artworkRefs = tpl === "artwork-confirm"
        ? (artFiles || []).filter(f => selArt.has(f.id) && !effectiveLink(f)).map(f => ({
            owner: artOwner(),
            fileId: f.id,
          }))
        : [];
      const payload = { to: to.trim(), subject, html: emailBodyToHtml(outBody), text: emailBodyToText(outBody) };
      if (attachments.length) payload.attachments = attachments;
      if (artworkRefs.length) payload.artworkRefs = artworkRefs;
      const r = await fetch("/api/send", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) });
      const j = await r.json().catch(() => ({}));
      if (!r.ok) throw new Error(j.error || "Send failed");
      setDone("Sent ✓"); setTimeout(() => onClose(true), 700);
    } catch (e) { setErr(e.message); } finally { setSending(false); }
  };
  return (
    <div className="card">
      <div className="card__head"><Icon name="mail" /><h3>Email customer</h3>
        <button className="icon-btn" style={{ marginLeft: "auto" }} onClick={() => onClose(false)} title="Close"><Icon name="x" /></button></div>
      <div className="card__body" style={{ display: "flex", flexDirection: "column", gap: 10 }}>
        <label className="out-field"><span>To</span><input className="rm-in" value={to} onChange={e => setTo(e.target.value)} placeholder="customer@email.com" /></label>

        {/* AI: write context, generate subject + body from it (+ order items & note) */}
        <label className="out-field"><span>Context for AI</span>
          <textarea className="out-html" style={{ height: 90 }} value={aiContext} onChange={e => setAiContext(e.target.value)} onPaste={onPaste}
            onKeyDown={e => { if ((e.ctrlKey || e.metaKey) && e.key === "Enter") { e.preventDefault(); genAI(); } }}
            placeholder={`What should the email say? e.g. apologise for a 2-day delay, offer 10% off next order…  Paste a screenshot of the artwork here and the AI will see it.  (${kbd}+Enter to write)`} /></label>
        <div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
          <button className="btn btn--primary btn--sm" disabled={genning} onClick={genAI} title="AI-write subject + body from your context, the order items & the order note (and any pasted artwork)"><Icon name="spark" /> {genning ? "Writing…" : "AI write"}</button>
          <span className="kbd-hint">{kbd}+↵</span>
          {images.length > 0 && <span className="kick" style={{ color: "var(--mint-ink)" }} title="Pasted artwork the AI will look at when writing"><Icon name="image" /> AI sees {Math.min(images.length, 4)} artwork image{images.length > 1 ? "s" : ""}</span>}
          <span className="kick">or template:</span>
          {tpls.map(t => <button key={t.key} className={"btn btn--sm " + (tpl === t.key ? "btn--primary" : "btn--ghost")} onClick={() => pickTpl(t)}>{t.label}</button>)}
        </div>

        <label className="out-field"><span>Subject</span><input className="rm-in" value={subject} onChange={e => setSubject(e.target.value)} /></label>
        <label className="out-field"><span>Body</span>
          <textarea className="out-html" style={{ height: 220 }} value={body} onChange={e => setBody(e.target.value)} onPaste={onPaste}
            onKeyDown={e => { if ((e.ctrlKey || e.metaKey) && e.key === "Enter") { e.preventDefault(); send(); } }}
            placeholder="Email body… you can paste an image here to send it as an attachment." /></label>
        {images.length > 0 && (
          <div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
            {images.map((im, i) => (
              <div key={i} style={{ position: "relative" }}>
                <img src={im.dataUrl} alt="" style={{ height: 56, borderRadius: 6, border: "1px solid var(--rule)" }} />
                <button onClick={() => setImages(p => p.filter((_, j) => j !== i))} title="Remove"
                  style={{ position: "absolute", top: -6, right: -6, width: 18, height: 18, borderRadius: "50%", border: "1px solid var(--rule-2)", background: "#fff", cursor: "pointer", lineHeight: "16px", fontSize: 12 }}>×</button>
              </div>
            ))}
          </div>
        )}
        {tpl === "artwork-confirm" && (
          <div style={{ border: "1px solid var(--rule)", borderRadius: 8, padding: "10px 12px", display: "flex", flexDirection: "column", gap: 8 }}>
            <div className="kick">Artwork — attach small files, or send big ones as a preview link:</div>
            {artFiles === null && <span className="dim" style={{ fontSize: 12 }}>Loading artwork…</span>}
            {artFiles !== null && artFiles.length === 0 && <span className="dim" style={{ fontSize: 12 }}>No artwork uploaded for this order yet.</span>}
            {(artFiles || []).map(f => {
              const isImg = /^image\//.test(f.contentType);
              const thumbUrl = draft
                ? DTF.artThumbUrlOwner({ type: "draft", id: o.legacyId }, f.id, 48)
                : DTF.artThumbUrl(o.id, f.id, 48);
              const overCap = (f.size || 0) > ATTACH_CAP;
              const sel = selArt.has(f.id);
              const asLink = effectiveLink(f);
              return (
                <div key={f.id} style={{ display: "flex", gap: 8, alignItems: "center", fontSize: 13 }}>
                  <label style={{ display: "flex", gap: 8, alignItems: "center", cursor: "pointer", flex: 1, minWidth: 0 }}>
                    <input type="checkbox" checked={sel} onChange={() => setSelArt(s => { const n = new Set(s); n.has(f.id) ? n.delete(f.id) : n.add(f.id); return n; })} />
                    {isImg && <img src={thumbUrl} alt="" style={{ height: 36, borderRadius: 4, border: "1px solid var(--rule)" }} />}
                    <span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{f.filename}</span>
                    {f.size > 0 && <span className="dim" style={{ fontSize: 11 }}>{window.fmtBytes ? window.fmtBytes(f.size) : Math.round(f.size / 1024) + " KB"}</span>}
                  </label>
                  {sel && (overCap
                    ? <span style={{ fontSize: 10, fontWeight: 700, color: "#b4700f", background: "#fff7ea", border: "1px solid var(--c-amber,#e6952a)", borderRadius: 999, padding: "1px 6px", whiteSpace: "nowrap" }} title="Too large to attach — sent as a preview link">link only</span>
                    : <label style={{ display: "flex", gap: 4, alignItems: "center", cursor: "pointer", fontSize: 11, color: asLink ? "var(--mint-ink)" : "var(--txt-3)", whiteSpace: "nowrap" }} title="Send a public preview link instead of attaching the file">
                        <input type="checkbox" checked={linkMode.has(f.id)} onChange={() => toggleLinkMode(f.id)} /> as link
                      </label>)}
                </div>
              );
            })}
            {(artFiles || []).some(f => selArt.has(f.id) && effectiveLink(f)) && (
              <button className="btn btn--ghost btn--sm" style={{ alignSelf: "flex-start" }} onClick={() => ensureArtLinks(body)}>
                <Icon name="link" /> Insert preview links into email
              </button>
            )}
          </div>
        )}
        <div style={{ display: "flex", gap: 18, alignItems: "center", flexWrap: "wrap", fontSize: 13, padding: "2px 0" }}>
          <span className="kick">Attach invoice:</span>
          {isPaid ? (
            <label style={{ display: "flex", gap: 6, alignItems: "center", cursor: "pointer" }}>
              <input type="checkbox" checked={attachPaid} onChange={e => setAttachPaid(e.target.checked)} /> Paid invoice <span className="pill pill--green" style={{ fontSize: 10 }}>PAID</span>
            </label>
          ) : (
            <label style={{ display: "flex", gap: 6, alignItems: "center", cursor: "pointer" }}>
              <input type="checkbox" checked={attachProforma} onChange={e => setAttachProforma(e.target.checked)} /> Proforma invoice
            </label>
          )}
        </div>
        {err && <div style={{ color: "var(--c-red)", fontSize: 12 }}>{err}</div>}
        <div style={{ display: "flex", gap: 8, alignItems: "center" }}>
          {(() => {
            const artSel = tpl === "artwork-confirm" ? (artFiles || []).filter(f => selArt.has(f.id)) : [];
            const nAttach = images.length + artSel.filter(f => !effectiveLink(f)).length;
            const nLink = artSel.filter(f => effectiveLink(f)).length;
            const bits = [];
            if (nAttach) bits.push(`${nAttach} attachment${nAttach > 1 ? "s" : ""}`);
            if (nLink) bits.push(`${nLink} preview link${nLink > 1 ? "s" : ""}`);
            return <span className="kick" style={{ marginRight: "auto" }}>Sends from orders@dtf.uk{bits.length ? ` · ${bits.join(" · ")}` : ""}</span>;
          })()}
          {done && <span className="kick" style={{ color: "var(--mint-ink)" }}>{done}</span>}
          <button className="btn btn--ghost btn--sm" onClick={() => onClose(false)}>Cancel</button>
          <button className="btn btn--primary btn--sm" disabled={sending} onClick={send}><Icon name="send" /> {sending ? "Sending…" : "Send email"}</button>
          <span className="kbd-hint">{kbd}+↵</span>
        </div>
      </div>
    </div>
  );
}
window.OrderEmailComposer = OrderEmailComposer;

function ProductionCard({ o, go, onChange }) {
  const [, force] = useState(0);
  if (["refunded","cancelled","collected","dispatched","delivered","closed"].includes(o.status)) return null;
  const printers = (DTF._workflow.printers || []).filter(p => p.active !== false);
  const stage = o.localStage || null;
  // printing/printed are sub-states of the print-queue lane — keep that route lit.
  const routeActive = (rStage) => stage === rStage || (rStage === "print_queue" && (stage === "printing" || stage === "printed"));
  // Re-render the whole detail (pagehead badge, steps) too, not just this card.
  const set = async (s, pid) => {
    await DTF.setOrderStage(o.id, s, pid !== undefined ? pid : o.printerId);
    force(x => x + 1); onChange && onChange();
    // Triage flow: if this move took the order OUT of the list it was opened from
    // (e.g. dispatch board → print queue), jump to the next remaining order. On the
    // last order there's no next, so fall back to the previous (now the new last)
    // one — keep working orders until the list is empty, then back to the list.
    const step = go && DTF.navStepper && DTF.navStepper(o.id);
    if (step && step.movedOut) {
      const jumpId = step.nextId || step.prevId;
      if (jumpId) go({ view: "order", id: jumpId });
      else { const nav = DTF.getOrderNav && DTF.getOrderNav(); go((nav && nav.meta && nav.meta.route) || { view: "orders" }); }
    }
  };
  return (
    <div className="card">
      <div className="card__head"><Icon name="shirt" /><h3>Production</h3>
        {stage && <span className="right"><StatusBadge status={stage} /></span>}</div>
      <div className="card__body">
        {/* One click per printer: assign the printer + move to its print queue. */}
        {!["printing","printed"].includes(stage) && printers.length > 0 && (
          <div style={{ marginBottom: 12 }}>
            <div className="kick" style={{ marginBottom: 4 }}>Send to print queue on</div>
            <div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
              {printers.map(p => <button key={p.id} className={"btn btn--sm " + (stage === "print_queue" && o.printerId === p.id ? "btn--primary" : "btn--ghost")} onClick={() => set("print_queue", p.id)} title={"Queue " + o.id + " on " + p.name}>{p.name}</button>)}
            </div>
          </div>
        )}
        <div className="kick" style={{ marginBottom: 6 }}>Route to</div>
        <div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
          {DTF.STAGE_ROUTES.map(r => (
            <button key={r.stage} className={"btn btn--sm " + (routeActive(r.stage) ? "btn--primary" : "btn--ghost")} onClick={() => set(r.stage)}>{r.label}</button>
          ))}
          {stage && <button className="btn btn--ghost btn--sm" onClick={() => set(null)}>Clear</button>}
        </div>
        {["printing","printed"].includes(stage) && (
          <div style={{ marginTop: 12 }}>
            <div className="kick" style={{ marginBottom: 4 }}>Printer</div>
            <select className="rm-in" style={{ width: "100%" }} value={o.printerId || ""} onChange={e => set(stage, e.target.value || null)}>
              <option value="">Unassigned</option>
              {printers.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
            </select>
          </div>
        )}
        {stage === "print_queue" && <button className="btn btn--primary btn--sm btn--block" style={{ marginTop: 10 }} onClick={() => set("printing")}>▶ Start printing</button>}
        {stage === "printing" && <button className="btn btn--ghost btn--sm btn--block" style={{ marginTop: 10 }} onClick={() => set("printed")}>✓ Mark printed</button>}
        {stage === "printed" && <div style={{ display: "flex", gap: 6, marginTop: 10 }}><button className="btn btn--ghost btn--sm" onClick={() => set("press_queue")}>→ Press queue</button><button className="btn btn--primary btn--sm" onClick={() => set("ready_dispatch")}>Ready to dispatch</button></div>}
        {stage === "press_queue" && <button className="btn btn--primary btn--sm btn--block" style={{ marginTop: 10 }} onClick={() => set("ready_dispatch")}>Ready to dispatch</button>}
        {stage === "design_queue" && <div style={{ display: "flex", gap: 6, marginTop: 10, flexWrap: "wrap" }}><button className="btn btn--ghost btn--sm" onClick={() => set("print_queue")}>→ Print queue</button><button className="btn btn--ghost btn--sm" onClick={() => set("press_queue")}>→ Press queue</button><button className="btn btn--primary btn--sm" onClick={() => set("ready_dispatch")}>Ready</button></div>}
        {stage === "awaiting_artwork" && <button className="btn btn--ghost btn--sm btn--block" style={{ marginTop: 10 }} onClick={() => set("design_queue")}>→ Design services</button>}
        {stage === "awaiting_garments" && <>
          <div className="kick" style={{ marginTop: 10, color: "var(--c-amber)" }}>On hold — postponed off the dispatch board until moved on.</div>
          <div style={{ display: "flex", gap: 6, marginTop: 8, flexWrap: "wrap" }}>
            <button className="btn btn--primary btn--sm" onClick={() => set("print_queue")}>Garments arrived → Print queue</button>
            <button className="btn btn--ghost btn--sm" onClick={() => set("rip_queue")}>→ RIP queue</button>
          </div>
        </>}
      </div>
    </div>
  );
}

// Live print progress panel — fed by the /api/printing/live poller (DTF._printing),
// re-rendered on each poll + a 1s tick so elapsed/ETA count down smoothly. Renders
// nothing unless this order is printing right now.
function PrintProgress({ orderId }) {
  const [, tick] = useState(0);
  useEffect(() => {
    const onLive = () => tick(x => x + 1);
    window.addEventListener("dtf:print-live", onLive);
    const t = setInterval(() => tick(x => x + 1), 1000);
    return () => { window.removeEventListener("dtf:print-live", onLive); clearInterval(t); };
  }, []);
  const live = DTF.printingFor && DTF.printingFor(orderId);
  if (!live) return null;
  const started = new Date(live.startedAt).getTime();
  const elapsedSec = Math.max(0, (Date.now() - started) / 1000);
  const total = live.totalMetres || 0, printed = live.printedMetres || 0;
  const percent = total ? Math.min(100, Math.round(printed / total * 100)) : (live.percent || 0);
  const avgRate = (elapsedSec > 30 && printed > 0) ? printed / (elapsedSec / 3600) : null;
  const rate = live.speedMh || avgRate;
  const remaining = Math.max(0, total - printed);
  const etaSec = rate ? remaining / rate * 3600 : null;
  return (
    <div className="card pp-card">
      <div className="card__head"><h3>🖨 Print progress</h3>{live.printer && <span className="right kick pp-printer">{live.printer}</span>}</div>
      <div className="card__body">
        <div className="pp-top">
          <span className="pp-order">{live.order}</span>
          {total > 0 && <span className="pp-metres">{DTF.fmtMetres(total)}</span>}
          <span className="pp-pct">~{percent}%</span>
        </div>
        <div className="pp-bar"><div className="pp-bar__fill" style={{ width: percent + "%" }} /></div>
        <div className="pp-line">⏱️ elapsed {DTF.fmtDuration(elapsedSec)}
          {etaSec != null && <> · ETA ~{DTF.fmtDuration(etaSec)} left (done ~{DTF.fmtClock(Date.now() + etaSec * 1000)})</>}</div>
        <div className="pp-sub">{rate ? <>est. at ~{Math.round(rate)} m/h avg</> : "measuring…"}{live.mode && <> · {live.mode}</>}{total > 0 && <> · {DTF.fmtMetres(printed)} done</>}</div>
      </div>
    </div>
  );
}

/* Internal notes — the Shopify order "Notes" field used as a shared chat between
   the studio (admin) and the fulfilment shop (partner). Each posted note is one
   `[stamp · author] text` line; we parse those lines back into chat bubbles.
   `viewerSide` ("studio"|"shop") + `shopNames` decide which bubbles are "mine".
   Posting appends a line (server-side) and emails the opposite party. */
function parseOrderNotes(note) {
  if (!note) return [];
  const RE = /^\[(.+?)\s·\s(.+?)\]\s([\s\S]*)$/;
  return String(note).split(/\r?\n/).map(ln => ln.replace(/\s+$/, "")).filter(Boolean).map(ln => {
    const m = ln.match(RE);
    return m ? { time: m[1], author: m[2], text: m[3] } : { time: null, author: null, text: ln };
  });
}
function OrderNotes({ note, postUrl, onPosted, viewerSide, shopNames }) {
  const [text, setText] = useState("");
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState("");
  const scrollRef = React.useRef(null);
  const msgs = parseOrderNotes(note);
  const shopSet = new Set((shopNames || (DTF.myShops || []).map(s => s.name)).map(n => String(n).toLowerCase()));
  const sideOf = (author) => (author && shopSet.has(String(author).toLowerCase())) ? "shop" : "studio";
  React.useEffect(() => { const el = scrollRef.current; if (el) el.scrollTop = el.scrollHeight; }, [note]);
  async function post() {
    const t = text.trim(); if (!t || busy) return;
    setBusy(true); setErr("");
    try {
      const r = await fetch(postUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: t }) });
      const j = await r.json().catch(() => ({}));
      if (!r.ok) throw new Error(j.error || (r.status + " " + r.statusText));
      setText(""); onPosted && onPosted(j.note != null ? j.note : note);
    } catch (e) { setErr(e.message); } finally { setBusy(false); }
  }
  const otherLabel = viewerSide === "shop" ? "the studio" : "the fulfilment shop";
  return (
    <div className="notes-card">
      <div className="notes-card__head"><Icon name="mail" /><span className="notes-card__lbl">Internal notes</span>
        <span className="dim" style={{ fontSize: 11 }}>· shared with {otherLabel} · they're emailed on each note</span></div>
      <div className="notes-chat" ref={scrollRef}>
        {msgs.length === 0
          ? <div className="dim" style={{ fontSize: 12, padding: "6px 2px" }}>No notes yet. Start the conversation — {otherLabel} gets an email.</div>
          : msgs.map((m, i) => {
            const mine = m.author && sideOf(m.author) === viewerSide;
            return (
              <div key={i} className={"note-msg" + (mine ? " note-msg--mine" : "") + (m.author ? "" : " note-msg--sys")}>
                <div className="note-msg__bubble">
                  {m.author && <div className="note-msg__meta">{m.author}{m.time ? " · " + m.time : ""}</div>}
                  <div className="note-msg__text">{m.text}</div>
                </div>
              </div>
            );
          })}
      </div>
      <div className="notes-card__compose">
        <textarea className="rm-in" rows={2} value={text} placeholder="Write a note… (⌘/Ctrl+Enter to send)"
          onChange={e => setText(e.target.value)} onKeyDown={e => { if ((e.metaKey || e.ctrlKey) && e.key === "Enter") post(); }} />
        <button className="btn btn--primary btn--sm" disabled={busy || !text.trim()} onClick={post}><Icon name="send" /> {busy ? "Sending…" : "Send"}</button>
      </div>
      {err && <div className="kick" style={{ color: "#c0392b", padding: "0 12px 8px" }}>{err}</div>}
    </div>
  );
}
window.OrderNotes = OrderNotes;

// Hold an order until a chosen date: it drops off the active boards and re-appears
// in New orders that morning. Shows the held state + a Release once set.
function HoldControl({ o, onChange }) {
  const [d, setD] = useState("");
  const [busy, setBusy] = useState(false);
  const today = new Date().toISOString().slice(0, 10);
  const isHeld = o.holdUntil && o.holdUntil > today;
  const fmt = (ymd) => { try { return new Date(ymd + "T00:00:00").toLocaleDateString("en-GB", { day: "numeric", month: "short" }); } catch { return ymd; } };
  async function apply(date) { setBusy(true); try { await DTF.holdOrder(o.id, date); onChange && onChange(); } finally { setBusy(false); } }
  if (isHeld) return (
    <span className="hold-ctl hold-ctl--on" title={"Held off the boards until " + o.holdUntil + " — re-appears in New orders that day"}>
      <Icon name="clock" /> Held until {fmt(o.holdUntil)}
      <button className="btn btn--ghost btn--sm" disabled={busy} onClick={() => apply(null)}>{busy ? "…" : "Release"}</button>
    </span>
  );
  return (
    <span className="hold-ctl">
      <input type="date" className="rm-in rm-in--sm" min={today} value={d} onChange={e => setD(e.target.value)} title="Hold this order until a date" />
      <button className="btn btn--ghost btn--sm" disabled={busy || !d} onClick={() => apply(d)}><Icon name="clock" /> {busy ? "…" : "Hold until"}</button>
    </span>
  );
}

// The customer's pinned internal note, surfaced on their orders so the studio
// sees the relationship context (e.g. "runs an embroidery business") in-place.
function CustomerNoteBanner({ customerId }) {
  const [note, setNote] = useState("");
  useEffect(() => {
    if (!customerId) { setNote(""); return; }
    let alive = true;
    const key = String(customerId).split("/").pop();
    fetch("/api/customers/" + encodeURIComponent(key) + "/note").then(r => r.json()).then(j => { if (alive) setNote((j && j.note) || ""); }).catch(() => {});
    return () => { alive = false; };
  }, [customerId]);
  if (!note) return null;
  return <div className="cust-note-banner" style={{ whiteSpace: "pre-wrap" }}><Icon name="doc" /><div><span className="cust-note-banner__lbl">Customer note</span> {note}</div></div>;
}

// Per-order PRINTING INSTRUCTIONS — free-text placement/spec the press team needs
// (e.g. "FRONT: logo centred, 20cm wide"). Editable here; printed on the Order
// Summary PDF. An AI summary of a linked email thread can be appended from the
// Inbox — this card just shows/edits the resulting single field.
function PrintingInstructionsCard({ orderId, draftId }) {
  const bare = String(orderId || "").replace(/^#/, "");
  const endpoint = draftId != null
    ? "/api/draft-orders/" + encodeURIComponent(String(draftId)) + "/instructions"
    : "/api/orders/" + encodeURIComponent(bare) + "/instructions";
  const [rec, setRec] = useState(null);
  const [text, setText] = useState("");
  const [loaded, setLoaded] = useState(false);
  const [saving, setSaving] = useState(false);
  const [savedAt, setSavedAt] = useState(0);
  const load = React.useCallback(() => {
    fetch(endpoint)
      .then(r => r.json()).then(j => { setRec(j.instructions || null); setText((j.instructions && j.instructions.text) || ""); setLoaded(true); })
      .catch(() => setLoaded(true));
  }, [endpoint]);
  useEffect(() => { setLoaded(false); load(); }, [endpoint, load]);
  // Refresh if the Inbox just pushed a thread summary onto this order.
  useEffect(() => {
    const h = (e) => { if (!e.detail || String(e.detail.order || "").replace(/^#/, "") === bare) load(); };
    window.addEventListener("dtf:instructions-updated", h);
    return () => window.removeEventListener("dtf:instructions-updated", h);
  }, [bare, load]);
  const dirty = loaded && text !== ((rec && rec.text) || "");
  async function save() {
    setSaving(true);
    try {
      const r = await fetch(endpoint, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text }) });
      const j = await r.json();
      setRec(j.instructions || null); setText((j.instructions && j.instructions.text) || "");
      setSavedAt(Date.now()); setTimeout(() => setSavedAt(0), 2000);
    } catch (e) { /* keep edits on failure */ } finally { setSaving(false); }
  }
  const rows = Math.max(4, Math.min(18, (text ? text.split("\n").length : 0) + 2));
  return (
    <div className="card">
      <div className="card__head"><Icon name="doc" /><h3>Printing instructions</h3>
        <span className="right kick">prints on order summary</span></div>
      <div className="card__body">
        <textarea rows={rows} value={text} onChange={e => setText(e.target.value)}
          style={{ width: "100%", boxSizing: "border-box", resize: "vertical", padding: "9px 11px", border: "1px solid var(--line, #d7d7d2)", borderRadius: 8, fontFamily: "'JetBrains Mono', ui-monospace, Menlo, monospace", fontSize: 12.5, lineHeight: 1.5, whiteSpace: "pre" }} />
        <div style={{ display: "flex", alignItems: "center", gap: 12, marginTop: 8, flexWrap: "wrap" }}>
          <button className="btn btn--primary btn--sm" disabled={!dirty || saving} onClick={save}>{saving ? "Saving…" : savedAt ? "Saved ✓" : "Save"}</button>
          {rec && rec.updatedAt ? <span className="dim" style={{ fontSize: 11 }}>updated {new Date(rec.updatedAt).toLocaleString("en-GB")}</span> : null}
          {rec && rec.threadEmail ? <span className="kick" title="Includes an AI summary from a linked email thread">✉ {rec.threadEmail}</span> : null}
        </div>
      </div>
    </div>
  );
}
window.PrintingInstructionsCard = PrintingInstructionsCard;

/* =================== ORDER DETAIL =================== */
// Once a label exists for an order, the Create-label CTA is replaced with these
// actions: View (open the PDF), Print (silent to the packing-station printer, or
// open the PDF if no station), Download (save the PDF).
function LabelActions({ o, label, onPrinted, refOverride }) {
  const stampRef = refOverride ? String(refOverride).replace(/^#/, "") : String(o.id).replace(/^#/, "");
  const oid = label.orderIdentifier;
  const pdfUrl = oid
    ? "/api/shipments/" + encodeURIComponent(oid) + "/label?type=postageLabel"
    : label.labelUrl; // Shopify carrier stores a direct PDF URL
  const printedAt = label.printedAt || null;
  const fmtTime = (t) => { try { return window.fmtEventTime ? window.fmtEventTime(t) : new Date(t).toLocaleString("en-GB", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" }); } catch (e) { return ""; } };
  const view = () => { if (pdfUrl) window.open(pdfUrl, "_blank"); };
  const download = async () => {
    if (!pdfUrl) return;
    try {
      const r = await fetch(pdfUrl);
      if (!r.ok) throw new Error("fetch");
      const b = await r.blob();
      const u = URL.createObjectURL(b);
      const a = document.createElement("a");
      a.href = u; a.download = "label-" + stampRef + ".pdf";
      document.body.appendChild(a); a.click(); a.remove();
      setTimeout(() => URL.revokeObjectURL(u), 30000);
    } catch { window.open(pdfUrl, "_blank"); }
  };
  // Record the print so the order shows "Printed <time>" and offers Re-print.
  const stampPrinted = async () => {
    try {
      const ref = stampRef;
      const r = await fetch("/api/orders/" + encodeURIComponent(ref) + "/label/printed", { method: "POST" });
      const j = await r.json();
      if (j && j.printedAt && onPrinted) onPrinted(j);
    } catch { /* non-fatal */ }
  };
  const [printing, setPrinting] = useState(false);
  const print = async () => {
    const station = (window.directPrintStation && window.directPrintStation()) || "";
    let queued = false;
    if (oid && station && station !== "browser") {
      setPrinting(true);
      try {
        const pr = await fetch("/api/labels/print", { method: "POST", headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ ids: String(oid), station, orderRef: String(o.id) }) });
        queued = pr.ok; // queued to the station silently
      } catch { /* fall through to manual */ }
      setPrinting(false);
    }
    if (!queued && pdfUrl) window.open(pdfUrl, "_blank"); // no station / failed → print manually
    stampPrinted();
  };
  return (
    <span className="label-actions" style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
      <span className="badge badge--mint" title={label.tracking ? "Royal Mail · " + label.tracking : "Label ready"}><Icon name="truck" /> Label</span>
      {printedAt && <span className="kick" style={{ fontSize: 11 }} title={"Label printed " + fmtTime(printedAt)}>Printed {fmtTime(printedAt)}</span>}
      <button className="btn btn--ghost btn--sm" onClick={view}><Icon name="external" /> View</button>
      <button className="btn btn--ghost btn--sm" disabled={printing} onClick={print}><Icon name="print" /> {printing ? "Printing…" : (printedAt ? "Re-print" : "Print")}</button>
      <button className="btn btn--ghost btn--sm" onClick={download}><Icon name="download" /> Download</button>
    </span>
  );
}

// Create an ADDITIONAL Royal Mail parcel for an order that's already labelled /
// shipped. Shopify can't make a 2nd label on a closed order, so this is a plain
// Click & Drop label under a synthetic reference (e.g. "4177-2"). CRM-only: we
// never write to Shopify or email the customer — it just prints here.
function AddShipmentModal({ o, onClose, onDone }) {
  const est = (DTF.shipEstimate && DTF.shipEstimate(o)) || { weightGrams: 0, format: "smallParcel" };
  const [services, setServices] = useState([]);
  const [weight, setWeight] = useState(est.weightGrams ? String(est.weightGrams) : "");
  const [serviceCode, setServiceCode] = useState("");
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState("");
  useEffect(() => {
    fetch("/api/settings/royalmail").then(r => r.json()).then(d => {
      // The endpoint already returns only enabled services ({id,name,code,format}).
      const en = d.services || [];
      setServices(en);
      const def = (d.defaultServiceId && en.find(s => s.id === d.defaultServiceId)) || en[0];
      setServiceCode(c => c || (def ? def.code : ""));
    }).catch(() => {});
  }, []);
  const submit = async () => {
    setErr("");
    if (!(Number(weight) > 0)) { setErr("Enter a weight in grams."); return; }
    if (!serviceCode) { setErr("Pick a service."); return; }
    // Royal Mail validates the package format against the service (e.g. TPN24 needs
    // "parcel", not "smallParcel"), so always use the selected service's own format.
    const svc = services.find(s => s.code === serviceCode);
    const fmt = (svc && svc.format) || "smallParcel";
    setBusy(true);
    try {
      const ref = String(o.id).replace(/^#/, "");
      const r = await fetch("/api/orders/" + encodeURIComponent(ref) + "/additional-shipment", {
        method: "POST", headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ weight: Number(weight), serviceCode, format: fmt }),
      });
      const j = await r.json().catch(() => ({}));
      if (!r.ok) throw new Error(j.error || j.message || "Royal Mail rejected the shipment");
      onDone && onDone();
    } catch (e) { setErr(e.message || "Failed to create the label."); }
    setBusy(false);
  };
  return (
    <div className="modal-overlay" onClick={onClose}>
      <div className="modal" onClick={e => e.stopPropagation()}>
        <div className="modal__head">
          <h3>Add shipment · {o.id}</h3>
          <button className="icon-btn" onClick={onClose}><Icon name="x" /></button>
        </div>
        <div className="modal__body">
          <p className="dim" style={{ fontSize: 12, marginTop: 0 }}>
            Creates an extra Royal Mail label for this order (a second parcel). It prints here and is tracked separately — nothing is sent to Shopify and the customer isn't emailed.
          </p>
          <div className="kick" style={{ marginBottom: 4 }}>Weight (g)</div>
          <input className="rm-in" type="number" min="1" style={{ width: "100%", marginBottom: 10 }} value={weight} onChange={e => setWeight(e.target.value)} placeholder="weight in grams" />
          <div className="kick" style={{ marginBottom: 4 }}>Service</div>
          <select className="rm-in" style={{ width: "100%" }} value={serviceCode} onChange={e => setServiceCode(e.target.value)}>
            <option value="">— pick a service —</option>
            {services.map(s => <option key={s.id || s.code} value={s.code}>{s.name || s.code}</option>)}
          </select>
          {(() => { const svc = services.find(s => s.code === serviceCode); return svc ? <div className="kick" style={{ fontSize: 11, marginTop: 6 }}>Parcel format: {svc.format} (set by the service)</div> : null; })()}
          {err && <div style={{ color: "#c0392b", fontSize: 12, marginTop: 10 }}>{err}</div>}
        </div>
        <div className="modal__foot">
          <button className="btn btn--ghost btn--sm" onClick={onClose} disabled={busy}>Cancel</button>
          <button className="btn btn--primary btn--sm" onClick={submit} disabled={busy}>{busy ? "Creating…" : "Create label"}</button>
        </div>
      </div>
    </div>
  );
}

// Per-line blank-garment stock for a line that carries a Shopify SKU: our DTF.UK
// shelf vs Rallawise dropship, whether the shelf covers the ordered qty, and a
// one-click "add this exact SKU × order qty to the Rallawise basket" when short.
function LineStock({ sku, need, levels }) {
  const [state, setState] = useState(null); // null | "adding" | "added" | "err:<msg>"
  if (!levels) return <div className="li__stock li__stock--load">checking stock…</div>;
  const lv = levels[sku];
  if (!lv) return null; // SKU not in Shopify (non-garment / unmatched) — stay quiet
  const local = lv.local || 0, ral = lv.ralawise || 0, want = need || 1;
  const enough = local >= want;
  const addToBasket = async () => {
    setState("adding");
    try { await DTF.ralawiseBasket([{ sku, qty: want }]); setState("added"); }
    catch (e) { setState("err:" + e.message); }
  };
  return (
    <div className="li__stock">
      <span className={"badge " + (enough ? "badge--green" : local > 0 ? "badge--amber" : "badge--ghost")}
            title="Available now on our DTF.UK shelf">DTF.UK {local}{want > 1 ? ` / ${want}` : ""}</span>
      <span className="badge badge--ghost" title="Rallawise dropship stock (Shopify)">Rallawise {ral}</span>
      {lv.adminUrl && <a className="li__stock-link" href={lv.adminUrl} target="_blank" rel="noopener noreferrer" title="Open product in Shopify">{sku} ↗</a>}
      {!enough && (
        <span className="li__stock-note">
          {local > 0 ? `only ${local} on the shelf — ` : "not in local stock — "}
          {ral > 0 ? `${Math.max(0, want - local)} to order from Rallawise` : "not on Rallawise either"}
        </span>
      )}
      {!enough && (state === "added"
        ? <span className="badge badge--green">✓ added to Rallawise basket</span>
        : <button className="btn btn--primary btn--sm" disabled={state === "adding"} onClick={addToBasket}
                  title={`Add ${want}× ${sku} to the Rallawise basket`}>
            {state === "adding" ? "adding…" : `+ Rallawise basket · ${want}×`}
          </button>)}
      {typeof state === "string" && state.startsWith("err:") && <span className="li__stock-note">{state.slice(4)}</span>}
    </div>
  );
}

function OrderDetail({ id, go }) {
  const [fetched, setFetched] = useState(undefined); // undefined=loading, "none"=not found, obj=fetched
  const inMem = DTF.getOrder(id);
  // Prefer the freshly-fetched copy over the cached one so an order changed elsewhere
  // (e.g. dispatched in Click & Drop) reflects its current status, not a stale cache.
  const o = (fetched && fetched !== "none") ? fetched : inMem;
  const c = o ? DTF.getCustomer(o.customerId) : null;

  // ALWAYS revalidate against Shopify on open (stale-while-revalidate): show the
  // cached order instantly if we have it, but refetch so a status changed elsewhere
  // (external dispatch/fulfilment, edits, refunds) is picked up. fetchOrderByName
  // replaces the cached copy in place, so the whole page sees the fresh state.
  useEffect(() => {
    let alive = true;
    if (!inMem) setFetched(undefined); // only show the loading state when nothing's cached
    DTF.fetchOrderByName(id)
      .then(r => { if (alive) setFetched(r || (inMem ? undefined : "none")); })
      .catch(() => { if (alive && !inMem) setFetched("none"); });
    return () => { alive = false; };
  }, [id]);

  const [shipFor, setShipFor] = useState(null); // create-label modal
  // Existing shipping label for this order (if any) → View/Print/Download instead
  // of the Create-label CTA. Probe live in Click & Drop only when the order looks
  // labelled/dispatched (so un-labelled orders don't hit Royal Mail on page view).
  const [label, setLabel] = useState(null);
  const refetchLabel = () => {
    if (!o || o.method === "Collection") { setLabel(null); return; }
    const ref = String(o.id).replace(/^#/, "");
    const probe = (o.tracking || o.status === "dispatched") ? "1" : "0";
    fetch("/api/orders/" + encodeURIComponent(ref) + "/label?probe=" + probe)
      .then(r => r.json())
      .then(j => setLabel(j && (j.orderIdentifier || j.labelUrl) ? j : null))
      .catch(() => {});
  };
  useEffect(() => { refetchLabel(); }, [o && o.id]);
  // Additional shipments (2nd/3rd parcel) for this order — CRM-only Royal Mail
  // labels under synthetic refs. Shown alongside the primary label.
  const [extraLabels, setExtraLabels] = useState([]);
  const [addShipOpen, setAddShipOpen] = useState(false);
  const refetchExtras = () => {
    if (!o || o.method === "Collection") { setExtraLabels([]); return; }
    const ref = String(o.id).replace(/^#/, "");
    fetch("/api/orders/" + encodeURIComponent(ref) + "/extra-labels")
      .then(r => r.json())
      .then(j => setExtraLabels((j && j.labels) || []))
      .catch(() => {});
  };
  useEffect(() => { refetchExtras(); }, [o && o.id]);
  // Per-garment-line stock availability (DTF.UK shelf vs Rallawise) for this order.
  const [stockLv, setStockLv] = useState(null);
  useEffect(() => {
    if (!o) return;
    const skus = [...new Set((o.items || []).filter(it => !it.removed && it.sku).map(it => it.sku))];
    if (!skus.length) { setStockLv({}); return; }
    let alive = true;
    DTF.stockLevels(skus).then(l => { if (alive) setStockLv(l); }).catch(() => { if (alive) setStockLv({}); });
    return () => { alive = false; };
  }, [o && o.id]);
  // Local-pickup actions (write to Shopify).
  const [acting, setActing] = useState(null);   // "ready" | "collected"
  const [actErr, setActErr] = useState(null);
  const [, forceRender] = useState(0);
  async function doCollectionAction(kind){
    setActErr(null); setActing(kind);
    try {
      // A combined order is handed over as one unit — apply to every group member
      // (ready + collected both cascade), skipping ones already past that stage.
      const g = DTF.groupOf(o);
      const memberIds = g ? [...new Set([g.parent, ...(g.children || [])])] : [o.id];
      const skip = kind === "ready"
        ? ["collected", "ready-collection", "refunded", "cancelled", "closed"]
        : ["collected", "refunded", "cancelled", "closed"];
      const ids = memberIds.filter(mid => {
        const m = DTF.getOrder(mid);
        if (!m) return true;
        // A production stage can mask the pickup state in m.status, so also skip an
        // order whose pickup FO is already prepared (delivery === "ready-collection").
        if (kind === "ready" && m.delivery === "ready-collection") return false;
        return !skip.includes(m.status);
      });
      for (const one of (ids.length ? ids : [o.id])) {
        if (kind === "ready") await DTF.markReadyForCollection(one);
        else await DTF.markCollected(one);
      }
      forceRender(x => x + 1);
    } catch (e) { setActErr(e.message); }
    finally { setActing(null); }
  }
  // Mark dispatched: fulfil every remaining item in Shopify (no tracking) and
  // notify the customer — mirrors markCollected's optimistic local update.
  async function doDispatch(){
    setActErr(null); setActing("dispatch");
    try {
      await DTF.fulfillOrderAll(o.id, { notify: true });
      const ord = DTF.getOrder(o.id);
      if (ord) { ord.status = "dispatched"; ord.fulfil = "fulfilled"; ord.dispatched = true; ord.packed = true; ord.delivery = "in-transit"; }
      forceRender(x => x + 1);
    } catch (e) { setActErr(e.message); }
    finally { setActing(null); }
  }

  // Cancel the Shopify fulfilment and return the order to the RIP queue — for an
  // order that reads as dispatched but wasn't really printed/shipped (e.g. a
  // combined order whose parcel tracking landed on its sibling).
  const [reverting, setReverting] = useState(false);
  async function doCancelFulfilment(){
    if (!window.confirm("Return " + o.id + " to the RIP queue?\n\nThis reverts any Shopify dispatch/collection status (no customer email is sent) and puts the order back on the production board.")) return;
    setActErr(null); setReverting(true);
    try {
      await DTF.cancelFulfilment(o.id, "rip_queue");
      forceRender(x => x + 1);
    } catch (e) { setActErr(e.message); }
    finally { setReverting(false); }
  }

  // Search the whole mailbox for this customer's emails (incl. archived).
  const [mailThreads, setMailThreads] = useState(null);
  const [emailOpen, setEmailOpen] = useState(false);
  const [emailSent, setEmailSent] = useState("");
  const custEmail = c && c.email;
  useEffect(() => {
    let alive = true;
    setMailThreads(null);
    if (custEmail) DTF.searchMail(custEmail).then(t => alive && setMailThreads(t)).catch(() => alive && setMailThreads([]));
    else setMailThreads([]);
    return () => { alive = false; };
  }, [id, custEmail]);
  // Refresh the emails section in-place (no page reload, no loading flash) — used
  // right after sending so the new message shows up immediately.
  const reloadMail = () => {
    if (!custEmail) { setMailThreads([]); return; }
    DTF.searchMail(custEmail).then(setMailThreads).catch(() => {});
  };

  // AI summary of all emails (sent + received) that contain this order's name in
  // the subject. Fetched once on open; regenerated on demand via the refresh icon.
  const [emailSummary, setEmailSummary] = useState(null);   // { text, count } | null
  const [summaryLoading, setSummaryLoading] = useState(false);
  const loadEmailSummary = React.useCallback((refresh) => {
    if (!o) return;
    const bare = String(o.id).replace(/^#/, "");
    setSummaryLoading(true);
    fetch("/api/orders/" + encodeURIComponent(bare) + "/email-summary" + (refresh ? "?refresh=1" : ""))
      .then(r => r.json())
      .then(j => { setEmailSummary(j && j.count ? j : null); })
      .catch(() => {})
      .finally(() => setSummaryLoading(false));
  }, [o && o.id]);
  useEffect(() => { setEmailSummary(null); loadEmailSummary(false); }, [loadEmailSummary]);
  // Partner-shop assignment: which shops exist + which line items / artwork are
  // handed to each shop (so items/artwork can be split across shops on one order).
  const [shopAssign, setShopAssign] = useState(null); // { shops:[], items:{slug:[ids]}, artwork:{slug:[ids]} }
  const bareId = String(id || "").replace(/^#/, "");
  const reloadAssign = React.useCallback(() => {
    if (!bareId) return;
    fetch("/api/orders/" + encodeURIComponent(bareId) + "/shop-assignments").then(r => r.json()).then(setShopAssign).catch(() => {});
  }, [bareId]);
  useEffect(() => { setShopAssign(null); reloadAssign(); }, [bareId]);
  // itemId → assigned shop slug (from the per-shop id lists).
  const itemShopOf = (lineId) => { const m = (shopAssign && shopAssign.items) || {}; return Object.keys(m).find(sl => (m[sl] || []).includes(lineId)) || ""; };
  async function assignItemShop(lineId, slug) {
    try { const j = await (await fetch("/api/orders/" + encodeURIComponent(bareId) + "/item-shop", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ itemId: lineId, slug }) })).json(); if (j && j.items) setShopAssign(a => ({ ...(a || {}), items: j.items })); } catch (e) {}
  }

  if (!o) return (
    <div className="empty">
      {fetched === "none"
        ? <div className="t">Order not found</div>
        : <><span className="spin spin--dark" /><div className="t">Loading order…</div></>}
    </div>
  );
  const sum = itemsSummary(o);
  // Use Shopify's real (current) figures so the breakdown matches the order total
  // even after edits/refunds — subtotal + shipping already equals the total. Fall
  // back to line-item sum / a per-method shipping estimate only if absent.
  const subtotal = (o.subtotal != null) ? +o.subtotal : o.items.reduce((s,i)=>s+i.qty*i.price,0);
  const shipping = (o.shipping != null) ? +o.shipping : (o.method === "Collection" ? 0 : (o.method === "SD" ? 8.50 : o.method === "DPD" ? 6.00 : 4.20));
  // Prices are VAT-inclusive (Shopify reports 0 separate tax), so show the VAT
  // PORTION included in the total (total × 20/120), not an amount added on top.
  // Only for orders on/after the VAT effective date (01 Jul 2026); earlier orders
  // were pre-registration → no VAT line.
  const vatDue = orderHasVat(o);
  const vat = !vatDue ? 0 : (o.tax != null && +o.tax > 0) ? +o.tax : +(((o.total != null ? +o.total : subtotal + shipping)) / 6).toFixed(2);
  // Email threads for this specific order: must contain the order number in
  // subject OR body (not all emails for this customer).
  const _orderNum = String(o.id).replace(/^#/, "");
  const _orderRef = o.id; // "#5300"
  const _matchesOrder = (t) => {
    const subj = (t.subject || "").toLowerCase();
    const body = (t.body || t.text || "").toLowerCase();
    return subj.includes(_orderNum) || subj.includes(_orderRef.toLowerCase()) ||
           body.includes(_orderNum) || body.includes(_orderRef.toLowerCase()) ||
           t.orderId === o.id;
  };
  const _seen = new Set();
  const custThreads = [
    ...DTF.getThreads().filter(t => (t.customerId === o.customerId || t.orderId === o.id) && _matchesOrder(t)),
    ...(mailThreads || []).filter(_matchesOrder),
  ].filter(t => _seen.has(t.id) ? false : (_seen.add(t.id), true))
   .sort((a,b) => {
     const am = a.orderId===o.id?1:0, bm = b.orderId===o.id?1:0;
     if (am !== bm) return bm - am;            // order-linked first
     return a.updated < b.updated ? 1 : -1;    // then most recent
   });
  const m = DTF.methodMeta[o.method];

  // Combined (grouped) order: several orders dispatched as one parcel, one lead
  // drives it. Surface the link so the operator can jump to the sibling order(s).
  const group = DTF.groupOf(o);
  const isGroupLead = group && group.parent === o.id;
  const groupOthers = group ? [group.parent, ...(group.children || [])].filter(mid => mid !== o.id) : [];

  // Where this order was opened from (list + source), for the Back button.
  // Prev/next now live in the topbar (see Topbar in components.jsx).
  const nav = DTF.getOrderNav ? DTF.getOrderNav() : null;
  const navSrc = nav && nav.meta && nav.meta.source;
  // Where "back" returns to — the page this order was opened from (board/all/new,
  // a customer profile, the inbox, production…). Falls back to the orders list.
  const backRoute = (nav && nav.meta && nav.meta.route) || { view:"orders" };

  // fulfillment steps
  const curIdx = DTF.STEP_ORDER.indexOf(o.status === "closed" ? "delivered" : o.status);

  return (
    <div className="detail">
      <div className="detail__main">
        <div className="crumbs">
          <a onClick={()=>go(backRoute)}>{navSrc || "Orders"}</a><span className="sep">/</span>
          <span style={{color:"var(--txt)"}}>{o.id}</span>
        </div>
        <div className="pagehead">
          <button className="icon-btn" onClick={()=>go(backRoute)} title={navSrc ? ("Back to " + navSrc) : "Back"}><Icon name="back" /></button>
          <h2>{o.id}</h2>
          <Flags flags={o.flags} />
          <StatusBadge status={o.status} />
          {group && groupOthers.length > 0 &&
            <span style={{ display:"inline-flex", alignItems:"center", gap:4, fontSize:12.5, fontWeight:600, color:"var(--mint-ink)", background:"rgba(24,244,175,.14)", border:"1px solid rgba(24,244,175,.4)", borderRadius:8, padding:"3px 9px" }}
                  title="Combined into one dispatch parcel — click to open the linked order">
              <Icon name="link" /> {isGroupLead ? "Combined with" : "Combined under"}{" "}
              {groupOthers.map((mid, i) => (
                <React.Fragment key={mid}>
                  {i > 0 && <span>, </span>}
                  <a style={{ color:"inherit", textDecoration:"underline", cursor:"pointer" }} onClick={()=>go({ view:"order", id: mid })}>
                    {mid}{mid === group.parent ? " (lead)" : ""}
                  </a>
                </React.Fragment>
              ))}
            </span>}
          <span className="meta">{o.date} · {o.channel}</span>
          <div className="actions">
            {o.adminId && DTF.shop && DTF.shop.adminUrl &&
              <a className="btn btn--ghost btn--sm" href={DTF.shop.adminUrl + "/orders/" + o.adminId} target="_blank" rel="noopener noreferrer"><Icon name="external" /> Shopify admin</a>}
            {window.HandoverControl ? <HandoverControl o={o} /> : null}
            <button className="btn btn--ghost btn--sm" onClick={()=>printPackingSlips([o])}><Icon name="print" /> Print packing slip</button>
            <button className="btn btn--ghost btn--sm" onClick={()=>printOrderSummary([o])}><Icon name="doc" /> Order summary</button>
            <button className="btn btn--ghost btn--sm" onClick={()=>downloadInvoice(o)}><Icon name="doc" /> {o.pay === "paid" ? "Paid invoice" : "Proforma invoice"}</button>
            <button className="btn btn--ghost btn--sm" onClick={()=>setEmailOpen(true)}><Icon name="mail" /> Email customer</button>
            {emailSent && <span className="kick" style={{ color:"var(--mint-ink)" }}>email sent ✓</span>}
            {(o.dispatched || o.delivery === "ready-collection") && !(DTF.PRODUCTION_STAGES || []).includes(o.localStage) &&
              <button className="btn btn--ghost btn--sm" onClick={doCancelFulfilment} disabled={reverting}
                      title="Return this order to the RIP queue — reverts any Shopify dispatch/collection status and puts it back on the production board (no customer email)">
                {reverting ? <span className="spin spin--dark" /> : <Icon name="refresh" />} {reverting ? "Reverting…" : "Return to RIP queue"}</button>}
            {!["refunded","cancelled","dispatched","collected","delivered"].includes(o.status) &&
              <HoldControl o={o} onChange={()=>forceRender(x=>x+1)} />}
            {o.method !== "Collection" && (label
              ? <LabelActions o={o} label={label} onPrinted={(rec)=>setLabel(rec)} />
              : <button className="btn btn--ghost btn--sm" onClick={()=>setShipFor([o])}><Icon name="truck" /> Create label</button>)}
            {o.method !== "Collection" && extraLabels.map((xl, i) => (
              <span key={xl.key || i} style={{ display:"inline-flex", alignItems:"center", gap:4 }} title={"Additional parcel " + xl.suffix}>
                <span className="kick" style={{ fontSize:11 }}>Parcel {xl.suffix}</span>
                <LabelActions o={o} label={xl} refOverride={xl.key} onPrinted={()=>refetchExtras()} />
              </span>
            ))}
            {o.method !== "Collection" && (label || extraLabels.length > 0) && !["refunded","cancelled","closed"].includes(o.status) &&
              <button className="btn btn--ghost btn--sm" onClick={()=>setAddShipOpen(true)}><Icon name="truck" /> Add shipment</button>}
            {["refunded","cancelled","closed"].includes(o.status) ? null
              : o.method === "Collection" ? <>
              {o.status !== "collected" && o.status !== "ready-collection" && o.delivery !== "ready-collection" &&
                <button className="btn btn--ghost btn--sm" disabled={!!acting} onClick={()=>doCollectionAction("ready")}>
                  <Icon name="box" /> {acting==="ready" ? "Marking…" : "Mark ready for collection"}</button>}
              {o.status !== "collected" &&
                <button className="btn btn--primary btn--sm" disabled={!!acting} onClick={()=>doCollectionAction("collected")}>
                  <Icon name="check" /> {acting==="collected" ? "Marking…" : "Mark collected"}</button>}
            </> : (
              !["collected","dispatched","delivered"].includes(o.status) &&
                <button className="btn btn--primary btn--sm" disabled={!!acting} onClick={doDispatch}>
                  <Icon name="truck" /> {acting==="dispatch" ? "Marking…" : "Mark dispatched"}</button>
            )}
            {actErr && <span className="dim" style={{color:"#c0392b",fontSize:11,alignSelf:"center"}}>{actErr}</span>}
          </div>
        </div>

        <CustomerNoteBanner customerId={o.customerId} />

        <OrderNotes note={o.note} viewerSide="studio" postUrl={"/api/orders/" + encodeURIComponent(String(o.id).replace(/^#/, "")) + "/note"}
          onPosted={(n) => { o.note = n; forceRender(x => x + 1); }} />

        {/* Email correspondence summary — AI digest of all emails linked by order number */}
        {(summaryLoading || (emailSummary && emailSummary.summary)) && (
          <div style={{ background:"#0d1a14", border:"1px solid #1a3326", borderRadius:8, padding:"10px 14px", marginBottom:10, display:"flex", gap:10, alignItems:"flex-start" }}>
            <span style={{ fontSize:15, flexShrink:0, marginTop:1 }}>✉</span>
            <div style={{ flex:1, minWidth:0 }}>
              <div style={{ display:"flex", alignItems:"center", gap:8, marginBottom:4 }}>
                <span style={{ fontSize:11, fontWeight:600, color:"#18f4af", letterSpacing:"0.04em", textTransform:"uppercase" }}>Email summary</span>
                {emailSummary && emailSummary.count > 0 && <span style={{ fontSize:11, color:"#555" }}>· {emailSummary.count} message{emailSummary.count !== 1 ? "s" : ""}</span>}
                <button onClick={() => loadEmailSummary(true)} disabled={summaryLoading} title="Regenerate" style={{ background:"none", border:"none", color:"#444", cursor:"pointer", padding:"0 2px", fontSize:12, marginLeft:"auto", flexShrink:0 }}>
                  {summaryLoading ? <span className="spin" style={{ width:11, height:11 }} /> : "↺"}
                </button>
              </div>
              {summaryLoading && !(emailSummary && emailSummary.summary)
                ? <span style={{ fontSize:12, color:"#555" }}>Summarising…</span>
                : <p style={{ margin:0, fontSize:13, color:"#ccc", lineHeight:1.5 }}>{emailSummary && emailSummary.summary}</p>}
            </div>
          </div>
        )}


        {/* inline email composer — opens at the top so order links stay copyable */}
        {emailOpen && <OrderEmailComposer o={o} c={c} onClose={(sent)=>{ setEmailOpen(false); if (sent) { setEmailSent("1"); setTimeout(()=>setEmailSent(""), 3000); reloadMail(); setTimeout(reloadMail, 2500); } }} />}

        {/* payment link for unpaid orders (self-hides when paid) */}
        <PaymentCard o={o} />

        {/* production status — kept above fulfilment for quick stage changes */}
        <PrintProgress orderId={o.id} />
        {(() => { const pr = DTF.printResultFor && DTF.printResultFor(o.id); return (pr && !(DTF.printingFor && DTF.printingFor(o.id))) ? (
          <div className="card">
            <div className="card__head"><h3>Print result</h3>{pr.printer && <span className="right kick">{pr.printer}</span>}</div>
            <div className="card__body"><PrintResult rec={pr} /></div>
          </div>
        ) : null; })()}
        <ProductionCard o={o} go={go} onChange={() => forceRender(x => x + 1)} />

        {/* fulfillment progress */}
        <div className="card">
          <div className="card__head"><h3>Fulfilment</h3>
            <div className="right"><MetaBadge map={DTF.fulfilMeta} value={o.fulfil} /><MetaBadge map={DTF.deliveryMeta} value={o.delivery} /></div>
          </div>
          <div className="card__body">
            <div className="steps">
              {DTF.STEP_ORDER.map((s,i) => (
                <div key={s} className={"step"+(i<curIdx?" done":"")+(i===curIdx?" cur":"")}>
                  <div className="step__bar" />
                  <div className="step__lbl">{(DTF.statusMeta[s]||{}).label || s}</div>
                </div>
              ))}
            </div>
          </div>
        </div>

        <ArtworkCard o={o} canMark />

        <PrintingInstructionsCard orderId={o.id} />

        {/* line items */}
        <div className="card">
          <div className="card__head"><h3>Items</h3><span className="right kick">{sum.units} units · {sum.lines} lines{sum.removed ? <> · <span className="li__removed-tag" title="Refunded or removed from the order">{sum.removed} removed</span></> : null}{(() => { const m = DTF.orderMetres(o); return m > 0 ? <> · <span className="m-badge m-badge--lg" title="Total DTF material to RIP (linear metres)">{DTF.fmtMetres(m)}</span></> : null; })()}</span></div>
          <div className="card__body--flush">
            {o.items.map((it,i) => (
              <div className={"lineitem" + (it.removed ? " lineitem--removed" : "")} key={i}>
                <div className="li__thumb">{it.image ? <img src={it.image} alt={it.name} /> : <span className="gm">DTF</span>}</div>
                <div style={{minWidth:0}}>
                  <div className="li__name">{!it.removed && it.qty > 1 && <span className="li__qtytag" title={"Quantity " + it.qty}>×{it.qty}</span>}{it.name}{it.removed && <span className="li__removed-tag" title="Refunded or removed from the order">removed</span>}</div>
                  {(it.productUrl || (it.productLegacyId && DTF.shop && DTF.shop.adminUrl)) && (
                    <div className="li__links">
                      {it.productLegacyId && DTF.shop && DTF.shop.adminUrl &&
                        <a href={DTF.shop.adminUrl + "/products/" + it.productLegacyId} target="_blank" rel="noopener noreferrer">Shopify admin ↗</a>}
                      {it.productUrl &&
                        <a href={it.productUrl} target="_blank" rel="noopener noreferrer">View on store ↗</a>}
                    </div>
                  )}
                  <div className="li__meta">{it.variant ? it.variant + " · " : ""}{DTF.fmtGBP(it.price)} each</div>
                  {!it.removed && it.sku && <LineStock sku={it.sku} need={it.qty} levels={stockLv} />}
                  <ItemProps it={it} orderId={o.id} />
                  {shopAssign && (shopAssign.shops || []).length > 0 && !it.removed && (
                    <div style={{ marginTop: 5, display: "inline-flex", alignItems: "center", gap: 6 }}>
                      <span className="kick" style={{ fontSize: 10.5 }}>Make at</span>
                      <select className="rm-in rm-in--sm" value={itemShopOf(it.id)} onChange={e => assignItemShop(it.id, e.target.value)} title="Hand this item to a partner shop — the shop then sees only its assigned items">
                        <option value="">DTF.uk (in-house)</option>
                        {(shopAssign.shops || []).map(s => <option key={s.slug} value={s.slug}>{s.name}</option>)}
                      </select>
                    </div>
                  )}
                </div>
                <div>
                  {it.removed
                    ? <><div className="li__price">—</div><div className="li__qty">was × {it.orderedQty}</div></>
                    : <>
                      <div className="li__price">{DTF.fmtGBP(it.qty*it.price)}</div>
                      <div className={"li__qty" + (it.qty > 1 ? " li__qty--multi" : "")}>× {it.qty}</div>
                      {(() => { const m = DTF.itemMetres(it); return m > 0 ? <div className="li__metres"><span className="m-badge m-badge--solid" title="DTF material for this line (linear metres)">{DTF.fmtMetres(m)}</span></div> : null; })()}
                    </>}
                </div>
              </div>
            ))}
            <div className="totals">
              {(() => { const m = DTF.orderMetres(o); return m > 0 ? <div className="row row--metres"><span>DTF to RIP</span><span className="m-badge m-badge--solid">{DTF.fmtMetres(m)}</span></div> : null; })()}
              <div className="row"><span className="dim">Subtotal</span><span>{DTF.fmtGBP(subtotal)}</span></div>
              <div className="row"><span className="dim">Shipping · {m.label}</span><span>{DTF.fmtGBP(shipping)}</span></div>
              {vatDue && <div className="row"><span className="dim">incl. VAT (20%)</span><span>{DTF.fmtGBP(vat)}</span></div>}
              <div className="row grand"><span>Total</span><span>{DTF.fmtGBP(o.total)}</span></div>
            </div>
          </div>
        </div>

        <FulfillmentCard o={o} />

        {/* timeline */}
        <div className="card">
          <div className="card__head"><h3>Timeline</h3></div>
          <div className="card__body">
            <div className="timeline">
              {o.timeline.map((t,i) => (
                <div className="tl" key={i}>
                  <span className={"tl__dot"+(t.on?" on":"")} />
                  <div className="tl__t">{t.t}</div>
                  <div className="tl__m">{t.m}</div>
                </div>
              ))}
            </div>
          </div>
        </div>

        {/* marketing attribution — where this sale came from */}
        {o.adminId && <OrderAttributionCard legacyId={o.adminId} />}

        {/* browsing history for this order's customer */}
        {c && c.email && <BrowsingSection email={c.email} />}
      </div>

      {/* side */}
      <div className="detail__side">
        {c ? (
        <div className="card">
          <div className="card__head"><h3>Customer</h3><a className="right kick" style={{cursor:"pointer"}} onClick={()=>go({view:"customer",id:c.id})}>View →</a></div>
          <div className="card__body">
            <div style={{display:"flex",gap:12,alignItems:"center",marginBottom:14,cursor:"pointer"}} onClick={()=>go({view:"customer",id:c.id})} title="Open customer">
              <Avatar name={c.name} lg />
              <div style={{minWidth:0}}>
                <div style={{fontWeight:700,fontSize:15,whiteSpace:"nowrap",overflow:"hidden",textOverflow:"ellipsis"}}>{c.name}</div>
                <div className="mono dim" title={c.email || ""} style={{fontSize:11,display:"flex",alignItems:"center",gap:4}} onClick={e=>e.stopPropagation()}>
                  <span style={{minWidth:0,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"}}>{c.email || "—"}</span>
                  {c.email && <CopyBtn text={c.email} title="Copy email" />}
                </div>
              </div>
            </div>
            <div style={{display:"flex",gap:6,flexWrap:"wrap",marginBottom:14}}>
              {c.subscribed && <span className="badge badge--mint">Subscribed</span>}
              {(c.tags||[]).map(t=><span key={t} className="tag">{t}</span>)}
            </div>
            <dl className="kv">
              <dt>Orders</dt><dd>{c.orders}</dd>
              <dt>Lifetime</dt><dd>{DTF.fmtGBP(c.spent)}</dd>
              <dt>Channel</dt><dd>{c.channel}</dd>
              <dt>Phone</dt><dd>{c.phone || (o.ship && o.ship.phone) || "—"}</dd>
            </dl>
          </div>
        </div>
        ) : (
        <div className="card">
          <div className="card__head"><h3>Customer</h3></div>
          <div className="card__body"><div className="dim" style={{fontSize:12.5}}>No customer record linked to this order.</div></div>
        </div>
        )}

        <div className="card">
          <div className="card__head"><h3>Delivery</h3></div>
          <div className="card__body">
            <div style={{display:"flex",alignItems:"center",gap:8,marginBottom:12}}>
              <span className={"urg-rail urg--"+m.urg} style={{height:18}} />
              <span style={{fontWeight:700}}>{m.label}</span>
            </div>
            <dl className="kv">
              {o.method === "Collection"
                ? <><dt>Collect from</dt><dd>DTF.uk</dd></>
                : <><dt>Ship to</dt><dd>{o.address}</dd></>}
              {!["refunded","cancelled"].includes(o.status) &&
                <><dt>{o.method === "Collection" ? "Collect by" : "Dispatch by"}</dt><dd>{fmtDateLabel(o.fulfillBy)} · {relDay(o.fulfillBy)}</dd></>}
              <dt>Status</dt><dd><MetaBadge map={DTF.deliveryMeta} value={o.delivery} /></dd>
              {o.method === "Collection"
                ? <><dt>Tracking</dt><dd className="dim">N/A — collection</dd></>
                : <><dt>Tracking</dt><dd><Tracking tracking={o.tracking} /></dd></>}
            </dl>
          </div>
        </div>

        <div className="card">
          <div className="card__head"><h3>Tags</h3></div>
          <div className="card__body">
            <div style={{display:"flex",gap:6,flexWrap:"wrap"}}>
              {o.tags && o.tags.length ? o.tags.map(t=><span key={t} className="tag">{t}</span>) : <span className="dim">No tags</span>}
              <button className="tag" style={{cursor:"pointer",borderStyle:"dashed"}}>+ add</button>
            </div>
          </div>
        </div>

        <div className="card">
          <div className="card__head"><Icon name="mail" /><h3>Emails</h3>
            {custThreads.length > 0 && <span className="right kick">{custThreads.length}</span>}
          </div>
          {custThreads.length > 0 ? (
            <div className="card__body--flush viewed">
              {custThreads.slice(0,6).map(t => (
                <a className="viewed__row" key={t.id} href={"#/inbox/"+encodeURIComponent(t.id)} style={{cursor:"pointer"}}
                   onClick={e=>{ if(e.metaKey||e.ctrlKey||e.shiftKey||e.button!==0) return; e.preventDefault(); go({view:"inbox",id:t.id}); }}>
                  <div className="viewed__ic"><Icon name="mail" /></div>
                  <div style={{minWidth:0}}>
                    <div className="viewed__t" style={{whiteSpace:"nowrap",overflow:"hidden",textOverflow:"ellipsis"}}>{t.subject}</div>
                    <div className="viewed__m">{t.orderId===o.id ? "Linked · " : ""}{t.messages.length} message{t.messages.length>1?"s":""} · {t.updated}</div>
                  </div>
                  <div className="viewed__time">{t.unread ? <span className="badge badge--mint">New</span> : null}</div>
                </a>
              ))}
            </div>
          ) : (
            <div className="card__body dim" style={{fontSize:13, display:"flex", alignItems:"center", gap:8}}>
              {mailThreads===null ? <><span className="spin spin--dark" /> Searching mailbox…</> : "No emails linked to this order or customer yet."}
            </div>
          )}
        </div>
      </div>
      {shipFor && <ShipModal orders={shipFor} onClose={()=>{ setShipFor(null); refetchLabel(); }} onDone={()=>{ refetchLabel(); }} />}
      {addShipOpen && <AddShipmentModal o={o} onClose={()=>setAddShipOpen(false)} onDone={()=>{ setAddShipOpen(false); refetchExtras(); }} />}
    </div>
  );
}

Object.assign(window, { OrdersList, OrderDetail, PrintProgress, fmtDateLabel, relDay });
