/* TOMEYLO product catalog — Pinterest-style masonry */
const { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } = React;
const t = (window.TMOI18N && window.TMOI18N.t) || ((s) => s);
const tf = (window.TMOI18N && window.TMOI18N.tf) || ((s) => s);

/* ---------- helpers ---------- */
const usePersisted = (key, initial) => {
  const [v, setV] = useState(() => {
    try {
      const s = localStorage.getItem(key);
      return s ? JSON.parse(s) : initial;
    } catch { return initial; }
  });
  useEffect(() => {
    try { localStorage.setItem(key, JSON.stringify(v)); } catch {}
  }, [key, v]);
  return [v, setV];
};

/* Product image.
   - thumb: cards load images/thumb/<code>.jpg (480px). On error, fall back to the
     full-res original, then to a code placeholder.
   - no thumb (lightbox): loads the full-res original directly — zoom stays sharp.
   IMGDIMS (image-dims.js) gives each image's pixel size so the card reserves the
   correct height before the image loads → no masonry re-layout jump. */
const IMGDIMS = (typeof window !== 'undefined' && window.IMGDIMS) || {};
const ProductImage = ({ code, alt, onLoad, thumb, load = true }) => {
  // stage 0 = thumb (if thumb requested), 1 = original, 2 = fallback placeholder
  const [stage, setStage] = useState(thumb ? 0 : 1);
  useEffect(() => { setStage(thumb ? 0 : 1); }, [code, thumb]);
  if (stage >= 2) {
    return (
      <div className="img-fallback" aria-label={alt}>
        <div className="img-fallback-code">{code}</div>
      </div>
    );
  }
  const dim = IMGDIMS[code];
  // width/height attributes give the <img> an intrinsic size, so its height is
  // reserved from the aspect ratio even before (or without) a src — the masonry
  // then measures the correct card height without loading any image.
  const wh = thumb && dim ? { width: dim[0], height: dim[1] } : {};
  // `load` gates the network request: only cards scrolled into view fetch their image.
  const src = load ? (stage === 0 ? `images/thumb/${code}.jpg` : `images/${code}.jpg`) : undefined;
  return (
    <img
      key={stage}
      src={src}
      alt={alt}
      loading="lazy"
      decoding="async"
      {...wh}
      onLoad={onLoad}
      onError={() => setStage(s => s + 1)}
    />
  );
};

/* Scroll reveal: once a card becomes active (near the viewport, decided by the
   masonry's deterministic windowing) it stays revealed — so its image loads once and
   the fade-in plays once, without re-animating or unloading as you scroll past. */
const useSeen = (active) => {
  const [seen, setSeen] = useState(false);
  useEffect(() => { if (active && !seen) setSeen(true); }, [active, seen]);
  return seen;
};

/* ---------- Barcode SVG (proper EAN-13) ---------- */
const BarcodeSVG = ({ value, height = 28 }) => {
  // EAN-13 encoding tables
  // L-code (left, odd parity), G-code (left, even parity), R-code (right)
  const L = ['0001101','0011001','0010011','0111101','0100011','0110001','0101111','0111011','0110111','0001011'];
  const G = ['0100111','0110011','0011011','0100001','0011101','0111001','0000101','0010001','0001001','0010111'];
  const R = ['1110010','1100110','1101100','1000010','1011100','1001110','1010000','1000100','1001000','1110100'];
  // Parity pattern based on first digit (decides L/G mix for digits 2-7)
  const PARITY = ['LLLLLL','LLGLGG','LLGGLG','LLGGGL','LGLLGG','LGGLLG','LGGGLL','LGLGLG','LGLGGL','LGGLGL'];

  const bits = useMemo(() => {
    let raw = String(value).replace(/\D/g, '');
    // Pad / truncate to 12, then compute checksum for 13th digit.
    if (raw.length < 12) raw = raw.padStart(12, '0');
    if (raw.length > 13) raw = raw.slice(0, 13);
    if (raw.length === 12) {
      // calc check digit
      let sum = 0;
      for (let i = 0; i < 12; i++) sum += parseInt(raw[i],10) * (i % 2 === 0 ? 1 : 3);
      const check = (10 - (sum % 10)) % 10;
      raw = raw + check;
    }
    const digits = raw.split('').map(d => parseInt(d, 10));
    const first = digits[0];
    const parity = PARITY[first];
    let out = '101'; // start guard
    for (let i = 1; i <= 6; i++) {
      out += parity[i-1] === 'L' ? L[digits[i]] : G[digits[i]];
    }
    out += '01010'; // center guard
    for (let i = 7; i <= 12; i++) {
      out += R[digits[i]];
    }
    out += '101'; // end guard
    return out;
  }, [value]);

  const totalModules = bits.length; // 95
  return (
    <svg className="barcode-svg" viewBox={`0 0 ${totalModules} ${height}`} preserveAspectRatio="none" aria-label={`barcode ${value}`}>
      {bits.split('').map((b, i) =>
        b === '1' ? <rect key={i} x={i} y={0} width={1} height={height} fill="currentColor" /> : null
      )}
    </svg>
  );
};

/* ---------- Banner Card (group of products sharing one image) ---------- */
const BannerCard = ({ p, showBarcode, onOpen, favs, onToggleFav, cols, gap, active }) => {
  const imgKey = p.imageKey || p.anchorCode || (p.members && p.members[0]?.code);
  const groupAlt = `${t(p.title || '')} ${t('系列')} ${tf('共 {n} 件',{n:p.members.length})}`;
  const bannerCols = Math.min(Math.max(cols || 4, 4), 6);
  const bannerStyle = { '--banner-cols': bannerCols, '--banner-gap': (gap || 14) + 'px' };
  const seen = useSeen(active);
  return (
    <div className={"card banner-card reveal" + (seen ? " is-in" : "")} style={bannerStyle} data-screen-label={`Banner ${p.title}`}>
      <div className="banner-header">
        <div className="banner-title">{t(p.title)}</div>
        <div className="banner-count">{tf('共 {n} 件',{n:p.members.length})}</div>
      </div>
      <div className="banner-grid">
        <button
          className="banner-img-btn"
          onClick={() => onOpen({ ...p, code: p.title || p.anchorCode, name: groupAlt })}
          aria-label={`${t('放大')} ${groupAlt}`}
        >
          <ProductImage code={imgKey} alt={groupAlt} thumb load={seen} />
          <span className="card-zoom-hint" aria-hidden="true">
            <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/><path d="M11 8v6M8 11h6"/></svg>
          </span>
        </button>
        <div className="banner-list">
          <ul className="banner-rows">
            {p.members.map((m, i) => {
              const fav = favs && favs.includes(m.code);
              return (
                <li key={m.code} className="banner-row">
                  <div className="banner-row-code">{m.code}</div>
                  <div className="banner-row-mid">
                    {showBarcode && <BarcodeSVG value={m.barcode} />}
                    <div className="banner-row-barnum">{m.barcode}</div>
                  </div>
                  <div className="banner-row-name">{m.name}</div>
                  <button
                    className={"banner-fav" + (fav ? " is-fav" : "")}
                    onClick={(e) => { e.stopPropagation(); onToggleFav && onToggleFav(m.code); }}
                    aria-label={fav ? t('取消收藏') : t('收藏')}
                    aria-pressed={fav}
                  >
                    <svg width="16" height="16" viewBox="0 0 24 24" fill={fav ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2" strokeLinejoin="round">
                      <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>
                    </svg>
                  </button>
                </li>
              );
            })}
          </ul>
        </div>
      </div>
    </div>
  );
};

/* ---------- Card ---------- */
const ProductCard = ({ p, showBarcode, onOpen, isFav, onToggleFav, active }) => {
  const imgKey = p.imageKey || p.code;
  const seen = useSeen(active);
  return (
    <div className={"card reveal" + (seen ? " is-in" : "")} data-screen-label={`Card ${p.code}`}>
      <div className="card-meta">
        <div className="card-code">{p.code}</div>
        <div className="card-barcode" aria-label={`${t('條碼')} ${p.barcode}`}>
          {showBarcode && <BarcodeSVG value={p.barcode} />}
          <div className="card-barcode-num">{p.barcode}</div>
        </div>
      </div>
      <button className="card-img-btn" onClick={() => onOpen(p)} aria-label={`${t('放大')} ${p.code} ${p.name}`}>
        <ProductImage code={imgKey} alt={p.name} thumb load={seen} />
        <span className="card-zoom-hint" aria-hidden="true">
          <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/><path d="M11 8v6M8 11h6"/></svg>
        </span>
      </button>
      <div className="card-foot">
        <div className="card-name" title={p.name}>{
          p.name.split(/(\([^)]*\))/g).map((part, i) => {
            if (part.includes('紅')) return <span key={i} className="color-red">{part}</span>;
            if (part.includes('藍')) return <span key={i} className="color-blue">{part}</span>;
            if (part.includes('黑')) return <span key={i} className="color-black">{part}</span>;
            return part;
          })
        }</div>
        <button
          className={"fav-btn" + (isFav ? " is-fav" : "")}
          onClick={() => onToggleFav(p.code)}
          aria-label={isFav ? t('取消收藏') : t('收藏')}
          aria-pressed={isFav}
        >
          <svg width="20" height="20" viewBox="0 0 24 24" fill={isFav ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2" strokeLinejoin="round">
            <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>
          </svg>
        </button>
      </div>
    </div>
  );
};

/* ---------- Lightbox ---------- */
const Lightbox = ({ p, onClose, onCopy, copiedField, favs, onToggleFav }) => {
  useEffect(() => {
    const onKey = (e) => { if (e.key === 'Escape') onClose(); };
    window.addEventListener('keydown', onKey);
    document.body.style.overflow = 'hidden';
    return () => {
      window.removeEventListener('keydown', onKey);
      document.body.style.overflow = '';
    };
  }, [onClose]);
  if (!p) return null;
  const isGroup = p.kind === 'group';
  const seriesLetter = isGroup
    ? (p.anchorCode ? p.anchorCode[0] : (p.members && p.members[0]?.code[0]))
    : p.code[0];
  return (
    <div className="lb-backdrop" onClick={onClose}>
      <div className={"lb-panel" + (isGroup ? " lb-panel--group" : "")} onClick={(e) => e.stopPropagation()}>
        <button className="lb-close" onClick={onClose} aria-label={t('關閉')}>
          <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M6 6l12 12M18 6l-12 12"/></svg>
        </button>
        <div className="lb-img-wrap">
          <ProductImage code={p.imageKey || (isGroup ? p.anchorCode : p.code)} alt={isGroup ? p.title : p.name} />
        </div>
        <div className="lb-info">
          {isGroup ? (
            <>
              <h2 className="lb-name lb-group-title">{t(p.title)}</h2>
              <div className="lb-group-sub">{tf('共 {n} 件',{n:p.members.length})}</div>
              <ul className="lb-group-list">
                {p.members.map(m => {
                  const fav = favs && favs.includes(m.code);
                  return (
                  <li key={m.code} className="lb-group-item">
                    <div className="lb-group-item-head">
                      <div className="lb-group-code">{m.code}</div>
                      <div className="lb-group-actions">
                        <button
                          className={"lb-group-fav" + (fav ? " is-fav" : "")}
                          onClick={() => onToggleFav && onToggleFav(m.code)}
                          aria-label={fav ? t('取消收藏') : t('收藏')}
                          aria-pressed={fav}
                        >
                          <svg width="18" height="18" viewBox="0 0 24 24" fill={fav ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2" strokeLinejoin="round">
                            <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>
                          </svg>
                        </button>
                        <button className="lb-copy lb-copy--sm" onClick={() => onCopy('code:'+m.code, m.code)}>
                          {copiedField === 'code:'+m.code ? t('已複製 ✓') : t('複製代號')}
                        </button>
                      </div>
                    </div>
                    <div className="lb-group-item-name">{m.name}</div>
                    <div className="lb-group-bar">
                      <BarcodeSVG value={m.barcode} height={36} />
                      <div className="lb-group-bar-row">
                        <div className="lb-group-bar-num">{m.barcode}</div>
                        <button className="lb-copy lb-copy--sm" onClick={() => onCopy('bar:'+m.code, m.barcode)}>
                          {copiedField === 'bar:'+m.code ? t('已複製 ✓') : t('複製條碼')}
                        </button>
                      </div>
                    </div>
                  </li>
                  );
                })}
              </ul>
              <div className="lb-meta">{t('系列')} {seriesLetter}</div>
            </>
          ) : (
            <>
              <div className="lb-code-row">
                <div className="lb-code">{p.code}</div>
                <button className="lb-copy" onClick={() => onCopy('code', p.code)}>
                  {copiedField === 'code' ? t('已複製 ✓') : t('複製代號')}
                </button>
              </div>
              <h2 className="lb-name">{p.name}</h2>
              <div className="lb-barcode-block">
                <BarcodeSVG value={p.barcode} height={56} />
                <div className="lb-barcode-row">
                  <div className="lb-barcode-num">{p.barcode}</div>
                  <button className="lb-copy" onClick={() => onCopy('barcode', p.barcode)}>
                    {copiedField === 'barcode' ? t('已複製 ✓') : t('複製條碼')}
                  </button>
                </div>
              </div>
              <div className="lb-meta">{t('系列')} {seriesLetter}</div>
            </>
          )}
        </div>
      </div>
    </div>
  );
};

/* ---------- Masonry (row-major, JS-positioned) ---------- */
const Masonry = ({ items, cols, gap, render }) => {
  const containerRef = useRef(null);
  const itemRefs = useRef(new Map());
  const [layout, setLayout] = useState({ positions: [], height: 0 });

  // Determine actual column count based on viewport
  const computeCols = useCallback(() => {
    const w = window.innerWidth;
    if (w <= 540) return 2;
    if (w <= 760) return 3;
    if (w <= 980) return 4;
    if (w <= 1200) return 5;
    if (w <= 1480) return Math.min(cols, 6);
    return cols;
  }, [cols]);

  const doLayout = useCallback(() => {
    const container = containerRef.current;
    if (!container) return;
    const W = container.clientWidth;
    const numCols = computeCols();
    const colWidth = (W - gap * (numCols - 1)) / numCols;
    const colTops = new Array(numCols).fill(0);
    const positions = [];

    // Compute each item's span (clamped to numCols)
    // Banner cards: full width across all columns.
    const spanOf = (p) => {
      if (p.kind === 'group') return numCols;
      if (p.size === 'medium') return Math.min(2, numCols); // medium = 2 column units
      return 1;
    };

    items.forEach((p, i) => {
      const span = spanOf(p);
      const k = `${p.code}-${p.seq}`;
      const el = itemRefs.current.get(k);
      const h = el ? el.offsetHeight : 0;

      // Find leftmost starting column where this span fits, with min(max) of top among those columns
      let bestCol = 0;
      let bestY = Infinity;
      for (let c = 0; c + span <= numCols; c++) {
        let maxTop = 0;
        for (let s = 0; s < span; s++) maxTop = Math.max(maxTop, colTops[c + s]);
        if (maxTop < bestY) { bestY = maxTop; bestCol = c; }
      }
      const x = bestCol * (colWidth + gap);
      const w = colWidth * span + gap * (span - 1);
      const y = bestY;
      positions.push({ key: k, x, y, w, h });
      for (let s = 0; s < span; s++) colTops[bestCol + s] = y + h + gap;
    });

    const height = Math.max(0, ...colTops) - gap;
    setLayout({ positions, height });
  }, [items, gap, computeCols]);

  // Position synchronously before paint so cards never flash stacked at (0,0).
  // That stacked frame would otherwise make every lazy image + reveal observer
  // think all cards are in-viewport (defeating windowed loading and scroll reveal).
  useLayoutEffect(() => {
    doLayout();
  }, [doLayout]);

  useEffect(() => {
    const onResize = () => doLayout();
    window.addEventListener('resize', onResize);
    return () => window.removeEventListener('resize', onResize);
  }, [doLayout]);

  // Watch each card for size changes (image loads etc.)
  useEffect(() => {
    const ro = new ResizeObserver(() => doLayout());
    itemRefs.current.forEach(el => el && ro.observe(el));
    return () => ro.disconnect();
  }, [items, doLayout]);

  // Deterministic windowing: track the scroll viewport (in masonry-local coords) and
  // only activate cards whose vertical band is near it. Active cards fetch their image
  // and fade in; far-off cards stay as reserved-height placeholders (no image request).
  const [win, setWin] = useState({ top: -1, bottom: -1 });
  useEffect(() => {
    let raf = 0;
    const update = () => {
      raf = 0;
      const container = containerRef.current;
      if (!container) return;
      const rect = container.getBoundingClientRect();
      const top = -rect.top;                          // scroll offset into the masonry
      const bottom = top + window.innerHeight;
      setWin(prev => (Math.abs(prev.top - top) < 40 && Math.abs(prev.bottom - bottom) < 40) ? prev : { top, bottom });
    };
    const onScroll = () => { if (!raf) raf = requestAnimationFrame(update); };
    update();
    window.addEventListener('scroll', onScroll, { passive: true });
    window.addEventListener('resize', onScroll);
    return () => { window.removeEventListener('scroll', onScroll); window.removeEventListener('resize', onScroll); cancelAnimationFrame(raf); };
  }, [layout.height]);

  const posByCode = new Map(layout.positions.map(p => [p.key, p]));
  const MARGIN = 700; // px above/below the viewport that count as "near"

  return (
    <div
      ref={containerRef}
      className="masonry"
      style={{ height: layout.height + 'px' }}
    >
      {(() => { const _renderCols = computeCols(); return items.map(p => {
        const k = `${p.code}-${p.seq}`;
        const pos = posByCode.get(k);
        const style = pos
          ? { transform: `translate(${pos.x}px, ${pos.y}px)`, width: pos.w + 'px' }
          : { visibility: 'hidden', width: '100%' };
        const active = !!pos && win.bottom >= 0
          && pos.y < win.bottom + MARGIN
          && pos.y + pos.h > win.top - MARGIN;
        return (
          <div
            key={k}
            ref={(el) => {
              if (el) itemRefs.current.set(k, el);
              else itemRefs.current.delete(k);
            }}
            style={style}
          >
            {render(p, { numCols: _renderCols, gap, active })}
          </div>
        );
      }); })()}
    </div>
  );
};

/* ---------- Main App ---------- */
const App = () => {
  const TWEAKS_DEFAULTS = /*EDITMODE-BEGIN*/{
    "columnsDesktop": 6,
    "showBarcodeOnCard": true,
    "darkMode": false,
    "density": "comfortable",
    "wireframe": false
  }/*EDITMODE-END*/;
  const [tweaks, setTweaks] = (window.useTweaks || ((d)=>useState(d)))(TWEAKS_DEFAULTS);
  const setTweak = (k, v) => {
    if (typeof k === 'object') setTweaks(prev => ({...prev, ...k}));
    else setTweaks(prev => ({...prev, [k]: v}));
  };

  const seriesKeys = Object.keys(window.PRODUCTS || {}).sort();
  const initialSeries = (() => {
    const q = new URLSearchParams(window.location.search).get('series');
    if (q && seriesKeys.includes(q.toUpperCase())) return q.toUpperCase();
    return seriesKeys[0] || 'A';
  })();
  const [series, setSeries] = useState(initialSeries);
  const initialQuery = (() => {
    try { return new URLSearchParams(window.location.search).get('q') || ''; } catch(e){ return ''; }
  })();
  const [query, setQuery] = useState(initialQuery);
  const [sortMode, setSortMode] = useState('type'); // default to curated 同類型 order
  const [favs, setFavs] = usePersisted('tomeylo:favs', []);
  const [showFavsOnly, setShowFavsOnly] = useState(false);
  const [open, setOpen] = useState(null);
  const [copied, setCopied] = useState(null);
  const [scrolled, setScrolled] = useState(false);
  const [collapsed, setCollapsed] = useState(false);
  const [menuOpen, setMenuOpen] = useState(false);
  const [bbSearchOpen, setBbSearchOpen] = useState(false);
  const [openRow, setOpenRow] = useState(null);
  const SERIES_TITLES = {A:'混合龍頭',B:'自由栓龍頭',C:'水塞 / 龍頭配件',D:'起泡頭',E:'進排水配件',F:'管用配件',G:'洗衣機配件',H:'瓦斯配件',I:'淨水濾心',J:'淨水器配件',K:'衛浴間配件',L:'淋浴配件',M:'水箱水塔另件',N:'面盆 / 水槽配件',O:'浴室鏡',P:'管束夾 / 園藝配件'};
  const MENU = window.TMOMENU || {INSTALL:[],ABOUT:[],CONTACT:[],SOCIAL:[]};
  const INSTALL_ITEMS = (MENU.INSTALL||[]);
  const toggleRow = (k) => setOpenRow(r => r===k ? null : k);

  // Apply dark mode
  useEffect(() => {
    document.documentElement.dataset.theme = tweaks.darkMode ? 'dark' : 'light';
  }, [tweaks.darkMode]);

  // Apply wireframe (線稿) mode — strips color & images so layout is the focus
  useEffect(() => {
    document.documentElement.dataset.wireframe = tweaks.wireframe ? 'on' : 'off';
  }, [tweaks.wireframe]);

  // Apply series accent color
  useEffect(() => {
    document.documentElement.dataset.series = series;
  }, [series]);

  useEffect(() => {
    let lastY = window.scrollY;
    let ticking = false;
    const onScroll = () => {
      if (ticking) return;
      ticking = true;
      requestAnimationFrame(() => {
        const y = window.scrollY;
        setScrolled(y > 4);
        const delta = y - lastY;
        if (y < 60) {
          setCollapsed(false);
        } else if (delta > 4) {
          setCollapsed(true);
        } else if (delta < -4) {
          setCollapsed(false);
        }
        lastY = y;
        ticking = false;
      });
    };
    window.addEventListener('scroll', onScroll, {passive: true});
    return () => window.removeEventListener('scroll', onScroll);
  }, []);

  // Keep the bottom bar's search input above the on-screen keyboard.
  // Mobile browsers don't move a fixed;bottom:0 bar above the software keyboard,
  // so while the search is open we lift the bar by the keyboard height (measured
  // via visualViewport) — otherwise the user can't see what they type.
  useEffect(() => {
    const vv = window.visualViewport;
    if (!vv) return;
    const bar = document.querySelector('.botbar');
    if (!bar) return;
    const update = () => {
      const inset = Math.max(0, window.innerHeight - vv.height - vv.offsetTop);
      if (bbSearchOpen && inset > 40) { bar.style.bottom = inset + 'px'; }
      else { bar.style.bottom = ''; }
    };
    vv.addEventListener('resize', update);
    vv.addEventListener('scroll', update);
    update();
    return () => {
      vv.removeEventListener('resize', update);
      vv.removeEventListener('scroll', update);
      bar.style.bottom = '';
    };
  }, [bbSearchOpen]);

  // Footer accordion (mobile) — matches homepage [data-acc] behavior
  useEffect(() => {
    const handler = (e) => {
      const btn = e.target.closest('button');
      if (btn && btn.parentElement && btn.parentElement.classList.contains('ft-row')) {
        btn.parentElement.classList.toggle('open');
      }
    };
    document.addEventListener('click', handler);
    return () => document.removeEventListener('click', handler);
  }, []);

  const data = window.PRODUCTS;
  // Per-series type keywords. Each series only uses its OWN keyword list,
  // so a D-series product can never be mis-grouped under an A/B keyword (e.g. "浴缸", "長栓").
  // Order within each list defines display order — first match wins.
  const TYPE_PATTERNS_BY_SERIES = {
    A: [
      ['淋浴龍頭', '淋浴龍頭'],
      ['沐浴龍頭', '沐浴龍頭'],
      ['蓮蓬', '蓮蓬龍頭'],
      ['壁式', '壁式龍頭'],
      ['立式', '立式龍頭'],
      ['台盆', '台盆龍頭'],
      ['浴缸', '浴缸龍頭'],
      ['廚房', '廚房龍頭'],
      ['長栓', '長栓'],
      ['短栓', '短栓'],
      ['過濾', '過濾龍頭'],
      ['伸縮', '伸縮龍頭'],
      ['冷熱|混合', '混合龍頭'],
      ['水塞', '水塞配件'],
      ['軟管|蛇管', '軟管配件'],
      ['花灑|蓮蓬頭', '花灑配件'],
      ['配件|螺絲|零件', '配件零件'],
    ],
    D: [
      ['濾泡頭|起泡器|起泡頭|起波器|起波頭|省水閥|省水|分流器|節水器|外牙接頭', '起泡頭'],
      ['過濾', '過濾器'],
      ['珠鏈|珠鍊|鏈條|鍊條', '珠鏈條'],
      ['墊圈', '墊圈'],
      ['延伸器', '延伸器'],
      ['飲水機', '飲水機'],
    ],
    F: [
      ['內外牙', '內外牙'],
      ['外牙', '外牙'],
      ['OS內牙', 'OS內牙'],
      ['內牙', '內牙'],
      ['凡而', '凡而'],
      ['四角中間', '四角中間'],
      ['ST銅珠閥', 'ST銅珠閥'],
      ['球塞ST', '球塞ST'],
      ['銅球塞', '銅球塞'],
      ['接頭', '接頭'],
      ['OS', 'OS'],
      ['OL', 'OL'],
      ['OP', 'OP'],
      ['OT', 'OT'],
      ['WS', 'WS'],
      ['逆止閥', '逆止閥'],
      ['逆福特', '逆福特'],
      ['南亞B管|南亞|B管', '南亞B管'],
      ['由任', '由任'],
      ['立布', '立布'],
      ['管帽', '管帽'],
      ['三通', '三通'],
      ['牙塞', '牙塞'],
      ['彎頭', '彎頭'],
    ],
    G: [
      ['快速珠鏈|快速珠鍊', '快速珠鏈'],
      ['快速螺絲', '快速螺絲'],
      ['專利洗衣機進水管', '專利洗衣機進水管'],
      ['珠鏈洗衣機|珠鍊洗衣機', '珠鏈洗衣機'],
      ['螺絲洗衣機', '螺絲洗衣機'],
      ['濾網', '濾網'],
    ],
  };
  // B and C reuse A's keyword set by default.
  TYPE_PATTERNS_BY_SERIES.B = TYPE_PATTERNS_BY_SERIES.A;
  TYPE_PATTERNS_BY_SERIES.C = TYPE_PATTERNS_BY_SERIES.A;

  const getPatternsFor = (s) => TYPE_PATTERNS_BY_SERIES[s] || TYPE_PATTERNS_BY_SERIES.A;
  const getTypeIndex = (name, s) => {
    const pats = getPatternsFor(s);
    if (!name) return pats.length;
    for (let i = 0; i < pats.length; i++) {
      if (new RegExp(pats[i][0]).test(name)) return i;
    }
    return pats.length;
  };
  const getType = (name, s) => {
    const pats = getPatternsFor(s);
    const idx = getTypeIndex(name, s);
    return idx === pats.length ? '其他' : pats[idx][1];
  };

  const items = useMemo(() => {
    const searching = !!query.trim();
    let list = searching
      ? Object.keys(data).flatMap(k => (data[k].items || []))
      : (data[series]?.items || []).slice();
    if (!searching && sortMode === 'code') {
      list.sort((a,b) => {
        const ca = a.anchorCode || a.code;
        const cb = b.anchorCode || b.code;
        return ca.localeCompare(cb);
      });
    } else if (!searching && sortMode === 'type') {
      // Series with an explicit, user-defined 「依同類型產品」 order.
      // When present, use it verbatim (by anchorCode/code) and skip the keyword algorithm.
      const MANUAL_TYPE_ORDER = {
        A: ['A001','A064','A004','A011','A020','A021','A060','A079','A098','A099','A094','A012','A013','A037','A065','A014','A018','A015','A019','A016','A068','A087','A066','A078','A097'],
      };
      if (MANUAL_TYPE_ORDER[series]) {
        const ord = MANUAL_TYPE_ORDER[series];
        const rank = new Map(ord.map((c,i)=>[c,i]));
        const keyOf = (p) => p.anchorCode || p.code;
        list.sort((a,b) => {
          const ra = rank.has(keyOf(a)) ? rank.get(keyOf(a)) : 9999;
          const rb = rank.has(keyOf(b)) ? rank.get(keyOf(b)) : 9999;
          if (ra !== rb) return ra - rb;
          return keyOf(a).localeCompare(keyOf(b));
        });
      } else {
      // codes that should always sink to the very end of the type-grouped list
      const TAIL_CODES = new Set(['E009','E008','E003','E322','E336','E337']);
      // codes that must appear consecutively, in the order listed
      const CLUSTERS = [
        ['E281','E285','E286'],
      ];
      const clusterOrder = new Map(); // code -> [clusterIdx, posInCluster]
      CLUSTERS.forEach((arr, ci) => arr.forEach((c, pi) => clusterOrder.set(c, [ci, pi])));
      // anchor each cluster to the type-index of its FIRST item (so the cluster lives in that group)
      const clusterAnchorTypeIdx = CLUSTERS.map(arr => {
        const first = (data[series]?.items || []).find(x => x.code === arr[0]);
        return first ? getTypeIndex(first.name, series) : 999;
      });
      // group by type using TYPE_PATTERNS list order, then by code within each group
      list.sort((a,b) => {
        // Card-size rank: big card (group) → medium card → individual card.
        const sizeRank = (p) => p.kind === 'group' ? 0 : (p.size === 'medium' ? 1 : 2);
        const sa = sizeRank(a), sb = sizeRank(b);
        if (sa !== sb) return sa - sb;
        // L series: names containing 「組」 float to the very front of the type list.
        if (series === 'L') {
          const za = (a.name||'').includes('組') ? 0 : 1;
          const zb = (b.name||'').includes('組') ? 0 : 1;
          if (za !== zb) return za - zb;
        }
        const ta = TAIL_CODES.has(a.code) ? 1 : 0;
        const tb = TAIL_CODES.has(b.code) ? 1 : 0;
        if (ta !== tb) return ta - tb;
        const ca = clusterOrder.get(a.code);
        const cb = clusterOrder.get(b.code);
        const ia = ca ? clusterAnchorTypeIdx[ca[0]] : getTypeIndex(a.name, series);
        const ib = cb ? clusterAnchorTypeIdx[cb[0]] : getTypeIndex(b.name, series);
        if (ia !== ib) return ia - ib;
        // within same type group: cluster members sort by clusterIdx then posInCluster, before non-cluster
        if (ca && cb) {
          if (ca[0] !== cb[0]) return ca[0] - cb[0];
          return ca[1] - cb[1];
        }
        if (ca && !cb) return -1;
        if (!ca && cb) return 1;
        return a.code.localeCompare(b.code);
      });
      }
    }
    if (showFavsOnly) list = list.filter(i => favs.includes(i.code));
    if (query.trim()) {
      const q = query.trim().toLowerCase();
      list = list.filter(i => {
        if (i.kind === 'group') {
          if ((i.title||'').toLowerCase().includes(q)) return true;
          return (i.members||[]).some(m =>
            m.code.toLowerCase().includes(q) ||
            (m.name||'').toLowerCase().includes(q) ||
            (m.barcode||'').includes(q)
          );
        }
        return (
          i.code.toLowerCase().includes(q) ||
          i.name.toLowerCase().includes(q) ||
          i.barcode.includes(q)
        );
      });
    }
    return list;
  }, [data, series, query, sortMode, showFavsOnly, favs]);

  const toggleFav = useCallback((code) => {
    setFavs(prev => prev.includes(code) ? prev.filter(c => c !== code) : [...prev, code]);
  }, [setFavs]);

  const onCopy = (field, val) => {
    navigator.clipboard?.writeText(val);
    setCopied(field);
    setTimeout(() => setCopied(null), 1400);
  };

  const cols = Math.max(2, Math.min(8, tweaks.columnsDesktop || 6));
  const dense = tweaks.density === 'compact';

  return (
    <div className={"app" + (dense ? " app--dense" : "")} style={{'--cols-desktop': cols}}>
      {/* HEADER */}
      <header className={"hdr" + (scrolled ? " is-stuck" : "") + (collapsed ? " is-collapsed" : "")}>
        <div className="hdr-row">
          <a className="brand" href="index.html" aria-label="TMO" title={t('回首頁')}>
            <img className="brand-logo" src="assets/logo-lm.svg" alt="TMO 水材所" />
          </a>

          <div className="hdr-actions">
            <div className="hdr-search">
              <input
                type="search"
                value={query}
                onChange={(e)=>setQuery(e.target.value)}
                placeholder={t('搜尋全部產品代號、品名或條碼')}
                aria-label={t('搜尋全部產品')}
              />
              <button className="icon-btn hdr-search-btn" onClick={()=>{ const el=document.querySelector('.hdr-search input'); if(el) el.focus(); }} aria-label={t('搜尋')} title={t('搜尋全部產品')}>
                <svg width="22" height="22" viewBox="0 0 22 22" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="9.5" cy="9.5" r="6.5"/><path d="M14.5 14.5 L20.5 20.5"/></svg>
              </button>
            </div>
            <button
              className={"icon-btn" + (showFavsOnly ? " is-active" : "")}
              onClick={()=>setShowFavsOnly(v=>!v)}
              aria-label={t('只看收藏')}
              aria-pressed={showFavsOnly}
              title={t('只看收藏')}
            >
              <svg width="22" height="20" viewBox="0 0 24 22" fill={showFavsOnly?"currentColor":"none"} stroke="currentColor" strokeWidth="2"><path d="M12 20 C5 14.5 1.5 10.8 1.5 6.9 C1.5 3.9 3.9 1.5 6.9 1.5 C8.9 1.5 10.9 2.6 12 4.4 C13.1 2.6 15.1 1.5 17.1 1.5 C20.1 1.5 22.5 3.9 22.5 6.9 C22.5 10.8 19 14.5 12 20 Z"/></svg>
            </button>
            <button className="icon-btn lang-btn" onClick={()=>window.TMOI18N && window.TMOI18N.toggle()} aria-label="Switch language" title="中 / EN">{(window.TMOI18N && window.TMOI18N.btnLabel()) || 'EN'}</button>
            <a className="icon-btn" href="#" aria-label={t('會員')} title={t('會員')}>
              <svg width="22" height="22" viewBox="0 0 22 22" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="11" cy="6.5" r="4.2"/><path d="M2.5 21 C2.5 15.8 6.3 13 11 13 C15.7 13 19.5 15.8 19.5 21"/></svg>
            </a>
            <button className={"tn-burger" + (menuOpen ? " open" : "")} onClick={()=>setMenuOpen(o=>!o)} aria-label={t('選單')}>
              <span className="tnb-box"><span></span><span></span><span></span></span>
            </button>
          </div>
        </div>

        {/* Series Tabs — auto-derived from PRODUCTS keys, supports A→Z */}
        <nav className="tabs" role="tablist" aria-label={t('系列')}>
          {Object.keys(data).sort().map(s => (
            <button
              key={s}
              role="tab"
              aria-selected={series===s}
              className={"tab" + (series===s ? " is-active" : "")}
              onClick={()=>{ setSeries(s); setShowFavsOnly(false); }}
            >
              <span className="tab-letter">{s}.</span>
              <span className="tab-title">{t(data[s].title)}</span>
            </button>
          ))}
        </nav>

        {/* Sub-bar: sort + favs count + result count */}
        <div className="subbar">
          <div className="subbar-left">
            <div className="search">
              <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
                <circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/>
              </svg>
              <input
                type="text"
                value={query}
                onChange={(e)=>setQuery(e.target.value)}
                placeholder={t('搜尋代號、品名或條碼')}
                aria-label={t('搜尋')}
              />
              {query && <button className="search-clear" onClick={()=>setQuery('')} aria-label={t('清除')}>
                <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4"><path d="M6 6l12 12M18 6l-12 12"/></svg>
              </button>}
            </div>
          </div>
          <div className="subbar-right">
            <label className="sort">
              <span>{t('排序')}</span>
              <select value={sortMode} onChange={(e)=>setSortMode(e.target.value)}>
                <option value="code">{t('依編號順序')}</option>
                <option value="type">{t('依同類型產品')}</option>
              </select>
            </label>
          </div>
        </div>
      </header>

      {/* SLIDE-IN MENU (matches homepage) */}
      <div className={"sheet" + (menuOpen ? " open" : "")} onClick={(e)=>{ if(e.target.classList.contains('sheet')) setMenuOpen(false); }}>
        <div className="sheet-panel">
          <div className="sheet-x"><button onClick={()=>setMenuOpen(false)} aria-label={t('關閉選單')}><svg width="22" height="22" viewBox="0 0 22 22"><path d="M4 4 L18 18 M18 4 L4 18" stroke="#28283c" strokeWidth="2" fill="none"></path></svg></button></div>
          <div className="sheet-scroll">
            <div className={"mrow" + (openRow==='p' ? " open" : "")}>
              <button onClick={()=>toggleRow('p')}><span className="lbl">{t('產品')}</span><span className="pm"></span></button>
              <div className="msub">
                {Object.keys(SERIES_TITLES).map(k => (
                  <a key={k} href={"product.html?series="+k} onClick={(e)=>{ e.preventDefault(); setSeries(k); setShowFavsOnly(false); setMenuOpen(false); window.scrollTo({top:0,behavior:'smooth'}); }}>{k}. {t(SERIES_TITLES[k])}</a>
                ))}
              </div>
            </div>
            <div className={"mrow" + (openRow==='i' ? " open" : "")}>
              <button onClick={()=>toggleRow('i')}><span className="lbl">{t('安裝')}</span><span className="pm"></span></button>
              <div className="msub">
                {INSTALL_ITEMS.map((x,i) => (<a key={i} href="index.html#sec-install">{t(x)}</a>))}
              </div>
            </div>
            <div className="mrow"><a className="mrow-link" href="about.html"><span className="lbl">{t('關於 TMO')}</span></a></div>
            <div className="mrow"><a className="mrow-link" href="contact.html"><span className="lbl">{t('聯繫 TMO')}</span></a></div>
            <div className={"mrow" + (openRow==='s' ? " open" : "")}>
              <button onClick={()=>toggleRow('s')}><span className="lbl">{t('TMO 社群')}</span><span className="pm"></span></button>
              <div className="msub">{(MENU.SOCIAL||[]).map((it,i)=>(<a key={i} href={it[1]}>{it[0]}</a>))}</div>
            </div>
            <div className="mrow mrow-lang"><button className="mrow-link lang-row" onClick={()=>window.TMOI18N && window.TMOI18N.toggle()}><span className="lbl">{(window.TMOI18N && window.TMOI18N.lang==='en') ? '中文' : 'English'}</span></button></div>
          </div>
        </div>
      </div>

      {/* MAIN GRID */}
      <main className="grid-wrap">
        {items.length === 0 ? (
          <div className="empty">
            <div className="empty-mark">—</div>
            <div className="empty-text">{showFavsOnly ? t('尚未收藏任何商品') : t('找不到符合條件的商品')}</div>
            {(query || showFavsOnly) && (
              <button className="empty-reset" onClick={()=>{setQuery(''); setShowFavsOnly(false);}}>{t('清除條件')}</button>
            )}
          </div>
        ) : (
          <Masonry
            key={series + sortMode + (showFavsOnly ? '-fav' : '')}
            items={items}
            cols={tweaks.columnsDesktop || 6}
            gap={tweaks.density === 'dense' ? 10 : 14}
            render={(p, ctx) => p.kind === 'group' ? (
              <BannerCard
                key={p.code}
                p={p}
                showBarcode={tweaks.showBarcodeOnCard}
                onOpen={setOpen}
                favs={favs}
                onToggleFav={toggleFav}
                cols={ctx && ctx.numCols}
                gap={ctx && ctx.gap}
                active={!!(ctx && ctx.active)}
              />
            ) : (
              <ProductCard
                key={p.code}
                p={p}
                showBarcode={tweaks.showBarcodeOnCard}
                onOpen={setOpen}
                isFav={favs.includes(p.code)}
                onToggleFav={toggleFav}
                active={!!(ctx && ctx.active)}
              />
            )}
          />
        )}
      </main>
      <footer className="footer" id="footer" style={{background:'#ededee', padding:'6px 0 34px'}}>
          <div className="ft-cols">
            <div className="ft-about"><h4><a href="about.html">{t('關於 TMO')}</a></h4><h4 style={{marginTop:'18px'}}><a href="contact.html">{t('聯繫 TMO')}</a></h4><h4 style={{marginTop:'18px'}}>{t('TMO 社群')}</h4><a href="#">Facebook</a><a href="#">Instagram</a><a href="#">YouTube</a></div>
            <div className="ft-prod"><h4>{t('產品')}</h4><div className="ft-prod-grid"><a href="product.html?series=A">A. {t('混合龍頭')}</a><a href="product.html?series=B">B. {t('自由栓龍頭')}</a><a href="product.html?series=C">C. {t('水塞 / 龍頭配件')}</a><a href="product.html?series=D">D. {t('起泡頭')}</a><a href="product.html?series=E">E. {t('進排水配件')}</a><a href="product.html?series=F">F. {t('管用配件')}</a><a href="product.html?series=G">G. {t('洗衣機配件')}</a><a href="product.html?series=H">H. {t('瓦斯配件')}</a><a href="product.html?series=I">I. {t('淨水濾心')}</a><a href="product.html?series=J">J. {t('淨水器配件')}</a><a href="product.html?series=K">K. {t('衛浴間配件')}</a><a href="product.html?series=L">L. {t('淋浴配件')}</a><a href="product.html?series=M">M. {t('水箱水塔另件')}</a><a href="product.html?series=N">N. {t('面盆 / 水槽配件')}</a><a href="product.html?series=O">O. {t('浴室鏡')}</a><a href="product.html?series=P">P. {t('管束夾 / 園藝配件')}</a></div></div>
            <div className="ft-inst"><h4>{t('安裝')}</h4><div className="ft-inst-grid"><a href="index.html#sec-install">{t('自由栓安裝')}</a><a href="index.html#sec-install">{t('起泡頭安裝')}</a><a href="index.html#sec-install">{t('馬桶水箱安裝')}</a><a href="index.html#sec-install">{t('馬桶蓋安裝')}</a><a href="index.html#sec-install">{t('洗衣機進排水管安裝')}</a><a href="index.html#sec-install">{t('地排安裝')}</a><a href="index.html#sec-install">{t('面盆水槽管用安裝')}</a><a href="index.html#sec-install">{t('淨水器安裝')}</a><a href="index.html#sec-install">{t('蓮蓬頭安裝')}</a><a href="index.html#sec-install">{t('浴室鏡安裝')}</a><a href="index.html#sec-install">{t('浴室架安裝')}</a><a href="index.html#sec-install">{t('園藝管束安裝')}</a></div></div>
            <div className="fb"><img src="images/logo-footer.svg" alt="TMO" /><p>{t('德美樂企業股份有限公司')}<br />{t('30 年衛浴五金專業供應')}<br />{t('賣場規劃 · 零件供應 · 安裝支援')}</p></div>
          </div>
          <div className="ft-acc">
            <div className="ft-row" data-acc=""><button><span className="lbl">{t('產品')}</span><span className="pm"></span></button><div className="ft-sub"><a href="product.html?series=A">A. {t('混合龍頭')}</a><a href="product.html?series=B">B. {t('自由栓龍頭')}</a><a href="product.html?series=C">C. {t('水塞 / 龍頭配件')}</a><a href="product.html?series=D">D. {t('起泡頭')}</a><a href="product.html?series=E">E. {t('進排水配件')}</a><a href="product.html?series=F">F. {t('管用配件')}</a><a href="product.html?series=G">G. {t('洗衣機配件')}</a><a href="product.html?series=H">H. {t('瓦斯配件')}</a><a href="product.html?series=I">I. {t('淨水濾心')}</a><a href="product.html?series=J">J. {t('淨水器配件')}</a><a href="product.html?series=K">K. {t('衛浴間配件')}</a><a href="product.html?series=L">L. {t('淋浴配件')}</a><a href="product.html?series=M">M. {t('水箱水塔另件')}</a><a href="product.html?series=N">N. {t('面盆 / 水槽配件')}</a><a href="product.html?series=O">O. {t('浴室鏡')}</a><a href="product.html?series=P">P. {t('管束夾 / 園藝配件')}</a></div></div>
            <div className="ft-row" data-acc=""><button><span className="lbl">{t('安裝')}</span><span className="pm"></span></button><div className="ft-sub"><a href="index.html#sec-install">{t('自由栓安裝')}</a><a href="index.html#sec-install">{t('起泡頭安裝')}</a><a href="index.html#sec-install">{t('馬桶水箱安裝')}</a><a href="index.html#sec-install">{t('馬桶蓋安裝')}</a><a href="index.html#sec-install">{t('洗衣機進排水管安裝')}</a><a href="index.html#sec-install">{t('地排安裝')}</a><a href="index.html#sec-install">{t('面盆水槽管用安裝')}</a><a href="index.html#sec-install">{t('淨水器安裝')}</a><a href="index.html#sec-install">{t('蓮蓬頭安裝')}</a><a href="index.html#sec-install">{t('浴室鏡安裝')}</a><a href="index.html#sec-install">{t('浴室架安裝')}</a><a href="index.html#sec-install">{t('園藝管束安裝')}</a></div></div>
            <div className="ft-row"><a className="ft-link" href="about.html"><span className="lbl">{t('關於 TMO')}</span></a></div>
            <div className="ft-row"><a className="ft-link" href="contact.html"><span className="lbl">{t('聯繫 TMO')}</span></a></div>
            <div className="ft-row" data-acc=""><button><span className="lbl">{t('TMO 社群')}</span><span className="pm"></span></button><div className="ft-sub">{(MENU.SOCIAL||[]).map((it,i)=>(<a key={i} href={it[1]}>{it[0]}</a>))}</div></div>
          </div>
          <div className="ft-logo"><img src="images/logo-footer.svg" alt="TMO" /></div>
          <div className="ft-copy"><span>{t('本網站圖片與內容為德美樂企業股份有限公司版權所有')}</span><br /><span className="ftc-2">Copyright © TMO. All Rights Reserved.</span></div>
        </footer>

      {/* MOBILE BOTTOM NAV — matches homepage */}
      <nav className={"botbar" + (collapsed && !bbSearchOpen ? " is-hidden" : "")} aria-label={t('底部導覽')}>
        <button className="bb-btn" id="burger-pb" onClick={()=>setMenuOpen(o=>!o)} aria-label={t('選單')}>
          <svg width="26" height="22" viewBox="0 0 26 22"><path d="M1 3 H25 M1 11 H25 M1 19 H25" stroke="currentColor" strokeWidth="2.4" fill="none" strokeLinecap="round"/></svg>
        </button>
        <div className="bb-right">
          <div className="bb-search" data-open={bbSearchOpen ? "1" : "0"}>
            <input
              type="search"
              value={query}
              onChange={(e)=>setQuery(e.target.value)}
              placeholder={t('搜尋全部產品代號、品名或條碼')}
              aria-label={t('搜尋全部產品')}
            />
            <button
              className="bb-btn bb-search-btn"
              onClick={()=>{ if(bbSearchOpen){ if(!query){ setBbSearchOpen(false); } } else { setBbSearchOpen(true); setTimeout(()=>{ const el=document.querySelector('.bb-search input'); if(el) el.focus(); }, 60); } }}
              aria-label={t('搜尋')}
            >
              <svg width="22" height="22" viewBox="0 0 22 22"><circle cx="9.5" cy="9.5" r="6.5" fill="none" stroke="currentColor" strokeWidth="2"/><path d="M14.5 14.5 L20.5 20.5" stroke="currentColor" strokeWidth="2"/></svg>
            </button>
          </div>
          <button
            className="bb-btn"
            onClick={()=>{ setShowFavsOnly(v=>!v); window.scrollTo({top:0, behavior:'smooth'}); }}
            aria-label={t('收藏')}
            aria-pressed={showFavsOnly}
          >
            <svg width="24" height="22" viewBox="0 0 24 22"><path d="M12 20 C5 14.5 1.5 10.8 1.5 6.9 C1.5 3.9 3.9 1.5 6.9 1.5 C8.9 1.5 10.9 2.6 12 4.4 C13.1 2.6 15.1 1.5 17.1 1.5 C20.1 1.5 22.5 3.9 22.5 6.9 C22.5 10.8 19 14.5 12 20 Z" fill={showFavsOnly?"currentColor":"none"} stroke="currentColor" strokeWidth="2"/></svg>
          </button>
          <a className="bb-btn" href="index.html" aria-label={t('會員')}>
            <svg width="22" height="22" viewBox="0 0 22 22"><circle cx="11" cy="6.5" r="4.2" fill="none" stroke="currentColor" strokeWidth="2"/><path d="M2.5 21 C2.5 15.8 6.3 13 11 13 C15.7 13 19.5 15.8 19.5 21" fill="none" stroke="currentColor" strokeWidth="2"/></svg>
          </a>
        </div>
      </nav>

      {/* LIGHTBOX */}
      {open && <Lightbox p={open} onClose={()=>setOpen(null)} onCopy={onCopy} copiedField={copied} favs={favs} onToggleFav={toggleFav} />}

      {/* TWEAKS PANEL */}
      {window.TweaksPanel && (
        <window.TweaksPanel title="Tweaks">
          <window.TweakSection title="顯示">
            <window.TweakSlider
              label="桌面欄數"
              value={tweaks.columnsDesktop}
              min={3} max={8} step={1}
              onChange={(v)=>setTweak('columnsDesktop', v)}
            />
            <window.TweakRadio
              label="密度"
              value={tweaks.density}
              options={[{value:'comfortable', label:'舒適'},{value:'compact', label:'緊湊'}]}
              onChange={(v)=>setTweak('density', v)}
            />
            <window.TweakToggle
              label="顯示卡片條碼"
              value={tweaks.showBarcodeOnCard}
              onChange={(v)=>setTweak('showBarcodeOnCard', v)}
            />
            <window.TweakToggle
              label="深色模式"
              value={tweaks.darkMode}
              onChange={(v)=>setTweak('darkMode', v)}
            />
            <window.TweakToggle
              label="線稿模式（看排版）"
              value={tweaks.wireframe}
              onChange={(v)=>setTweak('wireframe', v)}
            />
          </window.TweakSection>
        </window.TweaksPanel>
      )}
    </div>
  );
};

ReactDOM.createRoot(document.getElementById('root')).render(<App />);
